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.
- package/README.md +48 -0
- package/client/http.ts +253 -0
- package/client/index.ts +211 -0
- package/client/node.ts +61 -0
- package/create-game/scaffold.ts +73 -0
- package/create-game/template/CLAUDE.md.tmpl +167 -0
- package/create-game/template/_gitignore +4 -0
- package/create-game/template/game.meta.json.tmpl +8 -0
- package/create-game/template/game.ts.tmpl +57 -0
- package/create-game/template/index.html.tmpl +11 -0
- package/create-game/template/package.json.tmpl +26 -0
- package/create-game/template/scripts/verify.ts.tmpl +79 -0
- package/create-game/template/src/assets/critical/.gitkeep +0 -0
- package/create-game/template/src/assets/lazy/.gitkeep +0 -0
- package/create-game/template/src/assets/manifest.ts +30 -0
- package/create-game/template/src/assets/registry.ts +23 -0
- package/create-game/template/src/components/App.svelte.tmpl +54 -0
- package/create-game/template/src/context.svelte.ts.tmpl +22 -0
- package/create-game/template/src/dev.ts +8 -0
- package/create-game/template/src/index.ts.tmpl +17 -0
- package/create-game/template/src/rules/const.ts +9 -0
- package/create-game/template/src/rules/restore.ts.tmpl +39 -0
- package/create-game/template/src/rules/schema.ts.tmpl +19 -0
- package/create-game/template/src/rules/simulate.ts.tmpl +9 -0
- package/create-game/template/src/rules/types.ts.tmpl +13 -0
- package/create-game/template/src/state/game.svelte.ts.tmpl +45 -0
- package/create-game/template/src/styles/animations.css +4 -0
- package/create-game/template/src/styles/global.css +18 -0
- package/create-game/template/src/view/bridge.svelte.ts +33 -0
- package/create-game/template/src/view/mount.svelte.ts.tmpl +86 -0
- package/create-game/template/src/view/present.ts.tmpl +36 -0
- package/create-game/template/test/e2e.test.ts.tmpl +81 -0
- package/create-game/template/tsconfig.json +22 -0
- package/create-game/template/vite.config.ts +4 -0
- package/create-game/template/vitest.config.ts +10 -0
- package/dev-host/DevShell.svelte +348 -0
- package/dev-host/Field.svelte +25 -0
- package/dev-host/SchemaFields.svelte +14 -0
- package/dev-host/SchemaFieldsPure.svelte +98 -0
- package/dev-host/context.svelte.ts +9 -0
- package/dev-host/fonts.ts +16 -0
- package/dev-host/index.ts +8 -0
- package/dev-host/local.ts +69 -0
- package/dev-host/platform-server.ts +78 -0
- package/dev-host/platform.svelte.ts +277 -0
- package/dev-host/remote.ts +178 -0
- package/dev-host/run.ts +46 -0
- package/dev-host/table-server.ts +194 -0
- package/dev-host/theme.css +344 -0
- package/lut/book.ts +97 -0
- package/lut/csv.ts +37 -0
- package/lut/format.ts +106 -0
- package/lut/index.ts +4 -0
- package/lut/verify.ts +243 -0
- package/package.json +58 -0
- package/protocol/bets.ts +74 -0
- package/protocol/errors.ts +90 -0
- package/protocol/games.ts +158 -0
- package/protocol/index.ts +9 -0
- package/protocol/money.ts +51 -0
- package/protocol/rounds.ts +60 -0
- package/protocol/seeds.ts +36 -0
- package/protocol/session.ts +18 -0
- package/protocol/tables.ts +120 -0
- package/protocol/verify.ts +33 -0
- package/publish/build.ts +168 -0
- package/publish/index.ts +6 -0
- package/publish/parallel.ts +56 -0
- package/publish/sample-worker.ts +31 -0
- package/publish/sample.ts +131 -0
- package/publish/spec.ts +8 -0
- package/publish/table.ts +244 -0
- package/publish/upload-core.ts +176 -0
- package/publish/upload.ts +30 -0
- package/runtime/assets.ts +122 -0
- package/runtime/fonts.ts +21 -0
- package/runtime/index.ts +3 -0
- package/runtime/types.ts +42 -0
- package/sdk/contract.ts +51 -0
- package/sdk/hash.ts +184 -0
- package/sdk/host.ts +96 -0
- package/sdk/index.ts +9 -0
- package/sdk/rng.ts +109 -0
- package/sdk/sample.ts +68 -0
- package/sdk/schema.ts +90 -0
- package/sdk/spec.ts +255 -0
- package/sdk/stage.ts +20 -0
- package/sdk/store.ts +67 -0
- package/studio/app/App.svelte +113 -0
- package/studio/app/lib/api.ts +59 -0
- package/studio/app/lib/bus.svelte.ts +44 -0
- package/studio/app/lib/upload.ts +34 -0
- package/studio/app/main.ts +5 -0
- package/studio/app/panels/CasePanel.svelte +83 -0
- package/studio/app/panels/LogPanel.svelte +19 -0
- package/studio/app/panels/PreviewPanel.svelte +33 -0
- package/studio/app/panels/PublishPanel.svelte +111 -0
- package/studio/app/panels/TablePanel.svelte +64 -0
- package/studio/app/panels/TuningPanel.svelte +140 -0
- package/studio/app/virtual.d.ts +6 -0
- package/studio/bin.ts +68 -0
- package/studio/cli.ts +43 -0
- package/studio/preview/bridge.ts +63 -0
- package/studio/preview/entry.ts +89 -0
- package/studio/preview/virtual.d.ts +6 -0
- package/studio/src/api.ts +157 -0
- package/studio/src/build.ts +166 -0
- package/studio/src/bus.ts +98 -0
- package/studio/src/canon.ts +18 -0
- package/studio/src/cases.ts +255 -0
- package/studio/src/config.ts +39 -0
- package/studio/src/engine.ts +218 -0
- package/studio/src/fingerprint.ts +39 -0
- package/studio/src/game-vite.ts +91 -0
- package/studio/src/game.ts +173 -0
- package/studio/src/jobs.ts +59 -0
- package/studio/src/mcp.ts +357 -0
- package/studio/src/probe.ts +359 -0
- package/studio/src/scaffold.ts +85 -0
- package/studio/src/solver.ts +139 -0
- package/studio/src/stats.ts +134 -0
- package/studio/src/studio-plugin.ts +76 -0
- package/studio/src/tasks.ts +52 -0
- package/studio/src/worker/pool.ts +84 -0
- package/studio/src/worker/rpc.ts +178 -0
- package/vite-config/index.js +207 -0
- package/vite-config/index.ts +87 -0
- package/vite-config/manifest.ts +144 -0
- package/vite-config/meta.ts +41 -0
- package/vite-config/namespace-css.ts +74 -0
- package/weights/index.ts +11 -0
- package/weights/linalg.ts +118 -0
- package/weights/report.ts +78 -0
- package/weights/solve.ts +236 -0
- package/weights/types.ts +73 -0
- package/weights/volatility.ts +40 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* studio 的状态总线:node 侧 ↔ 页面 ↔ 预览 iframe。
|
|
2
|
+
*
|
|
3
|
+
* 下行 SSE 广播,上行普通 POST。预览控制类的 MCP 工具(演一局、读状态)走这条:
|
|
4
|
+
* node 发一条带 id 的命令,预览壳执行完 POST 回同一个 id 的结果。
|
|
5
|
+
* 没有预览页连着时命令直接失败——不能让 AI 等一个永远不会来的回复。 */
|
|
6
|
+
import type { ServerResponse } from 'node:http';
|
|
7
|
+
|
|
8
|
+
export type BusEvent =
|
|
9
|
+
| { t: 'hello'; clients: number }
|
|
10
|
+
| { t: 'log'; level: 'info' | 'warn' | 'error'; msg: string; at: number }
|
|
11
|
+
| { t: 'job'; id: string; kind: string; status: string; msg: string }
|
|
12
|
+
| { t: 'fingerprint'; hash: string }
|
|
13
|
+
| { t: 'preview'; state: unknown }
|
|
14
|
+
| { t: 'cmd'; id: string; cmd: unknown }
|
|
15
|
+
| { t: 'cases'; changed: true }
|
|
16
|
+
| { t: 'build'; artifacts: unknown }
|
|
17
|
+
| { t: 'publish'; request: unknown };
|
|
18
|
+
|
|
19
|
+
interface Waiter { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
20
|
+
|
|
21
|
+
export class Bus {
|
|
22
|
+
#clients = new Set<ServerResponse>();
|
|
23
|
+
#previews = new Set<ServerResponse>();
|
|
24
|
+
#waiters = new Map<string, Waiter>();
|
|
25
|
+
#seq = 0;
|
|
26
|
+
/** 最近的日志,新连上的页面先补这些 */
|
|
27
|
+
#recent: BusEvent[] = [];
|
|
28
|
+
/** 预览壳最近一次上报状态的时刻。SSE 的 close 不可靠:bun 的 fetch 中止不关 TCP,
|
|
29
|
+
浏览器标签页崩掉时 socket 也可能半死不活。预览壳每秒上报一次,没心跳就是没人 */
|
|
30
|
+
#lastPreviewBeat = 0;
|
|
31
|
+
static readonly BEAT_TTL_MS = 4_000;
|
|
32
|
+
|
|
33
|
+
/** 一个 SSE 连接。`role=preview` 的是预览壳,命令只发给它们 */
|
|
34
|
+
attach(res: ServerResponse, role: 'page' | 'preview'): void {
|
|
35
|
+
res.writeHead(200, {
|
|
36
|
+
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive',
|
|
37
|
+
'Access-Control-Allow-Origin': '*',
|
|
38
|
+
});
|
|
39
|
+
res.write(': connected\n\n');
|
|
40
|
+
this.#clients.add(res);
|
|
41
|
+
if (role === 'preview') { this.#previews.add(res); this.heartbeat(); }
|
|
42
|
+
for (const e of this.#recent) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
43
|
+
this.emit({ t: 'hello', clients: this.#clients.size });
|
|
44
|
+
res.on('close', () => {
|
|
45
|
+
this.#clients.delete(res);
|
|
46
|
+
this.#previews.delete(res);
|
|
47
|
+
this.emit({ t: 'hello', clients: this.#clients.size });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 预览壳上报状态时打一下 */
|
|
52
|
+
heartbeat(): void { this.#lastPreviewBeat = Date.now(); }
|
|
53
|
+
|
|
54
|
+
get previewConnected(): boolean {
|
|
55
|
+
return this.#previews.size > 0 && Date.now() - this.#lastPreviewBeat < Bus.BEAT_TTL_MS;
|
|
56
|
+
}
|
|
57
|
+
get clients(): number { return this.#clients.size; }
|
|
58
|
+
|
|
59
|
+
emit(e: BusEvent): void {
|
|
60
|
+
if (e.t === 'log' || e.t === 'job') {
|
|
61
|
+
this.#recent.push(e);
|
|
62
|
+
if (this.#recent.length > 100) this.#recent.shift();
|
|
63
|
+
}
|
|
64
|
+
const line = `data: ${JSON.stringify(e)}\n\n`;
|
|
65
|
+
for (const c of this.#clients) c.write(line);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log(level: 'info' | 'warn' | 'error', msg: string): void {
|
|
69
|
+
this.emit({ t: 'log', level, msg, at: Date.now() });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 给预览壳发命令并等结果 */
|
|
73
|
+
command<T>(cmd: unknown, timeoutMs = 30_000): Promise<T> {
|
|
74
|
+
if (!this.previewConnected) {
|
|
75
|
+
return Promise.reject(new Error(this.#previews.size > 0
|
|
76
|
+
? '预览页的连接还在,但 4 秒没心跳了——标签页可能卡住或已关闭。刷新预览页再试'
|
|
77
|
+
: '没有预览页连着。在浏览器里打开 studio 的预览页再试'));
|
|
78
|
+
}
|
|
79
|
+
const id = `c${++this.#seq}`;
|
|
80
|
+
return new Promise<T>((resolve, reject) => {
|
|
81
|
+
const timer = setTimeout(() => { this.#waiters.delete(id); reject(new Error(`预览页 ${timeoutMs / 1000}s 内没回复命令 ${id}`)); }, timeoutMs);
|
|
82
|
+
this.#waiters.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
|
|
83
|
+
const line = `data: ${JSON.stringify({ t: 'cmd', id, cmd })}\n\n`;
|
|
84
|
+
for (const p of this.#previews) p.write(line);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 预览壳 POST 回来的结果 */
|
|
89
|
+
resolve(id: string, ok: boolean, payload: unknown): boolean {
|
|
90
|
+
const w = this.#waiters.get(id);
|
|
91
|
+
if (!w) return false;
|
|
92
|
+
this.#waiters.delete(id);
|
|
93
|
+
clearTimeout(w.timer);
|
|
94
|
+
if (ok) w.resolve(payload);
|
|
95
|
+
else w.reject(new Error(typeof payload === 'string' ? payload : JSON.stringify(payload)));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* 规范化哈希:把任意可序列化的结果变成一个稳定的 16 位指纹。
|
|
2
|
+
*
|
|
3
|
+
* case 库用它给 restore() 的 outcome 做指纹——round() 自带 proof.outcomeHash,
|
|
4
|
+
* 但 restore 路径拿不到那一步,填的是空串。键排序、typed array 展开成普通数组,
|
|
5
|
+
* 于是同一个结果在任何序列化顺序下都得到同一个哈希。 */
|
|
6
|
+
import { sha256, toHex, utf8 } from 'gamekit777/sdk/hash';
|
|
7
|
+
|
|
8
|
+
export function canonical(v: unknown): string {
|
|
9
|
+
if (v === null || v === undefined) return 'null';
|
|
10
|
+
if (typeof v === 'bigint') return `"${v}n"`;
|
|
11
|
+
if (typeof v !== 'object') return JSON.stringify(v);
|
|
12
|
+
if (ArrayBuffer.isView(v)) return canonical(Array.from(v as unknown as ArrayLike<number>));
|
|
13
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
14
|
+
const o = v as Record<string, unknown>;
|
|
15
|
+
return `{${Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const canonHash = (v: unknown): string => toHex(sha256(utf8(canonical(v)))).slice(0, 16);
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* case 库:<gameDir>/studio/cases.jsonl,一行一个局面。
|
|
2
|
+
*
|
|
3
|
+
* 一鱼三吃:作者点一下就在预览里重演(调 UI 和文案);AI 读它写单测或直接跑;
|
|
4
|
+
* 以及结果漂移的护栏——每条存着结果哈希,任何变化都是 fail。改规则是有意的就 bless,
|
|
5
|
+
* 那是显式的接受动作;已发布过的 mode 解码逻辑不能改,要改就换 slug。
|
|
6
|
+
*
|
|
7
|
+
* studio 不独占这个文件:Claude Code 直接改它是预期用法,所以用 watcher 盯着,
|
|
8
|
+
* 追加走 appendFile 一行(把和外部编辑的冲突面降到最小),只有 bless 才读全量改写。 */
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, watch, writeFileSync, type FSWatcher } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import type { RngSeed } from 'gamekit777/sdk';
|
|
12
|
+
import { canonHash } from './canon.ts';
|
|
13
|
+
import type { Config, Game } from './game.ts';
|
|
14
|
+
|
|
15
|
+
export interface CaseRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
tags: string[];
|
|
19
|
+
game: string;
|
|
20
|
+
created: string;
|
|
21
|
+
origin: 'preview' | 'sample' | 'manual' | 'mcp';
|
|
22
|
+
config: Config;
|
|
23
|
+
/** 只作记录:bet 不属于局面,只线性缩放 payout */
|
|
24
|
+
bet: number;
|
|
25
|
+
/** 直接跑 round() 的定位 */
|
|
26
|
+
seed?: RngSeed;
|
|
27
|
+
/**
|
|
28
|
+
* 表模型定位。桥:sampleSeed(prefix, mode, simId) 确定性派生,只记这两个就能在两条路上都重建。
|
|
29
|
+
*
|
|
30
|
+
* 有 table 时,round() 那条路用的是**采样时的配置**(configOf(mode)),不是 config:
|
|
31
|
+
* 自由字段(抛硬币的 side)会改变某一局的结果,表里那一行是按 mode 冻结的默认值采出来的,
|
|
32
|
+
* 用玩家的 config 重跑 round() 得到的是另一局。config 只给 restore 用。
|
|
33
|
+
*/
|
|
34
|
+
table?: { mode: string; simId: number };
|
|
35
|
+
expect: {
|
|
36
|
+
payoutCenti: number;
|
|
37
|
+
/** canonHash(round().outcome) */
|
|
38
|
+
outcomeHash?: string;
|
|
39
|
+
/** canonHash(restore().outcome) */
|
|
40
|
+
restoreHash?: string;
|
|
41
|
+
/** 可选,验 codec 往返 */
|
|
42
|
+
frames?: string;
|
|
43
|
+
};
|
|
44
|
+
note?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CaseStatus = 'pass' | 'fail' | 'skipped' | 'error';
|
|
48
|
+
|
|
49
|
+
export interface CaseResult {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
model: 'formula' | 'table';
|
|
53
|
+
status: CaseStatus;
|
|
54
|
+
detail: string;
|
|
55
|
+
/** 实际值,作者要「接受新结果」时用 */
|
|
56
|
+
actual?: { payoutCenti: number; outcomeHash?: string; restoreHash?: string };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const b64 = (b: Uint8Array | null): string | undefined => (b ? btoa(String.fromCharCode(...b)) : undefined);
|
|
60
|
+
|
|
61
|
+
export class CaseStore {
|
|
62
|
+
readonly path: string;
|
|
63
|
+
#cases: CaseRecord[] = [];
|
|
64
|
+
#watcher: FSWatcher | null = null;
|
|
65
|
+
#onChange: () => void;
|
|
66
|
+
|
|
67
|
+
constructor(gameDir: string, onChange: () => void = () => {}) {
|
|
68
|
+
this.path = join(gameDir, 'studio', 'cases.jsonl');
|
|
69
|
+
this.#onChange = onChange;
|
|
70
|
+
this.reload();
|
|
71
|
+
this.#watch();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#watch(): void {
|
|
75
|
+
const dir = dirname(this.path);
|
|
76
|
+
if (!existsSync(dir)) return;
|
|
77
|
+
try {
|
|
78
|
+
this.#watcher = watch(dir, (_ev, name) => {
|
|
79
|
+
if (name === 'cases.jsonl') { this.reload(); this.#onChange(); }
|
|
80
|
+
});
|
|
81
|
+
} catch { /* watcher 起不来就退化成每次手动 reload */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
reload(): void {
|
|
85
|
+
if (!existsSync(this.path)) { this.#cases = []; return; }
|
|
86
|
+
const lines = readFileSync(this.path, 'utf8').split('\n').filter((l) => l.trim());
|
|
87
|
+
const out: CaseRecord[] = [];
|
|
88
|
+
for (const l of lines) {
|
|
89
|
+
try { out.push(JSON.parse(l) as CaseRecord); }
|
|
90
|
+
catch { /* 坏行跳过:作者手改时半行没写完是常态,别让整个库跟着炸 */ }
|
|
91
|
+
}
|
|
92
|
+
this.#cases = out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
list(): CaseRecord[] { return [...this.#cases]; }
|
|
96
|
+
get(id: string): CaseRecord | undefined { return this.#cases.find((c) => c.id === id); }
|
|
97
|
+
|
|
98
|
+
add(c: Omit<CaseRecord, 'id' | 'created'> & { id?: string }): CaseRecord {
|
|
99
|
+
const rec: CaseRecord = {
|
|
100
|
+
...c,
|
|
101
|
+
id: c.id ?? `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
|
|
102
|
+
created: new Date().toISOString(),
|
|
103
|
+
};
|
|
104
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
105
|
+
const fresh = !existsSync(this.path);
|
|
106
|
+
appendFileSync(this.path, `${JSON.stringify(rec)}\n`);
|
|
107
|
+
this.#cases.push(rec);
|
|
108
|
+
if (fresh) this.#watch();
|
|
109
|
+
return rec;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 接受新结果。写 tmp 再 rename,外部编辑器不会读到半个文件 */
|
|
113
|
+
bless(updates: { id: string; expect: CaseRecord['expect'] }[]): number {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const u of updates) {
|
|
116
|
+
const c = this.#cases.find((x) => x.id === u.id);
|
|
117
|
+
if (!c) continue;
|
|
118
|
+
c.expect = u.expect; n++;
|
|
119
|
+
}
|
|
120
|
+
this.#write();
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
remove(id: string): boolean {
|
|
125
|
+
const before = this.#cases.length;
|
|
126
|
+
this.#cases = this.#cases.filter((c) => c.id !== id);
|
|
127
|
+
if (this.#cases.length === before) return false;
|
|
128
|
+
this.#write();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#write(): void {
|
|
133
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
134
|
+
const tmp = `${this.path}.tmp`;
|
|
135
|
+
writeFileSync(tmp, this.#cases.map((c) => JSON.stringify(c)).join('\n') + (this.#cases.length ? '\n' : ''));
|
|
136
|
+
renameSync(tmp, this.path);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
close(): void { this.#watcher?.close(); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── runner ── */
|
|
143
|
+
|
|
144
|
+
export interface RunCasesOptions {
|
|
145
|
+
ids?: string[];
|
|
146
|
+
model?: 'formula' | 'table' | 'both';
|
|
147
|
+
/** 每局顺带把 Math.random / crypto 换成会抛的假实现 */
|
|
148
|
+
purity?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const DRIFT = '——结果变了。有意改的规则且这个 mode 没发布过:case_bless 接受;已发布过的 mode 解码逻辑不能改,要改就换 slug';
|
|
152
|
+
|
|
153
|
+
/** 任何结果变化都是 fail:没有「版本号」可以让它变成 stale,接受要走显式的 bless */
|
|
154
|
+
export async function runCases(game: Game, store: CaseStore, o: RunCasesOptions = {}): Promise<{
|
|
155
|
+
total: number; pass: number; fail: number; skipped: number; error: number; results: CaseResult[];
|
|
156
|
+
}> {
|
|
157
|
+
const snap = await game.snapshot();
|
|
158
|
+
const model = o.model ?? 'both';
|
|
159
|
+
const purity = o.purity ?? true;
|
|
160
|
+
const cases = store.list().filter((c) => !o.ids || o.ids.includes(c.id));
|
|
161
|
+
const results: CaseResult[] = [];
|
|
162
|
+
|
|
163
|
+
/** 表模型 case 的 round() 用采样时的配置:表里那一行是按 mode 冻结的默认值采出来的 */
|
|
164
|
+
const formulaConfig = async (c: CaseRecord): Promise<Config> => {
|
|
165
|
+
if (!c.table) return c.config;
|
|
166
|
+
const m = snap.spec.modes.find((x) => x.name === c.table!.mode);
|
|
167
|
+
return m ? (await game.normalize(m.config)).config : c.config;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const c of cases) {
|
|
171
|
+
if ((model === 'formula' || model === 'both') && c.seed) {
|
|
172
|
+
try {
|
|
173
|
+
const r = await game.round(await formulaConfig(c), c.bet, c.seed, purity);
|
|
174
|
+
const actual = { payoutCenti: Math.round(r.multiplier * 100), outcomeHash: canonHash(r.outcome) };
|
|
175
|
+
const same = actual.payoutCenti === c.expect.payoutCenti
|
|
176
|
+
&& (!c.expect.outcomeHash || actual.outcomeHash === c.expect.outcomeHash);
|
|
177
|
+
results.push({
|
|
178
|
+
id: c.id, title: c.title, model: 'formula',
|
|
179
|
+
status: same ? 'pass' : 'fail',
|
|
180
|
+
detail: same ? `payout ${actual.payoutCenti} centi,outcomeHash ${actual.outcomeHash}`
|
|
181
|
+
: `期望 payout ${c.expect.payoutCenti} / hash ${c.expect.outcomeHash ?? '-'},实际 ${actual.payoutCenti} / ${actual.outcomeHash}${DRIFT}`,
|
|
182
|
+
actual,
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
results.push({ id: c.id, title: c.title, model: 'formula', status: 'error', detail: e instanceof Error ? e.message : String(e) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if ((model === 'table' || model === 'both') && c.table) {
|
|
190
|
+
const mi = snap.spec.modes.findIndex((m) => m.name === c.table!.mode);
|
|
191
|
+
if (mi < 0) {
|
|
192
|
+
results.push({ id: c.id, title: c.title, model: 'table', status: 'skipped', detail: `清单里没有 mode ${c.table.mode}` });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const t = await game.tableRoundtrip(mi, c.table.simId, c.config, c.bet);
|
|
197
|
+
const restoreHash = canonHash(t.restored.outcome);
|
|
198
|
+
const actual = { payoutCenti: t.payoutCenti, restoreHash };
|
|
199
|
+
const payoutOk = t.payoutCenti === c.expect.payoutCenti && t.restored.payout === (c.bet / 100) * t.payoutCenti;
|
|
200
|
+
const hashOk = !c.expect.restoreHash || restoreHash === c.expect.restoreHash;
|
|
201
|
+
const framesOk = !c.expect.frames || b64(t.frames) === c.expect.frames;
|
|
202
|
+
const same = payoutOk && hashOk && framesOk;
|
|
203
|
+
results.push({
|
|
204
|
+
id: c.id, title: c.title, model: 'table',
|
|
205
|
+
status: same ? 'pass' : 'fail',
|
|
206
|
+
detail: same ? `行 ${c.table.simId}:payout ${t.payoutCenti} centi,restoreHash ${restoreHash}`
|
|
207
|
+
: `期望 payout ${c.expect.payoutCenti} / restoreHash ${c.expect.restoreHash ?? '-'},实际 ${t.payoutCenti} / ${restoreHash}${!framesOk ? ',book 字节也变了' : ''}${DRIFT}`,
|
|
208
|
+
actual,
|
|
209
|
+
});
|
|
210
|
+
} catch (e) {
|
|
211
|
+
results.push({ id: c.id, title: c.title, model: 'table', status: 'error', detail: e instanceof Error ? e.message : String(e) });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const count = (s: CaseStatus): number => results.filter((r) => r.status === s).length;
|
|
217
|
+
return { total: results.length, pass: count('pass'), fail: count('fail'), skipped: count('skipped'), error: count('error'), results };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 从一局结果造一条 case(预览里「存为 case」、MCP 的 case_add 都走这里) */
|
|
221
|
+
export async function captureCase(game: Game, input: {
|
|
222
|
+
title: string; tags?: string[]; origin: CaseRecord['origin']; config: Config; bet: number;
|
|
223
|
+
seed?: RngSeed; table?: { mode: string; simId: number }; note?: string;
|
|
224
|
+
}): Promise<Omit<CaseRecord, 'id' | 'created'>> {
|
|
225
|
+
const snap = await game.snapshot();
|
|
226
|
+
const expect: CaseRecord['expect'] = { payoutCenti: 0 };
|
|
227
|
+
let seed = input.seed;
|
|
228
|
+
let roundConfig = input.config;
|
|
229
|
+
|
|
230
|
+
if (input.table) {
|
|
231
|
+
const mi = snap.spec.modes.findIndex((m) => m.name === input.table!.mode);
|
|
232
|
+
if (mi < 0) throw new Error(`清单里没有 mode ${input.table.mode}`);
|
|
233
|
+
const t = await game.tableRoundtrip(mi, input.table.simId, input.config, input.bet);
|
|
234
|
+
expect.payoutCenti = t.payoutCenti;
|
|
235
|
+
expect.restoreHash = canonHash(t.restored.outcome);
|
|
236
|
+
expect.frames = b64(t.frames);
|
|
237
|
+
seed = seed ?? t.seed;
|
|
238
|
+
roundConfig = (await game.normalize(snap.spec.modes[mi]!.config)).config;
|
|
239
|
+
}
|
|
240
|
+
if (seed) {
|
|
241
|
+
const r = await game.round(roundConfig, input.bet, seed);
|
|
242
|
+
const centi = Math.round(r.multiplier * 100);
|
|
243
|
+
if (input.table && centi !== expect.payoutCenti) {
|
|
244
|
+
throw new Error(`表里第 ${input.table.simId} 行赔 ${expect.payoutCenti} centi,按采样配置重跑 round() 却是 ${centi}——采样不可复现,这是 round() 不纯或 sims 变了`);
|
|
245
|
+
}
|
|
246
|
+
expect.payoutCenti = centi;
|
|
247
|
+
expect.outcomeHash = canonHash(r.outcome);
|
|
248
|
+
}
|
|
249
|
+
if (!seed && !input.table) throw new Error('case 至少要有 seed 或 table 之一');
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
title: input.title, tags: input.tags ?? [], game: snap.spec.slug,
|
|
253
|
+
origin: input.origin, config: input.config, bet: input.bet, seed, table: input.table, expect, note: input.note,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* studio 的启动配置。
|
|
2
|
+
*
|
|
3
|
+
* 游戏目录就是 cwd——studio 被 Claude Code 按 stdio 拉起时,cwd 天然就是创作者
|
|
4
|
+
* 正在编辑的那个目录,不需要再传一遍。手动启动时可以用位置参数覆盖。 */
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export interface StudioConfig {
|
|
10
|
+
/** 游戏目录,绝对路径。必须有 package.json 和 vite.config.ts */
|
|
11
|
+
gameDir: string;
|
|
12
|
+
/** studio 包自己的根目录。preview/ 在这里,vite 的 fs.allow 要放行它 */
|
|
13
|
+
studioRoot: string;
|
|
14
|
+
/** 游戏 vite dev server 的端口 */
|
|
15
|
+
gamePort: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STUDIO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
|
|
20
|
+
export function loadConfig(argv: readonly string[]): StudioConfig {
|
|
21
|
+
const rest = [...argv];
|
|
22
|
+
let dir: string | undefined;
|
|
23
|
+
let gamePort = 4301;
|
|
24
|
+
|
|
25
|
+
while (rest.length > 0) {
|
|
26
|
+
const k = rest.shift()!;
|
|
27
|
+
if (k === '--game-port') gamePort = Number(rest.shift());
|
|
28
|
+
else if (k.startsWith('-')) throw new Error(`不认识的参数 ${k}`);
|
|
29
|
+
else dir = k;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const gameDir = dir ? (isAbsolute(dir) ? dir : resolve(process.cwd(), dir)) : process.cwd();
|
|
33
|
+
for (const need of ['package.json', 'vite.config.ts']) {
|
|
34
|
+
if (!existsSync(join(gameDir, need))) {
|
|
35
|
+
throw new Error(`${gameDir} 下没有 ${need}——这不像一个游戏目录。在游戏目录里启动,或把目录作为参数传进来`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { gameDir, studioRoot: STUDIO_ROOT, gamePort };
|
|
39
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/* 把 node 侧的零件组装成一个 Engine。MCP 工具和 /studio/api 都只跟它说话。
|
|
2
|
+
*
|
|
3
|
+
* 状态只有两处:长任务(Jobs)和总线(Bus)。游戏本身的状态在 Game 里按指纹缓存,
|
|
4
|
+
* 预览的状态在浏览器里,node 侧只留一份最近上报的快照给 AI 读。 */
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { Segment } from 'gamekit777/weights';
|
|
7
|
+
import { Bus } from './bus.ts';
|
|
8
|
+
import { buildMode, groupsSummary, listArtifacts, readArtifactBytes, sampleMode, type Artifact, type BuildModeOptions } from './build.ts';
|
|
9
|
+
import { CaseStore, captureCase, runCases, type CaseRecord, type RunCasesOptions } from './cases.ts';
|
|
10
|
+
import { Game, type Config } from './game.ts';
|
|
11
|
+
import { Jobs, type Job } from './jobs.ts';
|
|
12
|
+
import { probe, type ProbeOptions, type ProbeResult } from './probe.ts';
|
|
13
|
+
import { runSolve, type Targets } from './solver.ts';
|
|
14
|
+
import { runTask, type TaskName, type TaskResult } from './tasks.ts';
|
|
15
|
+
|
|
16
|
+
export interface PreviewState {
|
|
17
|
+
connected: boolean;
|
|
18
|
+
config: Config | null;
|
|
19
|
+
mode: string | null;
|
|
20
|
+
bet: number | null;
|
|
21
|
+
balance: number | null;
|
|
22
|
+
status: unknown;
|
|
23
|
+
lastError: string | null;
|
|
24
|
+
recent: unknown[];
|
|
25
|
+
consoleErrors: string[];
|
|
26
|
+
updatedAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PublishRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
modes: string[] | null;
|
|
32
|
+
note: string | null;
|
|
33
|
+
from: 'mcp' | 'page';
|
|
34
|
+
createdAt: number;
|
|
35
|
+
status: 'awaiting-approval' | 'running' | 'published' | 'rejected' | 'failed';
|
|
36
|
+
progress: string[];
|
|
37
|
+
result: unknown;
|
|
38
|
+
error: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Engine {
|
|
42
|
+
readonly game: Game;
|
|
43
|
+
readonly bus = new Bus();
|
|
44
|
+
readonly jobs: Jobs;
|
|
45
|
+
readonly cases: CaseStore;
|
|
46
|
+
readonly urls: { studio: string; preview: string };
|
|
47
|
+
preview: PreviewState = {
|
|
48
|
+
connected: false, config: null, mode: null, bet: null, balance: null,
|
|
49
|
+
status: null, lastError: null, recent: [], consoleErrors: [], updatedAt: 0,
|
|
50
|
+
};
|
|
51
|
+
#publish = new Map<string, PublishRequest>();
|
|
52
|
+
#lastFingerprint: string;
|
|
53
|
+
|
|
54
|
+
constructor(gameDir: string, urls: { studio: string; preview: string }) {
|
|
55
|
+
this.game = new Game(gameDir);
|
|
56
|
+
this.urls = urls;
|
|
57
|
+
this.#lastFingerprint = this.game.fingerprint;
|
|
58
|
+
this.jobs = new Jobs((job, msg) => this.bus.emit({ t: 'job', id: job.id, kind: job.kind, status: job.status, msg }));
|
|
59
|
+
this.cases = new CaseStore(gameDir, () => this.bus.emit({ t: 'cases', changed: true }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 每次外部调用前过一遍:指纹变了就广播,页面和 AI 都该知道缓存作废了 */
|
|
63
|
+
touch(): void {
|
|
64
|
+
if (this.game.refresh() || this.game.fingerprint !== this.#lastFingerprint) {
|
|
65
|
+
this.#lastFingerprint = this.game.fingerprint;
|
|
66
|
+
this.bus.emit({ t: 'fingerprint', hash: this.game.fingerprint });
|
|
67
|
+
this.bus.log('info', `规则指纹变了 → ${this.game.fingerprint},采样缓存与产物按新指纹重算`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async info(): Promise<{
|
|
72
|
+
gameDir: string; fingerprint: string; packageName: string;
|
|
73
|
+
snapshot: Awaited<ReturnType<Game['snapshot']>>;
|
|
74
|
+
urls: Engine['urls']; previewConnected: boolean; artifacts: Artifact[]; cases: number;
|
|
75
|
+
caseFile: string;
|
|
76
|
+
}> {
|
|
77
|
+
this.touch();
|
|
78
|
+
return {
|
|
79
|
+
gameDir: this.game.dir, fingerprint: this.game.fingerprint, packageName: this.game.packageName,
|
|
80
|
+
snapshot: await this.game.snapshot(), urls: this.urls,
|
|
81
|
+
previewConnected: this.bus.previewConnected,
|
|
82
|
+
artifacts: listArtifacts(this.game), cases: this.cases.list().length,
|
|
83
|
+
caseFile: this.cases.path,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── 探测 / 采样 / 求解 / 建表 ── */
|
|
88
|
+
|
|
89
|
+
probe(o: ProbeOptions = {}): Job<ProbeResult> {
|
|
90
|
+
this.touch();
|
|
91
|
+
return this.jobs.start('probe', async (say) => {
|
|
92
|
+
say('探测中:数组合、逐字段采样、数分布…');
|
|
93
|
+
const r = await probe(this.game, o);
|
|
94
|
+
say(`结论:${r.verdict}——${r.verdictReason}`);
|
|
95
|
+
return r;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sample(modes: string[] | null, sims: number | null): Job<{ modes: string[]; sims: number }> {
|
|
100
|
+
this.touch();
|
|
101
|
+
return this.jobs.start('sample', async (say) => {
|
|
102
|
+
const snap = await this.game.snapshot();
|
|
103
|
+
const n = sims ?? snap.spec.sims;
|
|
104
|
+
const idx = this.#modeIndices(snap.spec.modes.map((m) => m.name), modes);
|
|
105
|
+
for (const i of idx) await sampleMode(this.game, i, n, say);
|
|
106
|
+
return { modes: idx.map((i) => snap.spec.modes[i]!.name), sims: n };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async groups(mode: string | null, sims: number | null): Promise<ReturnType<typeof groupsSummary>> {
|
|
111
|
+
this.touch();
|
|
112
|
+
const snap = await this.game.snapshot();
|
|
113
|
+
const i = mode ? snap.spec.modes.findIndex((m) => m.name === mode) : 0;
|
|
114
|
+
if (i < 0) throw new Error(`没有 mode ${mode}`);
|
|
115
|
+
return groupsSummary(this.game, i, sims ?? snap.spec.sims);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async solve(mode: string | null, sims: number | null, targets: Targets): Promise<ReturnType<typeof runSolve> & { mode: string; rtpRangePpm: [number, number] }> {
|
|
119
|
+
const g = await this.groups(mode, sims);
|
|
120
|
+
const { rtpRangePpm } = await import('./solver.ts');
|
|
121
|
+
return { ...runSolve(g.groups, targets, g.betCostCenti, g.sims), mode: g.mode, rtpRangePpm: rtpRangePpm(g.groups, g.betCostCenti) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
build(modes: string[] | null, o: BuildModeOptions & { segments?: Segment[] } = {}): Job<Artifact[]> {
|
|
125
|
+
this.touch();
|
|
126
|
+
return this.jobs.start('build', async (say) => {
|
|
127
|
+
const snap = await this.game.snapshot();
|
|
128
|
+
const idx = this.#modeIndices(snap.spec.modes.map((m) => m.name), modes);
|
|
129
|
+
const out: Artifact[] = [];
|
|
130
|
+
for (const i of idx) out.push(await buildMode(this.game, i, o, say));
|
|
131
|
+
this.bus.emit({ t: 'build', artifacts: listArtifacts(this.game) });
|
|
132
|
+
return out;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
artifacts(): Artifact[] { this.touch(); return listArtifacts(this.game); }
|
|
137
|
+
|
|
138
|
+
artifactBytes(mode: string): ReturnType<typeof readArtifactBytes> | null {
|
|
139
|
+
const a = listArtifacts(this.game).find((x) => x.mode === mode);
|
|
140
|
+
return a ? readArtifactBytes(a) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#modeIndices(all: string[], want: string[] | null): number[] {
|
|
144
|
+
if (!want) return all.map((_, i) => i);
|
|
145
|
+
return want.map((m) => {
|
|
146
|
+
const i = all.indexOf(m);
|
|
147
|
+
if (i < 0) throw new Error(`清单里没有 mode ${m}。有的是:${all.join('、')}`);
|
|
148
|
+
return i;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ── 任务 ── */
|
|
153
|
+
|
|
154
|
+
task(name: TaskName): Job<TaskResult> {
|
|
155
|
+
this.touch();
|
|
156
|
+
return this.jobs.start(`task:${name}`, async (say) => {
|
|
157
|
+
say(`bun run ${name}`);
|
|
158
|
+
const r = await runTask(this.game.dir, name);
|
|
159
|
+
say(r.ok ? `通过(${(r.durationMs / 1000).toFixed(1)}s)` : `失败,退出码 ${r.exitCode}${r.timedOut ? '(超时)' : ''}`);
|
|
160
|
+
return r;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── case ── */
|
|
165
|
+
|
|
166
|
+
async caseAdd(input: Parameters<typeof captureCase>[1]): Promise<CaseRecord> {
|
|
167
|
+
this.touch();
|
|
168
|
+
const rec = this.cases.add(await captureCase(this.game, input));
|
|
169
|
+
this.bus.emit({ t: 'cases', changed: true });
|
|
170
|
+
return rec;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
caseRun(o: RunCasesOptions = {}): ReturnType<typeof runCases> {
|
|
174
|
+
this.touch();
|
|
175
|
+
return runCases(this.game, this.cases, o);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ── 预览 ── */
|
|
179
|
+
|
|
180
|
+
reportPreview(state: Partial<PreviewState>): void {
|
|
181
|
+
this.bus.heartbeat();
|
|
182
|
+
this.preview = { ...this.preview, ...state, connected: this.bus.previewConnected, updatedAt: Date.now() };
|
|
183
|
+
this.bus.emit({ t: 'preview', state: this.preview });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
previewCommand<T>(cmd: unknown, timeoutMs?: number): Promise<T> {
|
|
187
|
+
return this.bus.command<T>(cmd, timeoutMs);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ── 发布:node 侧只管排队和记录,上传由页面用浏览器身份做 ── */
|
|
191
|
+
|
|
192
|
+
requestPublish(modes: string[] | null, note: string | null, from: PublishRequest['from']): PublishRequest {
|
|
193
|
+
const req: PublishRequest = {
|
|
194
|
+
id: `p${Date.now().toString(36)}`, modes, note, from, createdAt: Date.now(),
|
|
195
|
+
status: 'awaiting-approval', progress: [], result: null, error: null,
|
|
196
|
+
};
|
|
197
|
+
this.#publish.set(req.id, req);
|
|
198
|
+
this.bus.emit({ t: 'publish', request: req });
|
|
199
|
+
return req;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
updatePublish(id: string, patch: Partial<PublishRequest>): PublishRequest | null {
|
|
203
|
+
const req = this.#publish.get(id);
|
|
204
|
+
if (!req) return null;
|
|
205
|
+
Object.assign(req, patch);
|
|
206
|
+
this.bus.emit({ t: 'publish', request: req });
|
|
207
|
+
return req;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
publishRequests(): PublishRequest[] { return [...this.#publish.values()].sort((a, b) => b.createdAt - a.createdAt); }
|
|
211
|
+
|
|
212
|
+
async dispose(): Promise<void> {
|
|
213
|
+
this.cases.close();
|
|
214
|
+
await this.game.dispose();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const studioDir = (gameDir: string): string => join(gameDir, '.studio');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* 规则指纹:游戏逻辑的内容哈希。
|
|
2
|
+
*
|
|
3
|
+
* 采样缓存、构建产物全挂在它上面。指纹没变就复用,
|
|
4
|
+
* 变了就作废——拿旧表演新逻辑是「表里写 0x、客户端演 4.64x」的翻版,而且完全静默。
|
|
5
|
+
*
|
|
6
|
+
* 范围故意取得宽:src/ 下所有非 .svelte 的 .ts,外加清单 game.ts。
|
|
7
|
+
* 纯逻辑面的传递依赖没法静态枚举(coco 的 rules/ 依赖 lib/),漏掉一个就是静默失效,
|
|
8
|
+
* 多算几个文件的代价只是偶尔一次不必要的重采样。 */
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, relative } from 'node:path';
|
|
11
|
+
import { Sha256 } from 'gamekit777/sdk/hash';
|
|
12
|
+
|
|
13
|
+
const walk = (dir: string, out: string[] = []): string[] => {
|
|
14
|
+
for (const n of readdirSync(dir).sort()) {
|
|
15
|
+
const p = join(dir, n);
|
|
16
|
+
if (statSync(p).isDirectory()) walk(p, out);
|
|
17
|
+
else if (/\.ts$/.test(n) && !/\.svelte\.ts$/.test(n) && !/\.test\.ts$/.test(n)) out.push(p);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface Fingerprint {
|
|
23
|
+
/** 16 位十六进制 */
|
|
24
|
+
hash: string;
|
|
25
|
+
files: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function fingerprintOf(gameDir: string): Fingerprint {
|
|
29
|
+
const files = walk(join(gameDir, 'src'));
|
|
30
|
+
try { statSync(join(gameDir, 'game.ts')); files.push(join(gameDir, 'game.ts')); } catch { /* 没有就没有 */ }
|
|
31
|
+
|
|
32
|
+
const h = new Sha256();
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
h.update(new TextEncoder().encode(`${relative(gameDir, f)}\0`));
|
|
35
|
+
h.update(readFileSync(f));
|
|
36
|
+
h.update(new TextEncoder().encode('\0'));
|
|
37
|
+
}
|
|
38
|
+
return { hash: h.hex().slice(0, 16), files: files.length };
|
|
39
|
+
}
|