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,122 @@
|
|
|
1
|
+
/* 资源并发预加载。
|
|
2
|
+
|
|
3
|
+
三个不太显然但很要紧的决定:
|
|
4
|
+
|
|
5
|
+
1. 用固定并发池,不用裸 Promise.all。五十张图同时 decode 会把主线程压死,
|
|
6
|
+
进度条反而不动——看起来更慢。
|
|
7
|
+
2. 进度按**字节**加权而不是按个数。按个数会出现「99% 卡住等那张 2MB 的图」。
|
|
8
|
+
3. 所有图片都设 crossOrigin='anonymous'。游戏文件夹可能放在 CDN 上,
|
|
9
|
+
而特效层迟早会把图画进 canvas;没有这一条,那时候画布会被污染,
|
|
10
|
+
而且报错离原因很远。 */
|
|
11
|
+
import type { AssetEntry, GameManifest, LoadProgress, LoadedAssets } from './types.ts';
|
|
12
|
+
|
|
13
|
+
export interface LoadOptions {
|
|
14
|
+
manifest: GameManifest;
|
|
15
|
+
/** registry 给出的 key → 运行时 URL(已经过 import.meta.url 解析) */
|
|
16
|
+
urls: Record<string, string>;
|
|
17
|
+
onProgress?(p: LoadProgress): void;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
audioContext?: AudioContext;
|
|
20
|
+
/** 并发上限。HTTP/2 下网络不是瓶颈,解码才是,6~8 合适 */
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
/** 只加载这个优先级,默认 critical */
|
|
23
|
+
priority?: 'critical' | 'lazy';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
|
|
27
|
+
const { manifest, urls, onProgress, signal, concurrency = 6, priority = 'critical' } = opts;
|
|
28
|
+
const entries = manifest.assets.filter(a => a.priority === priority);
|
|
29
|
+
|
|
30
|
+
const images = new Map<string, HTMLImageElement>();
|
|
31
|
+
const audios = new Map<string, AudioBuffer>();
|
|
32
|
+
const failed: string[] = [];
|
|
33
|
+
|
|
34
|
+
const total = entries.reduce((s, a) => s + a.bytes, 0) || 1;
|
|
35
|
+
let loaded = 0, done = 0;
|
|
36
|
+
|
|
37
|
+
const resolve = (a: AssetEntry): string => urls[a.key] ?? new URL(a.path, location.href).href;
|
|
38
|
+
|
|
39
|
+
const bump = (a: AssetEntry): void => {
|
|
40
|
+
loaded += a.bytes; done++;
|
|
41
|
+
onProgress?.({
|
|
42
|
+
ratio: Math.min(1, loaded / total), loaded, total,
|
|
43
|
+
done, count: entries.length, current: a.key
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
async function one(a: AssetEntry): Promise<void> {
|
|
48
|
+
const url = resolve(a);
|
|
49
|
+
try {
|
|
50
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
|
51
|
+
|
|
52
|
+
if (a.type === 'image') {
|
|
53
|
+
const img = new Image();
|
|
54
|
+
img.crossOrigin = 'anonymous';
|
|
55
|
+
img.decoding = 'async';
|
|
56
|
+
img.src = url;
|
|
57
|
+
/* decode() 把解码也提前做掉,首帧不会因为解码抖一下。
|
|
58
|
+
部分 Safari 版本的 decode() 有坑,回落到 onload */
|
|
59
|
+
await img.decode().catch(() => new Promise<void>((res, rej) => {
|
|
60
|
+
if (img.complete && img.naturalWidth) return res();
|
|
61
|
+
img.onload = () => res();
|
|
62
|
+
img.onerror = () => rej(new Error('image load failed'));
|
|
63
|
+
}));
|
|
64
|
+
images.set(a.key, img);
|
|
65
|
+
|
|
66
|
+
} else if (a.type === 'audio') {
|
|
67
|
+
const buf = await fetch(url, { signal, mode: 'cors' }).then(r => {
|
|
68
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
69
|
+
return r.arrayBuffer();
|
|
70
|
+
});
|
|
71
|
+
if (opts.audioContext) audios.set(a.key, await opts.audioContext.decodeAudioData(buf));
|
|
72
|
+
|
|
73
|
+
} else {
|
|
74
|
+
// font / json / binary:进了 HTTP 缓存,之后同步用就不会闪
|
|
75
|
+
const r = await fetch(url, { signal, mode: 'cors' });
|
|
76
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
77
|
+
await r.arrayBuffer();
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if ((e as Error).name === 'AbortError') throw e;
|
|
81
|
+
// 单个资源失败不炸整局:记下来让游戏自己降级
|
|
82
|
+
console.warn(`[assets] ${a.key} 加载失败,降级处理`, e);
|
|
83
|
+
failed.push(a.key);
|
|
84
|
+
} finally {
|
|
85
|
+
bump(a);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let cursor = 0;
|
|
90
|
+
await Promise.all(
|
|
91
|
+
Array.from({ length: Math.min(concurrency, entries.length) }, async () => {
|
|
92
|
+
while (cursor < entries.length) {
|
|
93
|
+
if (signal?.aborted) return;
|
|
94
|
+
await one(entries[cursor++]);
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
image: k => images.get(k) ?? null,
|
|
101
|
+
audio: k => audios.get(k) ?? null,
|
|
102
|
+
url: k => urls[k] ?? k,
|
|
103
|
+
failed
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 后台预取 lazy 资源。不阻塞、不报错、不影响开场 */
|
|
108
|
+
export function prefetchLazy(manifest: GameManifest, urls: Record<string, string>): void {
|
|
109
|
+
const run = (): void => {
|
|
110
|
+
for (const a of manifest.assets) {
|
|
111
|
+
if (a.priority !== 'lazy') continue;
|
|
112
|
+
const l = document.createElement('link');
|
|
113
|
+
l.rel = 'prefetch';
|
|
114
|
+
l.href = urls[a.key] ?? a.path;
|
|
115
|
+
l.as = a.type === 'image' ? 'image' : a.type === 'audio' ? 'audio' : 'fetch';
|
|
116
|
+
l.crossOrigin = 'anonymous';
|
|
117
|
+
document.head.appendChild(l);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
if ('requestIdleCallback' in window) requestIdleCallback(run);
|
|
121
|
+
else setTimeout(run, 1500);
|
|
122
|
+
}
|
package/runtime/fonts.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* 字体注入。
|
|
2
|
+
|
|
3
|
+
必须挂到主文档的 head,不能挂进游戏容器:@font-face 在 shadow root 里
|
|
4
|
+
会被规范性忽略,在普通容器里虽然能生效但没有去重。
|
|
5
|
+
幂等——同页两个游戏用同一份字体只会注入一次。 */
|
|
6
|
+
const injected = new Set<string>();
|
|
7
|
+
|
|
8
|
+
export function ensureFonts(hrefs: readonly string[]): Promise<unknown> {
|
|
9
|
+
for (const href of hrefs) {
|
|
10
|
+
if (injected.has(href)) continue;
|
|
11
|
+
injected.add(href);
|
|
12
|
+
const l = document.createElement('link');
|
|
13
|
+
l.rel = 'stylesheet';
|
|
14
|
+
l.href = href;
|
|
15
|
+
l.crossOrigin = 'anonymous';
|
|
16
|
+
document.head.appendChild(l);
|
|
17
|
+
}
|
|
18
|
+
/* 等字体真的可用再开场,否则开场动画会被 FOUT 打断。
|
|
19
|
+
document.fonts 不存在(老 WebView)就直接放行 */
|
|
20
|
+
return document.fonts?.ready ?? Promise.resolve();
|
|
21
|
+
}
|
package/runtime/index.ts
ADDED
package/runtime/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* 与 @gamekit/vite-config 的 manifest 插件产出的 manifest.json 对应。
|
|
2
|
+
两边各自声明而不是互相 import:runtime 会被打进游戏产物,
|
|
3
|
+
不该为了一个类型把构建期的包拖进运行时依赖图。 */
|
|
4
|
+
export type AssetType = 'image' | 'audio' | 'font' | 'json' | 'video' | 'binary';
|
|
5
|
+
|
|
6
|
+
export interface AssetEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
path: string;
|
|
9
|
+
type: AssetType;
|
|
10
|
+
bytes: number;
|
|
11
|
+
priority: 'critical' | 'lazy';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GameManifest {
|
|
15
|
+
id: string;
|
|
16
|
+
version: string;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
entry: string;
|
|
19
|
+
css: string | null;
|
|
20
|
+
totalBytes: number;
|
|
21
|
+
criticalBytes: number;
|
|
22
|
+
assets: AssetEntry[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LoadProgress {
|
|
26
|
+
/** 已加载字节 / 总字节 */
|
|
27
|
+
ratio: number;
|
|
28
|
+
loaded: number;
|
|
29
|
+
total: number;
|
|
30
|
+
/** 已完成个数 / 总个数 */
|
|
31
|
+
done: number;
|
|
32
|
+
count: number;
|
|
33
|
+
current?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoadedAssets {
|
|
37
|
+
image(key: string): HTMLImageElement | null;
|
|
38
|
+
audio(key: string): AudioBuffer | null;
|
|
39
|
+
url(key: string): string;
|
|
40
|
+
/** 加载失败的 key。调用方自己决定怎么降级,不要让一张图炸掉整局 */
|
|
41
|
+
failed: string[];
|
|
42
|
+
}
|
package/sdk/contract.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* 一局的输入与输出。清单本身见 spec.ts。 */
|
|
2
|
+
import type { RngSeed } from './rng.ts';
|
|
3
|
+
|
|
4
|
+
export interface RoundInput<C> {
|
|
5
|
+
/** 完整的 config。采样时是 mode 冻结出来的那份 */
|
|
6
|
+
config: C;
|
|
7
|
+
/** 最小货币单位的整数(分)。不要传浮点 */
|
|
8
|
+
bet: number;
|
|
9
|
+
seed: RngSeed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RoundResult<O> {
|
|
13
|
+
/** 这一局的注额,最小货币单位整数。payout 脱离 bet 没有意义,所以带上 */
|
|
14
|
+
bet: number;
|
|
15
|
+
/** 玩家拿回的总额,最小货币单位整数。输 = 0。净输赢 = payout - bet */
|
|
16
|
+
payout: number;
|
|
17
|
+
multiplier: number;
|
|
18
|
+
/** 可序列化的结果快照,足以重建整条动画 */
|
|
19
|
+
outcome: O;
|
|
20
|
+
/** 默认速度下这局演多久、演完该停多久。平台排自动模式的间隔用 */
|
|
21
|
+
pacing: { durationMs: number; postDelayMs: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 表模型下,服务端返回的一行。
|
|
26
|
+
*
|
|
27
|
+
* 平台按权重抽中一行就把它原样发回来——它不执行游戏代码,所以这一行里
|
|
28
|
+
* 没有 outcome,只有赔付和游戏自己编码的那点字节。
|
|
29
|
+
*/
|
|
30
|
+
export interface RestoreInput<C> {
|
|
31
|
+
/** 下注那一刻的完整 config(回放时是那一局存下来的)。平台已保证它和 mode 一致 */
|
|
32
|
+
config: C;
|
|
33
|
+
/** 注额,最小货币单位整数 */
|
|
34
|
+
bet: number;
|
|
35
|
+
/** 相对单注的赔付 ×100。平台已据此记账,客户端不得改写 */
|
|
36
|
+
payoutCenti: number;
|
|
37
|
+
/** 演出载荷。游戏自己 encode / decode,平台一个字节都不看。整表没传时为 null */
|
|
38
|
+
frames: Uint8Array | null;
|
|
39
|
+
/** 抽中的是哪张表 */
|
|
40
|
+
mode: string;
|
|
41
|
+
/** 平台抽行用的种子。只是随行带着,'seed' codec 用的是 frames 里的采样种子,不是它 */
|
|
42
|
+
seed: RngSeed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 把服务端抽中的一行还原成可演出的结果。
|
|
47
|
+
*
|
|
48
|
+
* 契约:返回的 payout 必须恰好等于 (bet / 100) × payoutCenti。
|
|
49
|
+
* 对不上就是客户端演出与平台记账分叉,宁可抛错也不要演。
|
|
50
|
+
*/
|
|
51
|
+
export type Restore<C, O> = (input: RestoreInput<C>) => RoundResult<O>;
|
package/sdk/hash.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* 同步的 SHA-256 / HMAC-SHA256。
|
|
2
|
+
|
|
3
|
+
为什么不用 crypto.subtle:它是异步的,而 round() 必须是同步纯函数——
|
|
4
|
+
服务端、客户端、验证页、蒙特卡洛脚本都要能直接调用它拿返回值。
|
|
5
|
+
为什么不用 node:crypto:浏览器里没有。
|
|
6
|
+
所以这里是唯一的选择:一份两边都跑得动的纯 JS 实现。 */
|
|
7
|
+
|
|
8
|
+
const K = new Uint32Array([
|
|
9
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
10
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
11
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
12
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
13
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
14
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
15
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
16
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
|
|
20
|
+
|
|
21
|
+
const INIT = [
|
|
22
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/** 压缩一个 64 字节块,就地更新 H。w 是复用的调度数组 */
|
|
26
|
+
function compress(H: Uint32Array, dv: DataView, off: number, w: Uint32Array): void {
|
|
27
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
|
28
|
+
for (let i = 16; i < 64; i++) {
|
|
29
|
+
const x = w[i - 15], y = w[i - 2];
|
|
30
|
+
const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
|
|
31
|
+
const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
|
|
32
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
|
|
35
|
+
for (let i = 0; i < 64; i++) {
|
|
36
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
37
|
+
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
|
38
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
39
|
+
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
|
40
|
+
h = g; g = f; f = e; e = (d + t1) >>> 0;
|
|
41
|
+
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
42
|
+
}
|
|
43
|
+
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
|
|
44
|
+
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sha256(msg: Uint8Array): Uint8Array {
|
|
48
|
+
const H = new Uint32Array(INIT);
|
|
49
|
+
const len = msg.length, total = (((len + 9) + 63) & ~63);
|
|
50
|
+
const buf = new Uint8Array(total);
|
|
51
|
+
buf.set(msg);
|
|
52
|
+
buf[len] = 0x80;
|
|
53
|
+
const dv = new DataView(buf.buffer);
|
|
54
|
+
const bits = len * 8;
|
|
55
|
+
dv.setUint32(total - 8, Math.floor(bits / 0x100000000));
|
|
56
|
+
dv.setUint32(total - 4, bits >>> 0);
|
|
57
|
+
|
|
58
|
+
const w = new Uint32Array(64);
|
|
59
|
+
for (let off = 0; off < total; off += 64) compress(H, dv, off, w);
|
|
60
|
+
|
|
61
|
+
const out = new Uint8Array(32), odv = new DataView(out.buffer);
|
|
62
|
+
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array {
|
|
67
|
+
const BLOCK = 64;
|
|
68
|
+
const k = new Uint8Array(BLOCK);
|
|
69
|
+
k.set(key.length > BLOCK ? sha256(key) : key);
|
|
70
|
+
|
|
71
|
+
const inner = new Uint8Array(BLOCK + msg.length);
|
|
72
|
+
const outer = new Uint8Array(BLOCK + 32);
|
|
73
|
+
for (let i = 0; i < BLOCK; i++) { inner[i] = k[i] ^ 0x36; outer[i] = k[i] ^ 0x5c; }
|
|
74
|
+
inner.set(msg, BLOCK);
|
|
75
|
+
outer.set(sha256(inner), BLOCK);
|
|
76
|
+
return sha256(outer);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const enc = new TextEncoder();
|
|
80
|
+
export const utf8 = (s: string): Uint8Array => enc.encode(s);
|
|
81
|
+
export const toHex = (b: Uint8Array): string =>
|
|
82
|
+
Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
|
|
83
|
+
|
|
84
|
+
/** 公平证里事前公布的服务端种子指纹 */
|
|
85
|
+
export const seedHash = (serverSeed: string): string => toHex(sha256(utf8(serverSeed)));
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 可续算的流式 SHA-256。
|
|
89
|
+
*
|
|
90
|
+
* 分块上传要跨多个 HTTP 请求对同一串字节算一个哈希,中间状态得存进数据库。
|
|
91
|
+
* crypto.subtle 做不到这件事——它不暴露内部 H 状态。手写实现在这里是刚需,
|
|
92
|
+
* 不是历史包袱。
|
|
93
|
+
*/
|
|
94
|
+
export class Sha256 {
|
|
95
|
+
#H = new Uint32Array(INIT);
|
|
96
|
+
#buf = new Uint8Array(64);
|
|
97
|
+
#dv = new DataView(this.#buf.buffer);
|
|
98
|
+
#w = new Uint32Array(64);
|
|
99
|
+
#total = 0;
|
|
100
|
+
|
|
101
|
+
/** 已吃进的总字节数 */
|
|
102
|
+
get byteLength(): number { return this.#total; }
|
|
103
|
+
|
|
104
|
+
update(bytes: Uint8Array): this {
|
|
105
|
+
let i = 0;
|
|
106
|
+
const pending = this.#total % 64;
|
|
107
|
+
|
|
108
|
+
if (pending > 0) {
|
|
109
|
+
const need = 64 - pending;
|
|
110
|
+
if (bytes.length < need) {
|
|
111
|
+
this.#buf.set(bytes, pending);
|
|
112
|
+
this.#total += bytes.length;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
this.#buf.set(bytes.subarray(0, need), pending);
|
|
116
|
+
compress(this.#H, this.#dv, 0, this.#w);
|
|
117
|
+
i = need;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 对齐的整块直接在入参上压缩,不拷贝
|
|
121
|
+
const aligned = bytes.byteOffset % 4 === 0;
|
|
122
|
+
const view = aligned
|
|
123
|
+
? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
124
|
+
: null;
|
|
125
|
+
for (; i + 64 <= bytes.length; i += 64) {
|
|
126
|
+
if (view) {
|
|
127
|
+
compress(this.#H, view, i, this.#w);
|
|
128
|
+
} else {
|
|
129
|
+
this.#buf.set(bytes.subarray(i, i + 64));
|
|
130
|
+
compress(this.#H, this.#dv, 0, this.#w);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (i < bytes.length) this.#buf.set(bytes.subarray(i), 0);
|
|
135
|
+
this.#total += bytes.length;
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 不终结:算完仍可继续 update,所以可以中途取快照 */
|
|
140
|
+
digest(): Uint8Array {
|
|
141
|
+
const H = new Uint32Array(this.#H);
|
|
142
|
+
const pending = this.#total % 64;
|
|
143
|
+
const tailLen = pending + 9 <= 64 ? 64 : 128;
|
|
144
|
+
const tail = new Uint8Array(tailLen);
|
|
145
|
+
tail.set(this.#buf.subarray(0, pending));
|
|
146
|
+
tail[pending] = 0x80;
|
|
147
|
+
const dv = new DataView(tail.buffer);
|
|
148
|
+
const bits = this.#total * 8;
|
|
149
|
+
dv.setUint32(tailLen - 8, Math.floor(bits / 0x100000000));
|
|
150
|
+
dv.setUint32(tailLen - 4, bits >>> 0);
|
|
151
|
+
const w = new Uint32Array(64);
|
|
152
|
+
for (let off = 0; off < tailLen; off += 64) compress(H, dv, off, w);
|
|
153
|
+
|
|
154
|
+
const out = new Uint8Array(32), odv = new DataView(out.buffer);
|
|
155
|
+
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
hex(): string { return toHex(this.digest()); }
|
|
160
|
+
|
|
161
|
+
/** 十六进制串:H(64) + 总字节数(16) + 未满一块的余料。余料长度 = total % 64,自描述 */
|
|
162
|
+
exportState(): string {
|
|
163
|
+
const h = Array.from(this.#H, (x) => x.toString(16).padStart(8, '0')).join('');
|
|
164
|
+
const total = this.#total.toString(16).padStart(16, '0');
|
|
165
|
+
const pending = this.#total % 64;
|
|
166
|
+
return h + total + toHex(this.#buf.subarray(0, pending));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
static importState(state: string): Sha256 {
|
|
170
|
+
if (state.length < 80 || !/^[0-9a-f]*$/.test(state)) {
|
|
171
|
+
throw new RangeError('Sha256 状态串非法');
|
|
172
|
+
}
|
|
173
|
+
const s = new Sha256();
|
|
174
|
+
for (let i = 0; i < 8; i++) s.#H[i] = parseInt(state.slice(i * 8, i * 8 + 8), 16) >>> 0;
|
|
175
|
+
s.#total = Number.parseInt(state.slice(64, 80), 16);
|
|
176
|
+
if (!Number.isSafeInteger(s.#total)) throw new RangeError('Sha256 状态的字节数越界');
|
|
177
|
+
const pending = s.#total % 64;
|
|
178
|
+
if (state.length !== 80 + pending * 2) throw new RangeError('Sha256 状态串长度与字节数不符');
|
|
179
|
+
for (let i = 0; i < pending; i++) {
|
|
180
|
+
s.#buf[i] = parseInt(state.slice(80 + i * 2, 82 + i * 2), 16);
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/sdk/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* 平台注入给游戏的东西,以及游戏交还给平台的把手。
|
|
2
|
+
|
|
3
|
+
方向是单向的:平台 → 游戏全是 Readable,游戏想改值要走 requestConfig 申请,
|
|
4
|
+
平台有否决权。不做双向读写的共享 store,原因有三条:
|
|
5
|
+
1. 夹取逻辑会重复实现,或者产生回声循环(游戏写回 clamp 值 → 平台更新 → 再推回游戏)
|
|
6
|
+
2. 局中、自动模式中、余额不足、服务端已锁参数——这些只有平台知道
|
|
7
|
+
3. 跨框架的双向绑定在 React StrictMode 下会出微妙的 bug */
|
|
8
|
+
import type { Readable } from './store.ts';
|
|
9
|
+
import type { RoundResult } from './contract.ts';
|
|
10
|
+
|
|
11
|
+
/** 不影响结果的表现参数。每个游戏都有,所以是固定字段,不进 schema */
|
|
12
|
+
export interface ViewSettings {
|
|
13
|
+
sound: boolean;
|
|
14
|
+
volume: number;
|
|
15
|
+
turbo: boolean;
|
|
16
|
+
reducedMotion: boolean;
|
|
17
|
+
/** 造型/皮肤。只是贴图,不参与结果计算 */
|
|
18
|
+
skin: string;
|
|
19
|
+
locale: string;
|
|
20
|
+
/**
|
|
21
|
+
* 平台正在跑自动模式。自动循环归平台(它才知道余额够不够、还剩几局),
|
|
22
|
+
* 但游戏要据此禁用画布内的设置控件——否则玩家能在自动跑的中途改图纸。
|
|
23
|
+
* 配合 schema 的 lock: 'inRoundOrAuto' 使用。
|
|
24
|
+
*/
|
|
25
|
+
autoActive: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WalletView {
|
|
29
|
+
balance: number;
|
|
30
|
+
currency: string;
|
|
31
|
+
/** 最小货币单位到显示单位的位数,人民币/美元是 2 */
|
|
32
|
+
decimals: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Host<C> {
|
|
36
|
+
/** 影响结果的参数。游戏只读 */
|
|
37
|
+
config: Readable<C>;
|
|
38
|
+
view: Readable<ViewSettings>;
|
|
39
|
+
/** 只读展示。游戏不碰钱 */
|
|
40
|
+
wallet: Readable<WalletView>;
|
|
41
|
+
jackpot: Readable<number | null>;
|
|
42
|
+
/** 由平台统一创建和解锁(iOS 要用户手势),游戏不要自己 new AudioContext */
|
|
43
|
+
audio: { context(): AudioContext | null; volume(): number };
|
|
44
|
+
locale: string;
|
|
45
|
+
/**
|
|
46
|
+
* 画布内控件的写入申请。平台校验后写回 config,游戏订阅到新值再重渲染。
|
|
47
|
+
* 只有声明了 ui: 'stage' 字段的游戏才需要它。
|
|
48
|
+
*/
|
|
49
|
+
requestConfig?(patch: Partial<C>): void;
|
|
50
|
+
emit(e: GameEvent): void;
|
|
51
|
+
log(level: 'debug' | 'info' | 'warn' | 'error', msg: string, data?: unknown): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GameStatus {
|
|
55
|
+
phase: 'loading' | 'idle' | 'playing' | 'settling' | 'error';
|
|
56
|
+
/** 正在演出,平台的下注按钮该禁用 */
|
|
57
|
+
busy: boolean;
|
|
58
|
+
canBet: boolean;
|
|
59
|
+
canSkip: boolean;
|
|
60
|
+
/** 现在能不能改影响结果的参数。平台据此把设置控件变灰——
|
|
61
|
+
和游戏画布内的控件用的是同一个判断,两边不会打架 */
|
|
62
|
+
canEditConfig: boolean;
|
|
63
|
+
/** 资源加载进度 0..1,phase === 'loading' 时有意义 */
|
|
64
|
+
progress?: number;
|
|
65
|
+
lastPayout?: number;
|
|
66
|
+
/** validate() 的结果,平台显示在设置面板上 */
|
|
67
|
+
issues?: import('./schema.ts').ConfigIssue[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PresentOptions {
|
|
71
|
+
/** 播放倍率。1 = 正常,Infinity = 直接看结果。只影响演出,不影响结果 */
|
|
72
|
+
rate?: number;
|
|
73
|
+
silent?: boolean;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GameInstance<O> {
|
|
78
|
+
status: Readable<GameStatus>;
|
|
79
|
+
/** 演一局已经算好的结果。resolve 于演出结束(含结算特效)。不碰钱 */
|
|
80
|
+
present(r: RoundResult<O>, o?: PresentOptions): Promise<void>;
|
|
81
|
+
/** 只演不结账:回看别人的大奖用。不发 present:end */
|
|
82
|
+
preview(r: RoundResult<O>, o?: PresentOptions): Promise<void>;
|
|
83
|
+
/** 快进到结果 */
|
|
84
|
+
skip(): void;
|
|
85
|
+
reset(): void;
|
|
86
|
+
resize(): void;
|
|
87
|
+
destroy(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type GameEvent =
|
|
91
|
+
| { type: 'ready' }
|
|
92
|
+
| { type: 'present:start' }
|
|
93
|
+
| { type: 'present:end'; payout: number }
|
|
94
|
+
| { type: 'milestone'; kind: 'bigwin' | 'jackpot' | 'nearmiss'; tier?: number }
|
|
95
|
+
| { type: 'config:request'; patch: Record<string, unknown> }
|
|
96
|
+
| { type: 'error'; code: string; message: string };
|
package/sdk/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './contract.ts';
|
|
2
|
+
export * from './spec.ts';
|
|
3
|
+
export * from './host.ts';
|
|
4
|
+
export * from './rng.ts';
|
|
5
|
+
export * from './schema.ts';
|
|
6
|
+
export * from './store.ts';
|
|
7
|
+
export { sha256, hmacSha256, utf8, toHex, Sha256 } from './hash.ts';
|
|
8
|
+
export * from './sample.ts';
|
|
9
|
+
export * from './stage.ts';
|
package/sdk/rng.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* 确定性随机数。
|
|
2
|
+
|
|
3
|
+
平台给一个种子三元组,游戏内展开成一条流。同一个种子 + 同一份代码
|
|
4
|
+
必然得到同一个结果——这是「服务端算钱、客户端演出」能对上账的前提。
|
|
5
|
+
|
|
6
|
+
算法:HMAC-SHA256(server, `${client}:${nonce}:${tag}`) 取前 128 位播种 xoshiro128**。
|
|
7
|
+
不采用「每个字都由 HMAC 直出」的做法:一局可能要抽成百上千个字(洗一副牌就是 51 次),
|
|
8
|
+
带重洗分支的游戏更多。播种一次再用快速 PRNG 展开,同样完全确定、
|
|
9
|
+
同样可验证(验证就是跑这份公开算法),成本却可以忽略。
|
|
10
|
+
|
|
11
|
+
全流程只有 uint32 的位运算和 Math.imul,不碰浮点,任何 JS 引擎结果一致。 */
|
|
12
|
+
import { hmacSha256, utf8 } from './hash.ts';
|
|
13
|
+
|
|
14
|
+
export interface RngSeed {
|
|
15
|
+
/** 服务端种子。下注前平台只公布它的 sha256,局后 reveal */
|
|
16
|
+
server: string;
|
|
17
|
+
/** 客户端种子,玩家可改 */
|
|
18
|
+
client: string;
|
|
19
|
+
/** 该 client 种子下的局序号,单调递增 */
|
|
20
|
+
nonce: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Rng {
|
|
24
|
+
u32(): number;
|
|
25
|
+
/** 无偏整数 [0, n)。用拒绝采样,不要自己写 % n */
|
|
26
|
+
below(n: number): number;
|
|
27
|
+
/** [0, 1) */
|
|
28
|
+
float(): number;
|
|
29
|
+
/** 洗牌,返回新数组,不改入参 */
|
|
30
|
+
shuffle<T>(a: readonly T[]): T[];
|
|
31
|
+
/** 已消耗的 u32 个数(含被拒绝采样丢掉的)。写进公平证供审计对账 */
|
|
32
|
+
readonly draws: number;
|
|
33
|
+
/**
|
|
34
|
+
* 派生一条域分隔的独立子流,不动当前游标。
|
|
35
|
+
*
|
|
36
|
+
* 两个用途:一是把演出用的随机(台词、粒子)与结果隔离,这样加一句新台词
|
|
37
|
+
* 不会改变牌序;二是把子系统分开,将来改发牌逻辑不会让金牌位置跟着漂。
|
|
38
|
+
*/
|
|
39
|
+
fork(tag: string): Rng;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rotl = (x: number, k: number): number => (x << k) | (x >>> (32 - k));
|
|
43
|
+
|
|
44
|
+
class Xoshiro implements Rng {
|
|
45
|
+
#s0: number; #s1: number; #s2: number; #s3: number;
|
|
46
|
+
#draws = 0;
|
|
47
|
+
#seed: RngSeed;
|
|
48
|
+
#tag: string;
|
|
49
|
+
|
|
50
|
+
constructor(seed: RngSeed, tag: string) {
|
|
51
|
+
this.#seed = seed;
|
|
52
|
+
this.#tag = tag;
|
|
53
|
+
const h = hmacSha256(utf8(seed.server), utf8(`${seed.client}:${seed.nonce}:${tag}`));
|
|
54
|
+
const dv = new DataView(h.buffer, h.byteOffset, h.byteLength);
|
|
55
|
+
this.#s0 = dv.getUint32(0); this.#s1 = dv.getUint32(4);
|
|
56
|
+
this.#s2 = dv.getUint32(8); this.#s3 = dv.getUint32(12);
|
|
57
|
+
// 全零状态会让 xoshiro 永远输出 0。HMAC 撞出全零的概率是 2^-128,但便宜
|
|
58
|
+
if (!(this.#s0 | this.#s1 | this.#s2 | this.#s3)) this.#s0 = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get draws(): number { return this.#draws; }
|
|
62
|
+
|
|
63
|
+
u32(): number {
|
|
64
|
+
const result = Math.imul(rotl(Math.imul(this.#s1, 5), 7), 9) >>> 0;
|
|
65
|
+
const t = this.#s1 << 9;
|
|
66
|
+
this.#s2 ^= this.#s0;
|
|
67
|
+
this.#s3 ^= this.#s1;
|
|
68
|
+
this.#s1 ^= this.#s2;
|
|
69
|
+
this.#s0 ^= this.#s3;
|
|
70
|
+
this.#s2 ^= t;
|
|
71
|
+
this.#s3 = rotl(this.#s3, 11);
|
|
72
|
+
this.#draws++;
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
below(n: number): number {
|
|
77
|
+
if (!Number.isInteger(n) || n <= 0) throw new RangeError(`below(${n}):要正整数`);
|
|
78
|
+
if (n === 1) return 0;
|
|
79
|
+
/* 4294967296 不是 n 的整数倍时,直接取模会让小余数偏多。
|
|
80
|
+
把超出最大整数倍的那一段丢掉再取模,分布才是均匀的 */
|
|
81
|
+
const lim = Math.floor(0x100000000 / n) * n;
|
|
82
|
+
let x: number;
|
|
83
|
+
do { x = this.u32(); } while (x >= lim);
|
|
84
|
+
return x % n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
float(): number { return this.u32() / 0x100000000; }
|
|
88
|
+
|
|
89
|
+
shuffle<T>(a: readonly T[]): T[] {
|
|
90
|
+
const out = a.slice();
|
|
91
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
92
|
+
const j = this.below(i + 1);
|
|
93
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fork(tag: string): Rng {
|
|
99
|
+
return new Xoshiro(this.#seed, this.#tag ? `${this.#tag}/${tag}` : tag);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const createRng = (seed: RngSeed, tag = ''): Rng => new Xoshiro(seed, tag);
|
|
104
|
+
|
|
105
|
+
/** 平台侧用:生成一个新的服务端种子。这是全流程唯一的真随机来源 */
|
|
106
|
+
export const randomSeed = (bytes = 32): string =>
|
|
107
|
+
Array.from(crypto.getRandomValues(new Uint8Array(bytes)), b => b.toString(16).padStart(2, '0')).join('');
|
|
108
|
+
|
|
109
|
+
export { seedHash } from './hash.ts';
|