@table-games-engine/game-kit 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 +202 -0
- package/README.md +81 -2
- 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 +35 -4
- package/schema/game.schema.json +54 -0
- package/skill/create-store-game.md +127 -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/dist/index.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// src/game-json.ts
|
|
2
|
+
import Ajv from "ajv";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// schema/game.schema.json
|
|
7
|
+
var game_schema_default = {
|
|
8
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
9
|
+
$id: "https://github.com/Dmitry-Kucher/table-games-engine/game.schema.json",
|
|
10
|
+
title: "Store game metadata (game.json)",
|
|
11
|
+
type: "object",
|
|
12
|
+
definitions: {
|
|
13
|
+
localized: {
|
|
14
|
+
oneOf: [
|
|
15
|
+
{ type: "string", minLength: 1 },
|
|
16
|
+
{ type: "object", minProperties: 1, additionalProperties: { type: "string", minLength: 1 } }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
required: ["slug", "name", "version", "engine", "players", "locales", "description", "rules", "authors", "listed", "client"],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: {
|
|
23
|
+
$schema: { type: "string" },
|
|
24
|
+
slug: { type: "string", pattern: "^[a-z][a-z0-9-]{1,40}$" },
|
|
25
|
+
name: { $ref: "#/definitions/localized" },
|
|
26
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
27
|
+
engine: { type: "string", minLength: 1 },
|
|
28
|
+
players: {
|
|
29
|
+
type: "object",
|
|
30
|
+
required: ["min", "max"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: { min: { type: "integer", minimum: 2 }, max: { type: "integer", minimum: 2 } }
|
|
33
|
+
},
|
|
34
|
+
locales: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", pattern: "^[a-z]{2}(-[A-Z]{2})?$" } },
|
|
35
|
+
description: { $ref: "#/definitions/localized" },
|
|
36
|
+
rules: { type: "object", minProperties: 1, additionalProperties: { type: "string", pattern: "\\.md$" } },
|
|
37
|
+
tags: { type: "array", items: { type: "string" } },
|
|
38
|
+
cover: { type: "string" },
|
|
39
|
+
authors: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
|
|
40
|
+
credits: { type: "string" },
|
|
41
|
+
listed: { type: "boolean" },
|
|
42
|
+
client: {
|
|
43
|
+
type: "object",
|
|
44
|
+
required: ["kind", "entry"],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
properties: { kind: { enum: ["iframe"] }, entry: { type: "string", minLength: 1 } }
|
|
47
|
+
},
|
|
48
|
+
presets: {
|
|
49
|
+
type: "array",
|
|
50
|
+
minItems: 1,
|
|
51
|
+
items: {
|
|
52
|
+
type: "object",
|
|
53
|
+
required: ["id", "name", "config"],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
properties: {
|
|
56
|
+
id: { type: "string", pattern: "^[a-z0-9-]+$" },
|
|
57
|
+
name: { $ref: "#/definitions/localized" },
|
|
58
|
+
config: { type: "object" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
simulation: {
|
|
63
|
+
type: "object",
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: { maxSteps: { type: "integer", minimum: 1 } }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// src/game-json.ts
|
|
71
|
+
var DEFAULT_MAX_STEPS = 2e3;
|
|
72
|
+
var validateSchema = new Ajv({ allErrors: true }).compile(game_schema_default);
|
|
73
|
+
function validateGameJson(raw, dir) {
|
|
74
|
+
if (!validateSchema(raw)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
errors: (validateSchema.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "invalid"}`)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const game = raw;
|
|
81
|
+
const errors = [];
|
|
82
|
+
const coverage = (field, value) => {
|
|
83
|
+
if (typeof value === "string") return;
|
|
84
|
+
for (const l of game.locales) if (!value[l]) errors.push(`${field}: missing locale "${l}"`);
|
|
85
|
+
};
|
|
86
|
+
coverage("name", game.name);
|
|
87
|
+
coverage("description", game.description);
|
|
88
|
+
game.presets?.forEach((p, i) => coverage(`presets[${i}].name`, p.name));
|
|
89
|
+
for (const l of game.locales) {
|
|
90
|
+
const file = game.rules[l];
|
|
91
|
+
if (!file) errors.push(`rules: missing locale "${l}"`);
|
|
92
|
+
else if (!existsSync(join(dir, file))) errors.push(`rules.${l}: file "${file}" not found`);
|
|
93
|
+
}
|
|
94
|
+
const seen = /* @__PURE__ */ new Set();
|
|
95
|
+
for (const p of game.presets ?? []) {
|
|
96
|
+
if (seen.has(p.id)) errors.push(`presets: duplicate id "${p.id}"`);
|
|
97
|
+
seen.add(p.id);
|
|
98
|
+
}
|
|
99
|
+
if (game.players.min > game.players.max) errors.push(`players: min (${game.players.min}) > max (${game.players.max})`);
|
|
100
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true, game };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/load-game.ts
|
|
104
|
+
import { readFileSync } from "node:fs";
|
|
105
|
+
import { join as join2 } from "node:path";
|
|
106
|
+
import { pathToFileURL } from "node:url";
|
|
107
|
+
import { tsImport } from "tsx/esm/api";
|
|
108
|
+
async function loadGame(dir) {
|
|
109
|
+
const checked = validateGameJson(JSON.parse(readFileSync(join2(dir, "game.json"), "utf8")), dir);
|
|
110
|
+
if (!checked.ok) throw new Error(`game.json is invalid:
|
|
111
|
+
${checked.errors.join("\n ")}`);
|
|
112
|
+
const mod = await tsImport(pathToFileURL(join2(dir, "logic/index.ts")).href, import.meta.url);
|
|
113
|
+
if (!mod.default) throw new Error("logic/index.ts must `export default` the GamePlugin");
|
|
114
|
+
return { game: checked.game, plugin: mod.default };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/simulate.ts
|
|
118
|
+
import { createHash } from "node:crypto";
|
|
119
|
+
import {
|
|
120
|
+
driveAction,
|
|
121
|
+
rulesFromPlugin,
|
|
122
|
+
setupMatch
|
|
123
|
+
} from "@table-games-engine/core";
|
|
124
|
+
var MOVE_WARN_LIMIT = 5e3;
|
|
125
|
+
var DEFAULT_PRESET = { id: "default", name: "Default", config: {} };
|
|
126
|
+
function runSimulation(plugin, opts) {
|
|
127
|
+
if (!plugin.legalMoves) {
|
|
128
|
+
return { ok: false, played: 0, warnings: [], failures: [{ preset: "-", players: 0, seed: opts.seed, step: 0, reason: "plugin has no legalMoves", lastMoves: [] }] };
|
|
129
|
+
}
|
|
130
|
+
const failures = [];
|
|
131
|
+
const warnings = /* @__PURE__ */ new Set();
|
|
132
|
+
let played = 0;
|
|
133
|
+
for (const preset of opts.presets.length > 0 ? opts.presets : [DEFAULT_PRESET]) {
|
|
134
|
+
for (const players of opts.players) {
|
|
135
|
+
for (let i = 0; i < opts.matches; i++) {
|
|
136
|
+
const seed = opts.seed + i;
|
|
137
|
+
const first = playMatch(plugin, preset, players, seed, opts, warnings);
|
|
138
|
+
played += 1;
|
|
139
|
+
if (first.failure) {
|
|
140
|
+
failures.push(first.failure);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const replay = playMatch(plugin, preset, players, seed, opts, warnings);
|
|
144
|
+
if (replay.failure || replay.hash !== first.hash) {
|
|
145
|
+
failures.push({
|
|
146
|
+
preset: preset.id,
|
|
147
|
+
players,
|
|
148
|
+
seed,
|
|
149
|
+
step: first.step,
|
|
150
|
+
reason: "non-deterministic: replaying the same seed produced a different final state",
|
|
151
|
+
lastMoves: first.moves.slice(-20)
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { ok: failures.length === 0, played, failures, warnings: [...warnings] };
|
|
158
|
+
}
|
|
159
|
+
function playMatch(plugin, preset, players, seed, opts, warnings) {
|
|
160
|
+
const moves = [];
|
|
161
|
+
const fail = (step, reason) => ({
|
|
162
|
+
failure: { preset: preset.id, players, seed, step, reason, lastMoves: moves.slice(-20) },
|
|
163
|
+
hash: "",
|
|
164
|
+
step,
|
|
165
|
+
moves
|
|
166
|
+
});
|
|
167
|
+
const playerIds = Array.from({ length: players }, (_, i) => `p${i + 1}`);
|
|
168
|
+
let state;
|
|
169
|
+
try {
|
|
170
|
+
state = setupMatch(plugin, { playerIds, config: preset.config, seed: `sim-${seed}` });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return fail(0, `setup threw: ${message(e)}`);
|
|
173
|
+
}
|
|
174
|
+
const rules = rulesFromPlugin(plugin);
|
|
175
|
+
const random = mulberry32(seed);
|
|
176
|
+
for (let step = 0; step < opts.maxSteps; step++) {
|
|
177
|
+
if (state.result) return { failure: null, hash: hashState(state), step, moves };
|
|
178
|
+
const actor = state.priority.holder ?? state.turn.activePlayer;
|
|
179
|
+
let options;
|
|
180
|
+
try {
|
|
181
|
+
options = plugin.legalMoves(state, actor);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return fail(step, `legalMoves threw: ${message(e)}`);
|
|
184
|
+
}
|
|
185
|
+
if (options.length === 0) return fail(step, `legalMoves is empty for ${String(actor)}, who must act`);
|
|
186
|
+
if (options.length > MOVE_WARN_LIMIT) {
|
|
187
|
+
warnings.add(`${preset.id}: legalMoves returned ${options.length} moves (> ${MOVE_WARN_LIMIT})`);
|
|
188
|
+
}
|
|
189
|
+
const move = options[Math.floor(random() * options.length)];
|
|
190
|
+
moves.push(move);
|
|
191
|
+
let driven;
|
|
192
|
+
try {
|
|
193
|
+
driven = driveAction(rules, state, { ...move, clientId: `sim-${step}`, stateVersion: state.version });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return fail(step, `game logic threw: ${message(e)}`);
|
|
196
|
+
}
|
|
197
|
+
if (!driven.ok) {
|
|
198
|
+
const why = driven.error.valid ? "unknown" : `${driven.error.error.code}: ${driven.error.error.message}`;
|
|
199
|
+
return fail(step, `legalMoves offered a move the engine rejected (${why})`);
|
|
200
|
+
}
|
|
201
|
+
state = driven.state;
|
|
202
|
+
}
|
|
203
|
+
return state.result ? { failure: null, hash: hashState(state), step: opts.maxSteps, moves } : fail(opts.maxSteps, `no result after maxSteps=${opts.maxSteps}`);
|
|
204
|
+
}
|
|
205
|
+
function formatReport(r) {
|
|
206
|
+
const lines = [`simulate: ${r.played} matches, ${r.failures.length} failed`];
|
|
207
|
+
for (const w of r.warnings) lines.push(` warning: ${w}`);
|
|
208
|
+
for (const f of r.failures.slice(0, 5)) {
|
|
209
|
+
lines.push(` FAIL preset=${f.preset} players=${f.players} seed=${f.seed} step=${f.step}: ${f.reason}`);
|
|
210
|
+
lines.push(` replay: game-kit simulate --preset ${f.preset} --players ${f.players} --seed ${f.seed} -n 1`);
|
|
211
|
+
for (const m of f.lastMoves) lines.push(` ${JSON.stringify(m)}`);
|
|
212
|
+
}
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
function hashState(s) {
|
|
216
|
+
return createHash("sha256").update(JSON.stringify(s)).digest("hex");
|
|
217
|
+
}
|
|
218
|
+
function message(e) {
|
|
219
|
+
return e instanceof Error ? e.message : String(e);
|
|
220
|
+
}
|
|
221
|
+
function mulberry32(seed) {
|
|
222
|
+
let a = seed >>> 0;
|
|
223
|
+
return () => {
|
|
224
|
+
a = a + 1831565813 >>> 0;
|
|
225
|
+
let t = a;
|
|
226
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
227
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
228
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/check.ts
|
|
233
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
234
|
+
import { join as join3 } from "node:path";
|
|
235
|
+
import { build } from "vite";
|
|
236
|
+
|
|
237
|
+
// src/lint.ts
|
|
238
|
+
import { relative } from "node:path";
|
|
239
|
+
import { ESLint } from "eslint";
|
|
240
|
+
import tseslint from "typescript-eslint";
|
|
241
|
+
var ALLOWED = "may import only @table-games-engine/core and relative paths";
|
|
242
|
+
var allowedImports = {
|
|
243
|
+
meta: { type: "problem", schema: [] },
|
|
244
|
+
create(context) {
|
|
245
|
+
const check = (node, source) => {
|
|
246
|
+
if (typeof source !== "string") return;
|
|
247
|
+
if (source === "@table-games-engine/core" || source.startsWith("./") || source.startsWith("../")) return;
|
|
248
|
+
context.report({ node, message: `Store game logic ${ALLOWED} (found "${source}").` });
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
ImportDeclaration: (n) => check(n, n.source.value),
|
|
252
|
+
ExportNamedDeclaration: (n) => n.source && check(n, n.source.value),
|
|
253
|
+
ExportAllDeclaration: (n) => check(n, n.source.value),
|
|
254
|
+
ImportExpression: (n) => context.report({ node: n, message: `Dynamic import is not allowed; logic ${ALLOWED}.` })
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
var NONDETERMINISM = "is non-deterministic or environment-dependent; use ctx.random*/state instead";
|
|
259
|
+
var logicLintConfig = [
|
|
260
|
+
{
|
|
261
|
+
files: ["logic/**/*.ts"],
|
|
262
|
+
languageOptions: { parser: tseslint.parser },
|
|
263
|
+
plugins: { "game-kit": { rules: { "allowed-imports": allowedImports } } },
|
|
264
|
+
rules: {
|
|
265
|
+
"game-kit/allowed-imports": "error",
|
|
266
|
+
"no-restricted-properties": ["error", { object: "Math", property: "random", message: `Math.random ${NONDETERMINISM}.` }],
|
|
267
|
+
"no-restricted-globals": [
|
|
268
|
+
"error",
|
|
269
|
+
...["Date", "globalThis", "window", "process", "setTimeout", "setInterval", "fetch", "require"].map((name) => ({
|
|
270
|
+
name,
|
|
271
|
+
message: `'${name}' ${NONDETERMINISM}.`
|
|
272
|
+
}))
|
|
273
|
+
],
|
|
274
|
+
"no-restricted-syntax": [
|
|
275
|
+
"error",
|
|
276
|
+
...["let", "var"].flatMap((kind) => [
|
|
277
|
+
{ selector: `Program > VariableDeclaration[kind='${kind}']`, message: `module-level '${kind}' is shared mutable state across matches.` },
|
|
278
|
+
{ selector: `Program > ExportNamedDeclaration > VariableDeclaration[kind='${kind}']`, message: `module-level '${kind}' is shared mutable state across matches.` }
|
|
279
|
+
])
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
];
|
|
284
|
+
async function lintLogic(dir) {
|
|
285
|
+
const eslint = new ESLint({ cwd: dir, overrideConfigFile: true, overrideConfig: logicLintConfig });
|
|
286
|
+
const results = await eslint.lintFiles(["logic/**/*.ts"]);
|
|
287
|
+
const lines = results.flatMap(
|
|
288
|
+
(r) => r.messages.map((m) => `${relative(dir, r.filePath)}:${m.line}:${m.column} ${m.message}`)
|
|
289
|
+
);
|
|
290
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/exec.ts
|
|
294
|
+
import { spawnSync } from "node:child_process";
|
|
295
|
+
function runTool(cmd, args, cwd) {
|
|
296
|
+
const r = spawnSync(cmd, [...args], { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
297
|
+
return r.status ?? 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/check.ts
|
|
301
|
+
async function runCheck(dir, stages = defaultStages) {
|
|
302
|
+
for (const stage of stages) {
|
|
303
|
+
let failure;
|
|
304
|
+
try {
|
|
305
|
+
failure = await stage.run(dir);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
308
|
+
}
|
|
309
|
+
if (failure !== null) return { ok: false, failedStage: stage.name, message: failure };
|
|
310
|
+
}
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
var tool = (name, cmd, args) => ({
|
|
314
|
+
name,
|
|
315
|
+
run: async (dir) => runTool(cmd, args, dir) === 0 ? null : `${cmd} ${args.join(" ")} failed`
|
|
316
|
+
});
|
|
317
|
+
function simulateStage(opts) {
|
|
318
|
+
return {
|
|
319
|
+
name: "simulate",
|
|
320
|
+
run: async (dir) => {
|
|
321
|
+
const { game, plugin } = await loadGame(dir);
|
|
322
|
+
const presets = (game.presets ?? []).filter((p) => !opts.preset || p.id === opts.preset);
|
|
323
|
+
if (opts.preset && presets.length === 0) return `unknown preset "${opts.preset}"`;
|
|
324
|
+
const { min, max } = game.players;
|
|
325
|
+
if (opts.players !== void 0 && (opts.players < min || opts.players > max)) {
|
|
326
|
+
return `--players ${opts.players} is outside game.json players (${min}..${max})`;
|
|
327
|
+
}
|
|
328
|
+
const report = runSimulation(plugin, {
|
|
329
|
+
matches: opts.matches,
|
|
330
|
+
seed: opts.seed,
|
|
331
|
+
maxSteps: game.simulation?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
332
|
+
players: opts.players !== void 0 ? [opts.players] : Array.from({ length: max - min + 1 }, (_, i) => min + i),
|
|
333
|
+
presets
|
|
334
|
+
});
|
|
335
|
+
console.log(formatReport(report));
|
|
336
|
+
return report.ok ? null : "simulation failed (see above)";
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
var defaultStages = [
|
|
341
|
+
{
|
|
342
|
+
name: "game.json",
|
|
343
|
+
run: async (dir) => {
|
|
344
|
+
const r = validateGameJson(JSON.parse(readFileSync2(join3(dir, "game.json"), "utf8")), dir);
|
|
345
|
+
return r.ok ? null : r.errors.join("\n");
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{ name: "lint", run: lintLogic },
|
|
349
|
+
tool("typecheck", "npx", ["tsc", "--noEmit", "-p", "."]),
|
|
350
|
+
tool("test", "npx", ["vitest", "run"]),
|
|
351
|
+
simulateStage({ matches: 1e3, seed: 1 }),
|
|
352
|
+
{
|
|
353
|
+
name: "client-build",
|
|
354
|
+
run: async (dir) => {
|
|
355
|
+
await build({ root: join3(dir, "client"), logLevel: "warn", build: { outDir: "dist", emptyOutDir: true } });
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
export {
|
|
361
|
+
DEFAULT_MAX_STEPS,
|
|
362
|
+
defaultStages,
|
|
363
|
+
formatReport,
|
|
364
|
+
lintLogic,
|
|
365
|
+
loadGame,
|
|
366
|
+
runCheck,
|
|
367
|
+
runSimulation,
|
|
368
|
+
simulateStage,
|
|
369
|
+
validateGameJson
|
|
370
|
+
};
|
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GamePlugin, type LegalMove } from '@table-games-engine/core';
|
|
2
|
+
import type { Preset } from './game-json.js';
|
|
3
|
+
export interface SimulateOptions {
|
|
4
|
+
readonly matches: number;
|
|
5
|
+
readonly seed: number;
|
|
6
|
+
readonly maxSteps: number;
|
|
7
|
+
/** Player counts to play, e.g. every count from game.json players.min to max. */
|
|
8
|
+
readonly players: ReadonlyArray<number>;
|
|
9
|
+
readonly presets: ReadonlyArray<Preset>;
|
|
10
|
+
}
|
|
11
|
+
export interface MatchFailure {
|
|
12
|
+
readonly preset: string;
|
|
13
|
+
readonly players: number;
|
|
14
|
+
readonly seed: number;
|
|
15
|
+
readonly step: number;
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
readonly lastMoves: ReadonlyArray<LegalMove>;
|
|
18
|
+
}
|
|
19
|
+
export interface SimulateReport {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly played: number;
|
|
22
|
+
readonly failures: ReadonlyArray<MatchFailure>;
|
|
23
|
+
readonly warnings: ReadonlyArray<string>;
|
|
24
|
+
}
|
|
25
|
+
export declare function runSimulation(plugin: GamePlugin, opts: SimulateOptions): SimulateReport;
|
|
26
|
+
export declare function formatReport(r: SimulateReport): string;
|
package/host/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>game-kit preview</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font-family: system-ui, sans-serif; margin: 16px; }
|
|
8
|
+
#seats { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
9
|
+
iframe { width: 100%; height: 70vh; border: 1px solid #ccc; border-radius: 8px; }
|
|
10
|
+
#log { font-size: 12px; color: #666; white-space: pre-wrap; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<h1 id="title">game-kit preview</h1>
|
|
15
|
+
<div id="seats"></div>
|
|
16
|
+
<pre id="log"></pre>
|
|
17
|
+
<script type="module" src="./main.ts"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
package/host/main.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference store host: two device sessions, one private match, one
|
|
3
|
+
* sandboxed iframe + bridge per seat. Store web hosts follow this shape.
|
|
4
|
+
*/
|
|
5
|
+
import { Client, type Session, type Socket } from '@heroiclabs/nakama-js';
|
|
6
|
+
import type { LegalMove, PlayerView } from '@table-games-engine/core';
|
|
7
|
+
import { createBridgeHost, iframePort, type StatePayload } from '@table-games-engine/game-kit/bridge';
|
|
8
|
+
|
|
9
|
+
declare const __GAME_KIT__: { gameId: string; config: Record<string, unknown>; locale: string; clientUrl: string; title: string };
|
|
10
|
+
const cfg = __GAME_KIT__;
|
|
11
|
+
const log = (m: string) => (document.getElementById('log')!.textContent += `${m}\n`);
|
|
12
|
+
document.getElementById('title')!.textContent = `${cfg.title} — preview (${cfg.locale})`;
|
|
13
|
+
|
|
14
|
+
const client = new Client('defaultkey', '127.0.0.1', '7350', false);
|
|
15
|
+
const run = crypto.randomUUID().slice(0, 8);
|
|
16
|
+
|
|
17
|
+
async function seat(index: number, session: Session, matchId: string): Promise<void> {
|
|
18
|
+
const iframe = document.createElement('iframe');
|
|
19
|
+
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
20
|
+
iframe.src = `${cfg.clientUrl}?locale=${encodeURIComponent(cfg.locale)}`;
|
|
21
|
+
document.getElementById('seats')!.appendChild(iframe);
|
|
22
|
+
|
|
23
|
+
const socket: Socket = client.createSocket(false, false);
|
|
24
|
+
let version = 0;
|
|
25
|
+
let counter = 0;
|
|
26
|
+
let last: StatePayload | null = null;
|
|
27
|
+
const bridge = createBridgeHost(iframePort(iframe), (move: LegalMove) => {
|
|
28
|
+
const base = { clientId: `s${index}-${counter++}`, stateVersion: version };
|
|
29
|
+
const envelope =
|
|
30
|
+
move.data.type === 'action' ? { kind: 'action', ...base, action: move.data } : { kind: move.type, ...base };
|
|
31
|
+
void socket.sendMatchState(matchId, 1, JSON.stringify(envelope));
|
|
32
|
+
});
|
|
33
|
+
socket.onmatchdata = (msg) => {
|
|
34
|
+
const body = JSON.parse(typeof msg.data === 'string' ? msg.data : new TextDecoder().decode(msg.data));
|
|
35
|
+
if (msg.op_code === 1) {
|
|
36
|
+
const view = body.view as PlayerView;
|
|
37
|
+
version = body.version;
|
|
38
|
+
const seatIndex = Object.keys(view.players).indexOf(view.viewerId as string);
|
|
39
|
+
last = { view, seat: seatIndex, legalMoves: body.legalMoves ?? [], status: 'active' };
|
|
40
|
+
bridge.pushState(last);
|
|
41
|
+
} else if (msg.op_code === 2 && body.ok === false) {
|
|
42
|
+
log(`seat ${index + 1}: rejected ${body.error?.code}: ${body.error?.message}`);
|
|
43
|
+
bridge.notice('rejected');
|
|
44
|
+
} else if (msg.op_code === 4 && last) {
|
|
45
|
+
last = { ...last, legalMoves: [], status: 'finished', result: body.result };
|
|
46
|
+
bridge.pushState(last);
|
|
47
|
+
log(`match ended: ${JSON.stringify(body.result)}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
socket.ondisconnect = () => bridge.notice('reconnecting');
|
|
51
|
+
await socket.connect(session, true);
|
|
52
|
+
await socket.joinMatch(matchId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function start(): Promise<void> {
|
|
56
|
+
const s1 = await client.authenticateDevice(`gk-${run}-seat1-device`, true, `seat1-${run}`);
|
|
57
|
+
const s2 = await client.authenticateDevice(`gk-${run}-seat2-device`, true, `seat2-${run}`);
|
|
58
|
+
const rpc = await client.rpc(s1, 'create_tabletop_match', JSON.stringify({
|
|
59
|
+
gameId: cfg.gameId,
|
|
60
|
+
playerIds: [s1.user_id, s2.user_id],
|
|
61
|
+
config: cfg.config,
|
|
62
|
+
}) as never);
|
|
63
|
+
const payload = rpc.payload as unknown;
|
|
64
|
+
const { matchId } = (typeof payload === 'string' ? JSON.parse(payload) : payload) as { matchId: string };
|
|
65
|
+
log(`match ${matchId}`);
|
|
66
|
+
await seat(0, s1, matchId);
|
|
67
|
+
await seat(1, s2, matchId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
start().catch((e) => log(`error: ${e instanceof Error ? e.message : JSON.stringify(e)}`));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Nakama + Postgres stack for local playground / smoke testing.
|
|
2
|
+
#
|
|
3
|
+
# Boots Postgres 12 first (Nakama's metadata store), waits for it to be
|
|
4
|
+
# healthy, then starts Nakama 3.21+. Nakama's TS runtime loads our
|
|
5
|
+
# bundled `build/index.js` via the `runtime.path` and
|
|
6
|
+
# `runtime.js_entrypoint` settings in `local.yml` (mounted read-only).
|
|
7
|
+
#
|
|
8
|
+
# Ports
|
|
9
|
+
# 7350 — Nakama HTTP API (gRPC, REST, WebSocket socket)
|
|
10
|
+
# 7351 — Nakama Console (browser UI)
|
|
11
|
+
# 7349 — Nakama gRPC (cluster / admin)
|
|
12
|
+
services:
|
|
13
|
+
postgres:
|
|
14
|
+
image: postgres:12-alpine
|
|
15
|
+
environment:
|
|
16
|
+
POSTGRES_DB: nakama
|
|
17
|
+
POSTGRES_USER: nakama
|
|
18
|
+
POSTGRES_PASSWORD: localdev
|
|
19
|
+
healthcheck:
|
|
20
|
+
test: ["CMD-SHELL", "pg_isready -U nakama -d nakama"]
|
|
21
|
+
interval: 3s
|
|
22
|
+
timeout: 3s
|
|
23
|
+
retries: 20
|
|
24
|
+
expose:
|
|
25
|
+
- "5432"
|
|
26
|
+
volumes:
|
|
27
|
+
- pgdata:/var/lib/postgresql/data
|
|
28
|
+
|
|
29
|
+
nakama:
|
|
30
|
+
# Use the Docker Hub multi-arch image (amd64 + arm64). The
|
|
31
|
+
# `registry.heroiclabs.com` mirror is amd64-only at older tags and
|
|
32
|
+
# crashes under Rosetta on Apple Silicon.
|
|
33
|
+
image: heroiclabs/nakama:3.30.0
|
|
34
|
+
depends_on:
|
|
35
|
+
postgres:
|
|
36
|
+
condition: service_healthy
|
|
37
|
+
entrypoint:
|
|
38
|
+
- "/bin/sh"
|
|
39
|
+
- "-ecx"
|
|
40
|
+
- >
|
|
41
|
+
/nakama/nakama migrate up --database.address postgres://nakama:localdev@postgres:5432/nakama &&
|
|
42
|
+
exec /nakama/nakama
|
|
43
|
+
--config /nakama/data/local.yml
|
|
44
|
+
--database.address postgres://nakama:localdev@postgres:5432/nakama
|
|
45
|
+
healthcheck:
|
|
46
|
+
test: ["CMD", "/nakama/nakama", "healthcheck"]
|
|
47
|
+
interval: 5s
|
|
48
|
+
timeout: 5s
|
|
49
|
+
retries: 20
|
|
50
|
+
ports:
|
|
51
|
+
- "7349:7349"
|
|
52
|
+
- "7350:7350"
|
|
53
|
+
- "7351:7351"
|
|
54
|
+
volumes:
|
|
55
|
+
- ./local.yml:/nakama/data/local.yml:ro
|
|
56
|
+
- ./build:/nakama/data/modules:ro
|
|
57
|
+
|
|
58
|
+
volumes:
|
|
59
|
+
pgdata:
|