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,91 @@
1
+ /* 对游戏目录起真 vite dev server。
2
+ *
3
+ * 游戏的 vite.config.ts 原样吃进来,一个字节不改;studio 的东西全部走 inline 合并。
4
+ * 这样预览走的就是 `bun run dev` 那条路——同一份 defineGameConfig、同一套 HMR,
5
+ * 不存在「studio 里演得好好的,真 build 出来不一样」的分叉空间。 */
6
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { createLogger, createServer, searchForWorkspaceRoot, type Logger, type ViteDevServer } from 'vite';
9
+ import type { Engine } from './engine.ts';
10
+ import { studioPlugin } from './studio-plugin.ts';
11
+
12
+ export interface GameServerOptions {
13
+ gameDir: string;
14
+ studioRoot: string;
15
+ port: number;
16
+ /** 平台后端,/v1 反代到它。页面同源登录之后,发布用的就是浏览器里的这份身份 */
17
+ backendUrl: string;
18
+ engine: Engine;
19
+ /** stdio 模式下 stdout 是 MCP 协议,vite 的一切输出都得走 stderr */
20
+ quiet?: boolean;
21
+ }
22
+
23
+ /** 游戏入口。从 package.json 的 exports['.'] 读,没有就按脚手架的约定 */
24
+ export function resolveGameEntry(gameDir: string): string {
25
+ const pkgPath = join(gameDir, 'package.json');
26
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { exports?: Record<string, string> | string };
27
+ const entry = typeof pkg.exports === 'string' ? pkg.exports : pkg.exports?.['.'] ?? './src/index.ts';
28
+ const abs = resolve(gameDir, entry);
29
+ if (!existsSync(abs)) throw new Error(`游戏入口 ${abs} 不存在`);
30
+ return abs;
31
+ }
32
+
33
+ /**
34
+ * vite 的 fs.allow 检查的是 **realpath**,而且一旦显式给了这个字段,
35
+ * 默认的 workspace 根探测就整个失效——只放行 gameDir 和 studioRoot 是不够的:
36
+ * 预览壳 import 的 `gamekit777/dev-host` 在仓库内是符号链接,realpath 落在
37
+ * packages/dev-host,不在这两个目录下,直接 403。第一次跑就是这么白屏的。
38
+ *
39
+ * 所以两侧的 workspace 根都要进名单:仓库内它们是同一个(仓库根),
40
+ * 创作者机器上 gameDir 找不到 workspace 就返回自己,studio 那边返回 gamekit777 的包根。
41
+ */
42
+ function allowList(o: GameServerOptions): string[] {
43
+ const list = new Set([
44
+ o.gameDir, searchForWorkspaceRoot(o.gameDir),
45
+ o.studioRoot, searchForWorkspaceRoot(o.studioRoot),
46
+ ]);
47
+ try { list.add(realpathSync(join(o.studioRoot, 'node_modules', 'gamekit777'))); } catch { /* 没装就算了 */ }
48
+ return [...list];
49
+ }
50
+
51
+ function stderrLogger(): Logger {
52
+ const base = createLogger('info', { allowClearScreen: false });
53
+ const w = (m: string): void => { process.stderr.write(`${m}\n`); };
54
+ return { ...base, info: w, warn: w, warnOnce: w, error: w, clearScreen: () => {} };
55
+ }
56
+
57
+ export async function startGameServer(o: GameServerOptions): Promise<ViteDevServer> {
58
+ const server = await createServer({
59
+ root: o.gameDir,
60
+ configFile: join(o.gameDir, 'vite.config.ts'),
61
+ customLogger: o.quiet ? stderrLogger() : undefined,
62
+ clearScreen: false,
63
+ plugins: [studioPlugin({
64
+ engine: o.engine,
65
+ gameEntry: resolveGameEntry(o.gameDir),
66
+ studioRoot: o.studioRoot,
67
+ })],
68
+ server: {
69
+ port: o.port,
70
+ strictPort: false,
71
+ host: '127.0.0.1',
72
+ /* 预览页嵌在 studio 页面的 iframe 里;同源,但照抄 apps/web 的做法放开——
73
+ sandbox 属性一加 Origin 就是 null,默认的 cors 会挡 */
74
+ cors: { origin: '*' },
75
+ fs: { allow: allowList(o) },
76
+ /* 同源代理到平台后端:cookie 才带得上。发布时用的就是这份身份 */
77
+ proxy: {
78
+ '/v1': { target: o.backendUrl, changeOrigin: false },
79
+ '/api': { target: o.backendUrl, changeOrigin: false },
80
+ },
81
+ },
82
+ /* 预览壳和游戏各自 import svelte,物理上可能是两份(studio 的和游戏的 node_modules)。
83
+ runes 跨实例静默失灵,没有任何报错——必须收敛成一份 */
84
+ resolve: { dedupe: ['svelte'] },
85
+ /* defineGameConfig 里也有这条;这里再写一遍是防创作者的 vite.config.ts 没用预设。
86
+ gamekit777 在 node_modules 里是真目录时会被 esbuild 预构建,*.svelte.ts 过不了 */
87
+ optimizeDeps: { exclude: ['gamekit777'] },
88
+ });
89
+ await server.listen();
90
+ return server;
91
+ }
@@ -0,0 +1,173 @@
1
+ /* 一个游戏目录在 studio 里的句柄。
2
+ *
3
+ * 持有的全是纯数据(快照、指纹)和一池 worker。想调游戏代码就发任务给池。
4
+ * 指纹变了池就重建——调用方不用关心缓存,每次 `pool()` 拿到的一定是当前代码。
5
+ *
6
+ * 游戏只有一份清单:<gameDir>/game.ts 默认导出 defineGame({...})。逻辑与发布信息都在里面。 */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import type { ConfigSchema, Paytable, RngSeed, RoundResult } from 'gamekit777/sdk';
10
+ import { fingerprintOf, type Fingerprint } from './fingerprint.ts';
11
+ import { WorkerPool } from './worker/pool.ts';
12
+
13
+ export type Config = Record<string, unknown>;
14
+
15
+ /** 清单里过得了 structured clone 的那部分。主进程只认识这个,不认识清单本身 */
16
+ export interface SpecSnapshot {
17
+ slug: string; title: string; description?: string;
18
+ autoplay: boolean; rtp: number; sims: number; seedPrefix: string;
19
+ book: 'none' | 'seed' | 'custom';
20
+ modes: { name: string; config: Config }[];
21
+ /** 出现在 mode.config 里的键,即影响分布的字段。单表游戏为空 */
22
+ modeKeys: string[];
23
+ }
24
+
25
+ export interface Snapshot {
26
+ schema: ConfigSchema<Config>;
27
+ spec: SpecSnapshot;
28
+ }
29
+
30
+ export interface Normalized {
31
+ config: Config;
32
+ issues: { field: string; code: string }[];
33
+ /** null = 没有 mode 匹配这份配置——modes 声明不完整 */
34
+ mode: string | null;
35
+ }
36
+
37
+ function resolveSpecPath(gameDir: string): string {
38
+ const spec = join(gameDir, 'game.ts');
39
+ if (existsSync(spec)) return spec;
40
+ if (existsSync(join(gameDir, 'publish.ts')) || existsSync(join(gameDir, 'src', 'rules', 'index.ts'))) {
41
+ throw new Error(`${gameDir} 还是旧结构:publish.ts / src/rules/index.ts 已并入 game.ts。把 defineGame({...}) 放到 game.ts 并默认导出(参考 games/coin-flip/game.ts)`);
42
+ }
43
+ throw new Error(`${gameDir} 下找不到 game.ts(游戏清单:默认导出 defineGame({...}))`);
44
+ }
45
+
46
+ export class Game {
47
+ readonly dir: string;
48
+ readonly specPath: string;
49
+ #fp: Fingerprint;
50
+ #pool: WorkerPool | null = null;
51
+ #snapshot: Snapshot | null = null;
52
+
53
+ constructor(dir: string) {
54
+ this.dir = resolve(dir);
55
+ this.specPath = resolveSpecPath(this.dir);
56
+ this.#fp = fingerprintOf(this.dir);
57
+ }
58
+
59
+ get fingerprint(): string { return this.#fp.hash; }
60
+
61
+ get packageName(): string {
62
+ try { return (JSON.parse(readFileSync(join(this.dir, 'package.json'), 'utf8')) as { name?: string }).name ?? ''; }
63
+ catch { return ''; }
64
+ }
65
+
66
+ /** 重算指纹;变了就把池和快照都作废。每次外部调用前都该过一遍 */
67
+ refresh(): boolean {
68
+ const fp = fingerprintOf(this.dir);
69
+ if (fp.hash === this.#fp.hash) return false;
70
+ this.#fp = fp;
71
+ this.#snapshot = null;
72
+ const old = this.#pool;
73
+ this.#pool = null;
74
+ void old?.dispose();
75
+ return true;
76
+ }
77
+
78
+ async pool(): Promise<WorkerPool> {
79
+ this.refresh();
80
+ if (!this.#pool) {
81
+ this.#pool = new WorkerPool({ specPath: this.specPath });
82
+ await this.#pool.ready();
83
+ }
84
+ return this.#pool;
85
+ }
86
+
87
+ async snapshot(): Promise<Snapshot> {
88
+ this.refresh();
89
+ if (!this.#snapshot) this.#snapshot = await (await this.pool()).call<Snapshot>('snapshot', {});
90
+ return this.#snapshot;
91
+ }
92
+
93
+ async normalize(config: Partial<Config>, prev?: Config): Promise<Normalized> {
94
+ return (await this.pool()).call('normalize', { config, prev });
95
+ }
96
+
97
+ async paytable(config: Config): Promise<Paytable> {
98
+ return (await this.pool()).call('paytable', { config });
99
+ }
100
+
101
+ /** 每份配置该抽哪张表;null = 没有匹配的 mode */
102
+ async modeOf(configs: Config[]): Promise<(string | null)[]> {
103
+ return (await this.pool()).call('modeOf', { configs });
104
+ }
105
+
106
+ async round(config: Config, bet: number, seed: RngSeed, purity = false): Promise<RoundResult<unknown>> {
107
+ return (await this.pool()).call('round', { config, bet, seed, purity });
108
+ }
109
+
110
+ /** 表模型往返:重建第 simId 局 → book → restore。不需要先建表 */
111
+ async tableRoundtrip(modeIndex: number, simId: number, config: Config, bet: number): Promise<{
112
+ payoutCenti: number; frames: Uint8Array | null; seed: RngSeed; restored: RoundResult<unknown>;
113
+ }> {
114
+ return (await this.pool()).call('tableRoundtrip', { modeIndex, simId, config, bet });
115
+ }
116
+
117
+ /** 用现成的采样结果建表(在一个 worker 里跑 buildTable) */
118
+ async buildFromBatch(modeIndex: number, batch: { payoutCenti: Int32Array; books: Uint8Array[]; draws: number },
119
+ o: { declaredRtpPpm?: number; segments?: import('gamekit777/weights').Segment[] } = {}): Promise<import('gamekit777/publish').BuiltTable> {
120
+ let total = 0;
121
+ for (const b of batch.books) total += b.length;
122
+ const data = new Uint8Array(total);
123
+ const offsets = new Int32Array(batch.books.length + 1);
124
+ let at = 0;
125
+ for (let i = 0; i < batch.books.length; i++) { data.set(batch.books[i]!, at); at += batch.books[i]!.length; offsets[i + 1] = at; }
126
+ return (await this.pool()).call('buildFromBatch', {
127
+ modeIndex, payoutCenti: batch.payoutCenti, data, offsets, draws: batch.draws, ...o,
128
+ });
129
+ }
130
+
131
+ async restore(input: { config: Config; bet: number; payoutCenti: number; frames: Uint8Array | null; mode: string; seed: RngSeed }): Promise<RoundResult<unknown>> {
132
+ return (await this.pool()).call('restore', input);
133
+ }
134
+
135
+ /** 对任意配置多核采样,按序拼回。probe 用——不要求这份配置对应某个 mode */
136
+ async sample(config: Config, name: string, seedPrefix: string, sims: number): Promise<{ payoutCenti: Int32Array; draws: number }> {
137
+ const pool = await this.pool();
138
+ const parts = await pool.spread(sims, (w, from, to) =>
139
+ pool.call<{ payoutCenti: Int32Array; draws: number }>('sample', { config, name, seedPrefix, from, to }, w));
140
+ const out = new Int32Array(sims);
141
+ let at = 0, draws = 0;
142
+ for (const p of parts) { out.set(p.payoutCenti, at); at += p.payoutCenti.length; draws += p.draws; }
143
+ return { payoutCenti: out, draws };
144
+ }
145
+
146
+ /** 按清单里的 mode 采样,含 book 字节 */
147
+ async sampleSpec(modeIndex: number, sims: number): Promise<{ payoutCenti: Int32Array; books: Uint8Array[]; draws: number }> {
148
+ const pool = await this.pool();
149
+ type Part = { payoutCenti: Int32Array; data: Uint8Array; offsets: Int32Array; draws: number };
150
+ const parts = await pool.spread(sims, (w, from, to) => pool.call<Part>('sampleSpec', { modeIndex, from, to }, w));
151
+ const payoutCenti = new Int32Array(sims);
152
+ const books: Uint8Array[] = [];
153
+ let at = 0, draws = 0;
154
+ for (const p of parts) {
155
+ payoutCenti.set(p.payoutCenti, at); at += p.payoutCenti.length; draws += p.draws;
156
+ for (let i = 0; i + 1 < p.offsets.length; i++) books.push(p.data.subarray(p.offsets[i]!, p.offsets[i + 1]!));
157
+ }
158
+ return { payoutCenti, books, draws };
159
+ }
160
+
161
+ async pairedDiff(configA: Config, configB: Config, seedPrefix: string, count: number): Promise<{ changed: number; count: number }> {
162
+ return (await this.pool()).call('pairedDiff', { configA, configB, seedPrefix, count });
163
+ }
164
+
165
+ async betLinearity(config: Config, seedPrefix: string, indices: number[]): Promise<void> {
166
+ await (await this.pool()).call('betLinearity', { config, seedPrefix, indices });
167
+ }
168
+
169
+ async dispose(): Promise<void> {
170
+ await this.#pool?.dispose();
171
+ this.#pool = null;
172
+ }
173
+ }
@@ -0,0 +1,59 @@
1
+ /* 长任务:采样几十秒、建表几十秒、上传几分钟。
2
+ *
3
+ * MCP 的一次工具调用不该卡这么久,所以 >10s 的工具统一返回 jobId,
4
+ * 由 job_status 轮询。进度消息同时推给 SSE 总线,页面和 AI 看到的是同一份。 */
5
+
6
+ export type JobStatus = 'running' | 'done' | 'failed' | 'cancelled';
7
+
8
+ export interface Job<T = unknown> {
9
+ id: string;
10
+ kind: string;
11
+ status: JobStatus;
12
+ startedAt: number;
13
+ endedAt: number | null;
14
+ progress: string[];
15
+ result: T | null;
16
+ error: string | null;
17
+ }
18
+
19
+ export class Jobs {
20
+ #jobs = new Map<string, Job>();
21
+ #onProgress: (job: Job, msg: string) => void;
22
+
23
+ constructor(onProgress: (job: Job, msg: string) => void = () => {}) {
24
+ this.#onProgress = onProgress;
25
+ }
26
+
27
+ start<T>(kind: string, run: (say: (msg: string) => void) => Promise<T>): Job<T> {
28
+ const id = `${kind}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
29
+ const job: Job<T> = { id, kind, status: 'running', startedAt: Date.now(), endedAt: null, progress: [], result: null, error: null };
30
+ this.#jobs.set(id, job as Job);
31
+
32
+ const say = (msg: string): void => {
33
+ job.progress.push(msg);
34
+ if (job.progress.length > 200) job.progress.shift();
35
+ this.#onProgress(job as Job, msg);
36
+ };
37
+ void run(say).then(
38
+ (r) => { job.status = 'done'; job.result = r; job.endedAt = Date.now(); this.#onProgress(job as Job, '完成'); },
39
+ (e: unknown) => { job.status = 'failed'; job.error = e instanceof Error ? e.message : String(e); job.endedAt = Date.now(); this.#onProgress(job as Job, `失败:${job.error}`); },
40
+ );
41
+ return job;
42
+ }
43
+
44
+ get(id: string): Job | undefined { return this.#jobs.get(id); }
45
+
46
+ list(): Job[] { return [...this.#jobs.values()].sort((a, b) => b.startedAt - a.startedAt); }
47
+
48
+ /** 等一个 job 结束。工具想同步拿结果时用 */
49
+ async wait<T>(id: string, timeoutMs = 600_000): Promise<Job<T>> {
50
+ const t0 = Date.now();
51
+ for (;;) {
52
+ const j = this.#jobs.get(id) as Job<T> | undefined;
53
+ if (!j) throw new Error(`没有 job ${id}`);
54
+ if (j.status !== 'running') return j;
55
+ if (Date.now() - t0 > timeoutMs) throw new Error(`job ${id} 超时`);
56
+ await new Promise((r) => setTimeout(r, 100));
57
+ }
58
+ }
59
+ }