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
|
+
中位数按 segment 内部算,不是全局:Stake 的波动率约束(min/max_mean_to_median)
|
|
5
|
+
也是在每个 fence 的赔付分布上判的,全局中位数会被零赢局压成 0。 */
|
|
6
|
+
|
|
7
|
+
import type { Group, Segment } from './types.ts';
|
|
8
|
+
|
|
9
|
+
export interface SegmentReport {
|
|
10
|
+
name: string;
|
|
11
|
+
mass: number;
|
|
12
|
+
hitRate: number;
|
|
13
|
+
rtp: number;
|
|
14
|
+
avgWin: number;
|
|
15
|
+
median: number;
|
|
16
|
+
meanToMedian: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Report {
|
|
20
|
+
rtp: number;
|
|
21
|
+
segments: SegmentReport[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function weightedMedian(
|
|
25
|
+
groups: readonly Group[],
|
|
26
|
+
members: readonly number[],
|
|
27
|
+
weights: Float64Array,
|
|
28
|
+
mass: number,
|
|
29
|
+
): number {
|
|
30
|
+
if (mass <= 0) return 0;
|
|
31
|
+
const order = [...members].sort((a, b) => groups[a]!.payout - groups[b]!.payout);
|
|
32
|
+
let acc = 0;
|
|
33
|
+
// 中位数定义为使 P(X ≤ m) ≥ 0.5 的最小 m。中位数约束会让累计恰好等于 0.5,
|
|
34
|
+
// 这时浮点误差足以把结果推到下一档,所以比较要留容差
|
|
35
|
+
for (const i of order) {
|
|
36
|
+
acc += weights[i]! / mass;
|
|
37
|
+
if (acc >= 0.5 - 1e-9) return groups[i]!.payout;
|
|
38
|
+
}
|
|
39
|
+
return groups[order[order.length - 1]!]!.payout;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function report(
|
|
43
|
+
groups: readonly Group[],
|
|
44
|
+
segments: readonly Segment[],
|
|
45
|
+
weights: Float64Array,
|
|
46
|
+
betCost = 1,
|
|
47
|
+
): Report {
|
|
48
|
+
let total = 0;
|
|
49
|
+
for (let i = 0; i < groups.length; i++) total += weights[i]!;
|
|
50
|
+
|
|
51
|
+
let rtp = 0;
|
|
52
|
+
for (let i = 0; i < groups.length; i++) {
|
|
53
|
+
rtp += ((weights[i]! / total) * groups[i]!.payout) / betCost;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const segs = segments.map((seg) => {
|
|
57
|
+
let mass = 0;
|
|
58
|
+
let segPayout = 0;
|
|
59
|
+
for (const i of seg.members) {
|
|
60
|
+
const w = weights[i]! / total;
|
|
61
|
+
mass += w;
|
|
62
|
+
segPayout += w * groups[i]!.payout;
|
|
63
|
+
}
|
|
64
|
+
const avgWin = mass > 0 ? segPayout / mass : 0;
|
|
65
|
+
const median = weightedMedian(groups, seg.members, weights, mass * total);
|
|
66
|
+
return {
|
|
67
|
+
name: seg.name,
|
|
68
|
+
mass,
|
|
69
|
+
hitRate: mass > 0 ? 1 / mass : Infinity,
|
|
70
|
+
rtp: segPayout / betCost,
|
|
71
|
+
avgWin,
|
|
72
|
+
median,
|
|
73
|
+
meanToMedian: median > 0 ? avgWin / median : Infinity,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { rtp, segments: segs };
|
|
78
|
+
}
|
package/weights/solve.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/* 权重求解器。
|
|
2
|
+
*
|
|
3
|
+
* 问题:给定每局的赔付,求每局被抽中的概率 w,使得
|
|
4
|
+
* - 总 RTP 等于目标
|
|
5
|
+
* - 每个 segment(爆奖 / 免费游戏 / 零赢 ...)的命中率和 RTP 贡献等于目标
|
|
6
|
+
* - w ≥ 0
|
|
7
|
+
* 全是线性等式约束,可行域是个多面体,里面有无穷多组解。
|
|
8
|
+
*
|
|
9
|
+
* 选哪一组?取对自然频率 prior 的 KL 散度最小的那组——即在满足商业目标的前提下,
|
|
10
|
+
* 对游戏本来的样子扭曲最小。这个目标下最优解落在指数族上:
|
|
11
|
+
*
|
|
12
|
+
* w_g = prior_g · exp(Σ_k θ_k A_kg)
|
|
13
|
+
*
|
|
14
|
+
* 于是未知数从「每组一个权重」(几千个)塌缩成「每条约束一个 θ」(十来个),
|
|
15
|
+
* 且非负性由 exp 白送。对偶问题
|
|
16
|
+
*
|
|
17
|
+
* F(θ) = Σ_g prior_g exp(Aθ)_g − b·θ
|
|
18
|
+
*
|
|
19
|
+
* 是凸的,牛顿法几十步收敛。梯度正好是约束残差,Hessian 正好是 A 在 w 下的二阶矩。
|
|
20
|
+
*
|
|
21
|
+
* 这就是为什么这里不需要遗传算法,也不需要 Rust。 */
|
|
22
|
+
|
|
23
|
+
import { reduceRows, solveSpd } from './linalg.ts';
|
|
24
|
+
import type { Constraint, Problem, SolveOptions, SolveResult } from './types.ts';
|
|
25
|
+
|
|
26
|
+
export function buildConstraints(problem: Problem): {
|
|
27
|
+
all: Constraint[];
|
|
28
|
+
infeasible: string | null;
|
|
29
|
+
} {
|
|
30
|
+
const { groups, segments } = problem;
|
|
31
|
+
const n = groups.length;
|
|
32
|
+
const bet = problem.betCost ?? 1;
|
|
33
|
+
const all: Constraint[] = [];
|
|
34
|
+
|
|
35
|
+
for (const seg of segments) {
|
|
36
|
+
if (seg.mass !== undefined) {
|
|
37
|
+
const coef = new Float64Array(n);
|
|
38
|
+
for (const i of seg.members) coef[i] = 1;
|
|
39
|
+
all.push({ label: `${seg.name}.mass`, coef, target: seg.mass });
|
|
40
|
+
}
|
|
41
|
+
if (seg.rtp !== undefined) {
|
|
42
|
+
const coef = new Float64Array(n);
|
|
43
|
+
for (const i of seg.members) coef[i] = groups[i]!.payout / bet;
|
|
44
|
+
all.push({ label: `${seg.name}.rtp`, coef, target: seg.rtp });
|
|
45
|
+
}
|
|
46
|
+
if (seg.median !== undefined) {
|
|
47
|
+
if (seg.mass === undefined) {
|
|
48
|
+
return { all, infeasible: `段 ${seg.name} 指定了 median 但没有 mass,中位数无从定义` };
|
|
49
|
+
}
|
|
50
|
+
const coef = new Float64Array(n);
|
|
51
|
+
for (const i of seg.members) if (groups[i]!.payout <= seg.median) coef[i] = 1;
|
|
52
|
+
all.push({ label: `${seg.name}.median`, coef, target: seg.mass / 2 });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 恒加 Σw=1。若各段质量已铺满全集,它会被判为冗余而丢弃;
|
|
57
|
+
// 若各段质量加起来不等于 1,它会被判为矛盾——这正是想要的诊断
|
|
58
|
+
all.push({ label: 'total.mass', coef: new Float64Array(n).fill(1), target: 1 });
|
|
59
|
+
if (problem.totalRtp !== undefined) {
|
|
60
|
+
const coef = new Float64Array(n);
|
|
61
|
+
for (let i = 0; i < n; i++) coef[i] = groups[i]!.payout / bet;
|
|
62
|
+
all.push({ label: 'total.rtp', coef, target: problem.totalRtp });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 段内平均赔付必须落在该段赔付的取值范围内,否则 w≥0 下无解
|
|
66
|
+
for (const seg of segments) {
|
|
67
|
+
if (seg.mass === undefined || seg.rtp === undefined || seg.mass <= 0) continue;
|
|
68
|
+
let lo = Infinity;
|
|
69
|
+
let hi = -Infinity;
|
|
70
|
+
for (const i of seg.members) {
|
|
71
|
+
const p = groups[i]!.payout;
|
|
72
|
+
if (p < lo) lo = p;
|
|
73
|
+
if (p > hi) hi = p;
|
|
74
|
+
}
|
|
75
|
+
if (seg.members.length === 0) {
|
|
76
|
+
return { all, infeasible: `段 ${seg.name} 没有任何样本` };
|
|
77
|
+
}
|
|
78
|
+
const want = (seg.rtp / seg.mass) * bet;
|
|
79
|
+
if (want < lo - 1e-9 || want > hi + 1e-9) {
|
|
80
|
+
return {
|
|
81
|
+
all,
|
|
82
|
+
infeasible:
|
|
83
|
+
`段 ${seg.name} 要求平均赔付 ${want.toFixed(4)},` +
|
|
84
|
+
`但样本赔付范围只有 [${lo}, ${hi}]`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { all, infeasible: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function solve(problem: Problem, options: SolveOptions = {}): SolveResult {
|
|
93
|
+
const started = performance.now();
|
|
94
|
+
const maxIter = options.maxIter ?? 200;
|
|
95
|
+
// 混合判据:|r| ≤ atol + rtol·|b|。
|
|
96
|
+
// 纯相对判据在这里行不通——wincap 这类约束目标值小到 2e-7,而残差是把几千个
|
|
97
|
+
// 量级 1/n 的数加起来算的,绝对误差底噪就有 1e-13 上下,除出来永远不达标。
|
|
98
|
+
const rtol = options.tol ?? 1e-10;
|
|
99
|
+
const atol = options.atol ?? 1e-12;
|
|
100
|
+
|
|
101
|
+
const n = problem.groups.length;
|
|
102
|
+
if (n === 0) return { ok: false, reason: 'empty', detail: '没有任何 group' };
|
|
103
|
+
|
|
104
|
+
const prior = new Float64Array(n);
|
|
105
|
+
let priorSum = 0;
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
const p = problem.groups[i]!.prior;
|
|
108
|
+
if (!(p > 0)) return { ok: false, reason: 'bad-prior', detail: `group ${i} 的 prior 必须为正` };
|
|
109
|
+
prior[i] = p;
|
|
110
|
+
priorSum += p;
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < n; i++) prior[i] = prior[i]! / priorSum;
|
|
113
|
+
|
|
114
|
+
const { all, infeasible } = buildConstraints(problem);
|
|
115
|
+
if (infeasible) return { ok: false, reason: 'infeasible', detail: infeasible };
|
|
116
|
+
|
|
117
|
+
const reduction = reduceRows(
|
|
118
|
+
all.map((c) => c.coef),
|
|
119
|
+
all.map((c) => c.target),
|
|
120
|
+
);
|
|
121
|
+
if (reduction.inconsistent.length > 0) {
|
|
122
|
+
const names = reduction.inconsistent.map((r) => all[r]!.label);
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: 'inconsistent',
|
|
126
|
+
detail: `约束互相矛盾:${names.join(', ')} 与前面的约束无法同时满足`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const active = reduction.keep.map((r) => all[r]!);
|
|
131
|
+
const m = active.length;
|
|
132
|
+
const theta = new Float64Array(m);
|
|
133
|
+
const w = new Float64Array(n);
|
|
134
|
+
const resid = new Float64Array(m);
|
|
135
|
+
const hess = new Float64Array(m * m);
|
|
136
|
+
|
|
137
|
+
const evalW = (th: Float64Array, out: Float64Array): number => {
|
|
138
|
+
let sum = 0;
|
|
139
|
+
for (let g = 0; g < n; g++) {
|
|
140
|
+
let dot = 0;
|
|
141
|
+
for (let k = 0; k < m; k++) dot += th[k]! * active[k]!.coef[g]!;
|
|
142
|
+
const v = prior[g]! * Math.exp(dot);
|
|
143
|
+
out[g] = v;
|
|
144
|
+
sum += v;
|
|
145
|
+
}
|
|
146
|
+
return sum;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// 对偶目标,仅用于线搜索判定下降
|
|
150
|
+
const dual = (th: Float64Array): number => {
|
|
151
|
+
const sum = evalW(th, w);
|
|
152
|
+
let lin = 0;
|
|
153
|
+
for (let k = 0; k < m; k++) lin += th[k]! * active[k]!.target;
|
|
154
|
+
return sum - lin;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
let iterations = 0;
|
|
158
|
+
let residual = Infinity;
|
|
159
|
+
let worstRelative = Infinity;
|
|
160
|
+
let fCur = dual(theta);
|
|
161
|
+
// 触到浮点底噪后残差就不再下降,继续迭代只是空转
|
|
162
|
+
let prevResidual = Infinity;
|
|
163
|
+
let stalled = 0;
|
|
164
|
+
|
|
165
|
+
for (; iterations < maxIter; iterations++) {
|
|
166
|
+
evalW(theta, w);
|
|
167
|
+
|
|
168
|
+
residual = 0;
|
|
169
|
+
worstRelative = 0;
|
|
170
|
+
for (let k = 0; k < m; k++) {
|
|
171
|
+
const coef = active[k]!.coef;
|
|
172
|
+
let s = 0;
|
|
173
|
+
for (let g = 0; g < n; g++) s += coef[g]! * w[g]!;
|
|
174
|
+
resid[k] = s - active[k]!.target;
|
|
175
|
+
const limit = atol + rtol * Math.abs(active[k]!.target);
|
|
176
|
+
residual = Math.max(residual, Math.abs(resid[k]!) / limit);
|
|
177
|
+
worstRelative = Math.max(
|
|
178
|
+
worstRelative,
|
|
179
|
+
Math.abs(resid[k]!) / Math.max(atol, Math.abs(active[k]!.target)),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (residual <= 1) break;
|
|
183
|
+
stalled = residual > prevResidual * 0.9 ? stalled + 1 : 0;
|
|
184
|
+
prevResidual = residual;
|
|
185
|
+
if (stalled >= 3) break;
|
|
186
|
+
|
|
187
|
+
for (let j = 0; j < m; j++) {
|
|
188
|
+
const cj = active[j]!.coef;
|
|
189
|
+
for (let k = j; k < m; k++) {
|
|
190
|
+
const ck = active[k]!.coef;
|
|
191
|
+
let s = 0;
|
|
192
|
+
for (let g = 0; g < n; g++) s += cj[g]! * ck[g]! * w[g]!;
|
|
193
|
+
hess[j * m + k] = s;
|
|
194
|
+
hess[k * m + j] = s;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const step = solveSpd(hess, m, resid);
|
|
199
|
+
if (!step) {
|
|
200
|
+
return { ok: false, reason: 'singular', detail: `第 ${iterations} 步 Hessian 无法分解` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let slope = 0;
|
|
204
|
+
for (let k = 0; k < m; k++) slope += resid[k]! * step[k]!;
|
|
205
|
+
|
|
206
|
+
let t = 1;
|
|
207
|
+
let accepted = false;
|
|
208
|
+
const trial = new Float64Array(m);
|
|
209
|
+
for (let ls = 0; ls < 60; ls++) {
|
|
210
|
+
for (let k = 0; k < m; k++) trial[k] = theta[k]! - t * step[k]!;
|
|
211
|
+
const f = dual(trial);
|
|
212
|
+
if (Number.isFinite(f) && f <= fCur - 1e-4 * t * slope) {
|
|
213
|
+
theta.set(trial);
|
|
214
|
+
fCur = f;
|
|
215
|
+
accepted = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
t *= 0.5;
|
|
219
|
+
}
|
|
220
|
+
if (!accepted) break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const total = evalW(theta, w);
|
|
224
|
+
for (let g = 0; g < n; g++) w[g] = w[g]! / total;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
weights: w,
|
|
229
|
+
iterations,
|
|
230
|
+
residual: worstRelative,
|
|
231
|
+
theta,
|
|
232
|
+
constraints: active,
|
|
233
|
+
dropped: reduction.redundant.map((r) => all[r]!.label),
|
|
234
|
+
millis: performance.now() - started,
|
|
235
|
+
};
|
|
236
|
+
}
|
package/weights/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* 权重求解的输入输出契约。
|
|
2
|
+
|
|
3
|
+
一个 group 是「赔付相同、所属 segment 相同」的一组模拟局。它们在约束里
|
|
4
|
+
完全等价,所以按组求权重,组内再按 prior 摊开——变量数从几十万降到几千。 */
|
|
5
|
+
|
|
6
|
+
export interface Group {
|
|
7
|
+
/** 赔付倍数(相对单次下注) */
|
|
8
|
+
payout: number;
|
|
9
|
+
/** 先验权重,未归一化即可。来自自然探测频率,通常就是组内样本数 */
|
|
10
|
+
prior: number;
|
|
11
|
+
/** 组内的模拟局 id,仅用于最终摊开 */
|
|
12
|
+
ids?: readonly number[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Segment {
|
|
16
|
+
name: string;
|
|
17
|
+
/** groups 数组的下标 */
|
|
18
|
+
members: readonly number[];
|
|
19
|
+
/** 该段被抽中的总概率,即 1/命中率。不给则由其余约束挤出 */
|
|
20
|
+
mass?: number;
|
|
21
|
+
/** 该段对总 RTP 的贡献,即 Σ w·payout */
|
|
22
|
+
rtp?: number;
|
|
23
|
+
/**
|
|
24
|
+
* 段内赔付中位数的目标值,用来控制波动率。
|
|
25
|
+
* 均值已被 mass/rtp 钉死,所以定了中位数就等于定了 均值/中位数 比。
|
|
26
|
+
* 取值必须是该段实际出现过的赔付之一,否则约束无法精确满足。
|
|
27
|
+
*/
|
|
28
|
+
median?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Problem {
|
|
32
|
+
groups: readonly Group[];
|
|
33
|
+
segments: readonly Segment[];
|
|
34
|
+
/** 全局 RTP。当各段 rtp 已覆盖全部 group 时可省略 */
|
|
35
|
+
totalRtp?: number;
|
|
36
|
+
/** 单注成本。payout 是相对单位下注的倍数,RTP = Σ w·payout / betCost */
|
|
37
|
+
betCost?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Constraint {
|
|
41
|
+
label: string;
|
|
42
|
+
coef: Float64Array;
|
|
43
|
+
target: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SolveOptions {
|
|
47
|
+
maxIter?: number;
|
|
48
|
+
/** 约束残差的相对容差 */
|
|
49
|
+
tol?: number;
|
|
50
|
+
/** 约束残差的绝对容差。目标值极小的约束靠它兜底 */
|
|
51
|
+
atol?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SolveResult =
|
|
55
|
+
| {
|
|
56
|
+
ok: true;
|
|
57
|
+
/** 每个 group 的概率,Σ = 1 */
|
|
58
|
+
weights: Float64Array;
|
|
59
|
+
iterations: number;
|
|
60
|
+
/** 收敛时的最大约束残差 */
|
|
61
|
+
residual: number;
|
|
62
|
+
/** 对偶变量,每个生效约束一个 */
|
|
63
|
+
theta: Float64Array;
|
|
64
|
+
constraints: readonly Constraint[];
|
|
65
|
+
/** 被判定为冗余而丢弃的约束 */
|
|
66
|
+
dropped: readonly string[];
|
|
67
|
+
millis: number;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
ok: false;
|
|
71
|
+
reason: string;
|
|
72
|
+
detail: string;
|
|
73
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* 波动率控制。
|
|
2
|
+
*
|
|
3
|
+
* 段内平均赢额已经被 mass 和 rtp 钉死,所以「均值/中位数」这个波动率指标
|
|
4
|
+
* 等价于直接指定中位数。想要 m2m 落在 [lo, hi],中位数就必须落在
|
|
5
|
+
* [avgWin/hi, avgWin/lo]——一维区间,不需要搜索,直接挑。
|
|
6
|
+
*
|
|
7
|
+
* 中位数约束本身是线性的(Σ_{payout ≤ m} w = mass/2),所以加上它
|
|
8
|
+
* 问题依然是凸的。这是相对遗传算法「随机杂交直到 m2m 碰巧落在区间内」
|
|
9
|
+
* 的关键差别:这里是解出来的,不是撞出来的。 */
|
|
10
|
+
|
|
11
|
+
import type { Group, Segment } from './types.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 在段内实际出现过的赔付值里,挑一个能让 m2m 落进 [lo, hi] 的中位数。
|
|
15
|
+
* 挑不到就返回 null——这说明样本的赔付档位太稀疏,撑不起要求的波动率。
|
|
16
|
+
*/
|
|
17
|
+
export function pickMedian(
|
|
18
|
+
groups: readonly Group[],
|
|
19
|
+
seg: Segment,
|
|
20
|
+
betCost: number,
|
|
21
|
+
range: readonly [number, number],
|
|
22
|
+
): number | null {
|
|
23
|
+
if (seg.mass === undefined || seg.rtp === undefined || seg.mass <= 0) return null;
|
|
24
|
+
const avgWin = (seg.rtp / seg.mass) * betCost;
|
|
25
|
+
const [lo, hi] = range;
|
|
26
|
+
const want: [number, number] = [avgWin / hi, avgWin / lo];
|
|
27
|
+
|
|
28
|
+
const candidates = [...new Set(seg.members.map((i) => groups[i]!.payout))]
|
|
29
|
+
.filter((p) => p > 0 && p >= want[0] && p <= want[1])
|
|
30
|
+
.sort((a, b) => a - b);
|
|
31
|
+
if (candidates.length === 0) return null;
|
|
32
|
+
|
|
33
|
+
// 往区间几何中心靠,给数值求解留余量
|
|
34
|
+
const center = Math.sqrt(want[0] * want[1]);
|
|
35
|
+
let best = candidates[0]!;
|
|
36
|
+
for (const c of candidates) {
|
|
37
|
+
if (Math.abs(Math.log(c / center)) < Math.abs(Math.log(best / center))) best = c;
|
|
38
|
+
}
|
|
39
|
+
return best;
|
|
40
|
+
}
|