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,158 @@
1
+ import { z } from 'zod';
2
+ import { Cents, MAX_BET_CENTS, PayoutCenti, Ppm, RtpPpm, Sha256Hex } from './money.ts';
3
+
4
+ export const GameStatus = z.enum([
5
+ 'draft', 'verifying', 'verified', 'published', 'rejected', 'archived',
6
+ ]);
7
+ export type GameStatus = z.infer<typeof GameStatus>;
8
+
9
+ export const I18n = z.record(z.string(), z.string());
10
+
11
+ /**
12
+ * 玩家上传的游戏配置。平台只做格式校验,不解释语义。
13
+ * symbols / grid 只有用 GKBK1 编码演出数据的游戏才需要——平台不看 book 载荷。
14
+ */
15
+ export const GameConfig = z.object({
16
+ symbols: z.array(z.string().min(1).max(8)).min(2).max(256).optional(),
17
+ grid: z.object({ reels: z.int().min(1).max(32), rows: z.int().min(1).max(32) }).optional(),
18
+ betModes: z.array(z.object({
19
+ name: z.string().min(1).max(32),
20
+ costCenti: z.int().min(1),
21
+ })).min(1).max(8),
22
+ /** 平台按它决定要不要显示自动按钮。不给当 true */
23
+ autoplay: z.boolean().optional(),
24
+ /**
25
+ * 按 mode 出的赔率表。一张表 = 一种赔付分布,倍数和概率都随 mode 变;
26
+ * 概率来自表的权重(发布时从字节推导),不是游戏声明——两者从此不可能不一致
27
+ */
28
+ paytables: z.array(z.object({
29
+ mode: z.string().min(1).max(32),
30
+ rows: z.array(z.object({
31
+ label: I18n,
32
+ multCenti: PayoutCenti,
33
+ chancePpm: Ppm.optional(),
34
+ })).max(200),
35
+ })).max(64).optional(),
36
+ display: z.object({
37
+ aspect: z.string().regex(/^\d+:\d+$/).optional(),
38
+ thumbnailUrl: z.url().optional(),
39
+ locales: z.array(z.string()).max(16).optional(),
40
+ }).optional(),
41
+ volatility: z.enum(['low', 'mid', 'high']).optional(),
42
+ notes: z.array(z.string().max(500)).max(20).optional(),
43
+ }).strict();
44
+ export type GameConfig = z.infer<typeof GameConfig>;
45
+
46
+ const betRange = {
47
+ minBetCents: Cents.min(1),
48
+ maxBetCents: Cents.min(1).max(MAX_BET_CENTS),
49
+ betStepCents: Cents.min(1),
50
+ };
51
+
52
+ /** 注额区间是运营侧的事,不在创建请求里——建出来是默认区间,之后由 UpdateGame 调 */
53
+ export const DEFAULT_BET_RANGE = { minBetCents: 100, maxBetCents: 1_000_000, betStepCents: 100 } as const;
54
+
55
+ export const CreateGame = z.object({
56
+ slug: z.string().min(3).max(48).regex(/^[a-z0-9][a-z0-9-]*$/),
57
+ title: z.string().min(1).max(80),
58
+ description: z.string().max(2000).optional(),
59
+ declaredRtpPpm: RtpPpm,
60
+ betCostCenti: z.int().min(1).max(1_000_000).default(100),
61
+ config: GameConfig,
62
+ });
63
+ export type CreateGame = z.infer<typeof CreateGame>;
64
+
65
+ export const UpdateGame = z.object({
66
+ title: z.string().min(1).max(80).optional(),
67
+ description: z.string().max(2000).nullable().optional(),
68
+ declaredRtpPpm: RtpPpm.optional(),
69
+ minBetCents: betRange.minBetCents.optional(),
70
+ maxBetCents: betRange.maxBetCents.optional(),
71
+ betStepCents: betRange.betStepCents.optional(),
72
+ config: GameConfig.optional(),
73
+ }).refine((v) => v.minBetCents === undefined || v.maxBetCents === undefined || v.minBetCents <= v.maxBetCents, {
74
+ message: 'minBetCents 不能大于 maxBetCents', path: ['minBetCents'],
75
+ }).refine((v) => v.minBetCents === undefined || v.betStepCents === undefined || v.minBetCents % v.betStepCents === 0, {
76
+ message: 'minBetCents 必须是 betStepCents 的整数倍', path: ['minBetCents'],
77
+ });
78
+ export type UpdateGame = z.infer<typeof UpdateGame>;
79
+
80
+ export const PublishGame = z.object({
81
+ tableId: z.uuid(),
82
+ /** 必须等于服务端算出的 computedRtpPpm。防止「声明值改了但没重验」就发布 */
83
+ acknowledgeRtpPpm: Ppm,
84
+ });
85
+ export type PublishGame = z.infer<typeof PublishGame>;
86
+
87
+ export const Game = z.object({
88
+ id: z.uuid(),
89
+ ownerId: z.string(),
90
+ slug: z.string(),
91
+ title: z.string(),
92
+ description: z.string().nullable(),
93
+ status: GameStatus,
94
+ declaredRtpPpm: z.int(),
95
+ verifiedRtpPpm: z.int().nullable(),
96
+ betCostCenti: z.int(),
97
+ minBetCents: z.int(),
98
+ maxBetCents: z.int(),
99
+ betStepCents: z.int(),
100
+ maxPayoutCenti: z.int().nullable(),
101
+ hitRatePpm: z.int().nullable(),
102
+ /* 读的时候剥掉未知键,不 strict:库里的 config 是**写入当时**的 schema 校验过的,
103
+ schema 演进后老行还在(比如去掉 paytable 之前发的游戏)。严格解析会让整页列表因一行老数据拒收 */
104
+ config: GameConfig.strip(),
105
+ configHash: Sha256Hex,
106
+ gameHash: Sha256Hex.nullable(),
107
+ activeTableId: z.uuid().nullable(),
108
+ publishedAt: z.int().nullable(),
109
+ createdAt: z.int(),
110
+ updatedAt: z.int(),
111
+ });
112
+ export type Game = z.infer<typeof Game>;
113
+
114
+ export const ListGamesQuery = z.object({
115
+ status: z.enum(['published', 'draft', 'all']).default('published'),
116
+ owner: z.string().optional(),
117
+ q: z.string().max(64).optional(),
118
+ minRtpPpm: z.coerce.number().int().optional(),
119
+ sort: z.enum(['recent', 'rtp']).default('recent'),
120
+ cursor: z.string().max(256).optional(),
121
+ limit: z.coerce.number().int().min(1).max(50).default(20),
122
+ });
123
+ export type ListGamesQuery = z.infer<typeof ListGamesQuery>;
124
+
125
+ /**
126
+ * 一个已生效的 mode。
127
+ *
128
+ * 一张表 = 一种赔付分布,所以 mode 是玩家真正在选的东西(coco 的 N 和金牌开关都在里面)。
129
+ * 每个 mode 的 RTP 和最大赔付可以不同,展示时不能只报游戏级的那一个数。
130
+ */
131
+ export const PublishedMode = z.object({
132
+ mode: z.string(),
133
+ tableId: z.uuid(),
134
+ contentHash: Sha256Hex,
135
+ gameHash: Sha256Hex,
136
+ rowCount: z.int(),
137
+ totalWeight: z.int(),
138
+ computedRtpPpm: z.int(),
139
+ maxPayoutCenti: z.int(),
140
+ hitRatePpm: z.int(),
141
+ distinctPayouts: z.int(),
142
+ /** 整表是否带演出数据。false 说明客户端得自己从 payout 推导 */
143
+ hasFrames: z.boolean(),
144
+ publishedAt: z.int(),
145
+ });
146
+ export type PublishedMode = z.infer<typeof PublishedMode>;
147
+
148
+ /** 单个游戏的详情。比列表多一份已生效的 mode 清单——列表页不需要,也不值得 N+1 次查询 */
149
+ export const GameDetail = Game.extend({
150
+ modes: z.array(PublishedMode),
151
+ });
152
+ export type GameDetail = z.infer<typeof GameDetail>;
153
+
154
+ export const GamePage = z.object({
155
+ items: z.array(Game),
156
+ nextCursor: z.string().nullable(),
157
+ });
158
+ export type GamePage = z.infer<typeof GamePage>;
@@ -0,0 +1,9 @@
1
+ export * from './money.ts';
2
+ export * from './errors.ts';
3
+ export * from './games.ts';
4
+ export * from './tables.ts';
5
+ export * from './bets.ts';
6
+ export * from './rounds.ts';
7
+ export * from './seeds.ts';
8
+ export * from './session.ts';
9
+ export * from './verify.ts';
@@ -0,0 +1,51 @@
1
+ /* 金额与比例的原语。
2
+
3
+ 全流程整数,JSON 里永远不出现浮点金额:
4
+ cents 最小货币单位(美分)。字段名一律带 Cents 后缀
5
+ centi 倍率 ×100。payoutCenti=1150 表示 11.5 倍
6
+ ppm 百万分之一。rtpPpm=967000 表示 96.7%
7
+
8
+ 后端没有换算层。换算只发生在 UI 的格式化函数里——任何「接收 USD 浮点再乘 100」
9
+ 的设计都会在 0.1+0.2 上翻车,而且是上线三个月后才在对账报表里发现的那种。 */
10
+ import { z } from 'zod';
11
+
12
+ export const CURRENCY = 'USD' as const;
13
+ export const DECIMALS = 2 as const;
14
+
15
+ /** 最小投注粒度。win = bet/100 × payoutCenti 必须整除,否则 RTP 会变成 bet 的函数 */
16
+ export const BET_GRANULARITY_CENTS = 100;
17
+
18
+ export const PAYOUT_SCALE = 100;
19
+ export const MAX_PAYOUT_CENTI = 10_000_000;
20
+ export const MAX_BET_CENTS = 10_000_000;
21
+ export const MAX_TOTAL_WEIGHT = Number.MAX_SAFE_INTEGER;
22
+
23
+ export const MIN_RTP_PPM = 800_000;
24
+ export const MAX_RTP_PPM = 990_000;
25
+ export const RTP_TOLERANCE_PPM = 100;
26
+
27
+ export const SIGNUP_GRANT_CENTS = 100_000;
28
+
29
+ export const Cents = z.int().min(0).max(MAX_BET_CENTS);
30
+ export const PayoutCenti = z.int().min(0).max(MAX_PAYOUT_CENTI);
31
+ export const Ppm = z.int().min(0).max(1_000_000);
32
+ export const RtpPpm = z.int().min(MIN_RTP_PPM).max(MAX_RTP_PPM);
33
+
34
+ export const BetCents = Cents.refine(
35
+ (v) => v > 0 && v % BET_GRANULARITY_CENTS === 0,
36
+ `注额必须是 ${BET_GRANULARITY_CENTS} 的正整数倍`,
37
+ );
38
+
39
+ export const Sha256Hex = z.string().length(64).regex(/^[0-9a-f]+$/, '必须是小写十六进制');
40
+ export const SeedString = z.string().min(1).max(64).regex(/^[\w.:-]+$/);
41
+
42
+ export const Wallet = z.object({
43
+ balanceCents: z.int().min(0),
44
+ currency: z.literal(CURRENCY),
45
+ decimals: z.literal(DECIMALS),
46
+ });
47
+ export type Wallet = z.infer<typeof Wallet>;
48
+
49
+ /** 恒为精确整数——前提是 betCents 能被 PAYOUT_SCALE 整除 */
50
+ export const winCents = (betCents: number, payoutCenti: number): number =>
51
+ (betCents / PAYOUT_SCALE) * payoutCenti;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { FairProof, RoundConfig } from './bets.ts';
3
+ import { PayoutCenti } from './money.ts';
4
+
5
+ export const RoundSummary = z.object({
6
+ id: z.uuid(),
7
+ gameId: z.uuid(),
8
+ /** 历史是跨游戏的,回放得先知道该切到哪个游戏 */
9
+ gameSlug: z.string(),
10
+ /** 这一局抽的是哪张表。回放时游戏要据此把配置调回去 */
11
+ mode: z.string(),
12
+ nonce: z.int(),
13
+ betCents: z.int(),
14
+ payoutCents: z.int(),
15
+ payoutCenti: PayoutCenti,
16
+ simId: z.int(),
17
+ createdAt: z.int(),
18
+ });
19
+ export type RoundSummary = z.infer<typeof RoundSummary>;
20
+
21
+ export const Round = RoundSummary.extend({
22
+ tableId: z.uuid(),
23
+ rowIndex: z.int(),
24
+ /**
25
+ * 演出载荷,base64。平台一个字节都不看——游戏自己 decode。
26
+ *
27
+ * 列表里不带它:回放是点出来的,多一次往返换一个轻量的列表是划算的。
28
+ */
29
+ frames: z.string().nullable(),
30
+ /** 下注那一刻的 config 快照。回放用它解码,牌面才和原玩家看到的一致。老局为 null */
31
+ config: RoundConfig.nullable(),
32
+ proof: FairProof,
33
+ });
34
+ export type Round = z.infer<typeof Round>;
35
+
36
+ export const ListRoundsQuery = z.object({
37
+ gameId: z.uuid().optional(),
38
+ cursor: z.string().max(256).optional(),
39
+ limit: z.coerce.number().int().min(1).max(100).default(20),
40
+ });
41
+ export type ListRoundsQuery = z.infer<typeof ListRoundsQuery>;
42
+
43
+ export const RoundPage = z.object({
44
+ items: z.array(RoundSummary),
45
+ nextCursor: z.string().nullable(),
46
+ });
47
+ export type RoundPage = z.infer<typeof RoundPage>;
48
+
49
+ /**
50
+ * 单局详情。`verification` 是给人看的验证指引,不是机器契约的一部分——
51
+ * 真正的机器契约是 `proof` 加 `/v1/verify/spec`。
52
+ */
53
+ export const RoundDetail = Round.extend({
54
+ verification: z.object({
55
+ steps: z.array(z.object({ n: z.int(), what: z.string(), expect: z.string() })),
56
+ tableUrl: z.string(),
57
+ algorithmDoc: z.string(),
58
+ }),
59
+ });
60
+ export type RoundDetail = z.infer<typeof RoundDetail>;
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { Sha256Hex, SeedString } from './money.ts';
3
+
4
+ /**
5
+ * 种子链的公开视图。serverSeed(当前未揭示的 s_n)和 terminalSecret(s_N)
6
+ * 绝不出现在任何响应里。
7
+ */
8
+ export const SeedState = z.object({
9
+ chainId: z.uuid(),
10
+ chainRoot: Sha256Hex,
11
+ /** 下一局将用的种子的承诺 = s_{n-1} */
12
+ serverSeedHash: Sha256Hex,
13
+ clientSeed: z.string(),
14
+ nonce: z.int().min(0),
15
+ chainLength: z.int(),
16
+ });
17
+ export type SeedState = z.infer<typeof SeedState>;
18
+
19
+ export const SetClientSeed = z.object({ clientSeed: SeedString });
20
+ export type SetClientSeed = z.infer<typeof SetClientSeed>;
21
+
22
+ /**
23
+ * 换 clientSeed 或主动轮换都会换链。顺序不可颠倒:先公布新根,再收新 clientSeed。
24
+ * 否则服务端可以对着已知的 clientSeed 磨候选链。
25
+ */
26
+ export const RotateResult = z.object({
27
+ revealed: z.object({
28
+ chainId: z.uuid(),
29
+ /** 旧链的终端秘密,揭示后旧链上每一局都可回溯核验 */
30
+ terminalSecret: z.string(),
31
+ chainLength: z.int(),
32
+ usedNonces: z.int(),
33
+ }),
34
+ current: SeedState,
35
+ });
36
+ export type RotateResult = z.infer<typeof RotateResult>;
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { SeedState } from './seeds.ts';
3
+ import { SeedString, Wallet } from './money.ts';
4
+
5
+ export const Bootstrap = z.object({ clientSeed: SeedString.optional() });
6
+ export type Bootstrap = z.infer<typeof Bootstrap>;
7
+
8
+ export const SessionView = z.object({
9
+ user: z.object({
10
+ id: z.string(),
11
+ name: z.string().nullable(),
12
+ image: z.string().nullable(),
13
+ isAnonymous: z.boolean(),
14
+ }),
15
+ wallet: Wallet,
16
+ seeds: SeedState,
17
+ });
18
+ export type SessionView = z.infer<typeof SessionView>;
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { Ppm, Sha256Hex } from './money.ts';
3
+
4
+ export const TableStatus = z.enum([
5
+ 'uploading', 'uploaded', 'verifying', 'verified', 'rejected',
6
+ ]);
7
+ export type TableStatus = z.infer<typeof TableStatus>;
8
+
9
+ export const MAX_ROW_COUNT = 2_000_000;
10
+ export const GKLT1_HEADER_BYTES = 64;
11
+ export const GKLT1_ROW_BYTES = 16;
12
+
13
+ export const gklt1ByteLength = (rowCount: number): number =>
14
+ GKLT1_HEADER_BYTES + GKLT1_ROW_BYTES * rowCount;
15
+
16
+ export const DeclareTable = z.object({
17
+ mode: z.string().min(1).max(32).default('base'),
18
+ format: z.literal('GKLT1').default('GKLT1'),
19
+ rowCount: z.int().min(1).max(MAX_ROW_COUNT),
20
+ byteLength: z.int().min(GKLT1_HEADER_BYTES),
21
+ contentHash: Sha256Hex,
22
+ betCostCenti: z.int().min(1),
23
+ }).refine((v) => v.byteLength === gklt1ByteLength(v.rowCount), {
24
+ message: 'byteLength 与 rowCount 不符', path: ['byteLength'],
25
+ });
26
+ export type DeclareTable = z.infer<typeof DeclareTable>;
27
+
28
+ export const TableIssueCode = z.enum([
29
+ 'BAD_MAGIC', 'BAD_VERSION', 'BAD_LENGTH', 'BAD_ALIGNMENT',
30
+ 'SIM_ID_NOT_ASCENDING', 'ZERO_WEIGHT', 'PAYOUT_TOO_LARGE',
31
+ 'TOTAL_WEIGHT_MISMATCH', 'TOTAL_WEIGHT_OVERFLOW', 'HASH_MISMATCH',
32
+ 'ROW_COUNT_MISMATCH', 'BOOKS_INCOMPLETE', 'RTP_MISMATCH', 'RTP_OUT_OF_BOUNDS',
33
+ ]);
34
+ export type TableIssueCode = z.infer<typeof TableIssueCode>;
35
+
36
+ export const TableIssue = z.object({
37
+ code: TableIssueCode,
38
+ rowIndex: z.int().optional(),
39
+ message: z.string(),
40
+ });
41
+ export type TableIssue = z.infer<typeof TableIssue>;
42
+
43
+ export const GameTable = z.object({
44
+ id: z.uuid(),
45
+ gameId: z.uuid(),
46
+ mode: z.string(),
47
+ status: TableStatus,
48
+ format: z.literal('GKLT1'),
49
+ rowCount: z.int(),
50
+ rowsIngested: z.int(),
51
+ byteLength: z.int(),
52
+ contentHash: Sha256Hex,
53
+ booksHash: Sha256Hex.nullable(),
54
+ payoutSeqHash: Sha256Hex.nullable(),
55
+ totalWeight: z.int(),
56
+ betCostCenti: z.int(),
57
+ computedRtpPpm: z.int().nullable(),
58
+ maxPayoutCenti: z.int().nullable(),
59
+ distinctPayouts: z.int().nullable(),
60
+ rejectReason: z.string().nullable(),
61
+ createdAt: z.int(),
62
+ verifiedAt: z.int().nullable(),
63
+ });
64
+ export type GameTable = z.infer<typeof GameTable>;
65
+
66
+ export const VerifyTableResult = z.object({
67
+ table: GameTable,
68
+ verification: z.object({
69
+ ok: z.boolean(),
70
+ rowCount: z.int(),
71
+ totalWeight: z.int(),
72
+ computedRtpPpm: Ppm,
73
+ declaredRtpPpm: Ppm,
74
+ deltaPpm: z.int(),
75
+ tolerancePpm: z.int(),
76
+ /** 精确分数,事后仲裁凭据。BigInt 的十进制串 */
77
+ rtpNumerator: z.string(),
78
+ rtpDenominator: z.string(),
79
+ maxPayoutCenti: z.int(),
80
+ hitRatePpm: Ppm,
81
+ distinctPayouts: z.int(),
82
+ contentHash: Sha256Hex,
83
+ payoutSeqHash: Sha256Hex,
84
+ issues: z.array(TableIssue),
85
+ }),
86
+ });
87
+ export type VerifyTableResult = z.infer<typeof VerifyTableResult>;
88
+
89
+ export const DeclareTableResult = z.object({
90
+ table: GameTable,
91
+ /** true = 同一份字节先前已声明过,继续用那张表 */
92
+ resumed: z.boolean(),
93
+ });
94
+ export type DeclareTableResult = z.infer<typeof DeclareTableResult>;
95
+
96
+ export const ChunkResult = z.object({
97
+ rowsIngested: z.int(),
98
+ done: z.boolean(),
99
+ rowCount: z.int(),
100
+ });
101
+ export type ChunkResult = z.infer<typeof ChunkResult>;
102
+
103
+ /** 一次 books 请求最多带多少行。上限由 D1 单次调用的语句数决定 */
104
+ export const MAX_BOOKS_PER_CHUNK = 2000;
105
+ export const MAX_FRAMES_BASE64_LENGTH = 8192;
106
+
107
+ export const BooksChunk = z.object({
108
+ books: z.array(z.object({
109
+ simId: z.int().min(0),
110
+ /** 载荷的 base64。平台不解析,游戏自己 decode */
111
+ frames: z.string().min(1).max(MAX_FRAMES_BASE64_LENGTH),
112
+ })).min(1).max(MAX_BOOKS_PER_CHUNK),
113
+ });
114
+ export type BooksChunk = z.infer<typeof BooksChunk>;
115
+
116
+ export const BooksResult = z.object({
117
+ ingested: z.int(),
118
+ rowCount: z.int(),
119
+ });
120
+ export type BooksResult = z.infer<typeof BooksResult>;
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { SAMPLER_ID } from './bets.ts';
3
+ import { SeedString, Sha256Hex } from './money.ts';
4
+
5
+ export const VerifyRoundRequest = z.object({
6
+ serverSeed: z.string().min(1).max(128),
7
+ clientSeed: SeedString,
8
+ nonce: z.int().min(0),
9
+ tableContentHash: Sha256Hex,
10
+ /** BigInt 放不进 JSON number 的部分用十进制串传 */
11
+ totalWeight: z.union([z.int().min(1), z.string().regex(/^\d+$/)]),
12
+ });
13
+ export type VerifyRoundRequest = z.infer<typeof VerifyRoundRequest>;
14
+
15
+ export const VerifyRoundResult = z.object({
16
+ serverSeedHash: Sha256Hex,
17
+ drawTarget: z.string(),
18
+ drawWords: z.array(z.string().length(8)),
19
+ rngDraws: z.int(),
20
+ });
21
+ export type VerifyRoundResult = z.infer<typeof VerifyRoundResult>;
22
+
23
+ /**
24
+ * 算法规范。刻意宽松:新增说明字段不该让老客户端解析失败,
25
+ * 这份文档的读者是人和第三方实现者,不是我们的解码器。
26
+ */
27
+ export const VerifySpec = z.looseObject({
28
+ sampler: z.literal(SAMPLER_ID),
29
+ rng: z.looseObject({ alg: z.string(), version: z.int() }),
30
+ draw: z.looseObject({ words: z.int(), range: z.string() }),
31
+ tableFormat: z.looseObject({ magic: z.string(), version: z.int(), headerBytes: z.int(), rowBytes: z.int() }),
32
+ });
33
+ export type VerifySpec = z.infer<typeof VerifySpec>;
@@ -0,0 +1,168 @@
1
+ /* 编排:采样 → 解权重 → 量化 → 编码 → 自验。
2
+ *
3
+ * 全程不碰网络。上传前先在本地跑一遍平台的终验,RTP 对不上就别浪费一次上传——
4
+ * 一张 10 万行的表传上去再被拒,代价是几十秒和一张脏数据。 */
5
+ import { Sha256 } from '../sdk/hash.ts';
6
+ import {
7
+ type Verdict, consumeChunk, encodeHeader, encodeRows, finalize, initState,
8
+ } from '../lut/index.ts';
9
+ import { MAX_PAYOUT_CENTI, PAYOUT_SCALE } from '../protocol/index.ts';
10
+ import type { Segment } from '../weights/index.ts';
11
+ import {
12
+ type SampleBatch, SAMPLE_BET_CENTS, checkBetLinearity, freezeConfig, sampleRange,
13
+ } from './sample.ts';
14
+ import type { GameDefinition } from '../sdk/index.ts';
15
+ import {
16
+ type PayoutStat, flagOutliers, planRows, quantize, solveWeights, toLutRows, trueUpRtp,
17
+ } from './table.ts';
18
+
19
+ export interface BookEntry { simId: number; frames: string }
20
+
21
+ export interface BuiltTable {
22
+ mode: string;
23
+ /** 写进 GKLT1 头部、也是终验比对的那个值 */
24
+ declaredRtpPpm: number;
25
+ bytes: Uint8Array;
26
+ contentHash: string;
27
+ rowCount: number;
28
+ totalWeight: number;
29
+ betCostCenti: number;
30
+ books: BookEntry[] | null;
31
+ verdict: Verdict;
32
+ stats: {
33
+ sims: number;
34
+ distinctPayouts: number;
35
+ /** 游戏的 paytable 声明了几个赔付档位。和实际采到的差得多就说明采样量不够 */
36
+ declaredTiers: number;
37
+ rtpPpm: number;
38
+ maxPayoutCenti: number;
39
+ hitRatePpm: number;
40
+ iterations: number;
41
+ residual: number;
42
+ trueUp: ReturnType<typeof trueUpRtp>;
43
+ thin: PayoutStat[];
44
+ distorted: PayoutStat[];
45
+ millis: number;
46
+ };
47
+ }
48
+
49
+ export type Sampler = (modeIndex: number) => Promise<SampleBatch> | SampleBatch;
50
+
51
+ export interface BuildOptions {
52
+ /** 注入多核采样。默认同进程,方便测试和小表 */
53
+ sampler?: Sampler;
54
+ onProgress?: (msg: string) => void;
55
+ /**
56
+ * 覆盖这张表的目标 RTP。不给就用 mode 自己的,再不给用游戏级的。
57
+ * 调参 UI 拖滑杆时改的就是它;`bun run pub` 从不传
58
+ */
59
+ declaredRtpPpm?: number;
60
+ /** 命中率/波动率的分段约束。默认空——只有全局 RTP 一条商业目标 */
61
+ segments?: readonly Segment[];
62
+ }
63
+
64
+ const b64 = (bytes: Uint8Array): string => {
65
+ let s = '';
66
+ for (let i = 0; i < bytes.length; i += 0x8000) {
67
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
68
+ }
69
+ return btoa(s);
70
+ };
71
+
72
+ export async function buildTable<C, O>(
73
+ spec: GameDefinition<C, O>, modeIndex: number, opts: BuildOptions = {},
74
+ ): Promise<BuiltTable> {
75
+ const t0 = Date.now();
76
+ const mode = spec.modes[modeIndex]!;
77
+ // 单注成本恒为 1 注 = 100 centi。bonus buy 那种 100x 的 mode 将来做时 cost 挪到 mode 上,现在不做
78
+ const betCostCenti = PAYOUT_SCALE;
79
+ // RTP 只有一个数:清单的 rtp(小数),所有 mode 都解到它;调参 UI 才会覆盖
80
+ const declaredRtpPpm = opts.declaredRtpPpm ?? Math.round(spec.rtp * 1e6);
81
+ const say = opts.onProgress ?? (() => {});
82
+
83
+ say(`[${mode.name}] 采样 ${spec.sims} 局`);
84
+ const sampler: Sampler = opts.sampler ?? ((mi) => sampleRange(spec, mi, 0, spec.sims));
85
+ const batch = await sampler(modeIndex);
86
+
87
+ // 抽检跨注额线性:全量跑一遍太慢,而不一致是系统性的,抽几局就能发现
88
+ const probes = Array.from({ length: Math.min(32, spec.sims) },
89
+ (_, i) => Math.floor((i * spec.sims) / Math.min(32, spec.sims)));
90
+ checkBetLinearity(spec, modeIndex, probes);
91
+
92
+ const collapse = spec.book === 'none';
93
+ const plan = planRows(batch.payoutCenti, collapse, batch.books);
94
+ say(`[${mode.name}] ${plan.groups.length} 种赔付 → ${plan.payoutCenti.length} 行${plan.payoutCenti.length < batch.payoutCenti.length && !collapse ? `(${batch.payoutCenti.length - plan.payoutCenti.length} 局演出重复,已合并)` : ''}`);
95
+
96
+ const solved = solveWeights(plan.groups, declaredRtpPpm, betCostCenti, opts.segments ?? []);
97
+ const weight = quantize(plan, solved.weights);
98
+ const trueUp = trueUpRtp(plan.payoutCenti, weight, betCostCenti, declaredRtpPpm);
99
+
100
+ const rows = toLutRows(plan.payoutCenti, weight);
101
+ const totalWeight = rows.reduce((s, r) => s + r.weight, 0);
102
+ if (!Number.isSafeInteger(totalWeight)) {
103
+ throw new Error(`总权重 ${totalWeight} 超过 2^53-1,D1 会静默丢精度`);
104
+ }
105
+
106
+ const header = encodeHeader({ rowCount: rows.length, betCostCenti, totalWeight, declaredRtpPpm });
107
+ const body = encodeRows(rows);
108
+ const bytes = new Uint8Array(header.length + body.length);
109
+ bytes.set(header);
110
+ bytes.set(body, header.length);
111
+ const contentHash = new Sha256().update(bytes).hex();
112
+
113
+ const verdict = selfVerify(bytes, {
114
+ rowCount: rows.length, contentHash, betCostCenti, declaredRtpPpm, headerTotalWeight: totalWeight,
115
+ });
116
+ if (!verdict.ok) {
117
+ throw new Error(`本地终验没通过:${verdict.issues.map((i) => `${i.code} ${i.message}`).join(';')}`);
118
+ }
119
+
120
+ return {
121
+ mode: mode.name,
122
+ declaredRtpPpm,
123
+ bytes,
124
+ contentHash,
125
+ rowCount: rows.length,
126
+ totalWeight,
127
+ betCostCenti,
128
+ books: collapse ? null : Array.from(plan.sample, (s, i) => ({ simId: i, frames: b64(batch.books[s]!) })),
129
+ verdict,
130
+ stats: {
131
+ sims: spec.sims,
132
+ distinctPayouts: plan.groups.length,
133
+ declaredTiers: spec.paytableFor(freezeConfig(spec, mode)).length,
134
+ rtpPpm: verdict.computedRtpPpm,
135
+ maxPayoutCenti: verdict.maxPayoutCenti,
136
+ hitRatePpm: verdict.hitRatePpm,
137
+ iterations: solved.iterations,
138
+ residual: solved.residual,
139
+ trueUp,
140
+ ...flagOutliers(plan, solved.weights, spec.sims),
141
+ millis: Date.now() - t0,
142
+ },
143
+ };
144
+ }
145
+
146
+ /** 完整走一遍平台的流式校验路径——同一份 @gamekit/lut 代码,所以结论可迁移 */
147
+ export function selfVerify(
148
+ bytes: Uint8Array,
149
+ expect: {
150
+ rowCount: number; contentHash: string; betCostCenti: number;
151
+ declaredRtpPpm: number; headerTotalWeight: number;
152
+ },
153
+ ): Verdict {
154
+ const state = initState();
155
+ state.hashState = new Sha256().update(bytes.subarray(0, 64)).exportState();
156
+ consumeChunk(state, bytes.subarray(64), { maxPayoutCenti: MAX_PAYOUT_CENTI });
157
+ return finalize(state, expect);
158
+ }
159
+
160
+ export async function buildAll<C, O>(
161
+ spec: GameDefinition<C, O>, opts: BuildOptions = {},
162
+ ): Promise<BuiltTable[]> {
163
+ const out: BuiltTable[] = [];
164
+ for (let i = 0; i < spec.modes.length; i++) out.push(await buildTable(spec, i, opts));
165
+ return out;
166
+ }
167
+
168
+ export { SAMPLE_BET_CENTS };
@@ -0,0 +1,6 @@
1
+ export * from './spec.ts';
2
+ export * from './sample.ts';
3
+ export * from './table.ts';
4
+ export * from './build.ts';
5
+ export * from './upload.ts';
6
+ export { defaultConcurrency, sampleParallel } from './parallel.ts';