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
@@ -0,0 +1,134 @@
1
+ /* probe 用的那点统计:两个赔付直方图是不是同一个分布。
2
+ *
3
+ * 用 G 检验(对数似然比)而不是 Pearson χ²:赔付分布极度长尾,
4
+ * 稀有档位的期望计数很小,Pearson 在那里数值不稳。桶合并之后两者渐近等价,
5
+ * G 在小期望上更保守一点。 */
6
+
7
+ /** 上正则化不完全伽马 Q(a, x)——χ² 分布的右尾。Numerical Recipes 的 gammq */
8
+ export function gammaQ(a: number, x: number): number {
9
+ if (x <= 0) return 1;
10
+ if (x < a + 1) return 1 - gammaPSeries(a, x);
11
+ return gammaQContinued(a, x);
12
+ }
13
+
14
+ function lgamma(z: number): number {
15
+ const c = [76.18009172947146, -86.50532032941677, 24.01409824083091,
16
+ -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5];
17
+ let y = z, x = z;
18
+ let tmp = x + 5.5;
19
+ tmp -= (x + 0.5) * Math.log(tmp);
20
+ let ser = 1.000000000190015;
21
+ for (const ci of c) ser += ci / ++y;
22
+ return -tmp + Math.log(2.5066282746310005 * ser / x);
23
+ }
24
+
25
+ function gammaPSeries(a: number, x: number): number {
26
+ let ap = a, sum = 1 / a, del = sum;
27
+ for (let n = 0; n < 500; n++) {
28
+ ap += 1; del *= x / ap; sum += del;
29
+ if (Math.abs(del) < Math.abs(sum) * 1e-14) break;
30
+ }
31
+ return sum * Math.exp(-x + a * Math.log(x) - lgamma(a));
32
+ }
33
+
34
+ function gammaQContinued(a: number, x: number): number {
35
+ const FPMIN = 1e-300;
36
+ let b = x + 1 - a, c = 1 / FPMIN, d = 1 / b, h = d;
37
+ for (let i = 1; i < 500; i++) {
38
+ const an = -i * (i - a);
39
+ b += 2;
40
+ d = an * d + b; if (Math.abs(d) < FPMIN) d = FPMIN;
41
+ c = b + an / c; if (Math.abs(c) < FPMIN) c = FPMIN;
42
+ d = 1 / d;
43
+ const del = d * c;
44
+ h *= del;
45
+ if (Math.abs(del - 1) < 1e-14) break;
46
+ }
47
+ return Math.exp(-x + a * Math.log(x) - lgamma(a)) * h;
48
+ }
49
+
50
+ /** χ² 右尾概率 */
51
+ export const chiSquareTail = (stat: number, df: number): number => (df <= 0 ? 1 : gammaQ(df / 2, stat / 2));
52
+
53
+ export interface HistogramTest {
54
+ /** G 统计量 */
55
+ g: number;
56
+ df: number;
57
+ p: number;
58
+ /** 合并桶之后,各桶标准化偏差 (o-e)/√e 的最大绝对值 */
59
+ maxSigma: number;
60
+ /** 只在一侧出现且期望计数够大的赔付——支持集就不同,不用算统计量就能判 */
61
+ disjoint: number[];
62
+ bins: number;
63
+ }
64
+
65
+ /**
66
+ * 两个样本是否来自同一分布(同质性检验)。
67
+ *
68
+ * 期望计数 < minExpected 的桶合并进相邻桶——否则一个只出现过两次的爆奖
69
+ * 就能把统计量推到天上,那不是分布不同,是样本不够。
70
+ */
71
+ export function compareHistograms(
72
+ a: ReadonlyMap<number, number>, b: ReadonlyMap<number, number>, minExpected = 5,
73
+ ): HistogramTest {
74
+ const na = [...a.values()].reduce((s, v) => s + v, 0);
75
+ const nb = [...b.values()].reduce((s, v) => s + v, 0);
76
+ const n = na + nb;
77
+ const keys = [...new Set([...a.keys(), ...b.keys()])].sort((x, y) => x - y);
78
+
79
+ const disjoint: number[] = [];
80
+ for (const k of keys) {
81
+ const ca = a.get(k) ?? 0, cb = b.get(k) ?? 0;
82
+ // 一侧为零、另一侧按合并样本算期望也 ≥ minExpected:这不是抽样噪声
83
+ const expected = ((ca + cb) / n) * Math.min(na, nb);
84
+ if ((ca === 0 || cb === 0) && expected >= minExpected) disjoint.push(k);
85
+ }
86
+
87
+ // 按赔付排序后贪心合并,直到每桶在两侧的期望都 ≥ minExpected
88
+ const bins: { ca: number; cb: number }[] = [];
89
+ let cur = { ca: 0, cb: 0 };
90
+ for (const k of keys) {
91
+ cur.ca += a.get(k) ?? 0;
92
+ cur.cb += b.get(k) ?? 0;
93
+ const tot = cur.ca + cur.cb;
94
+ if ((tot / n) * na >= minExpected && (tot / n) * nb >= minExpected) { bins.push(cur); cur = { ca: 0, cb: 0 }; }
95
+ }
96
+ if (cur.ca + cur.cb > 0) {
97
+ if (bins.length > 0) { bins[bins.length - 1]!.ca += cur.ca; bins[bins.length - 1]!.cb += cur.cb; }
98
+ else bins.push(cur);
99
+ }
100
+
101
+ let g = 0, maxSigma = 0;
102
+ for (const { ca, cb } of bins) {
103
+ const tot = ca + cb;
104
+ const ea = (tot * na) / n, eb = (tot * nb) / n;
105
+ if (ca > 0) g += 2 * ca * Math.log(ca / ea);
106
+ if (cb > 0) g += 2 * cb * Math.log(cb / eb);
107
+ if (ea > 0) maxSigma = Math.max(maxSigma, Math.abs(ca - ea) / Math.sqrt(ea));
108
+ if (eb > 0) maxSigma = Math.max(maxSigma, Math.abs(cb - eb) / Math.sqrt(eb));
109
+ }
110
+ const df = Math.max(0, bins.length - 1);
111
+ return { g, df, p: chiSquareTail(g, df), maxSigma, disjoint, bins: bins.length };
112
+ }
113
+
114
+ export const histogram = (xs: ArrayLike<number>): Map<number, number> => {
115
+ const m = new Map<number, number>();
116
+ for (let i = 0; i < xs.length; i++) m.set(xs[i]!, (m.get(xs[i]!) ?? 0) + 1);
117
+ return m;
118
+ };
119
+
120
+ /** C(n, k),BigInt */
121
+ export function choose(n: number, k: number): bigint {
122
+ if (k < 0 || k > n) return 0n;
123
+ let r = 1n;
124
+ for (let i = 1; i <= k; i++) r = (r * BigInt(n - k + i)) / BigInt(i);
125
+ return r;
126
+ }
127
+
128
+ /** P(n, k) = n!/(n-k)!,BigInt */
129
+ export function permutations(n: number, k: number): bigint {
130
+ if (k < 0 || k > n) return 0n;
131
+ let r = 1n;
132
+ for (let i = 0; i < k; i++) r *= BigInt(n - i);
133
+ return r;
134
+ }
@@ -0,0 +1,76 @@
1
+ /* 往游戏的 vite dev server 里注入 studio:页面、预览页、API。
2
+ *
3
+ * 三者同源是刻意的。页面要 import 游戏 logic 算赔率表、跑求解;预览要 fetch 产物;
4
+ * 发布要用浏览器 cookie 打反代到 /v1。分开起第二个 server 这三件事全要绕。
5
+ *
6
+ * 不往游戏目录写任何文件:两个 html 从内存吐,入口用 /@fs 指回 studio 包。
7
+ * 为什么不直接用游戏自己的 index.html:那是给 `bun run dev` 的,指向 src/dev.ts。 */
8
+ import type { Plugin } from 'vite';
9
+ import { API_PREFIX, createApi } from './api.ts';
10
+ import type { Engine } from './engine.ts';
11
+
12
+ export interface StudioPluginOptions {
13
+ engine: Engine;
14
+ /** 游戏入口的绝对路径,默认导出 GameModule */
15
+ gameEntry: string;
16
+ /** studio 包根,preview/ 和 app/ 在这里 */
17
+ studioRoot: string;
18
+ }
19
+
20
+ export const VIRTUAL_GAME = 'virtual:studio/game';
21
+ export const PREVIEW_PATH = '/__studio/preview.html';
22
+ export const PAGE_PATH = '/__studio/';
23
+
24
+ const page = (title: string, entry: string): string => `<!doctype html>
25
+ <html lang="zh-CN">
26
+ <head>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
29
+ <title>${title}</title>
30
+ <script type="module" src="/@fs${entry}"></script>
31
+ </head>
32
+ <body></body>
33
+ </html>
34
+ `;
35
+
36
+ export function studioPlugin(o: StudioPluginOptions): Plugin {
37
+ const previewHtml = page('GameKit Studio 预览', `${o.studioRoot}/preview/entry.ts`);
38
+ const appHtml = page('GameKit Studio', `${o.studioRoot}/app/main.ts`);
39
+ const api = createApi(o.engine);
40
+
41
+ return {
42
+ name: 'gamekit-studio',
43
+
44
+ resolveId(id) {
45
+ // 返回真实路径而不是 \0 前缀的虚拟 id:让 vite 把它当普通文件,HMR 才跟得上
46
+ if (id === VIRTUAL_GAME) return o.gameEntry;
47
+ return null;
48
+ },
49
+
50
+ configureServer(server) {
51
+ server.middlewares.use(api);
52
+ server.middlewares.use((req, res, next) => {
53
+ const path = req.url?.split('?')[0];
54
+ const html = path === PREVIEW_PATH ? previewHtml
55
+ : path === PAGE_PATH || path === '/__studio' || path === '/__studio/index.html' ? appHtml
56
+ : null;
57
+ if (!html) return next();
58
+ void server.transformIndexHtml(req.url!, html).then((out) => {
59
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
60
+ res.end(out);
61
+ }, next);
62
+ });
63
+ },
64
+
65
+ /* 规则改了要让 node 侧知道:采样缓存按指纹作废、页面收到广播。
66
+ 预览页自己的热更新由 vite 照常处理,这里只是顺便通知 */
67
+ handleHotUpdate(ctx) {
68
+ if (ctx.file.startsWith(o.engine.game.dir) && !ctx.file.includes('/.studio/')) {
69
+ queueMicrotask(() => o.engine.touch());
70
+ }
71
+ return undefined;
72
+ },
73
+ };
74
+ }
75
+
76
+ export { API_PREFIX };
@@ -0,0 +1,52 @@
1
+ /* 在游戏目录里跑 check / test / verify / build。
2
+ *
3
+ * 就是 `bun run <task>`,studio 不自己实现这些——游戏目录的 package.json 才是真值。
4
+ * 加的只有超时、输出截断和一点诊断抽取,让 AI 拿到的是结构而不是一屏日志。 */
5
+ import { spawn } from 'node:child_process';
6
+
7
+ export type TaskName = 'check' | 'test' | 'verify' | 'build';
8
+
9
+ export interface TaskResult {
10
+ task: TaskName;
11
+ ok: boolean;
12
+ exitCode: number | null;
13
+ timedOut: boolean;
14
+ durationMs: number;
15
+ /** 从输出里抽出来的错误行,AI 优先看这里 */
16
+ issues: string[];
17
+ /** 完整输出(截断到 maxOutput) */
18
+ output: string;
19
+ }
20
+
21
+ export interface RunTaskOptions {
22
+ timeoutMs?: number;
23
+ maxOutput?: number;
24
+ }
25
+
26
+ const ISSUE = /(error|✗|Error:|FAIL|AssertionError|TS\d{4}:|✖|失败|不通过)/;
27
+
28
+ export function runTask(gameDir: string, task: TaskName, o: RunTaskOptions = {}): Promise<TaskResult> {
29
+ const timeoutMs = o.timeoutMs ?? 300_000;
30
+ const maxOutput = o.maxOutput ?? 40_000;
31
+ const t0 = Date.now();
32
+
33
+ return new Promise((resolve) => {
34
+ const child = spawn('bun', ['run', task], { cwd: gameDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' } });
35
+ let out = '';
36
+ const push = (b: Buffer): void => { if (out.length < maxOutput * 2) out += b.toString(); };
37
+ child.stdout.on('data', push);
38
+ child.stderr.on('data', push);
39
+
40
+ let timedOut = false;
41
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
42
+
43
+ child.on('close', (code) => {
44
+ clearTimeout(timer);
45
+ const clean = out.replace(/\x1b\[[0-9;]*m/g, '');
46
+ const lines = clean.split('\n');
47
+ const issues = lines.filter((l) => ISSUE.test(l)).map((l) => l.trim()).filter(Boolean).slice(0, 60);
48
+ const output = clean.length > maxOutput ? `${clean.slice(0, maxOutput / 2)}\n…(截断 ${clean.length - maxOutput} 字)…\n${clean.slice(-maxOutput / 2)}` : clean;
49
+ resolve({ task, ok: code === 0 && !timedOut, exitCode: code, timedOut, durationMs: Date.now() - t0, issues, output });
50
+ });
51
+ });
52
+ }
@@ -0,0 +1,84 @@
1
+ /* 主进程侧:一池 worker,每个都加载了同一份游戏代码。
2
+ *
3
+ * 池按规则指纹存活:指纹一变整池换掉。这是「主进程不持有游戏代码」的另一半——
4
+ * 主进程只认识指纹和纯数据,游戏代码活在哪一批 worker 里由指纹决定。 */
5
+ import { Worker } from 'node:worker_threads';
6
+ import { availableParallelism } from 'node:os';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ interface Pending { resolve: (v: unknown) => void; reject: (e: Error) => void }
10
+
11
+ const RPC = new URL('./rpc.ts', import.meta.url);
12
+
13
+ export class WorkerPool {
14
+ #workers: Worker[] = [];
15
+ #pending = new Map<number, Pending>();
16
+ #next = 0;
17
+ #rr = 0;
18
+ #ready: Promise<void>;
19
+ readonly size: number;
20
+
21
+ constructor(o: { specPath: string; size?: number }) {
22
+ this.size = o.size ?? Math.max(1, Math.min(8, availableParallelism() - 1));
23
+ const workerData = { specUrl: pathToFileURL(o.specPath).href };
24
+ const readies: Promise<void>[] = [];
25
+ for (let i = 0; i < this.size; i++) {
26
+ const w = new Worker(RPC, { workerData });
27
+ readies.push(new Promise<void>((resolve, reject) => {
28
+ const onReady = (m: { id: number; ok: boolean; result?: unknown; error?: string }): void => {
29
+ if (m.id !== -1) return;
30
+ w.off('message', onReady);
31
+ resolve();
32
+ };
33
+ w.on('message', onReady);
34
+ w.once('error', reject);
35
+ }));
36
+ w.on('message', (m: { id: number; ok: boolean; result?: unknown; error?: string }) => {
37
+ if (m.id === -1) return;
38
+ const p = this.#pending.get(m.id);
39
+ if (!p) return;
40
+ this.#pending.delete(m.id);
41
+ if (m.ok) p.resolve(m.result);
42
+ else p.reject(new Error(m.error));
43
+ });
44
+ w.on('error', (e: unknown) => {
45
+ // 一个 worker 崩了,挂在它上面的调用全部失败,别让它们悬着
46
+ const err = e instanceof Error ? e : new Error(String(e));
47
+ for (const [id, p] of this.#pending) { p.reject(err); this.#pending.delete(id); }
48
+ });
49
+ this.#workers.push(w);
50
+ }
51
+ this.#ready = Promise.all(readies).then(() => undefined);
52
+ }
53
+
54
+ ready(): Promise<void> { return this.#ready; }
55
+
56
+ /** 轮转派发。任务都是纯计算,谁接都一样 */
57
+ call<T>(task: string, args: unknown, worker?: number): Promise<T> {
58
+ const w = this.#workers[worker ?? (this.#rr++ % this.#workers.length)]!;
59
+ const id = this.#next++;
60
+ return new Promise<T>((resolve, reject) => {
61
+ this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
62
+ w.postMessage({ id, task, args });
63
+ });
64
+ }
65
+
66
+ /** 把 [0, n) 切给所有 worker 并行跑,按序拼回 */
67
+ async spread<T>(n: number, run: (worker: number, from: number, to: number) => Promise<T>): Promise<T[]> {
68
+ const k = Math.min(this.size, Math.max(1, Math.ceil(n / 2_000)));
69
+ const per = Math.ceil(n / k);
70
+ const jobs: Promise<T>[] = [];
71
+ for (let i = 0; i < k; i++) {
72
+ const from = i * per;
73
+ const to = Math.min(n, from + per);
74
+ if (to > from) jobs.push(run(i, from, to));
75
+ }
76
+ return Promise.all(jobs);
77
+ }
78
+
79
+ async dispose(): Promise<void> {
80
+ await Promise.all(this.#workers.map((w) => w.terminate()));
81
+ this.#workers = [];
82
+ for (const [id, p] of this.#pending) { p.reject(new Error('worker 池已关闭')); this.#pending.delete(id); }
83
+ }
84
+ }
@@ -0,0 +1,178 @@
1
+ /* 工作线程侧:加载游戏清单,按名字执行任务。
2
+ *
3
+ * 为什么所有游戏代码都在这里跑而不在主进程:bun 的 import() 缓存不认 query,
4
+ * 改了规则再 import 拿到的还是旧模块。worker 每个都有独立的模块缓存,
5
+ * 换一批 worker 就是换一份代码——这比任何破缓存的花招都可靠。
6
+ * 顺带把「平台的模块图里不能有游戏代码」这条不变式在 studio 里也守住了。 */
7
+ import { parentPort, workerData } from 'node:worker_threads';
8
+ import { sampleSeed, type GameDefinition, type RngSeed } from 'gamekit777/sdk';
9
+ import type { Segment } from 'gamekit777/weights';
10
+ import { type BuiltTable, type SampleBatch, buildTable, checkBetLinearity, sampleRange } from 'gamekit777/publish';
11
+
12
+ type Config = Record<string, unknown>;
13
+ type AnySpec = GameDefinition<Config, unknown>;
14
+
15
+ interface Boot { specUrl: string }
16
+ interface Call { id: number; task: string; args: unknown }
17
+
18
+ const boot = workerData as Boot;
19
+ const spec = ((await import(boot.specUrl)) as { default?: AnySpec }).default;
20
+ if (!spec || typeof spec.normalize !== 'function' || typeof spec.round !== 'function' || !Array.isArray(spec.modes)) {
21
+ throw new Error('game.ts 的默认导出要是 defineGame({...}) 的返回值');
22
+ }
23
+
24
+ /**
25
+ * 给任意一份配置采样:借清单的 round / seedPrefix,只把 mode 换成这一份配置。
26
+ * probe 要逐字段换值采样,那些配置多半不对应任何 mode,所以不能走 spec.configOf。
27
+ * book 设成 'none':probe 只看赔付分布,不用编演出。
28
+ */
29
+ const specFor = (config: Config, name: string, seedPrefix: string): AnySpec => ({
30
+ ...spec, seedPrefix, book: 'none',
31
+ modes: [{ name, config }], modeKeys: Object.keys(config),
32
+ configOf: () => spec.normalize(config),
33
+ modeOf: () => name,
34
+ });
35
+
36
+ const bookKind = (): 'none' | 'seed' | 'custom' => (spec.book === 'none' ? 'none' : spec.book === 'seed' ? 'seed' : 'custom');
37
+
38
+ const pack = (batch: SampleBatch): { payoutCenti: Int32Array; data: Uint8Array; offsets: Int32Array; draws: number } => {
39
+ let total = 0;
40
+ for (const b of batch.books) total += b.length;
41
+ const data = new Uint8Array(total);
42
+ const offsets = new Int32Array(batch.books.length + 1);
43
+ let at = 0;
44
+ for (let i = 0; i < batch.books.length; i++) { data.set(batch.books[i]!, at); at += batch.books[i]!.length; offsets[i + 1] = at; }
45
+ return { payoutCenti: batch.payoutCenti, data, offsets, draws: batch.draws };
46
+ };
47
+
48
+ const modeAt = (i: number): AnySpec['modes'][number] => {
49
+ const m = spec.modes[i];
50
+ if (!m) throw new Error(`没有第 ${i} 个 mode(共 ${spec.modes.length} 个)`);
51
+ return m;
52
+ };
53
+
54
+ const tasks: Record<string, (args: never) => unknown | Promise<unknown>> = {
55
+ /** 纯数据快照。主进程只认识这个,不认识清单本身 */
56
+ snapshot: () => ({
57
+ schema: spec.schema,
58
+ spec: {
59
+ slug: spec.slug, title: spec.title, description: spec.description,
60
+ autoplay: spec.autoplay, rtp: spec.rtp, sims: spec.sims, seedPrefix: spec.seedPrefix,
61
+ book: bookKind(),
62
+ modes: spec.modes.map((m) => ({ name: m.name, config: m.config })),
63
+ modeKeys: spec.modeKeys,
64
+ },
65
+ }),
66
+
67
+ normalize: (a: { config: Partial<Config>; prev?: Config }) => {
68
+ const config = spec.normalize(a.config, a.prev);
69
+ let mode: string | null;
70
+ try { mode = spec.modeOf(config); } catch { mode = null; }
71
+ return { config, issues: spec.validate(config), mode };
72
+ },
73
+
74
+ paytable: (a: { config: Config }) => spec.paytableFor(spec.normalize(a.config)),
75
+
76
+ modeOf: (a: { configs: Config[] }) => a.configs.map((c) => { try { return spec.modeOf(c); } catch { return null; } }),
77
+
78
+ /**
79
+ * purity=true 时照抄 scripts/verify.ts 的手法:把 Math.random 和 crypto 换成会抛的假实现。
80
+ * 每次跑 case 都顺带验一遍「rules/ 没偷用随机源」,这是白捡的保障。
81
+ */
82
+ round: (a: { config: Config; bet: number; seed: RngSeed; purity?: boolean }) => {
83
+ if (!a.purity) return spec.round(a);
84
+ const realRandom = Math.random;
85
+ const realCrypto = globalThis.crypto;
86
+ const boom = (who: string) => (): never => { throw new Error(`round() 调用了 ${who}`); };
87
+ Math.random = boom('Math.random') as typeof Math.random;
88
+ Object.defineProperty(globalThis, 'crypto', { configurable: true, get: boom('crypto') });
89
+ try {
90
+ return spec.round(a);
91
+ } finally {
92
+ Math.random = realRandom;
93
+ Object.defineProperty(globalThis, 'crypto', { configurable: true, value: realCrypto, writable: true });
94
+ }
95
+ },
96
+
97
+ /**
98
+ * 表模型的一条往返:按清单重建第 simId 局 → 编 book → restore 还原。
99
+ * case 库验表模型走这条,不需要先建好表——(seedPrefix, mode, simId) 确定性派生就能重建那一行。
100
+ */
101
+ tableRoundtrip: (a: { modeIndex: number; simId: number; config: Config; bet: number }) => {
102
+ const mode = modeAt(a.modeIndex);
103
+ const batch = sampleRange(spec, a.modeIndex, a.simId, a.simId + 1);
104
+ const payoutCenti = batch.payoutCenti[0]!;
105
+ const frames = batch.books[0] ?? null;
106
+ const seed = sampleSeed(spec.seedPrefix, mode.name, a.simId);
107
+ const restored = spec.restore({ config: a.config, bet: a.bet, payoutCenti, frames, mode: mode.name, seed });
108
+ return { payoutCenti, frames, seed, restored };
109
+ },
110
+
111
+ /**
112
+ * 用主进程并行采好的 batch 建表。buildTable 要清单本身(算 declaredTiers、验注额线性),
113
+ * 所以在 worker 里跑;采样那一步慢,由主进程切给全部 worker,这里只吃现成的。
114
+ */
115
+ buildFromBatch: async (a: {
116
+ modeIndex: number; payoutCenti: Int32Array; data: Uint8Array; offsets: Int32Array; draws: number;
117
+ declaredRtpPpm?: number; segments?: Segment[];
118
+ }): Promise<BuiltTable> => {
119
+ modeAt(a.modeIndex);
120
+ const books: Uint8Array[] = [];
121
+ for (let i = 0; i + 1 < a.offsets.length; i++) books.push(a.data.subarray(a.offsets[i]!, a.offsets[i + 1]!));
122
+ const batch: SampleBatch = { payoutCenti: a.payoutCenti, books, draws: a.draws };
123
+ return buildTable({ ...spec, sims: a.payoutCenti.length }, a.modeIndex, {
124
+ sampler: () => batch, declaredRtpPpm: a.declaredRtpPpm, segments: a.segments,
125
+ });
126
+ },
127
+
128
+ restore: (a: { config: Config; bet: number; payoutCenti: number; frames: Uint8Array | null; mode: string; seed: RngSeed }) => spec.restore(a),
129
+
130
+ /**
131
+ * 对给定 config 采样 [from, to)。走 publish 的 sampleRange,
132
+ * 白嫖它的 payout 整数性 / 上限校验——那些正是「能不能打表」的硬门槛。
133
+ */
134
+ sample: (a: { config: Config; name: string; seedPrefix: string; from: number; to: number }) => {
135
+ const batch = sampleRange(specFor(a.config, a.name, a.seedPrefix), 0, a.from, a.to);
136
+ return { payoutCenti: batch.payoutCenti, draws: batch.draws };
137
+ },
138
+
139
+ /** 用清单里的 mode 采样,含 book 编码 */
140
+ sampleSpec: (a: { modeIndex: number; from: number; to: number }) => {
141
+ modeAt(a.modeIndex);
142
+ return pack(sampleRange(spec, a.modeIndex, a.from, a.to));
143
+ },
144
+
145
+ /** 成对种子:两个 config 在同一批种子下,赔付变了多少局 */
146
+ pairedDiff: (a: { configA: Config; configB: Config; seedPrefix: string; count: number }) => {
147
+ const ca = spec.normalize(a.configA);
148
+ const cb = spec.normalize(a.configB);
149
+ let changed = 0;
150
+ for (let i = 0; i < a.count; i++) {
151
+ const seed = sampleSeed(a.seedPrefix, 'pair', i);
152
+ const pa = spec.round({ config: ca, bet: 100, seed }).payout;
153
+ const pb = spec.round({ config: cb, bet: 100, seed }).payout;
154
+ if (pa !== pb) changed++;
155
+ }
156
+ return { changed, count: a.count };
157
+ },
158
+
159
+ betLinearity: (a: { config: Config; seedPrefix: string; indices: number[] }) => {
160
+ checkBetLinearity(specFor(a.config, 'lin', a.seedPrefix), 0, a.indices);
161
+ return { ok: true };
162
+ },
163
+ };
164
+
165
+ parentPort!.on('message', (m: Call) => {
166
+ void (async () => {
167
+ try {
168
+ const fn = tasks[m.task];
169
+ if (!fn) throw new Error(`worker 不认识任务 ${m.task}`);
170
+ const result = await fn(m.args as never);
171
+ parentPort!.postMessage({ id: m.id, ok: true, result });
172
+ } catch (e) {
173
+ parentPort!.postMessage({ id: m.id, ok: false, error: e instanceof Error ? e.message : String(e) });
174
+ }
175
+ })();
176
+ });
177
+
178
+ parentPort!.postMessage({ id: -1, ok: true, result: 'ready' });