gamekit777 0.1.7 → 0.1.9

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 CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  ```sh
17
17
  bunx gamekit777 new [名字] # 一个能跑的抛硬币 + bun install:不给名字就地用当前空目录,给名字生成到子目录
18
+ # 比 npm 最新版旧会自动换最新版重跑(bunx 会缓存旧版);生成的 package.json 钉死同一版本
18
19
  cd my-game
19
20
  bun run dev # 起 studio:http://127.0.0.1:4301/ ,预览 / 调参 / 建表 / Case / 发布
20
21
  ```
@@ -167,13 +167,14 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
167
167
  → probe_table 能不能打表、modes 该写哪些键、book 怎么选
168
168
  → preview_play { rounds, seed?, simId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore)
169
169
  → preview_set { layout: 'mobile' } 切到手机机台看一眼:安全区对不对、画面有没有被 HUD 盖住;preview_state 报回 stage.safe
170
- → case_add 把值得盯的局面存进 studio/cases.jsonl
170
+ → case_add 把值得盯的局面存进 studio/cases.jsonl(seed / simId 取自 preview_play 的结果表)
171
171
  → build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
172
172
  → publish_dry_run → publish 一键:vite build → 传包 → 传表 → 发布,不用人确认,不可逆。身份是作者在 studio 页面连过的站点身份;报「没有身份」就让作者打开页面连一次
173
173
  ```
174
174
 
175
175
  - `game_info` 是第一跳:清单、schema、指纹、已建的表、预览连没连。
176
176
  - 长任务返回 `jobId` 就用 `job_status` 轮询。
177
+ - **case 要早存、常跑。** 规则刚能跑就给每个 mode 存赢 / 输 / 边界 / 最大赔付各一条;改规则前先 `case_run` 一遍留底,改完再跑一遍看漂移。遇到演出异常、restore 对不上、某局赔付可疑,先用 `case_add` 把那一局固定住再改代码,修完用同一条 case 验证。
177
178
  - `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**——历史局靠它们回放,要改就换 slug。
178
179
  - `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
179
180
  - 手动:`bun run dev` 起 studio(地址看终端输出,默认 `http://127.0.0.1:4301/`,被占会换;左边是预览机台,上方可切桌面 / 手机并选设备,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
@@ -355,7 +355,9 @@
355
355
  {/if}
356
356
  </div>
357
357
 
358
- <!-- 机台下面:开发者要看的东西。玩家站点这里放的是最近对局和这局用哪张表 -->
358
+ <!-- 机台下面:开发者要看的东西。玩家站点这里放的是最近对局和这局用哪张表。
359
+ 嵌进 studio 时不渲染:预览只留机台,历史 / case / 表都在 studio 页面右侧和 MCP 里 -->
360
+ {#if !embedded}
359
361
  <section class="panel dev">
360
362
  <div class="tabs" role="tablist">
361
363
  <button role="tab" aria-selected={tab === 'history'} onclick={() => tab = 'history'}>历史 {p.history.length}</button>
@@ -434,6 +436,7 @@
434
436
  <p class="hint">{p.spec.slug} · 目标 RTP {(p.spec.rtp * 100).toFixed(2)}%</p>
435
437
  {/if}
436
438
  </section>
439
+ {/if}
437
440
  </div>
438
441
 
439
442
  {#snippet cta()}
@@ -502,7 +505,6 @@
502
505
  .shell.mobile .dev { margin: 16px 12px 0; }
503
506
  .sheet .body .tg { align-items: stretch; }
504
507
  .sheet .body .tg > button { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); background: var(--field); border-radius: 12px; min-height: 42px; padding: 0 12px; font-weight: 700; color: var(--dim); }
505
- .sheet .body .tg > button[aria-pressed="true"] { color: var(--ink); border-color: var(--ink); }
506
508
  .sheet .body .tg svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
507
509
  /* 和玩家站点的 game.html 一样:游戏根元素撑满整块逻辑画面,由游戏自己在里面排版。
508
510
  不撑的话根元素按内容高度长,超出 771 的那一截会被舞台裁掉,而且很不显眼 */
@@ -528,6 +530,5 @@
528
530
  .skins { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 8px; }
529
531
  .skins button { border: 0; background: none; padding: 0; border-radius: 12px; overflow: hidden; line-height: 0; }
530
532
  .skins img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; opacity: .55; transition: opacity .15s; }
531
- .skins button[aria-pressed="true"] img { opacity: 1; box-shadow: 0 0 0 2px var(--accent-ink); }
532
533
  .skins span { display: block; font-size: 12px; padding: 8px 4px; line-height: 1.2; background: var(--field); border-radius: 12px; }
533
534
  </style>
@@ -197,7 +197,6 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
197
197
  border-radius: 10px; padding: 6px 10px; font-size: 13px; font-weight: 500; color: var(--dim);
198
198
  }
199
199
  .bar button:hover:not(:disabled) { background: var(--field); }
200
- .bar button[aria-pressed="true"] { color: var(--ink); font-weight: 700; }
201
200
  .bar button:disabled { opacity: .45; }
202
201
  .bar svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
203
202
  .bar .fairbtn { text-decoration: underline; text-underline-offset: 3px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamekit777",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gamekit777": "./studio/cli.ts"
@@ -52,7 +52,7 @@
52
52
  {#if summary}<span class="hint"><span class="ok">{summary.pass} 过</span> · <span class="bad">{summary.fail} 败</span> · {summary.skipped} 跳过 · {summary.error} 错</span>{/if}
53
53
  </div>
54
54
  {#if err}<p class="bad">{err}</p>{/if}
55
- {#if !cases.length}<p class="hint">还没有 case。在预览的历史列表里点「存」,或让 AI 用 case_add。</p>{/if}
55
+ {#if !cases.length}<p class="hint">还没有 case。让 AI 在预览里演到想要的局面后用 case_add 存下来。</p>{/if}
56
56
  <table>
57
57
  <thead><tr><th>id</th><th>标题</th><th>定位</th><th>期望</th><th>结果</th><th></th></tr></thead>
58
58
  <tbody>
package/studio/bin.ts CHANGED
@@ -11,6 +11,7 @@ import { Engine } from './src/engine.ts';
11
11
  import { startGameServer } from './src/game-vite.ts';
12
12
  import { serveStdio } from './src/mcp.ts';
13
13
  import { PAGE_PATH, PREVIEW_PATH } from './src/studio-plugin.ts';
14
+ import { installedVersion, ownVersion } from './src/version.ts';
14
15
 
15
16
  const argv = process.argv.slice(2);
16
17
  const forceMcp = argv.includes('--mcp');
@@ -32,6 +33,13 @@ try {
32
33
  process.exit(1);
33
34
  }
34
35
 
36
+ /* 正常情况 bunx 在项目里优先用 node_modules 里的 gamekit777,两边必然一致;
37
+ 不一致只会是绝对路径或全局装的旧版在跑——提示一句,免得模板与运行时错位的错静默出现 */
38
+ const own = ownVersion(), installed = installedVersion(cfg.gameDir);
39
+ if (own && installed && own !== installed) {
40
+ say(`⚠️ 正在运行的 studio 是 gamekit777@${own},项目里装的是 ${installed};在项目目录里用 bunx gamekit777 studio 启动会用项目里的那份`);
41
+ }
42
+
35
43
  const backendUrl = cfg.backendUrl;
36
44
  const placeholder = { studio: '', preview: '', backend: cfg.backendUrl, site: cfg.siteUrl };
37
45
  const engine = new Engine(cfg.gameDir, placeholder);
package/studio/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /* `gamekit777` 这个命令的入口:按子命令分派。
3
3
  *
4
4
  * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL] 起工作台(MCP 走 stdio)
5
- * gamekit777 new [名字] 新建游戏并装依赖:给名字 → 子目录 ./名字;不给 → 就地用当前(空)目录
5
+ * gamekit777 new [名字] [--no-update] 新建游戏并装依赖:给名字 → 子目录 ./名字;不给 → 就地用当前(空)目录;比 npm 旧就换最新版重跑
6
6
  *
7
7
  * 只是薄薄一层分派,逻辑各在 bin.ts 和 src/scaffold.ts。 */
8
8
  const [sub, ...rest] = process.argv.slice(2);
@@ -10,7 +10,8 @@ const [sub, ...rest] = process.argv.slice(2);
10
10
  const usage = (): never => {
11
11
  process.stderr.write(`用法:
12
12
  gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL]
13
- gamekit777 new [名字] 新建游戏并装依赖:给名字 → 生成 ./名字;不给 → 就地用当前目录(必须是空目录,id 取目录名)
13
+ gamekit777 new [名字] [--no-update] 新建游戏并装依赖:给名字 → 生成 ./名字;不给 → 就地用当前目录(必须是空目录,id 取目录名)
14
+ 比 npm 上的最新版旧时自动换最新版重跑;--no-update 用当前这份
14
15
 
15
16
  接 Claude Code:在游戏目录里
16
17
  claude mcp add gamekit -- bunx gamekit777 studio
@@ -26,12 +27,24 @@ switch (sub) {
26
27
  }
27
28
  case 'new': {
28
29
  /* 只收一个可选的名字。不给就用当前目录:AI 多半已经站在要建的目录里,再套一层 my-game 只会让它 cd 错地方。
29
- 标题之类都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉 */
30
- if (rest.length > 1 || rest[0]?.startsWith('-')) usage();
31
- const { scaffold } = await import('./src/scaffold.ts');
30
+ 标题之类都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉。
31
+ 跑之前先看 npm 有没有更新的版本:bunx 把裸 `gamekit777` 缓存成一个临时目录、隔天照用,
32
+ 旧脚手架生成的模板对不上新装的运行时。比 npm 旧就用精确版本号重跑一遍,模板与运行时同时是最新的 */
33
+ const noUpdate = rest.includes('--no-update');
34
+ const args = rest.filter((a) => a !== '--no-update');
35
+ if (args.length > 1 || args[0]?.startsWith('-')) usage();
36
+ const { scaffold, reexecArgs, inWorkspace } = await import('./src/scaffold.ts');
37
+ const { newerOnNpm, ownVersion } = await import('./src/version.ts');
38
+ const latest = noUpdate || inWorkspace(process.cwd()) ? null : await newerOnNpm();
39
+ if (latest) {
40
+ process.stderr.write(`· 这份 gamekit777 是 ${ownVersion()},npm 上最新是 ${latest},改用最新版生成(--no-update 可跳过)\n`);
41
+ const { cmd, args: a, env } = reexecArgs(latest, args[0]);
42
+ const { spawnSync } = await import('node:child_process');
43
+ process.exit(spawnSync(cmd, a, { stdio: 'inherit', env }).status ?? 1);
44
+ }
32
45
  try {
33
- const r = await scaffold({ name: rest[0] }, process.cwd());
34
- process.stdout.write(`✓ ${r.inPlace ? `就地生成在 ${r.dest}` : r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n ${r.inPlace ? '' : `cd ${r.dest} && `}bunx gamekit777 studio\n`);
46
+ const r = await scaffold({ name: args[0], noUpdate: true }, process.cwd());
47
+ process.stdout.write(`✓ ${r.inPlace ? `就地生成在 ${r.dest}` : r.dest}(gamekit777 ${r.gamekit})\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n ${r.inPlace ? '' : `cd ${r.dest} && `}bunx gamekit777 studio\n`);
35
48
  } catch (e) {
36
49
  process.stderr.write(`✗ ${e instanceof Error ? e.message : String(e)}\n`);
37
50
  process.exit(1);
package/studio/src/mcp.ts CHANGED
@@ -135,7 +135,8 @@ export function createMcpServer(engine: Engine): McpServer {
135
135
  const tail = ['', '### 下一步',
136
136
  guide ? `- 先读 ${join(i.gameDir, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定`
137
137
  : '- 这个目录没有 CLAUDE.md(写给 AI 的开发指南)。读 game.ts 和 src/rules/ 之前先记住:round() 纯函数只用注入 rng,payout 整数分,restore 的 payout 必须等于 (bet/100)×payoutCenti',
138
- '- 改了规则先 run_task verify,再 probe_table 看 modes / book 该怎么写,然后 preview_play'];
138
+ '- 改了规则先 run_task verify,再 probe_table 看 modes / book 该怎么写,然后 preview_play',
139
+ ...(i.cases === 0 ? ['- 还没有 case:先 preview_play 几局,把每个 mode 的赢 / 输 / 边界局面用 case_add 存下来,之后改规则靠 case_run 抓漂移、复现问题靠它定位'] : [])];
139
140
  for (const [k, f] of Object.entries(sn.schema)) {
140
141
  const range = f.kind === 'int' || f.kind === 'number' ? `${f.min}..${f.max}` : f.kind === 'enum' ? f.options.map((o) => o.value).join('/') : f.kind === 'pick' ? `从 ${f.of.length} 选 ${f.count}` : '';
141
142
  lines.push(`| ${k} | ${f.kind} ${range} | ${JSON.stringify(f.default)} | ${f.affectsOutcome ? '是' : '否'} | ${f.ui} | ${f.devOnly ? '是' : ''} |`);
@@ -145,12 +146,15 @@ export function createMcpServer(engine: Engine): McpServer {
145
146
  });
146
147
 
147
148
  s.registerTool('scaffold', {
148
- description: '新建一个游戏(能跑的抛硬币)并 bun install。不给 name 就在当前目录就地生成(当前目录必须是空的,id 取目录名);给 name 就生成到子目录 ./name。当前目录已经是项目时会拒绝并让你给名字。标题之后改 game.meta.json。生成后要在那个目录里重启 studio。',
149
- inputSchema: { name: z.string().optional().describe('子目录名,同时是 id(小写短横线)。不给 = 用当前目录') },
149
+ description: '新建一个游戏(能跑的抛硬币)并 bun install。不给 name 就在当前目录就地生成(当前目录必须是空的,id 取目录名);给 name 就生成到子目录 ./name。当前目录已经是项目时会拒绝并让你给名字。标题之后改 game.meta.json。模板钉死生成它的 gamekit777 版本;npm 上有更新版本时会用那一版代跑。生成后要在那个目录里重启 studio。',
150
+ inputSchema: {
151
+ name: z.string().optional().describe('子目录名,同时是 id(小写短横线)。不给 = 用当前目录'),
152
+ noUpdate: z.boolean().optional().describe('不查 npm、不用更新的版本代跑'),
153
+ },
150
154
  }, async (a) => {
151
155
  try {
152
- const r = await scaffold({ name: a.name }, process.cwd());
153
- return out(`## ✅ ${r.inPlace ? `已就地生成在 ${r.dest}` : `已生成 ${r.dest}`}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
156
+ const r = await scaffold({ name: a.name, noUpdate: a.noUpdate }, process.cwd());
157
+ return out(`## ✅ ${r.inPlace ? `已就地生成在 ${r.dest}` : `已生成 ${r.dest}`}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'},gamekit777 ${r.gamekit}${r.upgradedTo ? '(由 npm 最新版代跑)' : ''}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
154
158
  } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
155
159
  });
156
160
 
@@ -302,7 +306,7 @@ export function createMcpServer(engine: Engine): McpServer {
302
306
  clientSeed: z.string().optional(),
303
307
  skin: z.string().optional(),
304
308
  turbo: z.boolean().optional(),
305
- layout: z.enum(['desktop', 'mobile']).optional().describe('切机台布局。游戏不重挂,收到 host.stage 后自己重排'),
309
+ layout: z.enum(['desktop', 'mobile']).optional().describe('切机台布局。游戏不重挂,收到 host.stage 后自己重排。两种布局都要验过;具体机型尺寸与刘海只能在 studio 页面机台上方选,MCP 不切'),
306
310
  },
307
311
  }, async (a) => {
308
312
  try {
@@ -312,7 +316,7 @@ export function createMcpServer(engine: Engine): McpServer {
312
316
  });
313
317
 
314
318
  s.registerTool('preview_play', {
315
- description: '在预览里演几局并拿回每局结果(本地表模型:就地建表、按权重抽行、restore 还原)。给 seed 就复现那一局,给 simId 就直接演表里第 N 行。animate=false 跳过动画。',
319
+ description: '在预览里演几局并拿回每局结果(本地表模型:就地建表、按权重抽行、restore 还原)。给 seed 就复现那一局,给 simId 就直接演表里第 N 行。animate=false 跳过动画。结果表里的 seed / simId 可直接喂给 case_add 固定住那一局。',
316
320
  inputSchema: {
317
321
  rounds: z.number().int().min(1).max(50).default(1),
318
322
  bet: z.number().int().optional(),
@@ -336,7 +340,7 @@ export function createMcpServer(engine: Engine): McpServer {
336
340
  });
337
341
 
338
342
  s.registerTool('case_add', {
339
- description: '记一条 case:给 config + (seed 或 table:{mode,simId}),studio 跑一遍算出期望值存进 cases.jsonl。作者点播、AI 单测、结果漂移护栏三用。',
343
+ description: '记一条 case:给 config + (seed 或 table:{mode,simId}),studio 跑一遍算出期望值存进 cases.jsonl。作者点播、AI 单测、结果漂移护栏三用。开发早期就该存几条(每个 mode 的赢 / 输 / 边界 / 最大赔付各一条),遇到演出异常或 restore 对不上先把那局存成 case 再改代码——seed / simId 从 preview_play 的结果表里取。',
340
344
  inputSchema: {
341
345
  title: z.string(), tags: z.array(z.string()).optional(), note: z.string().optional(),
342
346
  config: z.record(z.string(), z.unknown()), bet: z.number().int().default(100),
@@ -1,11 +1,17 @@
1
1
  /* 建新游戏:脚手架 + 写 package.json + bun install。
2
2
  *
3
3
  * 这是 MCP 的 scaffold 工具背后的东西。装依赖这一步只有这里会自动做——
4
- * 别处发现缺 node_modules 一律只提示,不代作者动手。 */
4
+ * 别处发现缺 node_modules 一律只提示,不代作者动手。
5
+ *
6
+ * 一条不变式:**模板里钉的 gamekit777 版本 = 生成它的那份 gamekit777。**
7
+ * 写 `^x.y.z` 违反它——bunx 缓存里的旧脚手架写出 `^0.1.2`,bun install 却装到 0.1.7,
8
+ * 模板和运行时错位,第一条 check 就红。所以版本钉死;而「跑着的是旧版」这件事
9
+ * 由 newerOnNpm 兜:比 npm 旧就换最新版重跑一遍,模板与运行时同时是最新的。 */
5
10
  import { spawn } from 'node:child_process';
6
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
12
  import { basename, join } from 'node:path';
8
13
  import { scaffoldGame, ID_PATTERN } from 'gamekit777/create-game';
14
+ import { REEXEC_ENV, newerOnNpm, ownVersion } from './version.ts';
9
15
 
10
16
  export const DEFAULT_ID = 'my-game';
11
17
  export const DEFAULT_TITLE = '我的游戏';
@@ -42,10 +48,12 @@ export interface ScaffoldRequest {
42
48
  /** 子目录名,同时是 id。不给就在 cwd 里就地生成,id 取自目录名 */
43
49
  name?: string;
44
50
  title?: string;
45
- /** package.json 里 gamekit777 的版本说明符。仓库内是 workspace:*,创作者机器上是版本号 */
51
+ /** package.json 里 gamekit777 的版本说明符。仓库内是 workspace:*,创作者机器上是精确版本号 */
46
52
  gamekitSpec?: string;
47
53
  /** 跳过 bun install */
48
54
  skipInstall?: boolean;
55
+ /** 不查 npm、不自升级:离线或明确要用当前这份 */
56
+ noUpdate?: boolean;
49
57
  }
50
58
 
51
59
  export interface ScaffoldResult {
@@ -56,21 +64,26 @@ export interface ScaffoldResult {
56
64
  files: number;
57
65
  installed: boolean;
58
66
  installOutput: string;
67
+ /** 模板钉的 gamekit777 版本;仓库内是 workspace:* */
68
+ gamekit: string;
69
+ /** 由更新的版本代跑时是那个版本号;自己跑的是 null */
70
+ upgradedTo: string | null;
59
71
  next: string[];
60
72
  }
61
73
 
62
- function bunInstall(cwd: string): Promise<{ ok: boolean; output: string }> {
74
+ function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise<{ ok: boolean; output: string }> {
63
75
  return new Promise((resolve) => {
64
- const child = spawn('bun', ['install'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
76
+ const child = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] });
65
77
  let out = '';
66
78
  child.stdout.on('data', (b: Buffer) => { out += b.toString(); });
67
79
  child.stderr.on('data', (b: Buffer) => { out += b.toString(); });
80
+ child.on('error', (e) => resolve({ ok: false, output: `${out}\n${e.message}` }));
68
81
  child.on('close', (code) => resolve({ ok: code === 0, output: out.slice(-4000) }));
69
82
  });
70
83
  }
71
84
 
72
- /** 仓库内跑就用 workspace:*,否则用 studio 自己这份 gamekit777 的版本号 */
73
- function defaultSpec(dest: string): string {
85
+ /** 往上找 6 层,有 workspaces 的 package.json 就是在仓库内 */
86
+ export function inWorkspace(dest: string): boolean {
74
87
  let dir = dest;
75
88
  for (let i = 0; i < 6; i++) {
76
89
  dir = join(dir, '..');
@@ -78,42 +91,72 @@ function defaultSpec(dest: string): string {
78
91
  if (existsSync(pkg)) {
79
92
  try {
80
93
  const m = JSON.parse(readFileSync(pkg, 'utf8')) as { workspaces?: string[] };
81
- if (m.workspaces) return 'workspace:*';
94
+ if (m.workspaces) return true;
82
95
  } catch { /* 继续往上 */ }
83
96
  }
84
97
  }
85
- /* 仓库内这个文件在 packages/studio/src/,薄壳在 packages/gamekit777/;
86
- 压平进 npm 包后在 gamekit777/studio/src/,包根就是 gamekit777 本身。两种布局都试 */
87
- for (const rel of ['../../package.json', '../../../gamekit777/package.json']) {
88
- try {
89
- const m = JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8')) as { name?: string; version?: string };
90
- if (m.name === 'gamekit777' && m.version) return `^${m.version}`;
91
- } catch { /* 下一个 */ }
92
- }
93
- return '*';
98
+ return false;
99
+ }
100
+
101
+ /** 仓库内跑就用 workspace:*,否则钉死成 studio 自己这份 gamekit777 的精确版本 */
102
+ export function defaultSpec(dest: string): string {
103
+ if (inWorkspace(dest)) return 'workspace:*';
104
+ return ownVersion() ?? 'latest';
105
+ }
106
+
107
+ /** 用 npm 上更新的那份代跑 `new`。精确版本的 bunx 目录不会被旧缓存顶掉 */
108
+ export function reexecArgs(latest: string, name?: string): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } {
109
+ return {
110
+ cmd: 'bun',
111
+ args: ['x', `gamekit777@${latest}`, 'new', ...(name ? [name] : [])],
112
+ env: { ...process.env, [REEXEC_ENV]: latest },
113
+ };
114
+ }
115
+
116
+ const countFiles = (dir: string): number =>
117
+ readdirSync(dir).reduce((n, f) => {
118
+ if (f === 'node_modules' || f === '.git') return n;
119
+ const p = join(dir, f);
120
+ return n + (statSync(p).isDirectory() ? countFiles(p) : 1);
121
+ }, 0);
122
+
123
+ export function nextSteps(dest: string, id: string, inPlace: boolean, upgradedTo: string | null): string[] {
124
+ return [
125
+ inPlace
126
+ ? `就地生成在当前目录 ${dest}(id = ${id},取自目录名),不用 cd。是一个能跑的抛硬币;名字和标题在 game.meta.json 里改。`
127
+ : `游戏在 ${dest}(id = ${id}),是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
128
+ ...(upgradedTo ? [`模板由 npm 上最新的 gamekit777@${upgradedTo} 生成并钉死在 package.json 里;之后在这个目录里跑的 gamekit777 命令都用项目里装的这份,不再受 bunx 缓存影响。正在运行的 studio 还是旧版,要在这个目录里重启它。`] : []),
129
+ `先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
130
+ '改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
131
+ '已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
132
+ '用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
133
+ ];
94
134
  }
95
135
 
96
136
  export async function scaffold(req: ScaffoldRequest, cwd: string): Promise<ScaffoldResult> {
97
137
  const { id, dest, inPlace } = resolveTarget(cwd, req.name);
98
- const r = scaffoldGame({ id, title: req.title ?? DEFAULT_TITLE, dest, gamekitSpec: req.gamekitSpec ?? defaultSpec(dest) });
138
+ const spec = req.gamekitSpec ?? defaultSpec(dest);
139
+
140
+ /* 只有「创作者机器 + 默认版本」这条路才查 npm:仓库内是 workspace,自验指定了 tarball,都不用 */
141
+ const latest = !req.gamekitSpec && spec !== 'workspace:*' && !req.noUpdate && !req.skipInstall ? await newerOnNpm() : null;
142
+ if (latest) {
143
+ const { cmd, args, env } = reexecArgs(latest, req.name);
144
+ const r = await run(cmd, args, cwd, env);
145
+ if (!r.ok) throw new Error(`用 gamekit777@${latest} 生成失败(当前这份是 ${spec}):\n${r.output}`);
146
+ return {
147
+ dest, id, inPlace, files: countFiles(dest), installed: true, installOutput: r.output,
148
+ gamekit: latest, upgradedTo: latest, next: nextSteps(dest, id, inPlace, latest),
149
+ };
150
+ }
151
+
152
+ const r = scaffoldGame({ id, title: req.title ?? DEFAULT_TITLE, dest, gamekitSpec: spec });
99
153
 
100
154
  let installed = false, installOutput = '';
101
155
  if (!req.skipInstall) {
102
- const i = await bunInstall(dest);
156
+ const i = await run('bun', ['install'], dest);
103
157
  installed = i.ok; installOutput = i.output;
104
158
  if (!i.ok) throw new Error(`bun install 失败:\n${i.output}`);
105
159
  }
106
160
 
107
- return {
108
- dest, id, inPlace, files: r.files.length, installed, installOutput,
109
- next: [
110
- inPlace
111
- ? `就地生成在当前目录 ${dest}(id = ${id},取自目录名),不用 cd。是一个能跑的抛硬币;名字和标题在 game.meta.json 里改。`
112
- : `游戏在 ${dest}(id = ${id}),是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
113
- `先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
114
- '改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
115
- '已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
116
- '用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
117
- ],
118
- };
161
+ return { dest, id, inPlace, files: r.files.length, installed, installOutput, gamekit: spec, upgradedTo: null, next: nextSteps(dest, id, inPlace, null) };
119
162
  }
@@ -0,0 +1,62 @@
1
+ /* gamekit777 的三个版本:跑着的这份、npm 上最新的、创作者项目里装的。
2
+ *
3
+ * 只有 `new` 暴露在 bunx 的缓存下:bun 把裸 `gamekit777` 和 `@latest` 共用一个临时目录,
4
+ * 装过一次就一直用那份(实测隔天仍是旧版)。项目建好之后 `bunx gamekit777 studio` 优先用
5
+ * 项目 node_modules 里的那份,所以后面的命令天然和模板一致——要守的只有生成模板这一步。 */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const readVersion = (file: string | URL): string | null => {
10
+ try {
11
+ const m = JSON.parse(readFileSync(file, 'utf8')) as { name?: string; version?: string };
12
+ return m.name === 'gamekit777' && m.version ? m.version : null;
13
+ } catch { return null; }
14
+ };
15
+
16
+ /** 正在运行的这份 gamekit777 的版本。npm 包里 studio/src 往上两层就是包根;仓库内往上两层是 packages/,薄壳在它下面 */
17
+ export function ownVersion(): string | null {
18
+ for (const rel of ['../../package.json', '../../gamekit777/package.json']) {
19
+ const v = readVersion(new URL(rel, import.meta.url));
20
+ if (v) return v;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** 某个游戏目录里装的 gamekit777 版本;没装或是 workspace 链接回仓库时也照读 */
26
+ export function installedVersion(gameDir: string): string | null {
27
+ const file = join(gameDir, 'node_modules', 'gamekit777', 'package.json');
28
+ return existsSync(file) ? readVersion(file) : null;
29
+ }
30
+
31
+ export const NPM_REGISTRY = process.env.GAMEKIT_NPM_REGISTRY ?? 'https://registry.npmjs.org';
32
+
33
+ /** npm 上的最新版。查不到(离线、超时、registry 挂了)返回 null,调用方按「不升级」处理 */
34
+ export async function latestVersion(timeoutMs = 5000): Promise<string | null> {
35
+ try {
36
+ const res = await fetch(`${NPM_REGISTRY}/gamekit777/latest`, { signal: AbortSignal.timeout(timeoutMs) });
37
+ if (!res.ok) return null;
38
+ const m = await res.json() as { version?: string };
39
+ return typeof m.version === 'string' ? m.version : null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ /** 只比主.次.修订三段数字;预发布后缀忽略。a 比 b 新返回正数 */
44
+ export function compareVersions(a: string, b: string): number {
45
+ const pa = a.split('-')[0]!.split('.').map(Number);
46
+ const pb = b.split('-')[0]!.split('.').map(Number);
47
+ for (let i = 0; i < 3; i++) {
48
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
49
+ if (d !== 0) return d;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ /** 环境变量:由自升级出来的子进程带着,防止再套一层;也可手动设置跳过检查 */
55
+ export const REEXEC_ENV = 'GAMEKIT_NEW_REEXEC';
56
+
57
+ /** 跑着的比 npm 最新的旧就返回最新版号;查不到、在仓库内、已是子进程都返回 null */
58
+ export async function newerOnNpm(own: string | null = ownVersion()): Promise<string | null> {
59
+ if (!own || process.env[REEXEC_ENV]) return null;
60
+ const latest = await latestVersion();
61
+ return latest && compareVersions(latest, own) > 0 ? latest : null;
62
+ }