• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Zielak / cardsGame / 8551819a-a76b-477c-a70d-f6e4453c885e

30 Jun 2024 01:04PM UTC coverage: 73.943% (+1.0%) from 72.988%
8551819a-a76b-477c-a70d-f6e4453c885e

push

circleci

web-flow
fix: Bot compound actions (#109)

* chore(server): path mapping

* fix: bunch of small changes

* test: fixes

* fix: comp action association

* feat: consider ready clients

* feat: first player is ready by default

* fix: issues around failed room start

710 of 1126 branches covered (63.06%)

Branch coverage included in aggregate %.

275 of 379 new or added lines in 57 files covered. (72.56%)

4 existing lines in 3 files now uncovered.

2630 of 3391 relevant lines covered (77.56%)

138.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.52
/packages/server/src/room/base.ts
1
import deepMerge from "@bundled-es-modules/deepmerge"
16✔
2
import { type Client, type ISendOptions, Room as colRoom } from "@colyseus/core"
16✔
3
import type { IBroadcastOptions } from "@colyseus/core/build/Room.js"
4

5
import type { ActionDefinition } from "@/actions/types.js"
6
import type { BotActionsSet } from "@/bots/botNeuron.js"
7
import { BotRunner } from "@/bots/runner.js"
16✔
8
import { fallback } from "@/messages/fallback.js"
16✔
9
import { messages } from "@/messages/messageHandler.js"
16✔
10
import { BotOptions } from "@/player/bot.js"
11
import { Player, ServerPlayerMessage, Bot } from "@/player/index.js"
16✔
12
import { GameClient } from "@/state/client.js"
16✔
13
import { State } from "@/state/state.js"
16✔
14

15
import type { Command } from "../command.js"
16
import { CommandsManager } from "../commandsManager/commandsManager.js"
16✔
17
import type {
18
  IntegrationHookNames,
19
  IntegrationHooks,
20
  IntegrationHookCallbackContext,
21
  IntegrationHookData,
22
} from "../integration.js"
23
import { logs } from "../logs.js"
16✔
24

25
import type { RoomDefinition } from "./roomType.js"
26
import { debugRoomMessage } from "./utils/debugRoomMessage.js"
16✔
27

28
type BroadcastOptions = IBroadcastOptions & {
29
  undo: boolean
30
}
31
type ClientSendOptions = ISendOptions & {
32
  undo: boolean
33
}
34

35
/**
36
 * @ignore
37
 */
38
export abstract class Room<
16✔
39
    S extends State,
40
    MoreMessageTypes extends Record<string, unknown> = Record<string, unknown>,
41
  >
42
  extends colRoom<S>
43
  implements RoomDefinition<S>
44
{
45
  patchRate = 100 // ms = 10FPS
80✔
46

47
  commandsManager: CommandsManager<S>
48

49
  /**
50
   * Reference to your game's `State` class.
51
   */
52
  stateConstructor: new () => S
53

54
  playersCount?: PlayersCount
55

56
  variantsConfig?: VariantsConfig<S["variantData"]>
57

58
  /**
59
   * May be undefined if the game doesn't include any
60
   * bot-related configuration
61
   */
62
  botRunner?: BotRunner<S>
63

64
  /**
65
   * Set of all possible actions players can take in this game
66
   */
67
  possibleActions: ActionDefinition<S>[]
68
  botActivities: BotActionsSet<S>
69

70
  /**
71
   * Direct reference to Bot "players".
72
   */
73
  botClients: Bot[] = []
80✔
74

75
  /**
76
   * All room's available integration tests
77
   */
78
  integrationHooks: Record<string, IntegrationHooks<S>>
79
  /**
80
   * Currently running integration test
81
   * @private
82
   */
83
  currentIntegration: { name: string; data: IntegrationHookData }
84
  /**
85
   * An object passed down to integration hooks.
86
   * Contains limited set of methods on room and
87
   * additional (readonly) data defined in integration itself
88
   * @private
89
   */
90
  integrationContext: IntegrationHookCallbackContext<S>
91

92
  /**
93
   * Count all connected clients, with planned bot players
94
   */
95
  get allClientsCount(): number {
UNCOV
96
    return this.clients.length + this.botClients.length
×
97
  }
98

99
  /**
100
   * Count all clients who declared ready for the game.
101
   */
102
  get readyClientsCount(): number {
103
    return this.state.clients.filter((c) => c.ready)
2✔
104
  }
105

106
  get name(): string {
107
    return this.constructor.name
20✔
108
  }
109

110
  /**
111
   * Run a callback on integration hook, if available
112
   * @ignore
113
   */
114
  _executeIntegrationHook(hookName: IntegrationHookNames): void {
115
    if (this.currentIntegration) {
13✔
116
      this.integrationHooks[this.currentIntegration.name]?.[hookName]?.(
5✔
117
        this.state,
118
        this.integrationContext,
119
      )
120
    }
121
  }
122

123
  onCreate(options?: RoomCreateOptions): void {
124
    logs.info(`Room:${this.name}`, "creating new room")
9✔
125

126
    if (!this.possibleActions) {
9✔
127
      logs.warn(`Room:${this.name}`, "You didn't define any `possibleActions`!")
8✔
128
      this.possibleActions = []
8✔
129
    }
130

131
    this.commandsManager = new CommandsManager<S>(this)
9✔
132
    if (this.botActivities) {
9!
133
      this.botRunner = new BotRunner<S>(this)
×
134
    }
135

136
    // Register all known messages
137
    messages.forEach((callback, type) => {
9✔
138
      this.onMessage(type, (client, message) => {
45✔
NEW
139
        try {
×
NEW
140
          callback.call(this, client, message)
×
141
        } catch (e) {
NEW
142
          const err = e as Error
×
NEW
143
          logs.error(
×
144
            `Room:${this.name}`,
145
            "Failed to execute message handler:",
146
            err.name,
147
            err.message,
148
            "\n",
149
            err.stack,
150
          )
151
        }
152
      })
153
    })
154
    this.onMessage("*", fallback.bind(this))
9✔
155

156
    // Let the game state initialize!
157
    if (this.stateConstructor) {
9!
158
      this.setState(new this.stateConstructor())
9✔
159
    } else {
160
      this.setState(new State() as S)
×
161
    }
162

163
    if (this.variantsConfig) {
9✔
164
      this.state.variantData = deepMerge({}, this.variantsConfig.defaults)
1✔
165
    }
166

167
    this.onInitGame(options)
9✔
168

169
    if (this.integrationHooks && options?.test in this.integrationHooks) {
9✔
170
      logs.info(
3✔
171
        `Room:${this.name}`,
172
        "preparing for integration test:",
173
        options.test,
174
      )
175
      this.currentIntegration = {
3✔
176
        name: options.test,
177
        data: this.integrationHooks[options.test].data ?? {},
3!
178
      }
179
      this.integrationContext = {
3✔
180
        addClient: this.addClient.bind(this),
181
        addBot: this.addBot.bind(this),
182
        data: Object.freeze(this.currentIntegration.data),
183
      }
184
    }
185
    this._executeIntegrationHook("init")
9✔
186
  }
187

188
  /**
189
   * Add client to `state.clients`
190
   * @returns `undefined` is client is already there or if the game is already started
191
   *
192
   * @ignore exposed only for testing, do not use
193
   */
194
  addClient(sessionId: string): GameClient {
195
    const { state } = this
7✔
196

197
    // DONE: I Want spectators
198
    // if (state.isGameStarted) {
199
    //   logs.info("addClient", "state.isGameStarted")
200
    //   return false
201
    // }
202

203
    /**
204
     * this.botClients - only bots
205
     * this.clients - only human players and possible spectators?
206
     * state.clients - all clients considered to become players when game starts, including bots
207
     */
208

209
    // TODO: move that logic to the ready message
210
    // const withinPlayerLimits = playersCount?.max
211
    //   ? this.clients.length < playersCount.max
212
    //   : true
213
    // if (!withinPlayerLimits) {
214
    //   logs.info("addClient failed", "!withinPlayerLimits")
215
    //   return false
216
    // }
217

218
    const clientAlreadyIn = Array.from(state.clients.values()).some(
7✔
219
      (client) => sessionId === client.id,
2✔
220
    )
221
    if (clientAlreadyIn) {
7✔
222
      logs.info("addClient failed", "clientAlreadyIn")
1✔
223
      return
1✔
224
    }
225
    const newClient = new GameClient({ id: sessionId })
6✔
226
    if (state.clients.length === 0) {
6✔
227
      newClient.ready = true
5✔
228
    }
229

230
    state.clients.push(newClient)
6✔
231
    return newClient
6✔
232
  }
233

234
  addBot(bot: BotOptions): boolean {
235
    const { state, playersCount } = this
3✔
236

237
    if (state.isGameStarted) {
3!
238
      logs.info("addBot failed", "state.isGameStarted")
×
239
      return false
×
240
    }
241

242
    /**
243
     * this.botClients - only bots
244
     * this.clients - only human players and possible spectators?
245
     * state.clients - all clients considered to become players when game starts, including bots
246
     */
247

248
    const withinBotLimits = playersCount?.bots?.max
3✔
249
      ? this.botClients.length < playersCount?.bots.max
250
      : true
251

252
    if (!withinBotLimits) {
3✔
253
      logs.info("addBot failed", "!withinBotLimits")
1✔
254
      return false
1✔
255
    }
256

257
    const clientAlreadyIn = Array.from(state.clients.values()).some(
2✔
NEW
258
      (client) => bot.clientID === client.id,
×
259
    )
260

261
    if (clientAlreadyIn) {
2!
262
      logs.info("addBot failed", "clientAlreadyIn")
×
263
      return false
×
264
    }
265

266
    this.botClients.push(new Bot(bot))
2✔
267
    state.clients.push(
2✔
268
      new GameClient({ id: bot.clientID, isBot: true, ready: true }),
269
    )
270
    return true
2✔
271
  }
272

273
  /**
274
   * Remove client from `state.clients`
275
   *
276
   * @ignore exposed only for testing, do not use
277
   */
278
  removeClient(sessionId: string): boolean {
279
    /**
280
     * this.botClients - only bots
281
     * this.clients - only human players and possible spectators?
282
     * state.clients - all clients considered to become players when game starts
283
     */
284

NEW
285
    const clientIdx = this.state.clients.findIndex((c) => c.id === sessionId)
×
286

NEW
287
    if (clientIdx >= 0) {
×
288
      this.state.clients.splice(clientIdx, 1)
×
289
      return true
×
290
    }
291
    return false
×
292
  }
293

294
  /**
295
   * Is called when the client successfully joins the room, after onAuth has succeeded.
296
   *
297
   * So we have guarantee, this new client is still within `maxClients` limit.
298
   */
299
  onJoin(newClient: Client): void {
300
    const added = this.addClient(newClient.sessionId)
×
301
    const statusString = added ? " and" : `, wasn't`
×
302

303
    logs.log(
×
304
      "onJoin",
305
      `client "${newClient.sessionId}" joined${statusString} added to state.clients`,
306
    )
307
  }
308

309
  onLeave(client: Client, consented: boolean): void {
310
    if (consented || !this.state.isGameStarted) {
×
311
      this.removeClient(client.sessionId)
×
312
      logs.log("onLeave", `client "${client.sessionId}" left permanently`)
×
313
    } else {
314
      logs.log(
×
315
        "onLeave",
316
        `client "${client.sessionId}" disconnected, might be back`,
317
      )
318
    }
319
  }
320

321
  clientSend(
322
    clientID: string,
323
    type: string | number,
324
    message?: any,
325
    options?: ClientSendOptions,
326
  ): void
327
  clientSend(
328
    client: Client,
329
    type: string | number,
330
    message?: any,
331
    options?: ClientSendOptions,
332
  ): void
333
  /**
334
   * For convenience. Wraps message with additional data (like undo)
335
   */
336
  clientSend(
337
    clientIdOrRef: string | Client,
338
    type: string | number,
339
    message?: any,
340
    options?: ClientSendOptions,
341
  ): void {
342
    const wrappedMessage: ServerMessage = {
×
343
      data: message,
344
    }
345
    const cleanOptions: ClientSendOptions = {
×
346
      ...options,
347
    }
348
    if (options?.undo) {
×
349
      wrappedMessage.undo = true
×
350
      delete cleanOptions.undo
×
351
    }
352

353
    const client =
354
      typeof clientIdOrRef === "string"
×
355
        ? this.clients.find((client) => client.sessionId === clientIdOrRef)
×
356
        : clientIdOrRef
357

358
    if (!client) {
×
359
      logs.warn(
×
360
        "clientSend",
361
        `trying to send message to non-existing client "${clientIdOrRef}"`,
362
      )
363
    }
364

365
    client.send(
×
366
      type,
367
      wrappedMessage,
368
      Object.keys(cleanOptions).length > 0 ? cleanOptions : undefined,
×
369
    )
370
  }
371

372
  /**
373
   * For convenience. Wraps message with additional data (like undo)
374
   */
375
  broadcast<
376
    T extends keyof AllServerMessageTypes<MoreMessageTypes>,
377
    // M extends MoreMessageTypes & ServerMessageTypes
378
  >(
379
    type: T,
380
    message?: AllServerMessageTypes<MoreMessageTypes>[T],
381
    options?: BroadcastOptions,
382
  ): void {
383
    const wrappedMessage: ServerMessage = {
3✔
384
      data: message,
385
    }
386
    const cleanOptions: BroadcastOptions = {
3✔
387
      ...options,
388
    }
389
    if (options?.undo) {
3✔
390
      wrappedMessage.undo = true
2✔
391
      delete cleanOptions.undo
2✔
392
    }
393

394
    if (Object.keys(cleanOptions).length > 0) {
3✔
395
      super.broadcast(type as string | number, wrappedMessage, cleanOptions)
1✔
396
    } else {
397
      super.broadcast(type as string | number, wrappedMessage)
2✔
398
    }
399
  }
400

401
  /**
402
   * Handles new incoming event from client (human or bot).
403
   * @returns ~~DEPRECATE - is anyone listening to this return value?...~~ server testing might benefit
404
   *     `true` if action was executed, `false` if not, or if it failed.
405
   */
406
  async handleMessage(message: ServerPlayerMessage): Promise<boolean> {
407
    let result = false
4✔
408

409
    debugRoomMessage(message)
4✔
410

411
    if (!message.player) {
4✔
412
      throw new Error("client is not a player")
1✔
413
    }
414

415
    if (this.state.isGameOver) {
3✔
416
      throw new Error("game's already over")
1✔
417
    }
418

419
    result = await this.commandsManager.handlePlayerEvent(message)
2✔
420
    // try {
421
    //   result = await this.commandsManager.handlePlayerEvent(message)
422
    // } catch (e) {
423
    //   logs.error("handleMessage FAILED", e.message)
424
    //   logs.error((e as Error).stack)
425
    //   return false
426
    // }
427

428
    if (result) {
1✔
429
      this.botRunner?.onAnyMessage()
1✔
430
    }
431

432
    return result
1✔
433
  }
434

435
  /**
436
   * Override it to state your own conditions of whether the game can be started or not.
437
   * @returns {boolean}
438
   */
439
  canGameStart(): boolean {
440
    return true
2✔
441
  }
442

443
  /**
444
   * Will be called right after the game room is created.
445
   * Create your game state here:
446
   *
447
   * ~~```this.setState(new MyState())```~~ DON'T
448
   *
449
   * Prepare your play area now.
450
   *
451
   * @param state
452
   */
453
  onInitGame(options?: RoomCreateOptions): void {
454
    logs.info("Room", `onInitGame is not implemented!`)
7✔
455
  }
456

457
  /**
458
   * Will be called when clients agree to start the game.
459
   * `state.players` is already populated with all players.
460
   * Game options (variant data) is already set.
461
   * After this function, the game will give turn to the first player.
462
   * @param state
463
   */
464
  onStartGame(): void | Command[] {
465
    logs.info("Room", `onStartGame is not implemented!`)
2✔
466
  }
467

468
  /**
469
   * Invoked when players turn starts
470
   */
471
  onPlayerTurnStarted(player: Player): void | Command[] {
472
    if (!this.state.turnBased) {
2!
473
      logs.info("Room", `onPlayerTurnStarted is not implemented!`)
×
474
    }
475
  }
476

477
  /**
478
   * Invoked when players turn ends
479
   */
480
  onPlayerTurnEnded(player: Player): void | Command[] {
481
    if (!this.state.turnBased) {
×
482
      logs.info("Room", `onPlayerTurnEnded is not implemented!`)
×
483
    }
484
  }
485

486
  /**
487
   * Invoked when each round starts.
488
   */
489
  onRoundStart(): void | Command[] {
490
    logs.info(
2✔
491
      "Room",
492
      `"nextRound" action was called, but "room.onRoundStart()" is not implemented!`,
493
    )
494
  }
495

496
  /**
497
   * Invoked when a round is near completion.
498
   */
499
  onRoundEnd(): void | Command[] {
500
    logs.info(
×
501
      "Room",
502
      `"nextRound" action was called, but "room.onRoundEnd()" is not implemented!`,
503
    )
504
  }
505

506
  onDispose(): void {}
507
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc