gamekit777 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.
Files changed (136) hide show
  1. package/README.md +48 -0
  2. package/client/http.ts +253 -0
  3. package/client/index.ts +211 -0
  4. package/client/node.ts +61 -0
  5. package/create-game/scaffold.ts +73 -0
  6. package/create-game/template/CLAUDE.md.tmpl +167 -0
  7. package/create-game/template/_gitignore +4 -0
  8. package/create-game/template/game.meta.json.tmpl +8 -0
  9. package/create-game/template/game.ts.tmpl +57 -0
  10. package/create-game/template/index.html.tmpl +11 -0
  11. package/create-game/template/package.json.tmpl +26 -0
  12. package/create-game/template/scripts/verify.ts.tmpl +79 -0
  13. package/create-game/template/src/assets/critical/.gitkeep +0 -0
  14. package/create-game/template/src/assets/lazy/.gitkeep +0 -0
  15. package/create-game/template/src/assets/manifest.ts +30 -0
  16. package/create-game/template/src/assets/registry.ts +23 -0
  17. package/create-game/template/src/components/App.svelte.tmpl +54 -0
  18. package/create-game/template/src/context.svelte.ts.tmpl +22 -0
  19. package/create-game/template/src/dev.ts +8 -0
  20. package/create-game/template/src/index.ts.tmpl +17 -0
  21. package/create-game/template/src/rules/const.ts +9 -0
  22. package/create-game/template/src/rules/restore.ts.tmpl +39 -0
  23. package/create-game/template/src/rules/schema.ts.tmpl +19 -0
  24. package/create-game/template/src/rules/simulate.ts.tmpl +9 -0
  25. package/create-game/template/src/rules/types.ts.tmpl +13 -0
  26. package/create-game/template/src/state/game.svelte.ts.tmpl +45 -0
  27. package/create-game/template/src/styles/animations.css +4 -0
  28. package/create-game/template/src/styles/global.css +18 -0
  29. package/create-game/template/src/view/bridge.svelte.ts +33 -0
  30. package/create-game/template/src/view/mount.svelte.ts.tmpl +86 -0
  31. package/create-game/template/src/view/present.ts.tmpl +36 -0
  32. package/create-game/template/test/e2e.test.ts.tmpl +81 -0
  33. package/create-game/template/tsconfig.json +22 -0
  34. package/create-game/template/vite.config.ts +4 -0
  35. package/create-game/template/vitest.config.ts +10 -0
  36. package/dev-host/DevShell.svelte +348 -0
  37. package/dev-host/Field.svelte +25 -0
  38. package/dev-host/SchemaFields.svelte +14 -0
  39. package/dev-host/SchemaFieldsPure.svelte +98 -0
  40. package/dev-host/context.svelte.ts +9 -0
  41. package/dev-host/fonts.ts +16 -0
  42. package/dev-host/index.ts +8 -0
  43. package/dev-host/local.ts +69 -0
  44. package/dev-host/platform-server.ts +78 -0
  45. package/dev-host/platform.svelte.ts +277 -0
  46. package/dev-host/remote.ts +178 -0
  47. package/dev-host/run.ts +46 -0
  48. package/dev-host/table-server.ts +194 -0
  49. package/dev-host/theme.css +344 -0
  50. package/lut/book.ts +97 -0
  51. package/lut/csv.ts +37 -0
  52. package/lut/format.ts +106 -0
  53. package/lut/index.ts +4 -0
  54. package/lut/verify.ts +243 -0
  55. package/package.json +58 -0
  56. package/protocol/bets.ts +74 -0
  57. package/protocol/errors.ts +90 -0
  58. package/protocol/games.ts +158 -0
  59. package/protocol/index.ts +9 -0
  60. package/protocol/money.ts +51 -0
  61. package/protocol/rounds.ts +60 -0
  62. package/protocol/seeds.ts +36 -0
  63. package/protocol/session.ts +18 -0
  64. package/protocol/tables.ts +120 -0
  65. package/protocol/verify.ts +33 -0
  66. package/publish/build.ts +168 -0
  67. package/publish/index.ts +6 -0
  68. package/publish/parallel.ts +56 -0
  69. package/publish/sample-worker.ts +31 -0
  70. package/publish/sample.ts +131 -0
  71. package/publish/spec.ts +8 -0
  72. package/publish/table.ts +244 -0
  73. package/publish/upload-core.ts +176 -0
  74. package/publish/upload.ts +30 -0
  75. package/runtime/assets.ts +122 -0
  76. package/runtime/fonts.ts +21 -0
  77. package/runtime/index.ts +3 -0
  78. package/runtime/types.ts +42 -0
  79. package/sdk/contract.ts +51 -0
  80. package/sdk/hash.ts +184 -0
  81. package/sdk/host.ts +96 -0
  82. package/sdk/index.ts +9 -0
  83. package/sdk/rng.ts +109 -0
  84. package/sdk/sample.ts +68 -0
  85. package/sdk/schema.ts +90 -0
  86. package/sdk/spec.ts +255 -0
  87. package/sdk/stage.ts +20 -0
  88. package/sdk/store.ts +67 -0
  89. package/studio/app/App.svelte +113 -0
  90. package/studio/app/lib/api.ts +59 -0
  91. package/studio/app/lib/bus.svelte.ts +44 -0
  92. package/studio/app/lib/upload.ts +34 -0
  93. package/studio/app/main.ts +5 -0
  94. package/studio/app/panels/CasePanel.svelte +83 -0
  95. package/studio/app/panels/LogPanel.svelte +19 -0
  96. package/studio/app/panels/PreviewPanel.svelte +33 -0
  97. package/studio/app/panels/PublishPanel.svelte +111 -0
  98. package/studio/app/panels/TablePanel.svelte +64 -0
  99. package/studio/app/panels/TuningPanel.svelte +140 -0
  100. package/studio/app/virtual.d.ts +6 -0
  101. package/studio/bin.ts +68 -0
  102. package/studio/cli.ts +43 -0
  103. package/studio/preview/bridge.ts +63 -0
  104. package/studio/preview/entry.ts +89 -0
  105. package/studio/preview/virtual.d.ts +6 -0
  106. package/studio/src/api.ts +157 -0
  107. package/studio/src/build.ts +166 -0
  108. package/studio/src/bus.ts +98 -0
  109. package/studio/src/canon.ts +18 -0
  110. package/studio/src/cases.ts +255 -0
  111. package/studio/src/config.ts +39 -0
  112. package/studio/src/engine.ts +218 -0
  113. package/studio/src/fingerprint.ts +39 -0
  114. package/studio/src/game-vite.ts +91 -0
  115. package/studio/src/game.ts +173 -0
  116. package/studio/src/jobs.ts +59 -0
  117. package/studio/src/mcp.ts +357 -0
  118. package/studio/src/probe.ts +359 -0
  119. package/studio/src/scaffold.ts +85 -0
  120. package/studio/src/solver.ts +139 -0
  121. package/studio/src/stats.ts +134 -0
  122. package/studio/src/studio-plugin.ts +76 -0
  123. package/studio/src/tasks.ts +52 -0
  124. package/studio/src/worker/pool.ts +84 -0
  125. package/studio/src/worker/rpc.ts +178 -0
  126. package/vite-config/index.js +207 -0
  127. package/vite-config/index.ts +87 -0
  128. package/vite-config/manifest.ts +144 -0
  129. package/vite-config/meta.ts +41 -0
  130. package/vite-config/namespace-css.ts +74 -0
  131. package/weights/index.ts +11 -0
  132. package/weights/linalg.ts +118 -0
  133. package/weights/report.ts +78 -0
  134. package/weights/solve.ts +236 -0
  135. package/weights/types.ts +73 -0
  136. package/weights/volatility.ts +40 -0
package/studio/bin.ts ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
2
+ /* GameKit Studio:创作者工作台。
3
+ *
4
+ * 两种启动方式,同一个进程形态:
5
+ * 被 Claude Code 拉起(stdio 不是 TTY) → MCP 走 stdio,页面照常起在 HTTP 上
6
+ * 手动 `bunx gamekit777 studio [dir]` → 只起页面,AI 想接就配 stdio
7
+ *
8
+ * stdio 模式下 stdout 是协议通道,所有人话都走 stderr。 */
9
+ import { loadConfig } from './src/config.ts';
10
+ import { Engine } from './src/engine.ts';
11
+ import { startGameServer } from './src/game-vite.ts';
12
+ import { serveStdio } from './src/mcp.ts';
13
+ import { PAGE_PATH, PREVIEW_PATH } from './src/studio-plugin.ts';
14
+
15
+ const argv = process.argv.slice(2);
16
+ const forceMcp = argv.includes('--mcp');
17
+ const noMcp = argv.includes('--no-mcp');
18
+ const mcpMode = forceMcp || (!noMcp && !process.stdin.isTTY);
19
+ const rest = argv.filter((a) => a !== '--mcp' && a !== '--no-mcp');
20
+
21
+ const say = (m: string): void => { process.stderr.write(`${m}\n`); };
22
+ if (mcpMode) {
23
+ // 任何人在任何地方 console.log 都不能污染协议
24
+ console.log = (...a: unknown[]) => { process.stderr.write(`${a.map(String).join(' ')}\n`); };
25
+ }
26
+
27
+ let cfg;
28
+ try {
29
+ cfg = loadConfig(rest);
30
+ } catch (e) {
31
+ say(`✗ ${e instanceof Error ? e.message : String(e)}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ const backendUrl = process.env.GAMEKIT_BASE_URL ?? 'http://127.0.0.1:8787';
36
+ const placeholder = { studio: '', preview: '' };
37
+ const engine = new Engine(cfg.gameDir, placeholder);
38
+
39
+ const server = await startGameServer({
40
+ gameDir: cfg.gameDir, studioRoot: cfg.studioRoot, port: cfg.gamePort, backendUrl, engine, quiet: mcpMode,
41
+ });
42
+ const base = (server.resolvedUrls?.local[0] ?? `http://127.0.0.1:${cfg.gamePort}/`).replace(/\/$/, '');
43
+ placeholder.studio = `${base}${PAGE_PATH}`;
44
+ placeholder.preview = `${base}${PREVIEW_PATH}`;
45
+
46
+ let mcp: Awaited<ReturnType<typeof serveStdio>> | null = null;
47
+ if (mcpMode) mcp = await serveStdio(engine);
48
+
49
+ const snap = await engine.game.snapshot().catch((e: unknown) => { say(`⚠️ 加载游戏逻辑失败:${e instanceof Error ? e.message : String(e)}`); return null; });
50
+
51
+ say(`
52
+ GameKit Studio${mcpMode ? '(MCP over stdio)' : ''}
53
+ 游戏 ${cfg.gameDir}${snap ? ` · ${snap.spec.title}(${snap.spec.slug}) · ${snap.spec.modes.length} 个 mode` : ''}
54
+ 页面 ${placeholder.studio}
55
+ 预览 ${placeholder.preview}
56
+ 后端 ${backendUrl} → /v1
57
+ ${mcpMode ? '' : `
58
+ 接 AI:claude mcp add gamekit -- bunx gamekit777 studio
59
+ `}`);
60
+
61
+ const shutdown = async (): Promise<void> => {
62
+ await Promise.allSettled([mcp?.close(), server.close(), engine.dispose()]);
63
+ process.exit(0);
64
+ };
65
+ process.on('SIGINT', () => { void shutdown(); });
66
+ process.on('SIGTERM', () => { void shutdown(); });
67
+ // stdin 关了就是 Claude Code 走了,跟着退出,不留僵尸进程
68
+ if (mcpMode) process.stdin.on('end', () => { void shutdown(); });
package/studio/cli.ts ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bun
2
+ /* `gamekit777` 这个命令的入口:按子命令分派。
3
+ *
4
+ * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] 起工作台(MCP 走 stdio)
5
+ * gamekit777 new <id> [标题] [--dest 目录] 脚手架一个新游戏并装依赖
6
+ *
7
+ * 只是薄薄一层分派,逻辑各在 bin.ts 和 src/scaffold.ts。 */
8
+ const [sub, ...rest] = process.argv.slice(2);
9
+
10
+ const usage = (): never => {
11
+ process.stderr.write(`用法:
12
+ gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N]
13
+ gamekit777 new <id> [标题] [--dest 目录]
14
+
15
+ 接 Claude Code:在游戏目录里
16
+ claude mcp add gamekit -- bunx gamekit777 studio
17
+ `);
18
+ process.exit(1);
19
+ };
20
+
21
+ switch (sub) {
22
+ case 'studio': {
23
+ process.argv = [process.argv[0]!, process.argv[1]!, ...rest];
24
+ await import('./bin.ts');
25
+ break;
26
+ }
27
+ case 'new': {
28
+ const { scaffold } = await import('./src/scaffold.ts');
29
+ const destAt = rest.indexOf('--dest');
30
+ const dest = destAt >= 0 ? rest.splice(destAt, 2)[1] : undefined;
31
+ const [id, title] = rest;
32
+ if (!id) usage();
33
+ try {
34
+ const r = await scaffold({ id: id!, title, dest }, process.cwd());
35
+ process.stdout.write(`✓ ${r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n cd ${r.dest} && bunx gamekit777 studio\n`);
36
+ } catch (e) {
37
+ process.stderr.write(`✗ ${e instanceof Error ? e.message : String(e)}\n`);
38
+ process.exit(1);
39
+ }
40
+ break;
41
+ }
42
+ default: usage();
43
+ }
@@ -0,0 +1,63 @@
1
+ /* 预览壳 ↔ node 侧。下行 SSE 收命令,上行 POST 回结果和状态。
2
+ *
3
+ * 同源,所以直接 fetch /studio/api。控制台报错也顺手收一份上报——
4
+ * AI 读 preview_state 时最想知道的就是「预览里报了什么错」。 */
5
+ const API = '/studio/api';
6
+
7
+ export interface Cmd { id: string; cmd: Record<string, unknown> & { t: string } }
8
+
9
+ export type CmdHandler = (cmd: Cmd['cmd']) => Promise<unknown>;
10
+
11
+ export function connectBridge(o: { onCommand: CmdHandler; onFingerprint?: (hash: string) => void; onBuild?: () => void }): { report: (state: unknown) => void; log: (msg: string) => void; errors: string[] } {
12
+ const errors: string[] = [];
13
+ const post = (path: string, body: unknown): Promise<Response> =>
14
+ fetch(`${API}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
15
+
16
+ const es = new EventSource(`${API}/events?role=preview`);
17
+ es.onmessage = (e) => {
18
+ let msg: { t: string; id?: string; cmd?: Cmd['cmd']; hash?: string };
19
+ try { msg = JSON.parse(String(e.data)); } catch { return; }
20
+ if (msg.t === 'cmd' && msg.id && msg.cmd) {
21
+ const id = msg.id;
22
+ o.onCommand(msg.cmd).then(
23
+ (payload) => post('/preview/result', { id, ok: true, payload }),
24
+ (err: unknown) => post('/preview/result', { id, ok: false, payload: err instanceof Error ? err.message : String(err) }),
25
+ ).catch(() => {});
26
+ } else if (msg.t === 'fingerprint' && msg.hash) o.onFingerprint?.(msg.hash);
27
+ else if (msg.t === 'build') o.onBuild?.();
28
+ };
29
+
30
+ const origError = console.error.bind(console);
31
+ console.error = (...a: unknown[]) => {
32
+ const line = a.map((x) => (x instanceof Error ? x.message : typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
33
+ errors.push(line); if (errors.length > 30) errors.shift();
34
+ origError(...a);
35
+ };
36
+ addEventListener('error', (e) => { errors.push(e.message); if (errors.length > 30) errors.shift(); });
37
+ addEventListener('unhandledrejection', (e) => { errors.push(String((e as PromiseRejectionEvent).reason)); if (errors.length > 30) errors.shift(); });
38
+
39
+ let pending: unknown = null;
40
+ let timer: ReturnType<typeof setTimeout> | null = null;
41
+ return {
42
+ errors,
43
+ // 合并一下:runes 一变就上报会打爆接口
44
+ report: (state) => { pending = state; if (!timer) timer = setTimeout(() => { timer = null; void post('/preview/state', { ...(pending as object), consoleErrors: errors }); }, 150); },
45
+ log: (msg) => { void post('/log', { level: 'info', msg }); },
46
+ };
47
+ }
48
+
49
+ export const api = {
50
+ artifacts: async (): Promise<{ mode: string; table: Uint8Array; books: { simId: number; frames: string }[] | null }[]> => {
51
+ const list = await (await fetch(`${API}/artifacts`)).json() as { mode: string; hasBooks: boolean }[];
52
+ return Promise.all(list.map(async (a) => ({
53
+ mode: a.mode,
54
+ table: new Uint8Array(await (await fetch(`${API}/artifacts/${encodeURIComponent(a.mode)}/table`)).arrayBuffer()),
55
+ books: a.hasBooks ? await (await fetch(`${API}/artifacts/${encodeURIComponent(a.mode)}/books`)).json() as { simId: number; frames: string }[] : null,
56
+ })));
57
+ },
58
+ addCase: async (body: unknown): Promise<{ id: string }> => {
59
+ const r = await fetch(`${API}/cases`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
60
+ if (!r.ok) throw new Error((await r.json() as { error: string }).error);
61
+ return r.json() as Promise<{ id: string }>;
62
+ },
63
+ };
@@ -0,0 +1,89 @@
1
+ /* 预览壳的入口,跑在游戏 vite dev server 的页面里。
2
+ *
3
+ * 壳就是 dev-host 的 DevShell——和 `bun run dev` 看到的是同一个参考平台,同一条本地表模型:
4
+ * 浏览器里就地建表、按权重抽行、restore 还原。studio 多接的只有三样:
5
+ * 建好的正式产物(有就优先用)、存为 case、以及 node 侧的总线(MCP 的 preview_* 工具从那儿进来)。 */
6
+ import { runDevShell, parseTable, localServer, type LoadedTable, type PlatformOf } from 'gamekit777/dev-host';
7
+ import type { GameModule } from 'gamekit777/sdk';
8
+ import game from 'virtual:studio/game';
9
+ import { api, connectBridge } from './bridge.ts';
10
+
11
+ type G = GameModule<object, unknown>;
12
+ const g = game as G;
13
+
14
+ /** node 侧建好的表(.studio/build/<指纹>/),喂给本地服务端;没建的 mode 照样就地建 */
15
+ const external = async (): Promise<ReadonlyMap<string, LoadedTable>> => {
16
+ const out = new Map<string, LoadedTable>();
17
+ for (const a of await api.artifacts()) out.set(a.mode, parseTable(a.mode, a.table, a.books));
18
+ return out;
19
+ };
20
+
21
+ let p: PlatformOf<object, unknown> | null = null;
22
+
23
+ runDevShell(g, {
24
+ // 嵌入模式:页眉和余额由 studio 页面画,这里只剩机台和机台下面的开发面板
25
+ embedded: true,
26
+ server: localServer({ spec: g, external }),
27
+ onPlatform: (plat) => { p = plat; },
28
+ onSaveCase: async (h, title) => {
29
+ const body: Record<string, unknown> = { title, origin: 'preview', config: h.config, bet: h.bet };
30
+ if (h.simId !== null) body.table = { mode: h.mode, simId: h.simId };
31
+ if (h.seed) body.seed = h.seed;
32
+ return (await api.addCase(body)).id;
33
+ },
34
+ });
35
+
36
+ const plain = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
37
+ const snapshot = (): Record<string, unknown> => {
38
+ if (!p) return { connected: false };
39
+ return {
40
+ config: plain(p.config), mode: p.tableMode, bet: p.bet, balance: p.balance, pnl: p.pnl,
41
+ status: plain(p.game), lastError: p.err, tableModes: plain(p.tableModes),
42
+ recent: p.history.slice(0, 10).map((h) => ({ n: h.n, mode: h.mode, multiplier: h.multiplier, payout: h.payout, simId: h.simId, ok: h.checks.every((c) => c.ok) })),
43
+ };
44
+ };
45
+ const waitIdle = (): Promise<void> => new Promise((r) => { const t = setInterval(() => { if (p && !p.game.busy && !p.placing) { clearInterval(t); r(); } }, 50); });
46
+ const must = (): PlatformOf<object, unknown> => { if (!p) throw new Error('预览的平台还没建好'); return p; };
47
+
48
+ /* ── 总线:MCP 的 preview_* 工具从这里进来 ── */
49
+ const bridge = connectBridge({
50
+ onBuild: () => { void p?.reloadTables(); },
51
+ onCommand: async (cmd) => {
52
+ switch (cmd.t) {
53
+ case 'state': return snapshot();
54
+ case 'set': {
55
+ const plat = must();
56
+ const applied: string[] = [];
57
+ const rejected: { key: string; reason: string }[] = [];
58
+ if (cmd.config !== undefined) { if (plat.requestConfig(cmd.config as never)) applied.push('config'); else rejected.push({ key: 'config', reason: '局中/自动中/注已发出,平台否决' }); }
59
+ if (cmd.bet !== undefined) { if (plat.setBet(Number(cmd.bet))) applied.push('bet'); else rejected.push({ key: 'bet', reason: '局中/自动中' }); }
60
+ if (cmd.clientSeed !== undefined) { plat.clientSeed = String(cmd.clientSeed); applied.push('clientSeed'); }
61
+ if (cmd.skin !== undefined) { plat.setView({ skin: String(cmd.skin) }); applied.push('skin'); }
62
+ if (cmd.turbo !== undefined) { plat.setView({ turbo: !!cmd.turbo }); applied.push('turbo'); }
63
+ return { applied, rejected, state: snapshot() };
64
+ }
65
+ case 'play': {
66
+ const plat = must();
67
+ const n = Math.max(1, Math.min(50, Number(cmd.rounds ?? 1)));
68
+ if (cmd.bet !== undefined) plat.setBet(Number(cmd.bet));
69
+ const rounds: unknown[] = [];
70
+ const errors: string[] = [];
71
+ for (let i = 0; i < n; i++) {
72
+ if (plat.game.busy || plat.placing) await waitIdle();
73
+ const h = await plat.playOne({ seed: cmd.seed as never, simId: cmd.simId as number | undefined, animate: cmd.animate !== false });
74
+ if (!h) { errors.push(plat.err ?? '未知错误'); if (errors.length >= 3) break; continue; }
75
+ rounds.push({
76
+ n: h.n, mode: h.mode, bet: h.bet, payout: h.payout, multiplier: h.multiplier,
77
+ payoutCenti: Math.round(h.multiplier * 100), checks: h.checks, simId: h.simId, seed: h.seed,
78
+ });
79
+ }
80
+ return { rounds, wallet: { balance: plat.balance }, errors };
81
+ }
82
+ default: throw new Error(`预览不认识命令 ${cmd.t}`);
83
+ }
84
+ },
85
+ });
86
+
87
+ // 状态上报:给 AI 的 preview_state 和页面看
88
+ setInterval(() => bridge.report(snapshot()), 1000);
89
+ bridge.report(snapshot());
@@ -0,0 +1,6 @@
1
+ /* `virtual:studio/game` 由 src/preview-plugin.ts 在 vite 里解析到游戏入口 */
2
+ declare module 'virtual:studio/game' {
3
+ import type { GameModule } from 'gamekit777/sdk';
4
+ const game: GameModule<object, unknown>;
5
+ export default game;
6
+ }
@@ -0,0 +1,157 @@
1
+ /* /studio/api:页面和预览壳跟 node 侧说话的地方。
2
+ *
3
+ * 挂在游戏的 vite dev server 上当中间件——页面、预览、API 三者同源,
4
+ * 于是页面能直接 import 游戏 logic、预览能直接 fetch 产物、cookie 反代到 /v1 也同源。 */
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import type { Engine } from './engine.ts';
7
+ import type { ProbeOptions } from './probe.ts';
8
+ import type { Targets } from './solver.ts';
9
+ import type { TaskName } from './tasks.ts';
10
+
11
+ type Handler = (req: IncomingMessage, res: ServerResponse, params: Record<string, string>, body: unknown) => Promise<unknown> | unknown;
12
+
13
+ interface Route { method: string; pattern: RegExp; keys: string[]; handler: Handler }
14
+
15
+ const compile = (path: string): { pattern: RegExp; keys: string[] } => {
16
+ const keys: string[] = [];
17
+ const src = path.replace(/:([a-zA-Z]+)/g, (_, k: string) => { keys.push(k); return '([^/]+)'; });
18
+ return { pattern: new RegExp(`^${src}$`), keys };
19
+ };
20
+
21
+ async function readBody(req: IncomingMessage): Promise<unknown> {
22
+ const chunks: Buffer[] = [];
23
+ for await (const c of req) chunks.push(c as Buffer);
24
+ if (chunks.length === 0) return null;
25
+ const text = Buffer.concat(chunks).toString('utf8');
26
+ if (!text) return null;
27
+ try { return JSON.parse(text); } catch { return text; }
28
+ }
29
+
30
+ const json = (res: ServerResponse, status: number, body: unknown): void => {
31
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
32
+ res.end(JSON.stringify(body, (_k, v: unknown) => (typeof v === 'bigint' ? v.toString() : v)));
33
+ };
34
+
35
+ export const API_PREFIX = '/studio/api';
36
+
37
+ export function createApi(engine: Engine): (req: IncomingMessage, res: ServerResponse, next: () => void) => void {
38
+ const routes: Route[] = [];
39
+ const on = (method: string, path: string, handler: Handler): void => { routes.push({ method, ...compile(path), handler }); };
40
+ const num = (v: unknown): number | null => (v === undefined || v === null || v === '' ? null : Number(v));
41
+ const list = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v ? v.split(',') : null);
42
+
43
+ on('GET', '/info', () => engine.info());
44
+
45
+ /* 总线 */
46
+ on('GET', '/events', (req, res) => {
47
+ const role = new URL(req.url!, 'http://x').searchParams.get('role') === 'preview' ? 'preview' : 'page';
48
+ engine.bus.attach(res, role);
49
+ return undefined; // 已经接管了 res
50
+ });
51
+ on('POST', '/preview/state', (_r, _s, _p, body) => { engine.reportPreview(body as never); return { ok: true }; });
52
+ on('POST', '/preview/result', (_r, _s, _p, body) => {
53
+ const b = body as { id: string; ok: boolean; payload: unknown };
54
+ return { matched: engine.bus.resolve(b.id, b.ok, b.payload) };
55
+ });
56
+ on('POST', '/preview/command', (_r, _s, _p, body) => engine.previewCommand(body));
57
+ on('POST', '/log', (_r, _s, _p, body) => {
58
+ const b = body as { level?: 'info' | 'warn' | 'error'; msg: string };
59
+ engine.bus.log(b.level ?? 'info', b.msg);
60
+ return { ok: true };
61
+ });
62
+
63
+ /* 探测 / 采样 / 求解 / 建表 */
64
+ on('POST', '/probe', (_r, _s, _p, body) => engine.probe((body ?? {}) as ProbeOptions));
65
+ on('POST', '/sample', (_r, _s, _p, body) => {
66
+ const b = (body ?? {}) as { modes?: unknown; sims?: unknown };
67
+ return engine.sample(list(b.modes), num(b.sims));
68
+ });
69
+ on('GET', '/groups', (req) => {
70
+ const q = new URL(req.url!, 'http://x').searchParams;
71
+ return engine.groups(q.get('mode'), num(q.get('sims')));
72
+ });
73
+ on('POST', '/solve', (_r, _s, _p, body) => {
74
+ const b = body as { mode?: string; sims?: number; targets: Targets };
75
+ return engine.solve(b.mode ?? null, b.sims ?? null, b.targets);
76
+ });
77
+ on('POST', '/build', (_r, _s, _p, body) => {
78
+ const b = (body ?? {}) as { modes?: unknown; sims?: number; rtpPpm?: number; segments?: never };
79
+ return engine.build(list(b.modes), { sims: b.sims, declaredRtpPpm: b.rtpPpm, segments: b.segments });
80
+ });
81
+ on('GET', '/artifacts', () => engine.artifacts());
82
+ on('GET', '/artifacts/:mode/table', (_r, res, p) => {
83
+ const a = engine.artifactBytes(p.mode!);
84
+ if (!a) { json(res, 404, { error: `没有 mode ${p.mode} 的产物` }); return undefined; }
85
+ res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Access-Control-Allow-Origin': '*' });
86
+ res.end(a.table);
87
+ return undefined;
88
+ });
89
+ on('GET', '/artifacts/:mode/books', (_r, res, p) => {
90
+ const a = engine.artifactBytes(p.mode!);
91
+ if (!a) { json(res, 404, { error: `没有 mode ${p.mode} 的产物` }); return undefined; }
92
+ json(res, 200, a.books ?? []);
93
+ return undefined;
94
+ });
95
+
96
+ /* 长任务 */
97
+ on('GET', '/jobs', () => engine.jobs.list());
98
+ on('GET', '/jobs/:id', (_r, res, p) => {
99
+ const j = engine.jobs.get(p.id!);
100
+ if (!j) { json(res, 404, { error: `没有 job ${p.id}` }); return undefined; }
101
+ return j;
102
+ });
103
+
104
+ /* 任务 */
105
+ on('POST', '/task', (_r, _s, _p, body) => engine.task((body as { task: TaskName }).task));
106
+
107
+ /* case */
108
+ on('GET', '/cases', () => engine.cases.list());
109
+ on('POST', '/cases', (_r, _s, _p, body) => engine.caseAdd(body as never));
110
+ on('POST', '/cases/run', (_r, _s, _p, body) => engine.caseRun((body ?? {}) as never));
111
+ on('POST', '/cases/bless', (_r, _s, _p, body) => ({ blessed: engine.cases.bless((body as { updates: never[] }).updates) }));
112
+ on('DELETE', '/cases/:id', (_r, _s, p) => ({ removed: engine.cases.remove(p.id!) }));
113
+
114
+ /* 发布 */
115
+ on('GET', '/publish', () => engine.publishRequests());
116
+ on('POST', '/publish/request', (_r, _s, _p, body) => {
117
+ const b = (body ?? {}) as { modes?: unknown; note?: string };
118
+ return engine.requestPublish(list(b.modes), b.note ?? null, 'page');
119
+ });
120
+ on('POST', '/publish/:id', (_r, res, p, body) => {
121
+ const r = engine.updatePublish(p.id!, body as never);
122
+ if (!r) { json(res, 404, { error: `没有发布请求 ${p.id}` }); return undefined; }
123
+ return r;
124
+ });
125
+
126
+ return (req, res, next) => {
127
+ const url = new URL(req.url ?? '/', 'http://x');
128
+ if (!url.pathname.startsWith(API_PREFIX)) return next();
129
+ const path = url.pathname.slice(API_PREFIX.length) || '/';
130
+ const method = req.method ?? 'GET';
131
+
132
+ if (method === 'OPTIONS') {
133
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });
134
+ res.end();
135
+ return;
136
+ }
137
+
138
+ for (const r of routes) {
139
+ if (r.method !== method) continue;
140
+ const m = r.pattern.exec(path);
141
+ if (!m) continue;
142
+ const params: Record<string, string> = {};
143
+ r.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]!); });
144
+ void (async () => {
145
+ try {
146
+ const body = method === 'GET' ? null : await readBody(req);
147
+ const out = await r.handler(req, res, params, body);
148
+ if (out !== undefined && !res.headersSent) json(res, 200, out);
149
+ } catch (e) {
150
+ if (!res.headersSent) json(res, 500, { error: e instanceof Error ? e.message : String(e) });
151
+ }
152
+ })();
153
+ return;
154
+ }
155
+ json(res, 404, { error: `没有 ${method} ${path}` });
156
+ };
157
+ }
@@ -0,0 +1,166 @@
1
+ /* 表构建:采样(缓存)→ 求解 → 编码 → 自验 → 落盘。
2
+ *
3
+ * 采样是慢的那一步,结果按规则指纹缓存在 <gameDir>/.studio/cache/。
4
+ * 指纹一变缓存作废——拿旧表演新逻辑是「表里写 0x、客户端演 4.64x」的翻版,而且完全静默。
5
+ *
6
+ * 产物落在 <gameDir>/.studio/build/<fp>/。预览的表模型和发布上传吃的是**同一份**文件,
7
+ * 不存在「预览的表和发布的表不是一张」的可能。 */
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { PAYOUT_SCALE } from 'gamekit777/protocol';
11
+ import type { BuiltTable } from 'gamekit777/publish';
12
+ import { planRows, type RowPlan } from 'gamekit777/publish/table';
13
+ import type { Segment } from 'gamekit777/weights';
14
+ import type { Game } from './game.ts';
15
+
16
+ export interface Batch { payoutCenti: Int32Array; books: Uint8Array[]; draws: number }
17
+
18
+ const CACHE_MAGIC = new TextEncoder().encode('GKSC1');
19
+
20
+ /* ── 采样缓存 ── */
21
+
22
+ function cachePath(game: Game, mode: string, sims: number): string {
23
+ return join(game.dir, '.studio', 'cache', game.fingerprint, `${mode}.${sims}.bin`);
24
+ }
25
+
26
+ function writeBatch(path: string, b: Batch): void {
27
+ mkdirSync(join(path, '..'), { recursive: true });
28
+ let total = 0;
29
+ for (const x of b.books) total += x.length;
30
+ const sims = b.payoutCenti.length;
31
+ const head = new DataView(new ArrayBuffer(12));
32
+ head.setUint32(0, sims, true);
33
+ head.setUint32(4, total, true);
34
+ head.setUint32(8, b.draws >>> 0, true);
35
+ const offsets = new Int32Array(b.books.length + 1);
36
+ const data = new Uint8Array(total);
37
+ let at = 0;
38
+ for (let i = 0; i < b.books.length; i++) { data.set(b.books[i]!, at); at += b.books[i]!.length; offsets[i + 1] = at; }
39
+ const out = new Uint8Array(CACHE_MAGIC.length + 12 + sims * 4 + offsets.byteLength + total);
40
+ let p = 0;
41
+ out.set(CACHE_MAGIC, p); p += CACHE_MAGIC.length;
42
+ out.set(new Uint8Array(head.buffer), p); p += 12;
43
+ out.set(new Uint8Array(b.payoutCenti.buffer, b.payoutCenti.byteOffset, sims * 4), p); p += sims * 4;
44
+ out.set(new Uint8Array(offsets.buffer), p); p += offsets.byteLength;
45
+ out.set(data, p);
46
+ writeFileSync(path, out);
47
+ }
48
+
49
+ function readBatch(path: string): Batch | null {
50
+ if (!existsSync(path)) return null;
51
+ const buf = new Uint8Array(readFileSync(path));
52
+ for (let i = 0; i < CACHE_MAGIC.length; i++) if (buf[i] !== CACHE_MAGIC[i]) return null;
53
+ const dv = new DataView(buf.buffer, buf.byteOffset + CACHE_MAGIC.length, 12);
54
+ const sims = dv.getUint32(0, true), total = dv.getUint32(4, true), draws = dv.getUint32(8, true);
55
+ let p = CACHE_MAGIC.length + 12;
56
+ const payoutCenti = new Int32Array(buf.buffer.slice(buf.byteOffset + p, buf.byteOffset + p + sims * 4)); p += sims * 4;
57
+ const hasBooks = total > 0;
58
+ const offsets = new Int32Array(buf.buffer.slice(buf.byteOffset + p, buf.byteOffset + p + (sims + 1) * 4)); p += (sims + 1) * 4;
59
+ const data = buf.subarray(p, p + total);
60
+ const books: Uint8Array[] = hasBooks ? Array.from({ length: sims }, (_, i) => data.subarray(offsets[i]!, offsets[i + 1]!)) : [];
61
+ return { payoutCenti, books, draws };
62
+ }
63
+
64
+ /** 按清单采样,命中缓存就直接读 */
65
+ export async function sampleMode(game: Game, modeIndex: number, sims: number, say: (m: string) => void = () => {}): Promise<Batch> {
66
+ const snap = await game.snapshot();
67
+ const mode = snap.spec.modes[modeIndex];
68
+ if (!mode) throw new Error(`没有第 ${modeIndex} 个 mode`);
69
+ const path = cachePath(game, mode.name, sims);
70
+ const hit = readBatch(path);
71
+ if (hit) { say(`[${mode.name}] 采样命中缓存(${sims} 局)`); return hit; }
72
+ say(`[${mode.name}] 采样 ${sims} 局`);
73
+ const b = await game.sampleSpec(modeIndex, sims);
74
+ writeBatch(path, b);
75
+ return b;
76
+ }
77
+
78
+ /** 调参 UI 要的摘要:只有 groups,几百 KB 以内,浏览器拿去就能实时 solve */
79
+ export interface GroupsSummary {
80
+ mode: string; sims: number; betCostCenti: number; declaredRtpPpm: number;
81
+ groups: { payout: number; prior: number }[];
82
+ distinctPayouts: number; maxPayoutCenti: number; naturalRtpPpm: number; hitRatePpm: number;
83
+ }
84
+
85
+ export async function groupsSummary(game: Game, modeIndex: number, sims: number): Promise<GroupsSummary> {
86
+ const snap = await game.snapshot();
87
+ const spec = snap.spec;
88
+ const mode = spec.modes[modeIndex]!;
89
+ const b = await sampleMode(game, modeIndex, sims);
90
+ const plan: RowPlan = planRows(b.payoutCenti, true);
91
+ const betCostCenti = PAYOUT_SCALE;
92
+ let sum = 0, hit = 0, maxP = 0;
93
+ for (let i = 0; i < b.payoutCenti.length; i++) { const p = b.payoutCenti[i]!; sum += p; if (p > 0) hit++; if (p > maxP) maxP = p; }
94
+ return {
95
+ mode: mode.name, sims, betCostCenti,
96
+ declaredRtpPpm: Math.round(spec.rtp * 1e6),
97
+ groups: plan.groups.map((g) => ({ payout: g.payout, prior: g.prior })),
98
+ distinctPayouts: plan.groups.length, maxPayoutCenti: maxP,
99
+ naturalRtpPpm: Math.round((sum / sims / betCostCenti) * 1e6),
100
+ hitRatePpm: Math.round((hit / sims) * 1e6),
101
+ };
102
+ }
103
+
104
+ /* ── 产物 ── */
105
+
106
+ export interface Artifact {
107
+ mode: string; fingerprint: string; builtAt: number;
108
+ contentHash: string; rowCount: number; totalWeight: number; betCostCenti: number;
109
+ declaredRtpPpm: number; computedRtpPpm: number; maxPayoutCenti: number; hitRatePpm: number; distinctPayouts: number;
110
+ sims: number; hasBooks: boolean;
111
+ segments: Segment[] | null;
112
+ stats: BuiltTable['stats'];
113
+ files: { table: string; books: string | null; meta: string };
114
+ }
115
+
116
+ const buildDir = (game: Game): string => join(game.dir, '.studio', 'build', game.fingerprint);
117
+
118
+ export interface BuildModeOptions { sims?: number; declaredRtpPpm?: number; segments?: Segment[] }
119
+
120
+ export async function buildMode(game: Game, modeIndex: number, o: BuildModeOptions = {}, say: (m: string) => void = () => {}): Promise<Artifact> {
121
+ const snap = await game.snapshot();
122
+ const mode = snap.spec.modes[modeIndex];
123
+ if (!mode) throw new Error(`没有第 ${modeIndex} 个 mode`);
124
+ const sims = o.sims ?? snap.spec.sims;
125
+
126
+ const batch = await sampleMode(game, modeIndex, sims, say);
127
+ say(`[${mode.name}] 求解 → 量化 → 编码 → 自验`);
128
+ const t = await game.buildFromBatch(modeIndex, batch, { declaredRtpPpm: o.declaredRtpPpm, segments: o.segments });
129
+
130
+ const dir = buildDir(game);
131
+ mkdirSync(dir, { recursive: true });
132
+ const stem = join(dir, mode.name);
133
+ writeFileSync(`${stem}.gklt1`, t.bytes);
134
+ if (t.books) writeFileSync(`${stem}.books.jsonl`, `${t.books.map((b) => JSON.stringify(b)).join('\n')}\n`);
135
+ else rmSync(`${stem}.books.jsonl`, { force: true });
136
+
137
+ const art: Artifact = {
138
+ mode: mode.name, fingerprint: game.fingerprint, builtAt: Date.now(),
139
+ contentHash: t.contentHash, rowCount: t.rowCount, totalWeight: t.totalWeight, betCostCenti: t.betCostCenti,
140
+ declaredRtpPpm: t.declaredRtpPpm, computedRtpPpm: t.verdict.computedRtpPpm,
141
+ maxPayoutCenti: t.verdict.maxPayoutCenti, hitRatePpm: t.verdict.hitRatePpm, distinctPayouts: t.verdict.distinctPayouts,
142
+ sims, hasBooks: t.books !== null, segments: o.segments ?? null,
143
+ stats: t.stats,
144
+ files: { table: `${stem}.gklt1`, books: t.books ? `${stem}.books.jsonl` : null, meta: `${stem}.json` },
145
+ };
146
+ writeFileSync(`${stem}.json`, `${JSON.stringify(art, null, 2)}\n`);
147
+ say(`[${mode.name}] ${t.rowCount} 行,RTP ${t.verdict.computedRtpPpm} ppm,sha256 ${t.contentHash.slice(0, 12)}…`);
148
+ return art;
149
+ }
150
+
151
+ /** 当前指纹下已建好的表。别的指纹的产物不算——那是旧逻辑的表 */
152
+ export function listArtifacts(game: Game): Artifact[] {
153
+ const dir = buildDir(game);
154
+ if (!existsSync(dir)) return [];
155
+ return readdirSync(dir).filter((f) => f.endsWith('.json'))
156
+ .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')) as Artifact)
157
+ .sort((a, b) => a.mode.localeCompare(b.mode));
158
+ }
159
+
160
+ export function readArtifactBytes(art: Artifact): { table: Uint8Array; books: { simId: number; frames: string }[] | null } {
161
+ const table = new Uint8Array(readFileSync(art.files.table));
162
+ const books = art.files.books
163
+ ? readFileSync(art.files.books, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l) as { simId: number; frames: string })
164
+ : null;
165
+ return { table, books };
166
+ }