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,348 @@
1
+ <script lang="ts" generics="C extends object, O">
2
+ /* 参考平台的外壳。泛型,驱动任何符合清单的游戏。
3
+
4
+ 它把游戏当黑盒:mount 拿到 GameInstance,读它的 status 控制自己的按钮,
5
+ 调 present() 让它演。游戏那边完全不知道这个文件存在。
6
+ 默认服务端是本地表模型:浏览器里就地建表、按权重抽行、restore 还原——
7
+ 和真平台同一条路,restore 契约违反在这里就会被拦下。
8
+
9
+ 机台的样子和玩家站点的对局页是同一份:同一套类名(.cab / .panel-col / .stage / .bar),
10
+ 样式全部来自共享的 theme.css,舞台也是同一块 900×771 的逻辑画面整体缩放。
11
+ 只有游戏的挂载方式不同——那边是 sandbox iframe,这边直接挂进 .gameframe 这个 div。
12
+ 开发专用的东西(历史、校验、公平证、赔率、定点复现)放在机台下面,不挤进机台里。 */
13
+ import { onMount } from 'svelte';
14
+ import { STAGE_H, STAGE_W, type GameInstance, type GameModule } from '../sdk/index.ts';
15
+ import { createPlatform, type HistoryEntry, type PlatformOf } from './platform.svelte.ts';
16
+ import type { PlatformServer } from './platform-server.ts';
17
+ import type { LocalServer } from './local.ts';
18
+ import { setPlatform } from './context.svelte.ts';
19
+ import SchemaFields from './SchemaFields.svelte';
20
+ import Field from './Field.svelte';
21
+
22
+ let { game, title, note, embedded = false, server, onSaveCase, onPlatform }: {
23
+ game: GameModule<C, O>;
24
+ title?: string;
25
+ note?: string;
26
+ /** 嵌在 studio 的 iframe 里:不画页眉,余额由外面的 Header 显示 */
27
+ embedded?: boolean;
28
+ /** 不传就是本地表模型。传 createRemotePlatform(...) 就接到真平台上 */
29
+ server?: PlatformServer<C, O>;
30
+ /** studio 接上它就能把一局存成 case */
31
+ onSaveCase?: (h: HistoryEntry, title: string) => Promise<string>;
32
+ /** 平台实例建好了交出去。studio 的总线要拿它驱动预览(MCP 的 preview_* 工具) */
33
+ onPlatform?: (p: PlatformOf<C, O>) => void;
34
+ } = $props();
35
+
36
+ /* 只取初始值是故意的:一个平台实例对应一个游戏,换游戏应当整个重挂
37
+ (外面用 {#key game} 包住),而不是让平台在运行中换掉脚下的逻辑 */
38
+ // svelte-ignore state_referenced_locally
39
+ const p = createPlatform<C, O>(game, server);
40
+ setPlatform(p);
41
+ // svelte-ignore state_referenced_locally
42
+ onPlatform?.(p);
43
+
44
+ let stage = $state<HTMLElement>();
45
+ let frame = $state<HTMLElement>();
46
+ let inst = $state<GameInstance<O> | null>(null);
47
+ // svelte-ignore state_referenced_locally
48
+ let betText = $state((p.bet / 100).toFixed(2));
49
+ let tab = $state<'history' | 'fair' | 'proof' | 'pay' | 'skins' | 'replay'>('history');
50
+ let seedText = $state('');
51
+ let simIdText = $state('');
52
+ let caseTitle = $state('');
53
+ let caseMsg = $state<string | null>(null);
54
+
55
+ const money = (minor: number) =>
56
+ (minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
57
+ const signed = (minor: number) => `${minor >= 0 ? '+' : '−'}${money(Math.abs(minor))}`;
58
+ const t = (m: Record<string, string> | undefined) => (m ? Object.values(m)[0] : '');
59
+ const local = $derived(p.server as Partial<LocalServer<C, O>>);
60
+ const tableStats = $derived(p.tableMode ? local.table?.(p.tableMode)?.stats ?? null : null);
61
+
62
+ onMount(() => {
63
+ let live = true;
64
+ let off: (() => void) | undefined;
65
+ let handle: GameInstance<O> | undefined;
66
+
67
+ /* 和玩家站点的 GameFrame 同一条缩放规则:容器 CSS 尺寸恒为 STAGE_W×STAGE_H,
68
+ 按舞台宽度算一个倍率。舞台自己是黄金比例,高度自然对得上。
69
+ 观察的是舞台而不是窗口:机台在窄屏下会重排,同样的窗口宽度可能对应两种舞台宽度 */
70
+ const fit = (): void => {
71
+ const w = stage?.clientWidth ?? 0;
72
+ if (frame && w > 0) frame.style.transform = `scale(${w / STAGE_W})`;
73
+ };
74
+ const ro = new ResizeObserver(fit);
75
+ ro.observe(stage!);
76
+ fit();
77
+
78
+ void (async () => {
79
+ // 先和服务端握手:余额、种子承诺、注额区间都在那边
80
+ await p.init();
81
+ const i = await game.mount(p.host(), frame!);
82
+ if (!live) { i.destroy(); return; }
83
+ handle = i; inst = i;
84
+ off = i.status.subscribe(s => p.setGameStatus(s));
85
+ p.bindGame({ present: (r, rate) => i.present(r, rate ? { rate } : undefined), status: () => p.game });
86
+ })();
87
+
88
+ /* 键盘归平台:游戏不再往 document 上挂监听,否则同页两个游戏会一起响应 */
89
+ const onKey = (e: KeyboardEvent) => {
90
+ const tg = e.target as HTMLElement | null;
91
+ if (e.key !== ' ' || e.metaKey || e.ctrlKey || e.altKey) return;
92
+ if (tg && (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA')) return;
93
+ e.preventDefault();
94
+ if (e.repeat) return;
95
+ if (p.game.busy) inst?.skip();
96
+ else if (p.canBet) void p.playOne();
97
+ };
98
+ document.addEventListener('keydown', onKey);
99
+
100
+ return () => {
101
+ live = false;
102
+ ro.disconnect();
103
+ document.removeEventListener('keydown', onKey);
104
+ off?.();
105
+ handle?.destroy();
106
+ };
107
+ });
108
+
109
+ const commitBet = () => {
110
+ const v = parseFloat(betText.replace(/,/g, ''));
111
+ p.setBet(!isFinite(v) || v < 0 ? p.limits.min : Math.round(v * 100));
112
+ betText = (p.bet / 100).toFixed(2);
113
+ };
114
+ const scaleBet = (f: number) => { p.setBet(p.bet * f); betText = (p.bet / 100).toFixed(2); };
115
+
116
+ const replaySeed = () => {
117
+ const m = /^(.+?):(.+?):(\d+)$/.exec(seedText.trim());
118
+ if (!m) { p.err = '种子格式:server:client:nonce'; return; }
119
+ void p.playOne({ seed: { server: m[1]!, client: m[2]!, nonce: Number(m[3]) } });
120
+ };
121
+ const replaySim = () => { const n = parseInt(simIdText, 10); if (Number.isInteger(n)) void p.playOne({ simId: n }); };
122
+ const saveCase = async (h: HistoryEntry) => {
123
+ if (!onSaveCase) return;
124
+ caseMsg = null;
125
+ try { caseMsg = `已存为 case ${await onSaveCase(h, caseTitle || `${h.mode} ${h.multiplier.toFixed(2)}x`)}`; caseTitle = ''; }
126
+ catch (e) { caseMsg = `存 case 失败:${e instanceof Error ? e.message : String(e)}`; }
127
+ };
128
+ </script>
129
+
130
+ <div class="shell" class:embedded>
131
+ {#if !embedded}
132
+ <header class="top">
133
+ <span class="brand big">GameKit</span>
134
+ <span class="dim small">参考平台</span>
135
+ <div class="grow"></div>
136
+ <span class="bal">{money(p.balance)}</span>
137
+ <span class="small" class:win={p.pnl > 0} class:bad={p.pnl < 0}>本场 {signed(p.pnl)}</span>
138
+ </header>
139
+ <div class="intro">
140
+ <h1>{title ?? p.spec.title}</h1>
141
+ <p>{note ?? '这是参考平台:设置由游戏 schema 自动渲染,下注走本地表模型——浏览器里就地建表、按权重抽行、restore 还原,和真平台同一条路。'}</p>
142
+ </div>
143
+ {/if}
144
+
145
+ <div class="cab" data-mode={p.mode}>
146
+ <!-- 左:下注与参数 -->
147
+ <section class="panel-col">
148
+ <div class="panel-inner">
149
+ <div class="tabs" role="tablist">
150
+ <button role="tab" aria-selected={p.mode === 'manual'} disabled={p.game.busy || p.autoActive}
151
+ onclick={() => p.mode = 'manual'}>手动</button>
152
+ <button role="tab" aria-selected={p.mode === 'auto'} disabled={p.game.busy || p.autoActive}
153
+ onclick={() => p.mode = 'auto'}>自动</button>
154
+ </div>
155
+
156
+ <Field hint={`区间 ${money(p.limits.min)}–${money(p.limits.max)},步长 ${money(p.limits.step)}`}>
157
+ {#snippet label()}<label for="bet">注额</label>{/snippet}
158
+ {#snippet accent()}
159
+ <span>本场 <b class="acc" class:win={p.pnl > 0} class:bad={p.pnl < 0}>{signed(p.pnl)}</b></span>
160
+ {/snippet}
161
+ <span class="coin" aria-hidden="true"></span>
162
+ <input id="bet" inputmode="decimal" bind:value={betText} onchange={commitBet} disabled={!p.canBet} />
163
+ <button type="button" aria-label="减半" disabled={!p.canBet} onclick={() => scaleBet(0.5)}>½</button>
164
+ <button type="button" aria-label="加倍" disabled={!p.canBet} onclick={() => scaleBet(2)}>2×</button>
165
+ </Field>
166
+
167
+ {#if p.mode === 'auto'}
168
+ <Field>
169
+ {#snippet label()}<label for="rounds">自动局数</label>{/snippet}
170
+ {#snippet accent()}<span>0 = 不限</span>{/snippet}
171
+ <input id="rounds" type="number" min="0" max="1000" disabled={p.autoActive}
172
+ value={p.autoActive ? p.autoLeft : p.autoRounds}
173
+ onchange={e => { p.autoRounds = Math.max(0, parseInt(e.currentTarget.value, 10) || 0);
174
+ e.currentTarget.value = String(p.autoRounds); }} />
175
+ </Field>
176
+ <button class="cta cash" type="button" disabled={!p.ready0 || !p.spec.autoplay}
177
+ onclick={() => p.autoActive ? p.stopAuto() : p.startAuto()}>
178
+ {#if p.autoActive}停止<span class="n">({p.autoLeft})</span>{:else if p.spec.autoplay}开始自动{:else}这个游戏不支持自动{/if}
179
+ </button>
180
+ {:else if p.game.busy}
181
+ <button class="cta" type="button" disabled={!p.game.canSkip} onclick={() => inst?.skip()}>
182
+ {p.game.canSkip ? '直接看结果' : '进行中'}
183
+ </button>
184
+ {:else}
185
+ <button class="cta" type="button" disabled={!p.canBet} onclick={() => p.playOne()}>
186
+ 下注 <span class="n">{money(p.bet)}</span>
187
+ </button>
188
+ {/if}
189
+
190
+ {#if p.err}<p class="err small">{p.err}</p>{/if}
191
+
192
+ <hr />
193
+ <!-- 只有这一块滚:注额和下注按钮得一直够得着,参数再多也不该把它们顶出视野 -->
194
+ <div class="params">
195
+ <SchemaFields />
196
+ {#each p.issues as i (i.field)}
197
+ <p class="bad small">{Object.values(i.message)[0]}</p>
198
+ {/each}
199
+ {#if p.tableMode}
200
+ <p class="hint">表 <span class="mono">{p.tableMode}</span>{#if tableStats} · 实算 RTP {(tableStats.computedRtpPpm / 1e4).toFixed(2)}% · 命中 {(tableStats.hitRatePpm / 1e4).toFixed(2)}% · 最大 {(tableStats.maxPayoutCenti / 100).toFixed(2)}x{:else if !p.remote} · 第一注时就地建表{/if}</p>
201
+ {/if}
202
+ </div>
203
+ </div>
204
+ </section>
205
+
206
+ <!-- 右:游戏画布。容器尺寸恒为逻辑分辨率,onMount 里按舞台宽度整体缩放 -->
207
+ <section class="stage" style="--ratio:{STAGE_W}/{STAGE_H}" bind:this={stage}>
208
+ <div class="gameframe" bind:this={frame} style="width:{STAGE_W}px;height:{STAGE_H}px"></div>
209
+ {#if p.game.phase === 'loading'}
210
+ <p class="loading">加载资源… {Math.round((p.game.progress ?? 0) * 100)}%</p>
211
+ {/if}
212
+ </section>
213
+
214
+ <!-- 底:全机台的设置条 -->
215
+ <footer class="bar">
216
+ <div class="tg">
217
+ <button type="button" aria-pressed={p.view.turbo}
218
+ onclick={() => p.setView({ turbo: !p.view.turbo })}>
219
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z" /></svg>
220
+ 快速模式
221
+ </button>
222
+ <button type="button" aria-pressed={p.view.sound}
223
+ onclick={() => p.setView({ sound: !p.view.sound })}>
224
+ {#if p.view.sound}
225
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 8a5 5 0 0 1 0 8" /></svg>
226
+ {:else}
227
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 10l4 4M21 10l-4 4" /></svg>
228
+ {/if}
229
+ 音效
230
+ </button>
231
+ <label class="seed">客户端种子
232
+ <input bind:value={p.clientSeed} disabled={p.autoActive} /></label>
233
+ {#if p.server.refill}
234
+ <button type="button" onclick={() => p.refill()}>重置余额</button>
235
+ {/if}
236
+ </div>
237
+ <span class="brand">GameKit</span>
238
+ </footer>
239
+ </div>
240
+
241
+ <!-- 机台下面:开发者要看的东西。玩家站点这里放的是最近对局和这局用哪张表 -->
242
+ <section class="panel dev">
243
+ <div class="tabs" role="tablist">
244
+ <button role="tab" aria-selected={tab === 'history'} onclick={() => tab = 'history'}>历史 {p.history.length}</button>
245
+ <button role="tab" aria-selected={tab === 'fair'} onclick={() => tab = 'fair'}>校验</button>
246
+ <button role="tab" aria-selected={tab === 'proof'} onclick={() => tab = 'proof'}>公平证</button>
247
+ <button role="tab" aria-selected={tab === 'pay'} onclick={() => tab = 'pay'}>赔率</button>
248
+ {#if game.skins?.length}<button role="tab" aria-selected={tab === 'skins'} onclick={() => tab = 'skins'}>造型</button>{/if}
249
+ {#if !p.remote}<button role="tab" aria-selected={tab === 'replay'} onclick={() => tab = 'replay'}>定点复现</button>{/if}
250
+ </div>
251
+
252
+ {#if tab === 'history'}
253
+ {#if onSaveCase && p.history.length}
254
+ <div class="row">
255
+ <div class="inp grow"><input placeholder="case 标题(可空,存的时候用)" bind:value={caseTitle} /></div>
256
+ </div>
257
+ {#if caseMsg}<p class="hint">{caseMsg}</p>{/if}
258
+ {/if}
259
+ <table>
260
+ <thead><tr><th>#</th><th>表</th><th class="num">倍数</th><th class="num">盈亏</th><th>校验</th>{#if onSaveCase}<th></th>{/if}</tr></thead>
261
+ <tbody>
262
+ {#each p.history as h (h.n)}
263
+ <tr>
264
+ <td class="num">{h.n}</td>
265
+ <td class="mono">{h.mode}</td>
266
+ <td class="num" class:win={h.payout > h.bet}>{h.multiplier.toFixed(2)}x</td>
267
+ <td class="num" class:win={h.payout > h.bet} class:lose={h.payout < h.bet}>{signed(h.payout - h.bet)}</td>
268
+ <td class:win={h.checks.every((c) => c.ok)} class:bad={!h.checks.every((c) => c.ok)}>{h.checks.every((c) => c.ok) ? '✓' : '✗'}</td>
269
+ {#if onSaveCase}<td><button type="button" class="ghostbtn tiny" onclick={() => saveCase(h)}>存为 case</button></td>{/if}
270
+ </tr>
271
+ {:else}
272
+ <tr><td colspan="6" class="dim">还没演过。打一注,它就会出现在这里。</td></tr>
273
+ {/each}
274
+ </tbody>
275
+ </table>
276
+ {:else if tab === 'fair'}
277
+ {#each p.checks as c, i (i)}
278
+ <div class="chk small">
279
+ <span class:win={c.ok} class:bad={!c.ok}>{c.ok ? '✓' : '✗'}</span>
280
+ <span class="grow">{c.label}</span>
281
+ {#if c.detail}<span class="dim mono">{c.detail}</span>{/if}
282
+ </div>
283
+ {:else}
284
+ <p class="dim small">还没有下过注。下一注之后这里会列出每一步的校验结论。</p>
285
+ {/each}
286
+ {:else if tab === 'proof'}
287
+ <pre>{p.proof ? JSON.stringify(p.proof, null, 2) : '还没演过'}</pre>
288
+ {:else if tab === 'pay'}
289
+ <table>
290
+ <thead><tr><th>组合</th><th class="num">倍数</th></tr></thead>
291
+ <tbody>
292
+ {#each [...p.paytable].reverse() as row, i (i)}
293
+ <tr><td>{t(row.label)}</td><td class="num">{row.mult.toFixed(2)}x</td></tr>
294
+ {/each}
295
+ </tbody>
296
+ </table>
297
+ {:else if tab === 'skins'}
298
+ <!-- 造型由模块的 skins 声明。没声明的游戏这一页整个不出现 -->
299
+ <div class="skins">
300
+ {#each game.skins ?? [] as sk (sk.value)}
301
+ <button type="button" aria-label={t(sk.label)}
302
+ aria-pressed={p.view.skin === sk.value}
303
+ onclick={() => p.setView({ skin: sk.value })}>
304
+ {#if sk.asset}<img alt="" src={sk.asset} />{:else}<span>{t(sk.label)}</span>{/if}
305
+ </button>
306
+ {/each}
307
+ </div>
308
+ {:else if tab === 'replay'}
309
+ <div class="row">
310
+ <div class="inp grow"><input placeholder="server:client:nonce" bind:value={seedText} /></div>
311
+ <button type="button" class="ghostbtn" disabled={!p.canBet} onclick={replaySeed}>按种子演</button>
312
+ </div>
313
+ <div class="row">
314
+ <div class="inp grow"><input placeholder="simId" inputmode="numeric" bind:value={simIdText} /></div>
315
+ <button type="button" class="ghostbtn" disabled={!p.canBet} onclick={replaySim}>演表里第 N 行</button>
316
+ </div>
317
+ <p class="hint">{p.spec.slug} · 目标 RTP {(p.spec.rtp * 100).toFixed(2)}%</p>
318
+ {/if}
319
+ </section>
320
+ </div>
321
+
322
+ <style>
323
+ /* 只写这一页自己的布局。机台、表单、按钮的样式全在共享的 theme.css 里 */
324
+ .shell { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding-bottom: 48px; }
325
+ /* 嵌在 studio 里:外面已经决定了这一栏多宽,这里顶满、不留白 */
326
+ .shell.embedded { width: 100%; padding-bottom: 16px; }
327
+ .top { display: flex; align-items: center; gap: 12px; padding: 14px 4px 0; }
328
+ .top .big { font-size: 22px; color: var(--ink); }
329
+ .intro { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; margin: 0 4px 14px; }
330
+ .intro p { margin: 0; opacity: .78; max-width: 46em; }
331
+ .dev { margin-top: 18px; display: flex; flex-direction: column; gap: 12px; }
332
+ .dev .tabs { grid-auto-columns: minmax(0, 1fr); }
333
+ .seed { display: inline-flex; align-items: center; gap: 6px; color: var(--dim); font-size: 13px; }
334
+ .seed input {
335
+ border: 1px solid var(--line); background: var(--field); border-radius: 8px;
336
+ padding: 3px 8px; font-family: var(--mono); font-size: 12px; width: 11em;
337
+ }
338
+ .tiny { min-height: 28px; padding: 0 10px; font-size: 12px; }
339
+ .chk { display: flex; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
340
+ .chk:last-of-type { border-bottom: 0; }
341
+ .chk .mono { word-break: break-all; }
342
+ pre { font-family: var(--mono); font-size: 11px; line-height: 1.35; max-height: 40vh; overflow: auto; background: var(--field); padding: 10px; border-radius: 12px; margin: 0; }
343
+ .skins { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 8px; }
344
+ .skins button { border: 0; background: none; padding: 0; border-radius: 12px; overflow: hidden; line-height: 0; }
345
+ .skins img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; opacity: .55; transition: opacity .15s; }
346
+ .skins button[aria-pressed="true"] img { opacity: 1; box-shadow: 0 0 0 2px var(--accent-ink); }
347
+ .skins span { display: block; font-size: 12px; padding: 8px 4px; line-height: 1.2; background: var(--field); border-radius: 12px; }
348
+ </style>
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ /* 面板里的一行表单。结构和类名与玩家站点的注额栏完全一致(.field / .lab / .inp / .hint),
3
+ 样式全部来自共享的 theme.css——这里一条都不写,否则两边迟早分叉 */
4
+ import type { Snippet } from 'svelte';
5
+
6
+ let { label, accent, hint, children, extra }: {
7
+ label?: string | Snippet;
8
+ accent?: string | Snippet;
9
+ hint?: string;
10
+ children?: Snippet;
11
+ extra?: Snippet;
12
+ } = $props();
13
+ </script>
14
+
15
+ <div class="field">
16
+ {#if label || accent}
17
+ <div class="lab">
18
+ {#if typeof label === 'string'}<span>{label}</span>{:else if label}{@render label()}{:else}<span></span>{/if}
19
+ {#if typeof accent === 'string'}<span class="acc">{accent}</span>{:else if accent}{@render accent()}{/if}
20
+ </div>
21
+ {/if}
22
+ {#if children}<div class="inp">{@render children()}</div>{/if}
23
+ {#if hint}<p class="hint">{hint}</p>{/if}
24
+ {@render extra?.()}
25
+ </div>
@@ -0,0 +1,14 @@
1
+ <script lang="ts" generics="C extends object, O">
2
+ /* 从 context 取参考平台的状态,委托给纯受控的 SchemaFieldsPure 渲染。
3
+ 控件的形状和类名因此与玩家站点逐个相同——那边用的就是同一个组件 */
4
+ import type { ConfigSchema } from '../sdk/index.ts';
5
+ import { getPlatform } from './context.svelte.ts';
6
+ import SchemaFieldsPure from './SchemaFieldsPure.svelte';
7
+
8
+ const p = getPlatform<C, O>();
9
+ const schema = $derived(p.spec.schema as ConfigSchema<C>);
10
+ </script>
11
+
12
+ <SchemaFieldsPure {schema} config={p.config} issues={p.issues}
13
+ inRound={p.game.busy} inAuto={p.autoActive}
14
+ set={(patch) => p.requestConfig(patch)} />
@@ -0,0 +1,98 @@
1
+ <script lang="ts" generics="C extends object">
2
+ /* 按 schema 自动渲染参数控件——纯受控版:schema / config / issues 全从 props 进,不碰 context。
3
+ apps/web 和 studio 都用它;dev-host 自己那份 SchemaFields.svelte 是从 context 取值再委托到这里。
4
+
5
+ 这段代码不认识任何具体游戏:只认识 kind(int / bool / enum / pick)和
6
+ lock / devOnly / ui 这些标记。schema 是游戏从 iframe 里推过来的纯数据,
7
+ 所以「平台渲染游戏的设置面板」这件事完全不需要平台执行游戏的代码。 */
8
+ import { editable, fieldsFor, type ConfigIssue, type ConfigSchema } from '../sdk/index.ts';
9
+
10
+ let { schema, config, issues, inRound, inAuto, set }: {
11
+ schema: ConfigSchema<C>;
12
+ config: C;
13
+ issues: ConfigIssue[];
14
+ inRound: boolean;
15
+ inAuto: boolean;
16
+ set: (patch: Partial<C>) => void;
17
+ } = $props();
18
+
19
+ /* 只渲染声明为在平台侧渲染的字段。ui:'stage' 的由游戏自己在画布内画,
20
+ 改值同样要走一趟 requestConfig */
21
+ const keys = $derived(fieldsFor(schema, 'host'));
22
+ const can = (k: keyof C): boolean => editable(schema, k, { inRound, inAuto });
23
+ const t = (m: Record<string, string> | undefined): string | undefined =>
24
+ m ? Object.values(m)[0] : undefined;
25
+ const issueOf = (k: string): string | undefined =>
26
+ t(issues.find((i) => i.field === k)?.message);
27
+ const put = (k: keyof C, v: unknown): void => set({ [k]: v } as Partial<C>);
28
+ </script>
29
+
30
+ {#each keys as k (k)}
31
+ {@const f = schema[k]}
32
+ <div class="field">
33
+ {#if f.kind === 'bool'}
34
+ <button class="switch" type="button" role="switch"
35
+ aria-checked={!!config[k]} disabled={!can(k)} onclick={() => put(k, !config[k])}>
36
+ <span>
37
+ <b>{t(f.label)}{#if f.devOnly}<em>仅开发</em>{/if}</b>
38
+ {#if f.hint}<small>{t(f.hint)}</small>{/if}
39
+ </span>
40
+ <i></i>
41
+ </button>
42
+
43
+ {:else if f.kind === 'int' || f.kind === 'number'}
44
+ <div class="lab">
45
+ <label for="f-{String(k)}">{t(f.label)}</label>
46
+ {#if issueOf(String(k))}<span class="acc">{issueOf(String(k))}</span>{/if}
47
+ </div>
48
+ <div class="inp">
49
+ <input id="f-{String(k)}" inputmode="numeric" value={config[k]}
50
+ min={f.min} max={f.max} disabled={!can(k)}
51
+ onchange={(e) => put(k, parseFloat(e.currentTarget.value))} />
52
+ <button type="button" aria-label="减" disabled={!can(k)}
53
+ onclick={() => put(k, (config[k] as number) - (f.step ?? 1))}>−</button>
54
+ <button type="button" aria-label="加" disabled={!can(k)}
55
+ onclick={() => put(k, (config[k] as number) + (f.step ?? 1))}>+</button>
56
+ </div>
57
+ {#if f.hint}<p class="hint">{t(f.hint)}</p>{/if}
58
+
59
+ {:else if f.kind === 'enum'}
60
+ <div class="lab"><span>{t(f.label)}</span></div>
61
+ <div class="inp">
62
+ <select disabled={!can(k)} onchange={(e) => put(k, e.currentTarget.value)}>
63
+ {#each f.options as o (o.value)}
64
+ <option value={o.value} selected={config[k] === o.value}>{t(o.label)}</option>
65
+ {/each}
66
+ </select>
67
+ </div>
68
+ {#if f.hint}<p class="hint">{t(f.hint)}</p>{/if}
69
+
70
+ {:else if f.kind === 'pick'}
71
+ <div class="lab">
72
+ <span>{t(f.label)}(选 {f.count} 个)</span>
73
+ {#if issueOf(String(k))}<span class="acc">{issueOf(String(k))}</span>{/if}
74
+ </div>
75
+ <div class="chips">
76
+ {#each f.of as o (o.value)}
77
+ {@const on = (config[k] as unknown[]).includes(o.value)}
78
+ <button type="button" class:on disabled={!can(k)}
79
+ onclick={() => {
80
+ const cur = [...(config[k] as number[])];
81
+ const at = cur.indexOf(o.value as number);
82
+ if (at >= 0) cur.splice(at, 1);
83
+ else { if (cur.length >= f.count) cur.shift(); cur.push(o.value as number); }
84
+ put(k, cur);
85
+ }}>{t(o.label)}</button>
86
+ {/each}
87
+ </div>
88
+ {#if f.hint}<p class="hint">{t(f.hint)}</p>{/if}
89
+ {/if}
90
+ </div>
91
+ {/each}
92
+
93
+ <style>
94
+ .switch em {
95
+ font-style: normal; font-size: 10px; color: #E0A800;
96
+ margin-left: 6px; border: 1px solid; border-radius: 4px; padding: 0 4px;
97
+ }
98
+ </style>
@@ -0,0 +1,9 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import type { PlatformOf } from './platform.svelte.ts';
3
+
4
+ const KEY = Symbol('gamekit-dev-platform');
5
+
6
+ /* 用 context 而不是模块级单例:同一个页面可以挂两个不同的游戏,
7
+ 各自一套平台状态。这也是游戏侧最终要走的路(阶段 9 的工厂化) */
8
+ export const setPlatform = <C, O>(p: PlatformOf<C, O>): void => { setContext(KEY, p); };
9
+ export const getPlatform = <C, O>(): PlatformOf<C, O> => getContext(KEY);
@@ -0,0 +1,16 @@
1
+ /* 平台自己的字体(标题、数字、正文)。运行时注入而不是写进每个游戏的 index.html:
2
+ `bun run dev` 用的是游戏目录里的 html,studio 的两个页面从内存吐,三处都不该各抄一份链接 */
3
+ const HREF = 'https://fonts.googleapis.com/css2?family=Lilita+One&family=Noto+Sans+SC:wght@400;500;700&family=ZCOOL+KuaiLe&display=swap';
4
+
5
+ export function ensureHostFonts(): void {
6
+ if (typeof document === 'undefined' || document.querySelector(`link[href="${HREF}"]`)) return;
7
+ for (const href of ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']) {
8
+ const l = document.createElement('link');
9
+ l.rel = 'preconnect'; l.href = href;
10
+ if (href.includes('gstatic')) l.crossOrigin = 'anonymous';
11
+ document.head.appendChild(l);
12
+ }
13
+ const l = document.createElement('link');
14
+ l.rel = 'stylesheet'; l.href = HREF;
15
+ document.head.appendChild(l);
16
+ }
@@ -0,0 +1,8 @@
1
+ export * from './platform-server.ts';
2
+ export * from './table-server.ts';
3
+ export * from './local.ts';
4
+ export * from './remote.ts';
5
+ export * from './platform.svelte.ts';
6
+ export * from './context.svelte.ts';
7
+ export { runDevShell, type RunOptions } from './run.ts';
8
+ export { ensureHostFonts } from './fonts.ts';
@@ -0,0 +1,69 @@
1
+ /* 开发期的本地服务端:浏览器里就地建表,再按权重抽行。
2
+ *
3
+ * 表模型下 round() 只在采样时跑。开发期用小样本(每 mode 一两千局,几十毫秒),
4
+ * 表按 mode 懒建:第一次下到哪张就建哪张。改了规则刷新页面就是新表——
5
+ * 没有缓存要失效,也就不会拿旧表演新逻辑。
6
+ *
7
+ * studio 想用建好的正式产物时,通过 external 把表喂进来;喂不到的 mode 照样就地建。 */
8
+ import { buildTable } from '../publish/build.ts';
9
+ import type { GameDefinition } from '../sdk/index.ts';
10
+ import { DEFAULT_BALANCE, type PlatformServer, type WalletRef } from './platform-server.ts';
11
+ import { parseTable, tableServer, type LoadedTable } from './table-server.ts';
12
+
13
+ export interface LocalOptions<C, O> {
14
+ spec: GameDefinition<C, O>;
15
+ /** 就地建表时每张表采样多少局。默认 2000——开发期够看分布,几十毫秒建完 */
16
+ sims?: number;
17
+ balance?: number;
18
+ /** 外部喂进来的表(studio 的正式产物)。有就优先用 */
19
+ external?: () => Promise<ReadonlyMap<string, LoadedTable>>;
20
+ onBuild?: (mode: string, millis: number) => void;
21
+ }
22
+
23
+ export interface LocalServer<C, O> extends PlatformServer<C, O> {
24
+ /** 已经加载或建好的表,面板显示统计用 */
25
+ table(mode: string): LoadedTable | undefined;
26
+ modes(): string[];
27
+ }
28
+
29
+ export function localServer<C, O>(o: LocalOptions<C, O>): LocalServer<C, O> {
30
+ const spec = o.spec;
31
+ const sims = o.sims ?? 2_000;
32
+ const wallet: WalletRef = { balance: o.balance ?? DEFAULT_BALANCE };
33
+ const tables = new Map<string, LoadedTable>();
34
+ const building = new Map<string, Promise<void>>();
35
+
36
+ const build = (mode: string): Promise<void> => {
37
+ let p = building.get(mode);
38
+ if (p) return p;
39
+ p = (async () => {
40
+ const mi = spec.modes.findIndex((m) => m.name === mode);
41
+ if (mi < 0) throw new Error(`清单里没有 mode ${mode}`);
42
+ const t0 = Date.now();
43
+ const built = await buildTable({ ...spec, sims }, mi);
44
+ tables.set(mode, parseTable(mode, built.bytes, built.books));
45
+ o.onBuild?.(mode, Date.now() - t0);
46
+ })().finally(() => building.delete(mode));
47
+ building.set(mode, p);
48
+ return p;
49
+ };
50
+
51
+ const reload = async (): Promise<void> => {
52
+ tables.clear();
53
+ if (o.external) for (const [k, v] of await o.external()) tables.set(k, v);
54
+ };
55
+
56
+ const inner = tableServer<C, O>({
57
+ spec, wallet,
58
+ tables: () => tables,
59
+ ensure: (mode) => (tables.has(mode) ? Promise.resolve() : build(mode)),
60
+ reload,
61
+ });
62
+
63
+ return {
64
+ ...inner,
65
+ init: async () => { await reload(); return inner.init(); },
66
+ table: (mode) => tables.get(mode),
67
+ modes: () => [...tables.keys()],
68
+ };
69
+ }