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,56 @@
1
+ /* 多核采样。
2
+ *
3
+ * round() 是纯函数,种子空间天然可并行——分片怎么切都不影响结果,
4
+ * 所以这里不需要任何同步,只需要把 [0, sims) 切开再按序拼回去。 */
5
+ import { availableParallelism } from 'node:os';
6
+ import { Worker } from 'node:worker_threads';
7
+ import type { SampleBatch } from './sample.ts';
8
+
9
+ interface WorkerReply {
10
+ payoutCenti: Int32Array;
11
+ data: Uint8Array;
12
+ offsets: Int32Array;
13
+ draws: number;
14
+ }
15
+
16
+ /** 低于这个局数,起线程的开销比采样本身还大 */
17
+ const PARALLEL_THRESHOLD = 5_000;
18
+
19
+ export const defaultConcurrency = (): number => Math.max(1, Math.min(8, availableParallelism() - 1));
20
+
21
+ export async function sampleParallel(
22
+ specUrl: string, modeIndex: number, sims: number, concurrency: number,
23
+ ): Promise<SampleBatch> {
24
+ const workers = Math.max(1, Math.min(concurrency, Math.ceil(sims / PARALLEL_THRESHOLD)));
25
+ const per = Math.ceil(sims / workers);
26
+ const jobs = Array.from({ length: workers }, (_, i) => ({
27
+ from: i * per, to: Math.min(sims, (i + 1) * per),
28
+ })).filter((j) => j.to > j.from);
29
+
30
+ const replies = await Promise.all(jobs.map((j) => runOne(specUrl, modeIndex, j.from, j.to)));
31
+
32
+ const payoutCenti = new Int32Array(sims);
33
+ const books: Uint8Array[] = [];
34
+ let draws = 0;
35
+ let at = 0;
36
+ for (const r of replies) {
37
+ payoutCenti.set(r.payoutCenti, at);
38
+ at += r.payoutCenti.length;
39
+ draws += r.draws;
40
+ for (let i = 0; i + 1 < r.offsets.length; i++) {
41
+ books.push(r.data.subarray(r.offsets[i]!, r.offsets[i + 1]!));
42
+ }
43
+ }
44
+ return { payoutCenti, books, draws };
45
+ }
46
+
47
+ function runOne(specUrl: string, modeIndex: number, from: number, to: number): Promise<WorkerReply> {
48
+ return new Promise((resolve, reject) => {
49
+ const w = new Worker(new URL('./sample-worker.ts', import.meta.url), {
50
+ workerData: { specUrl, modeIndex, from, to },
51
+ });
52
+ w.once('message', (m: WorkerReply) => { resolve(m); void w.terminate(); });
53
+ w.once('error', reject);
54
+ w.once('exit', (code) => { if (code !== 0) reject(new Error(`采样线程退出码 ${code}`)); });
55
+ });
56
+ }
@@ -0,0 +1,31 @@
1
+ /* 采样工作线程。
2
+ *
3
+ * 它 import 的是发布清单本身而不是纯逻辑——codec 是个函数,传不过线程边界,
4
+ * 只能让每个线程各自把清单加载一遍。 */
5
+ import { parentPort, workerData } from 'node:worker_threads';
6
+ import { sampleRange } from './sample.ts';
7
+ import type { GameDefinition } from '../sdk/index.ts';
8
+
9
+ interface Job { specUrl: string; modeIndex: number; from: number; to: number }
10
+
11
+ const job = workerData as Job;
12
+ const mod = (await import(job.specUrl)) as { default: GameDefinition<unknown, unknown> };
13
+ const batch = sampleRange(mod.default, job.modeIndex, job.from, job.to);
14
+
15
+ // books 拼成一整块再过线程边界:十万个小 Uint8Array 各自结构化克隆一次,
16
+ // 光是克隆开销就能把多核省下的时间还回去
17
+ let total = 0;
18
+ for (const b of batch.books) total += b.length;
19
+ const data = new Uint8Array(total);
20
+ const offsets = new Int32Array(batch.books.length + 1);
21
+ let at = 0;
22
+ for (let i = 0; i < batch.books.length; i++) {
23
+ data.set(batch.books[i]!, at);
24
+ at += batch.books[i]!.length;
25
+ offsets[i + 1] = at;
26
+ }
27
+
28
+ parentPort!.postMessage(
29
+ { payoutCenti: batch.payoutCenti, data, offsets, draws: batch.draws },
30
+ [batch.payoutCenti.buffer, data.buffer, offsets.buffer] as ArrayBuffer[],
31
+ );
@@ -0,0 +1,131 @@
1
+ /* 采样:用不同种子跑 round(),收集每局的赔付。
2
+ *
3
+ * 这是公式模型和表模型的接缝,所以这里的校验比别处密——
4
+ * 采样阶段放过去的不一致,到线上会变成「表里写 0x、客户端演出 4.64x」,
5
+ * 而且只在某些种子上出现。 */
6
+ import { PAYOUT_SCALE, MAX_PAYOUT_CENTI } from '../protocol/index.ts';
7
+ import { sampleSeed, type BookCodec, type GameDefinition, type Mode, type Sample } from '../sdk/index.ts';
8
+
9
+ /** 采样注额。取 PAYOUT_SCALE 使 payout(分)恰好等于 payoutCenti,省掉一次换算 */
10
+ export const SAMPLE_BET_CENTS = PAYOUT_SCALE;
11
+
12
+ /**
13
+ * 跨注额线性抽检用的探针注额。
14
+ * 注额区间是运营侧的事,不在游戏清单里;这三档覆盖了小注、整数倍、大注三种取整路径。
15
+ */
16
+ export const LINEARITY_PROBE_BETS = [100, 300, 1_000_000] as const;
17
+
18
+ export interface SampleBatch {
19
+ /** 下标即 simId */
20
+ payoutCenti: Int32Array;
21
+ /** codec 为 'none' 时为空数组 */
22
+ books: Uint8Array[];
23
+ draws: number;
24
+ }
25
+
26
+ export class SampleError extends Error {
27
+ constructor(message: string, readonly index?: number) {
28
+ super(message);
29
+ this.name = 'SampleError';
30
+ }
31
+ }
32
+
33
+ /** mode 冻结出来的配置,再 validate 一次。round() 内部还会再 normalize,这里是为了尽早报错 */
34
+ export function freezeConfig<C, O>(spec: GameDefinition<C, O>, mode: Mode<C>): C {
35
+ const config = spec.configOf(mode.name);
36
+ const issues = spec.validate(config);
37
+ if (issues.length > 0) {
38
+ throw new SampleError(
39
+ `mode "${mode.name}" 的配置不合法:${issues.map((i) => `${i.field}(${i.code})`).join('、')}`);
40
+ }
41
+ return config;
42
+ }
43
+
44
+ const encodeBook = <O>(codec: BookCodec<O>, s: Sample<O>): Uint8Array | null => {
45
+ if (codec === 'none') return null;
46
+ if (codec === 'seed') return new TextEncoder().encode(s.seed.server);
47
+ return codec.encode(s);
48
+ };
49
+
50
+ /**
51
+ * 跑一段 [from, to) 的采样。
52
+ *
53
+ * 分片是纯的:同一个 (seedPrefix, mode, index) 恒得同一局,
54
+ * 所以怎么切、切几片、谁先跑完都不影响结果。
55
+ */
56
+ export function sampleRange<C, O>(
57
+ spec: GameDefinition<C, O>, modeIndex: number, from: number, to: number,
58
+ ): SampleBatch {
59
+ const mode = spec.modes[modeIndex]!;
60
+ const config = freezeConfig(spec, mode);
61
+ const n = to - from;
62
+
63
+ const payoutCenti = new Int32Array(n);
64
+ const books: Uint8Array[] = spec.book === 'none' ? [] : new Array<Uint8Array>(n);
65
+ let draws = 0;
66
+
67
+ for (let i = 0; i < n; i++) {
68
+ const index = from + i;
69
+ const seed = sampleSeed(spec.seedPrefix, mode.name, index);
70
+ const result = spec.round({ config, bet: SAMPLE_BET_CENTS, seed });
71
+ const centi = checkPayout(result.payout, result.multiplier, index);
72
+
73
+ payoutCenti[i] = centi;
74
+ // 抽了多少字不再进 proof,但采样统计还想知道;round 不暴露 rng,这里数不到,先记 0
75
+ draws += 0;
76
+ if (spec.book !== 'none') {
77
+ books[i] = encodeBook(spec.book, { index, seed, payoutCenti: centi, result })!;
78
+ }
79
+ }
80
+
81
+ return { payoutCenti, books, draws };
82
+ }
83
+
84
+ /**
85
+ * bet = 100 分时 payout(分)就是 payoutCenti。但要确认游戏本来就在 centi 的格子上——
86
+ * 倍率 1.965 会让 payoutCenti 变成 196.5,平台存不下,客户端和服务端从此各算各的。
87
+ */
88
+ function checkPayout(payout: number, multiplier: number, index: number): number {
89
+ if (!Number.isInteger(payout) || payout < 0) {
90
+ throw new SampleError(`第 ${index} 局的 payout ${payout} 不是非负整数`, index);
91
+ }
92
+ if (payout > MAX_PAYOUT_CENTI) {
93
+ throw new SampleError(
94
+ `第 ${index} 局赔付 ${payout / 100} 倍超过平台上限 ${MAX_PAYOUT_CENTI / 100} 倍`, index);
95
+ }
96
+ const exact = multiplier * PAYOUT_SCALE;
97
+ if (Math.abs(exact - payout) > 1e-6 * Math.max(1, payout)) {
98
+ throw new SampleError(
99
+ `第 ${index} 局倍率 ${multiplier} 在 centi 精度下表达不了:` +
100
+ `×100 = ${exact},而 payout = ${payout}。平台只存整数 payoutCenti`, index);
101
+ }
102
+ return payout;
103
+ }
104
+
105
+ /**
106
+ * 跨注额线性抽检。
107
+ *
108
+ * 平台按 winCents = betCents/100 × payoutCenti 赔付,游戏自己算 payout。
109
+ * 两者在 bet=100 上对得上不代表在 bet=5000 上也对得上——floor、四舍五入、
110
+ * 或者「注额越大越慷慨」的设计都会在这里露馅,而线上露馅就是资损。
111
+ */
112
+ export function checkBetLinearity<C, O>(
113
+ spec: GameDefinition<C, O>, modeIndex: number, indices: readonly number[],
114
+ ): void {
115
+ const mode = spec.modes[modeIndex]!;
116
+ const config = freezeConfig(spec, mode);
117
+
118
+ for (const index of indices) {
119
+ const seed = sampleSeed(spec.seedPrefix, mode.name, index);
120
+ const base = spec.round({ config, bet: SAMPLE_BET_CENTS, seed });
121
+ for (const bet of LINEARITY_PROBE_BETS) {
122
+ const got = spec.round({ config, bet, seed });
123
+ const want = (bet / PAYOUT_SCALE) * base.payout;
124
+ if (got.payout !== want) {
125
+ throw new SampleError(
126
+ `第 ${index} 局在注额 ${bet} 分下算出 ${got.payout},` +
127
+ `而平台按 ${bet}/100 × ${base.payout} 会赔 ${want}。赔付不是注额的线性函数,打不成表`, index);
128
+ }
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,8 @@
1
+ /* 发布清单就是游戏清单本身:defineGame 住在 @gamekit/sdk,这里只是给老引用留个名字。
2
+ *
3
+ * 为什么不能从 GameModule 推出来:表模型要求「一张表 = 一种赔付分布」,
4
+ * 而哪些 config 会改变分布,只有游戏作者知道——它写在 modes 里。 */
5
+ export type {
6
+ BookCodec, GameDefinition as PublishSpec, GameDefinition, GameSpec, Mode, Sample,
7
+ } from '../sdk/index.ts';
8
+ export { defineGame, sampleSeed, SAMPLE_CLIENT_SEED } from '../sdk/index.ts';
@@ -0,0 +1,244 @@
1
+ /* 采样结果 → 权重表。
2
+ *
3
+ * 三步:按赔付聚合成 group、解权重、量化成整数并把 RTP 钉死。
4
+ * 第三步是最容易糊弄过去的一步——量化残差落在 100 ppm 容差里也能过审,
5
+ * 但「声明 96.7% 实算 96.699%」是个永远解释不清的数字。 */
6
+ import type { LutRow } from '../lut/index.ts';
7
+ import { type Group, type Segment, solve } from '../weights/index.ts';
8
+
9
+ /**
10
+ * 权重的量化尺度。2^50 而不是更大:总权重必须 ≤ 2^53-1,
11
+ * D1 把 INTEGER 反序列化成 JS number,超了会静默丢精度。
12
+ */
13
+ export const WEIGHT_SCALE = 2 ** 50;
14
+
15
+ export interface RowPlan {
16
+ /** 每行的赔付,下标即 simId */
17
+ payoutCenti: Int32Array;
18
+ /** 每行属于哪个 group */
19
+ group: Int32Array;
20
+ /** 这一行代表哪个采样局。合并行时取组内第一个 */
21
+ sample: Int32Array;
22
+ /** 这一行代表多少个采样局。组内各行按它分权重,Σ(组内) = count[g] */
23
+ multiplicity: Int32Array;
24
+ groups: Group[];
25
+ /** 每个 group 的样本数 */
26
+ count: Int32Array;
27
+ }
28
+
29
+ /**
30
+ * 把采样局摊成表的行。
31
+ *
32
+ * collapse 时一种赔付只留一行:演出能从 payout 推导的游戏(coin-flip)不需要
33
+ * 「牌局不重复」,两行就够。不 collapse 时按 (payout, book 字节) 去重——同一副牌
34
+ * 采到两次就只留一行、权重记两份:行数多是为了玩家不会反复看到同一副牌,
35
+ * 而两行一模一样的演出数据对这件事毫无贡献,只让表白白变大。
36
+ */
37
+ export function planRows(payoutCenti: Int32Array, collapse: boolean, books?: readonly Uint8Array[]): RowPlan {
38
+ const byPayout = new Map<number, { g: number; first: number; count: number }>();
39
+ const groupOf = new Int32Array(payoutCenti.length);
40
+
41
+ for (let i = 0; i < payoutCenti.length; i++) {
42
+ const p = payoutCenti[i]!;
43
+ let e = byPayout.get(p);
44
+ if (!e) { e = { g: byPayout.size, first: i, count: 0 }; byPayout.set(p, e); }
45
+ e.count++;
46
+ groupOf[i] = e.g;
47
+ }
48
+
49
+ const entries = [...byPayout.entries()];
50
+ const groups: Group[] = entries.map(([p, e]) => ({ payout: p, prior: e.count, ids: [] }));
51
+ const count = Int32Array.from(entries, ([, e]) => e.count);
52
+
53
+ if (collapse) {
54
+ // simId 必须递增,所以行序就是 group 序;组内代表局用第一个
55
+ const payouts = Int32Array.from(entries, ([p]) => p);
56
+ const samples = Int32Array.from(entries, ([, e]) => e.first);
57
+ const group = Int32Array.from(entries, (_, i) => i);
58
+ for (let g = 0; g < groups.length; g++) groups[g]!.ids = [g];
59
+ return { payoutCenti: payouts, group, sample: samples, multiplicity: Int32Array.from(count), groups, count };
60
+ }
61
+
62
+ /* 按 (payout, frames) 去重。没有 book 字节时每局一行——分不出谁和谁重复 */
63
+ const rowOf = new Map<string, number>();
64
+ const rows: { sample: number; group: number; mult: number }[] = [];
65
+ const dedupe = books !== undefined && books.length === payoutCenti.length;
66
+ for (let i = 0; i < payoutCenti.length; i++) {
67
+ const key = dedupe ? `${payoutCenti[i]}\0${bytesKey(books![i]!)}` : String(i);
68
+ const at = rowOf.get(key);
69
+ if (at !== undefined) { rows[at]!.mult++; continue; }
70
+ rowOf.set(key, rows.length);
71
+ rows.push({ sample: i, group: groupOf[i]!, mult: 1 });
72
+ }
73
+ for (let r = 0; r < rows.length; r++) (groups[rows[r]!.group]!.ids as number[]).push(r);
74
+ return {
75
+ payoutCenti: Int32Array.from(rows, (r) => payoutCenti[r.sample]!),
76
+ group: Int32Array.from(rows, (r) => r.group),
77
+ sample: Int32Array.from(rows, (r) => r.sample),
78
+ multiplicity: Int32Array.from(rows, (r) => r.mult),
79
+ groups, count,
80
+ };
81
+ }
82
+
83
+ /** 字节串当 Map 的键。latin1 一字节一字符,不会撞 */
84
+ const bytesKey = (b: Uint8Array): string => {
85
+ let s = '';
86
+ for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000));
87
+ return s;
88
+ };
89
+
90
+ export interface SolveOutcome {
91
+ /** 每个 group 的概率,Σ = 1 */
92
+ weights: Float64Array;
93
+ iterations: number;
94
+ residual: number;
95
+ }
96
+
97
+ export function solveWeights(
98
+ groups: readonly Group[], declaredRtpPpm: number, betCostCenti: number,
99
+ /* 命中率/波动率目标。不给就只有全局 RTP 一条商业目标,其余交给 KL 最小——
100
+ 那是 `bun run pub` 的默认行为,调参 UI 才会往里塞分段约束 */
101
+ segments: readonly Segment[] = [],
102
+ ): SolveOutcome {
103
+ /* group.payout 用的是 centi,所以 betCost 也必须是 centi——
104
+ RTP = Σw·payout/betCost 两边单位必须一致,否则解出来的权重差 100 倍,
105
+ 而且量化和残差修正都不会报错,只有最终 RTP 是错的 */
106
+ const res = solve({
107
+ groups,
108
+ segments,
109
+ totalRtp: declaredRtpPpm / 1e6,
110
+ betCost: betCostCenti,
111
+ });
112
+ if (!res.ok) throw new Error(`权重求解失败(${res.reason}):${res.detail}`);
113
+ return { weights: res.weights, iterations: res.iterations, residual: res.residual };
114
+ }
115
+
116
+ /** 组内按 multiplicity 分。每行至少 1——权重为 0 的行永不可选,平台在终验时会拒 */
117
+ export function quantize(plan: RowPlan, weights: Float64Array): Float64Array {
118
+ const out = new Float64Array(plan.payoutCenti.length);
119
+ for (let i = 0; i < out.length; i++) {
120
+ const g = plan.group[i]!;
121
+ out[i] = Math.max(1, Math.round((weights[g]! * WEIGHT_SCALE * plan.multiplicity[i]!) / Math.max(1, plan.count[g]!)));
122
+ }
123
+ return out;
124
+ }
125
+
126
+ const roundDiv = (a: bigint, b: bigint): bigint => (2n * a + b) / (2n * b);
127
+
128
+ /** 与后端 finalize 逐位相同的公式。这里算出来的数就是平台会算出来的数 */
129
+ export function exactRtpPpm(
130
+ payoutCenti: Int32Array, weight: Float64Array, betCostCenti: number,
131
+ ): { ppm: number; num: bigint; den: bigint } {
132
+ let num = 0n;
133
+ let total = 0n;
134
+ for (let i = 0; i < payoutCenti.length; i++) {
135
+ const w = BigInt(weight[i]!);
136
+ total += w;
137
+ num += w * BigInt(payoutCenti[i]!);
138
+ }
139
+ const den = total * BigInt(betCostCenti);
140
+ return { ppm: den > 0n ? Number(roundDiv(num * 1_000_000n, den)) : 0, num, den };
141
+ }
142
+
143
+ export interface TrueUp {
144
+ before: number;
145
+ after: number;
146
+ /** 转移了多少权重,以及在哪两行之间 */
147
+ moved: number;
148
+ from: number;
149
+ to: number;
150
+ }
151
+
152
+ /**
153
+ * 残差修正:把量化后的 RTP 钉到与声明完全相等。
154
+ *
155
+ * 在两行之间转移权重:总权重不变(分母不动),分子变化 t×(p_高 − p_低)。
156
+ * 一次转移就够——可调粒度最小是 1 centi,而 ppm 的一格对应分子上
157
+ * 十万量级的余地,落不进格子的情况在这个尺度上不存在。
158
+ *
159
+ * 不做这一步也能过审(100 ppm 容差),但「声明 96.7%、实算 96.699%」
160
+ * 这种数字没法向任何人解释。
161
+ */
162
+ export function trueUpRtp(
163
+ payoutCenti: Int32Array, weight: Float64Array, betCostCenti: number, targetPpm: number,
164
+ ): TrueUp | null {
165
+ const start = exactRtpPpm(payoutCenti, weight, betCostCenti);
166
+ if (start.ppm === targetPpm) return null;
167
+
168
+ let hi = 0;
169
+ let lo = 0;
170
+ for (let i = 1; i < payoutCenti.length; i++) {
171
+ if (payoutCenti[i]! > payoutCenti[hi]!) hi = i;
172
+ if (payoutCenti[i]! < payoutCenti[lo]!) lo = i;
173
+ }
174
+ const d = BigInt(payoutCenti[hi]! - payoutCenti[lo]!);
175
+ if (d <= 0n) throw new Error('整张表只有一种赔付,RTP 无法微调');
176
+
177
+ const wantNum = roundDiv(BigInt(targetPpm) * start.den, 1_000_000n);
178
+ const delta = wantNum - start.num;
179
+ // 四舍五入到最近的可达值;delta 为负时向零取整的方向也要一致
180
+ const t = delta >= 0n ? (2n * delta + d) / (2n * d) : -((2n * -delta + d) / (2n * d));
181
+
182
+ const [inc, dec] = t >= 0n ? [hi, lo] : [lo, hi];
183
+ const amount = t >= 0n ? t : -t;
184
+ if (BigInt(weight[dec]!) - amount < 1n) {
185
+ throw new Error(`残差修正需要从第 ${dec} 行挪走 ${amount},但它只有 ${weight[dec]} 的权重`);
186
+ }
187
+ weight[inc] = Number(BigInt(weight[inc]!) + amount);
188
+ weight[dec] = Number(BigInt(weight[dec]!) - amount);
189
+
190
+ const end = exactRtpPpm(payoutCenti, weight, betCostCenti);
191
+ if (end.ppm !== targetPpm) {
192
+ throw new Error(`残差修正后 RTP 仍是 ${end.ppm} ppm,目标 ${targetPpm} ppm`);
193
+ }
194
+ return { before: start.ppm, after: end.ppm, moved: Number(amount), from: dec, to: inc };
195
+ }
196
+
197
+ export const toLutRows = (payoutCenti: Int32Array, weight: Float64Array): LutRow[] =>
198
+ Array.from(payoutCenti, (p, i) => ({ simId: i, payoutCenti: p, weight: weight[i]! }));
199
+
200
+ /** 少于这么多样本的赔付档位,权重是从个位数样本外推出来的,值得说一声 */
201
+ const THIN_SAMPLE = 30;
202
+ /** 求解后概率相对自然频率的放大倍数。超过它说明目标 RTP 在硬掰分布 */
203
+ const MAX_DISTORTION = 10;
204
+
205
+ export interface PayoutStat {
206
+ payoutCenti: number;
207
+ samples: number;
208
+ /** 自然频率 */
209
+ prior: number;
210
+ /** 求解后的概率 */
211
+ weight: number;
212
+ distortion: number;
213
+ }
214
+
215
+ /**
216
+ * 样本不足的赔付档位要报出来,而不是默默出一张烂表。
217
+ *
218
+ * 爆奖这种自然频率极低的结果,采样几万局可能只撞上三五次;优化器照样能给它
219
+ * 分配权重,但那个权重是从三五个样本外推的,实际 RTP 里最重的一块就压在上面。
220
+ *
221
+ * 放在 table.ts 而不是 build.ts,是因为它只吃 plan + weights + sims、不依赖量化,
222
+ * 于是调参 UI 能在每次求解后就地重算,不必走完整条编码链路。
223
+ */
224
+ export function flagOutliers(
225
+ plan: RowPlan, weights: Float64Array, sims: number,
226
+ ): { thin: PayoutStat[]; distorted: PayoutStat[] } {
227
+ const thin: PayoutStat[] = [];
228
+ const distorted: PayoutStat[] = [];
229
+
230
+ for (let g = 0; g < plan.groups.length; g++) {
231
+ const samples = plan.count[g]!;
232
+ const prior = samples / sims;
233
+ const w = weights[g]!;
234
+ const stat: PayoutStat = {
235
+ payoutCenti: plan.groups[g]!.payout,
236
+ samples, prior, weight: w,
237
+ distortion: prior > 0 ? w / prior : Infinity,
238
+ };
239
+ if (samples < THIN_SAMPLE && w > 0) thin.push(stat);
240
+ if (stat.distortion > MAX_DISTORTION || stat.distortion < 1 / MAX_DISTORTION) distorted.push(stat);
241
+ }
242
+ const bySize = (a: PayoutStat, b: PayoutStat): number => a.samples - b.samples;
243
+ return { thin: thin.sort(bySize), distorted: distorted.sort(bySize) };
244
+ }
@@ -0,0 +1,176 @@
1
+ /* 上传:声明 → 分块 → 演出数据 → 验证 → 发布。纯逻辑那一半。
2
+ *
3
+ * 不碰 node、不自己造 client——调用方给什么身份就用什么身份。
4
+ * 浏览器里的 studio 发布面板用它,身份是页面上登录的那个人;CLI 在 upload.ts 里包一层文件 cookie。 */
5
+ import {
6
+ type CreateGame, type Game, type GameConfig, type GameTable,
7
+ GameKitError, PAYOUT_SCALE, RTP_TOLERANCE_PPM,
8
+ } from '../protocol/index.ts';
9
+ import type { GameKitClient } from '../client/index.ts';
10
+ import { HEADER_BYTES, ROW_BYTES, decodeHeader, readRow, type Verdict } from '../lut/index.ts';
11
+ import type { GameDefinition } from '../sdk/index.ts';
12
+
13
+ /** 上传只需要 BuiltTable 的这几个字段。studio 从产物文件里拼出来的也能过 */
14
+ export interface UploadTable {
15
+ mode: string;
16
+ bytes: Uint8Array;
17
+ contentHash: string;
18
+ rowCount: number;
19
+ betCostCenti: number;
20
+ books: { simId: number; frames: string }[] | null;
21
+ verdict: Pick<Verdict, 'computedRtpPpm'>;
22
+ }
23
+
24
+ export interface UploadOptions {
25
+ client: GameKitClient;
26
+ /** 只发布这些 mode。不给就全发 */
27
+ publishModes?: readonly string[];
28
+ chunkRows?: number;
29
+ onProgress?: (msg: string) => void;
30
+ }
31
+
32
+ export interface UploadResult {
33
+ game: Game;
34
+ tables: GameTable[];
35
+ published: boolean;
36
+ }
37
+
38
+ /** 表里每种赔付的权重之和。总权重 ≤ 2^53-1 是表的不变式,所以按 number 累加是安全的 */
39
+ function histogramOf(bytes: Uint8Array): { weights: Map<number, number>; total: number } {
40
+ const h = decodeHeader(bytes);
41
+ if ('problem' in h) throw new GameKitError('TABLE_INVALID_FORMAT', `表头坏了:${h.problem.message}`);
42
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
43
+ const weights = new Map<number, number>();
44
+ const row = { simId: 0, payoutCenti: 0, weight: 0 };
45
+ for (let i = 0; i < h.header.rowCount; i++) {
46
+ readRow(dv, HEADER_BYTES + i * ROW_BYTES, row);
47
+ weights.set(row.payoutCenti, (weights.get(row.payoutCenti) ?? 0) + row.weight);
48
+ }
49
+ return { weights, total: h.header.totalWeight };
50
+ }
51
+
52
+ /**
53
+ * 平台侧的展示配置,从清单和**建好的表**推导。
54
+ *
55
+ * 赔率表按 mode 出:coco 22 张表的倍数各不相同,只取第一张玩家看不到另外 21 张。
56
+ * 每档的概率来自表的权重(求解器可能把它从公式模型的理论值拉偏),而不是游戏声明——
57
+ * 游戏声明和平台展示从此不可能不一致。
58
+ *
59
+ * betModes 和表的 mode 不是一回事:前者是「花多少钱买一注」,后者是「用哪张表」。
60
+ * coco 的 22 个 mode 成本完全相同,塞进 betModes 只会撑爆它的 8 个上限。
61
+ */
62
+ export function describeConfig<C, O>(spec: GameDefinition<C, O>, tables: readonly UploadTable[]): GameConfig {
63
+ const paytables = tables.map((t) => {
64
+ const rows = spec.paytableFor(spec.configOf(t.mode));
65
+ const { weights, total } = histogramOf(t.bytes);
66
+ return {
67
+ mode: t.mode,
68
+ rows: rows.slice(0, 200).map((r) => {
69
+ const multCenti = Math.round(r.mult * PAYOUT_SCALE);
70
+ const w = weights.get(multCenti);
71
+ return { label: r.label, multCenti, ...(w !== undefined && total > 0 ? { chancePpm: Math.round((w / total) * 1e6) } : {}) };
72
+ }),
73
+ };
74
+ });
75
+ return {
76
+ betModes: [{ name: 'base', costCenti: PAYOUT_SCALE }],
77
+ autoplay: spec.autoplay,
78
+ paytables,
79
+ };
80
+ }
81
+
82
+ /** 注额区间是运营侧的事,不在游戏清单里——后端按默认值建,之后由 games.update 调 */
83
+ const gameFields = <C, O>(spec: GameDefinition<C, O>): Omit<CreateGame, 'slug' | 'config'> => ({
84
+ title: spec.title,
85
+ description: spec.description,
86
+ declaredRtpPpm: Math.round(spec.rtp * 1e6),
87
+ betCostCenti: PAYOUT_SCALE,
88
+ });
89
+
90
+ /** 按 slug 找自己的游戏。list 的 q 是按 title 模糊匹配的,slug 只能自己翻页找 */
91
+ async function findBySlug(api: GameKitClient, slug: string): Promise<Game | null> {
92
+ let cursor: string | undefined;
93
+ for (;;) {
94
+ const page = await api.games.list({ status: 'all', owner: 'me', limit: 50, cursor });
95
+ const hit = page.items.find((g) => g.slug === slug);
96
+ if (hit) return hit;
97
+ if (!page.nextCursor) return null;
98
+ cursor = page.nextCursor;
99
+ }
100
+ }
101
+
102
+ export async function uploadTables<C, O>(
103
+ spec: GameDefinition<C, O>, tables: readonly UploadTable[], opts: UploadOptions,
104
+ ): Promise<UploadResult> {
105
+ const api = opts.client;
106
+ const say = opts.onProgress ?? (() => {});
107
+ const config = describeConfig(spec, tables);
108
+
109
+ let game = await findBySlug(api, spec.slug);
110
+ if (game?.status === 'published') {
111
+ throw new GameKitError('GAME_IMMUTABLE',
112
+ `slug "${spec.slug}" 已经发布过了。已发布的游戏不能换表——改个 slug 发一个新版本`);
113
+ }
114
+ if (game) {
115
+ game = await api.games.update(game.id, { ...gameFields(spec), config });
116
+ say(`复用草稿 ${game.id.slice(0, 8)}…`);
117
+ } else {
118
+ game = await api.games.create({ slug: spec.slug, config, ...gameFields(spec) });
119
+ say(`新建游戏 ${game.id.slice(0, 8)}…`);
120
+ }
121
+
122
+ const uploaded: GameTable[] = [];
123
+ for (const t of tables) {
124
+ const { table, resumed } = await api.tables.declare(game.id, {
125
+ mode: t.mode, format: 'GKLT1', rowCount: t.rowCount,
126
+ byteLength: t.bytes.length, contentHash: t.contentHash, betCostCenti: t.betCostCenti,
127
+ });
128
+ say(`[${t.mode}] 表 ${table.id.slice(0, 8)}…${resumed ? '(复用)' : ''}`);
129
+
130
+ if (table.status !== 'verified') {
131
+ if (table.rowsIngested < table.rowCount) {
132
+ await api.tables.upload(game.id, table.id, t.bytes, {
133
+ chunkRows: opts.chunkRows,
134
+ onProgress: (p) => say(`[${t.mode}] ${p.rowsIngested}/${p.rowCount} 行`),
135
+ });
136
+ }
137
+ if (t.books) {
138
+ await api.tables.uploadBooks(game.id, table.id, t.books);
139
+ say(`[${t.mode}] ${t.books.length} 条演出数据`);
140
+ }
141
+ }
142
+
143
+ const { verification, table: verified } = await api.tables.verify(game.id, table.id);
144
+ if (!verification.ok) {
145
+ throw new GameKitError('TABLE_INVALID_FORMAT',
146
+ `[${t.mode}] 服务端验证未通过:${verification.issues.map((i) => i.code).join('、')}`,
147
+ verification.issues);
148
+ }
149
+ if (verification.computedRtpPpm !== t.verdict.computedRtpPpm) {
150
+ throw new GameKitError('RTP_MISMATCH',
151
+ `[${t.mode}] 本地算 ${t.verdict.computedRtpPpm} ppm,服务端算 ${verification.computedRtpPpm} ppm。` +
152
+ '同一份字节算出两个 RTP,说明两边的 @gamekit/lut 不是同一个版本');
153
+ }
154
+ say(`[${t.mode}] 验证通过,RTP ${verification.computedRtpPpm} ppm`);
155
+ uploaded.push(verified);
156
+ }
157
+
158
+ /* 一张表一次发布:多 mode 的游戏就是发很多次。
159
+ 所有 mode 都解到同一个 rtp,这里的检查理论上恒过——留着是防 CLI --rtp 覆盖后清单没同步 */
160
+ const targets = opts.publishModes
161
+ ? uploaded.filter((t) => opts.publishModes!.includes(t.mode))
162
+ : uploaded;
163
+ const lowest = targets.reduce((m, t) => (t.computedRtpPpm! < m.computedRtpPpm! ? t : m), targets[0]!);
164
+ if (lowest.computedRtpPpm! + RTP_TOLERANCE_PPM < game.declaredRtpPpm) {
165
+ throw new GameKitError('RTP_MISMATCH',
166
+ `游戏声明 ${game.declaredRtpPpm} ppm,但 mode "${lowest.mode}" 实算只有 ${lowest.computedRtpPpm} ppm。` +
167
+ '大厅里展示的声明不能高于任何一个 mode 实际能给的');
168
+ }
169
+
170
+ for (const t of targets) {
171
+ game = await api.games.publish(game.id, { tableId: t.id, acknowledgeRtpPpm: t.computedRtpPpm! });
172
+ }
173
+ say(`已发布 ${game.slug}:${targets.length} 个 mode(${targets.map((t) => t.mode).join('、')})`);
174
+
175
+ return { game, tables: uploaded, published: true };
176
+ }
@@ -0,0 +1,30 @@
1
+ /* 上传的 node 入口:给 CLI 用,身份是 $TMPDIR 里那份文件 cookie。
2
+ *
3
+ * 作者身份必须稳定:不持久化的话每次上传都是一个新匿名用户,
4
+ * 而 games 的唯一约束是 (owner_id, slug)——同一个 slug 会在库里堆出很多份,
5
+ * `findBySlug` 也永远找不到上次传的那个草稿。
6
+ * 纯逻辑在 upload-core.ts,浏览器里的 studio 直接用那一份。 */
7
+ import { createClient } from '../client/index.ts';
8
+ import { fileCookieStore } from '../client/node.ts';
9
+ import type { BuiltTable } from './build.ts';
10
+ import type { PublishSpec } from './spec.ts';
11
+ import { uploadTables as uploadCore, type UploadOptions as CoreOptions, type UploadResult } from './upload-core.ts';
12
+
13
+ export { describeConfig, type UploadResult, type UploadTable } from './upload-core.ts';
14
+
15
+ export interface UploadOptions extends Omit<CoreOptions, 'client'> {
16
+ baseUrl?: string;
17
+ client?: CoreOptions['client'];
18
+ }
19
+
20
+ export async function uploadTables<C, O>(
21
+ spec: PublishSpec<C, O>, tables: readonly BuiltTable[], opts: UploadOptions = {},
22
+ ): Promise<UploadResult> {
23
+ const client = opts.client ?? createClient({
24
+ baseUrl: opts.baseUrl ?? 'http://127.0.0.1:8787',
25
+ cookies: 'manual',
26
+ cookieStore: fileCookieStore(),
27
+ });
28
+ await client.session.bootstrap();
29
+ return uploadCore(spec, tables, { ...opts, client });
30
+ }