@bazimazi/partyframe-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,898 @@
1
+ /**
2
+ * The one room type the platform needs.
3
+ *
4
+ * A `PartySessionRoom` owns a `GameSession`: its public code, its lobby, its
5
+ * players (human and bot), its lifecycle and its authoritative clock. The game
6
+ * being played is a plugin resolved at creation time, so this file contains no
7
+ * Bomb Party logic whatsoever - swapping in a different game changes only which
8
+ * adapter is looked up.
9
+ *
10
+ * Authority model, in one sentence: clients send *intentions*, this room decides
11
+ * what actually happened, and the resulting state is what everyone renders.
12
+ */
13
+ import { Room, ServerError, matchMaker } from "@colyseus/core";
14
+ import { Rng, randomSeed, validateSync, } from "@partyframe/game-core";
15
+ import { ABSOLUTE_MAX_PLAYERS, AVATARS, CLOCK_BEACON_MS, ClockPingSchema, HOST_RECONNECT_SECONDS, JoinOptionsSchema, MAX_MESSAGE_BYTES, MSG, PARTY_ROOM, PLAYER_COLORS, PLAYER_RECONNECT_SECONDS, SERVER_TICK_MS, SessionActionSchema, } from "@partyframe/protocol";
16
+ import { requireAdapter } from "./adapters.js";
17
+ import { EVENT, runtimeHost } from "./bind.js";
18
+ import { makeBotIdentity } from "./bots.js";
19
+ import { generateUniqueRoomCode } from "./roomCode.js";
20
+ import { CLOCK_PING_LIMITS, GAME_ACTION_LIMITS, RateLimiter, SESSION_ACTION_LIMITS, } from "./rateLimit.js";
21
+ import { PlayerSchema, SessionSchema } from "./sessionSchema.js";
22
+ /** Colyseus close codes must be >= 4000; these map onto `PartyErrorCode`. */
23
+ const JOIN_ERROR_CODE = 4400;
24
+ function rejectJoin(code) {
25
+ // The message carries the machine-readable code; the client localises it and
26
+ // never shows this string to a player.
27
+ throw new ServerError(JOIN_ERROR_CODE, code);
28
+ }
29
+ /** Status values in which the game plugin should be ticking. */
30
+ const RUNNING_STATUSES = new Set(["STARTING", "PLAYING", "ROUND_END"]);
31
+ export class PartySessionRoom extends Room {
32
+ constructor() {
33
+ super(...arguments);
34
+ this.maxClients = ABSOLUTE_MAX_PLAYERS + 2;
35
+ /** Presentation cues queued by the game during the current tick. */
36
+ this.eventQueue = [];
37
+ /** Status the game asked for during the current tick, applied once at the end. */
38
+ this.requestedStatus = null;
39
+ /** Bot strategies, one per difficulty, shared by every bot at that level. */
40
+ this.botStrategies = new Map();
41
+ /** A bot's chosen action and the time it should be submitted. */
42
+ this.botPending = new Map();
43
+ this.gameLimiter = new RateLimiter(GAME_ACTION_LIMITS);
44
+ this.sessionLimiter = new RateLimiter(SESSION_ACTION_LIMITS);
45
+ this.clockLimiter = new RateLimiter(CLOCK_PING_LIMITS);
46
+ this.createdAt = 0;
47
+ this.lastConnectedAt = 0;
48
+ this.lastBeaconAt = 0;
49
+ this.nextSeat = 0;
50
+ this.controllerRevision = 0;
51
+ /** Last envelope sent to each controller, so unchanged state is not resent. */
52
+ this.lastControllerJson = new Map();
53
+ }
54
+ // ---------------------------------------------------------------- lifecycle
55
+ async onCreate(options) {
56
+ const host = runtimeHost();
57
+ const gameId = options.gameId ?? host.defaultGameId;
58
+ this.adapter = requireAdapter(gameId);
59
+ // Generated here rather than by the caller so a client can never choose,
60
+ // guess or reuse a code. Colyseus awaits `onCreate` before admitting anyone,
61
+ // so the code is in place before the first join.
62
+ const publicCode = await generateUniqueRoomCode(async (candidate) => {
63
+ const rooms = await matchMaker.query({ name: PARTY_ROOM });
64
+ return rooms.some((room) => room.metadata?.publicCode === candidate);
65
+ });
66
+ this.logger = host.log.child({
67
+ sessionId: this.roomId,
68
+ roomCode: publicCode,
69
+ gameId: this.adapter.game.id,
70
+ });
71
+ this.rng = new Rng(options.seed ?? randomSeed());
72
+ this.gameOptions = this.adapter.game.parseOptions({});
73
+ this.gameState = this.adapter.game.createState(this.gameOptions);
74
+ const state = this.adapter.createState();
75
+ state.publicCode = publicCode;
76
+ state.gameId = this.adapter.game.id;
77
+ state.status = "LOBBY";
78
+ state.serverTime = Date.now();
79
+ state.settings.maxPlayers = Math.min(host.maxPlayers, this.adapter.game.maxPlayers);
80
+ state.settings.botCount = 0;
81
+ state.settings.botDifficulty = "medium";
82
+ this.setState(state);
83
+ this.registry = this.createRegistry();
84
+ // The session outlives an empty room on purpose: a TV that drops off Wi-Fi
85
+ // must be able to come back to the same game. `checkExpiry` reclaims it.
86
+ this.autoDispose = false;
87
+ this.createdAt = Date.now();
88
+ this.lastConnectedAt = this.createdAt;
89
+ this.registerMessageHandlers();
90
+ this.setSimulationInterval((deltaMs) => this.tick(deltaMs), SERVER_TICK_MS);
91
+ void this.publishMetadata();
92
+ this.logger.info(EVENT.SESSION_CREATED, { maxPlayers: state.settings.maxPlayers });
93
+ }
94
+ onAuth(_client, rawOptions) {
95
+ const parsed = JoinOptionsSchema.safeParse(rawOptions ?? {});
96
+ if (!parsed.success)
97
+ rejectJoin("INVALID_PAYLOAD");
98
+ const { role } = parsed.data;
99
+ if (this.state.status === "CLOSED")
100
+ rejectJoin("ROOM_CLOSED");
101
+ if (role === "host") {
102
+ // A second shared screen is refused rather than silently taking over, so a
103
+ // stray tab cannot hijack the TV mid-game.
104
+ const hostOnline = this.clients.some((client) => client.userData?.role === "host");
105
+ if (hostOnline)
106
+ rejectJoin("NOT_ALLOWED");
107
+ }
108
+ else {
109
+ const seated = [...this.state.players.values()].filter((p) => !p.isBot).length;
110
+ if (seated >= this.state.settings.maxPlayers)
111
+ rejectJoin("ROOM_FULL");
112
+ }
113
+ return { role, joinedAt: Date.now() };
114
+ }
115
+ onJoin(client, _options, auth) {
116
+ client.userData = auth;
117
+ this.lastConnectedAt = Date.now();
118
+ if (auth.role === "host") {
119
+ this.state.hostConnected = true;
120
+ this.logger.info(EVENT.HOST_ATTACHED, { playerId: client.sessionId });
121
+ }
122
+ else {
123
+ this.ensurePlayerRow(client.sessionId);
124
+ }
125
+ this.sendWelcome(client);
126
+ void this.publishMetadata();
127
+ this.pushControllerState(client, true);
128
+ }
129
+ async onLeave(client, consented) {
130
+ const data = client.userData;
131
+ const role = data?.role ?? "controller";
132
+ if (role === "host") {
133
+ this.state.hostConnected = false;
134
+ this.logger.warn(EVENT.HOST_DISCONNECTED, { consented });
135
+ if (consented)
136
+ return;
137
+ try {
138
+ // A TV losing Wi-Fi must not end everyone's game.
139
+ await this.allowReconnection(client, HOST_RECONNECT_SECONDS);
140
+ this.state.hostConnected = true;
141
+ this.sendWelcome(client);
142
+ this.logger.info(EVENT.HOST_RECONNECTED);
143
+ }
144
+ catch {
145
+ this.logger.info(EVENT.HOST_DISCONNECTED, { recovered: false });
146
+ }
147
+ return;
148
+ }
149
+ const player = this.state.players.get(client.sessionId);
150
+ if (!player)
151
+ return;
152
+ if (consented) {
153
+ this.removePlayer(client.sessionId, "left");
154
+ return;
155
+ }
156
+ player.connected = false;
157
+ this.notifyGameOfPlayer(client.sessionId, "disconnected");
158
+ this.emitPlatformEvent({
159
+ kind: "player-disconnected",
160
+ messageKey: "event.playerDisconnected",
161
+ params: { name: player.name },
162
+ playerId: player.id,
163
+ });
164
+ this.logger.info(EVENT.PLAYER_DISCONNECTED, { playerId: client.sessionId });
165
+ try {
166
+ const reconnected = await this.allowReconnection(client, PLAYER_RECONNECT_SECONDS);
167
+ const row = this.state.players.get(client.sessionId);
168
+ if (row)
169
+ row.connected = true;
170
+ reconnected.userData = data ?? { role: "controller", joinedAt: Date.now() };
171
+ this.lastConnectedAt = Date.now();
172
+ this.notifyGameOfPlayer(client.sessionId, "reconnected");
173
+ this.emitPlatformEvent({
174
+ kind: "player-reconnected",
175
+ messageKey: "event.playerReconnected",
176
+ params: { name: row?.name ?? "" },
177
+ playerId: client.sessionId,
178
+ });
179
+ // onJoin does not run again after a reconnection, so identity and the
180
+ // controller projection have to be re-sent explicitly.
181
+ this.sendWelcome(reconnected);
182
+ this.pushControllerState(reconnected, true);
183
+ this.logger.info(EVENT.PLAYER_RECONNECTED, { playerId: client.sessionId });
184
+ }
185
+ catch {
186
+ this.removePlayer(client.sessionId, "left");
187
+ }
188
+ }
189
+ onDispose() {
190
+ this.gameLimiter.clear();
191
+ this.sessionLimiter.clear();
192
+ this.clockLimiter.clear();
193
+ this.lastControllerJson.clear();
194
+ this.logger.info(EVENT.SESSION_DISPOSED);
195
+ }
196
+ // ------------------------------------------------------------------ players
197
+ /** Creates or revives the row for a controller's seat. */
198
+ ensurePlayerRow(playerId) {
199
+ const existing = this.state.players.get(playerId);
200
+ if (existing) {
201
+ existing.connected = true;
202
+ return existing;
203
+ }
204
+ const player = new PlayerSchema();
205
+ player.id = playerId;
206
+ player.seat = this.nextSeat++;
207
+ player.color = this.pickFreeColor();
208
+ player.avatar = this.pickFreeAvatar();
209
+ player.connected = true;
210
+ player.joined = false;
211
+ this.state.players.set(playerId, player);
212
+ return player;
213
+ }
214
+ pickFreeColor() {
215
+ const taken = new Set([...this.state.players.values()].map((p) => p.color));
216
+ return PLAYER_COLORS.find((c) => !taken.has(c)) ?? PLAYER_COLORS[0];
217
+ }
218
+ pickFreeAvatar() {
219
+ const taken = new Set([...this.state.players.values()].map((p) => p.avatar));
220
+ return AVATARS.find((a) => !taken.has(a)) ?? AVATARS[0];
221
+ }
222
+ removePlayer(playerId, reason) {
223
+ const player = this.state.players.get(playerId);
224
+ if (!player)
225
+ return;
226
+ this.state.players.delete(playerId);
227
+ this.botPending.delete(playerId);
228
+ this.gameLimiter.forget(playerId);
229
+ this.sessionLimiter.forget(playerId);
230
+ this.clockLimiter.forget(playerId);
231
+ this.lastControllerJson.delete(playerId);
232
+ if (this.state.hostPlayerId === playerId) {
233
+ this.state.hostPlayerId = this.electHostPlayer();
234
+ }
235
+ this.notifyGameOfPlayer(playerId, "left");
236
+ this.emitPlatformEvent({
237
+ kind: "player-left",
238
+ messageKey: "event.playerLeft",
239
+ params: { name: player.name },
240
+ playerId,
241
+ });
242
+ this.logger.info(EVENT.PLAYER_LEFT, { playerId, reason });
243
+ void this.publishMetadata();
244
+ }
245
+ /** The longest-seated joined human becomes host when the previous one leaves. */
246
+ electHostPlayer() {
247
+ const candidate = [...this.state.players.values()]
248
+ .filter((p) => !p.isBot && p.joined)
249
+ .sort((a, b) => a.seat - b.seat)[0];
250
+ for (const player of this.state.players.values()) {
251
+ player.isHost = candidate ? player.id === candidate.id : false;
252
+ }
253
+ return candidate?.id ?? "";
254
+ }
255
+ /** Players the game rules operate on: everyone who has completed joining. */
256
+ gamePlayers() {
257
+ return [...this.state.players.values()]
258
+ .filter((p) => p.joined)
259
+ .sort((a, b) => a.seat - b.seat);
260
+ }
261
+ createRegistry() {
262
+ const toGamePlayer = (p) => ({
263
+ id: p.id,
264
+ name: p.name,
265
+ isBot: p.isBot,
266
+ connected: p.connected,
267
+ score: p.score,
268
+ seat: p.seat,
269
+ });
270
+ return {
271
+ all: () => this.gamePlayers().map(toGamePlayer),
272
+ get: (playerId) => {
273
+ const row = this.state.players.get(playerId);
274
+ return row && row.joined ? toGamePlayer(row) : undefined;
275
+ },
276
+ has: (playerId) => Boolean(this.state.players.get(playerId)?.joined),
277
+ addScore: (playerId, delta) => {
278
+ const row = this.state.players.get(playerId);
279
+ if (row)
280
+ row.score = Math.max(0, row.score + delta);
281
+ },
282
+ setScore: (playerId, score) => {
283
+ const row = this.state.players.get(playerId);
284
+ if (row)
285
+ row.score = Math.max(0, score);
286
+ },
287
+ };
288
+ }
289
+ // --------------------------------------------------------------------- bots
290
+ /** Adds or removes bots so the roster matches `settings.botCount`. */
291
+ reconcileBots() {
292
+ const bots = [...this.state.players.values()].filter((p) => p.isBot);
293
+ const humans = [...this.state.players.values()].filter((p) => !p.isBot && p.joined);
294
+ const capacity = Math.max(0, this.state.settings.maxPlayers - humans.length);
295
+ const target = Math.min(this.state.settings.botCount, capacity);
296
+ for (let i = bots.length; i > target; i -= 1) {
297
+ const victim = bots[i - 1];
298
+ if (!victim)
299
+ break;
300
+ this.state.players.delete(victim.id);
301
+ this.botPending.delete(victim.id);
302
+ this.logger.info(EVENT.BOT_REMOVED, { playerId: victim.id });
303
+ }
304
+ for (let i = bots.length; i < target; i += 1) {
305
+ const takenNames = new Set([...this.state.players.values()].map((p) => p.name.toLowerCase()));
306
+ const takenColors = new Set([...this.state.players.values()].map((p) => p.color));
307
+ const identity = makeBotIdentity(i, takenNames, takenColors);
308
+ const bot = new PlayerSchema();
309
+ bot.id = `bot-${this.roomId}-${i}-${this.nextSeat}`;
310
+ bot.seat = this.nextSeat++;
311
+ bot.name = identity.name;
312
+ bot.avatar = identity.avatar;
313
+ bot.color = identity.color;
314
+ bot.isBot = true;
315
+ bot.connected = true;
316
+ bot.joined = true;
317
+ bot.ready = true;
318
+ this.state.players.set(bot.id, bot);
319
+ this.logger.info(EVENT.BOT_ADDED, { playerId: bot.id });
320
+ }
321
+ // Settings may have asked for more bots than there was room for.
322
+ this.state.settings.botCount = Math.min(this.state.settings.botCount, capacity);
323
+ void this.publishMetadata();
324
+ }
325
+ botStrategy() {
326
+ const difficulty = this.state.settings.botDifficulty;
327
+ let strategy = this.botStrategies.get(difficulty);
328
+ if (!strategy) {
329
+ strategy = this.adapter.game.createBot(difficulty === "easy" || difficulty === "hard" ? difficulty : "medium");
330
+ this.botStrategies.set(difficulty, strategy);
331
+ }
332
+ return strategy;
333
+ }
334
+ /**
335
+ * Drives every bot through the human action path.
336
+ *
337
+ * A bot never mutates game state directly: it produces the same payload a
338
+ * phone would send, and that payload goes through the same validation and the
339
+ * same `handleAction` call.
340
+ */
341
+ tickBots(ctx, now) {
342
+ const strategy = this.botStrategy();
343
+ for (const bot of this.gamePlayers()) {
344
+ if (!bot.isBot)
345
+ continue;
346
+ const pending = this.botPending.get(bot.id);
347
+ if (pending) {
348
+ if (now < pending.dueAt)
349
+ continue;
350
+ this.botPending.delete(bot.id);
351
+ this.applyGameAction(bot.id, pending.action, now);
352
+ continue;
353
+ }
354
+ const decision = strategy.decide(ctx, bot.id);
355
+ if (decision) {
356
+ this.botPending.set(bot.id, {
357
+ action: decision.action,
358
+ dueAt: now + Math.max(0, decision.delayMs),
359
+ });
360
+ }
361
+ }
362
+ }
363
+ // ----------------------------------------------------------------- messages
364
+ registerMessageHandlers() {
365
+ this.onMessage(MSG.SESSION_ACTION, (client, payload) => {
366
+ if (!this.checkPayload(client, payload))
367
+ return;
368
+ if (!this.sessionLimiter.tryConsume(client.sessionId, Date.now())) {
369
+ this.sendError(client, "RATE_LIMITED");
370
+ this.logger.warn(EVENT.RATE_LIMITED, { playerId: client.sessionId, channel: "session" });
371
+ return;
372
+ }
373
+ const parsed = SessionActionSchema.safeParse(payload);
374
+ if (!parsed.success) {
375
+ this.sendError(client, "INVALID_PAYLOAD");
376
+ return;
377
+ }
378
+ this.handleSessionAction(client, parsed.data);
379
+ });
380
+ this.onMessage(MSG.GAME_ACTION, (client, payload) => {
381
+ if (!this.checkPayload(client, payload))
382
+ return;
383
+ const now = Date.now();
384
+ if (!this.gameLimiter.tryConsume(client.sessionId, now)) {
385
+ this.sendError(client, "RATE_LIMITED");
386
+ this.logger.warn(EVENT.RATE_LIMITED, { playerId: client.sessionId, channel: "game" });
387
+ return;
388
+ }
389
+ const data = client.userData;
390
+ if (data?.role !== "controller") {
391
+ this.sendError(client, "NOT_ALLOWED");
392
+ return;
393
+ }
394
+ if (!RUNNING_STATUSES.has(this.state.status)) {
395
+ this.sendError(client, "WRONG_STATE");
396
+ return;
397
+ }
398
+ const player = this.state.players.get(client.sessionId);
399
+ if (!player?.joined) {
400
+ this.sendError(client, "NOT_ALLOWED");
401
+ return;
402
+ }
403
+ const validated = validateSync(this.adapter.game.actionSchema, payload);
404
+ if (!validated.ok) {
405
+ this.sendError(client, "INVALID_PAYLOAD");
406
+ this.logger.debug(EVENT.ACTION_REJECTED, {
407
+ playerId: client.sessionId,
408
+ issues: validated.issues,
409
+ });
410
+ return;
411
+ }
412
+ const applied = this.applyGameAction(client.sessionId, validated.value, now);
413
+ if (!applied)
414
+ this.sendError(client, "WRONG_STATE");
415
+ });
416
+ this.onMessage(MSG.CLOCK_PING, (client, payload) => {
417
+ if (!this.clockLimiter.tryConsume(client.sessionId, Date.now()))
418
+ return;
419
+ const parsed = ClockPingSchema.safeParse(payload);
420
+ if (!parsed.success)
421
+ return;
422
+ client.send(MSG.CLOCK_PONG, { t0: parsed.data.t0, t1: Date.now() });
423
+ });
424
+ }
425
+ /**
426
+ * Rejects payloads that are too large before any parsing work happens.
427
+ *
428
+ * Colyseus has already decoded the message by this point, so this is a guard
429
+ * against a client wasting the rules engine's time, not a transport-level
430
+ * defence - the transport's own frame limit handles that.
431
+ */
432
+ checkPayload(client, payload) {
433
+ let size = 0;
434
+ try {
435
+ size = JSON.stringify(payload ?? null).length;
436
+ }
437
+ catch {
438
+ this.sendError(client, "INVALID_PAYLOAD");
439
+ return false;
440
+ }
441
+ if (size > MAX_MESSAGE_BYTES) {
442
+ this.sendError(client, "INVALID_PAYLOAD");
443
+ this.logger.warn(EVENT.ACTION_REJECTED, { playerId: client.sessionId, size });
444
+ return false;
445
+ }
446
+ return true;
447
+ }
448
+ handleSessionAction(client, action) {
449
+ const data = client.userData;
450
+ const isHostScreen = data?.role === "host";
451
+ const player = this.state.players.get(client.sessionId);
452
+ const isHostPlayer = Boolean(player && player.isHost);
453
+ const canControlSession = isHostScreen || isHostPlayer;
454
+ switch (action.type) {
455
+ case "set-profile": {
456
+ if (!player)
457
+ return this.sendError(client, "NOT_ALLOWED");
458
+ if (this.state.status === "CLOSED")
459
+ return this.sendError(client, "ROOM_CLOSED");
460
+ const firstJoin = !player.joined;
461
+ player.name = action.name;
462
+ player.avatar = action.avatar;
463
+ player.color = action.color;
464
+ player.joined = true;
465
+ if (firstJoin) {
466
+ if (this.state.hostPlayerId === "") {
467
+ this.state.hostPlayerId = player.id;
468
+ player.isHost = true;
469
+ }
470
+ this.notifyGameOfPlayer(player.id, "joined");
471
+ this.emitPlatformEvent({
472
+ kind: "player-joined",
473
+ messageKey: "event.playerJoined",
474
+ params: { name: player.name },
475
+ playerId: player.id,
476
+ });
477
+ this.logger.info(EVENT.PLAYER_JOINED, { playerId: player.id });
478
+ this.reconcileBots();
479
+ }
480
+ void this.publishMetadata();
481
+ return;
482
+ }
483
+ case "set-ready": {
484
+ if (!player?.joined)
485
+ return this.sendError(client, "NOT_ALLOWED");
486
+ player.ready = action.ready;
487
+ return;
488
+ }
489
+ case "leave": {
490
+ this.removePlayer(client.sessionId, "left");
491
+ client.leave(1000);
492
+ return;
493
+ }
494
+ case "start-game": {
495
+ if (!canControlSession)
496
+ return this.sendError(client, "NOT_ALLOWED");
497
+ if (this.state.status !== "LOBBY")
498
+ return this.sendError(client, "WRONG_STATE");
499
+ this.startGame();
500
+ return;
501
+ }
502
+ case "rematch": {
503
+ if (!canControlSession)
504
+ return this.sendError(client, "NOT_ALLOWED");
505
+ if (this.state.status !== "GAME_OVER")
506
+ return this.sendError(client, "WRONG_STATE");
507
+ this.startGame();
508
+ return;
509
+ }
510
+ case "return-to-lobby": {
511
+ if (!canControlSession)
512
+ return this.sendError(client, "NOT_ALLOWED");
513
+ if (this.state.status !== "GAME_OVER")
514
+ return this.sendError(client, "WRONG_STATE");
515
+ this.returnToLobby();
516
+ return;
517
+ }
518
+ case "update-settings": {
519
+ if (!canControlSession)
520
+ return this.sendError(client, "NOT_ALLOWED");
521
+ if (this.state.status !== "LOBBY")
522
+ return this.sendError(client, "WRONG_STATE");
523
+ this.applySettings(action.settings);
524
+ return;
525
+ }
526
+ case "kick-player": {
527
+ if (!canControlSession)
528
+ return this.sendError(client, "NOT_ALLOWED");
529
+ if (action.playerId === client.sessionId)
530
+ return this.sendError(client, "NOT_ALLOWED");
531
+ const target = this.clients.find((c) => c.sessionId === action.playerId);
532
+ this.removePlayer(action.playerId, "kicked");
533
+ target?.leave(4001);
534
+ return;
535
+ }
536
+ case "dev-command": {
537
+ if (!runtimeHost().devToolsEnabled)
538
+ return this.sendError(client, "NOT_ALLOWED");
539
+ if (!canControlSession)
540
+ return this.sendError(client, "NOT_ALLOWED");
541
+ this.runDevCommand(action.command, action.value);
542
+ return;
543
+ }
544
+ }
545
+ }
546
+ applySettings(patch) {
547
+ const { settings } = this.state;
548
+ if (patch.maxPlayers !== undefined) {
549
+ const humans = [...this.state.players.values()].filter((p) => !p.isBot && p.joined).length;
550
+ // Never set a cap below the number of people already in the room.
551
+ settings.maxPlayers = Math.max(humans, Math.min(patch.maxPlayers, this.adapter.game.maxPlayers, runtimeHost().maxPlayers));
552
+ }
553
+ if (patch.botDifficulty !== undefined) {
554
+ settings.botDifficulty = patch.botDifficulty;
555
+ this.botStrategies.clear();
556
+ }
557
+ if (patch.botCount !== undefined) {
558
+ settings.botCount = Math.max(0, Math.min(patch.botCount, ABSOLUTE_MAX_PLAYERS));
559
+ }
560
+ if (patch.gameOptions !== undefined) {
561
+ this.gameOptions = this.adapter.game.parseOptions(patch.gameOptions);
562
+ }
563
+ this.reconcileBots();
564
+ }
565
+ // ---------------------------------------------------------------- lifecycle
566
+ startGame() {
567
+ this.reconcileBots();
568
+ const joined = this.gamePlayers();
569
+ if (joined.length < this.adapter.game.minPlayers) {
570
+ this.emitPlatformEvent({
571
+ kind: "start-refused",
572
+ messageKey: "host.needMorePlayers",
573
+ params: { count: this.adapter.game.minPlayers },
574
+ });
575
+ return;
576
+ }
577
+ this.gameState = this.adapter.game.createState(this.gameOptions);
578
+ this.botPending.clear();
579
+ for (const player of this.state.players.values()) {
580
+ player.ready = false;
581
+ }
582
+ this.setStatus("STARTING");
583
+ const ctx = this.buildContext(Date.now());
584
+ this.adapter.game.start(ctx);
585
+ // A countdown game calls requestStatus("STARTING") to hold this phase, then
586
+ // PLAYING from update(). Anything else would sit here forever.
587
+ const holdStarting = this.requestedStatus === "STARTING";
588
+ this.finishTick(ctx);
589
+ if (this.state.status === "STARTING" && !holdStarting) {
590
+ this.setStatus("PLAYING");
591
+ }
592
+ this.logger.info(EVENT.GAME_STARTED, { players: joined.length });
593
+ }
594
+ returnToLobby() {
595
+ this.gameState = this.adapter.game.createState(this.gameOptions);
596
+ this.botPending.clear();
597
+ for (const player of this.state.players.values()) {
598
+ player.ready = false;
599
+ player.score = 0;
600
+ }
601
+ this.setStatus("LOBBY");
602
+ this.projectGameState(Date.now());
603
+ }
604
+ setStatus(status) {
605
+ if (this.state.status === status)
606
+ return;
607
+ this.state.status = status;
608
+ this.logger.info(EVENT.STATUS_CHANGED, { status });
609
+ void this.publishMetadata();
610
+ }
611
+ /**
612
+ * Applies a status the game asked for, filtered by what the platform allows.
613
+ *
614
+ * The game may move between playing states, but it may not put the session
615
+ * back into the lobby or close it - those are platform decisions.
616
+ */
617
+ applyRequestedStatus() {
618
+ const requested = this.requestedStatus;
619
+ this.requestedStatus = null;
620
+ if (!requested)
621
+ return;
622
+ if (requested === "PLAYING" || requested === "ROUND_END") {
623
+ if (RUNNING_STATUSES.has(this.state.status))
624
+ this.setStatus(requested);
625
+ return;
626
+ }
627
+ if (requested === "GAME_OVER") {
628
+ this.setStatus("GAME_OVER");
629
+ this.botPending.clear();
630
+ this.logger.info(EVENT.GAME_ENDED);
631
+ }
632
+ }
633
+ // -------------------------------------------------------------- game bridge
634
+ buildContext(now) {
635
+ return {
636
+ state: this.gameState,
637
+ options: this.gameOptions,
638
+ players: this.registry,
639
+ rng: this.rng,
640
+ now,
641
+ emit: (event) => {
642
+ this.eventQueue.push({ ...event, at: now });
643
+ },
644
+ requestStatus: (status) => {
645
+ this.requestedStatus = status;
646
+ },
647
+ };
648
+ }
649
+ /** Runs one validated action through the rules and settles the resulting tick. */
650
+ applyGameAction(playerId, action, now) {
651
+ const ctx = this.buildContext(now);
652
+ let handled = false;
653
+ try {
654
+ handled = this.adapter.game.handleAction(ctx, playerId, action);
655
+ }
656
+ catch (error) {
657
+ this.logger.error(EVENT.GAME_ERROR, {
658
+ playerId,
659
+ message: error instanceof Error ? error.message : String(error),
660
+ });
661
+ handled = false;
662
+ }
663
+ this.finishTick(ctx);
664
+ if (handled)
665
+ this.logger.debug(EVENT.PLAYER_ACTION, { playerId });
666
+ return handled;
667
+ }
668
+ notifyGameOfPlayer(playerId, change) {
669
+ const hook = this.adapter.game.onPlayerChanged;
670
+ if (!hook)
671
+ return;
672
+ const ctx = this.buildContext(Date.now());
673
+ try {
674
+ hook.call(this.adapter.game, ctx, playerId, change);
675
+ }
676
+ catch (error) {
677
+ this.logger.error(EVENT.GAME_ERROR, {
678
+ playerId,
679
+ message: error instanceof Error ? error.message : String(error),
680
+ });
681
+ }
682
+ this.finishTick(ctx);
683
+ }
684
+ /** Broadcasts queued events, applies status requests and republishes state. */
685
+ finishTick(ctx) {
686
+ if (RUNNING_STATUSES.has(this.state.status) &&
687
+ this.requestedStatus !== "GAME_OVER" &&
688
+ this.adapter.game.isFinished(ctx)) {
689
+ this.requestedStatus = "GAME_OVER";
690
+ }
691
+ this.applyRequestedStatus();
692
+ this.flushEvents();
693
+ this.projectGameState(ctx.now);
694
+ }
695
+ projectGameState(now) {
696
+ const ctx = this.buildContext(now);
697
+ try {
698
+ this.adapter.project(this.state, this.adapter.game.getPublicState(ctx));
699
+ this.state.gameRevision += 1;
700
+ }
701
+ catch (error) {
702
+ this.logger.error(EVENT.GAME_ERROR, {
703
+ message: error instanceof Error ? error.message : String(error),
704
+ });
705
+ }
706
+ }
707
+ flushEvents() {
708
+ if (this.eventQueue.length === 0)
709
+ return;
710
+ const events = this.eventQueue;
711
+ this.eventQueue = [];
712
+ for (const event of events) {
713
+ this.broadcast(MSG.GAME_EVENT, event);
714
+ }
715
+ }
716
+ emitPlatformEvent(event) {
717
+ this.broadcast(MSG.GAME_EVENT, { ...event, at: Date.now() });
718
+ }
719
+ // ------------------------------------------------------------ controller io
720
+ controllerMode(player) {
721
+ if (!player?.joined)
722
+ return "setup";
723
+ switch (this.state.status) {
724
+ case "LOBBY":
725
+ return "lobby";
726
+ case "STARTING":
727
+ return "starting";
728
+ case "PLAYING":
729
+ return "game";
730
+ case "ROUND_END":
731
+ return "round-end";
732
+ case "GAME_OVER":
733
+ return "game-over";
734
+ default:
735
+ return "setup";
736
+ }
737
+ }
738
+ buildEnvelope(playerId) {
739
+ const player = this.state.players.get(playerId);
740
+ const mode = this.controllerMode(player);
741
+ let gamePart = { active: false, game: null };
742
+ if (player?.joined) {
743
+ const ctx = this.buildContext(Date.now());
744
+ try {
745
+ gamePart = this.adapter.game.getControllerState(ctx, playerId);
746
+ }
747
+ catch (error) {
748
+ this.logger.error(EVENT.GAME_ERROR, {
749
+ playerId,
750
+ message: error instanceof Error ? error.message : String(error),
751
+ });
752
+ }
753
+ }
754
+ return {
755
+ mode,
756
+ gameId: this.state.gameId,
757
+ active: gamePart.active,
758
+ score: player?.score ?? 0,
759
+ game: gamePart.game,
760
+ revision: this.controllerRevision,
761
+ };
762
+ }
763
+ /** Sends a controller its projection, skipping sends that would change nothing. */
764
+ pushControllerState(client, force = false) {
765
+ const data = client.userData;
766
+ if (data?.role !== "controller")
767
+ return;
768
+ const envelope = this.buildEnvelope(client.sessionId);
769
+ // `revision` is excluded from the comparison so it does not defeat the check.
770
+ const { revision: _revision, ...comparable } = envelope;
771
+ const json = JSON.stringify(comparable);
772
+ if (!force && this.lastControllerJson.get(client.sessionId) === json)
773
+ return;
774
+ this.lastControllerJson.set(client.sessionId, json);
775
+ this.controllerRevision += 1;
776
+ client.send(MSG.CONTROLLER_STATE, { ...envelope, revision: this.controllerRevision });
777
+ }
778
+ pushAllControllerStates() {
779
+ for (const client of this.clients) {
780
+ this.pushControllerState(client);
781
+ }
782
+ }
783
+ sendWelcome(client) {
784
+ const data = client.userData;
785
+ const payload = {
786
+ playerId: client.sessionId,
787
+ role: data?.role ?? "controller",
788
+ roomId: this.roomId,
789
+ roomCode: this.state.publicCode,
790
+ // The client rebuilds the full token from its own room object; this is
791
+ // sent so a client that lost its copy can still recover it.
792
+ reconnectionToken: `${this.roomId}:${client.reconnectionToken}`,
793
+ gameId: this.state.gameId,
794
+ serverTime: Date.now(),
795
+ };
796
+ client.send(MSG.WELCOME, payload);
797
+ }
798
+ sendError(client, code) {
799
+ client.send(MSG.ERROR, { code, messageKey: `error.${code}` });
800
+ }
801
+ // --------------------------------------------------------------------- tick
802
+ tick(deltaMs) {
803
+ const now = Date.now();
804
+ if (RUNNING_STATUSES.has(this.state.status)) {
805
+ const ctx = this.buildContext(now);
806
+ try {
807
+ this.adapter.game.update(ctx, deltaMs);
808
+ this.tickBots(ctx, now);
809
+ }
810
+ catch (error) {
811
+ this.logger.error(EVENT.GAME_ERROR, {
812
+ message: error instanceof Error ? error.message : String(error),
813
+ });
814
+ }
815
+ this.finishTick(ctx);
816
+ }
817
+ this.pushAllControllerStates();
818
+ if (now - this.lastBeaconAt >= CLOCK_BEACON_MS) {
819
+ this.lastBeaconAt = now;
820
+ this.state.serverTime = now;
821
+ }
822
+ this.checkExpiry(now);
823
+ }
824
+ /**
825
+ * Reclaims sessions nobody is using.
826
+ *
827
+ * Two independent limits: an idle timeout that starts when the last client
828
+ * leaves, and an absolute age cap so a forgotten room on a TV in an empty
829
+ * office cannot live forever.
830
+ */
831
+ checkExpiry(now) {
832
+ if (this.clients.length > 0) {
833
+ this.lastConnectedAt = now;
834
+ return;
835
+ }
836
+ const idleFor = now - this.lastConnectedAt;
837
+ const age = now - this.createdAt;
838
+ const limits = runtimeHost();
839
+ if (idleFor < limits.sessionTimeoutMs && age < limits.sessionMaxAgeMs)
840
+ return;
841
+ this.logger.info(EVENT.SESSION_EXPIRED, { idleFor, age });
842
+ this.state.status = "CLOSED";
843
+ void this.disconnect(4002);
844
+ }
845
+ // ----------------------------------------------------------------- metadata
846
+ async publishMetadata() {
847
+ try {
848
+ await this.setMetadata({
849
+ publicCode: this.state.publicCode,
850
+ gameId: this.state.gameId,
851
+ status: this.state.status,
852
+ playerCount: [...this.state.players.values()].filter((p) => p.joined && !p.isBot).length,
853
+ maxPlayers: this.state.settings.maxPlayers,
854
+ });
855
+ }
856
+ catch (error) {
857
+ this.logger.warn("METADATA_FAILED", {
858
+ message: error instanceof Error ? error.message : String(error),
859
+ });
860
+ }
861
+ }
862
+ // --------------------------------------------------------------- dev tools
863
+ /**
864
+ * Developer-mode shortcuts.
865
+ *
866
+ * Reachable only when `ENABLE_DEV_TOOLS` is on *and* the build is not
867
+ * production, checked again at the call site. Platform commands are handled
868
+ * here; anything else is delegated to the game plugin.
869
+ */
870
+ runDevCommand(command, value) {
871
+ switch (command) {
872
+ case "add-bot": {
873
+ this.state.settings.botCount = Math.min(this.state.settings.botCount + 1, ABSOLUTE_MAX_PLAYERS);
874
+ this.reconcileBots();
875
+ return;
876
+ }
877
+ case "remove-bot": {
878
+ this.state.settings.botCount = Math.max(0, this.state.settings.botCount - 1);
879
+ this.reconcileBots();
880
+ return;
881
+ }
882
+ case "end-session": {
883
+ this.state.status = "CLOSED";
884
+ void this.disconnect(4002);
885
+ return;
886
+ }
887
+ default: {
888
+ const handler = this.adapter.game.devCommands?.[command];
889
+ if (!handler)
890
+ return;
891
+ const ctx = this.buildContext(Date.now());
892
+ handler(ctx, value);
893
+ this.finishTick(ctx);
894
+ }
895
+ }
896
+ }
897
+ }
898
+ //# sourceMappingURL=PartySessionRoom.js.map