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,78 @@
|
|
|
1
|
+
/* 平台与「服务端」之间的那道缝。
|
|
2
|
+
*
|
|
3
|
+
* 本地服务端(浏览器里建表、按权重抽行)和真平台在这条缝的两侧各自成立。
|
|
4
|
+
* 缝画在这里而不是更靠上,是因为游戏那侧只看到 Host,换服务端时它一行都不用改。 */
|
|
5
|
+
import type { BetLimits, RestoreInput, RngSeed, RoundResult } from '../sdk/index.ts';
|
|
6
|
+
|
|
7
|
+
export interface WalletState {
|
|
8
|
+
/** 最小货币单位整数 */
|
|
9
|
+
balance: number;
|
|
10
|
+
currency: string;
|
|
11
|
+
decimals: number;
|
|
12
|
+
jackpot: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 一条校验结论,给公平性面板显示。失败的那条要能直接告诉玩家哪一步对不上 */
|
|
16
|
+
export interface Check {
|
|
17
|
+
label: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Delivery<C, O> {
|
|
23
|
+
/** 已经还原好的结果。宿主把 restore 交给了服务端适配层时走这条 */
|
|
24
|
+
result?: RoundResult<O>;
|
|
25
|
+
/**
|
|
26
|
+
* 服务端抽中的那一行,原样。
|
|
27
|
+
*
|
|
28
|
+
* restore 是游戏代码,把游戏隔离进 iframe 之后它不在这一侧——
|
|
29
|
+
* 于是这条缝也得能只递「那一行」,由拿得到游戏的那一侧去还原。
|
|
30
|
+
* 「还原出的赔付 = 平台记账」这一条校验也跟着挪过去,它得先有结果才验得了。
|
|
31
|
+
*/
|
|
32
|
+
row?: RestoreInput<C>;
|
|
33
|
+
wallet: WalletState;
|
|
34
|
+
checks: Check[];
|
|
35
|
+
/** 这一局的公平证,可以贴到验证页 */
|
|
36
|
+
proof?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PlaceResult<C, O> =
|
|
40
|
+
| { ok: true; round: Delivery<C, O> }
|
|
41
|
+
| { ok: false; error: string };
|
|
42
|
+
|
|
43
|
+
export interface PlaceInput<C> {
|
|
44
|
+
config: C;
|
|
45
|
+
/** 最小货币单位整数 */
|
|
46
|
+
bet: number;
|
|
47
|
+
clientSeed: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 定点复现:给 seed 复现那一局,给 simId 直接演表里第 N 行。只有本地服务端支持 */
|
|
51
|
+
export interface PlayInput<C> extends PlaceInput<C> {
|
|
52
|
+
seed?: RngSeed;
|
|
53
|
+
simId?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PlatformServer<C, O> {
|
|
57
|
+
readonly kind: 'local' | 'remote';
|
|
58
|
+
init(): Promise<WalletState>;
|
|
59
|
+
place(input: PlaceInput<C>): Promise<PlaceResult<C, O>>;
|
|
60
|
+
/**
|
|
61
|
+
* 服务端强制的注额区间。null = 用默认区间。
|
|
62
|
+
*
|
|
63
|
+
* 真平台一定要覆盖:平台的 bet_cents % 100 === 0 是记账前提,违反了 RTP 会变成注额的函数。
|
|
64
|
+
*/
|
|
65
|
+
limits(): BetLimits | null;
|
|
66
|
+
/** 开发期的充值。返回新钱包,没有就是不支持(真平台不支持) */
|
|
67
|
+
refill?(): WalletState | void;
|
|
68
|
+
/** 本地服务端才有:定点复现 */
|
|
69
|
+
play?(input: PlayInput<C>): Promise<PlaceResult<C, O>>;
|
|
70
|
+
/** 本地服务端才有:重新拉表(改了规则、建了新表之后) */
|
|
71
|
+
reload?(): Promise<{ modes: string[] }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 两个本地服务端共用一个钱包时用它,切换不清零 */
|
|
75
|
+
export interface WalletRef { balance: number }
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_BALANCE = 100_000;
|
|
78
|
+
export const DEFAULT_LIMITS: BetLimits = { min: 100, max: 1_000_000, step: 100 };
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/* 参考平台。泛型:驱动任何符合清单的游戏。
|
|
2
|
+
|
|
3
|
+
它做的事就是真平台要做的事:持有配置和钱包、渲染设置 UI、生成种子、
|
|
4
|
+
调服务端、对账、驱动自动模式循环、记历史。游戏那边完全不知道它的存在——只看到一个 Host。
|
|
5
|
+
|
|
6
|
+
状态用 runes 写(这是个 Svelte 宿主),交给游戏的是框架中立的 Readable。
|
|
7
|
+
换成 React 宿主时这一层换成 useSyncExternalStore,游戏侧零改动。 */
|
|
8
|
+
import {
|
|
9
|
+
defaultsOf, writable,
|
|
10
|
+
type GameDefinition, type GameStatus, type Host, type Readable, type RoundResult, type ViewSettings, type WalletView,
|
|
11
|
+
} from '../sdk/index.ts';
|
|
12
|
+
import { localServer } from './local.ts';
|
|
13
|
+
import { DEFAULT_LIMITS, type Check, type PlatformServer, type PlayInput } from './platform-server.ts';
|
|
14
|
+
|
|
15
|
+
export type Mode = 'manual' | 'auto';
|
|
16
|
+
|
|
17
|
+
export interface GameApi<O> {
|
|
18
|
+
present(res: RoundResult<O>, rate?: number): Promise<void>;
|
|
19
|
+
status(): GameStatus;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HistoryEntry {
|
|
23
|
+
n: number;
|
|
24
|
+
at: number;
|
|
25
|
+
mode: string;
|
|
26
|
+
bet: number;
|
|
27
|
+
payout: number;
|
|
28
|
+
multiplier: number;
|
|
29
|
+
checks: Check[];
|
|
30
|
+
proof: unknown;
|
|
31
|
+
seed: unknown;
|
|
32
|
+
simId: number | null;
|
|
33
|
+
config: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlayOptions {
|
|
37
|
+
seed?: PlayInput<unknown>['seed'];
|
|
38
|
+
simId?: number;
|
|
39
|
+
/** false = 直接看结果(present 用 Infinity 倍速) */
|
|
40
|
+
animate?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 参考平台。服务端是注入的:
|
|
45
|
+
* 不传 → 本地服务端,浏览器里就地建表、按权重抽行、restore 还原
|
|
46
|
+
* 传 remote → 真平台
|
|
47
|
+
* 游戏那侧看到的 Host 完全一样,一行都不用改。
|
|
48
|
+
*/
|
|
49
|
+
export function createPlatform<C, O>(
|
|
50
|
+
spec: GameDefinition<C, O>, server: PlatformServer<C, O> = localServer({ spec }),
|
|
51
|
+
) {
|
|
52
|
+
let api: GameApi<O> | null = null;
|
|
53
|
+
const defaults = defaultsOf(spec.schema);
|
|
54
|
+
|
|
55
|
+
/* ---- 交给游戏的 store。游戏只读 ---- */
|
|
56
|
+
const cfgStore = writable<{ v: C }>({ v: defaults });
|
|
57
|
+
const viewStore = writable<ViewSettings>({
|
|
58
|
+
sound: true, volume: 1, turbo: false,
|
|
59
|
+
reducedMotion: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
60
|
+
skin: 'classic', locale: 'zh-CN', autoActive: false,
|
|
61
|
+
});
|
|
62
|
+
const walletStore = writable<WalletView>({ balance: 0, currency: 'CNY', decimals: 2 });
|
|
63
|
+
const jackpotStore = writable<{ v: number | null }>({ v: null });
|
|
64
|
+
|
|
65
|
+
class Platform {
|
|
66
|
+
config = $state<C>(defaults);
|
|
67
|
+
view = $state<ViewSettings>(viewStore.get());
|
|
68
|
+
balance = $state(0);
|
|
69
|
+
startBalance = $state(0);
|
|
70
|
+
currency = $state('CNY');
|
|
71
|
+
decimals = $state(2);
|
|
72
|
+
/** 服务端强制的注额区间。远程平台一定会给,本地是 null → 默认区间 */
|
|
73
|
+
serverLimits = $state(server.limits());
|
|
74
|
+
/** 上一局的校验结论,公平性面板用 */
|
|
75
|
+
checks = $state<Check[]>([]);
|
|
76
|
+
/** 上一局的公平证 */
|
|
77
|
+
proof = $state<unknown>(null);
|
|
78
|
+
history = $state<HistoryEntry[]>([]);
|
|
79
|
+
ready0 = $state(server.kind === 'local');
|
|
80
|
+
/**
|
|
81
|
+
* 注已经发出去了,但还没开始演。
|
|
82
|
+
*
|
|
83
|
+
* 本地服务端下这只是一个微任务,真平台下是一次网络往返——玩家完全来得及在这期间
|
|
84
|
+
* 改参数,而这一局的 config 早已冻结发给了服务端。所以否决权必须从下注那一刻就生效,
|
|
85
|
+
* 不能等 game.busy(那是演出开始才置上的)。
|
|
86
|
+
*/
|
|
87
|
+
placing = $state(false);
|
|
88
|
+
/** 注额,最小货币单位 */
|
|
89
|
+
bet = $state(1_000);
|
|
90
|
+
mode = $state<Mode>('manual');
|
|
91
|
+
autoRounds = $state(10);
|
|
92
|
+
autoLeft = $state(0);
|
|
93
|
+
autoActive = $state(false);
|
|
94
|
+
err = $state<string | null>(null);
|
|
95
|
+
clientSeed = $state('player-local');
|
|
96
|
+
/** 游戏交回来的状态 */
|
|
97
|
+
game = $state<GameStatus>({ phase: 'loading', busy: false, canBet: false, canSkip: false, canEditConfig: false, progress: 0 });
|
|
98
|
+
/** 本地服务端已加载的表(mode 名) */
|
|
99
|
+
tableModes = $state<string[]>([]);
|
|
100
|
+
|
|
101
|
+
readonly spec = spec;
|
|
102
|
+
readonly server = server;
|
|
103
|
+
readonly remote = server.kind === 'remote';
|
|
104
|
+
#n = 0;
|
|
105
|
+
|
|
106
|
+
issues = $derived(spec.validate(this.config));
|
|
107
|
+
ready = $derived(this.issues.length === 0);
|
|
108
|
+
paytable = $derived(spec.paytableFor(this.config));
|
|
109
|
+
tableMode = $derived.by(() => { try { return spec.modeOf(this.config); } catch { return null; } });
|
|
110
|
+
/* 服务端说了算:平台的 bet_cents % 100 === 0 是记账前提——违反了 RTP 就变成注额的函数 */
|
|
111
|
+
limits = $derived(this.serverLimits ?? DEFAULT_LIMITS);
|
|
112
|
+
canBet = $derived(this.ready0 && this.ready && !this.autoActive && !this.placing
|
|
113
|
+
&& !this.game.busy && this.game.phase !== 'loading');
|
|
114
|
+
pnl = $derived(this.balance - this.startBalance);
|
|
115
|
+
|
|
116
|
+
/** 先握手:余额、种子承诺、注额区间都在服务端那边 */
|
|
117
|
+
async init(): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
const w = await server.init();
|
|
120
|
+
this.#apply(w.balance, w.currency, w.decimals);
|
|
121
|
+
this.startBalance = w.balance;
|
|
122
|
+
this.serverLimits = server.limits();
|
|
123
|
+
this.setBet(this.bet);
|
|
124
|
+
this.ready0 = true;
|
|
125
|
+
await this.reloadTables();
|
|
126
|
+
} catch (e) {
|
|
127
|
+
this.err = `连接平台失败:${e instanceof Error ? e.message : String(e)}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async reloadTables(): Promise<void> {
|
|
132
|
+
if (!server.reload) return;
|
|
133
|
+
try { this.tableModes = (await server.reload()).modes; }
|
|
134
|
+
catch (e) { this.err = `加载表失败:${e instanceof Error ? e.message : String(e)}`; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
bindGame(a: GameApi<O>): void { api = a; }
|
|
138
|
+
setGameStatus(s: GameStatus): void { this.game = s; }
|
|
139
|
+
|
|
140
|
+
host(): Host<C> {
|
|
141
|
+
return {
|
|
142
|
+
config: { get: () => cfgStore.get().v, subscribe: (run) => cfgStore.subscribe((s) => run(s.v)) } as Readable<C>,
|
|
143
|
+
view: viewStore,
|
|
144
|
+
wallet: walletStore,
|
|
145
|
+
jackpot: { get: () => jackpotStore.get().v, subscribe: (run) => jackpotStore.subscribe((s) => run(s.v)) } as Readable<number | null>,
|
|
146
|
+
audio: { context: () => audioCtx(), volume: () => this.view.volume },
|
|
147
|
+
locale: 'zh-CN',
|
|
148
|
+
requestConfig: (p) => { this.requestConfig(p); },
|
|
149
|
+
emit: (e) => { if (e.type === 'error') this.err = e.message; },
|
|
150
|
+
log: (lvl, msg, data) => console[lvl === 'debug' ? 'log' : lvl](`[${spec.slug}] ${msg}`, data ?? ''),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ---- 配置:平台是真值源,游戏来的是申请 ---- */
|
|
155
|
+
/** 平台的否决权:局中、自动中、注已发出,一律不批。返回是否批准 */
|
|
156
|
+
requestConfig(patch: Partial<C>): boolean {
|
|
157
|
+
if (this.autoActive || this.game.busy || this.placing) return false;
|
|
158
|
+
this.config = spec.normalize(patch, this.config);
|
|
159
|
+
cfgStore.set({ v: this.config });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setView(patch: Partial<ViewSettings>): void {
|
|
164
|
+
this.view = { ...this.view, ...patch };
|
|
165
|
+
viewStore.set(this.view);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setBet(minor: number): boolean {
|
|
169
|
+
if (this.autoActive || this.game.busy || this.placing) return false;
|
|
170
|
+
const { min, max, step } = this.limits;
|
|
171
|
+
const raw = Math.min(max, Math.max(min, Math.round(minor) || min));
|
|
172
|
+
// 对齐到步长:真平台按 (bet/100) × payoutCenti 赔付,不整除就得四舍五入,
|
|
173
|
+
// 声明的 RTP 会在小注上不成立
|
|
174
|
+
this.bet = step > 1 ? Math.max(min, Math.floor(raw / step) * step) : raw;
|
|
175
|
+
this.err = null;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
refill(): void {
|
|
180
|
+
const w = server.refill?.();
|
|
181
|
+
if (w) { this.#apply(w.balance, w.currency, w.decimals); this.startBalance = w.balance; this.history = []; }
|
|
182
|
+
this.err = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#apply(balance: number, currency = this.currency, decimals = this.decimals): void {
|
|
186
|
+
this.balance = balance; this.currency = currency; this.decimals = decimals;
|
|
187
|
+
walletStore.set({ balance, currency, decimals });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 一局的完整链路:服务端算钱 → 校验 → 游戏演出 → 记历史。
|
|
192
|
+
*
|
|
193
|
+
* 校验的语义由服务端实现决定:本地服务端验承诺链、抽样区间、还原一致,真平台一样。
|
|
194
|
+
* 失败处理是同一条——没通过就不演。
|
|
195
|
+
*/
|
|
196
|
+
async playOne(o: PlayOptions = {}): Promise<HistoryEntry | null> {
|
|
197
|
+
if (!api || this.game.busy || this.placing || !this.ready) return null;
|
|
198
|
+
|
|
199
|
+
this.placing = true;
|
|
200
|
+
let r;
|
|
201
|
+
try {
|
|
202
|
+
const input = { config: $state.snapshot(this.config) as C, bet: this.bet, clientSeed: this.clientSeed };
|
|
203
|
+
r = (o.seed || o.simId !== undefined) && server.play
|
|
204
|
+
? await server.play({ ...input, seed: o.seed, simId: o.simId })
|
|
205
|
+
: await server.place(input);
|
|
206
|
+
} finally {
|
|
207
|
+
this.placing = false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!r.ok) {
|
|
211
|
+
this.err = r.error;
|
|
212
|
+
this.checks = [{ label: '本局未通过校验', ok: false, detail: r.error }];
|
|
213
|
+
console.error(`[${spec.slug}] 本局未通过校验`, r.error);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
this.err = null;
|
|
217
|
+
this.#apply(r.round.wallet.balance); // 余额以服务端返回值为准,不本地推算
|
|
218
|
+
this.checks = r.round.checks;
|
|
219
|
+
this.proof = r.round.proof ?? null;
|
|
220
|
+
|
|
221
|
+
/* 这个平台在本进程里持有游戏,所以服务端适配层一定拿得到 restore。
|
|
222
|
+
只递「那一行」的那条路是给 iframe 宿主走的,不该到这里 */
|
|
223
|
+
if (!r.round.result) {
|
|
224
|
+
this.err = '服务端只给了抽中的那一行,这个平台不会还原它';
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const res = r.round.result;
|
|
228
|
+
const proof = r.round.proof as { simId?: number } | null;
|
|
229
|
+
const entry: HistoryEntry = {
|
|
230
|
+
n: ++this.#n, at: Date.now(), mode: r.round.row?.mode ?? this.tableMode ?? 'base',
|
|
231
|
+
bet: res.bet, payout: res.payout, multiplier: res.multiplier, checks: r.round.checks, proof: r.round.proof,
|
|
232
|
+
seed: r.round.row?.seed ?? null, simId: proof?.simId ?? null, config: $state.snapshot(this.config),
|
|
233
|
+
};
|
|
234
|
+
this.history = [entry, ...this.history].slice(0, 50);
|
|
235
|
+
await api.present(res, o.animate === false ? Infinity : undefined);
|
|
236
|
+
return entry;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async startAuto(): Promise<void> {
|
|
240
|
+
if (this.autoActive) return;
|
|
241
|
+
this.autoActive = true;
|
|
242
|
+
this.setView({ autoActive: true });
|
|
243
|
+
let left = this.autoRounds > 0 ? this.autoRounds : Infinity;
|
|
244
|
+
this.autoLeft = isFinite(left) ? left : 0;
|
|
245
|
+
|
|
246
|
+
while (this.autoActive && left > 0) {
|
|
247
|
+
/* 余额判断在平台——只有它知道钱够不够,这也是自动循环必须归平台的原因 */
|
|
248
|
+
if (this.bet > this.balance) { this.err = '余额不足,自动模式已停止'; break; }
|
|
249
|
+
if (!await this.playOne()) break;
|
|
250
|
+
left--;
|
|
251
|
+
this.autoLeft = isFinite(left) ? left : 0;
|
|
252
|
+
await sleep(this.view.turbo ? 220 : 600);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.autoActive = false;
|
|
256
|
+
this.setView({ autoActive: false });
|
|
257
|
+
this.autoLeft = this.autoRounds;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
stopAuto(): void { this.autoActive = false; }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return new Platform();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type PlatformOf<C, O> = ReturnType<typeof createPlatform<C, O>>;
|
|
267
|
+
|
|
268
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
269
|
+
|
|
270
|
+
let ac: AudioContext | null = null;
|
|
271
|
+
const audioCtx = (): AudioContext | null => {
|
|
272
|
+
try {
|
|
273
|
+
ac = ac ?? new AudioContext();
|
|
274
|
+
if (ac.state === 'suspended') void ac.resume();
|
|
275
|
+
return ac;
|
|
276
|
+
} catch { return null; }
|
|
277
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/* 真平台。
|
|
2
|
+
*
|
|
3
|
+
* 和假服务端最大的不同不是「换了个 URL」,而是**对账的语义变了**:
|
|
4
|
+
* 表模型下客户端无法重算 payout——payout 来自那张公开的权重表,不来自 round()。
|
|
5
|
+
* 能验的是抽样过程没作弊:
|
|
6
|
+
*
|
|
7
|
+
* 1. 揭示的种子哈希得上一局就公布过的承诺(反向哈希链,服务端无法事后挑种子)
|
|
8
|
+
* 2. 靶点落在中奖行的累积权重区间内
|
|
9
|
+
* 3. 赔付等于 注额/100 × 表里那一行的 payoutCenti
|
|
10
|
+
*
|
|
11
|
+
* 这比「重算一遍」更强——它验的是服务端没有在抽样这一步动手脚,
|
|
12
|
+
* 而不只是「两边跑同一份代码得到同一个数」。 */
|
|
13
|
+
import { GameKitError, type BetResult, winCents } from '../protocol/index.ts';
|
|
14
|
+
import type { GameKitClient } from '../client/index.ts';
|
|
15
|
+
import { sha256, toHex, utf8, type BetLimits, type Restore, type RestoreInput, type RngSeed } from '../sdk/index.ts';
|
|
16
|
+
import type { Check, PlaceInput, PlaceResult, PlatformServer, WalletState } from './platform-server.ts';
|
|
17
|
+
|
|
18
|
+
export interface RemoteOptions<C, O> {
|
|
19
|
+
client: GameKitClient;
|
|
20
|
+
gameId: string;
|
|
21
|
+
/**
|
|
22
|
+
* 抽哪张表。影响赔付分布的 config 都冻结在这里。
|
|
23
|
+
*
|
|
24
|
+
* 传函数的话每注现算——玩家改了 N,下一注就该落到另一张表上。
|
|
25
|
+
* 通常直接传游戏的 modeOf。
|
|
26
|
+
*/
|
|
27
|
+
mode?: string | ((config: C) => string);
|
|
28
|
+
/**
|
|
29
|
+
* 把服务端抽中的一行还原成可演出的结果。
|
|
30
|
+
*
|
|
31
|
+
* 不传就把那一行原样交出去(Delivery.row),由宿主自己还原——
|
|
32
|
+
* 游戏跑在 iframe 里时 restore 在被隔离的那一侧,这一侧碰不到它。
|
|
33
|
+
*/
|
|
34
|
+
restore?: Restore<C, O>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const bytesOf = (b64: string | null): Uint8Array | null =>
|
|
38
|
+
b64 === null ? null : Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
|
|
39
|
+
|
|
40
|
+
export function createRemotePlatform<C, O>(o: RemoteOptions<C, O>): PlatformServer<C, O> {
|
|
41
|
+
const modeFor = (config: C): string =>
|
|
42
|
+
typeof o.mode === 'function' ? o.mode(config) : o.mode ?? 'base';
|
|
43
|
+
let wallet: WalletState = { balance: 0, currency: 'USD', decimals: 2, jackpot: null };
|
|
44
|
+
let limits: BetLimits | null = null;
|
|
45
|
+
/** 下一局将要用的种子的承诺。每局用完更新成刚揭示的那个种子 */
|
|
46
|
+
let commitment = '';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 下注,承诺过期时刷新一次再来。
|
|
50
|
+
*
|
|
51
|
+
* 过期的典型原因是玩家在另一个标签页下了注,本地这份种子状态落后了。
|
|
52
|
+
* 这不能靠传输层重试解决——重试一个过期的承诺只会把同一个错误再犯三遍,
|
|
53
|
+
* 所以 STALE_COMMITMENT 被刻意标成不可重试,由这里重新拉状态。
|
|
54
|
+
* 被拒的那一注什么都没发生,重下是安全的。
|
|
55
|
+
*/
|
|
56
|
+
const placeOnce = async (input: PlaceInput<C>, retry: boolean): Promise<BetResult> => {
|
|
57
|
+
try {
|
|
58
|
+
return await o.client.bets.place({
|
|
59
|
+
gameId: o.gameId,
|
|
60
|
+
betCents: input.bet,
|
|
61
|
+
mode: modeFor(input.config),
|
|
62
|
+
// 存进历史局:回放要用这一刻的 config 解码,牌面才和玩家当时看到的一致
|
|
63
|
+
config: input.config as Record<string, unknown>,
|
|
64
|
+
// 带上自己以为在用的承诺。对不上说明本地拿的是过期状态,服务端会直接拒
|
|
65
|
+
...(commitment ? { expectedServerSeedHash: commitment } : {}),
|
|
66
|
+
});
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (retry && e instanceof GameKitError && e.code === 'STALE_COMMITMENT') {
|
|
69
|
+
commitment = (await o.client.seeds.current()).serverSeedHash;
|
|
70
|
+
return placeOnce(input, false);
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const place = async (input: PlaceInput<C>): Promise<PlaceResult<C, O>> => {
|
|
77
|
+
let bet: BetResult;
|
|
78
|
+
try {
|
|
79
|
+
bet = await placeOnce(input, true);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { ok: false, error: e instanceof GameKitError ? e.message : String(e) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const checks: Check[] = [];
|
|
85
|
+
const fail = (error: string): PlaceResult<C, O> => ({ ok: false, error });
|
|
86
|
+
|
|
87
|
+
// 1. 承诺链。这一条是整套可证明公平的支点
|
|
88
|
+
const revealed = toHex(sha256(utf8(bet.proof.serverSeed)));
|
|
89
|
+
const expected = commitment || bet.proof.serverSeedHash;
|
|
90
|
+
if (revealed !== expected) {
|
|
91
|
+
return fail(`公平性校验失败:揭示的种子哈希 ${revealed.slice(0, 16)}… 对不上承诺 ${expected.slice(0, 16)}…`);
|
|
92
|
+
}
|
|
93
|
+
checks.push({
|
|
94
|
+
label: '揭示的种子哈希得上一局公布的承诺',
|
|
95
|
+
ok: true,
|
|
96
|
+
detail: `sha256(种子) = ${revealed.slice(0, 16)}…`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 2. 靶点落在中奖行的区间内。不下载整表也能验,表只在争议时才需要下载
|
|
100
|
+
const target = BigInt(bet.proof.drawTarget);
|
|
101
|
+
const inRange = BigInt(bet.proof.cumulativeBefore) <= target
|
|
102
|
+
&& target < BigInt(bet.proof.cumulativeAfter);
|
|
103
|
+
if (!inRange) {
|
|
104
|
+
return fail(`公平性校验失败:靶点 ${bet.proof.drawTarget} 不在中奖行 [${bet.proof.cumulativeBefore}, ${bet.proof.cumulativeAfter}) 内`);
|
|
105
|
+
}
|
|
106
|
+
checks.push({
|
|
107
|
+
label: '靶点落在中奖行的累积权重区间内',
|
|
108
|
+
ok: true,
|
|
109
|
+
detail: `${bet.proof.cumulativeBefore} ≤ ${bet.proof.drawTarget} < ${bet.proof.cumulativeAfter}`,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 3. 记账自洽
|
|
113
|
+
const want = winCents(bet.betCents, bet.outcome.payoutCenti);
|
|
114
|
+
if (bet.payoutCents !== want) {
|
|
115
|
+
return fail(`记账不自洽:${bet.betCents}/100 × ${bet.outcome.payoutCenti} 应为 ${want},服务端给了 ${bet.payoutCents}`);
|
|
116
|
+
}
|
|
117
|
+
checks.push({ label: '赔付 = 注额/100 × 表里那一行的倍数', ok: true, detail: `${want} 分` });
|
|
118
|
+
|
|
119
|
+
const seed: RngSeed = {
|
|
120
|
+
server: bet.proof.serverSeed,
|
|
121
|
+
client: bet.proof.clientSeed,
|
|
122
|
+
nonce: bet.proof.nonce,
|
|
123
|
+
};
|
|
124
|
+
const row: RestoreInput<C> = {
|
|
125
|
+
config: input.config,
|
|
126
|
+
bet: bet.betCents,
|
|
127
|
+
payoutCenti: bet.outcome.payoutCenti,
|
|
128
|
+
frames: bytesOf(bet.outcome.frames),
|
|
129
|
+
mode: modeFor(input.config),
|
|
130
|
+
seed,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/* 钱已经扣了、种子已经揭示了,平台侧的状态必须先推进——
|
|
134
|
+
后面的还原无论成败,这一注在服务端都已经发生过了 */
|
|
135
|
+
commitment = bet.proof.serverSeed; // 揭示的种子就是下一局的承诺
|
|
136
|
+
wallet = { ...wallet, balance: bet.balanceAfterCents };
|
|
137
|
+
|
|
138
|
+
if (!o.restore) return { ok: true, round: { row, wallet, checks, proof: bet.proof } };
|
|
139
|
+
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = o.restore(row);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return fail(`演出数据还原失败:${e instanceof Error ? e.message : String(e)}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* 还原出来的 payout 必须等于平台记的账。不等就是客户端要演的东西
|
|
148
|
+
和玩家实际拿到的钱不一样——这类 bug 完全静默,最伤信任 */
|
|
149
|
+
if (result.payout !== bet.payoutCents) {
|
|
150
|
+
return fail(`还原出的赔付 ${result.payout} 与平台记账的 ${bet.payoutCents} 不符,本局不演出`);
|
|
151
|
+
}
|
|
152
|
+
checks.push({ label: '还原出的演出与平台记账一致', ok: true, detail: `${result.payout} 分` });
|
|
153
|
+
|
|
154
|
+
return { ok: true, round: { result, row, wallet, checks, proof: bet.proof } };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
kind: 'remote',
|
|
159
|
+
|
|
160
|
+
async init(): Promise<WalletState> {
|
|
161
|
+
const session = await o.client.session.bootstrap();
|
|
162
|
+
commitment = session.seeds.serverSeedHash;
|
|
163
|
+
wallet = {
|
|
164
|
+
balance: session.wallet.balanceCents,
|
|
165
|
+
currency: session.wallet.currency,
|
|
166
|
+
decimals: session.wallet.decimals,
|
|
167
|
+
jackpot: null, // 平台没有奖池,也不打算加
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const game = await o.client.games.get(o.gameId);
|
|
171
|
+
limits = { min: game.minBetCents, max: game.maxBetCents, step: game.betStepCents };
|
|
172
|
+
return wallet;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
limits: () => limits,
|
|
176
|
+
place,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/dev-host/run.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* 启动参考平台。
|
|
2
|
+
|
|
3
|
+
为什么要包一层而不是让每个游戏自己 mount(DevShell, {props}):
|
|
4
|
+
Svelte 的命令式 mount() 不做泛型推断,会把 C 擦成约束上界
|
|
5
|
+
(Record<string, unknown>),于是每个游戏的 dev.ts 都得写一次强转。
|
|
6
|
+
包在这里,强转只出现一次,游戏侧的调用是类型安全的。 */
|
|
7
|
+
import { mount } from 'svelte';
|
|
8
|
+
import type { GameModule } from '../sdk/index.ts';
|
|
9
|
+
import type { PlatformServer } from './platform-server.ts';
|
|
10
|
+
import type { HistoryEntry, PlatformOf } from './platform.svelte.ts';
|
|
11
|
+
import DevShell from './DevShell.svelte';
|
|
12
|
+
import { ensureHostFonts } from './fonts.ts';
|
|
13
|
+
import './theme.css';
|
|
14
|
+
|
|
15
|
+
export interface RunOptions<C = object, O = unknown> {
|
|
16
|
+
target?: HTMLElement;
|
|
17
|
+
title?: string;
|
|
18
|
+
note?: string;
|
|
19
|
+
/**
|
|
20
|
+
* 嵌在别的页面里(studio 的预览 iframe):不画自己的页眉和标题,余额由外面那个页面的
|
|
21
|
+
* Header 显示;机台顶到容器边上,由外面决定它多宽
|
|
22
|
+
*/
|
|
23
|
+
embedded?: boolean;
|
|
24
|
+
/** 不传就是本地表模型。传 createRemotePlatform(...) 就接到真平台上 */
|
|
25
|
+
server?: PlatformServer<C, O>;
|
|
26
|
+
onSaveCase?: (h: HistoryEntry, title: string) => Promise<string>;
|
|
27
|
+
onPlatform?: (p: PlatformOf<C, O>) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runDevShell<C extends object, O>(
|
|
31
|
+
game: GameModule<C, O>, o: RunOptions<C, O> = {}
|
|
32
|
+
): void {
|
|
33
|
+
ensureHostFonts();
|
|
34
|
+
mount(DevShell as unknown as typeof DevShell<object, unknown>, {
|
|
35
|
+
target: o.target ?? document.body,
|
|
36
|
+
props: {
|
|
37
|
+
game: game as unknown as GameModule<object, unknown>,
|
|
38
|
+
title: o.title,
|
|
39
|
+
note: o.note,
|
|
40
|
+
embedded: o.embedded,
|
|
41
|
+
server: o.server as unknown as PlatformServer<object, unknown> | undefined,
|
|
42
|
+
onSaveCase: o.onSaveCase,
|
|
43
|
+
onPlatform: o.onPlatform as unknown as ((p: PlatformOf<object, unknown>) => void) | undefined,
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|