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,167 @@
1
+ # __TITLE__(`__ID__`)— 写给 AI 的游戏开发指南
2
+
3
+ 这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
4
+ 运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
5
+ 所以你写的每一行逻辑都要满足「三处逐位一致」:作者机器采样、玩家浏览器还原、审计脚本复算。
6
+
7
+ 先读这份文件,再读 `game.ts`。模板是一个**能跑的抛硬币**——从能跑的东西开始改,不要从零写。
8
+
9
+ ## 目录
10
+
11
+ ```
12
+ game.ts 清单:defineGame({...})。Node 与浏览器都 import 它,不能碰 DOM / Svelte
13
+ src/rules/ 纯逻辑:schema、simulate、restore、常量、类型。零 DOM、零自取随机
14
+ src/index.ts 浏览器入口 = { ...game, mount }。平台加载的是它
15
+ src/state/ 单局状态(Svelte runes 类),只放「演到哪一步了」
16
+ src/view/ mount(接平台)与 present(把结果播出来)
17
+ src/components/ 画面。子组件一律 getInstance(),不 import 状态对象
18
+ src/assets/ 资源清单与注册表;critical/ 首屏必需,lazy/ 后台预取
19
+ scripts/verify.ts 数学回归:纯度、确定性、RTP、分布
20
+ test/e2e.test.ts happy-dom 里跑完整一局
21
+ studio/cases.jsonl case 库(进版本控制),studio 维护
22
+ .studio/ 采样缓存与建好的表,按规则指纹分目录,可随手删
23
+ ```
24
+
25
+ **没有** `publish.ts`、`src/rules/index.ts`、`meta`、版本号。看到教程里有这些就是旧结构,忽略。
26
+
27
+ ## 清单:`game.ts`
28
+
29
+ ```ts
30
+ export default defineGame<Config, Outcome>({
31
+ slug: '__ID__', title: '__TITLE__', description?: string,
32
+ rtp: 0.98, // 目标 RTP,小数。所有 mode 都由求解器钉到这一个数
33
+ sims: 100_000, // 每张表采样多少局。行数为的是「牌局不重复」,不是「结果种类多」
34
+ autoplay?: true, // 平台要不要显示自动按钮
35
+
36
+ schema, // 有哪些参数(见下)
37
+ round, // 一局怎么算(见下)
38
+ paytable, // 赔率表:PaytableRow[] 或 (config) => PaytableRow[]。给大厅展示,不参与计算
39
+
40
+ modes?, // 要几张表。不写 = 单表 'base'
41
+ book?, // 演出怎么编进表里:'none' | 'seed' | { encode(sample): Uint8Array }。不写 = 'seed'
42
+ restore?, // 怎么把一行还原成演出。book 为 'seed' 时不写,SDK 给 restoreFromSeed
43
+ });
44
+ ```
45
+
46
+ `normalize` / `validate` / `modeOf` / `configOf` **不要写**,`defineGame` 按 schema 和 modes 推导后挂在返回值上。
47
+ 清单里也**没有注额区间**——那是平台的事。
48
+
49
+ ### `round(input)`
50
+
51
+ ```ts
52
+ function round(input: RoundInput<Config>): RoundResult<Outcome>
53
+ // RoundInput = { config, bet, seed: { server, client, nonce } }
54
+ // RoundResult = { bet, payout, multiplier, outcome, pacing: { durationMs, postDelayMs } }
55
+ ```
56
+
57
+ 硬约束,每一条违反都会让表里的 payout 和客户端演出来的对不上,而且是概率性的:
58
+
59
+ 1. **同步纯函数**。不碰 DOM、时间、全局状态;随机只来自 `createRng(input.seed)`。`Math.random` / `crypto` 一次都不能碰——`scripts/verify.ts` 会把它们换成会抛的桩。
60
+ 2. **随机数抽取顺序是契约**。用 `rng.fork('tag')` 把子系统分开(发牌一条、金牌一条),将来改一处不会让另一处跟着漂。`below(n)` 是无偏整数,不要自己写 `% n`。
61
+ 3. **金额是最小货币单位的整数**。`payout` 是整数分;`multiplier` 最多两位小数(平台按 centi = ×100 存);`payout` 必须是 `bet` 的精确线性函数(bet 恒为 100 的倍数,`payout = bet * mult` 或 `(bet / 100) * payoutCenti` 都行,取整方式不能随 bet 变)。
62
+ 4. 先 `normalizeBySchema(schema, input.config)` 再用 config——调用方给的可能是部分或越界的值。
63
+ 5. 平台有最大倍数上限,超过就进不了表。
64
+
65
+ 装饰性随机(粒子角度、台词抽签)用 `Math.random()`,**放在 `src/view` / 组件里**,绝不进 `round()`:它们不该占用可复现的随机数,否则加一句台词就会改变牌序。
66
+
67
+ ### `schema`
68
+
69
+ `ConfigSchema<Config>`,每个字段 = 一种 kind + 元数据 + `default`:
70
+
71
+ | kind | 形状 | 例子 |
72
+ |---|---|---|
73
+ | `int` / `number` | `{ min, max, step? }` | 翻几张牌 |
74
+ | `bool` | — | 金牌开关 |
75
+ | `enum` | `{ options: [{ value, label }] }` | 押哪一面 |
76
+ | `pick` | `{ of: Option[], count, exact?, ordered? }` | 13 选 4 个点数 |
77
+
78
+ 元数据:`label`(`{'zh-CN': …}` 这种 I18n 记录)、`hint?`、`affectsOutcome`(影响结果的字段才进 schema;不影响的放 `ViewSettings`,别写进来)、
79
+ `lock`(`'never' | 'inRound' | 'inRoundOrAuto'`)、`ui`(`'host'` 平台的设置面板渲染 / `'stage'` 你在画布里渲染,但值仍归平台)、
80
+ `devOnly?`(只在开发环境允许,生产被服务端拒绝)、`control?` / `group?` / `order?`(渲染建议)。
81
+
82
+ 平台不认识你的游戏,它只认识 kind——设置 UI 是按 schema 自动渲染的。
83
+
84
+ ## 能不能打表:`modes` 与 `book`
85
+
86
+ 先问 studio:**`probe_table`**。它逐字段采样,告诉你两件不同的事:
87
+
88
+ - **影响分布**的字段(换值后赔付的概率分布变了)→ 必须写进 `modes`。一张表 = 一种分布。
89
+ - **只影响这一局**的字段(同一个种子下结果变了,但分布没变;抛硬币的 `side`、coco 的 `ranks`)→ 不进 modes,玩家运行时自由选。
90
+
91
+ ```ts
92
+ modes: [{ name: 'n16g0', config: { N: 16, goldOn: false } }, ...] // 只写影响分布的字段,每个 mode 写同一组键
93
+ ```
94
+
95
+ 出现在 `mode.config` 里的键就是 `modeKeys`;`modeOf(config)` 按它匹配,匹配不到就抛——那是你的 modes 声明不完整。
96
+ 表数 = modes 数,超过几十张就该把连续参数冻结成几档,或改成从有限档位里选。
97
+
98
+ `book` 按 probe 的建议选:
99
+
100
+ | 情形 | `book` | `restore` |
101
+ |---|---|---|
102
+ | 演出能从 payout 推导(≤3 种赔付,赢了就是你押的那面) | `'none'` | 自己写:从 `payoutCenti` 反推 outcome |
103
+ | 所有影响结果的字段都在 modes 里 | `'seed'`(默认) | 不写。SDK 存采样种子、还原时重跑 `round()` |
104
+ | 有字段只影响这一局(`side`、`ranks` 这类) | `{ encode(s) => Uint8Array }` | 自己写:解码时用玩家**当下**的自由字段。参考 coco 的 `rules/book.ts`:把 ranks 当重标记,存「命中第 k 格」而不是牌 id |
105
+
106
+ `'seed'` 用错的后果:表里写 0x,客户端按玩家当下选的值算出 4.64x。`restoreFromSeed` 会在还原时抛出来,但那已经是运行时了——先跑 probe。
107
+
108
+ ### `restore(input)` 的硬契约
109
+
110
+ ```ts
111
+ // RestoreInput = { config, bet, payoutCenti, frames: Uint8Array | null, mode, seed }
112
+ ```
113
+
114
+ **返回的 `payout` 必须恰好等于 `(input.bet / 100) * input.payoutCenti`**,对不上就 `throw`、不演出。
115
+ 「玩家看到连成三条线,余额却没动」这类 bug 完全静默,最伤信任。能多验一道就多验:解码出的局面算出的倍数应当等于 `payoutCenti`。
116
+ `input.config` 是玩家当下的配置(回放时是下注那一刻存下的),先 `normalizeBySchema` 再用。
117
+
118
+ ## 演出侧:`src/index.ts` 与 `mount`
119
+
120
+ ```ts
121
+ export default { ...game, mount: mountGame, skins?: Skin[] };
122
+ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
123
+ ```
124
+
125
+ `Host` 是平台给你的,**全是只读**:`config` / `view` / `wallet` / `jackpot` 四个 `Readable`(`get()` + `subscribe()`),
126
+ `audio.context()`(不要自己 new AudioContext)、`locale`、`emit(event)`、`log()`。
127
+ 想改参数只能 `host.requestConfig(patch)` 申请——平台校验、可能否决(局中、自动中)、批了才会从 `host.config` 推回来。
128
+ 画布内的控件因此也是受控组件:渲染平台的值,改动走申请。`ViewSettings.autoActive` 为真时禁用画布内的设置控件。
129
+
130
+ `GameInstance`:`status`(`Readable<GameStatus>`,平台据 `busy` / `canBet` / `canSkip` / `canEditConfig` 控制自己的按钮)、
131
+ `present(result, { rate? })`(演一局,resolve 于演完;`rate: Infinity` 直接看结果)、`preview()`(只演不结账,回放用)、`skip()` / `reset()` / `resize()` / `destroy()`。
132
+ 演出开始/结束发 `host.emit({ type: 'present:start' })` / `{ type: 'present:end', payout }`。
133
+
134
+ 约定:
135
+ - **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页两个实例互不干扰,`test/e2e.test.ts` 会验。
136
+ - `turbo` / `reducedMotion` 只改演出时长,改不了结果——它们根本不在 `round()` 的入参里。
137
+ - CSS 命名空间挂在容器 `el` 上(`gk-__ID__`),不写 `document.documentElement`。
138
+ - 画布是固定 900×771 的逻辑画面,平台整体缩放;按这个尺寸排版。
139
+ - 资源放 `src/assets/critical|lazy`,走 `registry.ts` 注册;`game.meta.json` 的 `fonts` 声明字体。
140
+
141
+ ## 工作流(studio 的 MCP 工具)
142
+
143
+ ```
144
+ 改 src/rules/ 或 game.ts
145
+ → run_task check / verify / test 类型、数学回归、端到端
146
+ → probe_table 能不能打表、modes 该写哪些键、book 怎么选
147
+ → preview_play { rounds, seed?, simId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore)
148
+ → case_add 把值得盯的局面存进 studio/cases.jsonl
149
+ → build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
150
+ → publish_dry_run → publish 发布要作者在页面上点确认,不可逆
151
+ ```
152
+
153
+ - `game_info` 是第一跳:清单、schema、指纹、已建的表、预览连没连。
154
+ - 长任务返回 `jobId` 就用 `job_status` 轮询。
155
+ - `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受;已发布过的 mode 解码逻辑**不能改**,要改就换 slug。
156
+ - `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
157
+ - 手动:`bun run dev`(参考平台,5173)、`bun run verify`、`bun run test`、`bun run build`。
158
+
159
+ ## 不要做的事
160
+
161
+ - 不写 `normalize` / `validate` / `modeOf`;不加 `meta`、版本号;不在清单里写注额区间。
162
+ - 不在 `round()` 或 `rules/` 里 import Svelte、DOM、`gamekit777/runtime`。
163
+ - 不用 `Math.random` 决定任何影响赔付的事;不用 `% n` 取整数随机。
164
+ - 不让 `payout` 出现小数或随 bet 变化的取整;`multiplier` 不超过两位小数。
165
+ - 不在 `restore` 里「修正」对不上的 payout——对不上就抛。
166
+ - 不把 `'seed'` 用在有自由字段的游戏上。
167
+ - 不改已发布 mode 的发牌逻辑 / book 编解码 / 取整方式。
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ # studio 的采样缓存与建表产物,按规则指纹分目录,随手就能重建
4
+ .studio/
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "__ID__",
3
+ "title": "__TITLE__",
4
+ "version": "1.0.0",
5
+ "aspect": "16:9",
6
+ "orientation": "any",
7
+ "fonts": []
8
+ }
@@ -0,0 +1,57 @@
1
+ /* __TITLE__ 的清单。只依赖 gamekit777/sdk 和自己的 rules/,Node 和浏览器都能 import:
2
+ studio 用它探测和建表、打包流水线用它采样、浏览器入口把它和 mount 拼在一起。
3
+
4
+ 模板是抛硬币:两面对称,**只有一种赔付分布**,所以一张表就够。
5
+ `side` 不改变分布,只改变某一局落在哪面——服务端不需要知道它,玩家运行时自由选。
6
+ book 因此可以完全不传:payout > 0 就说明硬币落在你押的那面(见 rules/restore.ts)。
7
+
8
+ 改成你自己的游戏时,先用 studio 的 probe_table 看看:
9
+ 哪些字段改变赔付分布 → 写进 modes(一张表 = 一种分布),只写那几个字段
10
+ 哪些字段只改变这一局 → 保持自由,用 book 编码演出(参考 coco 的 book.ts)
11
+ 赔付必须是 centi 精度、必须是注额的线性函数——这两条 probe 会替你查
12
+ 逻辑侧三个必填:schema 说有哪些参数,round 说结果怎么算,paytable 说赔多少。
13
+ normalize / validate / modeOf 不用写——它们由 schema 和 modes 推导。 */
14
+ import { createRng, defineGame, normalizeBySchema, type RoundInput, type RoundResult } from 'gamekit777/sdk';
15
+ import { HOUSE_EDGE, MULT } from './src/rules/const.ts';
16
+ import { restore } from './src/rules/restore.ts';
17
+ import { schema } from './src/rules/schema.ts';
18
+ import { simulate } from './src/rules/simulate.ts';
19
+ import type { __PASCAL__Config, __PASCAL__Outcome } from './src/rules/types.ts';
20
+
21
+ export { schema, simulate, restore };
22
+ export * from './src/rules/types.ts';
23
+
24
+ /**
25
+ * 一局的结果。同步纯函数:同样的 (config, bet, seed) 恒得同样的 payout。
26
+ *
27
+ * 随机数的抽取顺序是契约的一部分:flip 子流 below(2) 一次。
28
+ * 已发布 mode 的这段逻辑不能改——历史局的 book 要靠它解;要改就换 slug。
29
+ */
30
+ export function round(input: RoundInput<__PASCAL__Config>): RoundResult<__PASCAL__Outcome> {
31
+ const cfg = normalizeBySchema(schema, input.config);
32
+ const flip = createRng(input.seed).fork('flip').below(2);
33
+ const outcome = simulate(flip, cfg);
34
+ /* 金额一律是最小货币单位的整数。取整规则是契约的一部分——
35
+ 服务端按 bet/100 × payoutCenti 赔付,这里必须得出同一个整数 */
36
+ const payout = outcome.win ? Math.floor(input.bet * outcome.mult) : 0;
37
+ return {
38
+ bet: input.bet,
39
+ payout,
40
+ multiplier: outcome.mult,
41
+ outcome,
42
+ pacing: { durationMs: 900, postDelayMs: outcome.win ? 400 : 250 },
43
+ };
44
+ }
45
+
46
+ export default defineGame<__PASCAL__Config, __PASCAL__Outcome>({
47
+ slug: '__ID__',
48
+ title: '__TITLE__',
49
+ description: '押正面还是反面。',
50
+ rtp: HOUSE_EDGE,
51
+ sims: 100_000,
52
+ schema,
53
+ round,
54
+ paytable: [{ label: { 'zh-CN': '猜中' }, mult: MULT }],
55
+ book: 'none',
56
+ restore,
57
+ });
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>__TITLE__</title>
7
+ <script type="module" src="/src/dev.ts"></script>
8
+ </head>
9
+ <body>
10
+ </body>
11
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@game/__ID__",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "svelte-check --tsconfig ./tsconfig.json && vite build",
9
+ "check": "svelte-check --tsconfig ./tsconfig.json",
10
+ "test": "vitest run",
11
+ "verify": "bun scripts/verify.ts"
12
+ },
13
+ "dependencies": {
14
+ "gamekit777": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
18
+ "@tsconfig/svelte": "^5.0.5",
19
+ "happy-dom": "^20.14.5",
20
+ "svelte": "^5.42.2",
21
+ "svelte-check": "^4.3.3",
22
+ "typescript": "^5.9.3",
23
+ "vite": "^7.1.5",
24
+ "vitest": "^3.2.7"
25
+ }
26
+ }
@@ -0,0 +1,79 @@
1
+ /* 逻辑回归。跑法:bun scripts/verify.ts [局数]
2
+
3
+ 这四项是每个游戏都该有的:
4
+ 纯度 round() 不碰全局随机源,否则端服算出的 payout 会不一致
5
+ 确定性 同种子必然同结果,这是对账的前提
6
+ RTP 实际期望要对得上清单的 rtp
7
+ 分布 随机源本身是否无偏 */
8
+ import { MULT, WIN_CHANCE } from '../src/rules/const.ts';
9
+ import game, { round } from '../game.ts';
10
+
11
+ const ROUNDS = Number(process.argv[2] ?? 200_000);
12
+ let fails = 0;
13
+
14
+ console.log(`${game.title}(${game.slug}),目标 RTP=${game.rtp}`);
15
+
16
+ console.log('\n[1/4] round() 的纯度:把全局随机源换成会抛的桩');
17
+ {
18
+ const realRandom = Math.random, realCrypto = globalThis.crypto;
19
+ const boom = (who: string) => () => { throw new Error(`round() 调用了 ${who}`); };
20
+ Math.random = boom('Math.random') as typeof Math.random;
21
+ Object.defineProperty(globalThis, 'crypto', { configurable: true, get: boom('crypto') as () => Crypto });
22
+ let err: unknown = null;
23
+ try {
24
+ for (const side of ['heads', 'tails'] as const) {
25
+ round({ config: { side }, bet: 500, seed: { server: 'p', client: 'c', nonce: 1 } });
26
+ }
27
+ } catch (e) { err = e; } finally {
28
+ Math.random = realRandom;
29
+ Object.defineProperty(globalThis, 'crypto', { configurable: true, value: realCrypto, writable: true });
30
+ }
31
+ if (err) { console.error(' ✗', (err as Error).message); fails++; }
32
+ else console.log(' ✓ 没碰全局随机源');
33
+ }
34
+
35
+ console.log('\n[2/4] 确定性:同种子必然同结果');
36
+ {
37
+ const seed = { server: 'det', client: 'c', nonce: 7 };
38
+ const a = round({ config: { side: 'heads' }, bet: 1000, seed });
39
+ const b = round({ config: { side: 'heads' }, bet: 1000, seed });
40
+ const ok = a.payout === b.payout && a.outcome.landed === b.outcome.landed;
41
+ console.log(ok ? ' ✓ 一致' : ' ✗ 同种子产出不同结果');
42
+ if (!ok) fails++;
43
+ }
44
+
45
+ console.log(`\n[3/4] RTP 蒙特卡洛 · ${ROUNDS.toLocaleString()} 局`);
46
+ {
47
+ const BET = 100;
48
+ let staked = 0, returned = 0;
49
+ for (let i = 0; i < ROUNDS; i++) {
50
+ const r = round({ config: { side: 'heads' }, bet: BET, seed: { server: 'rtp', client: 'c', nonce: i } });
51
+ staked += BET; returned += r.payout;
52
+ }
53
+ const rtp = returned / staked;
54
+ /* 赔付是二值的(0 或 MULT),方差小,标准误可以直接算 */
55
+ const sd = MULT * Math.sqrt(WIN_CHANCE * (1 - WIN_CHANCE));
56
+ const se = sd / Math.sqrt(ROUNDS);
57
+ const z = Math.abs(rtp - game.rtp) / se;
58
+ const ok = z < 4;
59
+ if (!ok) { console.error(` ✗ RTP=${rtp.toFixed(4)} 偏离期望 ${z.toFixed(1)}σ`); fails++; }
60
+ else console.log(` ✓ RTP=${rtp.toFixed(4)}(期望 ${game.rtp},偏离 ${z.toFixed(1)}σ)`);
61
+ }
62
+
63
+ console.log('\n[4/4] 正反面分布:卡方检验');
64
+ {
65
+ let heads = 0;
66
+ for (let i = 0; i < ROUNDS; i++) {
67
+ const r = round({ config: { side: 'heads' }, bet: 100, seed: { server: 'dist', client: 'c', nonce: i } });
68
+ if (r.outcome.landed === 'heads') heads++;
69
+ }
70
+ const exp = ROUNDS / 2;
71
+ const chi2 = (heads - exp) ** 2 / exp + ((ROUNDS - heads) - exp) ** 2 / exp;
72
+ const bound = 1 + 6 * Math.sqrt(2); // df=1,6 sigma 上界
73
+ const ok = chi2 < bound;
74
+ if (!ok) { console.error(` ✗ chi2=${chi2.toFixed(1)} 超过 ${bound.toFixed(1)}`); fails++; }
75
+ else console.log(` ✓ chi2=${chi2.toFixed(2)}(正面 ${(heads / ROUNDS * 100).toFixed(2)}%)`);
76
+ }
77
+
78
+ console.log(fails === 0 ? '\n✅ 通过' : `\n❌ ${fails} 处问题`);
79
+ process.exit(fails === 0 ? 0 : 1);
File without changes
@@ -0,0 +1,30 @@
1
+ /* 取资源清单。
2
+
3
+ 生产环境下 manifest.json 是构建产物,和 index.js 同目录,用 import.meta.url 定位——
4
+ 这样游戏文件夹放到 CDN 任意子目录都找得到。
5
+ 开发和测试环境没有这个文件,从注册表现造一份。 */
6
+ import type { GameManifest } from 'gamekit777/runtime';
7
+ import { assets } from './registry.ts';
8
+ import buildMeta from '../../game.meta.json' with { type: 'json' };
9
+
10
+ const fallback = (): GameManifest => ({
11
+ id: buildMeta.id, version: buildMeta.version,
12
+ generatedAt: new Date().toISOString(),
13
+ entry: 'index.js', css: null, totalBytes: 0, criticalBytes: 0,
14
+ assets: assets.keys.map(key => ({
15
+ key, path: assets.url(key),
16
+ type: /\.(jpe?g|png|webp|avif|gif|svg)$/i.test(key) ? 'image' as const
17
+ : /\.(mp3|ogg|wav|m4a)$/i.test(key) ? 'audio' as const : 'binary' as const,
18
+ bytes: 1,
19
+ priority: key.startsWith('lazy/') ? 'lazy' as const : 'critical' as const
20
+ }))
21
+ });
22
+
23
+ export async function loadManifest(signal?: AbortSignal): Promise<GameManifest> {
24
+ if (!import.meta.url.includes('/dist/')) return fallback();
25
+ try {
26
+ const r = await fetch(new URL(/* @vite-ignore */ 'manifest.json', import.meta.url), { signal });
27
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
28
+ return await r.json() as GameManifest;
29
+ } catch { return fallback(); }
30
+ }
@@ -0,0 +1,23 @@
1
+ /* 资源注册表。目录即优先级。?no-inline 是必须的——
2
+ lib 模式会无条件把资源 base64 内联进 JS,这个 query 是唯一逃生口。
3
+ 这个游戏暂时没有资源文件,表是空的,但结构保持一致。 */
4
+ const critical = import.meta.glob('./critical/**/*', {
5
+ eager: true, query: '?no-inline', import: 'default'
6
+ }) as Record<string, string>;
7
+
8
+ const lazy = import.meta.glob('./lazy/**/*', {
9
+ eager: true, query: '?no-inline', import: 'default'
10
+ }) as Record<string, string>;
11
+
12
+ const table: Record<string, string> = {};
13
+ for (const [k, v] of Object.entries({ ...critical, ...lazy })) table[k.replace(/^\.\//, '')] = v;
14
+
15
+ export const assets = {
16
+ url(key: string): string {
17
+ const u = table[key];
18
+ if (!u) throw new Error(`[assets] 未知资源: ${key}`);
19
+ return u;
20
+ },
21
+ keys: Object.keys(table),
22
+ all: table
23
+ };
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ /* 游戏的根组件。平台化之后它只有画面——
3
+ 注额、押哪一面、音效、极速都是平台该渲染的 UI。
4
+
5
+ 实例经 context 往下传,子组件一律 getInstance(),不 import 任何状态对象,
6
+ 这样同页挂两个实例才不会互相污染。 */
7
+ import { setInstance, type Instance } from '../context.svelte.ts';
8
+ import { MULT } from '../rules/const.ts';
9
+
10
+ let { instance }: { instance: Instance } = $props();
11
+
12
+ /* 只取初始值是故意的:一个组件树对应一个实例,换实例应当整个重挂 */
13
+ // svelte-ignore state_referenced_locally
14
+ const inst = instance;
15
+ setInstance(inst);
16
+ const { game } = inst;
17
+
18
+ const label = (s: 'heads' | 'tails') => (s === 'heads' ? '正' : '反');
19
+ </script>
20
+
21
+ <div class="game" class:flash={game.phase === 'over' && game.last?.win}>
22
+ <p class="head">押 <b>{label(game.side)}</b> · 猜中赔 {MULT.toFixed(2)}×</p>
23
+
24
+ <div class="coin" class:spin={game.live} aria-live="polite">
25
+ <span>{label(game.face)}</span>
26
+ </div>
27
+
28
+ <p class="foot" class:win={game.last?.win}>
29
+ {#if game.phase === 'over' && game.last}
30
+ {game.last.win ? `猜中了,${MULT.toFixed(2)}× 到手` : '没猜中'}
31
+ {:else if game.live}
32
+ 抛出中…
33
+ {:else}
34
+ 在左边选一面,下注开始
35
+ {/if}
36
+ </p>
37
+ </div>
38
+
39
+ <style>
40
+ .game{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
41
+ height:100%;min-height:240px;padding:28px;border-radius:16px;
42
+ background:var(--bg);color:var(--ink);font-family:var(--txt)}
43
+ .game.flash::after{content:"";position:absolute;inset:0;pointer-events:none;border-radius:16px;
44
+ background:var(--win);animation:flash var(--d-flash,500ms) ease-out forwards}
45
+ .head{margin:0;font-size:13px;color:var(--dim)}
46
+ .head b{color:var(--ink);font-size:16px}
47
+ .coin{display:grid;place-items:center;width:104px;height:104px;border-radius:50%;
48
+ background:var(--mark);color:#241A00;font-family:var(--num);font-size:38px;font-weight:700;
49
+ box-shadow:inset 0 -6px 0 rgba(0,0,0,.18)}
50
+ .coin.spin{animation:pulse 420ms ease-in-out infinite}
51
+ .foot{margin:0;min-height:1.5em;font-size:13px;color:var(--dim)}
52
+ .foot.win{color:var(--win);font-weight:700}
53
+ @media (prefers-reduced-motion:reduce){.coin.spin{animation:none}.game.flash::after{animation:none}}
54
+ </style>
@@ -0,0 +1,22 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import type { Host } from 'gamekit777/sdk';
3
+ import type { __PASCAL__Config } from './rules/types.ts';
4
+ import { Game } from './state/game.svelte';
5
+
6
+ const KEY = Symbol('__ID__');
7
+
8
+ /** 一个游戏实例的全部状态。模块级不留任何可变状态,同页挂两个实例是安全的 */
9
+ export interface Instance {
10
+ game: Game;
11
+ host: Host<__PASCAL__Config>;
12
+ }
13
+
14
+ export const createInstance = (host: Host<__PASCAL__Config>): Instance => ({ game: new Game(), host });
15
+
16
+ /* 用 context 而不是模块级单例:组件一律 getInstance(),不 import 任何状态对象 */
17
+ export const setInstance = (i: Instance): void => { setContext(KEY, i); };
18
+ export const getInstance = (): Instance => {
19
+ const i = getContext<Instance>(KEY);
20
+ if (!i) throw new Error('[__ID__] 组件不在 <App> 之下,拿不到游戏实例');
21
+ return i;
22
+ };
@@ -0,0 +1,8 @@
1
+ /* 本地开发的宿主。index.html 指向它。
2
+
3
+ 外壳来自 gamekit777/dev-host——一个**泛型**参考平台,驱动任何符合契约的游戏。
4
+ 它不认识这个游戏:设置控件按 schema 渲染,赔率表来自清单的 paytable。 */
5
+ import { runDevShell } from 'gamekit777/dev-host';
6
+ import game from './index.ts';
7
+
8
+ runDevShell(game);
@@ -0,0 +1,17 @@
1
+ /* 游戏包的浏览器入口 = 清单 + view。平台加载这个文件:
2
+
3
+ import game from './dist/games/__ID__/index.js'
4
+ const inst = await game.mount(host, el)
5
+ await inst.present(result)
6
+
7
+ 清单在根目录的 game.ts,Node 侧(采样、studio)import 的是它;这里只是把 mount 拼上去。 */
8
+ import './styles/global.css';
9
+ import './styles/animations.css';
10
+
11
+ import game from '../game.ts';
12
+ import { mountGame } from './view/mount.svelte.ts';
13
+
14
+ export * from '../game.ts';
15
+ export { mountGame as mount };
16
+
17
+ export default { ...game, mount: mountGame };
@@ -0,0 +1,9 @@
1
+ /* 规则常量。改动其中任何一个都会改变结果——已发布 mode 的结果不能改,要改就换 slug */
2
+
3
+ /** 庄家优势。RTP = HOUSE_EDGE,赔率 = HOUSE_EDGE / 中奖概率 */
4
+ export const HOUSE_EDGE = 0.98;
5
+ /** 中奖概率:抛硬币就是一半 */
6
+ export const WIN_CHANCE = 0.5;
7
+ export const MULT = HOUSE_EDGE / WIN_CHANCE;
8
+ /** 倍率 ×100 = payoutCenti。平台只存整数 payoutCenti,restore 按它反推 */
9
+ export const PAYOUT_SCALE = 100;
@@ -0,0 +1,39 @@
1
+ /* 表模型下的还原:把平台抽中的那一行变回一局可演出的结果。
2
+ *
3
+ * 抛硬币是最省的那一档——**不需要任何演出数据**。
4
+ * 猜中了硬币就落在你押的那面,猜错了就落在另一面,payout 已经把这件事说完了。
5
+ * 所以这张表只有两行,book 一个字节都不传(game.ts 里 book: 'none')。
6
+ *
7
+ * 改成你自己的游戏时:
8
+ * 所有影响结果的字段都在 modes 里 → 删掉这个文件,game.ts 里不写 book 和 restore,
9
+ * defineGame 会给你 restoreFromSeed(存种子重跑 round(),渲染代码一行不用改)
10
+ * 有字段改变这一局却不改变分布(像 coco 的 ranks)→ 自己写 book 编码和这里的解码
11
+ * 硬契约只有一条:返回的 payout 必须恰好等于 (bet / 100) × payoutCenti。对不上就抛错、不演出——
12
+ * 「玩家看到大奖动画,余额却没动」这类 bug 完全静默,最伤信任。studio 的表模型预览会替你验。 */
13
+ import { normalizeBySchema, type Restore } from 'gamekit777/sdk';
14
+ import { PAYOUT_SCALE } from './const.ts';
15
+ import { schema } from './schema.ts';
16
+ import type { __PASCAL__Config, __PASCAL__Outcome } from './types.ts';
17
+
18
+ export const restore: Restore<__PASCAL__Config, __PASCAL__Outcome> = (input) => {
19
+ const { side } = normalizeBySchema(schema, input.config);
20
+ const win = input.payoutCenti > 0;
21
+ const mult = input.payoutCenti / PAYOUT_SCALE;
22
+
23
+ const outcome: __PASCAL__Outcome = {
24
+ side,
25
+ landed: win ? side : side === 'heads' ? 'tails' : 'heads',
26
+ win,
27
+ mult,
28
+ };
29
+
30
+ return {
31
+ bet: input.bet,
32
+ /* 必须和平台的 winCents 逐分相同。除法在前是故意的:
33
+ bet 保证能被 100 整除,先除再乘恒为精确整数 */
34
+ payout: (input.bet / PAYOUT_SCALE) * input.payoutCenti,
35
+ multiplier: mult,
36
+ outcome,
37
+ pacing: { durationMs: 900, postDelayMs: win ? 400 : 250 },
38
+ };
39
+ };
@@ -0,0 +1,19 @@
1
+ import type { ConfigSchema } from 'gamekit777/sdk';
2
+ import type { __PASCAL__Config } from './types.ts';
3
+
4
+ /* 平台按这份声明自动渲染设置控件,它不认识这个游戏。
5
+ affectsOutcome 影响结果 → 下注时冻结、随 RoundInput 上送、进 proof
6
+ lock 什么时候不许改
7
+ ui 谁渲染:'host' 平台的表单,'stage' 游戏画布内(仍然受控) */
8
+ export const schema: ConfigSchema<__PASCAL__Config> = {
9
+ side: {
10
+ kind: 'enum',
11
+ options: [
12
+ {value: 'heads', label: {'zh-CN': '正面'}},
13
+ {value: 'tails', label: {'zh-CN': '反面'}}
14
+ ],
15
+ default: 'heads',
16
+ label: {'zh-CN': '押哪一面'},
17
+ affectsOutcome: true, lock: 'inRoundOrAuto', ui: 'host', control: 'select', order: 1
18
+ }
19
+ };
@@ -0,0 +1,9 @@
1
+ /* 判定。纯函数:不碰 DOM、不碰时间、不碰全局状态、不自取随机数。 */
2
+ import { MULT } from './const.ts';
3
+ import type { __PASCAL__Config, __PASCAL__Outcome } from './types.ts';
4
+
5
+ export function simulate(flip: number, c: __PASCAL__Config): __PASCAL__Outcome {
6
+ const landed = flip === 0 ? 'heads' : 'tails';
7
+ const win = landed === c.side;
8
+ return {side: c.side, landed, win, mult: win ? MULT : 0};
9
+ }
@@ -0,0 +1,13 @@
1
+ /** 影响结果的参数。平台在下注这一刻冻结一份传进 round() */
2
+ export interface __PASCAL__Config {
3
+ /** 押正面还是反面 */
4
+ side: 'heads' | 'tails';
5
+ }
6
+
7
+ /** 一局的完整结果快照。演出层只读它,不做任何判定 */
8
+ export interface __PASCAL__Outcome {
9
+ side: 'heads' | 'tails';
10
+ landed: 'heads' | 'tails';
11
+ win: boolean;
12
+ mult: number;
13
+ }