/
command_table_create.go
388 lines (335 loc) · 11.5 KB
/
command_table_create.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
package main
import (
"context"
"regexp"
"strconv"
"strings"
"time"
"github.com/alexedwards/argon2id"
)
const (
// The maximum number of characters that a game name can be
MaxGameNameLength = 45
)
var (
isValidTableName = regexp.MustCompile(`^[a-zA-Z0-9 !@#$\(\)\-_=\+;:,\.\?]+$`).MatchString
)
// Data relating to games created with a special custom prefix (e.g. "!seed")
type SpecialGameData struct {
DatabaseID int
CustomNumPlayers int
CustomActions []*GameAction
SetSeedSuffix string
SetReplay bool
SetReplayTurn int
}
// commandTableCreate is sent when the user submits the "Create a New Game" form
//
// Example data:
// {
// name: 'my new table',
// options: {
// variant: 'No Variant',
// [other options omitted; see "Options.ts"]
// },
// password: 'super_secret',
// }
func commandTableCreate(ctx context.Context, s *Session, d *CommandData) {
// Validate that the server is not about to go offline
if checkImminentShutdown(s) {
return
}
// Validate that the server is not undergoing maintenance
if maintenanceMode.IsSet() {
s.Warning("The server is undergoing maintenance. " +
"You cannot start any new games for the time being.")
return
}
// Truncate long table names
// (we do this first to prevent wasting CPU cycles on validating extremely long table names)
if len(d.Name) > MaxGameNameLength {
d.Name = d.Name[0 : MaxGameNameLength-1]
}
// Remove any non-printable characters, if any
d.Name = removeNonPrintableCharacters(d.Name)
// Trim whitespace from both sides
d.Name = strings.TrimSpace(d.Name)
// Make a default game name if they did not provide one
if len(d.Name) == 0 {
d.Name = getName()
}
// Check for non-ASCII characters
if !containsAllPrintableASCII(d.Name) {
s.Warning("Game names can only contain ASCII characters.")
return
}
// Validate that the game name does not contain any special characters
// (this mitigates XSS attacks)
if !isValidTableName(d.Name) {
msg := "Game names can only contain English letters, numbers, spaces, " +
"<code>!</code>, " +
"<code>@</code>, " +
"<code>#</code>, " +
"<code>$</code>, " +
"<code>(</code>, " +
"<code>)</code>, " +
"<code>-</code>, " +
"<code>_</code>, " +
"<code>=</code>, " +
"<code>+</code>, " +
"<code>;</code>, " +
"<code>:</code>, " +
"<code>,</code>, " +
"<code>.</code>, " +
"and <code>?</code>."
s.Warning(msg)
return
}
// Set default values for data relating to tables created with a special prefix or custom data
data := &SpecialGameData{
DatabaseID: -1, // Normally, the database ID of an ongoing game should be -1
CustomNumPlayers: 0,
CustomActions: nil,
SetSeedSuffix: "",
SetReplay: false,
SetReplayTurn: 0,
}
// Handle special game option creation
if strings.HasPrefix(d.Name, "!") {
if d.GameJSON != nil {
s.Warning("You cannot create a table with a special prefix if JSON data is also provided.")
return
}
args := strings.Split(d.Name, " ")
command := args[0]
args = args[1:] // This will be an empty slice if there is nothing after the command
command = strings.TrimPrefix(command, "!")
command = strings.ToLower(command) // Commands are case-insensitive
if command == "seed" {
// !seed - Play a specific seed
if len(args) != 1 {
s.Warning("Games on specific seeds must be created in the form: " +
"!seed [seed number]")
return
}
// For normal games, the server creates seed suffixes sequentially from 0, 1, 2,
// and so on
// However, the seed does not actually have to be a number,
// so allow the user to use any arbitrary string as a seed suffix
data.SetSeedSuffix = args[0]
} else if command == "replay" {
// !replay - Replay a specific game up to a specific turn
if len(args) != 1 && len(args) != 2 {
s.Warning("Replays of specific games must be created in the form: " +
"!replay [game ID] [turn number]")
return
}
if v, err := strconv.Atoi(args[0]); err != nil {
s.Warning("The game ID of \"" + args[0] + "\" is not a number.")
return
} else {
data.DatabaseID = v
}
// Check to see if the game ID exists on the server
if exists, err := models.Games.Exists(data.DatabaseID); err != nil {
logger.Error("Failed to check to see if game " + strconv.Itoa(data.DatabaseID) +
" exists: " + err.Error())
s.Error(CreateGameFail)
return
} else if !exists {
s.Warning("That game ID does not exist in the database.")
return
}
if len(args) == 1 {
data.SetReplayTurn = 1
} else {
if v, err := strconv.Atoi(args[1]); err != nil {
s.Warning("The turn of \"" + args[1] + "\" is not a number.")
return
} else {
data.SetReplayTurn = v
}
if data.SetReplayTurn < 1 {
s.Warning("The replay turn must be greater than 0.")
return
}
}
// We have to minus the turn by one since turns are stored on the server starting at 0
// and turns are shown to the user starting at 1
data.SetReplayTurn--
// Check to see if this turn is valid
// (it has to be a turn before the game ends)
var numTurns int
if v, err := models.Games.GetNumTurns(data.DatabaseID); err != nil {
logger.Error("Failed to get the number of turns from the database for game " +
strconv.Itoa(data.DatabaseID) + ": " + err.Error())
s.Error(InitGameFail)
return
} else {
numTurns = v
}
if data.SetReplayTurn >= numTurns {
s.Warning("Game #" + strconv.Itoa(data.DatabaseID) + " only has " +
strconv.Itoa(numTurns) + " turns.")
return
}
data.SetReplay = true
} else {
msg := "You cannot start a game with an exclamation mark unless you are trying to use a specific game creation command."
s.Warning(msg)
return
}
}
// Validate that they sent the options object
if d.Options == nil {
d.Options = NewOptions()
}
// Validate that the variant name is valid
if _, ok := variants[d.Options.VariantName]; !ok {
s.Warning("\"" + d.Options.VariantName + "\" is not a valid variant.")
return
}
// Validate that the time controls are sane
if d.Options.Timed {
if d.Options.TimeBase <= 0 {
s.Warning("\"" + strconv.Itoa(d.Options.TimeBase) + "\" is too small of a value for \"Base Time\".")
return
}
if d.Options.TimeBase > 604800 { // 1 week in seconds
s.Warning("\"" + strconv.Itoa(d.Options.TimeBase) + "\" is too large of a value for \"Base Time\".")
return
}
if d.Options.TimePerTurn <= 0 {
s.Warning("\"" + strconv.Itoa(d.Options.TimePerTurn) + "\" is too small of a value for \"Time per Turn\".")
return
}
if d.Options.TimePerTurn > 86400 { // 1 day in seconds
s.Warning("\"" + strconv.Itoa(d.Options.TimePerTurn) + "\" is too large of a value for \"Time per Turn\".")
return
}
}
// Validate that there can be no time controls if this is not a timed game
if !d.Options.Timed {
d.Options.TimeBase = 0
d.Options.TimePerTurn = 0
}
// Validate that a speedrun cannot be timed
if d.Options.Speedrun {
d.Options.Timed = false
d.Options.TimeBase = 0
d.Options.TimePerTurn = 0
}
// Validate that they did not send both the "One Extra Card" and the "One Less Card" option at
// the same time (they effectively cancel each other out)
if d.Options.OneExtraCard && d.Options.OneLessCard {
d.Options.OneExtraCard = false
d.Options.OneLessCard = false
}
// Validate games with custom JSON
if d.GameJSON != nil {
if !validateJSON(s, d) {
return
}
}
tableCreate(ctx, s, d, data)
}
func tableCreate(ctx context.Context, s *Session, d *CommandData, data *SpecialGameData) {
// Since this is a function that changes a user's relationship to tables,
// we must acquires the tables lock to prevent race conditions
if !d.NoTablesLock {
tables.Lock(ctx)
defer tables.Unlock(ctx)
}
// Validate that the player is not joined to another table
// (this cannot be in the "commandTableCreate()" function because we need the tables lock)
if !strings.HasPrefix(s.Username, "Bot-") {
if len(tables.GetTablesUserPlaying(s.UserID)) > 0 {
s.Warning("You cannot join more than one table at a time. " +
"Terminate your other game before creating a new one.")
return
}
}
passwordHash := ""
if d.Password != "" {
// Create an Argon2id hash of the plain-text password
if v, err := argon2id.CreateHash(d.Password, argon2id.DefaultParams); err != nil {
logger.Error("Failed to create a hash from the submitted table password: " +
err.Error())
s.Error(CreateGameFail)
return
} else {
passwordHash = v
}
}
t := NewTable(d.Name, s.UserID)
t.Lock(ctx)
defer t.Unlock(ctx)
t.Visible = !d.HidePregame
t.PasswordHash = passwordHash
t.Options = d.Options
t.ExtraOptions = &ExtraOptions{
DatabaseID: data.DatabaseID,
NoWriteToDatabase: false,
JSONReplay: false,
CustomNumPlayers: data.CustomNumPlayers,
CustomCharacterAssignments: nil,
CustomSeed: "",
CustomDeck: nil,
CustomActions: nil,
Restarted: false,
SetSeedSuffix: data.SetSeedSuffix,
SetReplay: false,
SetReplayTurn: 0,
}
// If this is a "!replay" game, override the options with the ones found in the database
if data.SetReplay {
if _, success := loadDatabaseOptionsToTable(s, data.DatabaseID, t); !success {
return
}
// "loadJSONOptionsToTable()" sets the database ID to a positive number
// The database ID for an ongoing game should be set to -1
t.ExtraOptions.DatabaseID = -1
// "loadDatabaseOptionsToTable()" marks that the game should not be written to the database,
// which is not true in this special case
t.ExtraOptions.NoWriteToDatabase = false
// "loadDatabaseOptionsToTable()" does not specify the "!replay" options
t.ExtraOptions.SetReplay = data.SetReplay
t.ExtraOptions.SetReplayTurn = data.SetReplayTurn
}
// If the user specified JSON data,
// override the options with the ones specified in the JSON data
if d.GameJSON != nil {
loadJSONOptionsToTable(d, t)
// "loadJSONOptionsToTable()" sets the database ID to 0, which corresponds to a JSON replay
// The database ID for an ongoing game should be set to -1
t.ExtraOptions.DatabaseID = -1
// "loadJSONOptionsToTable()" marks that the game should not be written to the database,
// which is not true in this special case
t.ExtraOptions.NoWriteToDatabase = false
// "loadJSONOptionsToTable()" marks that the game is a JSON replay,
// which is not true in this special case
t.ExtraOptions.JSONReplay = false
}
// Add the table to a map so that we can keep track of all of the active tables
tables.Set(t.ID, t)
logger.Info(t.GetName() + "User \"" + s.Username + "\" created a table.")
// (a "table" message will be sent in the "commandTableJoin" function below)
// Log a chat message so that future players can see a timestamp of when the table was created
msg := s.Username + " created the table."
chatServerSend(ctx, msg, t.GetRoomName(), true)
// If the server is shutting down / restarting soon, warn the players
if shuttingDown.IsSet() {
timeLeft := ShutdownTimeout - time.Since(datetimeShutdownInit)
minutesLeft := int(timeLeft.Minutes())
msg := "The server is shutting down in " + strconv.Itoa(minutesLeft) + " minutes. " +
"Keep in mind that if your game is not finished in time, it will be terminated."
chatServerSend(ctx, msg, t.GetRoomName(), true)
}
// Join the user to the new table
commandTableJoin(ctx, s, &CommandData{ // nolint: exhaustivestruct
TableID: t.ID,
Password: d.Password,
NoTableLock: true,
NoTablesLock: true,
})
}