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
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # gamekit777
2
+
3
+ 可证明公平的博彩小游戏工具箱:游戏 SDK、vite 构建预设、参考平台、权重表求解与打包上传流水线,
4
+ 以及一个给 AI 用的创作工作台(MCP server + 页面)。
5
+
6
+ 平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,运行时按权重抽一行、查表赔付,
7
+ 浏览器用 `restore()` 把那一行还原成演出。审计的对象因此从「任意代码」变成「一张可下载的表」。
8
+
9
+ ## 需要
10
+
11
+ - [bun](https://bun.sh) ≥ 1.2(命令行入口是裸 TS,由 bun 执行;`npx` 不行)
12
+ - 游戏项目自带 `svelte` / `vite` / `@sveltejs/vite-plugin-svelte`(peer 依赖,脚手架会写好)
13
+
14
+ ## 开始
15
+
16
+ ```sh
17
+ bunx gamekit777 new my-game "我的游戏" # 一个能跑的抛硬币 + bun install
18
+ cd my-game
19
+ bun run dev # 参考平台:本地表模型,就地建表、抽行、restore 还原
20
+ ```
21
+
22
+ 接 Claude Code:
23
+
24
+ ```sh
25
+ claude mcp add gamekit -- bunx gamekit777 studio # 在游戏目录里
26
+ claude
27
+ ```
28
+
29
+ studio 同时是 MCP server、创作页面(预览 / 调参 / 建表 / Case / 发布)和到平台后端的反代。
30
+ 游戏目录里的 `CLAUDE.md` 是写给 AI 的开发指南:清单字段、`round()` 的硬约束、`modes` / `book` / `restore` 怎么选。
31
+
32
+ 手动起页面:`bunx gamekit777 studio [dir] --no-mcp [--game-port 4301]`。
33
+
34
+ ## 子路径
35
+
36
+ | import | 内容 |
37
+ |---|---|
38
+ | `gamekit777/sdk` | `defineGame`、契约类型、可证明公平的 RNG、SHA-256 / HMAC、加权抽样原语 |
39
+ | `gamekit777/runtime` | 资源加载、字体预取 |
40
+ | `gamekit777/vite-config` | `defineGameConfig`:一个游戏的 `vite.config.ts` 只要三行 |
41
+ | `gamekit777/dev-host` | 参考平台与本地表模型服务端;`createRemotePlatform` 接真平台 |
42
+ | `gamekit777/protocol` · `client` | 平台 REST 契约(zod)与类型化客户端 |
43
+ | `gamekit777/lut` · `weights` · `publish` | 权重表编解码与校验、凸优化求解器、采样→建表→上传流水线 |
44
+
45
+ ## 一条硬契约
46
+
47
+ `restore()` 返回的 `payout` 必须恰好等于 `(bet / 100) × payoutCenti`,对不上就抛、不演出。
48
+ 本地开发走的就是真平台那条路,违反在第一注就会被拦下。
package/client/http.ts ADDED
@@ -0,0 +1,253 @@
1
+ /* 传输层:一次 HTTP 往返 + 重试 + 会话状态。
2
+ *
3
+ * 分层的理由是重试策略只在这里有意义——上层的 api.bets.place() 不该知道
4
+ * 「同一个 Idempotency-Key 重发是安全的」这种事,而这恰恰是整个客户端最容易写错的地方。 */
5
+ import { ApiError, ErrorCode, GameKitError } from '../protocol/index.ts';
6
+ import type { ZodType } from 'zod';
7
+
8
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
9
+
10
+ export interface RetryPolicy {
11
+ /** 总尝试次数,含第一次。1 表示不重试 */
12
+ attempts: number;
13
+ baseDelayMs: number;
14
+ maxDelayMs: number;
15
+ }
16
+
17
+ export const DEFAULT_RETRY: RetryPolicy = { attempts: 4, baseDelayMs: 50, maxDelayMs: 2_000 };
18
+
19
+ export interface RetryEvent {
20
+ attempt: number;
21
+ delayMs: number;
22
+ error: GameKitError;
23
+ method: string;
24
+ path: string;
25
+ }
26
+
27
+ /**
28
+ * cookie 的落盘位置。
29
+ *
30
+ * 不持久化的话每次进程启动都是一个新匿名用户——脚本跑一次就在库里留下
31
+ * 一个用户、一个钱包、一份同名的游戏(`games` 的唯一约束是 `(owner_id, slug)`,
32
+ * 换了 owner 同一个 slug 就能再建一份)。跑十次开发库里就有十份。
33
+ */
34
+ export interface CookieStore {
35
+ read(): Record<string, string>;
36
+ write(cookies: Record<string, string>): void;
37
+ }
38
+
39
+ export interface ClientOptions {
40
+ baseUrl: string;
41
+ fetch?: FetchLike;
42
+ /**
43
+ * 'browser' 把 cookie 交给浏览器(跨域靠 credentials: 'include')。
44
+ * 'manual' 自己存 set-cookie——Node 里没有 cookie jar,不自己存的话
45
+ * 每个请求都是一个新匿名用户,而且不会报错,只会莫名其妙地余额归位。
46
+ */
47
+ cookies?: 'browser' | 'manual';
48
+ /** 只在 cookies: 'manual' 下有意义。Node 侧的文件实现见 @gamekit/client/node */
49
+ cookieStore?: CookieStore;
50
+ retry?: Partial<RetryPolicy>;
51
+ sleep?: (ms: number) => Promise<void>;
52
+ random?: () => number;
53
+ newIdempotencyKey?: () => string;
54
+ /** 用 zod 校验响应。默认开:协议漂移越早炸越好 */
55
+ validate?: boolean;
56
+ headers?: Record<string, string>;
57
+ onRetry?: (e: RetryEvent) => void;
58
+ }
59
+
60
+ export interface RequestSpec<T> {
61
+ method: string;
62
+ path: string;
63
+ query?: Record<string, string | number | boolean | undefined>;
64
+ json?: unknown;
65
+ body?: Uint8Array;
66
+ headers?: Record<string, string>;
67
+ /** 带上它就等于声明「这个请求重发是安全的」 */
68
+ idempotencyKey?: string;
69
+ schema?: ZodType<T>;
70
+ binary?: boolean;
71
+ }
72
+
73
+ const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
74
+
75
+ /** 解析失败一律当 INTERNAL:网关返回的 HTML 502 不是本协议的一部分 */
76
+ function toError(status: number, payload: unknown): GameKitError {
77
+ const parsed = ApiError.safeParse(payload);
78
+ if (parsed.success) {
79
+ const e = parsed.data.error;
80
+ return new GameKitError(e.code, e.message, e.details);
81
+ }
82
+ const code: ErrorCode = status === 404 ? 'NOT_FOUND' : status === 401 ? 'UNAUTHENTICATED' : 'INTERNAL';
83
+ return new GameKitError(code, `HTTP ${status}`, payload);
84
+ }
85
+
86
+ /**
87
+ * 客户端本地判定的失败:响应不符合协议、下载的字节哈希对不上。
88
+ *
89
+ * 必须和服务端返回的错误分开,因为它复用了 INTERNAL 这类「服务端说可重试」的码,
90
+ * 而重试一个协议漂移只会把同一个错误再犯三遍。
91
+ */
92
+ export class ClientError extends GameKitError {
93
+ override readonly name = 'ClientError';
94
+ }
95
+
96
+ /** 服务端说了才算。retryable 是响应信封里的字段,不是我们按状态码猜的 */
97
+ const isRetryable = (e: GameKitError): boolean =>
98
+ !(e instanceof ClientError) && e.toJSON().error.retryable;
99
+
100
+ export class Http {
101
+ readonly baseUrl: string;
102
+ #fetch: FetchLike;
103
+ #cookies: Map<string, string> | null;
104
+ #store: CookieStore | undefined;
105
+ #retry: RetryPolicy;
106
+ #sleep: (ms: number) => Promise<void>;
107
+ #random: () => number;
108
+ #newKey: () => string;
109
+ #validate: boolean;
110
+ #headers: Record<string, string>;
111
+ #onRetry: ((e: RetryEvent) => void) | undefined;
112
+ #bookmark: string | null = null;
113
+
114
+ constructor(opts: ClientOptions) {
115
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
116
+ this.#fetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
117
+ this.#cookies = opts.cookies === 'manual' ? new Map() : null;
118
+ this.#store = this.#cookies ? opts.cookieStore : undefined;
119
+ if (this.#cookies && this.#store) {
120
+ for (const [k, v] of Object.entries(this.#store.read())) this.#cookies.set(k, v);
121
+ }
122
+ this.#retry = { ...DEFAULT_RETRY, ...opts.retry };
123
+ this.#sleep = opts.sleep ?? wait;
124
+ this.#random = opts.random ?? Math.random;
125
+ this.#newKey = opts.newIdempotencyKey ?? (() => crypto.randomUUID());
126
+ this.#validate = opts.validate ?? true;
127
+ this.#headers = opts.headers ?? {};
128
+ this.#onRetry = opts.onRetry;
129
+ }
130
+
131
+ /** D1 Sessions 的因果一致性令牌。读自己刚写的数据全靠它 */
132
+ get bookmark(): string | null { return this.#bookmark; }
133
+ set bookmark(v: string | null) { this.#bookmark = v; }
134
+
135
+ newIdempotencyKey(): string { return this.#newKey(); }
136
+
137
+ /** 只在 cookies: 'manual' 下有内容;浏览器模式恒为空 */
138
+ get cookieHeader(): string {
139
+ if (!this.#cookies) return '';
140
+ return [...this.#cookies].map(([k, v]) => `${k}=${v}`).join('; ');
141
+ }
142
+
143
+ clearCookies(): void {
144
+ this.#cookies?.clear();
145
+ this.#store?.write({});
146
+ }
147
+
148
+ async request<T>(spec: RequestSpec<T>): Promise<T> {
149
+ // 幂等键在循环外生成:重试必须复用同一个 key,否则重试就是第二次下注
150
+ const key = spec.idempotencyKey;
151
+ const safeToRetry = spec.method === 'GET' || key !== undefined;
152
+ const url = this.#url(spec);
153
+
154
+ for (let attempt = 1; ; attempt++) {
155
+ let err: GameKitError;
156
+ try {
157
+ return await this.#once(spec, url, key);
158
+ } catch (e) {
159
+ err = e instanceof GameKitError
160
+ ? e
161
+ // fetch 自己抛 = 连接层面失败,请求多半没到服务端
162
+ : new GameKitError('INTERNAL', `请求失败:${(e as Error).message}`, { cause: String(e) });
163
+ }
164
+
165
+ if (!safeToRetry || !isRetryable(err) || attempt >= this.#retry.attempts) throw err;
166
+
167
+ const delayMs = this.#backoff(attempt);
168
+ this.#onRetry?.({ attempt, delayMs, error: err, method: spec.method, path: spec.path });
169
+ await this.#sleep(delayMs);
170
+ }
171
+ }
172
+
173
+ #backoff(attempt: number): number {
174
+ const full = Math.min(this.#retry.maxDelayMs, this.#retry.baseDelayMs * 2 ** (attempt - 1));
175
+ // 半抖动:多个客户端撞上同一次 NONCE_CONFLICT 时不要同步重试
176
+ return Math.round(full * (0.5 + this.#random() * 0.5));
177
+ }
178
+
179
+ #url(spec: RequestSpec<unknown>): string {
180
+ const qs = new URLSearchParams();
181
+ for (const [k, v] of Object.entries(spec.query ?? {})) {
182
+ if (v !== undefined) qs.set(k, String(v));
183
+ }
184
+ const q = qs.toString();
185
+ return `${this.baseUrl}${spec.path}${q ? `?${q}` : ''}`;
186
+ }
187
+
188
+ async #once<T>(spec: RequestSpec<T>, url: string, key: string | undefined): Promise<T> {
189
+ const headers: Record<string, string> = { ...this.#headers, ...spec.headers };
190
+ let body: BodyInit | undefined;
191
+
192
+ if (spec.json !== undefined) {
193
+ headers['content-type'] = 'application/json';
194
+ body = JSON.stringify(spec.json);
195
+ } else if (spec.body) {
196
+ headers['content-type'] = 'application/octet-stream';
197
+ // 传 ArrayBuffer 而不是视图:subarray 出来的视图带 byteOffset,
198
+ // 有些 fetch 实现会连整个底层 buffer 一起发出去
199
+ body = spec.body.slice().buffer as ArrayBuffer;
200
+ }
201
+
202
+ if (key) headers['idempotency-key'] = key;
203
+ if (this.#bookmark) headers['x-d1-bookmark'] = this.#bookmark;
204
+ const cookie = this.cookieHeader;
205
+ if (cookie) headers.cookie = cookie;
206
+
207
+ const res = await this.#fetch(url, {
208
+ method: spec.method,
209
+ headers,
210
+ body,
211
+ credentials: 'include',
212
+ });
213
+
214
+ this.#absorb(res);
215
+
216
+ if (!res.ok) throw toError(res.status, await this.#payload(res));
217
+
218
+ if (spec.binary) return new Uint8Array(await res.arrayBuffer()) as T;
219
+
220
+ const data = await this.#payload(res);
221
+ if (!this.#validate || !spec.schema) return data as T;
222
+ const parsed = spec.schema.safeParse(data);
223
+ if (!parsed.success) {
224
+ throw new ClientError('INTERNAL', `${spec.method} ${spec.path} 的响应不符合协议`, parsed.error.issues);
225
+ }
226
+ return parsed.data;
227
+ }
228
+
229
+ #absorb(res: Response): void {
230
+ const bm = res.headers.get('x-d1-bookmark');
231
+ if (bm) this.#bookmark = bm;
232
+
233
+ if (!this.#cookies) return;
234
+ const raw: string[] = typeof res.headers.getSetCookie === 'function'
235
+ ? res.headers.getSetCookie()
236
+ : [res.headers.get('set-cookie')].filter((v): v is string => v !== null);
237
+ let changed = false;
238
+ for (const line of raw) {
239
+ const pair = line.split(';', 1)[0] ?? '';
240
+ const eq = pair.indexOf('=');
241
+ if (eq <= 0) continue;
242
+ this.#cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
243
+ changed = true;
244
+ }
245
+ if (changed) this.#store?.write(Object.fromEntries(this.#cookies));
246
+ }
247
+
248
+ async #payload(res: Response): Promise<unknown> {
249
+ const text = await res.text();
250
+ if (!text) return null;
251
+ try { return JSON.parse(text) as unknown; } catch { return text; }
252
+ }
253
+ }
@@ -0,0 +1,211 @@
1
+ /* @gamekit/client —— 后端 REST 接口的类型化封装。
2
+ *
3
+ * 请求与响应的形状全部来自 @gamekit/protocol,这里一个 schema 都不定义。
4
+ * 客户端重新声明一遍协议的话,两边漂移时没有任何东西会报错。 */
5
+ import {
6
+ type BetResult as TBetResult, BetResult,
7
+ type BooksResult as TBooksResult, BooksResult,
8
+ type ChunkResult as TChunkResult, ChunkResult,
9
+ type CreateGame, type DeclareTable,
10
+ type DeclareTableResult as TDeclareTableResult, DeclareTableResult,
11
+ type Game as TGame, Game,
12
+ type GameDetail as TGameDetail, GameDetail,
13
+ type GamePage as TGamePage, GamePage,
14
+ type GameTable as TGameTable, GameTable,
15
+ GKLT1_HEADER_BYTES, GKLT1_ROW_BYTES,
16
+ GameKitError,
17
+ type ListGamesQuery, type ListRoundsQuery,
18
+ MAX_BOOKS_PER_CHUNK,
19
+ type PlaceBet, type PublishGame,
20
+ type RotateResult as TRotateResult, RotateResult,
21
+ type RoundDetail as TRoundDetail, RoundDetail,
22
+ type RoundPage as TRoundPage, RoundPage,
23
+ type SeedState as TSeedState, SeedState,
24
+ type SessionView as TSessionView, SessionView,
25
+ type UpdateGame,
26
+ type VerifyRoundRequest, type VerifyRoundResult as TVerifyRoundResult, VerifyRoundResult,
27
+ type VerifySpec as TVerifySpec, VerifySpec,
28
+ type VerifyTableResult as TVerifyTableResult, VerifyTableResult,
29
+ } from '../protocol/index.ts';
30
+ import { Sha256 } from '../sdk/hash.ts';
31
+ import { z } from 'zod';
32
+ import { ClientError, Http, type ClientOptions } from './http.ts';
33
+
34
+ export * from './http.ts';
35
+
36
+ /** 服务端说「重发是安全的」才是安全的,不要按状态码自己猜 */
37
+ export const isRetryableError = (e: unknown): boolean =>
38
+ e instanceof GameKitError && e.toJSON().error.retryable;
39
+
40
+ const Health = z.object({ ok: z.boolean() });
41
+
42
+ export interface BetOptions {
43
+ /** 不传就自动生成。重试时复用同一个,所以传自己的 key 只在跨进程续传时才有必要 */
44
+ idempotencyKey?: string;
45
+ }
46
+
47
+ export interface UploadOptions {
48
+ /** 每块多少行。10 万行的表一块传完也行,这里的上限是请求体大小而不是行数 */
49
+ chunkRows?: number;
50
+ onProgress?: (p: { rowsIngested: number; rowCount: number }) => void;
51
+ }
52
+
53
+ export interface BookEntry { simId: number; frames: string }
54
+
55
+ export function createClient(opts: ClientOptions) {
56
+ const http = new Http(opts);
57
+
58
+ const tables = {
59
+ declare: (gameId: string, body: DeclareTable): Promise<TDeclareTableResult> =>
60
+ http.request({
61
+ method: 'POST', path: `/v1/games/${gameId}/tables`, json: body, schema: DeclareTableResult,
62
+ }),
63
+
64
+ chunk: (gameId: string, tableId: string, bytes: Uint8Array): Promise<TChunkResult> =>
65
+ http.request({
66
+ method: 'PUT', path: `/v1/games/${gameId}/tables/${tableId}/chunk`,
67
+ body: bytes, schema: ChunkResult,
68
+ }),
69
+
70
+ /**
71
+ * 分块传完整张表。
72
+ *
73
+ * 切分规则是格式定的,不是随便切的:第一块必须以 64 字节头部开头,
74
+ * 之后每块都必须是 16 字节行的整数倍。切错了服务端只会回一句
75
+ * 「块长度必须是 16 的整数倍」,排查起来很费时间。
76
+ */
77
+ upload: async (
78
+ gameId: string, tableId: string, bytes: Uint8Array, o: UploadOptions = {},
79
+ ): Promise<TChunkResult> => {
80
+ const rowCount = (bytes.length - GKLT1_HEADER_BYTES) / GKLT1_ROW_BYTES;
81
+ if (!Number.isInteger(rowCount) || rowCount < 1) {
82
+ throw new ClientError('TABLE_INVALID_FORMAT', `${bytes.length} 字节不是合法的 GKLT1 长度`);
83
+ }
84
+ const step = Math.max(1, o.chunkRows ?? 20_000);
85
+ let last: TChunkResult = { rowsIngested: 0, done: false, rowCount };
86
+
87
+ for (let i = 0; i < rowCount; i += step) {
88
+ const from = i === 0 ? 0 : GKLT1_HEADER_BYTES + i * GKLT1_ROW_BYTES;
89
+ const to = Math.min(GKLT1_HEADER_BYTES + (i + step) * GKLT1_ROW_BYTES, bytes.length);
90
+ last = await tables.chunk(gameId, tableId, bytes.subarray(from, to));
91
+ o.onProgress?.({ rowsIngested: last.rowsIngested, rowCount });
92
+ }
93
+ return last;
94
+ },
95
+
96
+ books: (gameId: string, tableId: string, books: readonly BookEntry[]): Promise<TBooksResult> =>
97
+ http.request({
98
+ method: 'PUT', path: `/v1/games/${gameId}/tables/${tableId}/books`,
99
+ json: { books }, schema: BooksResult,
100
+ }),
101
+
102
+ /** 整表的演出数据,自动切批。要么一行不传,要么一行不少——部分覆盖会被终验拒掉 */
103
+ uploadBooks: async (
104
+ gameId: string, tableId: string, books: readonly BookEntry[],
105
+ ): Promise<TBooksResult> => {
106
+ let last: TBooksResult = { ingested: 0, rowCount: 0 };
107
+ for (let i = 0; i < books.length; i += MAX_BOOKS_PER_CHUNK) {
108
+ last = await tables.books(gameId, tableId, books.slice(i, i + MAX_BOOKS_PER_CHUNK));
109
+ }
110
+ return last;
111
+ },
112
+
113
+ verify: (gameId: string, tableId: string): Promise<TVerifyTableResult> =>
114
+ http.request({
115
+ method: 'POST', path: `/v1/games/${gameId}/tables/${tableId}/verify`,
116
+ schema: VerifyTableResult,
117
+ }),
118
+
119
+ get: (gameId: string, tableId: string): Promise<TGameTable> =>
120
+ http.request({
121
+ method: 'GET', path: `/v1/games/${gameId}/tables/${tableId}`, schema: GameTable,
122
+ }),
123
+
124
+ /**
125
+ * 公开下载已验证的表。默认自己算一遍 sha256——这是可证明公平的第一环,
126
+ * 「服务端说这份字节的哈希是 X」和「我自己算出来是 X」是完全不同的两件事。
127
+ */
128
+ download: async (contentHash: string, o: { verify?: boolean } = {}): Promise<Uint8Array> => {
129
+ const bytes = await http.request<Uint8Array>({
130
+ method: 'GET', path: `/v1/tables/${contentHash}`, binary: true,
131
+ });
132
+ if (o.verify !== false) {
133
+ const actual = new Sha256().update(bytes).hex();
134
+ if (actual !== contentHash) {
135
+ throw new ClientError('TABLE_HASH_MISMATCH',
136
+ `下载的字节哈希 ${actual} 与请求的 ${contentHash} 不符`, { actual, expected: contentHash });
137
+ }
138
+ }
139
+ return bytes;
140
+ },
141
+ };
142
+
143
+ return {
144
+ http,
145
+
146
+ health: (): Promise<z.infer<typeof Health>> =>
147
+ http.request({ method: 'GET', path: '/v1/health', schema: Health }),
148
+
149
+ session: {
150
+ bootstrap: (body: { clientSeed?: string } = {}): Promise<TSessionView> =>
151
+ http.request({ method: 'POST', path: '/v1/session/bootstrap', json: body, schema: SessionView }),
152
+ me: (): Promise<TSessionView> =>
153
+ http.request({ method: 'GET', path: '/v1/me', schema: SessionView }),
154
+ },
155
+
156
+ games: {
157
+ create: (body: CreateGame): Promise<TGame> =>
158
+ http.request({ method: 'POST', path: '/v1/games', json: body, schema: Game }),
159
+ update: (id: string, body: UpdateGame): Promise<TGame> =>
160
+ http.request({ method: 'PATCH', path: `/v1/games/${id}`, json: body, schema: Game }),
161
+ publish: (id: string, body: PublishGame): Promise<TGame> =>
162
+ http.request({ method: 'POST', path: `/v1/games/${id}/publish`, json: body, schema: Game }),
163
+ list: (query: Partial<ListGamesQuery> = {}): Promise<TGamePage> =>
164
+ http.request({ method: 'GET', path: '/v1/games', query, schema: GamePage }),
165
+ /** 详情比列表多一份已生效的 mode 清单——对局页要靠它知道有哪几张表可选 */
166
+ get: (id: string): Promise<TGameDetail> =>
167
+ http.request({ method: 'GET', path: `/v1/games/${id}`, schema: GameDetail }),
168
+ },
169
+
170
+ tables,
171
+
172
+ bets: {
173
+ /**
174
+ * 下注。
175
+ *
176
+ * 幂等键在这里生成而不是在传输层的重试循环里——重试必须复用同一个 key,
177
+ * 否则 NONCE_CONFLICT 的重试就变成了第二次真实下注。
178
+ */
179
+ place: (body: PlaceBet, o: BetOptions = {}): Promise<TBetResult> =>
180
+ http.request({
181
+ method: 'POST', path: '/v1/bets', json: body, schema: BetResult,
182
+ idempotencyKey: o.idempotencyKey ?? http.newIdempotencyKey(),
183
+ }),
184
+ },
185
+
186
+ rounds: {
187
+ list: (query: Partial<ListRoundsQuery> = {}): Promise<TRoundPage> =>
188
+ http.request({ method: 'GET', path: '/v1/rounds', query, schema: RoundPage }),
189
+ get: (id: string): Promise<TRoundDetail> =>
190
+ http.request({ method: 'GET', path: `/v1/rounds/${id}`, schema: RoundDetail }),
191
+ },
192
+
193
+ seeds: {
194
+ current: (): Promise<TSeedState> =>
195
+ http.request({ method: 'GET', path: '/v1/seeds/current', schema: SeedState }),
196
+ setClientSeed: (clientSeed: string): Promise<TRotateResult> =>
197
+ http.request({ method: 'POST', path: '/v1/seeds/client', json: { clientSeed }, schema: RotateResult }),
198
+ rotate: (): Promise<TRotateResult> =>
199
+ http.request({ method: 'POST', path: '/v1/seeds/rotate', schema: RotateResult }),
200
+ },
201
+
202
+ verify: {
203
+ spec: (): Promise<TVerifySpec> =>
204
+ http.request({ method: 'GET', path: '/v1/verify/spec', schema: VerifySpec }),
205
+ round: (body: VerifyRoundRequest): Promise<TVerifyRoundResult> =>
206
+ http.request({ method: 'POST', path: '/v1/verify/round', json: body, schema: VerifyRoundResult }),
207
+ },
208
+ };
209
+ }
210
+
211
+ export type GameKitClient = ReturnType<typeof createClient>;
package/client/node.ts ADDED
@@ -0,0 +1,61 @@
1
+ /* Node 侧的 cookie 落盘。浏览器不会走到这里——它自己有 cookie jar。
2
+ *
3
+ * 为什么要持久化:不存的话每次进程启动都是一个新匿名用户,而 `games` 的唯一约束是
4
+ * `(owner_id, slug)`,换了 owner 同一个 slug 就能再建一份。端到端脚本跑十次,
5
+ * 开发库里就躺着十份 `books-full`、十个匿名钱包。 */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import type { SessionView } from '../protocol/index.ts';
10
+ import type { CookieStore } from './http.ts';
11
+
12
+ /** 默认落在临时目录:不污染仓库,跨 cwd 一致,重装系统自然失效 */
13
+ export const defaultSessionFile = (): string =>
14
+ process.env.GAMEKIT_SESSION_FILE ?? join(tmpdir(), 'gamekit-dev-session.json');
15
+
16
+ export function fileCookieStore(path = defaultSessionFile()): CookieStore {
17
+ return {
18
+ read() {
19
+ try {
20
+ if (!existsSync(path)) return {};
21
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown;
22
+ if (raw === null || typeof raw !== 'object') return {};
23
+ return Object.fromEntries(
24
+ Object.entries(raw as Record<string, unknown>)
25
+ .filter((e): e is [string, string] => typeof e[1] === 'string'),
26
+ );
27
+ } catch {
28
+ // 坏文件当没有:一个开发期的便利设施不值得让脚本跑不起来
29
+ return {};
30
+ }
31
+ },
32
+ write(cookies) {
33
+ try {
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ writeFileSync(path, `${JSON.stringify(cookies, null, 2)}\n`);
36
+ } catch { /* 写不进去就算了,退化成不持久化 */ }
37
+ },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * 开发脚本的会话引导:复用上次那个匿名用户,钱不够了就换一个。
43
+ *
44
+ * 两难是这样的:不复用的话每跑一次就在开发库里留一个用户、一个钱包、一整套同名游戏;
45
+ * 复用的话余额会被历史下注耗光——赠金只发一次,而 RTP 低于 100%,跑够多次必然归零。
46
+ *
47
+ * 所以复用是默认,余额不够时清掉 cookie 换一个新用户。垃圾只在真的需要时才产生。
48
+ * 真实玩家当然不该有这种行为,所以它住在这里而不是 client 的主入口。
49
+ */
50
+ export async function bootstrapForScript(
51
+ api: {
52
+ session: { bootstrap(body?: { clientSeed?: string }): Promise<SessionView> };
53
+ http: { clearCookies(): void };
54
+ },
55
+ minCents = 20_000,
56
+ ): Promise<SessionView> {
57
+ const me = await api.session.bootstrap();
58
+ if (me.wallet.balanceCents >= minCents) return me;
59
+ api.http.clearCookies();
60
+ return api.session.bootstrap();
61
+ }
@@ -0,0 +1,73 @@
1
+ /* 新游戏脚手架的库函数面。
2
+ *
3
+ * 模板不是空壳,是一个跑得通的最小游戏(抛硬币):装完就能 bun run dev、
4
+ * bun run verify、bun run test、bun run build 全绿。
5
+ * 从一个能跑的东西开始改,比对着一堆 TODO 填空可靠得多。
6
+ *
7
+ * 抽成库函数是因为有三个调用方:CLI、studio 的 scaffold 工具、以及
8
+ * npm 打包脚本的产物自验——最后一个尤其要紧,它生成的项目就是创作者的真实起点,
9
+ * 用别的东西代替就验不到「模板能不能脱离仓库跑」。 */
10
+ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ /* 仓库内是 packages/create-game/{src,template},压平进 npm 包后是 create-game/{scaffold.ts,template}——
16
+ 两种布局都要找得到 */
17
+ export const TEMPLATE_DIR = [join(HERE, '..', 'template'), join(HERE, 'template')].find(existsSync)
18
+ ?? join(HERE, '..', 'template');
19
+
20
+ export const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
21
+
22
+ export interface ScaffoldOptions {
23
+ id: string;
24
+ title?: string;
25
+ /** 目标目录。省略就是仓库内的 games/<id> */
26
+ dest: string;
27
+ /**
28
+ * 覆盖 package.json 里 gamekit777 的版本说明符。
29
+ * 仓库内是 `workspace:*`,产物自验要指到 dist-npm,创作者机器上是版本号。
30
+ */
31
+ gamekitSpec?: string;
32
+ }
33
+
34
+ export interface ScaffoldResult { dest: string; files: string[] }
35
+
36
+ const walk = (dir: string): string[] =>
37
+ readdirSync(dir).flatMap((n) => {
38
+ const p = join(dir, n);
39
+ return statSync(p).isDirectory() ? walk(p) : [p];
40
+ });
41
+
42
+ /** 驼峰化:coin-flip → CoinFlip,用作类型名前缀 */
43
+ const pascal = (id: string): string =>
44
+ id.split('-').map((w) => w[0]!.toUpperCase() + w.slice(1)).join('');
45
+
46
+ export function scaffoldGame(o: ScaffoldOptions): ScaffoldResult {
47
+ if (!ID_PATTERN.test(o.id)) {
48
+ throw new Error(`id "${o.id}" 要是小写短横线形式——它同时是包名、输出目录名和 CSS 命名空间`);
49
+ }
50
+ if (existsSync(o.dest)) throw new Error(`${o.dest} 已经存在`);
51
+
52
+ cpSync(TEMPLATE_DIR, o.dest, { recursive: true });
53
+
54
+ const files: string[] = [];
55
+ for (const file of walk(o.dest)) {
56
+ if (/\.(png|jpe?g|gif|webp|ico|woff2?)$/i.test(file)) { files.push(file); continue; }
57
+ let out = readFileSync(file, 'utf8')
58
+ .replaceAll('__ID__', o.id)
59
+ .replaceAll('__TITLE__', o.title ?? o.id)
60
+ .replaceAll('__PASCAL__', pascal(o.id));
61
+ if (o.gamekitSpec && /package\.json(\.tmpl)?$/.test(file)) {
62
+ out = out.replace(/("gamekit777"\s*:\s*)"[^"]*"/, `$1${JSON.stringify(o.gamekitSpec)}`);
63
+ }
64
+ writeFileSync(file, out);
65
+ // 模板文件带 .tmpl 后缀,免得脚手架自己的 lint / 构建去处理它们;
66
+ // .gitignore 叫 _gitignore,因为 npm 打包会把包里的 .gitignore 改名或丢掉
67
+ let final = file.endsWith('.tmpl') ? file.slice(0, -5) : file;
68
+ if (final.endsWith('/_gitignore')) final = `${final.slice(0, -'_gitignore'.length)}.gitignore`;
69
+ if (final !== file) renameSync(file, final);
70
+ files.push(final);
71
+ }
72
+ return { dest: o.dest, files };
73
+ }