@sametnurel/game-server 0.0.0-stage → 1.0.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/dist/index.js ADDED
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryClient = exports.MemoryTransport = exports.ErrorCodes = exports.SystemEvents = exports.silentLogger = exports.ConsoleLogger = exports.IdGenerator = exports.TurnTimer = exports.SessionManager = exports.MatchQueue = exports.Player = exports.GameManager = exports.Game = exports.RoomRegistry = exports.ConnectionManager = exports.Connection = exports.PayloadError = exports.BaseCommand = exports.GameServer = void 0;
4
+ var server_1 = require("./server");
5
+ Object.defineProperty(exports, "GameServer", { enumerable: true, get: function () { return server_1.GameServer; } });
6
+ var command_1 = require("./command");
7
+ Object.defineProperty(exports, "BaseCommand", { enumerable: true, get: function () { return command_1.BaseCommand; } });
8
+ Object.defineProperty(exports, "PayloadError", { enumerable: true, get: function () { return command_1.PayloadError; } });
9
+ var connection_1 = require("./connection");
10
+ Object.defineProperty(exports, "Connection", { enumerable: true, get: function () { return connection_1.Connection; } });
11
+ var connection_manager_1 = require("./connection-manager");
12
+ Object.defineProperty(exports, "ConnectionManager", { enumerable: true, get: function () { return connection_manager_1.ConnectionManager; } });
13
+ var rooms_1 = require("./rooms");
14
+ Object.defineProperty(exports, "RoomRegistry", { enumerable: true, get: function () { return rooms_1.RoomRegistry; } });
15
+ var game_1 = require("./game");
16
+ Object.defineProperty(exports, "Game", { enumerable: true, get: function () { return game_1.Game; } });
17
+ var game_manager_1 = require("./game-manager");
18
+ Object.defineProperty(exports, "GameManager", { enumerable: true, get: function () { return game_manager_1.GameManager; } });
19
+ var player_1 = require("./player");
20
+ Object.defineProperty(exports, "Player", { enumerable: true, get: function () { return player_1.Player; } });
21
+ var matchmaking_1 = require("./matchmaking");
22
+ Object.defineProperty(exports, "MatchQueue", { enumerable: true, get: function () { return matchmaking_1.MatchQueue; } });
23
+ var sessions_1 = require("./sessions");
24
+ Object.defineProperty(exports, "SessionManager", { enumerable: true, get: function () { return sessions_1.SessionManager; } });
25
+ var turn_timer_1 = require("./turn-timer");
26
+ Object.defineProperty(exports, "TurnTimer", { enumerable: true, get: function () { return turn_timer_1.TurnTimer; } });
27
+ var ids_1 = require("./ids");
28
+ Object.defineProperty(exports, "IdGenerator", { enumerable: true, get: function () { return ids_1.IdGenerator; } });
29
+ var logger_1 = require("./logger");
30
+ Object.defineProperty(exports, "ConsoleLogger", { enumerable: true, get: function () { return logger_1.ConsoleLogger; } });
31
+ Object.defineProperty(exports, "silentLogger", { enumerable: true, get: function () { return logger_1.silentLogger; } });
32
+ var events_1 = require("./events");
33
+ Object.defineProperty(exports, "SystemEvents", { enumerable: true, get: function () { return events_1.SystemEvents; } });
34
+ Object.defineProperty(exports, "ErrorCodes", { enumerable: true, get: function () { return events_1.ErrorCodes; } });
35
+ var memory_1 = require("./transport/memory");
36
+ Object.defineProperty(exports, "MemoryTransport", { enumerable: true, get: function () { return memory_1.MemoryTransport; } });
37
+ Object.defineProperty(exports, "MemoryClient", { enumerable: true, get: function () { return memory_1.MemoryClient; } });
@@ -0,0 +1,19 @@
1
+ export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
2
+ export interface Logger {
3
+ error(message: string, ...meta: unknown[]): void;
4
+ warn(message: string, ...meta: unknown[]): void;
5
+ info(message: string, ...meta: unknown[]): void;
6
+ debug(message: string, ...meta: unknown[]): void;
7
+ }
8
+ /** Console logger with a minimum level. Defaults to LOG_LEVEL or 'info'. */
9
+ export declare class ConsoleLogger implements Logger {
10
+ private readonly level;
11
+ constructor(level?: LogLevel);
12
+ error(message: string, ...meta: unknown[]): void;
13
+ warn(message: string, ...meta: unknown[]): void;
14
+ info(message: string, ...meta: unknown[]): void;
15
+ debug(message: string, ...meta: unknown[]): void;
16
+ private write;
17
+ }
18
+ /** Logger that discards everything. Useful in tests. */
19
+ export declare const silentLogger: Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.silentLogger = exports.ConsoleLogger = void 0;
4
+ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
5
+ function isLogLevel(value) {
6
+ return value !== undefined && value in LEVELS;
7
+ }
8
+ /** Console logger with a minimum level. Defaults to LOG_LEVEL or 'info'. */
9
+ class ConsoleLogger {
10
+ level;
11
+ constructor(level) {
12
+ const fromEnv = process.env.LOG_LEVEL;
13
+ this.level = level ?? (isLogLevel(fromEnv) ? fromEnv : 'info');
14
+ }
15
+ error(message, ...meta) {
16
+ this.write('error', message, meta);
17
+ }
18
+ warn(message, ...meta) {
19
+ this.write('warn', message, meta);
20
+ }
21
+ info(message, ...meta) {
22
+ this.write('info', message, meta);
23
+ }
24
+ debug(message, ...meta) {
25
+ this.write('debug', message, meta);
26
+ }
27
+ write(level, message, meta) {
28
+ if (LEVELS[level] > LEVELS[this.level])
29
+ return;
30
+ const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${message}`;
31
+ const out = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
32
+ out(line, ...meta);
33
+ }
34
+ }
35
+ exports.ConsoleLogger = ConsoleLogger;
36
+ /** Logger that discards everything. Useful in tests. */
37
+ exports.silentLogger = {
38
+ error: () => { },
39
+ warn: () => { },
40
+ info: () => { },
41
+ debug: () => { },
42
+ };
@@ -0,0 +1,22 @@
1
+ export interface QueueEntry<T> {
2
+ item: T;
3
+ enqueuedAt: number;
4
+ }
5
+ /**
6
+ * First-come matchmaking per mode. When `groupSize` players wait in the same
7
+ * mode, enqueue() returns them as a group and removes them from the queue.
8
+ */
9
+ export declare class MatchQueue<T, TKey = number> {
10
+ private readonly keyOf;
11
+ private readonly groupSize;
12
+ private readonly queues;
13
+ constructor(keyOf: (item: T) => TKey, groupSize?: number);
14
+ enqueue(mode: string, item: T, now?: number): T[] | null;
15
+ /** Removes the item from every mode. Returns true if it was queued. */
16
+ remove(key: TKey): boolean;
17
+ has(key: TKey): boolean;
18
+ /** Removes and returns entries that waited at least `ms`, e.g. to pair them with a bot. */
19
+ takeWaitingLongerThan(mode: string, ms: number, now?: number): T[];
20
+ length(mode: string): number;
21
+ private queueFor;
22
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MatchQueue = void 0;
4
+ /**
5
+ * First-come matchmaking per mode. When `groupSize` players wait in the same
6
+ * mode, enqueue() returns them as a group and removes them from the queue.
7
+ */
8
+ class MatchQueue {
9
+ keyOf;
10
+ groupSize;
11
+ queues = new Map();
12
+ constructor(keyOf, groupSize = 2) {
13
+ this.keyOf = keyOf;
14
+ this.groupSize = groupSize;
15
+ if (groupSize < 2)
16
+ throw new RangeError('MatchQueue: groupSize must be >= 2');
17
+ }
18
+ enqueue(mode, item, now = Date.now()) {
19
+ this.remove(this.keyOf(item));
20
+ const queue = this.queueFor(mode);
21
+ queue.push({ item, enqueuedAt: now });
22
+ if (queue.length < this.groupSize)
23
+ return null;
24
+ return queue.splice(0, this.groupSize).map((entry) => entry.item);
25
+ }
26
+ /** Removes the item from every mode. Returns true if it was queued. */
27
+ remove(key) {
28
+ for (const queue of this.queues.values()) {
29
+ const index = queue.findIndex((entry) => this.keyOf(entry.item) === key);
30
+ if (index !== -1) {
31
+ queue.splice(index, 1);
32
+ return true;
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+ has(key) {
38
+ for (const queue of this.queues.values()) {
39
+ if (queue.some((entry) => this.keyOf(entry.item) === key))
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ /** Removes and returns entries that waited at least `ms`, e.g. to pair them with a bot. */
45
+ takeWaitingLongerThan(mode, ms, now = Date.now()) {
46
+ const queue = this.queueFor(mode);
47
+ const stale = queue.filter((entry) => now - entry.enqueuedAt >= ms);
48
+ this.queues.set(mode, queue.filter((entry) => now - entry.enqueuedAt < ms));
49
+ return stale.map((entry) => entry.item);
50
+ }
51
+ length(mode) {
52
+ return this.queues.get(mode)?.length ?? 0;
53
+ }
54
+ queueFor(mode) {
55
+ let queue = this.queues.get(mode);
56
+ if (!queue) {
57
+ queue = [];
58
+ this.queues.set(mode, queue);
59
+ }
60
+ return queue;
61
+ }
62
+ }
63
+ exports.MatchQueue = MatchQueue;
@@ -0,0 +1,16 @@
1
+ /** Minimal player record. Extend it for game-specific state. */
2
+ export declare class Player {
3
+ readonly id: number;
4
+ readonly name: string;
5
+ /** Stable client identifier, used to find the player again after a reconnect. */
6
+ readonly identifier?: string | undefined;
7
+ /** Bots have no network client; the app drives them in-process. */
8
+ readonly isBot: boolean;
9
+ gameId: number | undefined;
10
+ connected: boolean;
11
+ constructor(id: number, name: string,
12
+ /** Stable client identifier, used to find the player again after a reconnect. */
13
+ identifier?: string | undefined,
14
+ /** Bots have no network client; the app drives them in-process. */
15
+ isBot?: boolean);
16
+ }
package/dist/player.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Player = void 0;
4
+ /** Minimal player record. Extend it for game-specific state. */
5
+ class Player {
6
+ id;
7
+ name;
8
+ identifier;
9
+ isBot;
10
+ gameId;
11
+ connected = true;
12
+ constructor(id, name,
13
+ /** Stable client identifier, used to find the player again after a reconnect. */
14
+ identifier,
15
+ /** Bots have no network client; the app drives them in-process. */
16
+ isBot = false) {
17
+ this.id = id;
18
+ this.name = name;
19
+ this.identifier = identifier;
20
+ this.isBot = isBot;
21
+ }
22
+ }
23
+ exports.Player = Player;
@@ -0,0 +1,11 @@
1
+ import type { Connection } from './connection';
2
+ /** Named groups of connections, independent of the transport. */
3
+ export declare class RoomRegistry {
4
+ private readonly rooms;
5
+ join(room: string, conn: Connection<any>): void;
6
+ leave(room: string, conn: Connection<any>): void;
7
+ leaveAll(conn: Connection<any>): void;
8
+ members(room: string): Connection<any>[];
9
+ broadcast(room: string, event: string, data?: unknown, except?: Connection<any>): void;
10
+ delete(room: string): void;
11
+ }
package/dist/rooms.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RoomRegistry = void 0;
4
+ /** Named groups of connections, independent of the transport. */
5
+ class RoomRegistry {
6
+ rooms = new Map();
7
+ join(room, conn) {
8
+ let members = this.rooms.get(room);
9
+ if (!members) {
10
+ members = new Set();
11
+ this.rooms.set(room, members);
12
+ }
13
+ members.add(conn);
14
+ conn.rooms.add(room);
15
+ }
16
+ leave(room, conn) {
17
+ const members = this.rooms.get(room);
18
+ members?.delete(conn);
19
+ if (members?.size === 0)
20
+ this.rooms.delete(room);
21
+ conn.rooms.delete(room);
22
+ }
23
+ leaveAll(conn) {
24
+ for (const room of [...conn.rooms])
25
+ this.leave(room, conn);
26
+ }
27
+ members(room) {
28
+ return [...(this.rooms.get(room) ?? [])];
29
+ }
30
+ broadcast(room, event, data, except) {
31
+ for (const conn of this.rooms.get(room) ?? []) {
32
+ if (conn !== except)
33
+ conn.send(event, data);
34
+ }
35
+ }
36
+ delete(room) {
37
+ for (const conn of this.rooms.get(room) ?? [])
38
+ conn.rooms.delete(room);
39
+ this.rooms.delete(room);
40
+ }
41
+ }
42
+ exports.RoomRegistry = RoomRegistry;
@@ -0,0 +1,88 @@
1
+ import { type Server as HttpServer } from 'node:http';
2
+ import type { Command, CommandContext } from './command';
3
+ import type { Connection } from './connection';
4
+ import { ConnectionManager } from './connection-manager';
5
+ import { type ErrorPayload } from './events';
6
+ import type { Game } from './game';
7
+ import { GameManager } from './game-manager';
8
+ import { IdGenerator } from './ids';
9
+ import { type Logger } from './logger';
10
+ import { RoomRegistry } from './rooms';
11
+ import { type MemoryClient } from './transport/memory';
12
+ import type { CorsOptions, Transport } from './transport/types';
13
+ export interface GameServerOptions {
14
+ /** Network transports, e.g. [new SocketIOTransport(), new WebSocketTransport()]. */
15
+ transports?: Transport[];
16
+ /**
17
+ * Existing HTTP server (e.g. from an Express app) to attach to. When given,
18
+ * the app is responsible for calling listen() unless `port` is also set.
19
+ */
20
+ httpServer?: HttpServer;
21
+ /** Port to listen on. 0 picks a free port. Default 5200 when no httpServer is given. */
22
+ port?: number;
23
+ host?: string;
24
+ /** Allowed browser origins for network transports. Default: none restricted ('*'). */
25
+ cors?: CorsOptions;
26
+ logger?: Logger;
27
+ /** Interval for Game.update() calls in ms. 0 disables ticking. Default 1000. */
28
+ tickIntervalMs?: number;
29
+ /** Id range for games. */
30
+ gameIds?: IdGenerator;
31
+ }
32
+ type ConnectionHook<TData> = (connection: Connection<TData>) => void;
33
+ type DisconnectHook<TData> = (connection: Connection<TData>, reason: string) => void;
34
+ /**
35
+ * Wires transports, connections, commands, rooms and games together.
36
+ * Create one per process (or several in tests); nothing is global.
37
+ */
38
+ export declare class GameServer<TGame extends Game<any> = Game<any>, TData = unknown> {
39
+ readonly connections: ConnectionManager<TData>;
40
+ readonly rooms: RoomRegistry;
41
+ readonly games: GameManager<TGame>;
42
+ readonly logger: Logger;
43
+ private readonly commands;
44
+ private readonly pending;
45
+ private readonly connectHooks;
46
+ private readonly disconnectHooks;
47
+ private readonly transports;
48
+ private readonly memory;
49
+ private readonly options;
50
+ private httpServer;
51
+ private ownsHttpServer;
52
+ private tickTimer;
53
+ private tickCount;
54
+ private started;
55
+ constructor(options?: GameServerOptions);
56
+ /** Registers a handler for a client event. Accepts a Command or a plain function. */
57
+ command<TPayload>(event: string, command: Command<TPayload, TData> | ((context: CommandContext<TData>, payload: TPayload) => void | Promise<void>)): this;
58
+ onConnect(hook: ConnectionHook<TData>): this;
59
+ /** Called after a client is gone. The connection has already left its rooms. */
60
+ onDisconnect(hook: DisconnectHook<TData>): this;
61
+ start(): Promise<{
62
+ port: number | null;
63
+ }>;
64
+ stop(): Promise<void>;
65
+ /** Runs one tick now. Called automatically every `tickIntervalMs`. */
66
+ tick(now?: number): void;
67
+ /**
68
+ * An in-process client that talks to this server like a network client.
69
+ * Use it for bots and tests. Requires start().
70
+ */
71
+ createVirtualClient(): MemoryClient;
72
+ broadcast(room: string, event: string, data?: unknown, except?: Connection<TData>): void;
73
+ /** Sends a SystemEvents.Error to one connection. */
74
+ sendError(connection: Connection<TData>, error: ErrorPayload): void;
75
+ get http(): HttpServer | null;
76
+ private createHandlers;
77
+ private handleConnect;
78
+ /**
79
+ * Messages from one client are handled strictly in order: if a command is
80
+ * async, the next message from the same socket waits for it.
81
+ */
82
+ private enqueueMessage;
83
+ /** Runs one message. Returns a promise only when the command is async. Never throws. */
84
+ private dispatch;
85
+ private handleDisconnect;
86
+ private runHooks;
87
+ }
88
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GameServer = void 0;
4
+ const node_http_1 = require("node:http");
5
+ const command_1 = require("./command");
6
+ const connection_manager_1 = require("./connection-manager");
7
+ const events_1 = require("./events");
8
+ const game_manager_1 = require("./game-manager");
9
+ const logger_1 = require("./logger");
10
+ const rooms_1 = require("./rooms");
11
+ const memory_1 = require("./transport/memory");
12
+ /**
13
+ * Wires transports, connections, commands, rooms and games together.
14
+ * Create one per process (or several in tests); nothing is global.
15
+ */
16
+ class GameServer {
17
+ connections = new connection_manager_1.ConnectionManager();
18
+ rooms = new rooms_1.RoomRegistry();
19
+ games;
20
+ logger;
21
+ commands = new Map();
22
+ pending = new WeakMap();
23
+ connectHooks = [];
24
+ disconnectHooks = [];
25
+ transports;
26
+ memory = new memory_1.MemoryTransport();
27
+ options;
28
+ httpServer;
29
+ ownsHttpServer = false;
30
+ tickTimer = null;
31
+ tickCount = 0;
32
+ started = false;
33
+ constructor(options = {}) {
34
+ this.options = options;
35
+ this.logger = options.logger ?? new logger_1.ConsoleLogger();
36
+ this.games = new game_manager_1.GameManager(options.gameIds);
37
+ this.transports = [...(options.transports ?? []), this.memory];
38
+ this.httpServer = options.httpServer ?? null;
39
+ }
40
+ /** Registers a handler for a client event. Accepts a Command or a plain function. */
41
+ command(event, command) {
42
+ if (event === events_1.SystemEvents.Error)
43
+ throw new Error(`'${event}' is reserved`);
44
+ if (this.commands.has(event))
45
+ throw new Error(`A command for '${event}' is already registered`);
46
+ this.commands.set(event, typeof command === 'function' ? { handle: command } : command);
47
+ return this;
48
+ }
49
+ onConnect(hook) {
50
+ this.connectHooks.push(hook);
51
+ return this;
52
+ }
53
+ /** Called after a client is gone. The connection has already left its rooms. */
54
+ onDisconnect(hook) {
55
+ this.disconnectHooks.push(hook);
56
+ return this;
57
+ }
58
+ async start() {
59
+ if (this.started)
60
+ throw new Error('GameServer already started');
61
+ this.started = true;
62
+ const needsHttp = this.transports.some((t) => t.needsHttp);
63
+ if (needsHttp && !this.httpServer) {
64
+ this.httpServer = (0, node_http_1.createServer)((_req, res) => {
65
+ res.writeHead(404).end();
66
+ });
67
+ this.ownsHttpServer = true;
68
+ }
69
+ const handlers = this.createHandlers();
70
+ for (const transport of this.transports) {
71
+ transport.attach({
72
+ httpServer: transport.needsHttp ? this.httpServer : null,
73
+ handlers,
74
+ cors: this.options.cors,
75
+ });
76
+ }
77
+ let port = null;
78
+ const listenPort = this.options.port ?? (this.ownsHttpServer ? 5200 : undefined);
79
+ if (this.httpServer && listenPort !== undefined && !this.httpServer.listening) {
80
+ await new Promise((resolve, reject) => {
81
+ this.httpServer.once('error', reject);
82
+ this.httpServer.listen(listenPort, this.options.host, () => {
83
+ this.httpServer.off('error', reject);
84
+ resolve();
85
+ });
86
+ });
87
+ }
88
+ if (this.httpServer?.listening)
89
+ port = this.httpServer.address().port;
90
+ const tickMs = this.options.tickIntervalMs ?? 1000;
91
+ if (tickMs > 0) {
92
+ this.tickTimer = setInterval(() => this.tick(), tickMs);
93
+ this.tickTimer.unref();
94
+ }
95
+ this.logger.info(`GameServer started (${this.transports.map((t) => t.name).join(', ')})${port !== null ? ` on port ${port}` : ''}`);
96
+ return { port };
97
+ }
98
+ async stop() {
99
+ if (this.tickTimer)
100
+ clearInterval(this.tickTimer);
101
+ this.tickTimer = null;
102
+ for (const transport of this.transports)
103
+ await transport.close();
104
+ if (this.ownsHttpServer && this.httpServer?.listening) {
105
+ await new Promise((resolve) => this.httpServer.close(() => resolve()));
106
+ }
107
+ for (const game of [...this.games.all()])
108
+ this.games.remove(game.id);
109
+ this.started = false;
110
+ }
111
+ /** Runs one tick now. Called automatically every `tickIntervalMs`. */
112
+ tick(now = Date.now()) {
113
+ this.tickCount += 1;
114
+ this.games.tick(this.tickCount, now, (game, error) => this.logger.error(`Game ${game.id} update failed`, error));
115
+ }
116
+ /**
117
+ * An in-process client that talks to this server like a network client.
118
+ * Use it for bots and tests. Requires start().
119
+ */
120
+ createVirtualClient() {
121
+ return this.memory.connect();
122
+ }
123
+ broadcast(room, event, data, except) {
124
+ this.rooms.broadcast(room, event, data, except);
125
+ }
126
+ /** Sends a SystemEvents.Error to one connection. */
127
+ sendError(connection, error) {
128
+ connection.send(events_1.SystemEvents.Error, error);
129
+ }
130
+ get http() {
131
+ return this.httpServer;
132
+ }
133
+ createHandlers() {
134
+ return {
135
+ onConnect: (socket) => this.handleConnect(socket),
136
+ onMessage: (socket, event, data) => this.enqueueMessage(socket, event, data),
137
+ onDisconnect: (socket, reason) => this.handleDisconnect(socket, reason),
138
+ };
139
+ }
140
+ handleConnect(socket) {
141
+ const connection = this.connections.add(socket);
142
+ this.logger.debug(`connect ${socket.transport}:${socket.id}`);
143
+ this.runHooks(this.connectHooks, connection);
144
+ }
145
+ /**
146
+ * Messages from one client are handled strictly in order: if a command is
147
+ * async, the next message from the same socket waits for it.
148
+ */
149
+ enqueueMessage(socket, event, raw) {
150
+ const busy = this.pending.get(socket);
151
+ const result = busy
152
+ ? busy.then(() => this.dispatch(socket, event, raw))
153
+ : this.dispatch(socket, event, raw);
154
+ if (!result)
155
+ return;
156
+ this.pending.set(socket, result);
157
+ void result.finally(() => {
158
+ if (this.pending.get(socket) === result)
159
+ this.pending.delete(socket);
160
+ });
161
+ }
162
+ /** Runs one message. Returns a promise only when the command is async. Never throws. */
163
+ dispatch(socket, event, raw) {
164
+ const connection = this.connections.get(socket.id);
165
+ if (!connection)
166
+ return;
167
+ this.logger.debug(`message ${socket.id} ${event}`);
168
+ const command = this.commands.get(event);
169
+ if (!command) {
170
+ this.sendError(connection, { code: events_1.ErrorCodes.UnknownEvent, event });
171
+ return;
172
+ }
173
+ let payload = raw;
174
+ if (command.parse) {
175
+ try {
176
+ payload = command.parse(raw);
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof command_1.PayloadError ? error.message : undefined;
180
+ this.sendError(connection, { code: events_1.ErrorCodes.InvalidPayload, event, message });
181
+ return;
182
+ }
183
+ }
184
+ const fail = (error) => {
185
+ this.logger.error(`Command '${event}' failed`, error);
186
+ this.sendError(connection, { code: events_1.ErrorCodes.InternalError, event });
187
+ };
188
+ try {
189
+ const result = command.handle({ server: this, connection, event }, payload);
190
+ if (result instanceof Promise)
191
+ return result.catch(fail);
192
+ }
193
+ catch (error) {
194
+ fail(error);
195
+ }
196
+ }
197
+ handleDisconnect(socket, reason) {
198
+ const connection = this.connections.get(socket.id);
199
+ if (!connection || connection.socket !== socket)
200
+ return; // already re-bound to a newer socket
201
+ this.rooms.leaveAll(connection);
202
+ this.connections.remove(connection);
203
+ this.logger.debug(`disconnect ${socket.id} (${reason})`);
204
+ for (const hook of this.disconnectHooks) {
205
+ try {
206
+ hook(connection, reason);
207
+ }
208
+ catch (error) {
209
+ this.logger.error('onDisconnect hook failed', error);
210
+ }
211
+ }
212
+ }
213
+ runHooks(hooks, connection) {
214
+ for (const hook of hooks) {
215
+ try {
216
+ hook(connection);
217
+ }
218
+ catch (error) {
219
+ this.logger.error('onConnect hook failed', error);
220
+ }
221
+ }
222
+ }
223
+ }
224
+ exports.GameServer = GameServer;
@@ -0,0 +1,39 @@
1
+ import type { Connection } from './connection';
2
+ export interface Session<TState = unknown> {
3
+ readonly identifier: string;
4
+ connection: Connection<any> | undefined;
5
+ /** App state that must survive a reconnect, e.g. { playerId, gameId }. */
6
+ state: TState | undefined;
7
+ disconnectedAt: number | undefined;
8
+ }
9
+ export interface SessionManagerOptions<TState> {
10
+ /** How long a dropped client may take to come back. Default 30000 ms. */
11
+ reconnectWindowMs?: number;
12
+ /** Called when a disconnected session was not resumed in time. */
13
+ onExpire?: (session: Session<TState>) => void;
14
+ }
15
+ /**
16
+ * Keeps a client's identity across reconnects. A session is keyed by a stable
17
+ * identifier (device or account id). When its connection drops, the session is
18
+ * kept for `reconnectWindowMs`; attaching the same identifier again resumes it.
19
+ */
20
+ export declare class SessionManager<TState = unknown> {
21
+ private readonly sessions;
22
+ private readonly timers;
23
+ private readonly windowMs;
24
+ private readonly onExpire;
25
+ constructor(options?: SessionManagerOptions<TState>);
26
+ /** Binds a connection to an identifier. `resumed` is true if it came back in time. */
27
+ attach(identifier: string, connection: Connection<any>): {
28
+ session: Session<TState>;
29
+ resumed: boolean;
30
+ };
31
+ /** Marks the session of this connection as disconnected and starts the reconnect window. */
32
+ detach(connection: Connection<any>, now?: number): Session<TState> | undefined;
33
+ get(identifier: string): Session<TState> | undefined;
34
+ /** Ends a session for good (e.g. after the game is over). */
35
+ end(identifier: string): void;
36
+ clear(): void;
37
+ get size(): number;
38
+ private clearTimer;
39
+ }