@table-games-engine/nakama-adapter 0.0.0-stage → 0.1.1
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/LICENSE.md +105 -0
- package/README.md +77 -2
- package/dist/broadcast.d.ts +27 -0
- package/dist/idempotency.d.ts +54 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1224 -0
- package/dist/match-handler.d.ts +76 -0
- package/dist/persistence-bridge.d.ts +78 -0
- package/dist/plugins.d.ts +76 -0
- package/dist/register-plugin.d.ts +7 -0
- package/dist/registry.d.ts +84 -0
- package/dist/serialization.d.ts +135 -0
- package/dist/timeouts.d.ts +33 -0
- package/dist/types.d.ts +108 -0
- package/package.json +42 -4
|
@@ -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;
|
package/dist/types.d.ts
ADDED
|
@@ -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,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@table-games-engine/nakama-adapter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
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.1",
|
|
27
|
+
"@table-games-engine/persistence": "^0.1.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5.4.0",
|
|
31
|
+
"vitest": "^2.0.0"
|
|
32
|
+
},
|
|
33
|
+
"license": "FSL-1.1-ALv2",
|
|
34
|
+
"description": "Nakama runtime match handler for table-games-engine games.",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"table-games",
|
|
37
|
+
"turn-based",
|
|
38
|
+
"multiplayer",
|
|
39
|
+
"game-engine",
|
|
40
|
+
"nakama",
|
|
41
|
+
"boardgame"
|
|
42
|
+
],
|
|
43
|
+
"homepage": "https://www.npmjs.com/package/@table-games-engine/nakama-adapter"
|
|
44
|
+
}
|