@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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First stable release, renamed from `@sametnurel/socket-io-game-server` (0.0.x) because the server is no longer tied to Socket.IO. A rewrite with breaking changes.
6
+
7
+ ### Added
8
+ - Pluggable transports: `SocketIOTransport`, `WebSocketTransport` (plain JSON frames, for engines without Socket.IO such as Godot) and an in-process `MemoryTransport`. Several can run on one HTTP server and share rooms.
9
+ - `GameServer` instance that wires everything; several servers per process are possible.
10
+ - Payload validation per command (`parse`), `PayloadError`, and `error` events with codes (`unknown_event`, `invalid_payload`, `internal_error`, `bad_frame`).
11
+ - Per-client in-order message handling, also for async commands.
12
+ - `SessionManager` (reconnect window), `MatchQueue` (matchmaking per mode, stale-entry pickup for bots), `TurnTimer`, `RoomRegistry`.
13
+ - `Game<TStatus>` with custom phases, `update(tick, now)` on a server tick, `dispose()`.
14
+ - `createVirtualClient()` for bots and tests.
15
+ - Options for port, host, CORS, an existing HTTP server, logger and tick interval.
16
+ - Test suite (Vitest).
17
+
18
+ ### Changed
19
+ - No singletons: use `server.games`, `server.connections`, `server.rooms`.
20
+ - Commands get `(context, payload)` instead of `(socket, connection, message)`.
21
+ - Event names are plain strings; system events live in `SystemEvents`.
22
+ - `socket.io` and `ws` are optional peer dependencies; Express is no longer required.
23
+
24
+ ### Fixed
25
+ - `IdGenerator` looped forever once 9000 ids were used; ids are now released and exhaustion throws.
26
+ - The logger, `GameOverParams` and other public types were not exported.
27
+ - A throwing command could crash the process.
28
+ - Removed use of Socket.IO's private `_readyState`.
29
+ - README: wrong package name, placeholder badges, license mismatch.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # @sametnurel/game-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small, transport-agnostic server for turn-based multiplayer games. It gives you
4
+ commands, rooms, reconnectable sessions, matchmaking and turn timers, and lets
5
+ clients connect over **Socket.IO**, **plain WebSocket** (any engine, e.g. Godot),
6
+ or **in-process** (bots and tests) at the same time.
7
+
8
+ Game rules stay in your code. The server routes messages and manages people.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @sametnurel/game-server
14
+ # plus the transports you use:
15
+ npm install ws # plain WebSocket
16
+ npm install socket.io # Socket.IO
17
+ ```
18
+
19
+ Requires Node 18+. Works from both CommonJS and ESM.
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { GameServer, PayloadError } from '@sametnurel/game-server';
25
+ import { WebSocketTransport } from '@sametnurel/game-server/ws';
26
+ import { SocketIOTransport } from '@sametnurel/game-server/socket-io';
27
+
28
+ const server = new GameServer({
29
+ transports: [new WebSocketTransport({ path: '/ws' }), new SocketIOTransport()],
30
+ port: 5200,
31
+ cors: { origin: ['https://my-game.example'] },
32
+ });
33
+
34
+ server.command<{ text: string }>('chat', {
35
+ parse(raw) {
36
+ const text = (raw as { text?: unknown } | null)?.text;
37
+ if (typeof text !== 'string' || text.length > 200) throw new PayloadError('text must be a short string');
38
+ return { text };
39
+ },
40
+ handle({ server, connection }, { text }) {
41
+ server.broadcast('lobby', 'chat', { from: connection.id, text });
42
+ },
43
+ });
44
+
45
+ server.onConnect((connection) => server.rooms.join('lobby', connection));
46
+
47
+ await server.start();
48
+ ```
49
+
50
+ Nothing is global: create as many servers as you like (handy in tests).
51
+
52
+ ## Transports
53
+
54
+ | Transport | Import | Wire format |
55
+ | --- | --- | --- |
56
+ | `WebSocketTransport` | `.../ws` | One JSON frame per message: `{ "event": string, "data"?: any }`, both directions |
57
+ | `SocketIOTransport` | `.../socket-io` | Socket.IO events: `emit(event, data)` |
58
+ | `MemoryTransport` | built in | `server.createVirtualClient()`: for bots and tests |
59
+
60
+ All transports share one HTTP server, rooms and commands; a room can mix clients
61
+ from different transports. Plain WebSocket clients get a ping every 30 s and are
62
+ dropped if they stop answering.
63
+
64
+ ### Bring your own HTTP server
65
+
66
+ Pass an existing server (e.g. Express) to serve a REST API on the same port:
67
+
68
+ ```ts
69
+ const app = express();
70
+ app.get('/health', (_req, res) => res.send('ok'));
71
+ const http = app.listen(5200);
72
+ const server = new GameServer({ httpServer: http, transports: [new WebSocketTransport()] });
73
+ await server.start();
74
+ ```
75
+
76
+ ## Commands
77
+
78
+ `server.command(event, commandOrFunction)` registers one handler per event.
79
+
80
+ - `parse(raw)` validates the payload and returns it typed. Throw to reject; the
81
+ client gets `error` `{ code: 'invalid_payload', event, message }` (message only
82
+ for `PayloadError`). Any validator works: `parse: (raw) => schema.parse(raw)`.
83
+ - `handle(context, payload)` runs the logic. `context` has `server`, `connection`, `event`.
84
+ - A throwing or rejecting handler never crashes the process: it is logged and the
85
+ client gets `{ code: 'internal_error', event }`.
86
+ - Unregistered events get `{ code: 'unknown_event', event }`.
87
+ - Messages from one client are handled **in order**, also when handlers are async.
88
+
89
+ Prefer classes? Extend `BaseCommand<TPayload>`.
90
+
91
+ ## Connections and rooms
92
+
93
+ `Connection` wraps the current socket and has `data` (your state), `identifier`
94
+ (a stable device or account id) and `rooms`. Use `connections.identify(conn, id)`
95
+ and `connections.rebind(existing, fresh)` to move a returning client onto its
96
+ new socket while keeping rooms and data.
97
+
98
+ `server.rooms.join/leave/leaveAll/members`, `server.broadcast(room, event, data, except?)`.
99
+ Disconnected clients leave all rooms automatically, then `onDisconnect` hooks run.
100
+
101
+ ## Games
102
+
103
+ ```ts
104
+ class Match extends Game<'SETUP' | 'PLAY'> {
105
+ start() { this.status = 'PLAY'; }
106
+ override update(tick: number, now: number) { /* called every tickIntervalMs */ }
107
+ override dispose() { /* clear timers */ }
108
+ }
109
+ const match = server.games.create((id) => new Match(id));
110
+ server.broadcast(match.roomId, 'started');
111
+ server.games.remove(match.id); // disposes and frees the id
112
+ ```
113
+
114
+ Game ids come from `IdGenerator` (default 1000-9999). Ids are freed on remove and
115
+ the generator throws when the range is full instead of hanging.
116
+
117
+ ## Sessions and reconnect
118
+
119
+ ```ts
120
+ const sessions = new SessionManager<{ gameId: number }>({
121
+ reconnectWindowMs: 30_000,
122
+ onExpire: (session) => forfeit(session.state?.gameId),
123
+ });
124
+
125
+ server.command<{ deviceId: string }>('hello', ({ connection }, { deviceId }) => {
126
+ const { session, resumed } = sessions.attach(deviceId, connection);
127
+ connection.identifier = deviceId;
128
+ if (resumed) connection.send('resume', session.state);
129
+ });
130
+ server.onDisconnect((connection) => sessions.detach(connection));
131
+ ```
132
+
133
+ ## Matchmaking, bots and turn timers
134
+
135
+ ```ts
136
+ const queue = new MatchQueue<Player>((p) => p.id); // groups of 2
137
+ const pair = queue.enqueue('ranked', player); // Player[] when a match forms
138
+ const lonely = queue.takeWaitingLongerThan('ranked', 15_000); // pair these with bots
139
+
140
+ const bot = server.createVirtualClient(); // a bot speaks the same protocol
141
+ bot.on('yourTurn', (state) => bot.send('move', decide(state)));
142
+
143
+ const timer = new TurnTimer();
144
+ timer.start(30_000, () => endTurn());
145
+ connection.send('turn', { deadline: timer.deadline });
146
+ ```
147
+
148
+ ## Logging
149
+
150
+ `ConsoleLogger` (level from `LOG_LEVEL`, default `info`) is used by default.
151
+ Pass `logger: silentLogger` or your own `{ error, warn, info, debug }`.
152
+
153
+ ## Migrating from 0.0.x
154
+
155
+ This package was published as `@sametnurel/socket-io-game-server` up to 0.0.8. 1.0 is a rewrite; see [CHANGELOG.md](CHANGELOG.md). In short: no singletons
156
+ (`GameManager.getInstance()` → `server.games`), commands receive a context
157
+ instead of a socket, transports are pluggable, and port/CORS are options.
158
+
159
+ ## License
160
+
161
+ ISC © Samet Nurel
@@ -0,0 +1,25 @@
1
+ import type { Connection } from './connection';
2
+ import type { GameServer } from './server';
3
+ export interface CommandContext<TData = unknown> {
4
+ server: GameServer<any, TData>;
5
+ connection: Connection<TData>;
6
+ event: string;
7
+ }
8
+ /**
9
+ * Handles one client event. `parse` validates the raw payload and returns it
10
+ * typed; throw to reject it. Plug in any
11
+ * validator, e.g. `parse: (raw) => schema.parse(raw)` with zod.
12
+ */
13
+ export interface Command<TPayload = unknown, TData = unknown> {
14
+ parse?(raw: unknown): TPayload;
15
+ handle(context: CommandContext<TData>, payload: TPayload): void | Promise<void>;
16
+ }
17
+ /** Class form of Command, for apps that prefer one class per command. */
18
+ export declare abstract class BaseCommand<TPayload = unknown, TData = unknown> implements Command<TPayload, TData> {
19
+ parse?(raw: unknown): TPayload;
20
+ abstract handle(context: CommandContext<TData>, payload: TPayload): void | Promise<void>;
21
+ }
22
+ /** Thrown by `parse` to reject a payload with a client-visible message. */
23
+ export declare class PayloadError extends Error {
24
+ constructor(message: string);
25
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PayloadError = exports.BaseCommand = void 0;
4
+ /** Class form of Command, for apps that prefer one class per command. */
5
+ class BaseCommand {
6
+ }
7
+ exports.BaseCommand = BaseCommand;
8
+ /** Thrown by `parse` to reject a payload with a client-visible message. */
9
+ class PayloadError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'PayloadError';
13
+ }
14
+ }
15
+ exports.PayloadError = PayloadError;
@@ -0,0 +1,24 @@
1
+ import { Connection } from './connection';
2
+ import type { ClientSocket } from './transport/types';
3
+ /** Tracks live connections by socket id and by app identifier. */
4
+ export declare class ConnectionManager<TData = unknown> {
5
+ private readonly bySocket;
6
+ private readonly byIdentifier;
7
+ add(socket: ClientSocket): Connection<TData>;
8
+ get(socketId: string): Connection<TData> | undefined;
9
+ getByIdentifier(identifier: string): Connection<TData> | undefined;
10
+ /**
11
+ * Associates a connection with an identifier. If another live connection
12
+ * already had it (same device opened twice), that one is returned so the app
13
+ * can decide; it is not closed here.
14
+ */
15
+ identify(conn: Connection<TData>, identifier: string): Connection<TData> | undefined;
16
+ /**
17
+ * Moves an existing Connection (rooms, data) onto a new socket and drops the
18
+ * temporary Connection created for that socket. Returns the surviving one.
19
+ */
20
+ rebind(existing: Connection<TData>, fresh: Connection<TData>): Connection<TData>;
21
+ remove(conn: Connection<TData>): void;
22
+ all(): IterableIterator<Connection<TData>>;
23
+ get size(): number;
24
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConnectionManager = void 0;
4
+ const connection_1 = require("./connection");
5
+ /** Tracks live connections by socket id and by app identifier. */
6
+ class ConnectionManager {
7
+ bySocket = new Map();
8
+ byIdentifier = new Map();
9
+ add(socket) {
10
+ const conn = new connection_1.Connection(socket);
11
+ this.bySocket.set(socket.id, conn);
12
+ return conn;
13
+ }
14
+ get(socketId) {
15
+ return this.bySocket.get(socketId);
16
+ }
17
+ getByIdentifier(identifier) {
18
+ return this.byIdentifier.get(identifier);
19
+ }
20
+ /**
21
+ * Associates a connection with an identifier. If another live connection
22
+ * already had it (same device opened twice), that one is returned so the app
23
+ * can decide; it is not closed here.
24
+ */
25
+ identify(conn, identifier) {
26
+ const previous = this.byIdentifier.get(identifier);
27
+ if (conn.identifier && conn.identifier !== identifier)
28
+ this.byIdentifier.delete(conn.identifier);
29
+ conn.identifier = identifier;
30
+ this.byIdentifier.set(identifier, conn);
31
+ return previous && previous !== conn ? previous : undefined;
32
+ }
33
+ /**
34
+ * Moves an existing Connection (rooms, data) onto a new socket and drops the
35
+ * temporary Connection created for that socket. Returns the surviving one.
36
+ */
37
+ rebind(existing, fresh) {
38
+ if (existing === fresh)
39
+ return existing;
40
+ this.bySocket.delete(existing.id);
41
+ this.bySocket.delete(fresh.id);
42
+ existing.setSocket(fresh.socket);
43
+ this.bySocket.set(existing.id, existing);
44
+ return existing;
45
+ }
46
+ remove(conn) {
47
+ if (this.bySocket.get(conn.id) === conn)
48
+ this.bySocket.delete(conn.id);
49
+ if (conn.identifier && this.byIdentifier.get(conn.identifier) === conn) {
50
+ this.byIdentifier.delete(conn.identifier);
51
+ }
52
+ }
53
+ all() {
54
+ return this.bySocket.values();
55
+ }
56
+ get size() {
57
+ return this.bySocket.size;
58
+ }
59
+ }
60
+ exports.ConnectionManager = ConnectionManager;
@@ -0,0 +1,22 @@
1
+ import type { ClientSocket } from './transport/types';
2
+ /**
3
+ * A client as the game sees it. Wraps the current socket; on reconnect the same
4
+ * Connection can be re-bound to a new socket so rooms and data survive.
5
+ */
6
+ export declare class Connection<TData = unknown> {
7
+ /** Free slot for app data, e.g. the player this client controls. */
8
+ data: TData | undefined;
9
+ /** Stable client identifier (device id, account id) once the app sets it. */
10
+ identifier: string | undefined;
11
+ readonly rooms: Set<string>;
12
+ private current;
13
+ constructor(socket: ClientSocket);
14
+ /** Id of the current socket. Changes when the connection is re-bound. */
15
+ get id(): string;
16
+ get transport(): string;
17
+ get socket(): ClientSocket;
18
+ send(event: string, data?: unknown): void;
19
+ close(): void;
20
+ /** @internal Used by ConnectionManager.rebind. */
21
+ setSocket(socket: ClientSocket): void;
22
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Connection = void 0;
4
+ /**
5
+ * A client as the game sees it. Wraps the current socket; on reconnect the same
6
+ * Connection can be re-bound to a new socket so rooms and data survive.
7
+ */
8
+ class Connection {
9
+ /** Free slot for app data, e.g. the player this client controls. */
10
+ data;
11
+ /** Stable client identifier (device id, account id) once the app sets it. */
12
+ identifier;
13
+ rooms = new Set();
14
+ current;
15
+ constructor(socket) {
16
+ this.current = socket;
17
+ }
18
+ /** Id of the current socket. Changes when the connection is re-bound. */
19
+ get id() {
20
+ return this.current.id;
21
+ }
22
+ get transport() {
23
+ return this.current.transport;
24
+ }
25
+ get socket() {
26
+ return this.current;
27
+ }
28
+ send(event, data) {
29
+ this.current.send(event, data);
30
+ }
31
+ close() {
32
+ this.current.close();
33
+ }
34
+ /** @internal Used by ConnectionManager.rebind. */
35
+ setSocket(socket) {
36
+ this.current = socket;
37
+ }
38
+ }
39
+ exports.Connection = Connection;
@@ -0,0 +1,19 @@
1
+ /** Events the server itself sends. Game events are plain strings chosen by the app. */
2
+ export declare const SystemEvents: {
3
+ /** Sent when a message is rejected or a handler fails. Payload: ErrorPayload. */
4
+ readonly Error: 'error';
5
+ };
6
+ export type SystemEvent = (typeof SystemEvents)[keyof typeof SystemEvents];
7
+ export declare const ErrorCodes: {
8
+ readonly UnknownEvent: 'unknown_event';
9
+ readonly InvalidPayload: 'invalid_payload';
10
+ readonly InternalError: 'internal_error';
11
+ readonly BadFrame: 'bad_frame';
12
+ };
13
+ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
14
+ export interface ErrorPayload {
15
+ code: ErrorCode;
16
+ /** The client event that caused the error, when known. */
17
+ event?: string;
18
+ message?: string;
19
+ }
package/dist/events.js ADDED
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ErrorCodes = exports.SystemEvents = void 0;
4
+ /** Events the server itself sends. Game events are plain strings chosen by the app. */
5
+ exports.SystemEvents = {
6
+ /** Sent when a message is rejected or a handler fails. Payload: ErrorPayload. */
7
+ Error: 'error',
8
+ };
9
+ exports.ErrorCodes = {
10
+ UnknownEvent: 'unknown_event',
11
+ InvalidPayload: 'invalid_payload',
12
+ InternalError: 'internal_error',
13
+ BadFrame: 'bad_frame',
14
+ };
@@ -0,0 +1,16 @@
1
+ import type { Game } from './game';
2
+ import { IdGenerator } from './ids';
3
+ /** Owns the live games: creates them with unique ids, ticks and removes them. */
4
+ export declare class GameManager<TGame extends Game<any>> {
5
+ private readonly ids;
6
+ private readonly games;
7
+ constructor(ids?: IdGenerator);
8
+ create(factory: (id: number) => TGame): TGame;
9
+ get(id: number): TGame | undefined;
10
+ all(): IterableIterator<TGame>;
11
+ /** Disposes the game and frees its id. */
12
+ remove(id: number): boolean;
13
+ /** Calls update() on every game that is not over. Errors are reported, not thrown. */
14
+ tick(tick: number, now: number, onError?: (game: TGame, error: unknown) => void): void;
15
+ get size(): number;
16
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GameManager = void 0;
4
+ const ids_1 = require("./ids");
5
+ /** Owns the live games: creates them with unique ids, ticks and removes them. */
6
+ class GameManager {
7
+ ids;
8
+ games = new Map();
9
+ constructor(ids = new ids_1.IdGenerator()) {
10
+ this.ids = ids;
11
+ }
12
+ create(factory) {
13
+ const id = this.ids.next();
14
+ try {
15
+ const game = factory(id);
16
+ this.games.set(id, game);
17
+ return game;
18
+ }
19
+ catch (error) {
20
+ this.ids.release(id);
21
+ throw error;
22
+ }
23
+ }
24
+ get(id) {
25
+ return this.games.get(id);
26
+ }
27
+ all() {
28
+ return this.games.values();
29
+ }
30
+ /** Disposes the game and frees its id. */
31
+ remove(id) {
32
+ const game = this.games.get(id);
33
+ if (!game)
34
+ return false;
35
+ this.games.delete(id);
36
+ this.ids.release(id);
37
+ game.dispose();
38
+ return true;
39
+ }
40
+ /** Calls update() on every game that is not over. Errors are reported, not thrown. */
41
+ tick(tick, now, onError) {
42
+ for (const game of this.games.values()) {
43
+ if (game.isOver())
44
+ continue;
45
+ try {
46
+ game.update(tick, now);
47
+ }
48
+ catch (error) {
49
+ onError?.(game, error);
50
+ }
51
+ }
52
+ }
53
+ get size() {
54
+ return this.games.size;
55
+ }
56
+ }
57
+ exports.GameManager = GameManager;
package/dist/game.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type BaseGameStatus = 'NOT_STARTED' | 'STARTED' | 'OVER';
2
+ export interface GameOverParams {
3
+ /** Why the game ended, e.g. 'win', 'timeout', 'opponent_left'. */
4
+ reason?: string;
5
+ winnerId?: number;
6
+ [key: string]: unknown;
7
+ }
8
+ /**
9
+ * Base class for one match. Subclasses add players and rules.
10
+ * `TStatus` lets a game add its own phases (e.g. 'SETUP' | 'BATTLE').
11
+ */
12
+ export declare abstract class Game<TStatus extends string = BaseGameStatus> {
13
+ readonly id: number;
14
+ protected status: TStatus | BaseGameStatus;
15
+ protected overParams: GameOverParams | undefined;
16
+ constructor(id: number);
17
+ /** Room name for broadcasting to this game's clients. */
18
+ get roomId(): string;
19
+ getStatus(): TStatus | BaseGameStatus;
20
+ isOver(): boolean;
21
+ getOverParams(): GameOverParams | undefined;
22
+ /** Called on every server tick while the game is not over. Override for timers. */
23
+ update(_tick: number, _now: number): void;
24
+ /** Marks the game as over. Override to notify players, then call super. */
25
+ end(params?: GameOverParams): void;
26
+ /** Called when the game is removed from its manager: clear timers here. */
27
+ dispose(): void;
28
+ abstract start(): void;
29
+ }
package/dist/game.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Game = void 0;
4
+ /**
5
+ * Base class for one match. Subclasses add players and rules.
6
+ * `TStatus` lets a game add its own phases (e.g. 'SETUP' | 'BATTLE').
7
+ */
8
+ class Game {
9
+ id;
10
+ status = 'NOT_STARTED';
11
+ overParams;
12
+ constructor(id) {
13
+ this.id = id;
14
+ }
15
+ /** Room name for broadcasting to this game's clients. */
16
+ get roomId() {
17
+ return `game:${this.id}`;
18
+ }
19
+ getStatus() {
20
+ return this.status;
21
+ }
22
+ isOver() {
23
+ return this.status === 'OVER';
24
+ }
25
+ getOverParams() {
26
+ return this.overParams;
27
+ }
28
+ /** Called on every server tick while the game is not over. Override for timers. */
29
+ update(_tick, _now) { }
30
+ /** Marks the game as over. Override to notify players, then call super. */
31
+ end(params = {}) {
32
+ this.status = 'OVER';
33
+ this.overParams = params;
34
+ }
35
+ /** Called when the game is removed from its manager: clear timers here. */
36
+ dispose() { }
37
+ }
38
+ exports.Game = Game;
package/dist/ids.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface IdGeneratorOptions {
2
+ /** Smallest id (inclusive). Default 1000. */
3
+ min?: number;
4
+ /** Largest id (inclusive). Default 9999. */
5
+ max?: number;
6
+ /** Random source in [0, 1). Default Math.random. */
7
+ random?: () => number;
8
+ }
9
+ /**
10
+ * Hands out short, unique, human-friendly ids from a fixed range.
11
+ * Ids must be released when no longer used; when the range is full, next() throws
12
+ * instead of looping forever.
13
+ */
14
+ export declare class IdGenerator {
15
+ private readonly used;
16
+ private readonly min;
17
+ private readonly size;
18
+ private readonly random;
19
+ constructor(options?: IdGeneratorOptions);
20
+ next(): number;
21
+ release(id: number): void;
22
+ isUsed(id: number): boolean;
23
+ get inUse(): number;
24
+ }
package/dist/ids.js ADDED
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdGenerator = void 0;
4
+ /**
5
+ * Hands out short, unique, human-friendly ids from a fixed range.
6
+ * Ids must be released when no longer used; when the range is full, next() throws
7
+ * instead of looping forever.
8
+ */
9
+ class IdGenerator {
10
+ used = new Set();
11
+ min;
12
+ size;
13
+ random;
14
+ constructor(options = {}) {
15
+ this.min = options.min ?? 1000;
16
+ const max = options.max ?? 9999;
17
+ if (max < this.min)
18
+ throw new RangeError('IdGenerator: max must be >= min');
19
+ this.size = max - this.min + 1;
20
+ this.random = options.random ?? Math.random;
21
+ }
22
+ next() {
23
+ if (this.used.size >= this.size) {
24
+ throw new Error(`IdGenerator: all ${this.size} ids are in use`);
25
+ }
26
+ // Random start, then linear probe: always terminates.
27
+ const start = Math.floor(this.random() * this.size);
28
+ for (let i = 0; i < this.size; i++) {
29
+ const id = this.min + ((start + i) % this.size);
30
+ if (!this.used.has(id)) {
31
+ this.used.add(id);
32
+ return id;
33
+ }
34
+ }
35
+ throw new Error('IdGenerator: unreachable');
36
+ }
37
+ release(id) {
38
+ this.used.delete(id);
39
+ }
40
+ isUsed(id) {
41
+ return this.used.has(id);
42
+ }
43
+ get inUse() {
44
+ return this.used.size;
45
+ }
46
+ }
47
+ exports.IdGenerator = IdGenerator;
@@ -0,0 +1,16 @@
1
+ export { GameServer, type GameServerOptions } from './server';
2
+ export { BaseCommand, PayloadError, type Command, type CommandContext } from './command';
3
+ export { Connection } from './connection';
4
+ export { ConnectionManager } from './connection-manager';
5
+ export { RoomRegistry } from './rooms';
6
+ export { Game, type BaseGameStatus, type GameOverParams } from './game';
7
+ export { GameManager } from './game-manager';
8
+ export { Player } from './player';
9
+ export { MatchQueue, type QueueEntry } from './matchmaking';
10
+ export { SessionManager, type Session, type SessionManagerOptions } from './sessions';
11
+ export { TurnTimer } from './turn-timer';
12
+ export { IdGenerator, type IdGeneratorOptions } from './ids';
13
+ export { ConsoleLogger, silentLogger, type Logger, type LogLevel } from './logger';
14
+ export { SystemEvents, ErrorCodes, type SystemEvent, type ErrorCode, type ErrorPayload } from './events';
15
+ export { MemoryTransport, MemoryClient } from './transport/memory';
16
+ export type { Transport, TransportContext, TransportHandlers, ClientSocket, CorsOptions } from './transport/types';