@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.
- package/README.md +9 -0
- package/dist/PartySessionRoom.d.ts +143 -0
- package/dist/PartySessionRoom.d.ts.map +1 -0
- package/dist/PartySessionRoom.js +898 -0
- package/dist/PartySessionRoom.js.map +1 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +37 -0
- package/dist/adapters.js.map +1 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +72 -0
- package/dist/bind.js.map +1 -0
- package/dist/bots.d.ts.map +1 -0
- package/dist/bots.js +31 -0
- package/dist/bots.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +87 -0
- package/dist/listen.js.map +1 -0
- package/dist/roomCode.d.ts.map +1 -0
- package/dist/roomCode.js +47 -0
- package/dist/roomCode.js.map +1 -0
- package/package.json +49 -0
|
@@ -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
|