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.
- package/README.md +48 -0
- package/client/http.ts +253 -0
- package/client/index.ts +211 -0
- package/client/node.ts +61 -0
- package/create-game/scaffold.ts +73 -0
- package/create-game/template/CLAUDE.md.tmpl +167 -0
- package/create-game/template/_gitignore +4 -0
- package/create-game/template/game.meta.json.tmpl +8 -0
- package/create-game/template/game.ts.tmpl +57 -0
- package/create-game/template/index.html.tmpl +11 -0
- package/create-game/template/package.json.tmpl +26 -0
- package/create-game/template/scripts/verify.ts.tmpl +79 -0
- package/create-game/template/src/assets/critical/.gitkeep +0 -0
- package/create-game/template/src/assets/lazy/.gitkeep +0 -0
- package/create-game/template/src/assets/manifest.ts +30 -0
- package/create-game/template/src/assets/registry.ts +23 -0
- package/create-game/template/src/components/App.svelte.tmpl +54 -0
- package/create-game/template/src/context.svelte.ts.tmpl +22 -0
- package/create-game/template/src/dev.ts +8 -0
- package/create-game/template/src/index.ts.tmpl +17 -0
- package/create-game/template/src/rules/const.ts +9 -0
- package/create-game/template/src/rules/restore.ts.tmpl +39 -0
- package/create-game/template/src/rules/schema.ts.tmpl +19 -0
- package/create-game/template/src/rules/simulate.ts.tmpl +9 -0
- package/create-game/template/src/rules/types.ts.tmpl +13 -0
- package/create-game/template/src/state/game.svelte.ts.tmpl +45 -0
- package/create-game/template/src/styles/animations.css +4 -0
- package/create-game/template/src/styles/global.css +18 -0
- package/create-game/template/src/view/bridge.svelte.ts +33 -0
- package/create-game/template/src/view/mount.svelte.ts.tmpl +86 -0
- package/create-game/template/src/view/present.ts.tmpl +36 -0
- package/create-game/template/test/e2e.test.ts.tmpl +81 -0
- package/create-game/template/tsconfig.json +22 -0
- package/create-game/template/vite.config.ts +4 -0
- package/create-game/template/vitest.config.ts +10 -0
- package/dev-host/DevShell.svelte +348 -0
- package/dev-host/Field.svelte +25 -0
- package/dev-host/SchemaFields.svelte +14 -0
- package/dev-host/SchemaFieldsPure.svelte +98 -0
- package/dev-host/context.svelte.ts +9 -0
- package/dev-host/fonts.ts +16 -0
- package/dev-host/index.ts +8 -0
- package/dev-host/local.ts +69 -0
- package/dev-host/platform-server.ts +78 -0
- package/dev-host/platform.svelte.ts +277 -0
- package/dev-host/remote.ts +178 -0
- package/dev-host/run.ts +46 -0
- package/dev-host/table-server.ts +194 -0
- package/dev-host/theme.css +344 -0
- package/lut/book.ts +97 -0
- package/lut/csv.ts +37 -0
- package/lut/format.ts +106 -0
- package/lut/index.ts +4 -0
- package/lut/verify.ts +243 -0
- package/package.json +58 -0
- package/protocol/bets.ts +74 -0
- package/protocol/errors.ts +90 -0
- package/protocol/games.ts +158 -0
- package/protocol/index.ts +9 -0
- package/protocol/money.ts +51 -0
- package/protocol/rounds.ts +60 -0
- package/protocol/seeds.ts +36 -0
- package/protocol/session.ts +18 -0
- package/protocol/tables.ts +120 -0
- package/protocol/verify.ts +33 -0
- package/publish/build.ts +168 -0
- package/publish/index.ts +6 -0
- package/publish/parallel.ts +56 -0
- package/publish/sample-worker.ts +31 -0
- package/publish/sample.ts +131 -0
- package/publish/spec.ts +8 -0
- package/publish/table.ts +244 -0
- package/publish/upload-core.ts +176 -0
- package/publish/upload.ts +30 -0
- package/runtime/assets.ts +122 -0
- package/runtime/fonts.ts +21 -0
- package/runtime/index.ts +3 -0
- package/runtime/types.ts +42 -0
- package/sdk/contract.ts +51 -0
- package/sdk/hash.ts +184 -0
- package/sdk/host.ts +96 -0
- package/sdk/index.ts +9 -0
- package/sdk/rng.ts +109 -0
- package/sdk/sample.ts +68 -0
- package/sdk/schema.ts +90 -0
- package/sdk/spec.ts +255 -0
- package/sdk/stage.ts +20 -0
- package/sdk/store.ts +67 -0
- package/studio/app/App.svelte +113 -0
- package/studio/app/lib/api.ts +59 -0
- package/studio/app/lib/bus.svelte.ts +44 -0
- package/studio/app/lib/upload.ts +34 -0
- package/studio/app/main.ts +5 -0
- package/studio/app/panels/CasePanel.svelte +83 -0
- package/studio/app/panels/LogPanel.svelte +19 -0
- package/studio/app/panels/PreviewPanel.svelte +33 -0
- package/studio/app/panels/PublishPanel.svelte +111 -0
- package/studio/app/panels/TablePanel.svelte +64 -0
- package/studio/app/panels/TuningPanel.svelte +140 -0
- package/studio/app/virtual.d.ts +6 -0
- package/studio/bin.ts +68 -0
- package/studio/cli.ts +43 -0
- package/studio/preview/bridge.ts +63 -0
- package/studio/preview/entry.ts +89 -0
- package/studio/preview/virtual.d.ts +6 -0
- package/studio/src/api.ts +157 -0
- package/studio/src/build.ts +166 -0
- package/studio/src/bus.ts +98 -0
- package/studio/src/canon.ts +18 -0
- package/studio/src/cases.ts +255 -0
- package/studio/src/config.ts +39 -0
- package/studio/src/engine.ts +218 -0
- package/studio/src/fingerprint.ts +39 -0
- package/studio/src/game-vite.ts +91 -0
- package/studio/src/game.ts +173 -0
- package/studio/src/jobs.ts +59 -0
- package/studio/src/mcp.ts +357 -0
- package/studio/src/probe.ts +359 -0
- package/studio/src/scaffold.ts +85 -0
- package/studio/src/solver.ts +139 -0
- package/studio/src/stats.ts +134 -0
- package/studio/src/studio-plugin.ts +76 -0
- package/studio/src/tasks.ts +52 -0
- package/studio/src/worker/pool.ts +84 -0
- package/studio/src/worker/rpc.ts +178 -0
- package/vite-config/index.js +207 -0
- package/vite-config/index.ts +87 -0
- package/vite-config/manifest.ts +144 -0
- package/vite-config/meta.ts +41 -0
- package/vite-config/namespace-css.ts +74 -0
- package/weights/index.ts +11 -0
- package/weights/linalg.ts +118 -0
- package/weights/report.ts +78 -0
- package/weights/solve.ts +236 -0
- package/weights/types.ts +73 -0
- package/weights/volatility.ts +40 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* 表模型的本地服务端:按权重从建好的表里抽一行,再 restore() 还原。
|
|
2
|
+
*
|
|
3
|
+
* 这就是真平台的开奖方式。能在本地跑它,才能在发布前抓到 restore 契约违反
|
|
4
|
+
* (「玩家看到连成三条线,余额却没动」那一类)——以前的假服务端现算 round(),
|
|
5
|
+
* restore 一次都不跑,作者第一次发现问题是在发布之后。
|
|
6
|
+
*
|
|
7
|
+
* 抽行链路照抄 packages/backend/src/services/bet.ts 的 recompute():
|
|
8
|
+
* createRng(seed, `gklt/v1/${contentHash}`) → sampleBelow(rng, totalWeight) → 定位行 → winCents
|
|
9
|
+
* 四道校验照抄 remote.ts。种子链是本地的反向哈希链,承诺语义与平台一致。 */
|
|
10
|
+
import { HEADER_BYTES, ROW_BYTES, decodeHeader, fromBase64, readRow, type LutRow } from '../lut/index.ts';
|
|
11
|
+
import { SAMPLER_ID, winCents } from '../protocol/index.ts';
|
|
12
|
+
import {
|
|
13
|
+
Sha256, createRng, locateRow, randomSeed, sampleBelow, seedHash, sha256, toHex, utf8,
|
|
14
|
+
type GameDefinition, type RestoreInput, type RngSeed,
|
|
15
|
+
} from '../sdk/index.ts';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_BALANCE, DEFAULT_LIMITS, type Check, type PlaceResult, type PlatformServer, type PlayInput, type WalletRef, type WalletState,
|
|
18
|
+
} from './platform-server.ts';
|
|
19
|
+
|
|
20
|
+
export interface LoadedTable {
|
|
21
|
+
mode: string;
|
|
22
|
+
contentHash: string;
|
|
23
|
+
totalWeight: bigint;
|
|
24
|
+
betCostCenti: number;
|
|
25
|
+
declaredRtpPpm: number;
|
|
26
|
+
rows: LutRow[];
|
|
27
|
+
/** cumulative[i] = Σ weight[0..i],抽行用二分 */
|
|
28
|
+
cumulative: bigint[];
|
|
29
|
+
books: Map<number, Uint8Array> | null;
|
|
30
|
+
/** 从字节推的统计,面板显示用 */
|
|
31
|
+
stats: { computedRtpPpm: number; hitRatePpm: number; maxPayoutCenti: number; distinctPayouts: number };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TableServerOptions<C, O> {
|
|
35
|
+
spec: Pick<GameDefinition<C, O>, 'restore' | 'modeOf' | 'normalize' | 'validate'>;
|
|
36
|
+
/** 闭包持有可变引用:换表时换 Map 内容,接口不动 */
|
|
37
|
+
tables: () => ReadonlyMap<string, LoadedTable>;
|
|
38
|
+
/** 表还没有时怎么办。不给就报错 */
|
|
39
|
+
ensure?: (mode: string) => Promise<void>;
|
|
40
|
+
reload?: () => Promise<void>;
|
|
41
|
+
wallet?: WalletRef;
|
|
42
|
+
chainLength?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const next = (s: string): string => toHex(sha256(utf8(s)));
|
|
46
|
+
|
|
47
|
+
/** 本地反向哈希链。s_{k-1} = sha256(s_k),揭示 s_n 就是对 s_{n+1} 的承诺 */
|
|
48
|
+
function makeChain(length: number): string[] {
|
|
49
|
+
const seeds = new Array<string>(length + 1);
|
|
50
|
+
seeds[length] = randomSeed(32);
|
|
51
|
+
for (let k = length; k > 0; k--) seeds[k - 1] = next(seeds[k]!);
|
|
52
|
+
return seeds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseTable(
|
|
56
|
+
mode: string, bytes: Uint8Array, books: readonly { simId: number; frames: string }[] | null,
|
|
57
|
+
): LoadedTable {
|
|
58
|
+
const h = decodeHeader(bytes);
|
|
59
|
+
if ('problem' in h) throw new Error(`${mode} 的表头坏了:${h.problem.message}`);
|
|
60
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
61
|
+
const rows: LutRow[] = [];
|
|
62
|
+
const cumulative: bigint[] = [];
|
|
63
|
+
let acc = 0n;
|
|
64
|
+
let hit = 0n, maxP = 0;
|
|
65
|
+
let weighted = 0n;
|
|
66
|
+
const distinct = new Set<number>();
|
|
67
|
+
for (let i = 0; i < h.header.rowCount; i++) {
|
|
68
|
+
const r = readRow(dv, HEADER_BYTES + i * ROW_BYTES, { simId: 0, payoutCenti: 0, weight: 0 });
|
|
69
|
+
rows.push({ ...r });
|
|
70
|
+
acc += BigInt(r.weight);
|
|
71
|
+
cumulative.push(acc);
|
|
72
|
+
if (r.payoutCenti > 0) hit += BigInt(r.weight);
|
|
73
|
+
if (r.payoutCenti > maxP) maxP = r.payoutCenti;
|
|
74
|
+
weighted += BigInt(r.weight) * BigInt(r.payoutCenti);
|
|
75
|
+
distinct.add(r.payoutCenti);
|
|
76
|
+
}
|
|
77
|
+
const total = BigInt(h.header.totalWeight);
|
|
78
|
+
const den = total * BigInt(h.header.betCostCenti);
|
|
79
|
+
return {
|
|
80
|
+
mode, contentHash: new Sha256().update(bytes).hex(), totalWeight: total, betCostCenti: h.header.betCostCenti,
|
|
81
|
+
declaredRtpPpm: h.header.declaredRtpPpm, rows, cumulative,
|
|
82
|
+
books: books ? new Map(books.map((b) => [b.simId, fromBase64(b.frames)])) : null,
|
|
83
|
+
stats: {
|
|
84
|
+
computedRtpPpm: den > 0n ? Number((2n * weighted * 1_000_000n + den) / (2n * den)) : 0,
|
|
85
|
+
hitRatePpm: total > 0n ? Number((2n * hit * 1_000_000n + total) / (2n * total)) : 0,
|
|
86
|
+
maxPayoutCenti: maxP, distinctPayouts: distinct.size,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 二分定位:第一个 cumulative > r 的行。<5000 行时对着 sdk 的规范实现断言 */
|
|
92
|
+
function locate(t: LoadedTable, r: bigint): number {
|
|
93
|
+
let lo = 0, hi = t.cumulative.length - 1;
|
|
94
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (t.cumulative[mid]! > r) hi = mid; else lo = mid + 1; }
|
|
95
|
+
if (t.rows.length < 5_000) {
|
|
96
|
+
const ref = locateRow(t.rows.map((x) => x.weight), r);
|
|
97
|
+
if (ref !== lo) throw new Error(`二分定位 ${lo} 与规范实现 ${ref} 不一致——这是 dev-host 的 bug`);
|
|
98
|
+
}
|
|
99
|
+
return lo;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function tableServer<C, O>(o: TableServerOptions<C, O>): PlatformServer<C, O> {
|
|
103
|
+
const { spec } = o;
|
|
104
|
+
const wallet = o.wallet ?? { balance: DEFAULT_BALANCE };
|
|
105
|
+
const chain = makeChain(o.chainLength ?? 2_000);
|
|
106
|
+
let nonce = 0;
|
|
107
|
+
const state = (): WalletState => ({ balance: wallet.balance, currency: 'CNY', decimals: 2, jackpot: null });
|
|
108
|
+
|
|
109
|
+
const play = async (input: PlayInput<C>): Promise<PlaceResult<C, O>> => {
|
|
110
|
+
const fail = (error: string): PlaceResult<C, O> => ({ ok: false, error });
|
|
111
|
+
const config = spec.normalize(input.config);
|
|
112
|
+
const issues = spec.validate(config);
|
|
113
|
+
if (issues.length) return fail(Object.values(issues[0]!.message)[0] ?? '参数不合法');
|
|
114
|
+
if (input.bet <= 0 || input.bet % 100 !== 0) return fail('注额必须是 100 分(1 元)的整数倍——那是平台记账的前提');
|
|
115
|
+
if (input.bet > wallet.balance) return fail('余额不足');
|
|
116
|
+
|
|
117
|
+
let mode: string;
|
|
118
|
+
try { mode = spec.modeOf(config); } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
119
|
+
if (!o.tables().has(mode) && o.ensure) {
|
|
120
|
+
try { await o.ensure(mode); } catch (e) { return fail(`建表失败:${e instanceof Error ? e.message : String(e)}`); }
|
|
121
|
+
}
|
|
122
|
+
const t = o.tables().get(mode);
|
|
123
|
+
if (!t) return fail(`mode ${mode} 还没建表(已有:${[...o.tables().keys()].join('、') || '无'})`);
|
|
124
|
+
|
|
125
|
+
const checks: Check[] = [];
|
|
126
|
+
let rowIndex: number;
|
|
127
|
+
let seed: RngSeed;
|
|
128
|
+
let draw: { target: string; words: string[] } | null = null;
|
|
129
|
+
|
|
130
|
+
if (input.simId !== undefined) {
|
|
131
|
+
rowIndex = t.rows.findIndex((r) => r.simId === input.simId);
|
|
132
|
+
if (rowIndex < 0) return fail(`表 ${mode} 里没有 simId ${input.simId}`);
|
|
133
|
+
seed = { server: chain[nonce + 1]!, client: input.clientSeed, nonce };
|
|
134
|
+
// 不伪造一条通过的校验:定点复现没走抽样,就说没走
|
|
135
|
+
checks.push({ label: '定点复现,本局未走抽样', ok: true, detail: `simId ${input.simId} → 第 ${rowIndex} 行` });
|
|
136
|
+
} else {
|
|
137
|
+
if (nonce + 1 >= chain.length) return fail('本地种子链用完了,刷新页面重开一条');
|
|
138
|
+
const serverSeed = chain[nonce + 1]!;
|
|
139
|
+
const commitment = chain[nonce]!;
|
|
140
|
+
seed = { server: serverSeed, client: input.clientSeed, nonce };
|
|
141
|
+
const rng = createRng(seed, `${SAMPLER_ID}/${t.contentHash}`);
|
|
142
|
+
const d = sampleBelow(rng, t.totalWeight);
|
|
143
|
+
rowIndex = locate(t, d.value);
|
|
144
|
+
draw = { target: d.value.toString(), words: d.words };
|
|
145
|
+
|
|
146
|
+
const revealed = seedHash(serverSeed);
|
|
147
|
+
if (revealed !== commitment) return fail(`承诺链断了:sha256(种子)=${revealed.slice(0, 16)}… ≠ 承诺 ${commitment.slice(0, 16)}…`);
|
|
148
|
+
checks.push({ label: '揭示的种子哈希得上一局公布的承诺', ok: true, detail: `${revealed.slice(0, 16)}…` });
|
|
149
|
+
const before = rowIndex === 0 ? 0n : t.cumulative[rowIndex - 1]!;
|
|
150
|
+
const after = t.cumulative[rowIndex]!;
|
|
151
|
+
if (!(before <= d.value && d.value < after)) return fail(`靶点 ${d.value} 不在中奖行 [${before}, ${after}) 内`);
|
|
152
|
+
checks.push({ label: '靶点落在中奖行的累积权重区间内', ok: true, detail: `${before} ≤ ${d.value} < ${after}` });
|
|
153
|
+
}
|
|
154
|
+
nonce++;
|
|
155
|
+
|
|
156
|
+
const row = t.rows[rowIndex]!;
|
|
157
|
+
const payout = winCents(input.bet, row.payoutCenti);
|
|
158
|
+
checks.push({ label: '赔付 = 注额/100 × 表里那一行的倍数', ok: true, detail: `${input.bet}/100 × ${row.payoutCenti} = ${payout} 分` });
|
|
159
|
+
|
|
160
|
+
const restoreInput: RestoreInput<C> = { config, bet: input.bet, payoutCenti: row.payoutCenti, frames: t.books?.get(row.simId) ?? null, mode, seed };
|
|
161
|
+
// 钱先记:这一注在「平台」已经发生,后面的还原无论成败都不该回滚
|
|
162
|
+
wallet.balance += payout - input.bet;
|
|
163
|
+
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = spec.restore(restoreInput);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return fail(`演出数据还原失败(表 ${mode} 第 ${rowIndex} 行,simId ${row.simId}):${e instanceof Error ? e.message : String(e)}`);
|
|
169
|
+
}
|
|
170
|
+
if (result.payout !== payout) {
|
|
171
|
+
return fail(`还原出的赔付 ${result.payout} 与平台记账的 ${payout} 不符,本局不演出——这正是 restore 契约要拦的那类 bug`);
|
|
172
|
+
}
|
|
173
|
+
checks.push({ label: '还原出的演出与平台记账一致', ok: true, detail: `${result.payout} 分` });
|
|
174
|
+
|
|
175
|
+
const proof = {
|
|
176
|
+
algorithm: SAMPLER_ID, mode, tableContentHash: t.contentHash,
|
|
177
|
+
serverSeed: seed.server, serverSeedHash: seedHash(seed.server), clientSeed: seed.client, nonce: seed.nonce,
|
|
178
|
+
drawTarget: draw?.target ?? null, drawWords: draw?.words ?? [],
|
|
179
|
+
totalWeight: t.totalWeight.toString(), rowIndex, simId: row.simId, payoutCenti: row.payoutCenti,
|
|
180
|
+
cumulativeBefore: (rowIndex === 0 ? 0n : t.cumulative[rowIndex - 1]!).toString(), cumulativeAfter: t.cumulative[rowIndex]!.toString(),
|
|
181
|
+
};
|
|
182
|
+
return { ok: true, round: { result, row: restoreInput, wallet: state(), checks, proof } };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
kind: 'local',
|
|
187
|
+
init: async () => state(),
|
|
188
|
+
limits: () => DEFAULT_LIMITS,
|
|
189
|
+
refill: () => { wallet.balance = DEFAULT_BALANCE; return state(); },
|
|
190
|
+
place: (i) => play(i),
|
|
191
|
+
play,
|
|
192
|
+
reload: async () => { await o.reload?.(); return { modes: [...o.tables().keys()] }; },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/* 平台的视觉语言,玩家站点、参考平台、studio 三处共用同一份。
|
|
2
|
+
取自 Coco 那台机器:香蕉黄的底、奶油色的机身、酒红的舞台。
|
|
3
|
+
面板是「立在桌上的塑料机台」而不是「浮在暗色里的卡片」——所以阴影是硬的
|
|
4
|
+
(0 10px 0,没有模糊),按下去会真的沉下去 4px。
|
|
5
|
+
|
|
6
|
+
这份文件只由 apps/web 的 main.ts、dev-host 的 run.ts 和 studio 的 main.ts import,
|
|
7
|
+
改了任何一处三边一起变——这正是它存在的理由。 */
|
|
8
|
+
|
|
9
|
+
:root {
|
|
10
|
+
--page: #FFD21F;
|
|
11
|
+
--cab: #FFF6DA;
|
|
12
|
+
--field: #FFFFFF;
|
|
13
|
+
--line: #E6CF94;
|
|
14
|
+
--ink: #2A0F12;
|
|
15
|
+
--dim: #80605A;
|
|
16
|
+
--accent-ink: #A8231A;
|
|
17
|
+
--focus: #2A0F12;
|
|
18
|
+
--cab-shadow: 0 10px 0 #D9A900;
|
|
19
|
+
|
|
20
|
+
/* 舞台:游戏画面所在的那块深色区域 */
|
|
21
|
+
--stage: #1A0B11;
|
|
22
|
+
--tile: #2C161F;
|
|
23
|
+
--s-ink: #FFF1DC;
|
|
24
|
+
--s-dim: #A3858C;
|
|
25
|
+
|
|
26
|
+
--coco: #F0402E;
|
|
27
|
+
--coco-d: #A8231A;
|
|
28
|
+
--banana: #FFD21F;
|
|
29
|
+
--banana-d: #B98F00;
|
|
30
|
+
--cream: #FFF1DC;
|
|
31
|
+
--ok: #2E9E5B;
|
|
32
|
+
|
|
33
|
+
--num: "Lilita One", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
34
|
+
--txt: "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
|
35
|
+
--fun: "ZCOOL KuaiLe", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
36
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@media (prefers-color-scheme: dark) {
|
|
40
|
+
:root:not([data-theme="light"]) {
|
|
41
|
+
--page: #12070B;
|
|
42
|
+
--cab: #211017;
|
|
43
|
+
--field: #2E1821;
|
|
44
|
+
--line: #4A2A35;
|
|
45
|
+
--ink: #FFF1DC;
|
|
46
|
+
--dim: #B4959A;
|
|
47
|
+
--accent-ink: #FFD21F;
|
|
48
|
+
--focus: #FFD21F;
|
|
49
|
+
--cab-shadow: 0 10px 0 #090305;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
:root[data-theme="dark"] {
|
|
53
|
+
--page: #12070B;
|
|
54
|
+
--cab: #211017;
|
|
55
|
+
--field: #2E1821;
|
|
56
|
+
--line: #4A2A35;
|
|
57
|
+
--ink: #FFF1DC;
|
|
58
|
+
--dim: #B4959A;
|
|
59
|
+
--accent-ink: #FFD21F;
|
|
60
|
+
--focus: #FFD21F;
|
|
61
|
+
--cab-shadow: 0 10px 0 #090305;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
|
|
66
|
+
body {
|
|
67
|
+
margin: 0;
|
|
68
|
+
background: var(--page);
|
|
69
|
+
color: var(--ink);
|
|
70
|
+
font-family: var(--txt);
|
|
71
|
+
font-size: 15px;
|
|
72
|
+
line-height: 1.5;
|
|
73
|
+
-webkit-font-smoothing: antialiased;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* 游戏容器:CSS 尺寸恒为逻辑分辨率(sdk 的 STAGE_W/STAGE_H),由平台按舞台宽度整体 scale。
|
|
77
|
+
玩家站点里它是 sandbox iframe,参考平台里是直接挂载游戏的 div,缩放规则是同一条。
|
|
78
|
+
放在 @scope 外面,因为它自己就是下面那道边界。 */
|
|
79
|
+
.stage > .gameframe {
|
|
80
|
+
position: absolute; top: 0; left: 0; border: 0; display: block; transform-origin: 0 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* 下面所有带类名的规则都止步于 .gameframe:参考平台和 studio 预览里游戏和平台在同一个
|
|
84
|
+
document 里,而游戏自己就有 .coin / .stage / .win / .gold 这些类——不设边界的话,
|
|
85
|
+
平台的 .coin{width:18px} 会把 coin-flip 的硬币压成一个小圆点。
|
|
86
|
+
玩家站点里 .gameframe 是 iframe,没有子节点,这条边界在那边是空操作。 */
|
|
87
|
+
@scope (:root) to (.gameframe) {
|
|
88
|
+
|
|
89
|
+
a { color: var(--accent-ink); text-decoration: none; font-weight: 700; }
|
|
90
|
+
a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
91
|
+
|
|
92
|
+
button, input, select, output { font: inherit; color: inherit; }
|
|
93
|
+
button { cursor: pointer; }
|
|
94
|
+
button:disabled { cursor: not-allowed; }
|
|
95
|
+
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
96
|
+
|
|
97
|
+
h1 { font-family: var(--fun); font-weight: 400; font-size: 30px; line-height: 1.1; margin: 22px 0 6px; }
|
|
98
|
+
h2 { font-family: var(--fun); font-weight: 400; font-size: 20px; line-height: 1.2; margin: 0 0 12px; }
|
|
99
|
+
h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
|
|
100
|
+
|
|
101
|
+
/* 内容宽度固定 1200:机台要的是「一台机器」的稳定尺寸,不是随窗口伸缩的流式布局。
|
|
102
|
+
窄屏收到 calc(100% - 32px),否则整页会横向溢出——机台本身不会变窄,
|
|
103
|
+
被推出去的是页眉、大厅和下面的说明卡片 */
|
|
104
|
+
.wrap { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding-bottom: 48px; }
|
|
105
|
+
|
|
106
|
+
/* ---------- 机台与面板 ---------- */
|
|
107
|
+
.panel {
|
|
108
|
+
background: var(--cab);
|
|
109
|
+
border-radius: 22px;
|
|
110
|
+
box-shadow: var(--cab-shadow);
|
|
111
|
+
padding: 18px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* 机台:左参数、右画布、底部一条横跨整机的设置栏。
|
|
115
|
+
宽屏下整机的高度是确定的——它由画布的黄金比例算出来,不由游戏或参数条数决定 */
|
|
116
|
+
.cab {
|
|
117
|
+
display: grid;
|
|
118
|
+
grid-template-columns: 304px minmax(0, 1fr);
|
|
119
|
+
grid-template-areas: "panel stage" "bar bar";
|
|
120
|
+
background: var(--cab);
|
|
121
|
+
border-radius: 22px;
|
|
122
|
+
box-shadow: var(--cab-shadow);
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
/* 参数区里那一层是绝对定位的:机台这一行的高度必须只由画布的黄金比例决定,
|
|
126
|
+
而 grid 的 auto 行高会取到 item 的 max-content——参数多一条机台就高一截。
|
|
127
|
+
脱离文档流之后它对行高的贡献是 0,装不下就自己滚。
|
|
128
|
+
(用 `min-height:0` 只能让它被压扁,压不掉它对行高的贡献。) */
|
|
129
|
+
.panel-col { grid-area: panel; position: relative; min-height: 0; }
|
|
130
|
+
.panel-inner {
|
|
131
|
+
position: absolute; inset: 0;
|
|
132
|
+
display: flex; flex-direction: column; gap: 16px; padding: 20px;
|
|
133
|
+
overflow: hidden;
|
|
134
|
+
}
|
|
135
|
+
.panel-inner > * { flex: none; }
|
|
136
|
+
.panel-inner hr { width: 100%; border: 0; border-top: 1px solid var(--line); margin: 2px 0 0; }
|
|
137
|
+
/* 盖在参数区上。跟着 .panel-col 定位,所以窄屏重排后它依然盖的是同一块 */
|
|
138
|
+
.veil {
|
|
139
|
+
position: absolute; inset: 0; z-index: 3;
|
|
140
|
+
background: color-mix(in srgb, var(--cab) 92%, transparent);
|
|
141
|
+
backdrop-filter: blur(2px);
|
|
142
|
+
display: grid; place-items: center; padding: 20px;
|
|
143
|
+
}
|
|
144
|
+
.vbox { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 264px; text-align: center; }
|
|
145
|
+
.vbox h3 { font-family: var(--fun); font-weight: 400; font-size: 20px; margin: 0; }
|
|
146
|
+
.vbox p { margin: 0; }
|
|
147
|
+
.vbox b { font-family: var(--num); font-weight: 400; font-size: 17px; }
|
|
148
|
+
.vbox .hint { margin-bottom: 4px; }
|
|
149
|
+
|
|
150
|
+
/* min-height:0 是 flex 子项能滚起来的前提:默认 min-height:auto 会让它撑到内容高度,
|
|
151
|
+
于是溢出的是整个面板,而不是这一块自己滚 */
|
|
152
|
+
.params {
|
|
153
|
+
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
|
154
|
+
display: flex; flex-direction: column; gap: 16px; padding-right: 4px;
|
|
155
|
+
scrollbar-width: thin; scrollbar-color: var(--line) transparent;
|
|
156
|
+
}
|
|
157
|
+
.params::-webkit-scrollbar { width: 8px; }
|
|
158
|
+
.params::-webkit-scrollbar-thumb { background: var(--line); border-radius: 99px; }
|
|
159
|
+
|
|
160
|
+
/* 形状来自 --ratio,而 --ratio 由调用方用 STAGE_W/STAGE_H 写进 style——容器的缩放倍率算的是
|
|
161
|
+
同一组数。写成 CSS 字面量的话,改了那边忘了这边,画面会被裁掉一条边 */
|
|
162
|
+
.stage {
|
|
163
|
+
grid-area: stage; background: var(--stage); color: var(--s-ink);
|
|
164
|
+
min-width: 0; aspect-ratio: var(--ratio); position: relative; overflow: hidden;
|
|
165
|
+
}
|
|
166
|
+
.stage .loading {
|
|
167
|
+
position: absolute; inset: 0; margin: 0;
|
|
168
|
+
display: grid; place-items: center; color: var(--s-dim); font-family: var(--fun); font-size: 18px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.bar {
|
|
172
|
+
grid-area: bar; display: flex; align-items: center; justify-content: space-between;
|
|
173
|
+
gap: 12px; padding: 10px 20px; border-top: 1px solid var(--line);
|
|
174
|
+
}
|
|
175
|
+
.bar .tg { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
176
|
+
.bar button {
|
|
177
|
+
display: inline-flex; align-items: center; gap: 6px; border: 0; background: none;
|
|
178
|
+
border-radius: 10px; padding: 6px 10px; font-size: 13px; font-weight: 500; color: var(--dim);
|
|
179
|
+
}
|
|
180
|
+
.bar button:hover:not(:disabled) { background: var(--field); }
|
|
181
|
+
.bar button[aria-pressed="true"] { color: var(--ink); font-weight: 700; }
|
|
182
|
+
.bar button:disabled { opacity: .45; }
|
|
183
|
+
.bar svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
184
|
+
.bar .fairbtn { text-decoration: underline; text-underline-offset: 3px; }
|
|
185
|
+
.brand { font-family: var(--fun); font-size: 17px; color: var(--dim); }
|
|
186
|
+
.bal { font-family: var(--num); font-weight: 400; font-size: 19px; font-variant-numeric: tabular-nums; color: var(--accent-ink); }
|
|
187
|
+
|
|
188
|
+
/* 窄屏:画布在上,操作区在中,工具条在底。整机高度改为自适应——
|
|
189
|
+
画布仍按黄金比例跟着宽度走,参数区不再滚动而是整个摊开 */
|
|
190
|
+
@media (max-width: 1000px) {
|
|
191
|
+
.cab { grid-template-columns: minmax(0, 1fr); grid-template-areas: "stage" "panel" "bar"; }
|
|
192
|
+
.panel-col { min-height: auto; }
|
|
193
|
+
.panel-inner { position: static; overflow: visible; }
|
|
194
|
+
.params { flex: none; min-height: auto; overflow-y: visible; padding-right: 0; }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---------- 按钮 ---------- */
|
|
198
|
+
.cta {
|
|
199
|
+
border: 0;
|
|
200
|
+
border-radius: 14px;
|
|
201
|
+
min-height: 56px;
|
|
202
|
+
padding: 0 14px;
|
|
203
|
+
font-weight: 700;
|
|
204
|
+
font-size: 17px;
|
|
205
|
+
background: var(--coco);
|
|
206
|
+
color: #FFF6EA;
|
|
207
|
+
box-shadow: 0 5px 0 var(--coco-d);
|
|
208
|
+
transition: transform .06s, box-shadow .06s;
|
|
209
|
+
}
|
|
210
|
+
.cta:active:not(:disabled) { transform: translateY(4px); box-shadow: 0 1px 0 var(--coco-d); }
|
|
211
|
+
.cta.cash { background: var(--banana); color: #2A0F12; box-shadow: 0 5px 0 var(--banana-d); }
|
|
212
|
+
.cta.cash:active:not(:disabled) { box-shadow: 0 1px 0 var(--banana-d); }
|
|
213
|
+
.cta:disabled { background: var(--line); color: var(--dim); box-shadow: 0 5px 0 rgba(0, 0, 0, .12); }
|
|
214
|
+
.cta .n { font-family: var(--num); font-weight: 400; font-size: 19px; letter-spacing: .01em; }
|
|
215
|
+
|
|
216
|
+
.ghostbtn {
|
|
217
|
+
border: 1px solid var(--line);
|
|
218
|
+
background: none;
|
|
219
|
+
border-radius: 12px;
|
|
220
|
+
min-height: 42px;
|
|
221
|
+
padding: 0 14px;
|
|
222
|
+
font-weight: 700;
|
|
223
|
+
font-size: 14px;
|
|
224
|
+
}
|
|
225
|
+
.ghostbtn:hover:not(:disabled) { background: var(--field); }
|
|
226
|
+
.ghostbtn:disabled { opacity: .45; }
|
|
227
|
+
|
|
228
|
+
/* ---------- 表单 ---------- */
|
|
229
|
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
230
|
+
.lab { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; font-weight: 700; }
|
|
231
|
+
.lab > :last-child { font-weight: 500; color: var(--dim); font-variant-numeric: tabular-nums; }
|
|
232
|
+
.lab .acc { font-family: var(--num); font-size: 15px; color: var(--accent-ink); font-weight: 400; }
|
|
233
|
+
|
|
234
|
+
.inp {
|
|
235
|
+
display: flex; align-items: center; gap: 6px;
|
|
236
|
+
background: var(--field); border: 1px solid var(--line);
|
|
237
|
+
border-radius: 12px; padding: 6px 6px 6px 12px; min-height: 46px;
|
|
238
|
+
}
|
|
239
|
+
.inp:focus-within { border-color: var(--ink); }
|
|
240
|
+
.inp input, .inp output, .inp select {
|
|
241
|
+
flex: 1; min-width: 0; border: 0; background: none; outline: 0;
|
|
242
|
+
font-weight: 700; font-size: 16px; font-variant-numeric: tabular-nums;
|
|
243
|
+
}
|
|
244
|
+
.inp button {
|
|
245
|
+
border: 0; border-radius: 8px; background: var(--cab);
|
|
246
|
+
min-width: 38px; height: 32px; font-weight: 700; font-size: 13px;
|
|
247
|
+
}
|
|
248
|
+
.inp button:hover:not(:disabled) { background: var(--line); }
|
|
249
|
+
.inp button:disabled, .inp input:disabled, .inp select:disabled { opacity: .5; }
|
|
250
|
+
.coin {
|
|
251
|
+
flex: none; width: 18px; height: 18px; border-radius: 50%;
|
|
252
|
+
background: var(--banana);
|
|
253
|
+
box-shadow: inset 0 0 0 2px var(--banana-d), inset 0 0 0 4px var(--banana);
|
|
254
|
+
}
|
|
255
|
+
.hint { margin: 0; font-size: 12px; color: var(--dim); }
|
|
256
|
+
|
|
257
|
+
.switch {
|
|
258
|
+
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
259
|
+
width: 100%; text-align: left;
|
|
260
|
+
border: 1px solid var(--line); background: var(--field);
|
|
261
|
+
border-radius: 12px; padding: 8px 12px;
|
|
262
|
+
}
|
|
263
|
+
.switch b { display: block; font-size: 14px; }
|
|
264
|
+
.switch small { display: block; font-size: 12px; color: var(--dim); font-weight: 400; }
|
|
265
|
+
.switch i {
|
|
266
|
+
flex: none; position: relative; width: 40px; height: 24px;
|
|
267
|
+
border-radius: 99px; background: var(--line); transition: background .15s;
|
|
268
|
+
}
|
|
269
|
+
.switch i::after {
|
|
270
|
+
content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px;
|
|
271
|
+
border-radius: 50%; background: #FFF; box-shadow: 0 1px 2px rgba(0, 0, 0, .35); transition: transform .15s;
|
|
272
|
+
}
|
|
273
|
+
.switch[aria-checked="true"] i { background: #E0A800; }
|
|
274
|
+
.switch[aria-checked="true"] i::after { transform: translateX(16px); }
|
|
275
|
+
.switch:disabled { opacity: .55; }
|
|
276
|
+
|
|
277
|
+
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
278
|
+
.chips button {
|
|
279
|
+
border: 1px solid var(--line); background: var(--field);
|
|
280
|
+
border-radius: 8px; padding: 3px 9px; font-size: 13px; font-weight: 700;
|
|
281
|
+
}
|
|
282
|
+
.chips button.on { background: var(--ink); color: var(--cab); border-color: var(--ink); }
|
|
283
|
+
.chips button:disabled { opacity: .5; }
|
|
284
|
+
|
|
285
|
+
.seg { display: grid; grid-auto-flow: column; gap: 6px; }
|
|
286
|
+
.seg button {
|
|
287
|
+
border: 1px solid var(--line); background: var(--field); border-radius: 12px;
|
|
288
|
+
padding: 8px 6px; display: flex; flex-direction: column; align-items: center; line-height: 1.25;
|
|
289
|
+
}
|
|
290
|
+
.seg button b { font-size: 14px; }
|
|
291
|
+
.seg button small { font-size: 12px; color: var(--dim); }
|
|
292
|
+
.seg button[aria-checked="true"] { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
|
|
293
|
+
.seg button:disabled { opacity: .55; }
|
|
294
|
+
|
|
295
|
+
.tabs {
|
|
296
|
+
display: grid; grid-auto-flow: column; gap: 4px; padding: 4px;
|
|
297
|
+
background: var(--field); border: 1px solid var(--line); border-radius: 14px;
|
|
298
|
+
}
|
|
299
|
+
.tabs button { border: 0; background: none; border-radius: 10px; padding: 8px 0; font-weight: 700; color: var(--dim); }
|
|
300
|
+
.tabs button[aria-selected="true"] { background: var(--ink); color: var(--cab); }
|
|
301
|
+
.tabs button:disabled:not([aria-selected="true"]) { opacity: .45; }
|
|
302
|
+
|
|
303
|
+
/* ---------- 文字工具 ---------- */
|
|
304
|
+
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
305
|
+
.grow { flex: 1; min-width: 0; }
|
|
306
|
+
.dim { color: var(--dim); }
|
|
307
|
+
.mono { font-family: var(--mono); font-size: 13px; }
|
|
308
|
+
.num { font-family: var(--num); font-weight: 400; font-variant-numeric: tabular-nums; }
|
|
309
|
+
.small { font-size: 13px; }
|
|
310
|
+
.win { color: var(--ok); }
|
|
311
|
+
.lose { color: var(--dim); }
|
|
312
|
+
.bad { color: var(--coco-d); }
|
|
313
|
+
.gold { color: var(--accent-ink); }
|
|
314
|
+
:root[data-theme="dark"] .bad { color: #FF8F82; }
|
|
315
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .bad { color: #FF8F82; } }
|
|
316
|
+
|
|
317
|
+
/* ---------- 表格 ---------- */
|
|
318
|
+
table { width: 100%; border-collapse: collapse; }
|
|
319
|
+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
|
320
|
+
th { color: var(--dim); font-weight: 700; font-size: 12px; }
|
|
321
|
+
td.num, th.num { text-align: right; font-family: var(--num); font-variant-numeric: tabular-nums; }
|
|
322
|
+
|
|
323
|
+
.err {
|
|
324
|
+
background: color-mix(in srgb, var(--coco) 14%, var(--field));
|
|
325
|
+
border: 1px solid color-mix(in srgb, var(--coco) 45%, var(--line));
|
|
326
|
+
color: var(--accent-ink);
|
|
327
|
+
border-radius: 12px;
|
|
328
|
+
padding: 10px 12px;
|
|
329
|
+
font-weight: 700;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.step {
|
|
333
|
+
border: 1px solid var(--line);
|
|
334
|
+
border-radius: 14px;
|
|
335
|
+
padding: 12px 14px;
|
|
336
|
+
margin-bottom: 10px;
|
|
337
|
+
background: var(--field);
|
|
338
|
+
}
|
|
339
|
+
.step.ok { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); }
|
|
340
|
+
.step.no { border-color: color-mix(in srgb, var(--coco) 55%, var(--line)); }
|
|
341
|
+
.step h3 { margin: 0 0 6px; font-size: 14px; }
|
|
342
|
+
.step .v { word-break: break-all; }
|
|
343
|
+
|
|
344
|
+
}
|
package/lut/book.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* GKBK1:演出数据的紧凑编码。
|
|
2
|
+
*
|
|
3
|
+
* 存的是盘面序列,不是事件流。winInfo / setWin / setTotalWin / finalWin 这些
|
|
4
|
+
* 全都能从盘面 + 赔付表算出来——它们不是信息,是中间结果。
|
|
5
|
+
*
|
|
6
|
+
* 实测(0_0_lines bonus 模式 10 万局):Stake 的 JSON 平均 10,382 B/局,
|
|
7
|
+
* 这里 95 B/局,109 倍。不压的话 D1 的 10GB 只装得下 8 个游戏,压完能装 500 个。 */
|
|
8
|
+
|
|
9
|
+
export const BOOK_VERSION = 1;
|
|
10
|
+
export const FLAG_WIDE_SYMBOLS = 1; // 符号超过 16 种时每格 8 bit
|
|
11
|
+
|
|
12
|
+
export interface BookFrame {
|
|
13
|
+
gameType: number;
|
|
14
|
+
/** 行优先的符号下标,长度 = reels × rows */
|
|
15
|
+
cells: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Book {
|
|
19
|
+
payoutCenti: number;
|
|
20
|
+
frames: BookFrame[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const bookByteLength = (frameCount: number, cells: number, wide: boolean): number =>
|
|
24
|
+
8 + frameCount * (1 + (wide ? cells : Math.ceil(cells / 2)));
|
|
25
|
+
|
|
26
|
+
export function encodeBook(book: Book, cells: number, symbolCount: number): Uint8Array {
|
|
27
|
+
const wide = symbolCount > 16;
|
|
28
|
+
const out = new Uint8Array(bookByteLength(book.frames.length, cells, wide));
|
|
29
|
+
const dv = new DataView(out.buffer);
|
|
30
|
+
dv.setUint8(0, BOOK_VERSION);
|
|
31
|
+
dv.setUint8(1, book.frames.length);
|
|
32
|
+
dv.setUint16(2, wide ? FLAG_WIDE_SYMBOLS : 0, true);
|
|
33
|
+
dv.setUint32(4, book.payoutCenti, true);
|
|
34
|
+
|
|
35
|
+
let o = 8;
|
|
36
|
+
for (const f of book.frames) {
|
|
37
|
+
if (f.cells.length !== cells) {
|
|
38
|
+
throw new RangeError(`盘面格数 ${f.cells.length} 与声明的 ${cells} 不符`);
|
|
39
|
+
}
|
|
40
|
+
out[o++] = f.gameType;
|
|
41
|
+
if (wide) {
|
|
42
|
+
for (const c of f.cells) out[o++] = c;
|
|
43
|
+
} else {
|
|
44
|
+
// 每格 4 bit,低半字节在前
|
|
45
|
+
for (let i = 0; i < cells; i += 2) {
|
|
46
|
+
out[o++] = (f.cells[i]! & 0xf) | (((f.cells[i + 1] ?? 0) & 0xf) << 4);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decodeBook(bytes: Uint8Array, cells: number): Book {
|
|
54
|
+
if (bytes.length < 8) throw new RangeError('GKBK1 至少 8 字节');
|
|
55
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
56
|
+
const version = dv.getUint8(0);
|
|
57
|
+
if (version !== BOOK_VERSION) throw new RangeError(`不支持的 GKBK1 版本 ${version}`);
|
|
58
|
+
const frameCount = dv.getUint8(1);
|
|
59
|
+
const wide = (dv.getUint16(2, true) & FLAG_WIDE_SYMBOLS) !== 0;
|
|
60
|
+
const payoutCenti = dv.getUint32(4, true);
|
|
61
|
+
|
|
62
|
+
const stride = 1 + (wide ? cells : Math.ceil(cells / 2));
|
|
63
|
+
if (bytes.length !== 8 + frameCount * stride) {
|
|
64
|
+
throw new RangeError(`GKBK1 长度 ${bytes.length} 与 frameCount=${frameCount} 不符`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const frames: BookFrame[] = [];
|
|
68
|
+
let o = 8;
|
|
69
|
+
for (let f = 0; f < frameCount; f++) {
|
|
70
|
+
const gameType = bytes[o++]!;
|
|
71
|
+
const cellsOut: number[] = new Array(cells);
|
|
72
|
+
if (wide) {
|
|
73
|
+
for (let i = 0; i < cells; i++) cellsOut[i] = bytes[o++]!;
|
|
74
|
+
} else {
|
|
75
|
+
for (let i = 0; i < cells; i += 2) {
|
|
76
|
+
const b = bytes[o++]!;
|
|
77
|
+
cellsOut[i] = b & 0xf;
|
|
78
|
+
if (i + 1 < cells) cellsOut[i + 1] = (b >> 4) & 0xf;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
frames.push({ gameType, cells: cellsOut });
|
|
82
|
+
}
|
|
83
|
+
return { payoutCenti, frames };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const toBase64 = (b: Uint8Array): string => {
|
|
87
|
+
let s = '';
|
|
88
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
89
|
+
return btoa(s);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const fromBase64 = (s: string): Uint8Array => {
|
|
93
|
+
const bin = atob(s);
|
|
94
|
+
const out = new Uint8Array(bin.length);
|
|
95
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
96
|
+
return out;
|
|
97
|
+
};
|
package/lut/csv.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* 把 math-sdk 的 lookUpTable CSV 转成 GKLT1。迁移既有产物用。
|
|
2
|
+
CSV 三列:simId, weight, payoutCenti(第三列本来就是 ×100 的整数) */
|
|
3
|
+
import { type Gklt1Header, type LutRow, encode } from './format.ts';
|
|
4
|
+
|
|
5
|
+
export interface CsvResult { header: Gklt1Header; rows: LutRow[]; bytes: Uint8Array }
|
|
6
|
+
|
|
7
|
+
export function fromCsv(
|
|
8
|
+
text: string,
|
|
9
|
+
opts: { betCostCenti: number; declaredRtpPpm: number },
|
|
10
|
+
): CsvResult {
|
|
11
|
+
const rows: LutRow[] = [];
|
|
12
|
+
let totalWeight = 0;
|
|
13
|
+
|
|
14
|
+
for (const line of text.split('\n')) {
|
|
15
|
+
const s = line.trim();
|
|
16
|
+
if (!s) continue;
|
|
17
|
+
const parts = s.split(',');
|
|
18
|
+
if (parts.length !== 3) throw new RangeError(`CSV 行必须是 3 列:${s}`);
|
|
19
|
+
const simId = Number(parts[0]);
|
|
20
|
+
const weight = Number(parts[1]);
|
|
21
|
+
const payoutCenti = Number(parts[2]);
|
|
22
|
+
if (!Number.isSafeInteger(simId) || !Number.isSafeInteger(weight) || !Number.isSafeInteger(payoutCenti)) {
|
|
23
|
+
throw new RangeError(`CSV 行含非整数:${s}`);
|
|
24
|
+
}
|
|
25
|
+
rows.push({ simId, payoutCenti, weight });
|
|
26
|
+
totalWeight += weight;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
rows.sort((a, b) => a.simId - b.simId);
|
|
30
|
+
const header: Gklt1Header = {
|
|
31
|
+
rowCount: rows.length,
|
|
32
|
+
betCostCenti: opts.betCostCenti,
|
|
33
|
+
totalWeight,
|
|
34
|
+
declaredRtpPpm: opts.declaredRtpPpm,
|
|
35
|
+
};
|
|
36
|
+
return { header, rows, bytes: encode(header, rows) };
|
|
37
|
+
}
|