gamekit777 0.1.9 → 0.1.10
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
|
@@ -28,7 +28,7 @@ claude
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
studio 同时是 MCP server、创作页面(预览 / 调参 / 建表 / Case / 发布)和到平台后端的反代。
|
|
31
|
-
游戏目录里的 `
|
|
31
|
+
游戏目录里的 `AGENTS.md` 是写给 AI 的开发指南与创作规则(`CLAUDE.md` 只是 `@AGENTS.md` 一行引用):清单字段、`round()` 的硬约束、`modes` / `book` / `restore` 怎么选。
|
|
32
32
|
|
|
33
33
|
手动起页面:`bunx gamekit777 studio [dir] --no-mcp [--game-port 4301]`。
|
|
34
34
|
|
package/create-game/scaffold.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 比对着一堆 TODO 填空可靠得多;只维护一份也就不会再出现「模板和 coin-flip 漂开了」。
|
|
6
6
|
*
|
|
7
7
|
* 复制时剔掉仓库专属的东西(remote-e2e、平台分配的 gameId、缓存),再叠上只有新项目才需要的
|
|
8
|
-
* overlay/(给创作者 AI 的 CLAUDE.md、.gitignore)。
|
|
8
|
+
* overlay/(给创作者 AI 的 AGENTS.md、引用它的 CLAUDE.md、.gitignore)。
|
|
9
9
|
*
|
|
10
10
|
* 抽成库函数是因为有三个调用方:CLI、studio 的 scaffold 工具、以及
|
|
11
11
|
* npm 打包脚本的产物自验——最后一个尤其要紧,它生成的项目就是创作者的真实起点,
|
|
@@ -60,7 +60,7 @@ export const pascal = (id: string): string =>
|
|
|
60
60
|
id.split('-').map((w) => w[0]!.toUpperCase() + w.slice(1)).join('');
|
|
61
61
|
|
|
62
62
|
/** 存在也不算「非空」的东西:新建仓库时常见的那几样 */
|
|
63
|
-
const IGNORABLE = new Set(['README.md', 'LICENSE', 'CLAUDE.md']);
|
|
63
|
+
const IGNORABLE = new Set(['README.md', 'LICENSE', 'CLAUDE.md', 'AGENTS.md']);
|
|
64
64
|
|
|
65
65
|
/* 仓库内的 games/<id> 和仓库外的创作者项目要的不一样:
|
|
66
66
|
仓库内 tsconfig 继承根的 tsconfig.base.json、dev 脚本指到本地 8787/5180;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# __TITLE__(`__ID__`)— 写给 AI 的游戏开发指南
|
|
2
|
+
|
|
3
|
+
<!-- 规则只写在这一份里;CLAUDE.md 只是 `@AGENTS.md` 一行引用 -->
|
|
4
|
+
|
|
5
|
+
这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
|
|
6
|
+
运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
|
|
7
|
+
|
|
8
|
+
先读这份文件,再读 `game.ts`。这个目录是平台参考游戏(猜正反面的那个)的**完整拷贝**换了名字——它能跑、能建表、能发布;
|
|
9
|
+
从能跑的东西开始改,不要从零写。源码里带「改成你自己的游戏时」的注释就是给你的路标。
|
|
10
|
+
|
|
11
|
+
## 一条原则:演出必须能回放
|
|
12
|
+
|
|
13
|
+
一局的画面只能是「表里那一行」的函数。同一行,在下注当场、在历史列表里点开、从分享链接进来、换一台设备,
|
|
14
|
+
演出来的必须是同一局:同样的牌、同样的连线、同样的赔付。下面所有约束都是这一条推出来的:
|
|
15
|
+
|
|
16
|
+
- **`round()` 是纯函数**,随机只来自注入的 rng。作者机器采样、玩家浏览器还原、审计脚本复算,三处逐位一致。
|
|
17
|
+
- **`restore()` 把一行还原成 `RoundResult`**,返回的 `payout` 必须恰好等于 `(bet / 100) × payoutCenti`,对不上就 `throw`、不演出。
|
|
18
|
+
- **`present(result)` 只看 `result`**,不看上一局、不看余额、不看玩家当下的设置。回放是在一个全新的实例里跑 `preview(result)`,那里什么都没有。
|
|
19
|
+
- **装饰性随机**(粒子角度、台词抽签)用 `Math.random()`,放在 `src/view` / 组件里,绝不进 `round()`:否则加一句台词就会改变牌序,历史局就回放不出来了。
|
|
20
|
+
|
|
21
|
+
「玩家看到连成三条线,余额却没动」「分享出去的一局,别人看到另一副牌」——这类 bug 完全静默、最伤信任,所以每一条都有工具兜着:
|
|
22
|
+
`scripts/verify.ts` 把 `Math.random` / `crypto` 换成会抛的桩,平台在演出前逐分比对 payout,`case_run` 盯任何结果漂移。
|
|
23
|
+
|
|
24
|
+
## 目录
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
game.ts 清单:defineGame({...})。Node 与浏览器都 import 它,不能碰 DOM / Svelte
|
|
28
|
+
src/rules/ 纯逻辑:schema、simulate、restore、常量、类型。零 DOM、零自取随机
|
|
29
|
+
src/index.ts 浏览器入口 = { ...game, mount, assets }。平台加载的是它
|
|
30
|
+
src/state/ 单局状态(Svelte runes 类),只放「演到哪一步了」
|
|
31
|
+
src/view/ mount(接平台)与 present(把结果播出来)
|
|
32
|
+
src/components/ 画面。子组件一律 getInstance(),不 import 状态对象
|
|
33
|
+
src/assets/ 只放文件:critical/ 首屏必需,lazy/ 后台预取。runtime 按目录生成清单,平台负责加载和进度
|
|
34
|
+
scripts/verify.ts 数学回归:纯度、确定性、RTP、分布
|
|
35
|
+
test/e2e.test.ts happy-dom 里跑完整一局
|
|
36
|
+
studio/cases.jsonl case 库(进版本控制),studio 维护
|
|
37
|
+
.studio/ 采样缓存与建好的表,按规则指纹分目录,可随手删
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**改名**只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`。
|
|
41
|
+
`gameId` 是平台第一次发布时分配并写回的身份,不要手改、不要删。已发布过的 slug 不能改——那是另一个游戏。
|
|
42
|
+
|
|
43
|
+
## 清单:`game.ts`
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export default defineGame<Config, Outcome>({
|
|
47
|
+
slug: '__ID__', title: '__TITLE__', description?: string,
|
|
48
|
+
rtp: 0.98, // 目标 RTP,小数。所有 mode 都由求解器钉到这一个数
|
|
49
|
+
sims: 100_000, // 每张表采样多少局。行数为的是「牌局不重复」,不是「结果种类多」
|
|
50
|
+
autoplay?: true, // 平台要不要显示自动按钮
|
|
51
|
+
|
|
52
|
+
schema, // 有哪些参数(见下)
|
|
53
|
+
round, // 一局怎么算(见下)
|
|
54
|
+
paytable, // 赔率表:PaytableRow[] 或 (config) => PaytableRow[]。给大厅展示,不参与计算
|
|
55
|
+
|
|
56
|
+
modes?, // 要几张表。不写 = 单表 'base'
|
|
57
|
+
book?, // 演出怎么编进表里:'none' | 'seed' | { encode(sample): Uint8Array }。不写 = 'seed'
|
|
58
|
+
restore?, // 怎么把一行还原成演出。book 为 'seed' 时不写,SDK 给 restoreFromSeed
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`defineGame` 按 schema 和 modes 推导出 `normalize` / `validate` / `modeOf` / `configOf` 挂在返回值上;
|
|
63
|
+
自己要规整 config 时用 `normalizeBySchema(schema, config)`。注额区间是平台的事,清单里没有。
|
|
64
|
+
|
|
65
|
+
### `round(input)`
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
function round(input: RoundInput<Config>): RoundResult<Outcome>
|
|
69
|
+
// RoundInput = { config, bet, seed: { server, client, nonce } }
|
|
70
|
+
// RoundResult = { bet, payout, multiplier, outcome, pacing: { durationMs, postDelayMs } }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
1. **同步纯函数**。不碰 DOM、时间、全局状态;随机只来自 `createRng(input.seed)`。
|
|
74
|
+
2. **随机数抽取顺序是契约**。用 `rng.fork('tag')` 把子系统分开(发牌一条、金牌一条),将来改一处不会让另一处跟着漂。`below(n)` 是无偏整数,不要自己写 `% n`。
|
|
75
|
+
3. **金额是整数分**。`multiplier` 最多两位小数(平台按 centi 存);`payout` 是 `bet` 的精确线性函数,取整方式不能随 bet 变(bet 恒为 100 的倍数)。
|
|
76
|
+
4. 先 `normalizeBySchema(schema, input.config)` 再用 config——调用方给的可能是部分或越界的值。
|
|
77
|
+
5. 平台有最大倍数上限,超过就进不了表。
|
|
78
|
+
|
|
79
|
+
### `schema`
|
|
80
|
+
|
|
81
|
+
`ConfigSchema<Config>`,每个字段 = 一种 kind + 元数据 + `default`:
|
|
82
|
+
|
|
83
|
+
| kind | 形状 | 例子 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `int` / `number` | `{ min, max, step? }` | 翻几张牌 |
|
|
86
|
+
| `bool` | — | 金牌开关 |
|
|
87
|
+
| `enum` | `{ options: [{ value, label }] }` | 押哪一面 |
|
|
88
|
+
| `pick` | `{ of: Option[], count, exact?, ordered? }` | 13 选 4 个点数 |
|
|
89
|
+
|
|
90
|
+
元数据:`label`(`{'zh-CN': …}` 这种 I18n 记录)、`hint?`、`affectsOutcome`(影响结果的字段才进 schema;不影响的放 `ViewSettings`),
|
|
91
|
+
`lock`(`'never' | 'inRound' | 'inRoundOrAuto'`)、`ui`(`'host'` 平台的设置面板渲染 / `'stage'` 你在画布里渲染,但值仍归平台)、
|
|
92
|
+
`devOnly?`(只在开发环境允许,生产被服务端拒绝)、`control?` / `group?` / `order?`(渲染建议)。
|
|
93
|
+
|
|
94
|
+
平台不认识你的游戏,只认识 kind——设置 UI 是按 schema 自动渲染的。
|
|
95
|
+
|
|
96
|
+
## 能不能打表:`modes` 与 `book`
|
|
97
|
+
|
|
98
|
+
先问 studio:**`probe_table`**。它逐字段采样,告诉你两件不同的事:
|
|
99
|
+
|
|
100
|
+
- **影响分布**的字段(换值后赔付的概率分布变了)→ 必须写进 `modes`。一张表 = 一种分布。
|
|
101
|
+
- **只影响这一局**的字段(同一个种子下结果变了,但分布没变;猜正反面的 `side`、coco 的 `ranks`)→ 不进 modes,玩家运行时自由选。
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
modes: [{ name: 'n16g0', config: { N: 16, goldOn: false } }, ...] // 只写影响分布的字段,每个 mode 写同一组键
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
出现在 `mode.config` 里的键就是 `modeKeys`;`modeOf(config)` 按它匹配,匹配不到就抛——那是 modes 声明不完整。
|
|
108
|
+
表数 = modes 数,超过几十张就该把连续参数冻结成几档,或改成从有限档位里选。
|
|
109
|
+
|
|
110
|
+
`book` 按 probe 的建议选:
|
|
111
|
+
|
|
112
|
+
| 情形 | `book` | `restore` |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| 演出能从 payout 推导(≤3 种赔付,赢了就是你押的那面) | `'none'` | 自己写:从 `payoutCenti` 反推 outcome |
|
|
115
|
+
| 所有影响结果的字段都在 modes 里 | `'seed'`(默认) | 不写。SDK 存采样种子、还原时重跑 `round()` |
|
|
116
|
+
| 有字段只影响这一局(`side`、`ranks` 这类) | `{ encode(s) => Uint8Array }` | 自己写:解码时用玩家**当下**的自由字段。参考 coco 的 `rules/book.ts`:把 ranks 当重标记,存「命中第 k 格」而不是牌 id |
|
|
117
|
+
|
|
118
|
+
`'seed'` 用在有自由字段的游戏上,后果是表里写 0x、客户端按玩家当下选的值算出 4.64x。`restoreFromSeed` 会在还原时抛,但那已经是运行时了——先跑 probe。
|
|
119
|
+
|
|
120
|
+
### `restore(input)`
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// RestoreInput = { config, bet, payoutCenti, frames: Uint8Array | null, mode, seed }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`payout` 必须恰好等于 `(input.bet / 100) * input.payoutCenti`,对不上就 `throw`,不要「修正」。能多验一道就多验:解码出的局面算出的倍数应当等于 `payoutCenti`。
|
|
127
|
+
`input.config` 是玩家当下的配置(回放时是下注那一刻存下的),先 `normalizeBySchema` 再用。
|
|
128
|
+
|
|
129
|
+
## 演出侧:`src/index.ts` 与 `mount`
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
export default { ...game, mount: mountGame, skins?: Skin[], assets: assets.bundle() }; // assets 来自 gamekit777/runtime
|
|
133
|
+
mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`mount` 被调用时 critical 资源已由平台按 `assets` 清单预加载完,进度条也是平台画的;`mount` 里不写加载逻辑。
|
|
137
|
+
资源放 `src/assets/critical|lazy`,组件用 `assets.url(key)` 取地址;字体在 `game.meta.json` 的 `fonts` 里声明。
|
|
138
|
+
|
|
139
|
+
`Host` 是平台给你的,**全是只读**:`config` / `view` / `wallet` / `jackpot` 四个 `Readable`(`get()` + `subscribe()`),
|
|
140
|
+
`audio.context()`(不要自己 new AudioContext)、`locale`、`emit(event)`、`log()`。
|
|
141
|
+
想改参数只能 `host.requestConfig(patch)` 申请——平台校验、可能否决(局中、自动中)、批了才会从 `host.config` 推回来。
|
|
142
|
+
画布内的控件因此也是受控组件:渲染平台的值,改动走申请。`ViewSettings.autoActive` 为真时禁用画布内的设置控件。
|
|
143
|
+
|
|
144
|
+
`GameInstance`:`status`(`Readable<GameStatus>`,平台据 `busy` / `canBet` / `canSkip` / `canEditConfig` 控制自己的按钮)、
|
|
145
|
+
`present(result, { rate? })`(演一局,resolve 于演完;`rate: Infinity` 直接看结果)、`preview(result)`(只演不结账,回放用)、`skip()` / `reset()` / `resize()` / `destroy()`。
|
|
146
|
+
演出开始/结束发 `host.emit({ type: 'present:start' })` / `{ type: 'present:end', payout }`。
|
|
147
|
+
|
|
148
|
+
- **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页会有两个实例(主实例 + 回放),`test/e2e.test.ts` 会验。
|
|
149
|
+
- `turbo` / `reducedMotion` 只改演出时长,改不了结果——它们根本不在 `round()` 的入参里。
|
|
150
|
+
- CSS 命名空间挂在容器 `el` 上(`gk-__ID__`),不写 `document.documentElement`。
|
|
151
|
+
- **两块舞台都要支持**:桌面 900×771(横版机台)和手机 450×H(竖屏全屏,H 在 800–1000 之间按 50 分档)。
|
|
152
|
+
平台整体缩放,游戏里看到的宽度永远是这两个数之一;用 flex / 百分比排高度,不要写死 771。
|
|
153
|
+
当前在哪块由三处同一份数据告诉你:`host.stage`(`Readable<StageInfo>`,`layout` / `width` / `height` / `safe`)、
|
|
154
|
+
容器 `el` 上的 `data-layout="desktop|mobile"`、以及 CSS 变量 `--gk-safe-top/right/bottom/left`。
|
|
155
|
+
**安全区**:手机上平台的操作 UI(余额、注额、下注、参数)叠在画布上下两端,`safe` 就是被盖住的边(逻辑像素)。
|
|
156
|
+
安全区外可以渲染(底色、氛围),但**不要放交互和关键信息**——它们会被挡住。最简单的做法是给根元素
|
|
157
|
+
`padding: var(--gk-safe-top) … var(--gk-safe-bottom)`。舞台变化时平台会调 `resize()`,CSS 排版的游戏留空实现即可。
|
|
158
|
+
按尺寸分档用 `@container gk-stage (max-width: 500px)` 或 `[data-layout="mobile"]`,**不要用 `@media` / `vw`**:
|
|
159
|
+
iframe 里视口是逻辑分辨率,studio 预览里是整页,两处含义不同。
|
|
160
|
+
- **演出先查资源库**:`asset_list` 看有哪些。**animation** 是自足的小组件(赢面弹窗、分档大奖、揭示翻动、上飘文字…),`asset_use <name>` 复制进 `src/animations/` 再按需改;
|
|
161
|
+
**pattern** 是一套机制加接线清单(荷官说话…),`asset_show <name>` 先读 NOTES 里的接线表和台词写作指南,`asset_use <name>` 复制进 `src/patterns/<name>/` 后把调用按表穿进你的时间轴,
|
|
162
|
+
文案按这个游戏的角色重写。库是参考实现,复制进来就是你的代码;不要从零写一个库里已有的演出。
|
|
163
|
+
|
|
164
|
+
## 工作流(studio 的 MCP 工具)
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
改 src/rules/ 或 game.ts
|
|
168
|
+
→ run_task check / verify / test 类型、数学回归、端到端
|
|
169
|
+
→ probe_table 能不能打表、modes 该写哪些键、book 怎么选
|
|
170
|
+
→ preview_play { rounds, seed?, simId?, caseId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore);给 caseId 直接复现那条 case 并比对期望
|
|
171
|
+
→ preview_set { layout: 'mobile' } 切到手机机台看一眼:安全区对不对、画面有没有被 HUD 盖住;preview_state 报回 stage.safe
|
|
172
|
+
→ case_add 把值得盯的局面存进 studio/cases.jsonl(seed / simId 取自 preview_play 的结果表)
|
|
173
|
+
→ build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
|
|
174
|
+
→ publish_dry_run → publish 一键:vite build → 传包 → 传表 → 发布,不用人确认,不可逆。身份是作者在 studio 页面连过的站点身份;报「没有身份」就让作者打开页面连一次
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## 创作时的规则
|
|
178
|
+
|
|
179
|
+
- **调试只用预览**:`preview_state` / `preview_set` / `preview_play`,不自己起浏览器点。桌面和手机两种布局都要验过。
|
|
180
|
+
- **想让作者看效果就给 studio 页面地址**(`game_info` 里有),不要描述画面。
|
|
181
|
+
- **case 要早存**:规则能跑起来就用 `case_add` 存几条有代表性的局面;遇到问题先存 case 固定住再改。
|
|
182
|
+
- **不主动发布**。`publish` 不可逆,等作者明确说了再用。
|
|
183
|
+
|
|
184
|
+
- `game_info` 是第一跳:清单、schema、指纹、已建的表、预览连没连。
|
|
185
|
+
- 长任务返回 `jobId` 就用 `job_status` 轮询。
|
|
186
|
+
- **case 要早存、常跑。** 规则刚能跑就给每个 mode 存赢 / 输 / 边界 / 最大赔付各一条;改规则前先 `case_run` 一遍留底,改完再跑一遍看漂移。遇到演出异常、restore 对不上、某局赔付可疑,先用 `case_add` 把那一局固定住再改代码,修完用同一条 case 验证。
|
|
187
|
+
- `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**——历史局靠它们回放,要改就换 slug。
|
|
188
|
+
- `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
|
|
189
|
+
- 手动:`bun run dev` 起 studio(地址看终端输出,默认 `http://127.0.0.1:4301/`,被占会换;左边是预览机台,上方可切桌面 / 手机并选设备,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
|
|
@@ -1,180 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
|
|
4
|
-
运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
|
|
5
|
-
|
|
6
|
-
先读这份文件,再读 `game.ts`。这个目录是平台参考游戏 **coin-flip(抛硬币)的完整拷贝**换了名字——它能跑、能建表、能发布;
|
|
7
|
-
从能跑的东西开始改,不要从零写。源码里带「改成你自己的游戏时」的注释就是给你的路标。
|
|
8
|
-
|
|
9
|
-
## 一条原则:演出必须能回放
|
|
10
|
-
|
|
11
|
-
一局的画面只能是「表里那一行」的函数。同一行,在下注当场、在历史列表里点开、从分享链接进来、换一台设备,
|
|
12
|
-
演出来的必须是同一局:同样的牌、同样的连线、同样的赔付。下面所有约束都是这一条推出来的:
|
|
13
|
-
|
|
14
|
-
- **`round()` 是纯函数**,随机只来自注入的 rng。作者机器采样、玩家浏览器还原、审计脚本复算,三处逐位一致。
|
|
15
|
-
- **`restore()` 把一行还原成 `RoundResult`**,返回的 `payout` 必须恰好等于 `(bet / 100) × payoutCenti`,对不上就 `throw`、不演出。
|
|
16
|
-
- **`present(result)` 只看 `result`**,不看上一局、不看余额、不看玩家当下的设置。回放是在一个全新的实例里跑 `preview(result)`,那里什么都没有。
|
|
17
|
-
- **装饰性随机**(粒子角度、台词抽签)用 `Math.random()`,放在 `src/view` / 组件里,绝不进 `round()`:否则加一句台词就会改变牌序,历史局就回放不出来了。
|
|
18
|
-
|
|
19
|
-
「玩家看到连成三条线,余额却没动」「分享出去的一局,别人看到另一副牌」——这类 bug 完全静默、最伤信任,所以每一条都有工具兜着:
|
|
20
|
-
`scripts/verify.ts` 把 `Math.random` / `crypto` 换成会抛的桩,平台在演出前逐分比对 payout,`case_run` 盯任何结果漂移。
|
|
21
|
-
|
|
22
|
-
## 目录
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
game.ts 清单:defineGame({...})。Node 与浏览器都 import 它,不能碰 DOM / Svelte
|
|
26
|
-
src/rules/ 纯逻辑:schema、simulate、restore、常量、类型。零 DOM、零自取随机
|
|
27
|
-
src/index.ts 浏览器入口 = { ...game, mount, assets }。平台加载的是它
|
|
28
|
-
src/state/ 单局状态(Svelte runes 类),只放「演到哪一步了」
|
|
29
|
-
src/view/ mount(接平台)与 present(把结果播出来)
|
|
30
|
-
src/components/ 画面。子组件一律 getInstance(),不 import 状态对象
|
|
31
|
-
src/assets/ 只放文件:critical/ 首屏必需,lazy/ 后台预取。runtime 按目录生成清单,平台负责加载和进度
|
|
32
|
-
scripts/verify.ts 数学回归:纯度、确定性、RTP、分布
|
|
33
|
-
test/e2e.test.ts happy-dom 里跑完整一局
|
|
34
|
-
studio/cases.jsonl case 库(进版本控制),studio 维护
|
|
35
|
-
.studio/ 采样缓存与建好的表,按规则指纹分目录,可随手删
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
**改名**只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`。
|
|
39
|
-
`gameId` 是平台第一次发布时分配并写回的身份,不要手改、不要删。已发布过的 slug 不能改——那是另一个游戏。
|
|
40
|
-
|
|
41
|
-
## 清单:`game.ts`
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
export default defineGame<Config, Outcome>({
|
|
45
|
-
slug: '__ID__', title: '__TITLE__', description?: string,
|
|
46
|
-
rtp: 0.98, // 目标 RTP,小数。所有 mode 都由求解器钉到这一个数
|
|
47
|
-
sims: 100_000, // 每张表采样多少局。行数为的是「牌局不重复」,不是「结果种类多」
|
|
48
|
-
autoplay?: true, // 平台要不要显示自动按钮
|
|
49
|
-
|
|
50
|
-
schema, // 有哪些参数(见下)
|
|
51
|
-
round, // 一局怎么算(见下)
|
|
52
|
-
paytable, // 赔率表:PaytableRow[] 或 (config) => PaytableRow[]。给大厅展示,不参与计算
|
|
53
|
-
|
|
54
|
-
modes?, // 要几张表。不写 = 单表 'base'
|
|
55
|
-
book?, // 演出怎么编进表里:'none' | 'seed' | { encode(sample): Uint8Array }。不写 = 'seed'
|
|
56
|
-
restore?, // 怎么把一行还原成演出。book 为 'seed' 时不写,SDK 给 restoreFromSeed
|
|
57
|
-
});
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`defineGame` 按 schema 和 modes 推导出 `normalize` / `validate` / `modeOf` / `configOf` 挂在返回值上;
|
|
61
|
-
自己要规整 config 时用 `normalizeBySchema(schema, config)`。注额区间是平台的事,清单里没有。
|
|
62
|
-
|
|
63
|
-
### `round(input)`
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
function round(input: RoundInput<Config>): RoundResult<Outcome>
|
|
67
|
-
// RoundInput = { config, bet, seed: { server, client, nonce } }
|
|
68
|
-
// RoundResult = { bet, payout, multiplier, outcome, pacing: { durationMs, postDelayMs } }
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
1. **同步纯函数**。不碰 DOM、时间、全局状态;随机只来自 `createRng(input.seed)`。
|
|
72
|
-
2. **随机数抽取顺序是契约**。用 `rng.fork('tag')` 把子系统分开(发牌一条、金牌一条),将来改一处不会让另一处跟着漂。`below(n)` 是无偏整数,不要自己写 `% n`。
|
|
73
|
-
3. **金额是整数分**。`multiplier` 最多两位小数(平台按 centi 存);`payout` 是 `bet` 的精确线性函数,取整方式不能随 bet 变(bet 恒为 100 的倍数)。
|
|
74
|
-
4. 先 `normalizeBySchema(schema, input.config)` 再用 config——调用方给的可能是部分或越界的值。
|
|
75
|
-
5. 平台有最大倍数上限,超过就进不了表。
|
|
76
|
-
|
|
77
|
-
### `schema`
|
|
78
|
-
|
|
79
|
-
`ConfigSchema<Config>`,每个字段 = 一种 kind + 元数据 + `default`:
|
|
80
|
-
|
|
81
|
-
| kind | 形状 | 例子 |
|
|
82
|
-
|---|---|---|
|
|
83
|
-
| `int` / `number` | `{ min, max, step? }` | 翻几张牌 |
|
|
84
|
-
| `bool` | — | 金牌开关 |
|
|
85
|
-
| `enum` | `{ options: [{ value, label }] }` | 押哪一面 |
|
|
86
|
-
| `pick` | `{ of: Option[], count, exact?, ordered? }` | 13 选 4 个点数 |
|
|
87
|
-
|
|
88
|
-
元数据:`label`(`{'zh-CN': …}` 这种 I18n 记录)、`hint?`、`affectsOutcome`(影响结果的字段才进 schema;不影响的放 `ViewSettings`),
|
|
89
|
-
`lock`(`'never' | 'inRound' | 'inRoundOrAuto'`)、`ui`(`'host'` 平台的设置面板渲染 / `'stage'` 你在画布里渲染,但值仍归平台)、
|
|
90
|
-
`devOnly?`(只在开发环境允许,生产被服务端拒绝)、`control?` / `group?` / `order?`(渲染建议)。
|
|
91
|
-
|
|
92
|
-
平台不认识你的游戏,只认识 kind——设置 UI 是按 schema 自动渲染的。
|
|
93
|
-
|
|
94
|
-
## 能不能打表:`modes` 与 `book`
|
|
95
|
-
|
|
96
|
-
先问 studio:**`probe_table`**。它逐字段采样,告诉你两件不同的事:
|
|
97
|
-
|
|
98
|
-
- **影响分布**的字段(换值后赔付的概率分布变了)→ 必须写进 `modes`。一张表 = 一种分布。
|
|
99
|
-
- **只影响这一局**的字段(同一个种子下结果变了,但分布没变;抛硬币的 `side`、coco 的 `ranks`)→ 不进 modes,玩家运行时自由选。
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
modes: [{ name: 'n16g0', config: { N: 16, goldOn: false } }, ...] // 只写影响分布的字段,每个 mode 写同一组键
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
出现在 `mode.config` 里的键就是 `modeKeys`;`modeOf(config)` 按它匹配,匹配不到就抛——那是 modes 声明不完整。
|
|
106
|
-
表数 = modes 数,超过几十张就该把连续参数冻结成几档,或改成从有限档位里选。
|
|
107
|
-
|
|
108
|
-
`book` 按 probe 的建议选:
|
|
109
|
-
|
|
110
|
-
| 情形 | `book` | `restore` |
|
|
111
|
-
|---|---|---|
|
|
112
|
-
| 演出能从 payout 推导(≤3 种赔付,赢了就是你押的那面) | `'none'` | 自己写:从 `payoutCenti` 反推 outcome |
|
|
113
|
-
| 所有影响结果的字段都在 modes 里 | `'seed'`(默认) | 不写。SDK 存采样种子、还原时重跑 `round()` |
|
|
114
|
-
| 有字段只影响这一局(`side`、`ranks` 这类) | `{ encode(s) => Uint8Array }` | 自己写:解码时用玩家**当下**的自由字段。参考 coco 的 `rules/book.ts`:把 ranks 当重标记,存「命中第 k 格」而不是牌 id |
|
|
115
|
-
|
|
116
|
-
`'seed'` 用在有自由字段的游戏上,后果是表里写 0x、客户端按玩家当下选的值算出 4.64x。`restoreFromSeed` 会在还原时抛,但那已经是运行时了——先跑 probe。
|
|
117
|
-
|
|
118
|
-
### `restore(input)`
|
|
119
|
-
|
|
120
|
-
```ts
|
|
121
|
-
// RestoreInput = { config, bet, payoutCenti, frames: Uint8Array | null, mode, seed }
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
`payout` 必须恰好等于 `(input.bet / 100) * input.payoutCenti`,对不上就 `throw`,不要「修正」。能多验一道就多验:解码出的局面算出的倍数应当等于 `payoutCenti`。
|
|
125
|
-
`input.config` 是玩家当下的配置(回放时是下注那一刻存下的),先 `normalizeBySchema` 再用。
|
|
126
|
-
|
|
127
|
-
## 演出侧:`src/index.ts` 与 `mount`
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
export default { ...game, mount: mountGame, skins?: Skin[], assets: assets.bundle() }; // assets 来自 gamekit777/runtime
|
|
131
|
-
mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
`mount` 被调用时 critical 资源已由平台按 `assets` 清单预加载完,进度条也是平台画的;`mount` 里不写加载逻辑。
|
|
135
|
-
资源放 `src/assets/critical|lazy`,组件用 `assets.url(key)` 取地址;字体在 `game.meta.json` 的 `fonts` 里声明。
|
|
136
|
-
|
|
137
|
-
`Host` 是平台给你的,**全是只读**:`config` / `view` / `wallet` / `jackpot` 四个 `Readable`(`get()` + `subscribe()`),
|
|
138
|
-
`audio.context()`(不要自己 new AudioContext)、`locale`、`emit(event)`、`log()`。
|
|
139
|
-
想改参数只能 `host.requestConfig(patch)` 申请——平台校验、可能否决(局中、自动中)、批了才会从 `host.config` 推回来。
|
|
140
|
-
画布内的控件因此也是受控组件:渲染平台的值,改动走申请。`ViewSettings.autoActive` 为真时禁用画布内的设置控件。
|
|
141
|
-
|
|
142
|
-
`GameInstance`:`status`(`Readable<GameStatus>`,平台据 `busy` / `canBet` / `canSkip` / `canEditConfig` 控制自己的按钮)、
|
|
143
|
-
`present(result, { rate? })`(演一局,resolve 于演完;`rate: Infinity` 直接看结果)、`preview(result)`(只演不结账,回放用)、`skip()` / `reset()` / `resize()` / `destroy()`。
|
|
144
|
-
演出开始/结束发 `host.emit({ type: 'present:start' })` / `{ type: 'present:end', payout }`。
|
|
145
|
-
|
|
146
|
-
- **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页会有两个实例(主实例 + 回放),`test/e2e.test.ts` 会验。
|
|
147
|
-
- `turbo` / `reducedMotion` 只改演出时长,改不了结果——它们根本不在 `round()` 的入参里。
|
|
148
|
-
- CSS 命名空间挂在容器 `el` 上(`gk-__ID__`),不写 `document.documentElement`。
|
|
149
|
-
- **两块舞台都要支持**:桌面 900×771(横版机台)和手机 450×H(竖屏全屏,H 在 800–1000 之间按 50 分档)。
|
|
150
|
-
平台整体缩放,游戏里看到的宽度永远是这两个数之一;用 flex / 百分比排高度,不要写死 771。
|
|
151
|
-
当前在哪块由三处同一份数据告诉你:`host.stage`(`Readable<StageInfo>`,`layout` / `width` / `height` / `safe`)、
|
|
152
|
-
容器 `el` 上的 `data-layout="desktop|mobile"`、以及 CSS 变量 `--gk-safe-top/right/bottom/left`。
|
|
153
|
-
**安全区**:手机上平台的操作 UI(余额、注额、下注、参数)叠在画布上下两端,`safe` 就是被盖住的边(逻辑像素)。
|
|
154
|
-
安全区外可以渲染(底色、氛围),但**不要放交互和关键信息**——它们会被挡住。最简单的做法是给根元素
|
|
155
|
-
`padding: var(--gk-safe-top) … var(--gk-safe-bottom)`。舞台变化时平台会调 `resize()`,CSS 排版的游戏留空实现即可。
|
|
156
|
-
按尺寸分档用 `@container gk-stage (max-width: 500px)` 或 `[data-layout="mobile"]`,**不要用 `@media` / `vw`**:
|
|
157
|
-
iframe 里视口是逻辑分辨率,studio 预览里是整页,两处含义不同。
|
|
158
|
-
- **演出先查资源库**:`asset_list` 看有哪些。**animation** 是自足的小组件(赢面弹窗、分档大奖、揭示翻动、上飘文字…),`asset_use <name>` 复制进 `src/animations/` 再按需改;
|
|
159
|
-
**pattern** 是一套机制加接线清单(荷官说话…),`asset_show <name>` 先读 NOTES 里的接线表和台词写作指南,`asset_use <name>` 复制进 `src/patterns/<name>/` 后把调用按表穿进你的时间轴,
|
|
160
|
-
文案按这个游戏的角色重写。库是参考实现,复制进来就是你的代码;不要从零写一个库里已有的演出。
|
|
161
|
-
|
|
162
|
-
## 工作流(studio 的 MCP 工具)
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
改 src/rules/ 或 game.ts
|
|
166
|
-
→ run_task check / verify / test 类型、数学回归、端到端
|
|
167
|
-
→ probe_table 能不能打表、modes 该写哪些键、book 怎么选
|
|
168
|
-
→ preview_play { rounds, seed?, simId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore)
|
|
169
|
-
→ preview_set { layout: 'mobile' } 切到手机机台看一眼:安全区对不对、画面有没有被 HUD 盖住;preview_state 报回 stage.safe
|
|
170
|
-
→ case_add 把值得盯的局面存进 studio/cases.jsonl(seed / simId 取自 preview_play 的结果表)
|
|
171
|
-
→ build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
|
|
172
|
-
→ publish_dry_run → publish 一键:vite build → 传包 → 传表 → 发布,不用人确认,不可逆。身份是作者在 studio 页面连过的站点身份;报「没有身份」就让作者打开页面连一次
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
- `game_info` 是第一跳:清单、schema、指纹、已建的表、预览连没连。
|
|
176
|
-
- 长任务返回 `jobId` 就用 `job_status` 轮询。
|
|
177
|
-
- **case 要早存、常跑。** 规则刚能跑就给每个 mode 存赢 / 输 / 边界 / 最大赔付各一条;改规则前先 `case_run` 一遍留底,改完再跑一遍看漂移。遇到演出异常、restore 对不上、某局赔付可疑,先用 `case_add` 把那一局固定住再改代码,修完用同一条 case 验证。
|
|
178
|
-
- `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**——历史局靠它们回放,要改就换 slug。
|
|
179
|
-
- `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
|
|
180
|
-
- 手动:`bun run dev` 起 studio(地址看终端输出,默认 `http://127.0.0.1:4301/`,被占会换;左边是预览机台,上方可切桌面 / 手机并选设备,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
|
|
1
|
+
@AGENTS.md
|
package/package.json
CHANGED
package/studio/src/mcp.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
9
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import type { Engine } from './engine.ts';
|
|
12
|
+
import type { CaseRecord } from './cases.ts';
|
|
12
13
|
import type { Job } from './jobs.ts';
|
|
13
14
|
import type { ProbeResult } from './probe.ts';
|
|
14
15
|
import { scaffold } from './scaffold.ts';
|
|
@@ -90,12 +91,12 @@ function renderTask(r: TaskResult): string {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/* 客户端在 initialize 时拿到的说明。写游戏的知识不在工具描述里——工具是手,这段和游戏目录里的
|
|
93
|
-
|
|
94
|
+
AGENTS.md 才是脑子。Claude Code 经 CLAUDE.md 的 @AGENTS.md 自动读它,别的 MCP 客户端只有这一段 */
|
|
94
95
|
const INSTRUCTIONS = [
|
|
95
96
|
'GameKit studio:这个 MCP 服务对应 cwd 里的一个 GameKit 游戏目录。',
|
|
96
97
|
'平台不执行游戏代码:打包时用 game.ts 里的 round() 采样出权重表,运行时按权重抽一行、查表赔付,浏览器用 restore() 还原演出。',
|
|
97
98
|
'',
|
|
98
|
-
'开始前先读游戏目录里的
|
|
99
|
+
'开始前先读游戏目录里的 AGENTS.md(清单字段、round() 的硬约束、schema、modes/book/restore 怎么选、演出侧约定、创作时的规则)。',
|
|
99
100
|
'没有这个文件就先调 game_info,再读 game.ts 和 src/rules/。',
|
|
100
101
|
'',
|
|
101
102
|
'工作流:改 src/rules/ 或 game.ts → run_task check/verify/test → probe_table(能不能打表、modes 写哪些键、book 怎么选)',
|
|
@@ -131,10 +132,10 @@ export function createMcpServer(engine: Engine): McpServer {
|
|
|
131
132
|
`- 预览:${i.previewConnected ? '已连接' : '未连接'}——页面 ${i.urls.studio}`,
|
|
132
133
|
'', '### schema', '| 字段 | 类型 | 默认 | 影响结果 | 渲染 | devOnly |', '|---|---|---|---|---|---|',
|
|
133
134
|
];
|
|
134
|
-
const guide = existsSync(join(i.gameDir, '
|
|
135
|
+
const guide = existsSync(join(i.gameDir, 'AGENTS.md'));
|
|
135
136
|
const tail = ['', '### 下一步',
|
|
136
|
-
guide ? `- 先读 ${join(i.gameDir, '
|
|
137
|
-
: '- 这个目录没有
|
|
137
|
+
guide ? `- 先读 ${join(i.gameDir, 'AGENTS.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定、创作时的规则`
|
|
138
|
+
: '- 这个目录没有 AGENTS.md(写给 AI 的开发指南)。读 game.ts 和 src/rules/ 之前先记住:round() 纯函数只用注入 rng,payout 整数分,restore 的 payout 必须等于 (bet/100)×payoutCenti',
|
|
138
139
|
'- 改了规则先 run_task verify,再 probe_table 看 modes / book 该怎么写,然后 preview_play',
|
|
139
140
|
...(i.cases === 0 ? ['- 还没有 case:先 preview_play 几局,把每个 mode 的赢 / 输 / 边界局面用 case_add 存下来,之后改规则靠 case_run 抓漂移、复现问题靠它定位'] : [])];
|
|
140
141
|
for (const [k, f] of Object.entries(sn.schema)) {
|
|
@@ -142,7 +143,7 @@ export function createMcpServer(engine: Engine): McpServer {
|
|
|
142
143
|
lines.push(`| ${k} | ${f.kind} ${range} | ${JSON.stringify(f.default)} | ${f.affectsOutcome ? '是' : '否'} | ${f.ui} | ${f.devOnly ? '是' : ''} |`);
|
|
143
144
|
}
|
|
144
145
|
lines.push(...tail);
|
|
145
|
-
return out(lines.join('\n'), { ...i, guide: guide ? join(i.gameDir, '
|
|
146
|
+
return out(lines.join('\n'), { ...i, guide: guide ? join(i.gameDir, 'AGENTS.md') : null });
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
s.registerTool('scaffold', {
|
|
@@ -316,21 +317,38 @@ export function createMcpServer(engine: Engine): McpServer {
|
|
|
316
317
|
});
|
|
317
318
|
|
|
318
319
|
s.registerTool('preview_play', {
|
|
319
|
-
description: '在预览里演几局并拿回每局结果(本地表模型:就地建表、按权重抽行、restore 还原)。给 seed 就复现那一局,给 simId 就直接演表里第 N
|
|
320
|
+
description: '在预览里演几局并拿回每局结果(本地表模型:就地建表、按权重抽行、restore 还原)。给 seed 就复现那一局,给 simId 就直接演表里第 N 行。给 caseId 就在预览里复现那条 case:先把它的 config 与注额设进预览,再按它的 simId / seed 演一局,并和 case 的期望赔付比对。animate=false 跳过动画。结果表里的 seed / simId 可直接喂给 case_add 固定住那一局。',
|
|
320
321
|
inputSchema: {
|
|
321
322
|
rounds: z.number().int().min(1).max(50).default(1),
|
|
322
323
|
bet: z.number().int().optional(),
|
|
323
324
|
seed: z.object({ server: z.string(), client: z.string(), nonce: z.number().int() }).optional(),
|
|
324
325
|
simId: z.number().int().optional(),
|
|
326
|
+
caseId: z.string().optional().describe('case_list 里的 id。与 seed / simId 互斥,rounds 固定为 1'),
|
|
325
327
|
animate: z.boolean().default(true),
|
|
326
328
|
},
|
|
327
329
|
}, async (a) => {
|
|
328
330
|
try {
|
|
329
|
-
|
|
331
|
+
let cmd: Record<string, unknown> = { t: 'play', ...a };
|
|
332
|
+
let cs: CaseRecord | undefined;
|
|
333
|
+
if (a.caseId !== undefined) {
|
|
334
|
+
cs = engine.cases.get(a.caseId);
|
|
335
|
+
if (!cs) return fail(`没有 case ${a.caseId}。用 case_list 看有哪些`);
|
|
336
|
+
/* 表行是按 mode 冻结的默认值采的,但 restore 用玩家的 config;预览走的正是 restore 那条路,所以设的是 case.config */
|
|
337
|
+
const set = await engine.previewCommand<{ rejected: { key: string; reason: string }[] }>({ t: 'set', config: cs.config, bet: a.bet ?? cs.bet });
|
|
338
|
+
if (set.rejected.length) return fail(`case ${cs.id} 的设置被预览否决:${set.rejected.map((x) => `${x.key}(${x.reason})`).join('、')}`);
|
|
339
|
+
cmd = { t: 'play', rounds: 1, animate: a.animate, ...(cs.table ? { simId: cs.table.simId } : { seed: cs.seed }) };
|
|
340
|
+
}
|
|
341
|
+
const r = await engine.previewCommand<{ rounds: { payout: number; multiplier: number; payoutCenti: number; mode: string; checks: { label: string; ok: boolean }[]; simId: number | null; seed: unknown }[]; wallet: { balance: number }; errors: string[] }>(cmd, 30_000 + a.rounds * 8_000);
|
|
330
342
|
const lines = [`## 演了 ${r.rounds.length} 局,余额 ${r.wallet.balance}`, '', '| # | mode | 倍数 | 赔付 | 校验 | simId |', '|---|---|---|---|---|---|'];
|
|
331
343
|
r.rounds.forEach((x, i) => lines.push(`| ${i + 1} | ${x.mode} | ${x.multiplier.toFixed(2)}x | ${x.payout} | ${x.checks.every((c) => c.ok) ? '✓' : '✗ ' + x.checks.filter((c) => !c.ok).map((c) => c.label).join('、')} | ${x.simId ?? ''} |`));
|
|
344
|
+
if (cs) {
|
|
345
|
+
const got = r.rounds[0]?.payoutCenti;
|
|
346
|
+
lines.push('', got === undefined ? `case ${cs.id}「${cs.title}」没演出来`
|
|
347
|
+
: got === cs.expect.payoutCenti ? `case ${cs.id}「${cs.title}」:赔付 ${x(got)} 与期望一致`
|
|
348
|
+
: `⚠️ case ${cs.id}「${cs.title}」:赔付 ${x(got)},期望 ${x(cs.expect.payoutCenti)}——结果漂移了,先 case_run 看是哪条路变的`);
|
|
349
|
+
}
|
|
332
350
|
if (r.errors.length) lines.push('', '### 错误', ...r.errors.map((e) => `- ${e}`));
|
|
333
|
-
return out(lines.join('\n'), r);
|
|
351
|
+
return out(lines.join('\n'), { ...r, case: cs ? { id: cs.id, expect: cs.expect } : null });
|
|
334
352
|
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
335
353
|
});
|
|
336
354
|
|
package/studio/src/scaffold.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function resolveTarget(cwd: string, name?: string): { id: string; dest: s
|
|
|
39
39
|
}
|
|
40
40
|
const hit = looksLikeProject(cwd);
|
|
41
41
|
if (hit) throw new Error(`当前目录 ${cwd} 已经是一个项目(有 ${hit})。要在它里面新建就给个名字:gamekit777 new <名字>`);
|
|
42
|
-
const others = readdirSync(cwd).filter((f) => !f.startsWith('.') && !['README.md', 'LICENSE', 'CLAUDE.md'].includes(f));
|
|
42
|
+
const others = readdirSync(cwd).filter((f) => !f.startsWith('.') && !['README.md', 'LICENSE', 'CLAUDE.md', 'AGENTS.md'].includes(f));
|
|
43
43
|
if (others.length) throw new Error(`当前目录 ${cwd} 不是空的(有 ${others.slice(0, 5).join('、')}${others.length > 5 ? '…' : ''})。在空目录里跑,或给个名字:gamekit777 new <名字>`);
|
|
44
44
|
return { id: idFromDir(cwd), dest: cwd, inPlace: true };
|
|
45
45
|
}
|
|
@@ -126,7 +126,7 @@ export function nextSteps(dest: string, id: string, inPlace: boolean, upgradedTo
|
|
|
126
126
|
? `就地生成在当前目录 ${dest}(id = ${id},取自目录名),不用 cd。是一个能跑的抛硬币;名字和标题在 game.meta.json 里改。`
|
|
127
127
|
: `游戏在 ${dest}(id = ${id}),是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
|
|
128
128
|
...(upgradedTo ? [`模板由 npm 上最新的 gamekit777@${upgradedTo} 生成并钉死在 package.json 里;之后在这个目录里跑的 gamekit777 命令都用项目里装的这份,不再受 bunx 缓存影响。正在运行的 studio 还是旧版,要在这个目录里重启它。`] : []),
|
|
129
|
-
`先读 ${join(dest, '
|
|
129
|
+
`先读 ${join(dest, 'AGENTS.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定、创作时的规则。`,
|
|
130
130
|
'改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
|
|
131
131
|
'已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
|
|
132
132
|
'用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
|