@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/dist/cli.js ADDED
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/exec.ts
13
+ import { spawnSync } from "node:child_process";
14
+ function runTool(cmd, args, cwd) {
15
+ const r = spawnSync(cmd, [...args], { cwd, stdio: "inherit", shell: process.platform === "win32" });
16
+ return r.status ?? 1;
17
+ }
18
+ var init_exec = __esm({
19
+ "src/exec.ts"() {
20
+ "use strict";
21
+ }
22
+ });
23
+
24
+ // schema/game.schema.json
25
+ var game_schema_default;
26
+ var init_game_schema = __esm({
27
+ "schema/game.schema.json"() {
28
+ game_schema_default = {
29
+ $schema: "http://json-schema.org/draft-07/schema#",
30
+ $id: "https://github.com/Dmitry-Kucher/table-games-engine/game.schema.json",
31
+ title: "Store game metadata (game.json)",
32
+ type: "object",
33
+ definitions: {
34
+ localized: {
35
+ oneOf: [
36
+ { type: "string", minLength: 1 },
37
+ { type: "object", minProperties: 1, additionalProperties: { type: "string", minLength: 1 } }
38
+ ]
39
+ }
40
+ },
41
+ required: ["slug", "name", "version", "engine", "players", "locales", "description", "rules", "authors", "listed", "client"],
42
+ additionalProperties: false,
43
+ properties: {
44
+ $schema: { type: "string" },
45
+ slug: { type: "string", pattern: "^[a-z][a-z0-9-]{1,40}$" },
46
+ name: { $ref: "#/definitions/localized" },
47
+ version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
48
+ engine: { type: "string", minLength: 1 },
49
+ players: {
50
+ type: "object",
51
+ required: ["min", "max"],
52
+ additionalProperties: false,
53
+ properties: { min: { type: "integer", minimum: 2 }, max: { type: "integer", minimum: 2 } }
54
+ },
55
+ locales: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", pattern: "^[a-z]{2}(-[A-Z]{2})?$" } },
56
+ description: { $ref: "#/definitions/localized" },
57
+ rules: { type: "object", minProperties: 1, additionalProperties: { type: "string", pattern: "\\.md$" } },
58
+ tags: { type: "array", items: { type: "string" } },
59
+ cover: { type: "string" },
60
+ authors: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
61
+ credits: { type: "string" },
62
+ listed: { type: "boolean" },
63
+ client: {
64
+ type: "object",
65
+ required: ["kind", "entry"],
66
+ additionalProperties: false,
67
+ properties: { kind: { enum: ["iframe"] }, entry: { type: "string", minLength: 1 } }
68
+ },
69
+ presets: {
70
+ type: "array",
71
+ minItems: 1,
72
+ items: {
73
+ type: "object",
74
+ required: ["id", "name", "config"],
75
+ additionalProperties: false,
76
+ properties: {
77
+ id: { type: "string", pattern: "^[a-z0-9-]+$" },
78
+ name: { $ref: "#/definitions/localized" },
79
+ config: { type: "object" }
80
+ }
81
+ }
82
+ },
83
+ simulation: {
84
+ type: "object",
85
+ additionalProperties: false,
86
+ properties: { maxSteps: { type: "integer", minimum: 1 } }
87
+ }
88
+ }
89
+ };
90
+ }
91
+ });
92
+
93
+ // src/game-json.ts
94
+ import Ajv from "ajv";
95
+ import { existsSync as existsSync2 } from "node:fs";
96
+ import { join as join2 } from "node:path";
97
+ function validateGameJson(raw, dir) {
98
+ if (!validateSchema(raw)) {
99
+ return {
100
+ ok: false,
101
+ errors: (validateSchema.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "invalid"}`)
102
+ };
103
+ }
104
+ const game = raw;
105
+ const errors = [];
106
+ const coverage = (field, value) => {
107
+ if (typeof value === "string") return;
108
+ for (const l of game.locales) if (!value[l]) errors.push(`${field}: missing locale "${l}"`);
109
+ };
110
+ coverage("name", game.name);
111
+ coverage("description", game.description);
112
+ game.presets?.forEach((p, i) => coverage(`presets[${i}].name`, p.name));
113
+ for (const l of game.locales) {
114
+ const file = game.rules[l];
115
+ if (!file) errors.push(`rules: missing locale "${l}"`);
116
+ else if (!existsSync2(join2(dir, file))) errors.push(`rules.${l}: file "${file}" not found`);
117
+ }
118
+ const seen = /* @__PURE__ */ new Set();
119
+ for (const p of game.presets ?? []) {
120
+ if (seen.has(p.id)) errors.push(`presets: duplicate id "${p.id}"`);
121
+ seen.add(p.id);
122
+ }
123
+ if (game.players.min > game.players.max) errors.push(`players: min (${game.players.min}) > max (${game.players.max})`);
124
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, game };
125
+ }
126
+ var DEFAULT_MAX_STEPS, validateSchema;
127
+ var init_game_json = __esm({
128
+ "src/game-json.ts"() {
129
+ "use strict";
130
+ init_game_schema();
131
+ DEFAULT_MAX_STEPS = 2e3;
132
+ validateSchema = new Ajv({ allErrors: true }).compile(game_schema_default);
133
+ }
134
+ });
135
+
136
+ // src/load-game.ts
137
+ import { readFileSync as readFileSync2 } from "node:fs";
138
+ import { join as join3 } from "node:path";
139
+ import { pathToFileURL } from "node:url";
140
+ import { tsImport } from "tsx/esm/api";
141
+ async function loadGame(dir) {
142
+ const checked = validateGameJson(JSON.parse(readFileSync2(join3(dir, "game.json"), "utf8")), dir);
143
+ if (!checked.ok) throw new Error(`game.json is invalid:
144
+ ${checked.errors.join("\n ")}`);
145
+ const mod = await tsImport(pathToFileURL(join3(dir, "logic/index.ts")).href, import.meta.url);
146
+ if (!mod.default) throw new Error("logic/index.ts must `export default` the GamePlugin");
147
+ return { game: checked.game, plugin: mod.default };
148
+ }
149
+ var init_load_game = __esm({
150
+ "src/load-game.ts"() {
151
+ "use strict";
152
+ init_game_json();
153
+ }
154
+ });
155
+
156
+ // src/preview.ts
157
+ var preview_exports = {};
158
+ __export(preview_exports, {
159
+ preview: () => preview
160
+ });
161
+ import { copyFileSync, mkdirSync } from "node:fs";
162
+ import { join as join5 } from "node:path";
163
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
164
+ import { build as esbuild } from "esbuild";
165
+ import { build as viteBuild, createServer, preview as vitePreview } from "vite";
166
+ async function preview(dir, opts) {
167
+ const { game, plugin } = await loadGame(dir);
168
+ const preset = opts.preset ? game.presets?.find((p) => p.id === opts.preset) : game.presets?.[0];
169
+ if (opts.preset && !preset) throw new Error(`unknown preset "${opts.preset}"`);
170
+ const locale = opts.locale ?? game.locales[0];
171
+ if (!game.locales.includes(locale)) throw new Error(`locale "${locale}" is not in game.json locales`);
172
+ const work = join5(dir, ".game-kit");
173
+ mkdirSync(join5(work, "build"), { recursive: true });
174
+ await esbuild({
175
+ entryPoints: [join5(KIT, "nakama/init.ts")],
176
+ outfile: join5(work, "build/index.js"),
177
+ bundle: true,
178
+ platform: "neutral",
179
+ format: "cjs",
180
+ target: "es2017",
181
+ treeShaking: false,
182
+ external: ["better-sqlite3"],
183
+ alias: {
184
+ __game__: join5(dir, "logic/index.ts"),
185
+ "@table-games-engine/persistence": "@table-games-engine/persistence/core"
186
+ },
187
+ logLevel: "warning"
188
+ });
189
+ copyFileSync(join5(KIT, "nakama/docker-compose.yml"), join5(work, "docker-compose.yml"));
190
+ copyFileSync(join5(KIT, "nakama/local.yml"), join5(work, "local.yml"));
191
+ const project = `game-kit-${game.slug}`;
192
+ let stop;
193
+ const stopped = new Promise((resolve) => {
194
+ stop = resolve;
195
+ });
196
+ process.on("SIGINT", () => stop());
197
+ process.on("SIGTERM", () => stop());
198
+ let client;
199
+ let host;
200
+ const setup = async () => {
201
+ if (runTool("docker", ["compose", "-p", project, "up", "-d", "--wait"], work) !== 0) {
202
+ throw new Error("docker compose up failed \u2014 is Docker running and port 7350 free?");
203
+ }
204
+ await viteBuild({ root: join5(dir, "client"), logLevel: "warn", build: { outDir: "dist", emptyOutDir: true } });
205
+ const cs = await vitePreview({
206
+ root: join5(dir, "client"),
207
+ preview: { cors: true, host: "127.0.0.1", port: CLIENT_PORT, strictPort: true, headers: { "Content-Security-Policy": "connect-src 'none'" } }
208
+ });
209
+ client = { close: () => new Promise((r) => cs.httpServer.close(() => r())) };
210
+ const title = typeof game.name === "string" ? game.name : game.name[locale] ?? game.slug;
211
+ const hs = await createServer({
212
+ root: join5(KIT, "host"),
213
+ server: { host: "127.0.0.1", port: HOST_PORT, strictPort: true, fs: { allow: [KIT] } },
214
+ define: {
215
+ __GAME_KIT__: JSON.stringify({
216
+ gameId: String(plugin.id),
217
+ config: preset?.config ?? {},
218
+ locale,
219
+ clientUrl: `http://127.0.0.1:${CLIENT_PORT}/`,
220
+ title
221
+ })
222
+ }
223
+ });
224
+ host = hs;
225
+ await hs.listen();
226
+ console.log(`preview: http://127.0.0.1:${HOST_PORT}/ (preset=${preset?.id ?? "none"}, locale=${locale}) \u2014 Ctrl+C to stop`);
227
+ };
228
+ try {
229
+ const ready = setup();
230
+ ready.catch(() => {
231
+ });
232
+ await Promise.race([ready, stopped]);
233
+ await stopped;
234
+ } finally {
235
+ console.log("preview: tearing down\u2026");
236
+ await host?.close();
237
+ await client?.close();
238
+ runTool("docker", ["compose", "-p", project, "down", "-v"], work);
239
+ }
240
+ }
241
+ var KIT, HOST_PORT, CLIENT_PORT;
242
+ var init_preview = __esm({
243
+ "src/preview.ts"() {
244
+ "use strict";
245
+ init_load_game();
246
+ init_exec();
247
+ KIT = fileURLToPath2(new URL("..", import.meta.url));
248
+ HOST_PORT = 5173;
249
+ CLIENT_PORT = 5174;
250
+ }
251
+ });
252
+
253
+ // src/cli.ts
254
+ import { parseArgs } from "node:util";
255
+
256
+ // src/create.ts
257
+ init_exec();
258
+ import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
259
+ import { join, relative, sep } from "node:path";
260
+ import { fileURLToPath } from "node:url";
261
+ var TEMPLATE = fileURLToPath(new URL("../template", import.meta.url));
262
+ var FILLED = ["package.json", "game.json", "rules.md", "AGENTS.md", "logic/index.ts"];
263
+ function createGame(cwd, slug, opts) {
264
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(slug)) {
265
+ throw new Error(`invalid slug "${slug}": use lowercase letters, digits and dashes (e.g. "dragon-duel")`);
266
+ }
267
+ const target = join(cwd, slug);
268
+ if (existsSync(target)) throw new Error(`${target} already exists`);
269
+ const template = opts.template ?? TEMPLATE;
270
+ cpSync(template, target, { recursive: true, filter: (src) => !relative(template, src).split(sep).includes("node_modules") });
271
+ renameSync(join(target, "gitignore"), join(target, ".gitignore"));
272
+ const name = slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
273
+ for (const f of FILLED) {
274
+ const p = join(target, f);
275
+ writeFileSync(p, readFileSync(p, "utf8").replaceAll("__SLUG__", slug).replaceAll("__NAME__", name));
276
+ }
277
+ if (opts.install && runTool("npm", ["install"], target) !== 0) throw new Error("npm install failed");
278
+ return target;
279
+ }
280
+
281
+ // src/check.ts
282
+ init_game_json();
283
+ init_load_game();
284
+ import { readFileSync as readFileSync3 } from "node:fs";
285
+ import { join as join4 } from "node:path";
286
+ import { build } from "vite";
287
+
288
+ // src/lint.ts
289
+ import { relative as relative2 } from "node:path";
290
+ import { ESLint } from "eslint";
291
+ import tseslint from "typescript-eslint";
292
+ var ALLOWED = "may import only @table-games-engine/core and relative paths";
293
+ var allowedImports = {
294
+ meta: { type: "problem", schema: [] },
295
+ create(context) {
296
+ const check = (node, source) => {
297
+ if (typeof source !== "string") return;
298
+ if (source === "@table-games-engine/core" || source.startsWith("./") || source.startsWith("../")) return;
299
+ context.report({ node, message: `Store game logic ${ALLOWED} (found "${source}").` });
300
+ };
301
+ return {
302
+ ImportDeclaration: (n) => check(n, n.source.value),
303
+ ExportNamedDeclaration: (n) => n.source && check(n, n.source.value),
304
+ ExportAllDeclaration: (n) => check(n, n.source.value),
305
+ ImportExpression: (n) => context.report({ node: n, message: `Dynamic import is not allowed; logic ${ALLOWED}.` })
306
+ };
307
+ }
308
+ };
309
+ var NONDETERMINISM = "is non-deterministic or environment-dependent; use ctx.random*/state instead";
310
+ var logicLintConfig = [
311
+ {
312
+ files: ["logic/**/*.ts"],
313
+ languageOptions: { parser: tseslint.parser },
314
+ plugins: { "game-kit": { rules: { "allowed-imports": allowedImports } } },
315
+ rules: {
316
+ "game-kit/allowed-imports": "error",
317
+ "no-restricted-properties": ["error", { object: "Math", property: "random", message: `Math.random ${NONDETERMINISM}.` }],
318
+ "no-restricted-globals": [
319
+ "error",
320
+ ...["Date", "globalThis", "window", "process", "setTimeout", "setInterval", "fetch", "require"].map((name) => ({
321
+ name,
322
+ message: `'${name}' ${NONDETERMINISM}.`
323
+ }))
324
+ ],
325
+ "no-restricted-syntax": [
326
+ "error",
327
+ ...["let", "var"].flatMap((kind) => [
328
+ { selector: `Program > VariableDeclaration[kind='${kind}']`, message: `module-level '${kind}' is shared mutable state across matches.` },
329
+ { selector: `Program > ExportNamedDeclaration > VariableDeclaration[kind='${kind}']`, message: `module-level '${kind}' is shared mutable state across matches.` }
330
+ ])
331
+ ]
332
+ }
333
+ }
334
+ ];
335
+ async function lintLogic(dir) {
336
+ const eslint = new ESLint({ cwd: dir, overrideConfigFile: true, overrideConfig: logicLintConfig });
337
+ const results = await eslint.lintFiles(["logic/**/*.ts"]);
338
+ const lines = results.flatMap(
339
+ (r) => r.messages.map((m) => `${relative2(dir, r.filePath)}:${m.line}:${m.column} ${m.message}`)
340
+ );
341
+ return lines.length > 0 ? lines.join("\n") : null;
342
+ }
343
+
344
+ // src/simulate.ts
345
+ import { createHash } from "node:crypto";
346
+ import {
347
+ driveAction,
348
+ rulesFromPlugin,
349
+ setupMatch
350
+ } from "@table-games-engine/core";
351
+ var MOVE_WARN_LIMIT = 5e3;
352
+ var DEFAULT_PRESET = { id: "default", name: "Default", config: {} };
353
+ function runSimulation(plugin, opts) {
354
+ if (!plugin.legalMoves) {
355
+ return { ok: false, played: 0, warnings: [], failures: [{ preset: "-", players: 0, seed: opts.seed, step: 0, reason: "plugin has no legalMoves", lastMoves: [] }] };
356
+ }
357
+ const failures = [];
358
+ const warnings = /* @__PURE__ */ new Set();
359
+ let played = 0;
360
+ for (const preset of opts.presets.length > 0 ? opts.presets : [DEFAULT_PRESET]) {
361
+ for (const players of opts.players) {
362
+ for (let i = 0; i < opts.matches; i++) {
363
+ const seed = opts.seed + i;
364
+ const first = playMatch(plugin, preset, players, seed, opts, warnings);
365
+ played += 1;
366
+ if (first.failure) {
367
+ failures.push(first.failure);
368
+ continue;
369
+ }
370
+ const replay = playMatch(plugin, preset, players, seed, opts, warnings);
371
+ if (replay.failure || replay.hash !== first.hash) {
372
+ failures.push({
373
+ preset: preset.id,
374
+ players,
375
+ seed,
376
+ step: first.step,
377
+ reason: "non-deterministic: replaying the same seed produced a different final state",
378
+ lastMoves: first.moves.slice(-20)
379
+ });
380
+ }
381
+ }
382
+ }
383
+ }
384
+ return { ok: failures.length === 0, played, failures, warnings: [...warnings] };
385
+ }
386
+ function playMatch(plugin, preset, players, seed, opts, warnings) {
387
+ const moves = [];
388
+ const fail = (step, reason) => ({
389
+ failure: { preset: preset.id, players, seed, step, reason, lastMoves: moves.slice(-20) },
390
+ hash: "",
391
+ step,
392
+ moves
393
+ });
394
+ const playerIds = Array.from({ length: players }, (_, i) => `p${i + 1}`);
395
+ let state;
396
+ try {
397
+ state = setupMatch(plugin, { playerIds, config: preset.config, seed: `sim-${seed}` });
398
+ } catch (e) {
399
+ return fail(0, `setup threw: ${message(e)}`);
400
+ }
401
+ const rules = rulesFromPlugin(plugin);
402
+ const random = mulberry32(seed);
403
+ for (let step = 0; step < opts.maxSteps; step++) {
404
+ if (state.result) return { failure: null, hash: hashState(state), step, moves };
405
+ const actor = state.priority.holder ?? state.turn.activePlayer;
406
+ let options;
407
+ try {
408
+ options = plugin.legalMoves(state, actor);
409
+ } catch (e) {
410
+ return fail(step, `legalMoves threw: ${message(e)}`);
411
+ }
412
+ if (options.length === 0) return fail(step, `legalMoves is empty for ${String(actor)}, who must act`);
413
+ if (options.length > MOVE_WARN_LIMIT) {
414
+ warnings.add(`${preset.id}: legalMoves returned ${options.length} moves (> ${MOVE_WARN_LIMIT})`);
415
+ }
416
+ const move = options[Math.floor(random() * options.length)];
417
+ moves.push(move);
418
+ let driven;
419
+ try {
420
+ driven = driveAction(rules, state, { ...move, clientId: `sim-${step}`, stateVersion: state.version });
421
+ } catch (e) {
422
+ return fail(step, `game logic threw: ${message(e)}`);
423
+ }
424
+ if (!driven.ok) {
425
+ const why = driven.error.valid ? "unknown" : `${driven.error.error.code}: ${driven.error.error.message}`;
426
+ return fail(step, `legalMoves offered a move the engine rejected (${why})`);
427
+ }
428
+ state = driven.state;
429
+ }
430
+ return state.result ? { failure: null, hash: hashState(state), step: opts.maxSteps, moves } : fail(opts.maxSteps, `no result after maxSteps=${opts.maxSteps}`);
431
+ }
432
+ function formatReport(r) {
433
+ const lines = [`simulate: ${r.played} matches, ${r.failures.length} failed`];
434
+ for (const w of r.warnings) lines.push(` warning: ${w}`);
435
+ for (const f of r.failures.slice(0, 5)) {
436
+ lines.push(` FAIL preset=${f.preset} players=${f.players} seed=${f.seed} step=${f.step}: ${f.reason}`);
437
+ lines.push(` replay: game-kit simulate --preset ${f.preset} --players ${f.players} --seed ${f.seed} -n 1`);
438
+ for (const m of f.lastMoves) lines.push(` ${JSON.stringify(m)}`);
439
+ }
440
+ return lines.join("\n");
441
+ }
442
+ function hashState(s) {
443
+ return createHash("sha256").update(JSON.stringify(s)).digest("hex");
444
+ }
445
+ function message(e) {
446
+ return e instanceof Error ? e.message : String(e);
447
+ }
448
+ function mulberry32(seed) {
449
+ let a = seed >>> 0;
450
+ return () => {
451
+ a = a + 1831565813 >>> 0;
452
+ let t = a;
453
+ t = Math.imul(t ^ t >>> 15, t | 1);
454
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
455
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
456
+ };
457
+ }
458
+
459
+ // src/check.ts
460
+ init_exec();
461
+ async function runCheck(dir, stages = defaultStages) {
462
+ for (const stage of stages) {
463
+ let failure;
464
+ try {
465
+ failure = await stage.run(dir);
466
+ } catch (e) {
467
+ failure = e instanceof Error ? e.message : String(e);
468
+ }
469
+ if (failure !== null) return { ok: false, failedStage: stage.name, message: failure };
470
+ }
471
+ return { ok: true };
472
+ }
473
+ var tool = (name, cmd, args) => ({
474
+ name,
475
+ run: async (dir) => runTool(cmd, args, dir) === 0 ? null : `${cmd} ${args.join(" ")} failed`
476
+ });
477
+ function simulateStage(opts) {
478
+ return {
479
+ name: "simulate",
480
+ run: async (dir) => {
481
+ const { game, plugin } = await loadGame(dir);
482
+ const presets = (game.presets ?? []).filter((p) => !opts.preset || p.id === opts.preset);
483
+ if (opts.preset && presets.length === 0) return `unknown preset "${opts.preset}"`;
484
+ const { min, max } = game.players;
485
+ if (opts.players !== void 0 && (opts.players < min || opts.players > max)) {
486
+ return `--players ${opts.players} is outside game.json players (${min}..${max})`;
487
+ }
488
+ const report = runSimulation(plugin, {
489
+ matches: opts.matches,
490
+ seed: opts.seed,
491
+ maxSteps: game.simulation?.maxSteps ?? DEFAULT_MAX_STEPS,
492
+ players: opts.players !== void 0 ? [opts.players] : Array.from({ length: max - min + 1 }, (_, i) => min + i),
493
+ presets
494
+ });
495
+ console.log(formatReport(report));
496
+ return report.ok ? null : "simulation failed (see above)";
497
+ }
498
+ };
499
+ }
500
+ var defaultStages = [
501
+ {
502
+ name: "game.json",
503
+ run: async (dir) => {
504
+ const r = validateGameJson(JSON.parse(readFileSync3(join4(dir, "game.json"), "utf8")), dir);
505
+ return r.ok ? null : r.errors.join("\n");
506
+ }
507
+ },
508
+ { name: "lint", run: lintLogic },
509
+ tool("typecheck", "npx", ["tsc", "--noEmit", "-p", "."]),
510
+ tool("test", "npx", ["vitest", "run"]),
511
+ simulateStage({ matches: 1e3, seed: 1 }),
512
+ {
513
+ name: "client-build",
514
+ run: async (dir) => {
515
+ await build({ root: join4(dir, "client"), logLevel: "warn", build: { outDir: "dist", emptyOutDir: true } });
516
+ return null;
517
+ }
518
+ }
519
+ ];
520
+
521
+ // src/cli.ts
522
+ init_exec();
523
+ var USAGE = `game-kit <command>
524
+ create <slug> [--no-install] scaffold a new store game
525
+ test run the game's tests
526
+ simulate [-n 1000] [--seed 1] [--preset id] [--players N] (default: every count in game.json players)
527
+ check everything CI runs (game.json, lint, tsc, tests, simulate, client build)
528
+ preview [--preset id] [--locale xx] play both seats locally (needs Docker)`;
529
+ async function main() {
530
+ const [cmd, ...rest] = process.argv.slice(2);
531
+ const { values, positionals } = parseArgs({
532
+ args: rest,
533
+ allowPositionals: true,
534
+ options: {
535
+ n: { type: "string", short: "n" },
536
+ seed: { type: "string" },
537
+ preset: { type: "string" },
538
+ players: { type: "string" },
539
+ locale: { type: "string" },
540
+ "no-install": { type: "boolean" }
541
+ }
542
+ });
543
+ const cwd = process.cwd();
544
+ switch (cmd) {
545
+ case "create": {
546
+ const slug = positionals[0];
547
+ if (!slug) break;
548
+ const dir = createGame(cwd, slug, { install: !values["no-install"] });
549
+ console.log(`created ${dir}
550
+ next: cd ${slug} && npx game-kit check`);
551
+ return 0;
552
+ }
553
+ case "test":
554
+ return runTool("npx", ["vitest", "run"], cwd);
555
+ case "simulate": {
556
+ const matches = int(values.n ?? "1000", "-n", 1);
557
+ const seed = int(values.seed ?? "1", "--seed", 0);
558
+ const players = values.players === void 0 ? void 0 : int(values.players, "--players", 1);
559
+ if (matches === null || seed === null || players === null) return 1;
560
+ const stage = simulateStage({
561
+ matches,
562
+ seed,
563
+ ...values.preset ? { preset: values.preset } : {},
564
+ ...players !== void 0 ? { players } : {}
565
+ });
566
+ const r = await runCheck(cwd, [stage]);
567
+ if (!r.ok) console.error(r.message);
568
+ return r.ok ? 0 : 1;
569
+ }
570
+ case "check": {
571
+ const r = await runCheck(cwd, defaultStages);
572
+ console.log(r.ok ? "check: OK" : `check: FAILED at ${r.failedStage}
573
+ ${r.message}`);
574
+ return r.ok ? 0 : 1;
575
+ }
576
+ case "preview": {
577
+ const { preview: preview2 } = await Promise.resolve().then(() => (init_preview(), preview_exports));
578
+ await preview2(cwd, { ...values.preset ? { preset: values.preset } : {}, ...values.locale ? { locale: values.locale } : {} });
579
+ return 0;
580
+ }
581
+ }
582
+ console.log(USAGE);
583
+ return cmd ? 1 : 0;
584
+ }
585
+ function int(raw, flag, min) {
586
+ if (/^\d+$/.test(raw) && Number(raw) >= min) return Number(raw);
587
+ console.error(`${flag} must be an integer >= ${min}, got "${raw}"`);
588
+ return null;
589
+ }
590
+ main().then((code) => process.exit(code), (e) => {
591
+ console.error(e instanceof Error ? e.message : e);
592
+ process.exit(1);
593
+ });
@@ -0,0 +1,4 @@
1
+ export declare function createGame(cwd: string, slug: string, opts: {
2
+ install: boolean;
3
+ template?: string;
4
+ }): string;
package/dist/exec.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runTool(cmd: string, args: ReadonlyArray<string>, cwd: string): number;
@@ -0,0 +1,41 @@
1
+ import type { JsonObject } from '@table-games-engine/core';
2
+ export type Localized = string | Readonly<Record<string, string>>;
3
+ export interface Preset {
4
+ readonly id: string;
5
+ readonly name: Localized;
6
+ readonly config: JsonObject;
7
+ }
8
+ export interface GameJson {
9
+ readonly slug: string;
10
+ readonly name: Localized;
11
+ readonly version: string;
12
+ readonly engine: string;
13
+ readonly players: {
14
+ readonly min: number;
15
+ readonly max: number;
16
+ };
17
+ readonly locales: ReadonlyArray<string>;
18
+ readonly description: Localized;
19
+ readonly rules: Readonly<Record<string, string>>;
20
+ readonly tags?: ReadonlyArray<string>;
21
+ readonly cover?: string;
22
+ readonly authors: ReadonlyArray<string>;
23
+ readonly credits?: string;
24
+ readonly listed: boolean;
25
+ readonly client: {
26
+ readonly kind: 'iframe';
27
+ readonly entry: string;
28
+ };
29
+ readonly presets?: ReadonlyArray<Preset>;
30
+ readonly simulation?: {
31
+ readonly maxSteps?: number;
32
+ };
33
+ }
34
+ export declare const DEFAULT_MAX_STEPS = 2000;
35
+ export declare function validateGameJson(raw: unknown, dir: string): {
36
+ ok: true;
37
+ game: GameJson;
38
+ } | {
39
+ ok: false;
40
+ errors: string[];
41
+ };
@@ -0,0 +1,8 @@
1
+ export { validateGameJson, DEFAULT_MAX_STEPS } from './game-json.js';
2
+ export type { GameJson, Localized, Preset } from './game-json.js';
3
+ export { loadGame } from './load-game.js';
4
+ export { runSimulation, formatReport } from './simulate.js';
5
+ export type { SimulateOptions, SimulateReport, MatchFailure } from './simulate.js';
6
+ export { runCheck, defaultStages, simulateStage } from './check.js';
7
+ export type { Stage } from './check.js';
8
+ export { lintLogic } from './lint.js';