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,359 @@
|
|
|
1
|
+
/* 打表可行性探测:这个游戏能不能上表模型、要几张表、book 该怎么编。
|
|
2
|
+
*
|
|
3
|
+
* 这是 CLAUDE.md 那张「config 组合 / 不同的赔付分布 / 能否打表」表的自动化版。
|
|
4
|
+
* 人算那张表靠的是理解游戏;这里靠的是数数和抽样,不理解语义也能给出同样的结论。
|
|
5
|
+
*
|
|
6
|
+
* 核心区分是「影响分布」和「影响这一局」——两回事:
|
|
7
|
+
* coco 的 ranks 换一组,1/3 的种子结果会变,但赔付分布一模一样(重标记)
|
|
8
|
+
* coin-flip 的 side 换一面,每一局都变,分布照样一模一样
|
|
9
|
+
* 前者决定要几张表,后者决定 book 能不能只存种子。 */
|
|
10
|
+
import type { ConfigSchema, FieldKind, FieldMeta } from 'gamekit777/sdk';
|
|
11
|
+
import { MAX_PAYOUT_CENTI } from 'gamekit777/protocol';
|
|
12
|
+
import type { Config, Game } from './game.ts';
|
|
13
|
+
import { choose, compareHistograms, histogram, permutations } from './stats.ts';
|
|
14
|
+
|
|
15
|
+
export interface ProbeOptions {
|
|
16
|
+
/** 每个取值采多少局做分布检验 */
|
|
17
|
+
sims?: number;
|
|
18
|
+
/** 每个字段最多试几个代表值 */
|
|
19
|
+
maxValuesPerField?: number;
|
|
20
|
+
/** 分布检验的显著性阈值 */
|
|
21
|
+
alpha?: number;
|
|
22
|
+
/** 超过这么多张表就判 infeasible */
|
|
23
|
+
maxTables?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FieldProbe {
|
|
27
|
+
key: string;
|
|
28
|
+
kind: FieldKind['kind'];
|
|
29
|
+
devOnly: boolean;
|
|
30
|
+
affectsOutcome: boolean;
|
|
31
|
+
/** 这个字段有多少种合法取值 */
|
|
32
|
+
cardinality: string;
|
|
33
|
+
/** 试过的代表值 */
|
|
34
|
+
tried: unknown[];
|
|
35
|
+
/** 换这个字段的值,赔付分布会不会变 */
|
|
36
|
+
affectsDistribution: boolean | null;
|
|
37
|
+
/** 分布检验的最小 p 值与最大 σ */
|
|
38
|
+
minP: number | null;
|
|
39
|
+
maxSigma: number | null;
|
|
40
|
+
/** 同一批种子下,换值后赔付变了的比例 */
|
|
41
|
+
perSeedChangeRate: number | null;
|
|
42
|
+
/** 只在一侧出现的赔付档位 */
|
|
43
|
+
disjointPayouts: number[];
|
|
44
|
+
note?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProbeResult {
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
/** schema 里 config 的组合总数(devOnly 钉在默认值) */
|
|
50
|
+
configCombinations: string;
|
|
51
|
+
fields: FieldProbe[];
|
|
52
|
+
/** 影响分布的字段 */
|
|
53
|
+
distributionFields: string[];
|
|
54
|
+
/** 影响这一局但不影响分布的字段——它们决定 book 能不能只存种子 */
|
|
55
|
+
freeFields: string[];
|
|
56
|
+
/** 不同赔付分布的数量。声明了 modes 时是精确值,没有时是上界 */
|
|
57
|
+
distinctDistributions: string | null;
|
|
58
|
+
distinctIsUpperBound: boolean;
|
|
59
|
+
/** 清单声明了 modes(modeKeys 非空) */
|
|
60
|
+
hasModes: boolean;
|
|
61
|
+
/** 清单里 mode.config 的键 */
|
|
62
|
+
modeKeys: string[];
|
|
63
|
+
modeIssues: { level: 'error' | 'warning'; message: string }[];
|
|
64
|
+
verdict: 'single-table' | 'multi-table' | 'infeasible';
|
|
65
|
+
verdictReason: string;
|
|
66
|
+
gates: { name: string; ok: boolean; detail: string }[];
|
|
67
|
+
baseline: {
|
|
68
|
+
distinctPayouts: number;
|
|
69
|
+
hitRatePpm: number;
|
|
70
|
+
maxPayoutCenti: number;
|
|
71
|
+
/** 最稀有的赔付档位及其频率 */
|
|
72
|
+
rarest: { payoutCenti: number; probability: number };
|
|
73
|
+
/** 要让最稀有档位采到 30 个样本,大约需要多少局 */
|
|
74
|
+
simsForRarest: number;
|
|
75
|
+
} | null;
|
|
76
|
+
book: { suggestion: 'none' | 'seed' | 'custom'; reason: string };
|
|
77
|
+
millis: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Field = FieldKind & FieldMeta & { default: unknown };
|
|
81
|
+
|
|
82
|
+
const DEFAULTS: Required<ProbeOptions> = { sims: 20_000, maxValuesPerField: 4, alpha: 1e-6, maxTables: 64 };
|
|
83
|
+
const PAIR_COUNT = 2_000;
|
|
84
|
+
const MODE_ENUM_CAP = 4_096;
|
|
85
|
+
|
|
86
|
+
/** 一个字段有几种合法取值 */
|
|
87
|
+
function cardinality(f: Field): bigint {
|
|
88
|
+
switch (f.kind) {
|
|
89
|
+
case 'int': case 'number': return BigInt(Math.floor((f.max - f.min) / (f.step ?? 1)) + 1);
|
|
90
|
+
case 'bool': return 2n;
|
|
91
|
+
case 'enum': return BigInt(f.options.filter((o) => !o.disabled).length);
|
|
92
|
+
case 'pick': {
|
|
93
|
+
const n = f.of.filter((o) => !o.disabled).length;
|
|
94
|
+
return f.ordered ? permutations(n, f.count) : choose(n, f.count);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 代表值:默认值 + 两端 + 中间;pick 是默认组合 + 平移几组 */
|
|
100
|
+
function representatives(f: Field, max: number): unknown[] {
|
|
101
|
+
const out: unknown[] = [f.default];
|
|
102
|
+
const push = (v: unknown): void => { if (out.length < max && !out.some((x) => JSON.stringify(x) === JSON.stringify(v))) out.push(v); };
|
|
103
|
+
switch (f.kind) {
|
|
104
|
+
case 'int': case 'number': {
|
|
105
|
+
const step = f.step ?? 1;
|
|
106
|
+
push(f.min); push(f.max);
|
|
107
|
+
push(f.min + Math.floor((f.max - f.min) / (2 * step)) * step);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'bool': push(!f.default); break;
|
|
111
|
+
case 'enum': for (const o of f.options) if (!o.disabled) push(o.value); break;
|
|
112
|
+
case 'pick': {
|
|
113
|
+
const vals = f.of.filter((o) => !o.disabled).map((o) => o.value);
|
|
114
|
+
for (let shift = 1; shift < vals.length && out.length < max; shift++) {
|
|
115
|
+
const combo = Array.from({ length: f.count }, (_, i) => vals[(i * Math.max(1, Math.floor(vals.length / f.count)) + shift) % vals.length]!);
|
|
116
|
+
if (new Set(combo).size === f.count) push(combo);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 该字段的全部取值(给 mode 枚举用),超过 cap 就等距抽 */
|
|
125
|
+
function allValues(f: Field, cap: number): unknown[] {
|
|
126
|
+
switch (f.kind) {
|
|
127
|
+
case 'int': case 'number': {
|
|
128
|
+
const step = f.step ?? 1;
|
|
129
|
+
const n = Math.floor((f.max - f.min) / step) + 1;
|
|
130
|
+
if (n <= cap) return Array.from({ length: n }, (_, i) => f.min + i * step);
|
|
131
|
+
return Array.from({ length: cap }, (_, i) => f.min + Math.round((i * (n - 1)) / (cap - 1)) * step);
|
|
132
|
+
}
|
|
133
|
+
case 'bool': return [false, true];
|
|
134
|
+
case 'enum': return f.options.filter((o) => !o.disabled).map((o) => o.value);
|
|
135
|
+
case 'pick': return representatives(f, cap);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cartesian(lists: unknown[][]): unknown[][] {
|
|
140
|
+
return lists.reduce<unknown[][]>((acc, l) => acc.flatMap((a) => l.map((v) => [...a, v])), [[]]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function probe(game: Game, opts: ProbeOptions = {}): Promise<ProbeResult> {
|
|
144
|
+
const o = { ...DEFAULTS, ...opts };
|
|
145
|
+
const t0 = Date.now();
|
|
146
|
+
const snap = await game.snapshot();
|
|
147
|
+
const schema = snap.schema as ConfigSchema<Config>;
|
|
148
|
+
const keys = Object.keys(schema);
|
|
149
|
+
const seedPrefix = `probe/${game.fingerprint}`;
|
|
150
|
+
|
|
151
|
+
const base = await game.normalize({});
|
|
152
|
+
const gates: ProbeResult['gates'] = [];
|
|
153
|
+
if (base.issues.length > 0) {
|
|
154
|
+
gates.push({ name: '默认配置合法', ok: false, detail: base.issues.map((i) => `${i.field}(${i.code})`).join('、') });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ── 组合数 ── */
|
|
158
|
+
let combos = 1n;
|
|
159
|
+
for (const k of keys) {
|
|
160
|
+
const f = schema[k] as Field;
|
|
161
|
+
if (f.devOnly) continue;
|
|
162
|
+
combos *= cardinality(f);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ── 基线分布 + 三条硬门槛 ── */
|
|
166
|
+
let baseHist: Map<number, number> | null = null;
|
|
167
|
+
let baseline: ProbeResult['baseline'] = null;
|
|
168
|
+
try {
|
|
169
|
+
const s = await game.sample(base.config, 'base', `${seedPrefix}/base`, o.sims);
|
|
170
|
+
baseHist = histogram(s.payoutCenti);
|
|
171
|
+
gates.push({ name: 'payout 是 centi 精度的非负整数', ok: true, detail: `${o.sims} 局无异常` });
|
|
172
|
+
|
|
173
|
+
let hit = 0, maxP = 0;
|
|
174
|
+
let rarest = { payoutCenti: 0, probability: 1 };
|
|
175
|
+
for (const [p, c] of baseHist) {
|
|
176
|
+
if (p > 0) hit += c;
|
|
177
|
+
maxP = Math.max(maxP, p);
|
|
178
|
+
const prob = c / o.sims;
|
|
179
|
+
if (prob < rarest.probability) rarest = { payoutCenti: p, probability: prob };
|
|
180
|
+
}
|
|
181
|
+
baseline = {
|
|
182
|
+
distinctPayouts: baseHist.size,
|
|
183
|
+
hitRatePpm: Math.round((hit / o.sims) * 1e6),
|
|
184
|
+
maxPayoutCenti: maxP,
|
|
185
|
+
rarest,
|
|
186
|
+
simsForRarest: Math.ceil(30 / rarest.probability),
|
|
187
|
+
};
|
|
188
|
+
gates.push({
|
|
189
|
+
name: `最大赔付 ≤ 平台上限 ${MAX_PAYOUT_CENTI / 100}x`,
|
|
190
|
+
ok: maxP <= MAX_PAYOUT_CENTI, detail: `${maxP / 100}x`,
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
gates.push({ name: 'payout 是 centi 精度的非负整数', ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await game.betLinearity(base.config, `${seedPrefix}/lin`, Array.from({ length: 32 }, (_, i) => i * 37));
|
|
197
|
+
gates.push({ name: '赔付是注额的线性函数', ok: true, detail: '32 局 × 3 种注额' });
|
|
198
|
+
} catch (e) {
|
|
199
|
+
gates.push({ name: '赔付是注额的线性函数', ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── 逐字段 ── */
|
|
203
|
+
const fields: FieldProbe[] = [];
|
|
204
|
+
for (const k of keys) {
|
|
205
|
+
const f = schema[k] as Field;
|
|
206
|
+
const fp: FieldProbe = {
|
|
207
|
+
key: k, kind: f.kind, devOnly: !!f.devOnly, affectsOutcome: f.affectsOutcome,
|
|
208
|
+
cardinality: cardinality(f).toString(), tried: [], affectsDistribution: null,
|
|
209
|
+
minP: null, maxSigma: null, perSeedChangeRate: null, disjointPayouts: [],
|
|
210
|
+
};
|
|
211
|
+
if (f.devOnly) { fp.note = 'devOnly:生产环境钉在默认值,不参与打表'; fields.push(fp); continue; }
|
|
212
|
+
if (!f.affectsOutcome) { fp.note = '声明不影响结果'; fields.push(fp); continue; }
|
|
213
|
+
if (!baseHist) { fp.note = '基线采样失败,跳过'; fields.push(fp); continue; }
|
|
214
|
+
|
|
215
|
+
const reps = representatives(f, o.maxValuesPerField);
|
|
216
|
+
fp.tried = reps;
|
|
217
|
+
let minP = 1, maxSigma = 0, affects = false;
|
|
218
|
+
let changedTotal = 0, pairTotal = 0;
|
|
219
|
+
const failed: string[] = [];
|
|
220
|
+
|
|
221
|
+
for (let vi = 1; vi < reps.length; vi++) {
|
|
222
|
+
const alt = await game.normalize({ [k]: reps[vi] }, base.config);
|
|
223
|
+
if (alt.issues.length > 0) continue;
|
|
224
|
+
try {
|
|
225
|
+
// 分布检验用独立种子:成对种子会让计数相关,χ² 的零分布就不成立了
|
|
226
|
+
const s = await game.sample(alt.config, `alt-${k}`, `${seedPrefix}/${k}/${vi}`, o.sims);
|
|
227
|
+
const t = compareHistograms(baseHist, histogram(s.payoutCenti));
|
|
228
|
+
minP = Math.min(minP, t.p);
|
|
229
|
+
maxSigma = Math.max(maxSigma, t.maxSigma);
|
|
230
|
+
if (t.p < o.alpha || t.disjoint.length > 0) affects = true;
|
|
231
|
+
for (const d of t.disjoint) if (!fp.disjointPayouts.includes(d)) fp.disjointPayouts.push(d);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
/* 某个取值下 payout 不是 centi 精度 / 超上限。这个取值根本进不了表,
|
|
234
|
+
保守起见按「影响分布」计——它无论如何不能和别的取值共用一张表 */
|
|
235
|
+
failed.push(`${JSON.stringify(reps[vi])}:${e instanceof Error ? e.message : String(e)}`);
|
|
236
|
+
affects = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pd = await game.pairedDiff(base.config, alt.config, `${seedPrefix}/pair/${k}/${vi}`, PAIR_COUNT);
|
|
241
|
+
changedTotal += pd.changed; pairTotal += pd.count;
|
|
242
|
+
}
|
|
243
|
+
if (failed.length > 0) {
|
|
244
|
+
fp.note = `有取值采样失败:${failed.join(';')}`;
|
|
245
|
+
const gate = gates.find((g) => g.name.startsWith('payout 是 centi'));
|
|
246
|
+
if (gate) { gate.ok = false; gate.detail = `字段 ${k} 在某些取值下不成立——${failed[0]}`; }
|
|
247
|
+
}
|
|
248
|
+
fp.affectsDistribution = affects;
|
|
249
|
+
fp.minP = minP;
|
|
250
|
+
fp.maxSigma = maxSigma;
|
|
251
|
+
fp.perSeedChangeRate = pairTotal > 0 ? changedTotal / pairTotal : null;
|
|
252
|
+
fields.push(fp);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const distributionFields = fields.filter((f) => f.affectsDistribution === true).map((f) => f.key);
|
|
256
|
+
const freeFields = fields
|
|
257
|
+
.filter((f) => f.affectsDistribution === false && (f.perSeedChangeRate ?? 0) > 0)
|
|
258
|
+
.map((f) => f.key);
|
|
259
|
+
|
|
260
|
+
/* ── 数分布 ── */
|
|
261
|
+
const modeKeys = snap.spec.modeKeys;
|
|
262
|
+
const hasModes = modeKeys.length > 0;
|
|
263
|
+
const modeIssues: ProbeResult['modeIssues'] = [];
|
|
264
|
+
let distinct: bigint | null = null;
|
|
265
|
+
let upper = false;
|
|
266
|
+
|
|
267
|
+
/* 两道结构校验。modeOf 由 mode.config 的键集推导,所以「漏字段」在声明上就看得出来,
|
|
268
|
+
不用再拿样本去试 */
|
|
269
|
+
for (const k of distributionFields) {
|
|
270
|
+
if (!modeKeys.includes(k)) {
|
|
271
|
+
modeIssues.push({ level: 'error', message: `modes 漏了字段 ${k}:它会改变赔付分布,却没出现在 mode.config 里。同一张表会被灌进两种分布,这是完全静默的致命 bug` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const k of modeKeys) {
|
|
275
|
+
const fld = fields.find((f) => f.key === k);
|
|
276
|
+
if (fld && fld.affectsDistribution === false) {
|
|
277
|
+
modeIssues.push({ level: 'warning', message: `字段 ${k} 不改变赔付分布,却写进了 modes。表数被无谓放大 ${fld.cardinality} 倍——考虑像 coco 的 ranks 那样用 book 编码把它放出去` });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (hasModes) {
|
|
282
|
+
const lists = distributionFields.map((k) => allValues(schema[k] as Field, MODE_ENUM_CAP));
|
|
283
|
+
let combosList = cartesian(lists);
|
|
284
|
+
if (combosList.length > MODE_ENUM_CAP) {
|
|
285
|
+
// 等距抽样,别把 19,202 个 config 全塞给 worker
|
|
286
|
+
const stride = combosList.length / MODE_ENUM_CAP;
|
|
287
|
+
combosList = Array.from({ length: MODE_ENUM_CAP }, (_, i) => combosList[Math.floor(i * stride)]!);
|
|
288
|
+
upper = true;
|
|
289
|
+
}
|
|
290
|
+
const configs = await Promise.all(combosList.map(async (vals) => {
|
|
291
|
+
const patch: Config = {};
|
|
292
|
+
distributionFields.forEach((k, i) => { patch[k] = vals[i]; });
|
|
293
|
+
return (await game.normalize(patch, base.config)).config;
|
|
294
|
+
}));
|
|
295
|
+
const modes = await game.modeOf(configs);
|
|
296
|
+
const unmatched = configs.filter((_, i) => modes[i] === null);
|
|
297
|
+
if (unmatched.length) {
|
|
298
|
+
const sample = JSON.stringify(Object.fromEntries(distributionFields.map((k) => [k, unmatched[0]![k]])));
|
|
299
|
+
modeIssues.push({ level: 'error', message: `有 ${unmatched.length} 种影响分布的取值组合没有匹配的 mode(如 ${sample})——modes 声明不完整,玩家选到这里下注会被拒` });
|
|
300
|
+
}
|
|
301
|
+
distinct = BigInt(new Set(modes.filter((m): m is string => m !== null)).size);
|
|
302
|
+
} else {
|
|
303
|
+
distinct = 1n;
|
|
304
|
+
for (const k of distributionFields) distinct *= cardinality(schema[k] as Field);
|
|
305
|
+
upper = distributionFields.length > 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ── 结论 ── */
|
|
309
|
+
const tooMany = distinct !== null && distinct > BigInt(o.maxTables);
|
|
310
|
+
const countReason = (): string => {
|
|
311
|
+
const worst = fields
|
|
312
|
+
.filter((f) => distributionFields.includes(f.key))
|
|
313
|
+
.sort((a, b) => Number(BigInt(b.cardinality) - BigInt(a.cardinality)))[0];
|
|
314
|
+
return `${distinct} 种赔付分布,超过 ${o.maxTables} 张表的上限。元凶是 ${worst?.key}(${worst?.cardinality} 个取值)——` +
|
|
315
|
+
`冻结成有限档位(比如 8 档 → ${8n * (distinct! / BigInt(worst?.cardinality ?? '1'))} 张表),或改成从有限档位里选`;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
let verdict: ProbeResult['verdict'];
|
|
319
|
+
let verdictReason: string;
|
|
320
|
+
const hardFail = gates.find((g) => !g.ok);
|
|
321
|
+
if (hardFail) {
|
|
322
|
+
verdict = 'infeasible';
|
|
323
|
+
// 硬门槛和表数往往一起挂(dice-hunter 两条都不过),两条都要说,作者才知道要改几处
|
|
324
|
+
verdictReason = `硬门槛未过:${hardFail.name}——${hardFail.detail}${tooMany ? `。另外,${countReason()}` : ''}`;
|
|
325
|
+
} else if (distinct === 1n) {
|
|
326
|
+
verdict = 'single-table';
|
|
327
|
+
verdictReason = distributionFields.length === 0
|
|
328
|
+
? '没有任何字段改变赔付分布,一张表就够'
|
|
329
|
+
: '影响分布的字段只映射到一个 mode';
|
|
330
|
+
} else if (!tooMany) {
|
|
331
|
+
verdict = 'multi-table';
|
|
332
|
+
verdictReason = `${distinct} 张表(由 ${distributionFields.join('、')} 决定)${hasModes ? '' : '——还没声明 modes,需要在 game.ts 里补上'}`;
|
|
333
|
+
} else {
|
|
334
|
+
verdict = 'infeasible';
|
|
335
|
+
verdictReason = countReason();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ── book 建议 ── */
|
|
339
|
+
let book: ProbeResult['book'];
|
|
340
|
+
if (freeFields.length === 0) {
|
|
341
|
+
book = { suggestion: 'seed', reason: '所有影响结果的字段都影响分布,会被整体冻结进 mode;存种子重跑 round() 即可,渲染代码一行不用改' };
|
|
342
|
+
} else if (baseline && baseline.distinctPayouts <= 3) {
|
|
343
|
+
book = { suggestion: 'none', reason: `只有 ${baseline.distinctPayouts} 种赔付,演出大概率能从 payout 推导(${freeFields.join('、')} 只是重标记)。请人工确认` };
|
|
344
|
+
} else {
|
|
345
|
+
book = { suggestion: 'custom', reason: `${freeFields.join('、')} 改变这一局的结果却不改变分布。表里写的 payout 和玩家按当下配置算的会对不上,必须存一份与这些字段无关的演出编码(参考 coco 的 book.ts)` };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
fingerprint: game.fingerprint,
|
|
350
|
+
configCombinations: combos.toString(),
|
|
351
|
+
fields, distributionFields, freeFields,
|
|
352
|
+
distinctDistributions: distinct?.toString() ?? null,
|
|
353
|
+
distinctIsUpperBound: upper,
|
|
354
|
+
hasModes, modeKeys,
|
|
355
|
+
modeIssues,
|
|
356
|
+
verdict, verdictReason, gates, baseline, book,
|
|
357
|
+
millis: Date.now() - t0,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* 建新游戏:脚手架 + 写 package.json + bun install。
|
|
2
|
+
*
|
|
3
|
+
* 这是 MCP 的 scaffold 工具背后的东西。装依赖这一步只有这里会自动做——
|
|
4
|
+
* 别处发现缺 node_modules 一律只提示,不代作者动手。 */
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { scaffoldGame, ID_PATTERN } from 'gamekit777/create-game';
|
|
9
|
+
|
|
10
|
+
export interface ScaffoldRequest {
|
|
11
|
+
id: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
/** 目标目录。默认 <cwd>/<id> */
|
|
14
|
+
dest?: string;
|
|
15
|
+
/** package.json 里 gamekit777 的版本说明符。仓库内是 workspace:*,创作者机器上是版本号 */
|
|
16
|
+
gamekitSpec?: string;
|
|
17
|
+
/** 跳过 bun install */
|
|
18
|
+
skipInstall?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScaffoldResult {
|
|
22
|
+
dest: string;
|
|
23
|
+
files: number;
|
|
24
|
+
installed: boolean;
|
|
25
|
+
installOutput: string;
|
|
26
|
+
next: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bunInstall(cwd: string): Promise<{ ok: boolean; output: string }> {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const child = spawn('bun', ['install'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
32
|
+
let out = '';
|
|
33
|
+
child.stdout.on('data', (b: Buffer) => { out += b.toString(); });
|
|
34
|
+
child.stderr.on('data', (b: Buffer) => { out += b.toString(); });
|
|
35
|
+
child.on('close', (code) => resolve({ ok: code === 0, output: out.slice(-4000) }));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 仓库内跑就用 workspace:*,否则用 studio 自己这份 gamekit777 的版本号 */
|
|
40
|
+
function defaultSpec(dest: string): string {
|
|
41
|
+
let dir = dest;
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
dir = join(dir, '..');
|
|
44
|
+
const pkg = join(dir, 'package.json');
|
|
45
|
+
if (existsSync(pkg)) {
|
|
46
|
+
try {
|
|
47
|
+
const m = JSON.parse(readFileSync(pkg, 'utf8')) as { workspaces?: string[] };
|
|
48
|
+
if (m.workspaces) return 'workspace:*';
|
|
49
|
+
} catch { /* 继续往上 */ }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/* 仓库内这个文件在 packages/studio/src/,薄壳在 packages/gamekit777/;
|
|
53
|
+
压平进 npm 包后在 gamekit777/studio/src/,包根就是 gamekit777 本身。两种布局都试 */
|
|
54
|
+
for (const rel of ['../../package.json', '../../../gamekit777/package.json']) {
|
|
55
|
+
try {
|
|
56
|
+
const m = JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8')) as { name?: string; version?: string };
|
|
57
|
+
if (m.name === 'gamekit777' && m.version) return `^${m.version}`;
|
|
58
|
+
} catch { /* 下一个 */ }
|
|
59
|
+
}
|
|
60
|
+
return '*';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function scaffold(req: ScaffoldRequest, cwd: string): Promise<ScaffoldResult> {
|
|
64
|
+
if (!ID_PATTERN.test(req.id)) throw new Error(`id "${req.id}" 要是小写短横线形式`);
|
|
65
|
+
const dest = req.dest ?? join(cwd, req.id);
|
|
66
|
+
const r = scaffoldGame({ id: req.id, title: req.title, dest, gamekitSpec: req.gamekitSpec ?? defaultSpec(dest) });
|
|
67
|
+
|
|
68
|
+
let installed = false, installOutput = '';
|
|
69
|
+
if (!req.skipInstall) {
|
|
70
|
+
const i = await bunInstall(dest);
|
|
71
|
+
installed = i.ok; installOutput = i.output;
|
|
72
|
+
if (!i.ok) throw new Error(`bun install 失败:\n${i.output}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
dest, files: r.files.length, installed, installOutput,
|
|
77
|
+
next: [
|
|
78
|
+
`游戏在 ${dest},是一个能跑的抛硬币。从能跑的东西开始改。`,
|
|
79
|
+
`先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
|
|
80
|
+
'改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
|
|
81
|
+
'已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
|
|
82
|
+
'用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* 调参回路:groups + 目标 → 权重 + 报告。node 和浏览器共用这一份。
|
|
2
|
+
*
|
|
3
|
+
* 只吃 groups 摘要(几百 KB 以内),不吃 10 万行 plan,所以滑杆的每一帧都能在浏览器里闭环;
|
|
4
|
+
* MCP 的 solve_params 在 node 里调同一个函数,两边看到的是同一份判定。
|
|
5
|
+
*
|
|
6
|
+
* 两道判定缺一不可:solve() 的 ok:true 不等于收敛——stall 和线搜索失败也返回 ok:true。
|
|
7
|
+
* 所以 converged 是个显式字段,所有消费者都没法忘。 */
|
|
8
|
+
import { pickMedian, report, solve, type Group, type Report, type Segment } from 'gamekit777/weights';
|
|
9
|
+
import { flagOutliers, type PayoutStat, type RowPlan } from 'gamekit777/publish/table';
|
|
10
|
+
|
|
11
|
+
export interface SegmentTarget {
|
|
12
|
+
name: string;
|
|
13
|
+
/** 赔付区间 [lo, hi],centi,闭区间。分段必须是 payout 的函数 */
|
|
14
|
+
payoutRange: [number, number];
|
|
15
|
+
/** 每 N 局中一次 → mass = 1/N。不给就由其余约束挤出 */
|
|
16
|
+
oneIn?: number;
|
|
17
|
+
/** 该段对总 RTP 的贡献(ppm) */
|
|
18
|
+
rtpPpm?: number;
|
|
19
|
+
/** 波动率:均值/中位数 的目标区间 */
|
|
20
|
+
meanToMedian?: [number, number];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Targets {
|
|
24
|
+
totalRtpPpm: number;
|
|
25
|
+
segments: SegmentTarget[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SolveReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** ok 且残差 ≤ 1e-6(每条约束相对误差 < 1 ppm,和平台验 RTP 同单位) */
|
|
31
|
+
converged: boolean;
|
|
32
|
+
residual: number;
|
|
33
|
+
iterations: number;
|
|
34
|
+
millis: number;
|
|
35
|
+
weights: number[] | null;
|
|
36
|
+
/** 实算:每段的命中率 / RTP / 中位数 / m2m,和目标并排 */
|
|
37
|
+
achieved: Report | null;
|
|
38
|
+
/** |theta| 最大的那条约束——最吃力的目标 */
|
|
39
|
+
hardest: { label: string; theta: number } | null;
|
|
40
|
+
/** 被判冗余丢弃的约束。必须显示,否则作者以为它生效了 */
|
|
41
|
+
dropped: string[];
|
|
42
|
+
thin: PayoutStat[];
|
|
43
|
+
distorted: PayoutStat[];
|
|
44
|
+
/** 转成 weights 包的 Segment,建表时原样带过去 */
|
|
45
|
+
segments: Segment[];
|
|
46
|
+
failure: { kind: 'solve' | 'median' | 'rtp-range' | 'segment'; detail: string } | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const RESIDUAL_LIMIT = 1e-6;
|
|
50
|
+
|
|
51
|
+
/** 目标 RTP 的可达区间:全押最低赔付 ↔ 全押最高赔付 */
|
|
52
|
+
export function rtpRangePpm(groups: readonly Group[], betCost: number): [number, number] {
|
|
53
|
+
let lo = Infinity, hi = -Infinity;
|
|
54
|
+
for (const g of groups) { lo = Math.min(lo, g.payout); hi = Math.max(hi, g.payout); }
|
|
55
|
+
return [Math.ceil((lo / betCost) * 1e6), Math.floor((hi / betCost) * 1e6)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 把作者的赔付区间翻译成 group 下标 */
|
|
59
|
+
function membersOf(groups: readonly Group[], [lo, hi]: [number, number]): number[] {
|
|
60
|
+
const out: number[] = [];
|
|
61
|
+
for (let i = 0; i < groups.length; i++) if (groups[i]!.payout >= lo && groups[i]!.payout <= hi) out.push(i);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runSolve(groups: readonly Group[], targets: Targets, betCost: number, sims: number): SolveReport {
|
|
66
|
+
const base: Omit<SolveReport, 'failure'> = {
|
|
67
|
+
ok: false, converged: false, residual: NaN, iterations: 0, millis: 0, weights: null,
|
|
68
|
+
achieved: null, hardest: null, dropped: [], thin: [], distorted: [], segments: [],
|
|
69
|
+
};
|
|
70
|
+
const fail = (kind: SolveReport['failure'] extends infer F ? (F extends { kind: infer K } ? K : never) : never, detail: string): SolveReport =>
|
|
71
|
+
({ ...base, failure: { kind, detail } });
|
|
72
|
+
|
|
73
|
+
const [rlo, rhi] = rtpRangePpm(groups, betCost);
|
|
74
|
+
if (targets.totalRtpPpm < rlo || targets.totalRtpPpm > rhi) {
|
|
75
|
+
return fail('rtp-range', `目标 RTP ${targets.totalRtpPpm} ppm 超出这份样本的可达区间 [${rlo}, ${rhi}] ppm——最低赔付是 ${groups.reduce((m, g) => Math.min(m, g.payout), Infinity) / 100}x,最高 ${groups.reduce((m, g) => Math.max(m, g.payout), 0) / 100}x`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const segments: Segment[] = [];
|
|
79
|
+
for (const t of targets.segments) {
|
|
80
|
+
const members = membersOf(groups, t.payoutRange);
|
|
81
|
+
if (members.length === 0) return fail('segment', `段「${t.name}」的赔付区间 [${t.payoutRange[0] / 100}x, ${t.payoutRange[1] / 100}x] 里没有任何采到的档位`);
|
|
82
|
+
const seg: Segment = { name: t.name, members };
|
|
83
|
+
if (t.oneIn !== undefined && t.oneIn > 0) seg.mass = 1 / t.oneIn;
|
|
84
|
+
if (t.rtpPpm !== undefined) seg.rtp = t.rtpPpm / 1e6;
|
|
85
|
+
if (t.meanToMedian) {
|
|
86
|
+
if (seg.mass === undefined || seg.rtp === undefined) {
|
|
87
|
+
return fail('median', `段「${t.name}」要控波动率,必须同时给命中率和 RTP 份额——中位数是相对均值定义的,均值没钉死就无从谈起`);
|
|
88
|
+
}
|
|
89
|
+
const m = pickMedian(groups, seg, betCost, t.meanToMedian);
|
|
90
|
+
if (m === null) {
|
|
91
|
+
return fail('median', `段「${t.name}」的赔付档位太稀疏,撑不起 均值/中位数 ∈ [${t.meanToMedian[0]}, ${t.meanToMedian[1]}]。把区间放宽、把段的赔付范围挪一挪,或加大采样量`);
|
|
92
|
+
}
|
|
93
|
+
seg.median = m;
|
|
94
|
+
}
|
|
95
|
+
segments.push(seg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const res = solve({ groups, segments, totalRtp: targets.totalRtpPpm / 1e6, betCost });
|
|
99
|
+
if (!res.ok) return { ...base, segments, failure: { kind: 'solve', detail: res.detail } };
|
|
100
|
+
|
|
101
|
+
let hardest: SolveReport['hardest'] = null;
|
|
102
|
+
for (let i = 0; i < res.constraints.length; i++) {
|
|
103
|
+
const th = Math.abs(res.theta[i] ?? 0);
|
|
104
|
+
if (!hardest || th > Math.abs(hardest.theta)) hardest = { label: res.constraints[i]!.label, theta: res.theta[i] ?? 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// flagOutliers 只吃 plan 的 groups 和 count,这里从 prior 反推 count(prior 就是组内样本数)
|
|
108
|
+
const plan = {
|
|
109
|
+
groups, count: Int32Array.from(groups, (g) => Math.round(g.prior)),
|
|
110
|
+
payoutCenti: new Int32Array(0), group: new Int32Array(0), sample: new Int32Array(0),
|
|
111
|
+
} as unknown as RowPlan;
|
|
112
|
+
const { thin, distorted } = flagOutliers(plan, res.weights, sims);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
converged: res.residual <= RESIDUAL_LIMIT,
|
|
117
|
+
residual: res.residual, iterations: res.iterations, millis: res.millis,
|
|
118
|
+
weights: Array.from(res.weights),
|
|
119
|
+
achieved: report(groups, segments, res.weights, betCost),
|
|
120
|
+
hardest, dropped: [...res.dropped], thin, distorted, segments,
|
|
121
|
+
failure: null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 默认三段:未中奖 / 小奖 / 大奖,阈值取加权中位赔付。作者从这里开始拖 */
|
|
126
|
+
export function defaultSegments(groups: readonly Group[]): SegmentTarget[] {
|
|
127
|
+
const wins = groups.filter((g) => g.payout > 0).sort((a, b) => a.payout - b.payout);
|
|
128
|
+
if (wins.length === 0) return [{ name: '全部', payoutRange: [0, 0] }];
|
|
129
|
+
let total = 0;
|
|
130
|
+
for (const g of wins) total += g.prior;
|
|
131
|
+
let acc = 0, split = wins[wins.length - 1]!.payout;
|
|
132
|
+
for (const g of wins) { acc += g.prior; if (acc >= total / 2) { split = g.payout; break; } }
|
|
133
|
+
const maxP = wins[wins.length - 1]!.payout;
|
|
134
|
+
return [
|
|
135
|
+
{ name: '未中奖', payoutRange: [0, 0] },
|
|
136
|
+
{ name: '小奖', payoutRange: [1, split] },
|
|
137
|
+
...(split < maxP ? [{ name: '大奖', payoutRange: [split + 1, maxP] as [number, number] }] : []),
|
|
138
|
+
];
|
|
139
|
+
}
|