@table-games-engine/game-kit 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.
- package/LICENSE +202 -0
- package/dist/bridge.d.ts +49 -0
- package/dist/bridge.js +75 -0
- package/dist/check.d.ts +16 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +593 -0
- package/dist/create.d.ts +4 -0
- package/dist/exec.d.ts +1 -0
- package/dist/game-json.d.ts +41 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +370 -0
- package/dist/lint.d.ts +3 -0
- package/dist/load-game.d.ts +6 -0
- package/dist/preview.d.ts +4 -0
- package/dist/simulate.d.ts +26 -0
- package/host/index.html +19 -0
- package/host/main.ts +70 -0
- package/nakama/docker-compose.yml +59 -0
- package/nakama/init.ts +215 -0
- package/nakama/local.yml +28 -0
- package/nakama/nkruntime.d.ts +150 -0
- package/package.json +32 -4
- package/schema/game.schema.json +54 -0
- package/skill/create-store-game.md +135 -0
- package/template/AGENTS.md +6 -0
- package/template/client/index.html +17 -0
- package/template/client/main.ts +31 -0
- package/template/game.json +21 -0
- package/template/gitignore +3 -0
- package/template/logic/index.ts +119 -0
- package/template/package.json +19 -0
- package/template/rules.md +5 -0
- package/template/tests/game.test.ts +23 -0
- package/template/tsconfig.json +7 -0
- package/README.md +0 -3
package/nakama/init.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Nakama TypeScript runtime entry point.
|
|
3
|
+
*
|
|
4
|
+
* Nakama hosts this file as a CommonJS bundle (single file, no `node_modules`
|
|
5
|
+
* resolution at runtime, no ESM). The host invokes the top-level
|
|
6
|
+
* `InitModule(ctx, logger, nk, initializer)` function exactly once on boot.
|
|
7
|
+
*
|
|
8
|
+
* Wires `@table-games-engine/nakama-adapter`'s match handler into Nakama,
|
|
9
|
+
* registers the reference games via `GameRegistry`, and exposes an RPC
|
|
10
|
+
* `create_tabletop_match(payload)` that clients call to spawn a match.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT — Nakama 3.x match-handler binding requirement:
|
|
13
|
+
* `initializer.registerMatch(name, handler)` looks up the handler's seven
|
|
14
|
+
* methods by NAME in the bundle's global object scope. A closure-bound
|
|
15
|
+
* object (the value `createMatchHandler` returns) doesn't satisfy this
|
|
16
|
+
* lookup. We define top-level named functions `matchInit`/`matchJoinAttempt`/
|
|
17
|
+
* etc. that delegate to a module-scope singleton populated inside
|
|
18
|
+
* `InitModule`. This keeps the M3 adapter's API untouched while satisfying
|
|
19
|
+
* Nakama's global-name binding contract.
|
|
20
|
+
*
|
|
21
|
+
* The trailing `!InitModule && InitModule;` expression keeps the function
|
|
22
|
+
* symbol alive when the bundler tree-shakes the module — the function is
|
|
23
|
+
* only ever called by the runtime host.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/// <reference path="./nkruntime.d.ts" />
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
createMatchHandler,
|
|
30
|
+
GameRegistry,
|
|
31
|
+
IdempotencyCache,
|
|
32
|
+
} from '@table-games-engine/nakama-adapter';
|
|
33
|
+
import { registerPlugin } from '@table-games-engine/nakama-adapter';
|
|
34
|
+
import game from '__game__';
|
|
35
|
+
|
|
36
|
+
const MATCH_HANDLER_NAME = 'tabletop_match';
|
|
37
|
+
|
|
38
|
+
// Nakama allocates a pool of independent JS runtimes; module-level state
|
|
39
|
+
// is NOT shared between the InitModule runtime and the per-match runtimes.
|
|
40
|
+
// Lazy-build the handler per-runtime on first match-handler invocation.
|
|
41
|
+
let _handler: ReturnType<typeof createMatchHandler> | null = null;
|
|
42
|
+
|
|
43
|
+
function _ensureHandler(): ReturnType<typeof createMatchHandler> {
|
|
44
|
+
if (!_handler) {
|
|
45
|
+
const registry = new GameRegistry();
|
|
46
|
+
registerPlugin(registry, game);
|
|
47
|
+
_handler = createMatchHandler({
|
|
48
|
+
registry,
|
|
49
|
+
idempotencyCache: new IdempotencyCache(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return _handler;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Nakama-required match-handler entry points. Each function MUST be
|
|
56
|
+
// declared at the bundle's top level so Nakama can resolve it by name.
|
|
57
|
+
// The arity is intentional and matches Nakama's TS runtime contract.
|
|
58
|
+
|
|
59
|
+
function matchInit(
|
|
60
|
+
ctx: nkruntime.Context,
|
|
61
|
+
logger: nkruntime.Logger,
|
|
62
|
+
nk: nkruntime.Nakama,
|
|
63
|
+
params: { [key: string]: string },
|
|
64
|
+
): { state: unknown; tickRate: number; label: string } {
|
|
65
|
+
return _ensureHandler().matchInit(ctx, logger, nk, params) as never;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function matchJoinAttempt(
|
|
69
|
+
ctx: nkruntime.Context,
|
|
70
|
+
logger: nkruntime.Logger,
|
|
71
|
+
nk: nkruntime.Nakama,
|
|
72
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
73
|
+
tick: number,
|
|
74
|
+
state: unknown,
|
|
75
|
+
presence: nkruntime.Presence,
|
|
76
|
+
metadata: { [key: string]: unknown },
|
|
77
|
+
): { state: unknown; accept: boolean; rejectMessage?: string } | null {
|
|
78
|
+
return _ensureHandler().matchJoinAttempt(
|
|
79
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, presence as never, metadata,
|
|
80
|
+
) as never;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchJoin(
|
|
84
|
+
ctx: nkruntime.Context,
|
|
85
|
+
logger: nkruntime.Logger,
|
|
86
|
+
nk: nkruntime.Nakama,
|
|
87
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
88
|
+
tick: number,
|
|
89
|
+
state: unknown,
|
|
90
|
+
presences: nkruntime.Presence[],
|
|
91
|
+
): { state: unknown } | null {
|
|
92
|
+
return _ensureHandler().matchJoin(
|
|
93
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, presences as never,
|
|
94
|
+
) as never;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function matchLeave(
|
|
98
|
+
ctx: nkruntime.Context,
|
|
99
|
+
logger: nkruntime.Logger,
|
|
100
|
+
nk: nkruntime.Nakama,
|
|
101
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
102
|
+
tick: number,
|
|
103
|
+
state: unknown,
|
|
104
|
+
presences: nkruntime.Presence[],
|
|
105
|
+
): { state: unknown } | null {
|
|
106
|
+
return _ensureHandler().matchLeave(
|
|
107
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, presences as never,
|
|
108
|
+
) as never;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchLoop(
|
|
112
|
+
ctx: nkruntime.Context,
|
|
113
|
+
logger: nkruntime.Logger,
|
|
114
|
+
nk: nkruntime.Nakama,
|
|
115
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
116
|
+
tick: number,
|
|
117
|
+
state: unknown,
|
|
118
|
+
messages: nkruntime.MatchData[],
|
|
119
|
+
): { state: unknown } | null {
|
|
120
|
+
return _ensureHandler().matchLoop(
|
|
121
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, messages as never,
|
|
122
|
+
) as never;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function matchTerminate(
|
|
126
|
+
ctx: nkruntime.Context,
|
|
127
|
+
logger: nkruntime.Logger,
|
|
128
|
+
nk: nkruntime.Nakama,
|
|
129
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
130
|
+
tick: number,
|
|
131
|
+
state: unknown,
|
|
132
|
+
graceSeconds: number,
|
|
133
|
+
): { state: unknown } | null {
|
|
134
|
+
return _ensureHandler().matchTerminate(
|
|
135
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, graceSeconds,
|
|
136
|
+
) as never;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function matchSignal(
|
|
140
|
+
ctx: nkruntime.Context,
|
|
141
|
+
logger: nkruntime.Logger,
|
|
142
|
+
nk: nkruntime.Nakama,
|
|
143
|
+
dispatcher: nkruntime.MatchDispatcher,
|
|
144
|
+
tick: number,
|
|
145
|
+
state: unknown,
|
|
146
|
+
data: string,
|
|
147
|
+
): { state: unknown; data?: string } | null {
|
|
148
|
+
return _ensureHandler().matchSignal(
|
|
149
|
+
ctx, logger, nk, dispatcher as never, tick, state as never, data,
|
|
150
|
+
) as never;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createTabletopMatchRpc(
|
|
154
|
+
_rpcCtx: nkruntime.Context,
|
|
155
|
+
rpcLogger: nkruntime.Logger,
|
|
156
|
+
nk: nkruntime.Nakama,
|
|
157
|
+
payload: string,
|
|
158
|
+
): string {
|
|
159
|
+
let parsed: { gameId?: string; matchId?: string; playerIds?: string[]; config?: Record<string, unknown> } = {};
|
|
160
|
+
try {
|
|
161
|
+
parsed = payload ? JSON.parse(payload) : {};
|
|
162
|
+
// nakama-js's `client.rpc(session, id, input)` JSON-encodes `input`
|
|
163
|
+
// before posting. When callers pass an object, we receive an empty body
|
|
164
|
+
// string ("{}"); when callers pre-stringify (the supported path) we
|
|
165
|
+
// receive the string twice-encoded — an outer JSON string wrapping the
|
|
166
|
+
// inner object. After the first parse we get the inner string; parse
|
|
167
|
+
// again to land on the actual object.
|
|
168
|
+
if (typeof parsed === 'string') {
|
|
169
|
+
parsed = JSON.parse(parsed);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
rpcLogger.warn('create_tabletop_match: invalid JSON payload: %s', String(err));
|
|
173
|
+
throw new Error('Invalid JSON payload');
|
|
174
|
+
}
|
|
175
|
+
const gameId = parsed.gameId;
|
|
176
|
+
if (!gameId) {
|
|
177
|
+
throw new Error('payload.gameId is required');
|
|
178
|
+
}
|
|
179
|
+
// Nakama's matchCreate accepts only string-valued params; stringify
|
|
180
|
+
// any complex shapes (player lists) here, the match handler decodes.
|
|
181
|
+
const params: { [key: string]: string } = { gameId };
|
|
182
|
+
if (parsed.matchId) params.matchId = parsed.matchId;
|
|
183
|
+
if (parsed.playerIds && parsed.playerIds.length > 0) {
|
|
184
|
+
params.playerIds = parsed.playerIds.join(',');
|
|
185
|
+
}
|
|
186
|
+
if (parsed.config) params.config = JSON.stringify(parsed.config);
|
|
187
|
+
const matchId = nk.matchCreate(MATCH_HANDLER_NAME, params);
|
|
188
|
+
rpcLogger.info('Created tabletop match %s for game %s', matchId, gameId);
|
|
189
|
+
return JSON.stringify({ matchId });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function InitModule(
|
|
193
|
+
_ctx: nkruntime.Context,
|
|
194
|
+
logger: nkruntime.Logger,
|
|
195
|
+
_nk: nkruntime.Nakama,
|
|
196
|
+
initializer: nkruntime.Initializer,
|
|
197
|
+
): void {
|
|
198
|
+
initializer.registerMatch(MATCH_HANDLER_NAME, {
|
|
199
|
+
matchInit,
|
|
200
|
+
matchJoinAttempt,
|
|
201
|
+
matchJoin,
|
|
202
|
+
matchLeave,
|
|
203
|
+
matchLoop,
|
|
204
|
+
matchTerminate,
|
|
205
|
+
matchSignal,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
initializer.registerRpc('create_tabletop_match', createTabletopMatchRpc);
|
|
209
|
+
|
|
210
|
+
logger.info('game-kit preview runtime loaded - game %s', String(game.id));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Keep the symbol live for Nakama's V8 host. Do not remove.
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
215
|
+
!InitModule && InitModule;
|
package/nakama/local.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Nakama local config for the SaaS Game Engine playground.
|
|
2
|
+
#
|
|
3
|
+
# `runtime.path` is the directory Nakama scans on boot for runtime
|
|
4
|
+
# modules (Lua / Go / TypeScript). `runtime.js_entrypoint` names the
|
|
5
|
+
# bundled CommonJS file; Nakama then calls its top-level
|
|
6
|
+
# `InitModule(ctx, logger, nk, initializer)` function once.
|
|
7
|
+
name: game-kit-nakama
|
|
8
|
+
data_dir: /nakama/data
|
|
9
|
+
|
|
10
|
+
logger:
|
|
11
|
+
level: INFO
|
|
12
|
+
stdout: true
|
|
13
|
+
|
|
14
|
+
session:
|
|
15
|
+
encryption_key: defaultencryptionkey
|
|
16
|
+
token_expiry_sec: 7200
|
|
17
|
+
|
|
18
|
+
socket:
|
|
19
|
+
server_key: defaultkey
|
|
20
|
+
|
|
21
|
+
console:
|
|
22
|
+
port: 7351
|
|
23
|
+
username: admin
|
|
24
|
+
password: localdev
|
|
25
|
+
|
|
26
|
+
runtime:
|
|
27
|
+
path: /nakama/data/modules
|
|
28
|
+
js_entrypoint: index.js
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Minimal `nkruntime` namespace type stubs.
|
|
3
|
+
*
|
|
4
|
+
* Nakama's official `nkruntime` types ship inside the Nakama runtime image
|
|
5
|
+
* itself (no public npm package as of 2026). For local typechecking we declare
|
|
6
|
+
* the structural subset of the namespace this server actually uses. The real
|
|
7
|
+
* implementation is provided by the V8 host at runtime; nothing in this file
|
|
8
|
+
* appears in the bundled output.
|
|
9
|
+
*
|
|
10
|
+
* Reference:
|
|
11
|
+
* https://heroiclabs.com/docs/nakama/server-framework/typescript-setup/
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
15
|
+
declare namespace nkruntime {
|
|
16
|
+
interface Logger {
|
|
17
|
+
debug(format: string, ...args: unknown[]): void;
|
|
18
|
+
info(format: string, ...args: unknown[]): void;
|
|
19
|
+
warn(format: string, ...args: unknown[]): void;
|
|
20
|
+
error(format: string, ...args: unknown[]): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Context {
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Presence {
|
|
28
|
+
userId: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
username?: string;
|
|
31
|
+
node?: string;
|
|
32
|
+
status?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface MatchData {
|
|
36
|
+
opCode: number;
|
|
37
|
+
data: Uint8Array;
|
|
38
|
+
sender: Presence;
|
|
39
|
+
reliable?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MatchDispatcher {
|
|
43
|
+
broadcastMessage(
|
|
44
|
+
opCode: number,
|
|
45
|
+
data?: string | Uint8Array | null,
|
|
46
|
+
presences?: Presence[] | null,
|
|
47
|
+
sender?: Presence | null,
|
|
48
|
+
reliable?: boolean,
|
|
49
|
+
): void;
|
|
50
|
+
matchKick?(presences: Presence[]): void;
|
|
51
|
+
matchLabelUpdate?(label: string): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MatchState {
|
|
55
|
+
[key: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MatchHandler<T = MatchState> {
|
|
59
|
+
matchInit(
|
|
60
|
+
ctx: Context,
|
|
61
|
+
logger: Logger,
|
|
62
|
+
nk: Nakama,
|
|
63
|
+
params: { [key: string]: string },
|
|
64
|
+
): { state: T; tickRate: number; label: string };
|
|
65
|
+
|
|
66
|
+
matchJoinAttempt(
|
|
67
|
+
ctx: Context,
|
|
68
|
+
logger: Logger,
|
|
69
|
+
nk: Nakama,
|
|
70
|
+
dispatcher: MatchDispatcher,
|
|
71
|
+
tick: number,
|
|
72
|
+
state: T,
|
|
73
|
+
presence: Presence,
|
|
74
|
+
metadata: { [key: string]: unknown },
|
|
75
|
+
): { state: T; accept: boolean; rejectMessage?: string } | null;
|
|
76
|
+
|
|
77
|
+
matchJoin(
|
|
78
|
+
ctx: Context,
|
|
79
|
+
logger: Logger,
|
|
80
|
+
nk: Nakama,
|
|
81
|
+
dispatcher: MatchDispatcher,
|
|
82
|
+
tick: number,
|
|
83
|
+
state: T,
|
|
84
|
+
presences: Presence[],
|
|
85
|
+
): { state: T } | null;
|
|
86
|
+
|
|
87
|
+
matchLeave(
|
|
88
|
+
ctx: Context,
|
|
89
|
+
logger: Logger,
|
|
90
|
+
nk: Nakama,
|
|
91
|
+
dispatcher: MatchDispatcher,
|
|
92
|
+
tick: number,
|
|
93
|
+
state: T,
|
|
94
|
+
presences: Presence[],
|
|
95
|
+
): { state: T } | null;
|
|
96
|
+
|
|
97
|
+
matchLoop(
|
|
98
|
+
ctx: Context,
|
|
99
|
+
logger: Logger,
|
|
100
|
+
nk: Nakama,
|
|
101
|
+
dispatcher: MatchDispatcher,
|
|
102
|
+
tick: number,
|
|
103
|
+
state: T,
|
|
104
|
+
messages: MatchData[],
|
|
105
|
+
): { state: T } | null;
|
|
106
|
+
|
|
107
|
+
matchSignal(
|
|
108
|
+
ctx: Context,
|
|
109
|
+
logger: Logger,
|
|
110
|
+
nk: Nakama,
|
|
111
|
+
dispatcher: MatchDispatcher,
|
|
112
|
+
tick: number,
|
|
113
|
+
state: T,
|
|
114
|
+
data: string,
|
|
115
|
+
): { state: T; data?: string } | null;
|
|
116
|
+
|
|
117
|
+
matchTerminate(
|
|
118
|
+
ctx: Context,
|
|
119
|
+
logger: Logger,
|
|
120
|
+
nk: Nakama,
|
|
121
|
+
dispatcher: MatchDispatcher,
|
|
122
|
+
tick: number,
|
|
123
|
+
state: T,
|
|
124
|
+
graceSeconds: number,
|
|
125
|
+
): { state: T } | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type RpcFunction = (
|
|
129
|
+
ctx: Context,
|
|
130
|
+
logger: Logger,
|
|
131
|
+
nk: Nakama,
|
|
132
|
+
payload: string,
|
|
133
|
+
) => string | void;
|
|
134
|
+
|
|
135
|
+
interface Initializer {
|
|
136
|
+
registerMatch(name: string, handler: MatchHandler): void;
|
|
137
|
+
registerRpc(id: string, fn: RpcFunction): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface Nakama {
|
|
141
|
+
matchCreate(module: string, params?: { [key: string]: string }): string;
|
|
142
|
+
matchGet?(matchId: string): unknown;
|
|
143
|
+
[key: string]: unknown;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
declare module '__game__' {
|
|
148
|
+
const plugin: import('@table-games-engine/core').GamePlugin;
|
|
149
|
+
export default plugin;
|
|
150
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@table-games-engine/game-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"publishConfig": { "access": "public" },
|
|
6
|
+
"description": "Build, check, simulate and preview store games on table-games-engine.",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": { "game-kit": "dist/cli.js" },
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
13
|
+
"./bridge": { "types": "./dist/bridge.d.ts", "default": "./dist/bridge.js" }
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "schema", "template", "nakama", "host", "skill"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "esbuild src/index.ts src/bridge.ts --bundle --format=esm --platform=node --packages=external --outdir=dist && esbuild src/cli.ts --bundle --format=esm --platform=node --packages=external --outfile=dist/cli.js --banner:js='#!/usr/bin/env node' && tsc -p tsconfig.build.json",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@heroiclabs/nakama-js": "^2.7.0",
|
|
23
|
+
"@table-games-engine/core": "^0.1.0",
|
|
24
|
+
"@table-games-engine/nakama-adapter": "^0.1.0",
|
|
25
|
+
"@table-games-engine/persistence": "^0.1.0",
|
|
26
|
+
"ajv": "^8.17.0",
|
|
27
|
+
"esbuild": "^0.24.0",
|
|
28
|
+
"eslint": "^9.0.0",
|
|
29
|
+
"tsx": "^4.19.0",
|
|
30
|
+
"typescript-eslint": "^8.0.0",
|
|
31
|
+
"vite": "^5.4.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": { "typescript": "^5.4.0", "vitest": "^2.0.0" }
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/Dmitry-Kucher/table-games-engine/game.schema.json",
|
|
4
|
+
"title": "Store game metadata (game.json)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"definitions": {
|
|
7
|
+
"localized": {
|
|
8
|
+
"oneOf": [
|
|
9
|
+
{ "type": "string", "minLength": 1 },
|
|
10
|
+
{ "type": "object", "minProperties": 1, "additionalProperties": { "type": "string", "minLength": 1 } }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"required": ["slug", "name", "version", "engine", "players", "locales", "description", "rules", "authors", "listed", "client"],
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"$schema": { "type": "string" },
|
|
18
|
+
"slug": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,40}$" },
|
|
19
|
+
"name": { "$ref": "#/definitions/localized" },
|
|
20
|
+
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
|
21
|
+
"engine": { "type": "string", "minLength": 1 },
|
|
22
|
+
"players": {
|
|
23
|
+
"type": "object", "required": ["min", "max"], "additionalProperties": false,
|
|
24
|
+
"properties": { "min": { "type": "integer", "minimum": 2 }, "max": { "type": "integer", "minimum": 2 } }
|
|
25
|
+
},
|
|
26
|
+
"locales": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" } },
|
|
27
|
+
"description": { "$ref": "#/definitions/localized" },
|
|
28
|
+
"rules": { "type": "object", "minProperties": 1, "additionalProperties": { "type": "string", "pattern": "\\.md$" } },
|
|
29
|
+
"tags": { "type": "array", "items": { "type": "string" } },
|
|
30
|
+
"cover": { "type": "string" },
|
|
31
|
+
"authors": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
32
|
+
"credits": { "type": "string" },
|
|
33
|
+
"listed": { "type": "boolean" },
|
|
34
|
+
"client": {
|
|
35
|
+
"type": "object", "required": ["kind", "entry"], "additionalProperties": false,
|
|
36
|
+
"properties": { "kind": { "enum": ["iframe"] }, "entry": { "type": "string", "minLength": 1 } }
|
|
37
|
+
},
|
|
38
|
+
"presets": {
|
|
39
|
+
"type": "array", "minItems": 1,
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object", "required": ["id", "name", "config"], "additionalProperties": false,
|
|
42
|
+
"properties": {
|
|
43
|
+
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
|
44
|
+
"name": { "$ref": "#/definitions/localized" },
|
|
45
|
+
"config": { "type": "object" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"simulation": {
|
|
50
|
+
"type": "object", "additionalProperties": false,
|
|
51
|
+
"properties": { "maxSteps": { "type": "integer", "minimum": 1 } }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Skill: create-store-game
|
|
2
|
+
|
|
3
|
+
Build a store game on table-games-engine in a standalone package created by
|
|
4
|
+
`game-kit create`. You are done when `npx game-kit check` exits 0 and prints `check: OK`.
|
|
5
|
+
Do not edit anything under `node_modules/`.
|
|
6
|
+
|
|
7
|
+
> **Unpublished packages:** until `@table-games-engine/*` is on npm, you need the five
|
|
8
|
+
> local tarballs (`core`, `persistence`, `nakama-adapter`, `client-sdk`, `game-kit`). Then:
|
|
9
|
+
> ```sh
|
|
10
|
+
> mkdir installer && (cd installer && npm init -y && npm install /path/to/tarballs/*.tgz)
|
|
11
|
+
> npx --prefix ./installer game-kit create <slug> --no-install
|
|
12
|
+
> cd <slug> && npm install /path/to/tarballs/*.tgz
|
|
13
|
+
> ```
|
|
14
|
+
> (`npx --package=<game-kit tgz>` alone fails: it cannot resolve the sibling packages.)
|
|
15
|
+
> Once published, plain `npx @table-games-engine/game-kit create <slug>` installs for you.
|
|
16
|
+
|
|
17
|
+
## Inputs (ask the user if missing)
|
|
18
|
+
|
|
19
|
+
1. Rules text for every locale the game ships (`rules.md`, `rules.<locale>.md`).
|
|
20
|
+
2. Locales (first = default) and presets (match configurations, first = default).
|
|
21
|
+
3. Player count.
|
|
22
|
+
|
|
23
|
+
## What `game-kit create` gives you
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
game.json rules.md AGENTS.md package.json tsconfig.json
|
|
27
|
+
logic/index.ts a working 2-player "Fire Bolt" duel: replace it
|
|
28
|
+
tests/game.test.ts drives a full match: copy its pattern
|
|
29
|
+
client/index.html, client/main.ts bridge client: replace it
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Read `logic/index.ts` and `tests/game.test.ts` first; they are the reference for everything below.
|
|
33
|
+
|
|
34
|
+
## The contract you implement
|
|
35
|
+
|
|
36
|
+
`logic/index.ts` does `export default plugin`, where `plugin` is a `GamePlugin`
|
|
37
|
+
from `@table-games-engine/core` with:
|
|
38
|
+
|
|
39
|
+
| Field | What it must do |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `id`, `version`, `requiredMechanics: []` | As in the template. |
|
|
42
|
+
| `manifest` | Phases and actions. Keep one phase with `exitCondition: 'manual'` unless the game really has phases. Every action id a player can take must be in that phase's `legalActions`, and defined in `actions.actions` with `usesStack: true`, `effects: []`. Copy the template's manifest shape. |
|
|
43
|
+
| `setup(ctx, config, seats)` | Return `ok({ state })`. `ctx.state` is already a valid empty state with the seats in turn order and the first seat holding priority: spread it and set `players` and `shared`. `config` is the chosen preset's `config` (`{}` with no presets); only `config` reaches `setup`, never the preset's `id`, so put everything the logic needs (board size, variant flags) into `config`. |
|
|
44
|
+
| `actionHandlers` | `{ [actionTypeId]: (ctx, frame) => ... }`. Pure: read `ctx.state` and `frame.payload`, return `resolved(mutations)` (or `resolved(mutations, events)`), both helpers imported from `@table-games-engine/core`; no `as never` casts needed. The acting player is `'actor' in frame ? frame.actor : frame.controller`. **To end the turn, include `{ domain: 'turn', operation: { type: 'advance' } }` in `mutations`**: the engine then gives priority to the next player. If the same player moves again (bonus move), do not emit it. **To reject an illegal move, `return reject('rule-violation')`** (i.e. `{ success: false, failureReason: 'rule-violation', mutations: [], events: [], newFrames: [], firedTriggers: [] }`; other reasons: `'condition-failed'`, `'targets-illegal'`): the server rejects the input (`driveAction` returns `ok: false` with code `CONDITION_NOT_MET`, the player gets a `rejected` notice) and nothing changes, even if you returned mutations. |
|
|
45
|
+
| `winConditionChecker(ctx)` | `{ gameOver: false }` or `{ gameOver: true, winners: [...playerIds], reason }`. A draw is `winners: []`. Every game must end: add a turn limit if the rules have none. (The engine also ends the match when at most one non-eliminated player remains.) |
|
|
46
|
+
| `legalMoves(state, playerId)` | **Every** concrete move this player may submit now, each `{ playerId, type: 'action', data: { type: 'action', actionTypeId, targets: {}, payload } }` (type `LegalMove`). Return `[]` when `state.result` is set or `state.priority.holder !== playerId`. Never return `[]` for the holder of an unfinished game. |
|
|
47
|
+
|
|
48
|
+
Store game data in `state.shared` (JSON only: no class instances, `Map`, `Set`,
|
|
49
|
+
`undefined`). Update it with `{ domain: 'shared', operation: { type: 'merge', updates } }`
|
|
50
|
+
(shallow merge of top-level keys) or `{ domain: 'shared', operation: { type: 'set', path, value } }` (`path` is the literal top-level key `shared[path]`, not a nested path).
|
|
51
|
+
Other useful mutations: `{ domain: 'player', operation: { type: 'add-resource', playerId, resource, delta } }`,
|
|
52
|
+
`{ domain: 'turn', operation: { type: 'eliminate-player', playerId } }`.
|
|
53
|
+
|
|
54
|
+
### Randomness
|
|
55
|
+
|
|
56
|
+
`ctx.random()`, `ctx.randomInt(min, max)`, `ctx.randomPick(arr)`, `ctx.randomShuffle(arr)` return
|
|
57
|
+
`{ value, newRngState, consumed }` and do **not** change state by themselves. Every call within one
|
|
58
|
+
handler reads the same RNG state, so do **one draw per handler** (use `randomShuffle` for many items)
|
|
59
|
+
and persist it by emitting `{ domain: 'rng', operation: { type: 'set-state', state: result.newRngState } }`.
|
|
60
|
+
Otherwise the next action draws the same number.
|
|
61
|
+
|
|
62
|
+
### Logic rules (`game-kit check` lint stage enforces them; all files under `logic/`)
|
|
63
|
+
|
|
64
|
+
- Import only `@table-games-engine/core` and relative files.
|
|
65
|
+
- No `Math.random`, `Date`, `process`, `window`, `globalThis`, `setTimeout`, `setInterval`, `fetch`, `require`, dynamic `import()`.
|
|
66
|
+
- No module-level `let`/`var`: modules are shared by every match on the server (`const` is fine).
|
|
67
|
+
- Never mutate `ctx.state`; return mutations.
|
|
68
|
+
|
|
69
|
+
## The client
|
|
70
|
+
|
|
71
|
+
`client/` is a vite app (`client/index.html` -> `client/main.ts`) shown to each player in a sandboxed
|
|
72
|
+
iframe **with no network access** (no fetch, no CDN, no remote fonts or images: bundle everything).
|
|
73
|
+
It talks to the store only through the bridge:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { connectBridge, type StatePayload } from '@table-games-engine/game-kit/bridge';
|
|
77
|
+
const bridge = connectBridge(
|
|
78
|
+
(s: StatePayload) => render(s), // s.view (this player's view; s.view.viewerId is you), s.seat, s.legalMoves, s.status ('active'|'finished'), s.result
|
|
79
|
+
(kind) => showNotice(kind), // 'rejected' | 'reconnecting' | 'server-restart' | 'opponent-left'
|
|
80
|
+
);
|
|
81
|
+
// on a click: bridge.send(oneOf_s.legalMoves)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- Only send moves taken from the latest `s.legalMoves`; anything else is rejected (you get a `rejected` notice).
|
|
85
|
+
- Build the UI from `s.legalMoves`: they are exactly what is clickable now. `s.legalMoves` is empty when it is not your turn.
|
|
86
|
+
- Read the locale from `new URLSearchParams(location.search).get('locale')`, fall back
|
|
87
|
+
to the first locale in `game.json`, and ship UI strings for every listed locale.
|
|
88
|
+
- Must work on a phone-width screen (>= 360 px).
|
|
89
|
+
|
|
90
|
+
## game.json
|
|
91
|
+
|
|
92
|
+
Edit the template's file; keep every field the schema requires (`slug`, `name`, `version`, `engine`, `players`, `locales`, `description`, `rules`, `authors`, `listed`, `client`) and add no unknown fields. `locales` (first = default); `name` / `description` either a string or
|
|
93
|
+
`{ "<locale>": "..." }` covering every locale; `rules` = `{ "<locale>": "<file>.md" }` with every file present;
|
|
94
|
+
`presets` = `[{ "id": "small", "name": "..." or { ... }, "config": { ... } }]` (first = default, unique ids; `config`
|
|
95
|
+
is passed to `setup`); `players` `{min,max}`; `credits` with the original author and licence when adapting an
|
|
96
|
+
existing game; `client` stays `{ "kind": "iframe", "entry": "client/dist/index.html" }`;
|
|
97
|
+
`simulation.maxSteps` (default 2000) high enough for the longest legal game (one step = one move).
|
|
98
|
+
`players.min` is at least 2. `simulate` and `check` play every player count from `players.min` to `players.max`.
|
|
99
|
+
|
|
100
|
+
## Procedure
|
|
101
|
+
|
|
102
|
+
1. Read `rules.md` (and translations) completely. List every rule, then every
|
|
103
|
+
ambiguity; decide each one and write the decision into the rules files.
|
|
104
|
+
2. Write `logic/` (split board geometry / move generation / handlers into
|
|
105
|
+
files). Write `legalMoves` from the same helpers the handler validates with,
|
|
106
|
+
so they cannot disagree.
|
|
107
|
+
3. Write `tests/` (vitest): at least one scripted full match ending in a result, plus a
|
|
108
|
+
test per tricky rule. Drive moves with `setupMatch` + `driveAction` exactly
|
|
109
|
+
like `tests/game.test.ts` in the template. Read a move's fields with its typed
|
|
110
|
+
helper `actionOf(m)` (`m.data.type === 'action' ? m.data : null`, then
|
|
111
|
+
`actionOf(m)?.actionTypeId`, `actionOf(m)?.payload['x']`) instead of casting `m.data`.
|
|
112
|
+
A rejected move is `expect(r.ok).toBe(false)`.
|
|
113
|
+
4. Run `npx game-kit test`, then `npx game-kit simulate -n 200` (flags: `-n` matches per preset and
|
|
114
|
+
player count, default 1000; `--seed` default 1; `--preset <id>`; `--players <n>` one count instead of
|
|
115
|
+
every count in `players`). On a failure
|
|
116
|
+
it prints `FAIL preset=… players=… seed=… step=…: <reason>`, a `replay:` line and the last moves; replay one match with
|
|
117
|
+
`npx game-kit simulate --preset <id> --players <n> --seed <seed> -n 1` and fix the logic.
|
|
118
|
+
5. Write the client. If Docker is available run `npx game-kit preview --preset <id> --locale <l>`
|
|
119
|
+
and play both seats.
|
|
120
|
+
6. Run `npx game-kit check` (game.json, lint, typecheck, tests, simulate with 1000 matches per preset and player count, client build)
|
|
121
|
+
until it prints `check: OK`. On failure it prints `check: FAILED at <stage>` and the details.
|
|
122
|
+
|
|
123
|
+
## Common failures
|
|
124
|
+
|
|
125
|
+
| Symptom from `simulate` | Cause |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `no result after maxSteps=N` | The game can loop forever (also: a handler that forgets the turn `advance` lets the same player act forever): add a turn limit/draw rule and make every handler end the turn, or raise `simulation.maxSteps` if games are legitimately long. |
|
|
128
|
+
| `legalMoves is empty for pX, who must act` | `legalMoves` returned `[]` for the priority holder of an unfinished game: no moves left and no win/draw ended the match, or a wrong holder/`state.result` check inside `legalMoves`. |
|
|
129
|
+
| `legalMoves offered a move the engine rejected (NOT_PRIORITY_HOLDER …)` | A move returned by `legalMoves` carries a `playerId` other than the priority holder. |
|
|
130
|
+
| `… (CONDITION_NOT_MET: <reason>)` | Your handler returned `reject(...)` / `success: false` for a move `legalMoves` offered: the two disagree. Build both from the same helper. |
|
|
131
|
+
| `… (INVALID_ACTION_TYPE …)` | The action id is missing from the manifest's `actions.actions`. |
|
|
132
|
+
| `… (ILLEGAL_IN_WINDOW: <id> not legal in current window)` | The action id is not in the current phase's `legalActions` (or its speed does not fit the window). |
|
|
133
|
+
| `non-deterministic: replaying the same seed produced a different final state` | Module-level state, iteration over something unordered, or randomness outside `ctx`. |
|
|
134
|
+
| `setup threw: …` / `legalMoves threw: …` / `game logic threw: …` | Exception in your code; fix it. |
|
|
135
|
+
| `legalMoves returned N moves (> 5000)` (warning) | Too many concrete moves; the UI and bots get slow. Consider splitting the move into steps. |
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Agent instructions
|
|
2
|
+
|
|
3
|
+
This is a store game for table-games-engine. Follow the skill at
|
|
4
|
+
`node_modules/@table-games-engine/game-kit/skill/create-store-game.md`
|
|
5
|
+
step by step. You are done when `npx game-kit check` exits 0.
|
|
6
|
+
Do not edit anything under `node_modules/`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Game</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; margin: 16px; }
|
|
9
|
+
.row { display: flex; justify-content: space-between; max-width: 320px; }
|
|
10
|
+
button { margin-top: 12px; padding: 8px 16px; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main id="app"></main>
|
|
15
|
+
<script type="module" src="./main.ts"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|