@table-games-engine/nakama-adapter 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import type { ActionHandler, EngineAdapter, GameId, GameManifest, GamePlugin, MatchId, MatchState, StateBasedActionChecker, StateTransition, WinConditionChecker } from '@table-games-engine/core';
2
+ import type { SnapshotPolicy } from '@table-games-engine/persistence';
3
+ import type { GameRegistry } from './registry.js';
4
+ import type { IdempotencyCache } from './idempotency.js';
5
+ import type { NkMatchHandler, NkPresence } from './types.js';
6
+ import { type TimeoutOptions } from './timeouts.js';
7
+ import { type NakamaPlatformPlugin } from './plugins.js';
8
+ export type MatchStatus = 'lobby' | 'active' | 'paused' | 'finished';
9
+ /**
10
+ * Internal envelope Nakama threads through the loop. Every field is plain
11
+ * JSON-serialisable (the `manifest` and `handlers` references are recovered
12
+ * via the registry on every loop, so storing them here is a convenience for
13
+ * tests; in production they are recovered from the registry on each tick).
14
+ */
15
+ export interface NkMatchInternalState {
16
+ readonly matchId: MatchId;
17
+ readonly gameId: GameId;
18
+ engineState: MatchState;
19
+ readonly manifest: GameManifest;
20
+ readonly handlers: Readonly<Record<string, ActionHandler>>;
21
+ readonly stateBasedActions?: StateBasedActionChecker;
22
+ readonly winChecker?: WinConditionChecker;
23
+ readonly legalMoves?: GamePlugin['legalMoves'];
24
+ presences: Record<string, NkPresence>;
25
+ status: MatchStatus;
26
+ /** Wall-clock ms when the current priority holder took priority. Used by `checkTimeouts`. */
27
+ priorityHeldSince: number | null;
28
+ /** Last priority holder we observed; used to detect transitions. */
29
+ lastPriorityHolder: string | null;
30
+ }
31
+ export interface CreateMatchHandlerOptions {
32
+ /** Source of truth for `gameId -> manifest/handlers/setup`. */
33
+ readonly registry: GameRegistry;
34
+ /** Per-match idempotency cache. */
35
+ readonly idempotencyCache: IdempotencyCache;
36
+ /**
37
+ * Optional default match-id factory. If `params.matchId` is not supplied to
38
+ * `matchInit`, the handler uses this. Defaults to a deterministic
39
+ * `match-<now>` (the harness controls `now`, so tests stay deterministic).
40
+ */
41
+ readonly matchIdFactory?: (now: number) => string;
42
+ /**
43
+ * Optional clock for timeout calculations + idempotency stamping.
44
+ * Defaults to `Date.now`.
45
+ */
46
+ readonly now?: () => number;
47
+ /** Optional timeout configuration; if omitted, timeouts are disabled. */
48
+ readonly timeout?: TimeoutOptions;
49
+ /** Optional override of the default tick rate (`1`). */
50
+ readonly tickRate?: number;
51
+ /**
52
+ * Optional persistence backend. When supplied the handler appends every
53
+ * engine transition's events and snapshots per `policy`.
54
+ */
55
+ readonly adapter?: EngineAdapter;
56
+ /**
57
+ * Optional snapshot cadence; ignored when `adapter` is undefined.
58
+ * Defaults to `everyNTransitions(50)` per the M2 plan.
59
+ */
60
+ readonly policy?: SnapshotPolicy;
61
+ /**
62
+ * Optional ownership lease holder. When present and `adapter` is also
63
+ * present the handler acquires a lease at `matchInit` and releases at
64
+ * `matchTerminate`.
65
+ */
66
+ readonly ownerNode?: string;
67
+ /** Lease duration in seconds. Defaults to 60. */
68
+ readonly leaseSeconds?: number;
69
+ /**
70
+ * Optional platform plugins. Each lifecycle hook fires fire-and-forget;
71
+ * plugin failures never crash the match.
72
+ */
73
+ readonly plugins?: ReadonlyArray<NakamaPlatformPlugin>;
74
+ }
75
+ export declare function createMatchHandler(options: CreateMatchHandlerOptions): NkMatchHandler<NkMatchInternalState>;
76
+ export type { StateTransition };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @fileoverview Persistence bridge for the Nakama match handler.
3
+ *
4
+ * The Nakama match loop is a synchronous tick loop driven by Nakama's runtime.
5
+ * Adapter writes are async (`Promise<void>`). The bridge exists to:
6
+ *
7
+ * 1. Hide the (manifest, handlers, adapter, snapshot policy) bookkeeping
8
+ * so the match handler does not have to repeat it for every transition.
9
+ * 2. Translate `engine` results into `(events appended, snapshot maybe)`
10
+ * pairs.
11
+ *
12
+ * We deliberately do NOT use `@table-games-engine/persistence`'s
13
+ * `createPersistedEngine` here: that wrapper owns its own `Engine`
14
+ * synchronously across calls, while the Nakama match handler rebuilds an
15
+ * `Engine` per loop tick from a serialised state envelope (`engineState`).
16
+ * The bridge mirrors `createPersistedEngine`'s persistence-side state
17
+ * machine (transition counters, snapshot cadence) but takes the post-call
18
+ * `MatchState` from the caller rather than holding an engine instance.
19
+ *
20
+ * # Failure semantics
21
+ *
22
+ * If `appendEvents` or `persistSnapshot` rejects, the bridge re-throws so
23
+ * the match handler can decide what to do (today: bubble up to the
24
+ * `matchLoop` caller via `nk.logger.error` and let Nakama observe the
25
+ * error). The in-memory engine state has already been advanced; rolling it
26
+ * back is the host runtime's responsibility (e.g. terminate + replay).
27
+ */
28
+ import type { EngineAdapter, MatchId, MatchState, StateTransition } from '@table-games-engine/core';
29
+ import { type SnapshotPolicy } from '@table-games-engine/persistence';
30
+ /**
31
+ * Persistence-side state machine that mirrors the `createPersistedEngine`
32
+ * counters but without owning an `Engine`.
33
+ *
34
+ * Construct via {@link createPersistenceBridge}, call
35
+ * {@link PersistenceBridge.persistInitialSnapshot} once at `matchInit`, and
36
+ * call {@link PersistenceBridge.persistTransition} after every successful
37
+ * engine transition.
38
+ */
39
+ export interface PersistenceBridge {
40
+ /** The match this bridge is bound to. */
41
+ readonly matchId: MatchId;
42
+ /** The adapter the bridge writes against. */
43
+ readonly adapter: EngineAdapter;
44
+ /**
45
+ * Persist the initial state as snapshot v0 (or whatever
46
+ * `state.version` is). Idempotent against the adapter — the
47
+ * underlying adapter is required to tolerate repeated snapshots at
48
+ * the same `(matchId, version)` when the checksum matches.
49
+ */
50
+ persistInitialSnapshot(state: MatchState): Promise<void>;
51
+ /**
52
+ * Append a single transition's events and conditionally snapshot per
53
+ * the configured policy. The post-transition `MatchState` is required
54
+ * because the snapshot policy operates on it.
55
+ *
56
+ * Re-throws adapter errors verbatim.
57
+ */
58
+ persistTransition(transition: StateTransition, postState: MatchState): Promise<void>;
59
+ /**
60
+ * Force a snapshot at the current state, bypassing the policy. Useful
61
+ * for "snapshot before terminate" or test determinism.
62
+ */
63
+ forceSnapshot(state: MatchState): Promise<void>;
64
+ }
65
+ export interface CreatePersistenceBridgeArgs {
66
+ readonly adapter: EngineAdapter;
67
+ readonly matchId: MatchId;
68
+ /** Snapshot cadence; defaults to `everyNTransitions(50)` per M2 plan. */
69
+ readonly policy?: SnapshotPolicy;
70
+ }
71
+ /**
72
+ * Build a {@link PersistenceBridge} bound to `(adapter, matchId, policy)`.
73
+ *
74
+ * The bridge is stateful only in the persistence-side counters
75
+ * (`transitionsSince`, `lastSnapshotTick`, `lastVersionPersisted`) — the
76
+ * engine state itself is owned by the caller (the match handler).
77
+ */
78
+ export declare function createPersistenceBridge(args: CreatePersistenceBridgeArgs): PersistenceBridge;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @fileoverview Platform plugin contract for the Nakama match handler.
3
+ *
4
+ * Platform plugins are the side-channel hook surface for things that should
5
+ * NOT live inside the deterministic engine: analytics, leaderboards, audit
6
+ * logging, push notifications, anti-cheat sampling, achievements, etc.
7
+ *
8
+ * # Invocation contract
9
+ *
10
+ * - Plugins are invoked **fire-and-forget** from inside the match handler.
11
+ * - The match handler does NOT await plugin promises before returning
12
+ * control to Nakama's runtime. A slow plugin must never stall the
13
+ * game loop.
14
+ * - Plugin rejections are **caught and logged** via `nk.logger.error`.
15
+ * A failing plugin is never propagated up — games continue.
16
+ * - Multiple plugins can be registered; they are invoked in registration
17
+ * order, but their promises run in parallel (no sequencing).
18
+ *
19
+ * # Hook coverage
20
+ *
21
+ * Each callback is optional. Plugins implement only the hooks they care
22
+ * about. Coverage:
23
+ *
24
+ * - `onMatchCreated` fires inside `matchInit`.
25
+ * - `onMatchStarted` fires when status transitions `lobby -> active`.
26
+ * - `onPlayerJoined` fires for each presence in `matchJoin`.
27
+ * - `onPlayerLeft` fires for each presence in `matchLeave`.
28
+ * - `onPlayerAction` fires after each successful player-driven
29
+ * transition (only for player actions, not for
30
+ * stack resolution / triggers).
31
+ * - `onStateTransition` fires after EVERY successful engine transition
32
+ * (player action, stack drain, triggers, timeouts).
33
+ * - `onMatchEnded` fires when the engine reports `ended: true`.
34
+ */
35
+ import type { ActionInput, GameId, GameResult, MatchId, MatchState, StateTransition } from '@table-games-engine/core';
36
+ import type { NkLogger, NkPresence } from './types.js';
37
+ /**
38
+ * Common context every plugin callback receives. Pure data — plugins
39
+ * can capture this freely without retaining live engine references.
40
+ */
41
+ export interface PluginContext {
42
+ readonly matchId: MatchId;
43
+ readonly gameId: GameId;
44
+ /** Current match state. Plugins MUST treat it as readonly. */
45
+ readonly state: MatchState;
46
+ /** Wall-clock ms (host clock or harness clock — plugin should not care). */
47
+ readonly now: number;
48
+ }
49
+ /**
50
+ * Optional lifecycle callbacks. Each returns a Promise; rejections are
51
+ * logged and swallowed by the match handler.
52
+ */
53
+ export interface NakamaPlatformPlugin {
54
+ /** Optional human-friendly name surfaced in error logs. */
55
+ readonly name?: string;
56
+ onMatchCreated?(ctx: PluginContext): Promise<void>;
57
+ onMatchStarted?(ctx: PluginContext): Promise<void>;
58
+ onMatchEnded?(ctx: PluginContext, result: GameResult): Promise<void>;
59
+ onPlayerJoined?(ctx: PluginContext, presence: NkPresence): Promise<void>;
60
+ onPlayerLeft?(ctx: PluginContext, presence: NkPresence): Promise<void>;
61
+ onPlayerAction?(ctx: PluginContext, input: ActionInput, transition: StateTransition): Promise<void>;
62
+ onStateTransition?(ctx: PluginContext, transition: StateTransition): Promise<void>;
63
+ }
64
+ /**
65
+ * Invoke a plugin hook fire-and-forget. Errors are caught and logged via
66
+ * `logger.error`; never propagated to the match loop.
67
+ *
68
+ * `hookName` and `pluginName` are interpolated into the error log so
69
+ * operators can correlate failures.
70
+ */
71
+ export declare function invokePluginHook(logger: NkLogger, pluginName: string, hookName: string, fn: () => Promise<void> | void): void;
72
+ /**
73
+ * Iterate plugins and invoke a single hook on each, fire-and-forget.
74
+ * Plugins that do not implement the hook are skipped.
75
+ */
76
+ export declare function dispatchPluginHook<H extends keyof NakamaPlatformPlugin>(plugins: ReadonlyArray<NakamaPlatformPlugin> | undefined, logger: NkLogger, hookName: H, invoke: (plugin: NakamaPlatformPlugin) => Promise<void> | void): void;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @fileoverview GamePlugin → GameRegistry entry. One path for every game,
3
+ * including generated multi-game init bundles.
4
+ */
5
+ import { type GamePlugin } from '@table-games-engine/core';
6
+ import type { GameRegistry, RegisteredGame } from './registry.js';
7
+ export declare function registerPlugin(registry: GameRegistry, plugin: GamePlugin): RegisteredGame;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview In-process registry mapping `GameId` to its manifest, action
3
+ * handlers, optional win-condition checker, and setup function.
4
+ *
5
+ * NOTE: `@table-games-engine/core` already exposes a `GameRegistry` *interface*
6
+ * for plugin-shape registration. This adapter ships its own concrete
7
+ * registry tuned for the Nakama match handler — it stores the precise
8
+ * fields `matchInit` will need, keyed by raw `gameId` string for cheap
9
+ * lookup. We deliberately re-use the term "GameRegistry" but in this
10
+ * package's namespace.
11
+ */
12
+ import type { ActionHandler, ActionTypeId, GameId, GameManifest, GamePlugin, JsonObject, MatchId, MatchState, PlayerId, StateBasedActionChecker, WinConditionChecker } from '@table-games-engine/core';
13
+ /**
14
+ * Setup callback that builds the initial `MatchState` for a new match.
15
+ *
16
+ * Implementations are pure (no I/O); they may consult `ctx` for things like
17
+ * a deterministic clock or RNG seed and `config` for game-instance options.
18
+ */
19
+ export type GameSetup = (matchId: MatchId, playerIds: ReadonlyArray<PlayerId>, config: JsonObject, ctx: {
20
+ readonly now: number;
21
+ }) => MatchState;
22
+ /**
23
+ * Arguments accepted by `GameRegistry.register`.
24
+ */
25
+ export interface RegisterArgs {
26
+ readonly gameId: GameId;
27
+ readonly manifest: GameManifest;
28
+ readonly handlers: Readonly<Record<ActionTypeId, ActionHandler>>;
29
+ readonly setup: GameSetup;
30
+ readonly winChecker?: WinConditionChecker;
31
+ readonly stateBasedActions?: StateBasedActionChecker;
32
+ readonly legalMoves?: GamePlugin['legalMoves'];
33
+ }
34
+ /**
35
+ * The shape returned from `resolve` — a frozen view of what `register` was
36
+ * called with. Mutations on the returned object do not affect the registry.
37
+ */
38
+ export interface RegisteredGame {
39
+ readonly gameId: GameId;
40
+ readonly manifest: GameManifest;
41
+ readonly handlers: Readonly<Record<ActionTypeId, ActionHandler>>;
42
+ readonly setup: GameSetup;
43
+ readonly winChecker?: WinConditionChecker;
44
+ readonly stateBasedActions?: StateBasedActionChecker;
45
+ readonly legalMoves?: GamePlugin['legalMoves'];
46
+ }
47
+ /**
48
+ * Error thrown when registration is rejected (e.g. duplicate `gameId`).
49
+ *
50
+ * We use a plain `Error` subclass — the registry is a synchronous
51
+ * developer-facing surface and throwing is the most ergonomic signal.
52
+ */
53
+ export declare class RegistryError extends Error {
54
+ readonly code: 'duplicate' | 'not-found';
55
+ constructor(code: 'duplicate' | 'not-found', message: string);
56
+ }
57
+ /**
58
+ * In-process map-backed game registry. Synchronous; not thread-safe — but
59
+ * Node's match handler runs single-threaded per match, so callers wire
60
+ * registration once at module init and resolve at `matchInit`.
61
+ */
62
+ export declare class GameRegistry {
63
+ private readonly entries;
64
+ constructor();
65
+ /**
66
+ * Register a game. Throws `RegistryError('duplicate')` if `gameId` is
67
+ * already registered.
68
+ */
69
+ register(args: RegisterArgs): RegisteredGame;
70
+ /**
71
+ * Look up a registered game. Returns `null` (not undefined) when not
72
+ * found, to make call sites read more naturally.
73
+ */
74
+ resolve(gameId: GameId): RegisteredGame | null;
75
+ /**
76
+ * Remove a game from the registry. Returns `true` if a game was actually
77
+ * removed; `false` if `gameId` was not registered.
78
+ */
79
+ unregister(gameId: GameId): boolean;
80
+ /** Number of registered games. Mostly useful in tests. */
81
+ get size(): number;
82
+ /** List of registered game IDs (snapshot copy). */
83
+ list(): GameId[];
84
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @fileoverview JSON wire-format envelopes for the Nakama adapter.
3
+ *
4
+ * The wire format is a strict, discriminated-union JSON envelope keyed by
5
+ * `kind`. The adapter never throws on bad input: `parseInbound` always
6
+ * returns a value (using a special `'malformed'` envelope to surface errors
7
+ * uniformly).
8
+ *
9
+ * Inbound envelopes (client -> server):
10
+ * - 'action' a normal action with payload + targets
11
+ * - 'pass' pass priority
12
+ * - 'concede' forfeit the match
13
+ * - 'choose-targets' fill in targets for a pending frame
14
+ * - 'order-triggers' resolve simultaneous-trigger ordering
15
+ * - 'admin-pause' control plane: pause the match
16
+ * - 'admin-resume' control plane: resume the match
17
+ *
18
+ * Outbound envelopes (server -> client):
19
+ * - 'state' full filtered `PlayerView` snapshot
20
+ * - 'transition' incremental `StateTransition` delta
21
+ * - 'ack' per-action acknowledgement (carries error if any)
22
+ * - 'broadcast' broadcast event (e.g. PlayerJoined)
23
+ * - 'match-ended' terminal `GameResult` payload
24
+ *
25
+ * Note: `clientId` and `stateVersion` are *only* attached to inbound action
26
+ * envelopes (they're the engine's idempotency + staleness keys). The other
27
+ * inbound kinds carry only the data the engine needs.
28
+ */
29
+ import type { ActionInputAction, ActionInputChooseTargets, ActionInputOrderTriggers, GameResult, LegalMove, StateTransition } from '@table-games-engine/core';
30
+ import type { PlayerView } from '@table-games-engine/core';
31
+ /**
32
+ * Action envelope — the workhorse client message. Carries the full
33
+ * `ActionInputAction` payload plus the engine's idempotency key
34
+ * (`clientId`) and the client's last-seen `stateVersion`.
35
+ */
36
+ export interface InboundAction {
37
+ readonly kind: 'action';
38
+ readonly clientId: string;
39
+ readonly stateVersion: number;
40
+ readonly action: ActionInputAction;
41
+ }
42
+ /** Pass priority. */
43
+ export interface InboundPass {
44
+ readonly kind: 'pass';
45
+ readonly clientId: string;
46
+ readonly stateVersion: number;
47
+ }
48
+ /** Forfeit the match. */
49
+ export interface InboundConcede {
50
+ readonly kind: 'concede';
51
+ readonly clientId: string;
52
+ readonly stateVersion: number;
53
+ }
54
+ /** Fill in targets for a pending frame. */
55
+ export interface InboundChooseTargets {
56
+ readonly kind: 'choose-targets';
57
+ readonly clientId: string;
58
+ readonly stateVersion: number;
59
+ readonly action: ActionInputChooseTargets;
60
+ }
61
+ /** Order simultaneous triggers (APNAP). */
62
+ export interface InboundOrderTriggers {
63
+ readonly kind: 'order-triggers';
64
+ readonly clientId: string;
65
+ readonly stateVersion: number;
66
+ readonly action: ActionInputOrderTriggers;
67
+ }
68
+ /** Admin: pause the match. */
69
+ export interface InboundAdminPause {
70
+ readonly kind: 'admin-pause';
71
+ readonly clientId: string;
72
+ readonly reason?: string;
73
+ }
74
+ /** Admin: resume the match. */
75
+ export interface InboundAdminResume {
76
+ readonly kind: 'admin-resume';
77
+ readonly clientId: string;
78
+ }
79
+ /**
80
+ * Sentinel returned by `parseInbound` when the input could not be parsed
81
+ * into a valid envelope. Carries a structured reason so the dispatcher
82
+ * can surface a typed `ack` to the client.
83
+ */
84
+ export interface InboundMalformed {
85
+ readonly kind: 'malformed';
86
+ readonly reason: 'invalid-json' | 'not-object' | 'missing-kind' | 'unknown-kind' | 'invalid-fields';
87
+ readonly detail?: string;
88
+ }
89
+ export type InboundEnvelope = InboundAction | InboundPass | InboundConcede | InboundChooseTargets | InboundOrderTriggers | InboundAdminPause | InboundAdminResume;
90
+ /** Full per-player filtered snapshot. */
91
+ export interface OutboundState {
92
+ readonly kind: 'state';
93
+ readonly view: PlayerView;
94
+ readonly version: number;
95
+ readonly legalMoves?: ReadonlyArray<LegalMove>;
96
+ }
97
+ /** Incremental state delta. */
98
+ export interface OutboundTransition {
99
+ readonly kind: 'transition';
100
+ readonly delta: StateTransition;
101
+ }
102
+ /** Per-action acknowledgement; idempotency cache replays this verbatim. */
103
+ export interface OutboundAck {
104
+ readonly kind: 'ack';
105
+ readonly clientId: string;
106
+ readonly ok: boolean;
107
+ readonly error?: {
108
+ code: string;
109
+ message: string;
110
+ };
111
+ }
112
+ /** Broadcast event (e.g. PlayerJoined). */
113
+ export interface OutboundBroadcast {
114
+ readonly kind: 'broadcast';
115
+ readonly event: {
116
+ type: string;
117
+ [key: string]: unknown;
118
+ };
119
+ }
120
+ /** Terminal match-ended envelope. */
121
+ export interface OutboundMatchEnded {
122
+ readonly kind: 'match-ended';
123
+ readonly result: GameResult;
124
+ }
125
+ export type OutboundEnvelope = OutboundState | OutboundTransition | OutboundAck | OutboundBroadcast | OutboundMatchEnded;
126
+ /**
127
+ * Parse a JSON string into an `InboundEnvelope`. Never throws; surfaces
128
+ * parse / shape errors as a typed `InboundMalformed` value.
129
+ */
130
+ export declare function parseInbound(json: string): InboundEnvelope | InboundMalformed;
131
+ /**
132
+ * Serialize an outbound envelope to its JSON wire form. Pure function;
133
+ * never throws (envelopes are typed and assumed valid).
134
+ */
135
+ export declare function serializeOutbound(env: OutboundEnvelope): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @fileoverview Action timeout detector.
3
+ *
4
+ * If the priority holder has held priority for longer than `timeoutMs` (as
5
+ * measured by an injected `now` clock), `checkTimeouts` produces a synthetic
6
+ * `timeout` `ActionInput` the match handler can feed straight into
7
+ * `engine.applyAction`. The engine treats `timeout` like a concede — the
8
+ * player is eliminated and turn order advances.
9
+ *
10
+ * Pure: no I/O, no clock access, no global state. The match-handler's
11
+ * matchLoop owns "when did the holder last take priority" tracking and
12
+ * passes it in via `priorityHeldSince`.
13
+ */
14
+ import type { ActionInput, MatchState } from '@table-games-engine/core';
15
+ export interface TimeoutOptions {
16
+ /** Window in milliseconds before a holder times out. */
17
+ readonly timeoutMs: number;
18
+ }
19
+ /**
20
+ * Decide whether the current priority holder should be timed out.
21
+ *
22
+ * Returns `null` when:
23
+ * - there is no priority holder (system is in an automatic phase),
24
+ * - the holder has not yet held priority for `timeoutMs`,
25
+ * - `priorityHeldSince` is `null` (we haven't observed a holder yet).
26
+ *
27
+ * Returns a synthetic `ActionInput` of type `timeout` otherwise. The
28
+ * `clientId` is derived from the current state version so retries hit the
29
+ * same idempotency cache key, and the `stateVersion` is the engine's
30
+ * current version (the engine will reject stale inputs naturally if any
31
+ * other action squeezed through first).
32
+ */
33
+ export declare function checkTimeouts(state: MatchState, now: number, timeoutMs: number, priorityHeldSince: number | null): ActionInput | null;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @fileoverview Minimal `nkruntime`-shaped types for the Nakama adapter.
3
+ *
4
+ * The real `nkruntime` types ship with the Nakama TypeScript runtime, but
5
+ * this package builds standalone — we only need the shapes we actually call,
6
+ * and we want unit tests to exercise the adapter without a Nakama install.
7
+ *
8
+ * Anything in this file is intentionally a structural subset of the real API.
9
+ * If we later depend on the real `nkruntime`, swap these out for re-exports.
10
+ */
11
+ /**
12
+ * Subset of `nkruntime.Logger`.
13
+ *
14
+ * Nakama exposes additional levels (`debug`, `fatal`); we only need
15
+ * info/warn/error for the adapter and harness today.
16
+ */
17
+ export interface NkLogger {
18
+ info(format: string, ...args: unknown[]): void;
19
+ warn(format: string, ...args: unknown[]): void;
20
+ error(format: string, ...args: unknown[]): void;
21
+ }
22
+ /**
23
+ * Subset of `nkruntime.Presence` — identifies a connected player session.
24
+ */
25
+ export interface NkPresence {
26
+ /** Stable user identifier (Nakama user_id). */
27
+ readonly userId: string;
28
+ /** Per-connection session identifier. */
29
+ readonly sessionId: string;
30
+ /** Display name; optional. */
31
+ readonly username?: string;
32
+ /** Status payload supplied at connect time. */
33
+ readonly status?: string;
34
+ /** Node hosting this presence (for cluster routing). Optional. */
35
+ readonly node?: string;
36
+ }
37
+ /**
38
+ * Subset of `nkruntime.MatchData` — a single inbound message in the loop.
39
+ */
40
+ export interface NkMatchData {
41
+ /** Nakama opcode used to multiplex message kinds. */
42
+ readonly opCode: number;
43
+ /** Raw bytes (UTF-8 JSON in this adapter's wire format). */
44
+ readonly data: Uint8Array;
45
+ /** The presence that sent the message. */
46
+ readonly sender: NkPresence;
47
+ /** Whether the message is reliable. */
48
+ readonly reliable?: boolean;
49
+ }
50
+ /**
51
+ * Subset of `nkruntime.MatchDispatcher` used by the adapter to broadcast
52
+ * messages and request a match termination.
53
+ */
54
+ export interface NkMatchDispatcher {
55
+ /**
56
+ * Send a message to one or more presences. If `presences` is undefined,
57
+ * Nakama broadcasts to every joined presence.
58
+ */
59
+ broadcastMessage(opCode: number, data: string | Uint8Array | null, presences?: NkPresence[] | null, sender?: NkPresence | null, reliable?: boolean): void;
60
+ /**
61
+ * Optional: request the match terminate after `graceSeconds`.
62
+ */
63
+ matchKick?(presences: NkPresence[]): void;
64
+ }
65
+ /**
66
+ * Generic match-state container Nakama threads through the loop.
67
+ * The adapter stores its `MatchHandlerState` here.
68
+ */
69
+ export interface NkMatchState<T = unknown> {
70
+ readonly state: T;
71
+ }
72
+ /**
73
+ * Subset of the Nakama `MatchHandler<T>` shape. Phase A only declares this
74
+ * interface so callers can name the type — we wire the actual handler in
75
+ * Phase B.
76
+ */
77
+ export interface NkMatchHandler<T = unknown> {
78
+ matchInit(ctx: unknown, logger: NkLogger, nk: unknown, params: {
79
+ [key: string]: string;
80
+ }): {
81
+ state: T;
82
+ tickRate: number;
83
+ label: string;
84
+ };
85
+ matchJoinAttempt(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, presence: NkPresence, metadata: {
86
+ [key: string]: unknown;
87
+ }): {
88
+ state: T;
89
+ accept: boolean;
90
+ rejectMessage?: string;
91
+ } | null;
92
+ matchJoin(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, presences: NkPresence[]): {
93
+ state: T;
94
+ } | null;
95
+ matchLeave(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, presences: NkPresence[]): {
96
+ state: T;
97
+ } | null;
98
+ matchLoop(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, messages: NkMatchData[]): {
99
+ state: T;
100
+ } | null;
101
+ matchTerminate(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, graceSeconds: number): {
102
+ state: T;
103
+ } | null;
104
+ matchSignal(ctx: unknown, logger: NkLogger, nk: unknown, dispatcher: NkMatchDispatcher, tick: number, state: T, data: string): {
105
+ state: T;
106
+ data?: string;
107
+ } | null;
108
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@table-games-engine/nakama-adapter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "esbuild src/index.ts --bundle --format=esm --platform=neutral --packages=external --external:@table-games-engine/* --outfile=dist/index.js && tsc -p tsconfig.build.json",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@table-games-engine/core": "^0.1.0",
27
+ "@table-games-engine/persistence": "^0.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.4.0",
31
+ "vitest": "^2.0.0"
32
+ },
33
+ "license": "FSL-1.1-ALv2"
34
+ }