gamekit777 0.1.3 → 0.1.5
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 +1 -1
- package/client/http.ts +1 -1
- package/client/index.ts +26 -0
- package/create-game/template/CLAUDE.md.tmpl +38 -39
- package/create-game/template/src/index.ts.tmpl +5 -3
- package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
- package/create-game/template/vitest.config.ts +1 -1
- package/dev-host/DevShell.svelte +23 -2
- package/dev-host/platform.svelte.ts +4 -1
- package/dev-host/theme.css +5 -3
- package/package.json +1 -1
- package/protocol/assets.ts +108 -0
- package/protocol/bundles.ts +74 -0
- package/protocol/errors.ts +6 -0
- package/protocol/games.ts +5 -0
- package/protocol/index.ts +2 -0
- package/publish/upload-core.ts +95 -15
- package/publish/upload.ts +17 -1
- package/runtime/assets.ts +19 -15
- package/runtime/fonts.ts +1 -0
- package/runtime/index.ts +2 -0
- package/runtime/preload.ts +38 -0
- package/runtime/registry.ts +74 -0
- package/runtime/types.ts +3 -25
- package/sdk/assets.ts +24 -0
- package/sdk/host.ts +0 -2
- package/sdk/index.ts +1 -0
- package/sdk/spec.ts +3 -0
- package/studio/app/App.svelte +43 -14
- package/studio/app/lib/api.ts +11 -3
- package/studio/app/lib/bus.svelte.ts +2 -0
- package/studio/app/lib/identity.svelte.ts +4 -1
- package/studio/app/panels/AssetsPanel.svelte +239 -0
- package/studio/app/panels/PublishPanel.svelte +44 -51
- package/studio/bin.ts +2 -0
- package/studio/src/api.ts +20 -7
- package/studio/src/assets.ts +160 -0
- package/studio/src/bus.ts +2 -1
- package/studio/src/engine.ts +237 -18
- package/studio/src/game-vite.ts +11 -2
- package/studio/src/mcp.ts +68 -28
- package/studio/src/session.ts +37 -0
- package/studio/src/studio-plugin.ts +16 -0
- package/studio/src/tasks.ts +4 -1
- package/vite-config/index.js +160 -35
- package/vite-config/index.ts +5 -1
- package/vite-config/manifest.ts +16 -2
- package/vite-config/namespace-css.ts +130 -43
- package/create-game/template/src/assets/manifest.ts +0 -30
- package/create-game/template/src/assets/registry.ts +0 -23
- package/studio/app/lib/upload.ts +0 -34
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ studio 同时是 MCP server、创作页面(预览 / 调参 / 建表 / Case /
|
|
|
36
36
|
| import | 内容 |
|
|
37
37
|
|---|---|
|
|
38
38
|
| `gamekit777/sdk` | `defineGame`、契约类型、可证明公平的 RNG、SHA-256 / HMAC、加权抽样原语 |
|
|
39
|
-
| `gamekit777/runtime` |
|
|
39
|
+
| `gamekit777/runtime` | 资源注册表(目录即清单)、平台侧预加载器、字体注入 |
|
|
40
40
|
| `gamekit777/vite-config` | `defineGameConfig`:一个游戏的 `vite.config.ts` 只要三行 |
|
|
41
41
|
| `gamekit777/dev-host` | 参考平台与本地表模型服务端;`createRemotePlatform` 接真平台 |
|
|
42
42
|
| `gamekit777/protocol` · `client` | 平台 REST 契约(zod)与类型化客户端 |
|
package/client/http.ts
CHANGED
|
@@ -193,7 +193,7 @@ export class Http {
|
|
|
193
193
|
headers['content-type'] = 'application/json';
|
|
194
194
|
body = JSON.stringify(spec.json);
|
|
195
195
|
} else if (spec.body) {
|
|
196
|
-
headers['content-type']
|
|
196
|
+
headers['content-type'] ??= 'application/octet-stream'; // 调用方指定了就尊重(游戏包按扩展名给)
|
|
197
197
|
// 传 ArrayBuffer 而不是视图:subarray 出来的视图带 byteOffset,
|
|
198
198
|
// 有些 fetch 实现会连整个底层 buffer 一起发出去
|
|
199
199
|
body = spec.body.slice().buffer as ArrayBuffer;
|
package/client/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
GameKitError,
|
|
17
17
|
type ListGamesQuery, type ListRoundsQuery,
|
|
18
18
|
PAIR_CLAIM_PATH, PAIR_START_PATH,
|
|
19
|
+
type BundleCommitResult as TBundleCommitResult, BundleCommitResult,
|
|
20
|
+
type BundleFileResult as TBundleFileResult, BundleFileResult,
|
|
21
|
+
type BundleRef, bundleContentType, bundleManifestText,
|
|
19
22
|
type PairClaimResult as TPairClaimResult, PairClaimResult,
|
|
20
23
|
type PairStartResult as TPairStartResult, PairStartResult,
|
|
21
24
|
MAX_BOOKS_PER_CHUNK,
|
|
@@ -156,6 +159,29 @@ export function createClient(opts: ClientOptions) {
|
|
|
156
159
|
http.request({ method: 'GET', path: '/v1/me', schema: SessionView }),
|
|
157
160
|
},
|
|
158
161
|
|
|
162
|
+
/** 游戏包:vite build 的产物按内容寻址传到平台。upload 一把包完:算哈希 → 逐个 PUT → commit */
|
|
163
|
+
bundles: {
|
|
164
|
+
putFile: (gameId: string, hash: string, path: string, bytes: Uint8Array): Promise<TBundleFileResult> =>
|
|
165
|
+
http.request({
|
|
166
|
+
method: 'PUT', path: `/v1/games/${gameId}/bundle/${hash}/files/${path}`,
|
|
167
|
+
body: bytes, headers: { 'content-type': bundleContentType(path) }, schema: BundleFileResult,
|
|
168
|
+
}),
|
|
169
|
+
commit: (gameId: string, hash: string, files: { path: string; sha256: string; bytes: number }[]): Promise<TBundleCommitResult> =>
|
|
170
|
+
http.request({ method: 'POST', path: `/v1/games/${gameId}/bundle/${hash}/commit`, json: { files }, schema: BundleCommitResult }),
|
|
171
|
+
async upload(gameId: string, files: readonly { path: string; bytes: Uint8Array }[], onProgress?: (msg: string) => void): Promise<BundleRef> {
|
|
172
|
+
const manifest = files.map((f) => ({ path: f.path, sha256: new Sha256().update(f.bytes).hex(), bytes: f.bytes.length }));
|
|
173
|
+
const hash = new Sha256().update(new TextEncoder().encode(bundleManifestText(manifest))).hex();
|
|
174
|
+
for (const [i, f] of files.entries()) {
|
|
175
|
+
const r = await this.putFile(gameId, hash, f.path, f.bytes);
|
|
176
|
+
if (r.sha256 !== manifest[i]!.sha256) throw new GameKitError('BUNDLE_INVALID', `${f.path} 传过去的字节和本地不一致`);
|
|
177
|
+
onProgress?.(`[包] ${i + 1}/${files.length} ${f.path}(${f.bytes.length} 字节)`);
|
|
178
|
+
}
|
|
179
|
+
const { bundle } = await this.commit(gameId, hash, manifest);
|
|
180
|
+
onProgress?.(`[包] 已登记 ${bundle.hash.slice(0, 12)}…,${bundle.files} 个文件 ${bundle.bytes} 字节`);
|
|
181
|
+
return bundle;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
159
185
|
/** studio 配对:站点签 code,studio 拿 code 换一个同一用户的会话(cookie 落在 studio 的 origin) */
|
|
160
186
|
pair: {
|
|
161
187
|
start: (): Promise<TPairStartResult> =>
|
|
@@ -2,30 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
|
|
4
4
|
运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
|
|
5
|
-
所以你写的每一行逻辑都要满足「三处逐位一致」:作者机器采样、玩家浏览器还原、审计脚本复算。
|
|
6
5
|
|
|
7
6
|
先读这份文件,再读 `game.ts`。模板是一个**能跑的抛硬币**——从能跑的东西开始改,不要从零写。
|
|
8
7
|
|
|
8
|
+
## 一条原则:演出必须能回放
|
|
9
|
+
|
|
10
|
+
一局的画面只能是「表里那一行」的函数。同一行,在下注当场、在历史列表里点开、从分享链接进来、换一台设备,
|
|
11
|
+
演出来的必须是同一局:同样的牌、同样的连线、同样的赔付。下面所有约束都是这一条推出来的:
|
|
12
|
+
|
|
13
|
+
- **`round()` 是纯函数**,随机只来自注入的 rng。作者机器采样、玩家浏览器还原、审计脚本复算,三处逐位一致。
|
|
14
|
+
- **`restore()` 把一行还原成 `RoundResult`**,返回的 `payout` 必须恰好等于 `(bet / 100) × payoutCenti`,对不上就 `throw`、不演出。
|
|
15
|
+
- **`present(result)` 只看 `result`**,不看上一局、不看余额、不看玩家当下的设置。回放是在一个全新的实例里跑 `preview(result)`,那里什么都没有。
|
|
16
|
+
- **装饰性随机**(粒子角度、台词抽签)用 `Math.random()`,放在 `src/view` / 组件里,绝不进 `round()`:否则加一句台词就会改变牌序,历史局就回放不出来了。
|
|
17
|
+
|
|
18
|
+
「玩家看到连成三条线,余额却没动」「分享出去的一局,别人看到另一副牌」——这类 bug 完全静默、最伤信任,所以每一条都有工具兜着:
|
|
19
|
+
`scripts/verify.ts` 把 `Math.random` / `crypto` 换成会抛的桩,平台在演出前逐分比对 payout,`case_run` 盯任何结果漂移。
|
|
20
|
+
|
|
9
21
|
## 目录
|
|
10
22
|
|
|
11
23
|
```
|
|
12
24
|
game.ts 清单:defineGame({...})。Node 与浏览器都 import 它,不能碰 DOM / Svelte
|
|
13
25
|
src/rules/ 纯逻辑:schema、simulate、restore、常量、类型。零 DOM、零自取随机
|
|
14
|
-
src/index.ts 浏览器入口 = { ...game, mount }。平台加载的是它
|
|
26
|
+
src/index.ts 浏览器入口 = { ...game, mount, assets }。平台加载的是它
|
|
15
27
|
src/state/ 单局状态(Svelte runes 类),只放「演到哪一步了」
|
|
16
28
|
src/view/ mount(接平台)与 present(把结果播出来)
|
|
17
29
|
src/components/ 画面。子组件一律 getInstance(),不 import 状态对象
|
|
18
|
-
src/assets/
|
|
30
|
+
src/assets/ 只放文件:critical/ 首屏必需,lazy/ 后台预取。runtime 按目录生成清单,平台负责加载和进度
|
|
19
31
|
scripts/verify.ts 数学回归:纯度、确定性、RTP、分布
|
|
20
32
|
test/e2e.test.ts happy-dom 里跑完整一局
|
|
21
33
|
studio/cases.jsonl case 库(进版本控制),studio 维护
|
|
22
34
|
.studio/ 采样缓存与建好的表,按规则指纹分目录,可随手删
|
|
23
35
|
```
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
**改名**:只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`,
|
|
28
|
-
`game.ts` 从那里读。`package.json` 的 `name` 随意。已经发布过的 slug 不能改——那是另一个游戏。
|
|
37
|
+
**改名**只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`。
|
|
38
|
+
`gameId` 是平台第一次发布时分配并写回的身份,不要手改、不要删。已发布过的 slug 不能改——那是另一个游戏。
|
|
29
39
|
|
|
30
40
|
## 清单:`game.ts`
|
|
31
41
|
|
|
@@ -46,8 +56,8 @@ export default defineGame<Config, Outcome>({
|
|
|
46
56
|
});
|
|
47
57
|
```
|
|
48
58
|
|
|
49
|
-
`normalize` / `validate` / `modeOf` / `configOf`
|
|
50
|
-
|
|
59
|
+
`defineGame` 按 schema 和 modes 推导出 `normalize` / `validate` / `modeOf` / `configOf` 挂在返回值上;
|
|
60
|
+
自己要规整 config 时用 `normalizeBySchema(schema, config)`。注额区间是平台的事,清单里没有。
|
|
51
61
|
|
|
52
62
|
### `round(input)`
|
|
53
63
|
|
|
@@ -57,16 +67,12 @@ function round(input: RoundInput<Config>): RoundResult<Outcome>
|
|
|
57
67
|
// RoundResult = { bet, payout, multiplier, outcome, pacing: { durationMs, postDelayMs } }
|
|
58
68
|
```
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
1. **同步纯函数**。不碰 DOM、时间、全局状态;随机只来自 `createRng(input.seed)`。`Math.random` / `crypto` 一次都不能碰——`scripts/verify.ts` 会把它们换成会抛的桩。
|
|
70
|
+
1. **同步纯函数**。不碰 DOM、时间、全局状态;随机只来自 `createRng(input.seed)`。
|
|
63
71
|
2. **随机数抽取顺序是契约**。用 `rng.fork('tag')` 把子系统分开(发牌一条、金牌一条),将来改一处不会让另一处跟着漂。`below(n)` 是无偏整数,不要自己写 `% n`。
|
|
64
|
-
3.
|
|
72
|
+
3. **金额是整数分**。`multiplier` 最多两位小数(平台按 centi 存);`payout` 是 `bet` 的精确线性函数,取整方式不能随 bet 变(bet 恒为 100 的倍数)。
|
|
65
73
|
4. 先 `normalizeBySchema(schema, input.config)` 再用 config——调用方给的可能是部分或越界的值。
|
|
66
74
|
5. 平台有最大倍数上限,超过就进不了表。
|
|
67
75
|
|
|
68
|
-
装饰性随机(粒子角度、台词抽签)用 `Math.random()`,**放在 `src/view` / 组件里**,绝不进 `round()`:它们不该占用可复现的随机数,否则加一句台词就会改变牌序。
|
|
69
|
-
|
|
70
76
|
### `schema`
|
|
71
77
|
|
|
72
78
|
`ConfigSchema<Config>`,每个字段 = 一种 kind + 元数据 + `default`:
|
|
@@ -78,11 +84,11 @@ function round(input: RoundInput<Config>): RoundResult<Outcome>
|
|
|
78
84
|
| `enum` | `{ options: [{ value, label }] }` | 押哪一面 |
|
|
79
85
|
| `pick` | `{ of: Option[], count, exact?, ordered? }` | 13 选 4 个点数 |
|
|
80
86
|
|
|
81
|
-
元数据:`label`(`{'zh-CN': …}` 这种 I18n 记录)、`hint?`、`affectsOutcome`(影响结果的字段才进 schema;不影响的放 `ViewSettings
|
|
87
|
+
元数据:`label`(`{'zh-CN': …}` 这种 I18n 记录)、`hint?`、`affectsOutcome`(影响结果的字段才进 schema;不影响的放 `ViewSettings`),
|
|
82
88
|
`lock`(`'never' | 'inRound' | 'inRoundOrAuto'`)、`ui`(`'host'` 平台的设置面板渲染 / `'stage'` 你在画布里渲染,但值仍归平台)、
|
|
83
89
|
`devOnly?`(只在开发环境允许,生产被服务端拒绝)、`control?` / `group?` / `order?`(渲染建议)。
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
平台不认识你的游戏,只认识 kind——设置 UI 是按 schema 自动渲染的。
|
|
86
92
|
|
|
87
93
|
## 能不能打表:`modes` 与 `book`
|
|
88
94
|
|
|
@@ -95,7 +101,7 @@ function round(input: RoundInput<Config>): RoundResult<Outcome>
|
|
|
95
101
|
modes: [{ name: 'n16g0', config: { N: 16, goldOn: false } }, ...] // 只写影响分布的字段,每个 mode 写同一组键
|
|
96
102
|
```
|
|
97
103
|
|
|
98
|
-
出现在 `mode.config` 里的键就是 `modeKeys`;`modeOf(config)`
|
|
104
|
+
出现在 `mode.config` 里的键就是 `modeKeys`;`modeOf(config)` 按它匹配,匹配不到就抛——那是 modes 声明不完整。
|
|
99
105
|
表数 = modes 数,超过几十张就该把连续参数冻结成几档,或改成从有限档位里选。
|
|
100
106
|
|
|
101
107
|
`book` 按 probe 的建议选:
|
|
@@ -106,40 +112,43 @@ modes: [{ name: 'n16g0', config: { N: 16, goldOn: false } }, ...] // 只写影
|
|
|
106
112
|
| 所有影响结果的字段都在 modes 里 | `'seed'`(默认) | 不写。SDK 存采样种子、还原时重跑 `round()` |
|
|
107
113
|
| 有字段只影响这一局(`side`、`ranks` 这类) | `{ encode(s) => Uint8Array }` | 自己写:解码时用玩家**当下**的自由字段。参考 coco 的 `rules/book.ts`:把 ranks 当重标记,存「命中第 k 格」而不是牌 id |
|
|
108
114
|
|
|
109
|
-
`'seed'`
|
|
115
|
+
`'seed'` 用在有自由字段的游戏上,后果是表里写 0x、客户端按玩家当下选的值算出 4.64x。`restoreFromSeed` 会在还原时抛,但那已经是运行时了——先跑 probe。
|
|
110
116
|
|
|
111
|
-
### `restore(input)`
|
|
117
|
+
### `restore(input)`
|
|
112
118
|
|
|
113
119
|
```ts
|
|
114
120
|
// RestoreInput = { config, bet, payoutCenti, frames: Uint8Array | null, mode, seed }
|
|
115
121
|
```
|
|
116
122
|
|
|
117
|
-
|
|
118
|
-
「玩家看到连成三条线,余额却没动」这类 bug 完全静默,最伤信任。能多验一道就多验:解码出的局面算出的倍数应当等于 `payoutCenti`。
|
|
123
|
+
`payout` 必须恰好等于 `(input.bet / 100) * input.payoutCenti`,对不上就 `throw`,不要「修正」。能多验一道就多验:解码出的局面算出的倍数应当等于 `payoutCenti`。
|
|
119
124
|
`input.config` 是玩家当下的配置(回放时是下注那一刻存下的),先 `normalizeBySchema` 再用。
|
|
120
125
|
|
|
121
126
|
## 演出侧:`src/index.ts` 与 `mount`
|
|
122
127
|
|
|
123
128
|
```ts
|
|
124
|
-
export default { ...game, mount: mountGame, skins?: Skin[] };
|
|
129
|
+
export default { ...game, mount: mountGame, skins?: Skin[], assets: assets.bundle() }; // assets 来自 gamekit777/runtime
|
|
125
130
|
mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
|
|
126
131
|
```
|
|
127
132
|
|
|
133
|
+
`mount` 被调用时 critical 资源已由平台按 `assets` 清单预加载完,进度条也是平台画的;`mount` 里不写加载逻辑。
|
|
134
|
+
资源放 `src/assets/critical|lazy`,组件用 `assets.url(key)` 取地址;字体在 `game.meta.json` 的 `fonts` 里声明。
|
|
135
|
+
|
|
128
136
|
`Host` 是平台给你的,**全是只读**:`config` / `view` / `wallet` / `jackpot` 四个 `Readable`(`get()` + `subscribe()`),
|
|
129
137
|
`audio.context()`(不要自己 new AudioContext)、`locale`、`emit(event)`、`log()`。
|
|
130
138
|
想改参数只能 `host.requestConfig(patch)` 申请——平台校验、可能否决(局中、自动中)、批了才会从 `host.config` 推回来。
|
|
131
139
|
画布内的控件因此也是受控组件:渲染平台的值,改动走申请。`ViewSettings.autoActive` 为真时禁用画布内的设置控件。
|
|
132
140
|
|
|
133
141
|
`GameInstance`:`status`(`Readable<GameStatus>`,平台据 `busy` / `canBet` / `canSkip` / `canEditConfig` 控制自己的按钮)、
|
|
134
|
-
`present(result, { rate? })`(演一局,resolve 于演完;`rate: Infinity` 直接看结果)、`preview()`(只演不结账,回放用)、`skip()` / `reset()` / `resize()` / `destroy()`。
|
|
142
|
+
`present(result, { rate? })`(演一局,resolve 于演完;`rate: Infinity` 直接看结果)、`preview(result)`(只演不结账,回放用)、`skip()` / `reset()` / `resize()` / `destroy()`。
|
|
135
143
|
演出开始/结束发 `host.emit({ type: 'present:start' })` / `{ type: 'present:end', payout }`。
|
|
136
144
|
|
|
137
|
-
|
|
138
|
-
- **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页两个实例互不干扰,`test/e2e.test.ts` 会验。
|
|
145
|
+
- **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页会有两个实例(主实例 + 回放),`test/e2e.test.ts` 会验。
|
|
139
146
|
- `turbo` / `reducedMotion` 只改演出时长,改不了结果——它们根本不在 `round()` 的入参里。
|
|
140
147
|
- CSS 命名空间挂在容器 `el` 上(`gk-__ID__`),不写 `document.documentElement`。
|
|
141
148
|
- 画布是固定 900×771 的逻辑画面,平台整体缩放;按这个尺寸排版。
|
|
142
|
-
-
|
|
149
|
+
- **演出先查资源库**:`asset_list` 看有哪些。**animation** 是自足的小组件(赢面弹窗、分档大奖、揭示翻动、上飘文字…),`asset_use <name>` 复制进 `src/animations/` 再按需改;
|
|
150
|
+
**pattern** 是一套机制加接线清单(荷官说话…),`asset_show <name>` 先读 NOTES 里的接线表和台词写作指南,`asset_use <name>` 复制进 `src/patterns/<name>/` 后把调用按表穿进你的时间轴,
|
|
151
|
+
文案按这个游戏的角色重写。库是参考实现,复制进来就是你的代码;不要从零写一个库里已有的演出。
|
|
143
152
|
|
|
144
153
|
## 工作流(studio 的 MCP 工具)
|
|
145
154
|
|
|
@@ -150,21 +159,11 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
|
|
|
150
159
|
→ preview_play { rounds, seed?, simId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore)
|
|
151
160
|
→ case_add 把值得盯的局面存进 studio/cases.jsonl
|
|
152
161
|
→ build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
|
|
153
|
-
→ publish_dry_run → publish
|
|
162
|
+
→ publish_dry_run → publish 一键:vite build → 传包 → 传表 → 发布,不用人确认,不可逆。身份是作者在 studio 页面连过的站点身份;报「没有身份」就让作者打开页面连一次
|
|
154
163
|
```
|
|
155
164
|
|
|
156
165
|
- `game_info` 是第一跳:清单、schema、指纹、已建的表、预览连没连。
|
|
157
166
|
- 长任务返回 `jobId` 就用 `job_status` 轮询。
|
|
158
|
-
- `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless`
|
|
167
|
+
- `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**——历史局靠它们回放,要改就换 slug。
|
|
159
168
|
- `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
|
|
160
169
|
- 手动:`bun run dev` 起 studio(页面 `http://127.0.0.1:4301/`,左边是预览机台,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
|
|
161
|
-
|
|
162
|
-
## 不要做的事
|
|
163
|
-
|
|
164
|
-
- 不写 `normalize` / `validate` / `modeOf`;不加 `meta`、版本号;不在清单里写注额区间。
|
|
165
|
-
- 不在 `round()` 或 `rules/` 里 import Svelte、DOM、`gamekit777/runtime`。
|
|
166
|
-
- 不用 `Math.random` 决定任何影响赔付的事;不用 `% n` 取整数随机。
|
|
167
|
-
- 不让 `payout` 出现小数或随 bet 变化的取整;`multiplier` 不超过两位小数。
|
|
168
|
-
- 不在 `restore` 里「修正」对不上的 payout——对不上就抛。
|
|
169
|
-
- 不把 `'seed'` 用在有自由字段的游戏上。
|
|
170
|
-
- 不改已发布 mode 的发牌逻辑 / book 编解码 / 取整方式。
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/* 游戏包的浏览器入口 = 清单 + view。平台加载这个文件:
|
|
2
2
|
|
|
3
|
-
import game from '
|
|
3
|
+
import game from '/g/<gameId>/<bundleHash>/index.js' // 平台从 R2 流出的包
|
|
4
4
|
const inst = await game.mount(host, el)
|
|
5
5
|
await inst.present(result)
|
|
6
6
|
|
|
7
|
-
清单在根目录的 game.ts,Node 侧(采样、studio)import 的是它;这里只是把 mount 拼上去。
|
|
7
|
+
清单在根目录的 game.ts,Node 侧(采样、studio)import 的是它;这里只是把 mount 拼上去。
|
|
8
|
+
assets 是 src/assets/ 目录的清单(runtime 按目录生成),平台在 mount 之前按它预加载、画进度条——游戏自己不加载。 */
|
|
8
9
|
import './styles/global.css';
|
|
9
10
|
import './styles/animations.css';
|
|
10
11
|
|
|
12
|
+
import { assets } from 'gamekit777/runtime';
|
|
11
13
|
import game from '../game.ts';
|
|
12
14
|
import { mountGame } from './view/mount.svelte.ts';
|
|
13
15
|
|
|
14
16
|
export * from '../game.ts';
|
|
15
17
|
export { mountGame as mount };
|
|
16
18
|
|
|
17
|
-
export default { ...game, mount: mountGame };
|
|
19
|
+
export default { ...game, mount: mountGame, assets: assets.bundle() };
|
|
@@ -3,25 +3,16 @@
|
|
|
3
3
|
放在 .svelte.ts 里是因为要用 $effect.root——status 是从 runes 派生出来
|
|
4
4
|
推给平台的。 */
|
|
5
5
|
import { mount as svelteMount, unmount } from 'svelte';
|
|
6
|
-
import { ensureFonts, loadAssets, prefetchLazy, type LoadProgress } from 'gamekit777/runtime';
|
|
7
6
|
import type { GameInstance, GameStatus, Host, PresentOptions, Readable, RoundResult } from 'gamekit777/sdk';
|
|
8
7
|
import App from '../components/App.svelte';
|
|
9
8
|
import buildMeta from '../../game.meta.json' with { type: 'json' };
|
|
10
|
-
import { assets } from '../assets/registry.ts';
|
|
11
|
-
import { loadManifest } from '../assets/manifest.ts';
|
|
12
9
|
import { createInstance } from '../context.svelte.ts';
|
|
13
10
|
import type { __PASCAL__Config, __PASCAL__Outcome } from '../rules/types.ts';
|
|
14
11
|
import { present } from './present.ts';
|
|
15
12
|
import { toStore } from './bridge.svelte.ts';
|
|
16
13
|
|
|
17
|
-
export interface MountOptions {
|
|
18
|
-
/** 资源加载进度。平台拿它渲染自己的加载条 */
|
|
19
|
-
onProgress?(p: LoadProgress): void;
|
|
20
|
-
signal?: AbortSignal;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
14
|
export async function mountGame(
|
|
24
|
-
host: Host<__PASCAL__Config>, el: HTMLElement
|
|
15
|
+
host: Host<__PASCAL__Config>, el: HTMLElement
|
|
25
16
|
): Promise<GameInstance<__PASCAL__Outcome>> {
|
|
26
17
|
/* 每次 mount 造一棵全新的状态树,同页挂两个实例是安全的 */
|
|
27
18
|
const inst = createInstance(host);
|
|
@@ -35,16 +26,6 @@ export async function mountGame(
|
|
|
35
26
|
teardown.push(host.config.subscribe(c => game.applyConfig(c)));
|
|
36
27
|
teardown.push(host.view.subscribe(v => game.applyView(v)));
|
|
37
28
|
|
|
38
|
-
const manifest = await loadManifest(opts.signal);
|
|
39
|
-
const urls = Object.fromEntries(assets.keys.map(k => [k, assets.url(k)]));
|
|
40
|
-
let progress = 0;
|
|
41
|
-
await Promise.all([
|
|
42
|
-
ensureFonts(buildMeta.fonts ?? []),
|
|
43
|
-
loadAssets({ manifest, urls, signal: opts.signal,
|
|
44
|
-
onProgress: p => { progress = p.ratio; opts.onProgress?.(p); } })
|
|
45
|
-
]);
|
|
46
|
-
progress = 1;
|
|
47
|
-
|
|
48
29
|
const status = toStore<GameStatus>(() => ({
|
|
49
30
|
phase: game.phase === 'playing' ? 'playing' : game.phase === 'over' ? 'settling' : 'idle',
|
|
50
31
|
busy: game.live,
|
|
@@ -52,13 +33,11 @@ export async function mountGame(
|
|
|
52
33
|
canSkip: game.live,
|
|
53
34
|
/* 和游戏画布内的控件用同一个判断,两边不会打架 */
|
|
54
35
|
canEditConfig: !game.live && !game.autoActive,
|
|
55
|
-
progress,
|
|
56
36
|
lastPayout: game.lastPayout
|
|
57
37
|
}));
|
|
58
38
|
teardown.push(status.dispose);
|
|
59
39
|
|
|
60
40
|
const app = svelteMount(App, { target: el, props: { instance: inst } });
|
|
61
|
-
prefetchLazy(manifest, urls);
|
|
62
41
|
host.emit({ type: 'ready' });
|
|
63
42
|
|
|
64
43
|
const play = async (r: RoundResult<__PASCAL__Outcome>, o?: PresentOptions): Promise<void> => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
2
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
3
3
|
|
|
4
|
-
/* 测试必须走 Vite 管线:
|
|
4
|
+
/* 测试必须走 Vite 管线:gamekit777/runtime 的资源注册表用了 import.meta.glob(Vite 特性,按 root 找 src/assets/),
|
|
5
5
|
组件和 .svelte.ts 里的 runes 也要 Svelte 编译器处理。 */
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
plugins: [svelte({ hot: false })],
|
package/dev-host/DevShell.svelte
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
开发专用的东西(历史、校验、公平证、赔率、定点复现)放在机台下面,不挤进机台里。 */
|
|
13
13
|
import { onMount } from 'svelte';
|
|
14
14
|
import { STAGE_H, STAGE_W, type GameInstance, type GameModule } from '../sdk/index.ts';
|
|
15
|
+
import { preloadGame } from '../runtime/index.ts';
|
|
15
16
|
import { createPlatform, type HistoryEntry, type PlatformOf } from './platform.svelte.ts';
|
|
16
17
|
import type { PlatformServer } from './platform-server.ts';
|
|
17
18
|
import type { LocalServer } from './local.ts';
|
|
@@ -78,9 +79,14 @@
|
|
|
78
79
|
void (async () => {
|
|
79
80
|
// 先和服务端握手:余额、种子承诺、注额区间都在那边
|
|
80
81
|
await p.init();
|
|
82
|
+
/* 资源在平台这一侧预加载:进度画在机台上,游戏的 mount 假定 critical 已到齐。
|
|
83
|
+
图片句柄挂在 pre 上活到卸载——opaque origin 下没有落盘缓存,这份就是唯一一份 */
|
|
84
|
+
const pre = await preloadGame(game.assets, { onProgress: (x) => p.setLoadProgress(x.ratio) });
|
|
85
|
+
if (!live) return;
|
|
81
86
|
const i = await game.mount(p.host(), frame!);
|
|
82
87
|
if (!live) { i.destroy(); return; }
|
|
83
88
|
handle = i; inst = i;
|
|
89
|
+
pre.prefetchLazy();
|
|
84
90
|
off = i.status.subscribe(s => p.setGameStatus(s));
|
|
85
91
|
p.bindGame({ present: (r, rate) => i.present(r, rate ? { rate } : undefined), status: () => p.game });
|
|
86
92
|
})();
|
|
@@ -156,7 +162,7 @@
|
|
|
156
162
|
<Field hint={`区间 ${money(p.limits.min)}–${money(p.limits.max)},步长 ${money(p.limits.step)}`}>
|
|
157
163
|
{#snippet label()}<label for="bet">注额</label>{/snippet}
|
|
158
164
|
{#snippet accent()}
|
|
159
|
-
<span>本场 <b class="
|
|
165
|
+
<span>本场 <b class="num" class:win={p.pnl > 0} class:bad={p.pnl < 0}>{signed(p.pnl)}</b></span>
|
|
160
166
|
{/snippet}
|
|
161
167
|
<span class="coin" aria-hidden="true"></span>
|
|
162
168
|
<input id="bet" inputmode="decimal" bind:value={betText} onchange={commitBet} disabled={!p.canBet} />
|
|
@@ -207,7 +213,10 @@
|
|
|
207
213
|
<section class="stage" style="--ratio:{STAGE_W}/{STAGE_H}" bind:this={stage}>
|
|
208
214
|
<div class="gameframe" bind:this={frame} style="width:{STAGE_W}px;height:{STAGE_H}px"></div>
|
|
209
215
|
{#if p.game.phase === 'loading'}
|
|
210
|
-
<
|
|
216
|
+
<div class="loading">
|
|
217
|
+
<p>{p.loadProgress === null ? '正在装载游戏…' : `加载资源… ${Math.round(p.loadProgress * 100)}%`}</p>
|
|
218
|
+
{#if p.loadProgress !== null}<span class="loadbar"><i style="width:{p.loadProgress * 100}%"></i></span>{/if}
|
|
219
|
+
</div>
|
|
211
220
|
{/if}
|
|
212
221
|
</section>
|
|
213
222
|
|
|
@@ -324,6 +333,18 @@
|
|
|
324
333
|
.shell { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding-bottom: 48px; }
|
|
325
334
|
/* 嵌在 studio 里:外面已经决定了这一栏多宽,这里顶满、不留白 */
|
|
326
335
|
.shell.embedded { width: 100%; padding-bottom: 16px; }
|
|
336
|
+
/* 嵌入时 iframe 的视口就是机台那一栏的宽度,studio 把它缩到 1000 以下很常见(1440 的屏就会),
|
|
337
|
+
但机台不该因此切成窄屏的上下堆叠——它在 studio 里始终是一台横着的机器。
|
|
338
|
+
把 theme.css 里窄屏媒体查询改掉的那几条按宽屏写回来 */
|
|
339
|
+
.shell.embedded .cab { grid-template-columns: 304px minmax(0, 1fr); grid-template-areas: "panel stage" "bar bar"; }
|
|
340
|
+
.shell.embedded .panel-col { min-height: 0; }
|
|
341
|
+
.shell.embedded .panel-inner { position: absolute; overflow: hidden; }
|
|
342
|
+
.shell.embedded .params { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
|
|
343
|
+
/* 和玩家站点的 game.html 一样:游戏根元素撑满整块逻辑画面,由游戏自己在里面排版。
|
|
344
|
+
不撑的话根元素按内容高度长,超出 771 的那一截会被舞台裁掉,而且很不显眼 */
|
|
345
|
+
/* 写成 .stage > .gameframe 是为了压过 theme.css 里同名的 display:block */
|
|
346
|
+
.stage > .gameframe { display: flex; }
|
|
347
|
+
.stage > .gameframe > :global(*) { flex: 1; min-width: 0; }
|
|
327
348
|
.top { display: flex; align-items: center; gap: 12px; padding: 14px 4px 0; }
|
|
328
349
|
.top .big { font-size: 22px; color: var(--ink); }
|
|
329
350
|
.intro { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; margin: 0 4px 14px; }
|
|
@@ -94,7 +94,9 @@ export function createPlatform<C, O>(
|
|
|
94
94
|
err = $state<string | null>(null);
|
|
95
95
|
clientSeed = $state('player-local');
|
|
96
96
|
/** 游戏交回来的状态 */
|
|
97
|
-
game = $state<GameStatus>({ phase: 'loading', busy: false, canBet: false, canSkip: false, canEditConfig: false
|
|
97
|
+
game = $state<GameStatus>({ phase: 'loading', busy: false, canBet: false, canSkip: false, canEditConfig: false });
|
|
98
|
+
/** 资源预加载进度 0..1。平台侧的加载器写,游戏不知道有这回事;null = 代码还没下完 */
|
|
99
|
+
loadProgress = $state<number | null>(null);
|
|
98
100
|
/** 本地服务端已加载的表(mode 名) */
|
|
99
101
|
tableModes = $state<string[]>([]);
|
|
100
102
|
|
|
@@ -136,6 +138,7 @@ export function createPlatform<C, O>(
|
|
|
136
138
|
|
|
137
139
|
bindGame(a: GameApi<O>): void { api = a; }
|
|
138
140
|
setGameStatus(s: GameStatus): void { this.game = s; }
|
|
141
|
+
setLoadProgress(ratio: number): void { this.loadProgress = ratio; }
|
|
139
142
|
|
|
140
143
|
host(): Host<C> {
|
|
141
144
|
return {
|
package/dev-host/theme.css
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
|
|
20
20
|
/* 舞台:游戏画面所在的那块深色区域 */
|
|
21
21
|
--stage: #1A0B11;
|
|
22
|
-
--tile: #2C161F;
|
|
23
22
|
--s-ink: #FFF1DC;
|
|
24
23
|
--s-dim: #A3858C;
|
|
25
24
|
|
|
@@ -27,7 +26,6 @@
|
|
|
27
26
|
--coco-d: #A8231A;
|
|
28
27
|
--banana: #FFD21F;
|
|
29
28
|
--banana-d: #B98F00;
|
|
30
|
-
--cream: #FFF1DC;
|
|
31
29
|
--ok: #2E9E5B;
|
|
32
30
|
|
|
33
31
|
--num: "Lilita One", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
@@ -165,8 +163,12 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
|
|
|
165
163
|
}
|
|
166
164
|
.stage .loading {
|
|
167
165
|
position: absolute; inset: 0; margin: 0;
|
|
168
|
-
display: grid; place-
|
|
166
|
+
display: grid; place-content: center; justify-items: center; gap: 12px;
|
|
167
|
+
color: var(--s-dim); font-family: var(--fun); font-size: 18px;
|
|
169
168
|
}
|
|
169
|
+
.stage .loading p { margin: 0; }
|
|
170
|
+
.stage .loadbar { display: block; width: 220px; height: 6px; border-radius: 99px; background: rgba(255, 255, 255, .12); overflow: hidden; }
|
|
171
|
+
.stage .loadbar i { display: block; height: 100%; border-radius: 99px; background: var(--banana); transition: width .15s; }
|
|
170
172
|
|
|
171
173
|
.bar {
|
|
172
174
|
grid-area: bar; display: flex; align-items: center; justify-content: space-between;
|
package/package.json
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* 资源库:历史演出经验的归档,两种条目共用一套索引、同步与发布。
|
|
2
|
+
*
|
|
3
|
+
* animation 一个自足的 .svelte 组件,契约只有一条:挂载即播放一次。复制进游戏的 src/animations/
|
|
4
|
+
* pattern 一套机制(若干 .ts / .svelte)+ 一个 Demo.svelte(可点的场景,预览用)+ 范例文件(*.example.*)。
|
|
5
|
+
* 复制进游戏的 src/patterns/<name>/,Demo 和范例不复制;NOTES 里有接线清单,接线要 AI 照着穿进游戏的时间轴
|
|
6
|
+
*
|
|
7
|
+
* 内容不进 npm 包:源码在仓库 packages/assets/src/{animations,patterns}/,`bun run assets:push` 按内容寻址传到 R2,
|
|
8
|
+
* `/a/index.json` 是目录、`/a/<hash>/<file>` 是文件(immutable)。studio 启动后在后台拉,AI 用 MCP 查、看、复制进游戏。 */
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { BundleFile, BundlePath, bundleManifestText } from './bundles.ts';
|
|
11
|
+
import { Sha256Hex } from './money.ts';
|
|
12
|
+
|
|
13
|
+
export const AssetKind = z.enum(['animation', 'pattern']);
|
|
14
|
+
export type AssetKind = z.infer<typeof AssetKind>;
|
|
15
|
+
|
|
16
|
+
export const AssetName = z.string().min(2).max(48).regex(/^[a-z0-9][a-z0-9-]*$/);
|
|
17
|
+
const SvelteFile = z.string().regex(/^[A-Z][A-Za-z0-9]*\.svelte$/);
|
|
18
|
+
|
|
19
|
+
/** 预览页按它生成旋钮(animation 给组件,pattern 给 Demo);也是 AI 看「能调什么」的说明 */
|
|
20
|
+
export const AssetProp = z.object({
|
|
21
|
+
kind: z.enum(['number', 'boolean', 'string', 'enum']),
|
|
22
|
+
label: z.string().max(40).optional(),
|
|
23
|
+
default: z.union([z.number(), z.boolean(), z.string()]),
|
|
24
|
+
min: z.number().optional(),
|
|
25
|
+
max: z.number().optional(),
|
|
26
|
+
step: z.number().optional(),
|
|
27
|
+
/** kind = enum 时的取值 */
|
|
28
|
+
options: z.array(z.string().max(40)).max(24).optional(),
|
|
29
|
+
}).strict();
|
|
30
|
+
export type AssetProp = z.infer<typeof AssetProp>;
|
|
31
|
+
|
|
32
|
+
const base = {
|
|
33
|
+
name: AssetName,
|
|
34
|
+
title: z.string().min(1).max(60),
|
|
35
|
+
summary: z.string().min(1).max(200),
|
|
36
|
+
tags: z.array(z.string().min(1).max(24)).max(12),
|
|
37
|
+
props: z.record(z.string().regex(/^[a-z][A-Za-z0-9]*$/), AssetProp).default({}),
|
|
38
|
+
/** 预览舞台的逻辑尺寸,屏幕不够时整体缩放;不填就是 450×385 深色底 */
|
|
39
|
+
stage: z.object({
|
|
40
|
+
width: z.int().min(100).max(900).optional(),
|
|
41
|
+
height: z.int().min(100).max(771).optional(),
|
|
42
|
+
background: z.string().max(60).optional(),
|
|
43
|
+
}).strict().optional(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const AnimationMeta = z.object({
|
|
47
|
+
kind: z.literal('animation'),
|
|
48
|
+
...base,
|
|
49
|
+
/** 组件文件名,PascalCase.svelte;复制进游戏时也用这个名 */
|
|
50
|
+
component: SvelteFile,
|
|
51
|
+
}).strict();
|
|
52
|
+
export type AnimationMeta = z.infer<typeof AnimationMeta>;
|
|
53
|
+
|
|
54
|
+
export const PatternMeta = z.object({
|
|
55
|
+
kind: z.literal('pattern'),
|
|
56
|
+
...base,
|
|
57
|
+
/** 预览用的场景组件,只在 studio 里跑,不复制进游戏 */
|
|
58
|
+
demo: SvelteFile,
|
|
59
|
+
}).strict();
|
|
60
|
+
export type PatternMeta = z.infer<typeof PatternMeta>;
|
|
61
|
+
|
|
62
|
+
/** asset.json 的形状 */
|
|
63
|
+
export const AssetMeta = z.discriminatedUnion('kind', [AnimationMeta, PatternMeta]);
|
|
64
|
+
export type AssetMeta = z.infer<typeof AssetMeta>;
|
|
65
|
+
|
|
66
|
+
export const ASSET_REQUIRED = ['asset.json', 'NOTES.md'] as const;
|
|
67
|
+
export const MAX_ASSET_FILE_BYTES = 512 * 1024;
|
|
68
|
+
|
|
69
|
+
const indexed = { hash: Sha256Hex, files: z.array(BundleFile).min(3) };
|
|
70
|
+
export const AnimationIndexEntry = AnimationMeta.extend(indexed);
|
|
71
|
+
export const PatternIndexEntry = PatternMeta.extend(indexed);
|
|
72
|
+
export const AssetIndexEntry = z.discriminatedUnion('kind', [AnimationIndexEntry, PatternIndexEntry]);
|
|
73
|
+
export type AnimationIndexEntry = z.infer<typeof AnimationIndexEntry>;
|
|
74
|
+
export type PatternIndexEntry = z.infer<typeof PatternIndexEntry>;
|
|
75
|
+
export type AssetIndexEntry = z.infer<typeof AssetIndexEntry>;
|
|
76
|
+
|
|
77
|
+
export const AssetIndex = z.object({
|
|
78
|
+
version: z.literal(1),
|
|
79
|
+
entries: z.array(AssetIndexEntry),
|
|
80
|
+
});
|
|
81
|
+
export type AssetIndex = z.infer<typeof AssetIndex>;
|
|
82
|
+
|
|
83
|
+
/* /assets 让给了站点的静态产物(vite 的 dist/assets/*),库走短前缀 /a,和游戏包的 /g 同一副样子 */
|
|
84
|
+
export const ASSET_INDEX_PATH = '/a/index.json';
|
|
85
|
+
export const assetFilePath = (hash: string, path: string): string => `/a/${hash}/${path}`;
|
|
86
|
+
export const AssetFilePath = BundlePath;
|
|
87
|
+
/** 条目哈希 = 文件清单文本(path sha256 按 path 排序)的 sha256。内容一字不改哈希就不变 */
|
|
88
|
+
export const assetManifestText = bundleManifestText;
|
|
89
|
+
|
|
90
|
+
/** 预览时挂的那个组件:animation 是它自己,pattern 是 Demo */
|
|
91
|
+
export const assetPreviewFile = (m: AssetMeta): string => (m.kind === 'animation' ? m.component : m.demo);
|
|
92
|
+
|
|
93
|
+
/** 复制进游戏时落在哪:animation 一个文件,pattern 一个目录 */
|
|
94
|
+
export const assetTargetDir = (kind: AssetKind): string => (kind === 'animation' ? 'src/animations' : 'src/patterns');
|
|
95
|
+
|
|
96
|
+
/** 复制进游戏的文件。元数据、NOTES、Demo、范例(*.example.*)都是给人和 AI 看的,不进游戏 */
|
|
97
|
+
export function assetCopyFiles(m: AssetMeta, files: readonly string[]): string[] {
|
|
98
|
+
if (m.kind === 'animation') return [m.component];
|
|
99
|
+
return files.filter((f) => f !== 'asset.json' && f !== 'NOTES.md' && f !== m.demo && !/\.example\./.test(f));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function assetContentType(path: string): string {
|
|
103
|
+
if (path.endsWith('.svelte') || path.endsWith('.ts')) return 'text/plain; charset=utf-8';
|
|
104
|
+
if (path.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
105
|
+
if (path.endsWith('.md')) return 'text/markdown; charset=utf-8';
|
|
106
|
+
if (path.endsWith('.json')) return 'application/json; charset=utf-8';
|
|
107
|
+
return 'application/octet-stream';
|
|
108
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* 游戏包:vite build 出来的 index.js / manifest.json / meta.json / assets,按内容寻址存进 R2。
|
|
2
|
+
*
|
|
3
|
+
* 包不是审计对象——公平性的审计对象是表。包只是演出,所以已发布的游戏允许换包(修 UI、改演出),
|
|
4
|
+
* 表照旧不可变。「已发布 mode 的解码逻辑不能改」这条由 case 库和 restore 契约守,与包是否更新无关。
|
|
5
|
+
*
|
|
6
|
+
* 寻址:bundles/<gameId>/<bundleHash>/<path>。bundleHash 是文件清单(路径 + 各文件 sha256)的 sha256,
|
|
7
|
+
* 内容不变哈希就不变,所以对象永远不覆盖、可以 Cache-Control: immutable。 */
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Sha256Hex } from './money.ts';
|
|
10
|
+
|
|
11
|
+
/** 平台加载游戏时必须有的三个文件;其余(assets/…)随意 */
|
|
12
|
+
export const BUNDLE_REQUIRED = ['index.js', 'manifest.json', 'meta.json'] as const;
|
|
13
|
+
export const MAX_BUNDLE_FILE_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
export const MAX_BUNDLE_FILES = 400;
|
|
15
|
+
export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** 相对路径:每一段以字母数字开头(挡掉 . 和 ..),只允许字母数字 . _ - 和 / */
|
|
18
|
+
export const BundlePath = z.string().min(1).max(200)
|
|
19
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/, '路径只能是 a-z 0-9 . _ - 和 /,每段以字母数字开头');
|
|
20
|
+
export type BundlePath = z.infer<typeof BundlePath>;
|
|
21
|
+
|
|
22
|
+
export const BundleFile = z.object({
|
|
23
|
+
path: BundlePath,
|
|
24
|
+
sha256: Sha256Hex,
|
|
25
|
+
bytes: z.int().min(0).max(MAX_BUNDLE_FILE_BYTES),
|
|
26
|
+
});
|
|
27
|
+
export type BundleFile = z.infer<typeof BundleFile>;
|
|
28
|
+
|
|
29
|
+
export const BundleCommit = z.object({
|
|
30
|
+
files: z.array(BundleFile).min(1).max(MAX_BUNDLE_FILES),
|
|
31
|
+
});
|
|
32
|
+
export type BundleCommit = z.infer<typeof BundleCommit>;
|
|
33
|
+
|
|
34
|
+
/** 站点加载游戏要的全部信息:包哈希和入口 URL(同源相对路径,由平台流出) */
|
|
35
|
+
export const BundleRef = z.object({
|
|
36
|
+
hash: Sha256Hex,
|
|
37
|
+
/** game.meta.json 的 version,给人看的。缓存键是 hash:内容变了地址一定变,没变一定不变 */
|
|
38
|
+
version: z.string(),
|
|
39
|
+
entry: z.string(),
|
|
40
|
+
files: z.int(),
|
|
41
|
+
bytes: z.int(),
|
|
42
|
+
createdAt: z.int(),
|
|
43
|
+
});
|
|
44
|
+
export type BundleRef = z.infer<typeof BundleRef>;
|
|
45
|
+
|
|
46
|
+
export const BundleFileResult = z.object({ path: BundlePath, bytes: z.int(), sha256: Sha256Hex });
|
|
47
|
+
export type BundleFileResult = z.infer<typeof BundleFileResult>;
|
|
48
|
+
|
|
49
|
+
export const BundleCommitResult = z.object({ bundle: BundleRef });
|
|
50
|
+
export type BundleCommitResult = z.infer<typeof BundleCommitResult>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 清单的规范文本:按路径排序,一行一个 `path sha256`。两端(上传方、服务端)都对它算 sha256 得到 bundleHash。
|
|
54
|
+
* 放在协议里而不是各写一份,是因为一个空格的差别就是两个哈希、永远对不上。
|
|
55
|
+
*/
|
|
56
|
+
export function bundleManifestText(files: readonly { path: string; sha256: string }[]): string {
|
|
57
|
+
return [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
58
|
+
.map((f) => `${f.path} ${f.sha256}`).join('\n') + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const bundleEntryPath = (gameId: string, hash: string): string => `/g/${gameId}/${hash}/index.js`;
|
|
62
|
+
|
|
63
|
+
/** R2 对象的 content-type 按扩展名给:模块脚本没有 JS 的 MIME 浏览器会拒绝 import */
|
|
64
|
+
export function bundleContentType(path: string): string {
|
|
65
|
+
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
|
66
|
+
return ({
|
|
67
|
+
js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', json: 'application/json; charset=utf-8',
|
|
68
|
+
css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', txt: 'text/plain; charset=utf-8', map: 'application/json',
|
|
69
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
|
|
70
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
|
|
71
|
+
mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', webm: 'video/webm', mp4: 'video/mp4',
|
|
72
|
+
wasm: 'application/wasm',
|
|
73
|
+
} as Record<string, string>)[ext] ?? 'application/octet-stream';
|
|
74
|
+
}
|