hbengine 0.2.3 → 0.4.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/engine-cli/hbengine.mjs +106 -36
- package/engine-cli/outage.mjs +9 -0
- package/engine-cli/templates.mjs +1 -0
- package/engine-cli/upload-errors.mjs +18 -0
- package/engine-cli/upload.mjs +172 -0
- package/engine-conformance/analyse.mjs +155 -15
- package/engine-conformance/bundle.mjs +23 -5
- package/engine-conformance/census-worker.mjs +9 -0
- package/engine-conformance/chrome-flags.mjs +19 -0
- package/engine-conformance/diagnostic-text.mjs +19 -0
- package/engine-conformance/diagnostics.mjs +52 -0
- package/engine-conformance/focus.mjs +42 -0
- package/engine-conformance/hints.mjs +86 -0
- package/engine-conformance/js-releases.json +2365 -0
- package/engine-conformance/js-releases.mjs +75 -0
- package/engine-conformance/run.mjs +226 -39
- package/engine-conformance/scan.mjs +71 -18
- package/engine-conformance/sound.mjs +45 -0
- package/engine-conformance/travel.mjs +132 -6
- package/engine-examples/keno/math-kit/pack.mjs +87 -10
- package/engine-examples/lines/math-kit/pack.mjs +87 -10
- package/engine-examples/plinko/math-kit/pack.mjs +87 -10
- package/engine-examples/plinko-jar/AGENTS.md +3 -0
- package/engine-examples/plinko-jar/README.md +16 -0
- package/engine-examples/plinko-jar/bundle/assets.json +1 -0
- package/engine-examples/plinko-jar/bundle/game.css +10 -0
- package/engine-examples/plinko-jar/bundle/game.js +113 -0
- package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
- package/engine-examples/plinko-jar/bundle/index.html +16 -0
- package/engine-examples/plinko-jar/bundle/model.json +1 -0
- package/engine-examples/plinko-jar/math/gen.mjs +47 -0
- package/engine-examples/plinko-jar/math/manifest.json +1 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
- package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
- package/engine-examples/plinko-jar/math-kit/package.json +8 -0
- package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
- package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
- package/engine-examples/tumble/math-kit/pack.mjs +87 -10
- package/engine-math/pack.mjs +87 -10
- package/engine-mcp/server.mjs +30 -14
- package/engine-sdk/GUIDE.md +234 -32
- package/engine-sdk/bet-field.js +16 -3
- package/engine-sdk/bet.js +88 -0
- package/engine-sdk/frames.js +75 -0
- package/engine-sdk/progress.js +128 -0
- package/engine-sdk/protocol.js +3 -0
- package/engine-sdk/sdk.d.ts +35 -0
- package/engine-sdk/sdk.js +9 -0
- package/engine-sdk/ui.js +3 -0
- package/engine-shell/core/rgs.js +1 -0
- package/engine-shell/core/router.js +46 -54
- package/engine-shell/host/audio-session.js +24 -0
- package/engine-shell/host/boot.js +5 -2
- package/engine-shell/host/engine-v1.js +17 -4
- package/engine-shell/host/fair.js +27 -0
- package/engine-shell/host/index.html +44 -9
- package/engine-shell/host/intro.js +99 -0
- package/engine-shell/host/page.js +25 -5
- package/engine-shell/host/sandbox-panel.js +23 -3
- package/engine-shell/host/sandbox-scenarios.js +23 -11
- package/engine-shell/host/site-bridge.js +4 -2
- package/engine-shell/verifiers/engine-verify.js +12 -3
- package/package.json +2 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maths kit: a game's outcomes as weighted rows, its RTP exactly, and the
|
|
3
|
+
* pack the engine verifies (SPEC §1). A row is { weight, data, pay } where
|
|
4
|
+
* pay is { betId: multBp } (10000 = 1×, the total stake back) and data is
|
|
5
|
+
* what the bundle receives to show the round.
|
|
6
|
+
*
|
|
7
|
+
* import { writePack, fit } from './pack.mjs';
|
|
8
|
+
* const rows = fit(simulated, 9600); // exact 96.00% (balance() when no row pays nothing)
|
|
9
|
+
* writePack('math', { game: 'my-slot', rtpBp: 9600, modes: [{ id: 'base', rows, bets: ['spin'] }] });
|
|
10
|
+
*
|
|
11
|
+
* Every number here is an integer: weights are BigInt, RTP is a fraction.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const MAX_SUM = 1n << 63n;
|
|
17
|
+
|
|
18
|
+
// A small seeded generator for building tables (never for play: the engine draws).
|
|
19
|
+
export function rng(seed) {
|
|
20
|
+
let a = seed >>> 0;
|
|
21
|
+
const next = () => {
|
|
22
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
23
|
+
let t = a;
|
|
24
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
25
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
26
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
27
|
+
};
|
|
28
|
+
next.int = (n) => Math.floor(next() * n);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function binomial(n, k) {
|
|
33
|
+
let r = 1n;
|
|
34
|
+
for (let i = 1n; i <= BigInt(k); i++) r = (r * BigInt(n - k) + r * i) / i;
|
|
35
|
+
return r;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const gcd = (a, b) => { while (b) [a, b] = [b, a % b]; return a < 0n ? -a : a; };
|
|
39
|
+
|
|
40
|
+
/** RTP of one bet over rows, exactly: { num, den } in basis points, and a 4-decimal string. */
|
|
41
|
+
export function rtp(rows, bet) {
|
|
42
|
+
let num = 0n, den = 0n;
|
|
43
|
+
for (const r of rows) { den += r.weight; num += r.weight * BigInt(r.pay[bet] || 0); }
|
|
44
|
+
return { num, den, bp: bp4(num, den) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const bp4 = (num, den) => {
|
|
48
|
+
const x = (num * 10000n) / den; // bp × 10^4, truncated as the verifier reports it
|
|
49
|
+
return `${x / 10000n}.${String(x % 10000n).padStart(4, '0')}`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Weights so that one bet's RTP is exactly targetBp: the paying rows keep
|
|
54
|
+
* their proportions (all multiplied by one integer), the rows that pay
|
|
55
|
+
* nothing share what is left in their own proportions. Needs rows that pay
|
|
56
|
+
* nothing, and paying rows that alone return more than the target.
|
|
57
|
+
*/
|
|
58
|
+
export function fit(rows, targetBp, bet = Object.keys(rows[0].pay)[0]) {
|
|
59
|
+
const T = BigInt(targetBp);
|
|
60
|
+
const wins = rows.filter((r) => r.pay[bet] > 0), zeros = rows.filter((r) => !(r.pay[bet] > 0));
|
|
61
|
+
if (!zeros.length) throw new Error('fit: no row pays nothing');
|
|
62
|
+
const S = wins.reduce((s, r) => s + r.weight * (BigInt(r.pay[bet]) - T), 0n); // what the wins return above T
|
|
63
|
+
if (S <= 0n) throw new Error(`fit: the paying rows alone return less than ${targetBp / 100}%`);
|
|
64
|
+
const k = T / gcd(S, T); // so that k·S is a multiple of T
|
|
65
|
+
const Z = (k * S) / T; // the weight the zero rows share
|
|
66
|
+
const zw = zeros.reduce((s, r) => s + r.weight, 0n);
|
|
67
|
+
let given = 0n;
|
|
68
|
+
const out = rows.map((r) => {
|
|
69
|
+
if (r.pay[bet] > 0) return { ...r, weight: r.weight * k };
|
|
70
|
+
const w = (Z * r.weight) / zw;
|
|
71
|
+
given += w;
|
|
72
|
+
return { ...r, weight: w };
|
|
73
|
+
});
|
|
74
|
+
// The rounding remainder goes to the first zero row; a zero weight is dropped.
|
|
75
|
+
const first = out.find((r) => !(r.pay[bet] > 0));
|
|
76
|
+
first.weight += Z - given;
|
|
77
|
+
const kept = out.filter((r) => r.weight > 0n);
|
|
78
|
+
const check = rtp(kept, bet);
|
|
79
|
+
if (check.num !== T * check.den) throw new Error('fit: not exact (a bug)');
|
|
80
|
+
return kept;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Weights so that one bet's RTP is exactly targetBp when every row pays
|
|
85
|
+
* something (a bonus buy, plinko): rows paying more than the target are
|
|
86
|
+
* all multiplied by one integer, rows paying less by another, so each
|
|
87
|
+
* group keeps its own proportions and the two balance at the target.
|
|
88
|
+
*/
|
|
89
|
+
export function balance(rows, targetBp, bet = Object.keys(rows[0].pay)[0]) {
|
|
90
|
+
const T = BigInt(targetBp);
|
|
91
|
+
const over = rows.reduce((s, r) => s + (BigInt(r.pay[bet] || 0) > T ? r.weight * (BigInt(r.pay[bet]) - T) : 0n), 0n);
|
|
92
|
+
const under = rows.reduce((s, r) => s + (BigInt(r.pay[bet] || 0) < T ? r.weight * (T - BigInt(r.pay[bet] || 0)) : 0n), 0n);
|
|
93
|
+
if (!over || !under) throw new Error('balance: needs rows on both sides of the target');
|
|
94
|
+
const g = gcd(over, under);
|
|
95
|
+
const out = rows.map((r) => ({ ...r, weight: r.weight * (BigInt(r.pay[bet] || 0) > T ? under / g : BigInt(r.pay[bet] || 0) < T ? over / g : 1n) }));
|
|
96
|
+
const check = rtp(out, bet);
|
|
97
|
+
if (check.num !== T * check.den) throw new Error('balance: not exact (a bug)');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Rows with the same data are one row with the sum of their weights. */
|
|
102
|
+
export function merge(rows) {
|
|
103
|
+
const by = new Map();
|
|
104
|
+
for (const r of rows) {
|
|
105
|
+
const key = JSON.stringify(r.data);
|
|
106
|
+
const x = by.get(key);
|
|
107
|
+
if (x) x.weight += r.weight;
|
|
108
|
+
else by.set(key, { ...r, weight: r.weight });
|
|
109
|
+
}
|
|
110
|
+
return [...by.values()];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Inclusive [lo, hi] ranges of sorted indexes.
|
|
114
|
+
function ranges(ids) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const i of ids) {
|
|
117
|
+
const last = out.at(-1);
|
|
118
|
+
if (last && last[1] === i - 1) last[1] = i;
|
|
119
|
+
else out.push([i, i]);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The verifier's bound on one bet's pay tiers (housegames-backend mathpack.MaxTiers).
|
|
125
|
+
export const MAX_TIERS = 16384;
|
|
126
|
+
|
|
127
|
+
// The most a pack's maths may be: the files in math/ together, as their bytes
|
|
128
|
+
// on disk. Housebets' limit, not a verifier's: the live engine holds a game's
|
|
129
|
+
// tables in memory, and the upload's checks slow as they grow. Checked here,
|
|
130
|
+
// by hbengine before it sends a pack, and by the upload's own check
|
|
131
|
+
// (pipeline/job), so no way in passes a bigger one. The Go side's figure is
|
|
132
|
+
// internal/archive MaxMathBytes (pack.test.mjs holds the two together).
|
|
133
|
+
export const MAX_MATH_BYTES = 200 * 1024 * 1024;
|
|
134
|
+
|
|
135
|
+
// The refusal of maths over MAX_MATH_BYTES, in the same words wherever it is
|
|
136
|
+
// checked; the size is rounded up, so maths just over never reads as 200 MB.
|
|
137
|
+
export const mathTooLarge = (bytes) => `The maths is ${Math.ceil((bytes * 10) / 1048576) / 10} MB; a pack's maths may be at most ${MAX_MATH_BYTES / 1048576} MB.`;
|
|
138
|
+
|
|
139
|
+
// The bytes of the maths in dir: its files together (math/ holds nothing below them).
|
|
140
|
+
export const mathBytes = (dir) => readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a meter adds to the RTP of every bet of its modes, exactly (GUIDE:
|
|
144
|
+
* Meters): E[award] ÷ fill, in basis points of the stake. `rows` are the
|
|
145
|
+
* award's outcomes, `pay` the multiplier of the reference in bp.
|
|
146
|
+
*/
|
|
147
|
+
export function meterRtp({ fill, rows }) {
|
|
148
|
+
const den = rows.reduce((s, r) => s + r.weight, 0n) * BigInt(fill);
|
|
149
|
+
const num = rows.reduce((s, r) => s + r.weight * BigInt(r.pay || 0), 0n);
|
|
150
|
+
return { num, den, bp: bp4(num, den) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Writes manifest.json and one table per mode. A mode is { id, label?, rows,
|
|
155
|
+
* bets: [id | {id, label}], meter? }; its rows are sorted by what the first bet pays,
|
|
156
|
+
* so each pay tier is one range. Prints every bet's exact RTP, hit rate and
|
|
157
|
+
* largest multiplier; returns them.
|
|
158
|
+
*
|
|
159
|
+
* meters: [{ id, label?, fill, rows }] — progress that pays (GUIDE: Meters).
|
|
160
|
+
* A meter's rows are its award's outcomes, each { weight, pay, data? } with
|
|
161
|
+
* `pay` in bp of the meter's reference (20000 = twice the reference); a mode
|
|
162
|
+
* names the meter its rounds fill with `meter: id`. Every bet of such a mode
|
|
163
|
+
* reports base + meter RTP, and the base must clear 90% on its own.
|
|
164
|
+
*/
|
|
165
|
+
export function writePack(dir, { game, rtpBp, modes, meters = [] }) {
|
|
166
|
+
if (!Array.isArray(modes) || modes.length < 1 || modes.length > 64) throw new Error('a pack needs 1 to 64 modes');
|
|
167
|
+
for (const mode of modes) {
|
|
168
|
+
if (!Array.isArray(mode.bets) || mode.bets.length < 1 || mode.bets.length > 256) throw new Error(`${mode.id}: 1 to 256 bets per mode`);
|
|
169
|
+
}
|
|
170
|
+
const manifest = { format: 'house-engine-math/1', game, rtpBp, modes: [] };
|
|
171
|
+
const report = [];
|
|
172
|
+
const tables = new Map();
|
|
173
|
+
for (const mode of modes) {
|
|
174
|
+
const bets = mode.bets.map((b) => (typeof b === 'string' ? { id: b } : b));
|
|
175
|
+
const first = bets[0].id;
|
|
176
|
+
const rows = [...mode.rows].sort((a, b) => (a.pay[first] || 0) - (b.pay[first] || 0));
|
|
177
|
+
if (rows.length < 2 || rows.length > 1_000_000) throw new Error(`${mode.id}: 2 to 1,000,000 rows, not ${rows.length}`);
|
|
178
|
+
const sum = rows.reduce((s, r) => s + r.weight, 0n);
|
|
179
|
+
if (sum > MAX_SUM) throw new Error(`${mode.id}: weights sum to more than 2^63`);
|
|
180
|
+
const lines = rows.map((r) => {
|
|
181
|
+
if (r.weight <= 0n) throw new Error(`${mode.id}: a weight is not positive`);
|
|
182
|
+
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
183
|
+
if (Buffer.byteLength(json) > 4096) throw new Error(`${mode.id}: a row's data is over 4096 UTF-8 bytes`);
|
|
184
|
+
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
185
|
+
});
|
|
186
|
+
const table = `${game}-${mode.id}.tsv`;
|
|
187
|
+
tables.set(table, lines.join('\n') + '\n');
|
|
188
|
+
manifest.modes.push({
|
|
189
|
+
id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table, ...(mode.meter ? { meter: mode.meter } : {}),
|
|
190
|
+
bets: bets.map((b) => {
|
|
191
|
+
const tiers = new Map();
|
|
192
|
+
rows.forEach((r, i) => { const m = r.pay[b.id] || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
193
|
+
const { num, den, bp } = rtp(rows, b.id);
|
|
194
|
+
const hit = rows.reduce((s, r) => s + (r.pay[b.id] > 0 ? r.weight : 0n), 0n);
|
|
195
|
+
// The engine's verifier refuses a bet with more pay tiers than this (one tier per distinct
|
|
196
|
+
// multiplier): refused here, at the build, not at the upload (mathpack.MaxTiers).
|
|
197
|
+
if (tiers.size > MAX_TIERS) throw new Error(`${mode.id}: bet "${b.id}": ${tiers.size} distinct payouts (tiers), at most ${MAX_TIERS}: round the multipliers to fewer values`);
|
|
198
|
+
const max = Math.max(...tiers.keys());
|
|
199
|
+
const entry = { mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n };
|
|
200
|
+
if (mode.meter) {
|
|
201
|
+
const mt = meters.find((x) => x.id === mode.meter);
|
|
202
|
+
if (!mt) throw new Error(`${mode.id}: meter "${mode.meter}" is not in meters`);
|
|
203
|
+
const add = meterRtp(mt);
|
|
204
|
+
// base + E[award]/fill, as one exact fraction
|
|
205
|
+
const tn = num * add.den + add.num * den, td = den * add.den;
|
|
206
|
+
Object.assign(entry, { baseRtp: bp, meter: mode.meter, rtp: bp4(tn, td), exact: tn % td === 0n });
|
|
207
|
+
}
|
|
208
|
+
report.push(entry);
|
|
209
|
+
return { id: b.id, ...(b.label ? { label: b.label } : {}), tiers: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) };
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
for (const mt of meters) {
|
|
214
|
+
if (!Number.isSafeInteger(mt.fill) || mt.fill < 2 || mt.fill > 1_000_000) throw new Error(`meter ${mt.id}: fill must be 2 to 1,000,000`);
|
|
215
|
+
if (!modes.some((m) => m.meter === mt.id)) throw new Error(`meter ${mt.id}: no mode fills it`);
|
|
216
|
+
const rows = [...mt.rows].sort((a, b) => (a.pay || 0) - (b.pay || 0));
|
|
217
|
+
if (rows.length < 1) throw new Error(`meter ${mt.id}: an award needs rows`);
|
|
218
|
+
const table = `${game}-meter-${mt.id}.tsv`;
|
|
219
|
+
tables.set(table, rows.map((r) => {
|
|
220
|
+
if (r.weight <= 0n) throw new Error(`meter ${mt.id}: a weight is not positive`);
|
|
221
|
+
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
222
|
+
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
223
|
+
}).join('\n') + '\n');
|
|
224
|
+
const tiers = new Map();
|
|
225
|
+
rows.forEach((r, i) => { const m = r.pay || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
226
|
+
if (!tiers.size) throw new Error(`meter ${mt.id}: the award never pays`);
|
|
227
|
+
(manifest.meters ||= []).push({ id: mt.id, ...(mt.label ? { label: mt.label } : {}), fill: mt.fill, table,
|
|
228
|
+
award: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) });
|
|
229
|
+
const ev = meterRtp({ fill: 1, rows });
|
|
230
|
+
console.log(`meter ${mt.id}: fill ${mt.fill} award E ${bp4(ev.num, ev.den * 10000n)}× the reference adds ${meterRtp({ fill: mt.fill, rows }).bp} bp`);
|
|
231
|
+
}
|
|
232
|
+
tables.set('manifest.json', JSON.stringify(manifest) + '\n');
|
|
233
|
+
// The pack as it will be on disk, with what else is in dir already (the
|
|
234
|
+
// generator, often), measured before a file is written.
|
|
235
|
+
mkdirSync(dir, { recursive: true });
|
|
236
|
+
const others = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !tables.has(e.name));
|
|
237
|
+
const bytes = [...tables.values()].reduce((n, text) => n + Buffer.byteLength(text), 0) + others.reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
238
|
+
if (bytes > MAX_MATH_BYTES) throw new Error(mathTooLarge(bytes));
|
|
239
|
+
for (const [name, text] of tables) writeFileSync(join(dir, name), text);
|
|
240
|
+
for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp${r.meter ? ` (base ${r.baseRtp} + meter ${r.meter})` : ''} hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
|
|
241
|
+
return report;
|
|
242
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@housegames/engine-math",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "The maths kit: outcomes as weighted rows, exact RTP, an exact fit to a target, the pack the engine verifies; a video slot model (reels, lines, wilds, scatters, free spins) and a tumble model (scatter pays, tumbles, multipliers).",
|
|
7
|
+
"exports": { "./pack.mjs": "./pack.mjs", "./slot.mjs": "./slot.mjs", "./tumble.mjs": "./tumble.mjs" }
|
|
8
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A video slot for the maths kit: reels, paylines, a wild, a scatter that
|
|
3
|
+
* starts free spins (with a multiplier, retriggers), and a win cap. It plays
|
|
4
|
+
* rounds with a seeded generator to make the table's rows ("books"): every
|
|
5
|
+
* row is a whole round, base spin and free spins, with what it pays. fit()
|
|
6
|
+
* then makes the table's RTP exact; the table is the game.
|
|
7
|
+
*
|
|
8
|
+
* Pays are in basis points of the total stake (10000 = 1×) for 3, 4, 5 of a
|
|
9
|
+
* kind on a line, left to right; scatters pay anywhere, times the stake.
|
|
10
|
+
*
|
|
11
|
+
* A row's data, for the bundle: { b: spin, f: [spin…], x: free multiplier }
|
|
12
|
+
* where a spin is [stop0…stopN, winBp, lineMask, scatterCount].
|
|
13
|
+
*/
|
|
14
|
+
export function slot(cfg) {
|
|
15
|
+
const { reels, freeReels = reels, rows, lines, pays, wild, scatter, maxWinBp } = cfg;
|
|
16
|
+
const n = reels.length;
|
|
17
|
+
|
|
18
|
+
const grid = (strips, stops) => stops.map((s, r) => Array.from({ length: rows }, (_, y) => strips[r][(s + y) % strips[r].length]));
|
|
19
|
+
|
|
20
|
+
// One spin's wins: each line pays its best run from the left (wilds stand in).
|
|
21
|
+
function evaluate(strips, stops) {
|
|
22
|
+
const g = grid(strips, stops);
|
|
23
|
+
let win = 0, mask = 0;
|
|
24
|
+
lines.forEach((line, li) => {
|
|
25
|
+
const cells = line.map((y, r) => g[r][y]);
|
|
26
|
+
const sym = cells.find((c) => c !== wild) ?? wild;
|
|
27
|
+
if (sym === scatter.symbol) return;
|
|
28
|
+
let count = 0;
|
|
29
|
+
while (count < n && (cells[count] === sym || cells[count] === wild)) count++;
|
|
30
|
+
const pay = (pays[sym] || {})[count] || 0;
|
|
31
|
+
// A run of wilds alone pays as wilds when that is more.
|
|
32
|
+
let wc = 0;
|
|
33
|
+
while (wc < n && cells[wc] === wild) wc++;
|
|
34
|
+
const best = Math.max(pay, (pays[wild] || {})[wc] || 0);
|
|
35
|
+
if (best > 0) { win += best; mask |= 1 << li; }
|
|
36
|
+
});
|
|
37
|
+
const scatters = g.flat().filter((c) => c === scatter.symbol).length;
|
|
38
|
+
win += (scatter.pays[scatters] || 0);
|
|
39
|
+
return { win, mask, scatters };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const spin = (strips, next) => strips.map((s) => next.int(s.length));
|
|
43
|
+
|
|
44
|
+
/** One whole round: the row's data and what it pays (bp of the stake).
|
|
45
|
+
* A bought round is the free spins alone, as three scatters start them. */
|
|
46
|
+
function round(next, buy = false) {
|
|
47
|
+
const stops = buy ? null : spin(reels, next);
|
|
48
|
+
const base = buy ? { win: 0, scatters: 3 } : evaluate(reels, stops);
|
|
49
|
+
const data = buy ? { buy: 1 } : { b: [...stops, base.win, base.mask, base.scatters] };
|
|
50
|
+
let total = base.win;
|
|
51
|
+
let left = scatter.spins[base.scatters] || 0;
|
|
52
|
+
if (left) {
|
|
53
|
+
data.f = [];
|
|
54
|
+
data.x = scatter.multiplier;
|
|
55
|
+
while (left > 0 && data.f.length < scatter.cap) {
|
|
56
|
+
left--;
|
|
57
|
+
const s = spin(freeReels, next);
|
|
58
|
+
const e = evaluate(freeReels, s);
|
|
59
|
+
data.f.push([...s, e.win * scatter.multiplier, e.mask, e.scatters]);
|
|
60
|
+
total += e.win * scatter.multiplier;
|
|
61
|
+
left += scatter.retrigger[e.scatters] || 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (total > maxWinBp) { total = maxWinBp; data.cap = 1; }
|
|
65
|
+
return { data, pay: total };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { evaluate, round, grid };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Plays `count` rounds (bought ones with `buy`) and keeps them as rows: every paying round, and up to
|
|
73
|
+
* `losing` different losing rounds that share the weight of every losing
|
|
74
|
+
* round played (so the rows keep the played RTP and hit rate before fit).
|
|
75
|
+
*/
|
|
76
|
+
export function books(game, next, count, losing = 4000, buy = false) {
|
|
77
|
+
const wins = new Map(), lose = new Map();
|
|
78
|
+
let lost = 0n;
|
|
79
|
+
for (let i = 0; i < count; i++) {
|
|
80
|
+
const { data, pay } = game.round(next, buy);
|
|
81
|
+
if (pay <= 0) {
|
|
82
|
+
lost++;
|
|
83
|
+
if (lose.size < losing) lose.set(JSON.stringify(data), { weight: 0n, data, pay: { spin: 0 } });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const key = JSON.stringify(data);
|
|
87
|
+
const r = wins.get(key);
|
|
88
|
+
if (r) r.weight += 1n;
|
|
89
|
+
else wins.set(key, { weight: 1n, data, pay: { spin: pay } });
|
|
90
|
+
}
|
|
91
|
+
const zeros = [...lose.values()];
|
|
92
|
+
zeros.forEach((r, i) => { r.weight = lost / BigInt(zeros.length) + (BigInt(i) < lost % BigInt(zeros.length) ? 1n : 0n); });
|
|
93
|
+
return [...zeros.filter((r) => r.weight > 0n), ...wins.values()];
|
|
94
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tumbling, scatter-pays slot for the maths kit: a cols × rows grid, every
|
|
3
|
+
* cell drawn by symbol weight; a symbol pays when enough of it is anywhere
|
|
4
|
+
* on the grid; winners vanish, the rest fall, new symbols drop in, until
|
|
5
|
+
* nothing pays. Scatters start free spins, where multiplier symbols land
|
|
6
|
+
* and multiply the spin's win. Like slot.mjs, it plays whole rounds into
|
|
7
|
+
* rows; fit() then makes the RTP exact.
|
|
8
|
+
*
|
|
9
|
+
* Symbols are single characters. Pays are basis points of the stake by
|
|
10
|
+
* count: { A: [[8, 2000], [10, 5000], [12, 10000]] } pays the highest band
|
|
11
|
+
* the count reaches.
|
|
12
|
+
*
|
|
13
|
+
* A row's data, for the page:
|
|
14
|
+
* { b: spin, f: [spin…] } spin = [grid, [tumble…], multipliers?]
|
|
15
|
+
* grid the first grid, column by column, top to bottom ("ABCDE…")
|
|
16
|
+
* tumble [winners, winBp, fill0, fill1, …]: the symbols that paid, what
|
|
17
|
+
* they paid, and per column the new symbols that drop in (top first)
|
|
18
|
+
* multipliers free spins only: the values of the multiplier symbols on the
|
|
19
|
+
* last grid, summed onto the spin's win when it won
|
|
20
|
+
*/
|
|
21
|
+
export function tumble(cfg) {
|
|
22
|
+
const { cols, rows, weights, freeWeights = weights, pays, scatter, bomb, maxWinBp } = cfg;
|
|
23
|
+
const pick = (w, next) => {
|
|
24
|
+
const total = w.reduce((s, [, n]) => s + n, 0);
|
|
25
|
+
let x = next() * total;
|
|
26
|
+
for (const [s, n] of w) if ((x -= n) < 0) return s;
|
|
27
|
+
return w[w.length - 1][0];
|
|
28
|
+
};
|
|
29
|
+
const table = (o) => Object.entries(o);
|
|
30
|
+
const base = table(weights), free = table(freeWeights);
|
|
31
|
+
const counts = Object.keys(scatter.spins).map(Number), least = Math.min(...counts), most = Math.max(...counts);
|
|
32
|
+
|
|
33
|
+
// One spin: the grid, its tumbles, and (free spins) its multipliers.
|
|
34
|
+
function spin(next, inFree) {
|
|
35
|
+
const w = inFree ? free : base;
|
|
36
|
+
const draw = () => {
|
|
37
|
+
const s = pick(w, next);
|
|
38
|
+
return s === bomb?.symbol && !inFree ? pick(base.filter(([x]) => x !== bomb.symbol), next) : s;
|
|
39
|
+
};
|
|
40
|
+
let grid = Array.from({ length: cols }, () => Array.from({ length: rows }, draw));
|
|
41
|
+
const first = grid.map((c) => c.join('')).join('');
|
|
42
|
+
const tumbles = [];
|
|
43
|
+
let win = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const counts = {};
|
|
46
|
+
for (const c of grid) for (const s of c) counts[s] = (counts[s] || 0) + 1;
|
|
47
|
+
let paid = '', bp = 0;
|
|
48
|
+
for (const [s, bands] of Object.entries(pays)) {
|
|
49
|
+
const band = bands.filter(([n]) => counts[s] >= n).at(-1);
|
|
50
|
+
if (band) { paid += s; bp += band[1]; }
|
|
51
|
+
}
|
|
52
|
+
if (!paid) break;
|
|
53
|
+
win += bp;
|
|
54
|
+
const fills = grid.map((c) => {
|
|
55
|
+
const kept = c.filter((s) => !paid.includes(s));
|
|
56
|
+
const add = Array.from({ length: rows - kept.length }, draw);
|
|
57
|
+
c.splice(0, rows, ...add, ...kept); // new symbols on top
|
|
58
|
+
return add.join('');
|
|
59
|
+
});
|
|
60
|
+
tumbles.push([paid, bp, ...fills]);
|
|
61
|
+
}
|
|
62
|
+
const out = [first, tumbles];
|
|
63
|
+
const scatters = grid.flat().filter((s) => s === scatter.symbol).length; // on the last grid
|
|
64
|
+
let total = win;
|
|
65
|
+
if (inFree && bomb) {
|
|
66
|
+
const bombs = grid.flat().filter((s) => s === bomb.symbol).map(() => pick(bomb.values, next));
|
|
67
|
+
if (bombs.length) out.push(bombs);
|
|
68
|
+
if (win && bombs.length) total = win * bombs.reduce((a, b) => a + b, 0);
|
|
69
|
+
}
|
|
70
|
+
return { out, total, scatters };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One whole round: the row's data and what it pays (bp of the stake). */
|
|
74
|
+
function round(next) {
|
|
75
|
+
const b = spin(next, false);
|
|
76
|
+
const data = { b: b.out };
|
|
77
|
+
let total = b.total + (scatter.pays[Math.min(b.scatters, most)] || 0);
|
|
78
|
+
let left = b.scatters >= least ? scatter.spins[Math.min(b.scatters, most)] : 0;
|
|
79
|
+
if (left) {
|
|
80
|
+
data.f = [];
|
|
81
|
+
while (left > 0 && data.f.length < scatter.cap) {
|
|
82
|
+
left--;
|
|
83
|
+
const f = spin(next, true);
|
|
84
|
+
data.f.push(f.out);
|
|
85
|
+
total += f.total;
|
|
86
|
+
if (f.scatters >= scatter.retrigger.count) left += scatter.retrigger.spins;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (total > maxWinBp) { total = maxWinBp; data.cap = 1; }
|
|
90
|
+
return { data, pay: total };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { round };
|
|
94
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Every number here is an integer: weights are BigInt, RTP is a fraction.
|
|
12
12
|
*/
|
|
13
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
16
|
const MAX_SUM = 1n << 63n;
|
|
@@ -121,16 +121,55 @@ function ranges(ids) {
|
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// The verifier's bound on one bet's pay tiers (housegames-backend mathpack.MaxTiers).
|
|
125
|
+
export const MAX_TIERS = 16384;
|
|
126
|
+
|
|
127
|
+
// The most a pack's maths may be: the files in math/ together, as their bytes
|
|
128
|
+
// on disk. Housebets' limit, not a verifier's: the live engine holds a game's
|
|
129
|
+
// tables in memory, and the upload's checks slow as they grow. Checked here,
|
|
130
|
+
// by hbengine before it sends a pack, and by the upload's own check
|
|
131
|
+
// (pipeline/job), so no way in passes a bigger one. The Go side's figure is
|
|
132
|
+
// internal/archive MaxMathBytes (pack.test.mjs holds the two together).
|
|
133
|
+
export const MAX_MATH_BYTES = 200 * 1024 * 1024;
|
|
134
|
+
|
|
135
|
+
// The refusal of maths over MAX_MATH_BYTES, in the same words wherever it is
|
|
136
|
+
// checked; the size is rounded up, so maths just over never reads as 200 MB.
|
|
137
|
+
export const mathTooLarge = (bytes) => `The maths is ${Math.ceil((bytes * 10) / 1048576) / 10} MB; a pack's maths may be at most ${MAX_MATH_BYTES / 1048576} MB.`;
|
|
138
|
+
|
|
139
|
+
// The bytes of the maths in dir: its files together (math/ holds nothing below them).
|
|
140
|
+
export const mathBytes = (dir) => readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a meter adds to the RTP of every bet of its modes, exactly (GUIDE:
|
|
144
|
+
* Meters): E[award] ÷ fill, in basis points of the stake. `rows` are the
|
|
145
|
+
* award's outcomes, `pay` the multiplier of the reference in bp.
|
|
146
|
+
*/
|
|
147
|
+
export function meterRtp({ fill, rows }) {
|
|
148
|
+
const den = rows.reduce((s, r) => s + r.weight, 0n) * BigInt(fill);
|
|
149
|
+
const num = rows.reduce((s, r) => s + r.weight * BigInt(r.pay || 0), 0n);
|
|
150
|
+
return { num, den, bp: bp4(num, den) };
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
/**
|
|
125
154
|
* Writes manifest.json and one table per mode. A mode is { id, label?, rows,
|
|
126
|
-
* bets: [id | {id, label}] }; its rows are sorted by what the first bet pays,
|
|
155
|
+
* bets: [id | {id, label}], meter? }; its rows are sorted by what the first bet pays,
|
|
127
156
|
* so each pay tier is one range. Prints every bet's exact RTP, hit rate and
|
|
128
157
|
* largest multiplier; returns them.
|
|
158
|
+
*
|
|
159
|
+
* meters: [{ id, label?, fill, rows }] — progress that pays (GUIDE: Meters).
|
|
160
|
+
* A meter's rows are its award's outcomes, each { weight, pay, data? } with
|
|
161
|
+
* `pay` in bp of the meter's reference (20000 = twice the reference); a mode
|
|
162
|
+
* names the meter its rounds fill with `meter: id`. Every bet of such a mode
|
|
163
|
+
* reports base + meter RTP, and the base must clear 90% on its own.
|
|
129
164
|
*/
|
|
130
|
-
export function writePack(dir, { game, rtpBp, modes }) {
|
|
131
|
-
|
|
165
|
+
export function writePack(dir, { game, rtpBp, modes, meters = [] }) {
|
|
166
|
+
if (!Array.isArray(modes) || modes.length < 1 || modes.length > 64) throw new Error('a pack needs 1 to 64 modes');
|
|
167
|
+
for (const mode of modes) {
|
|
168
|
+
if (!Array.isArray(mode.bets) || mode.bets.length < 1 || mode.bets.length > 256) throw new Error(`${mode.id}: 1 to 256 bets per mode`);
|
|
169
|
+
}
|
|
132
170
|
const manifest = { format: 'house-engine-math/1', game, rtpBp, modes: [] };
|
|
133
171
|
const report = [];
|
|
172
|
+
const tables = new Map();
|
|
134
173
|
for (const mode of modes) {
|
|
135
174
|
const bets = mode.bets.map((b) => (typeof b === 'string' ? { id: b } : b));
|
|
136
175
|
const first = bets[0].id;
|
|
@@ -141,25 +180,63 @@ export function writePack(dir, { game, rtpBp, modes }) {
|
|
|
141
180
|
const lines = rows.map((r) => {
|
|
142
181
|
if (r.weight <= 0n) throw new Error(`${mode.id}: a weight is not positive`);
|
|
143
182
|
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
144
|
-
if (json
|
|
183
|
+
if (Buffer.byteLength(json) > 4096) throw new Error(`${mode.id}: a row's data is over 4096 UTF-8 bytes`);
|
|
145
184
|
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
146
185
|
});
|
|
147
186
|
const table = `${game}-${mode.id}.tsv`;
|
|
148
|
-
|
|
187
|
+
tables.set(table, lines.join('\n') + '\n');
|
|
149
188
|
manifest.modes.push({
|
|
150
|
-
id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table,
|
|
189
|
+
id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table, ...(mode.meter ? { meter: mode.meter } : {}),
|
|
151
190
|
bets: bets.map((b) => {
|
|
152
191
|
const tiers = new Map();
|
|
153
192
|
rows.forEach((r, i) => { const m = r.pay[b.id] || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
154
193
|
const { num, den, bp } = rtp(rows, b.id);
|
|
155
194
|
const hit = rows.reduce((s, r) => s + (r.pay[b.id] > 0 ? r.weight : 0n), 0n);
|
|
195
|
+
// The engine's verifier refuses a bet with more pay tiers than this (one tier per distinct
|
|
196
|
+
// multiplier): refused here, at the build, not at the upload (mathpack.MaxTiers).
|
|
197
|
+
if (tiers.size > MAX_TIERS) throw new Error(`${mode.id}: bet "${b.id}": ${tiers.size} distinct payouts (tiers), at most ${MAX_TIERS}: round the multipliers to fewer values`);
|
|
156
198
|
const max = Math.max(...tiers.keys());
|
|
157
|
-
|
|
199
|
+
const entry = { mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n };
|
|
200
|
+
if (mode.meter) {
|
|
201
|
+
const mt = meters.find((x) => x.id === mode.meter);
|
|
202
|
+
if (!mt) throw new Error(`${mode.id}: meter "${mode.meter}" is not in meters`);
|
|
203
|
+
const add = meterRtp(mt);
|
|
204
|
+
// base + E[award]/fill, as one exact fraction
|
|
205
|
+
const tn = num * add.den + add.num * den, td = den * add.den;
|
|
206
|
+
Object.assign(entry, { baseRtp: bp, meter: mode.meter, rtp: bp4(tn, td), exact: tn % td === 0n });
|
|
207
|
+
}
|
|
208
|
+
report.push(entry);
|
|
158
209
|
return { id: b.id, ...(b.label ? { label: b.label } : {}), tiers: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) };
|
|
159
210
|
}),
|
|
160
211
|
});
|
|
161
212
|
}
|
|
162
|
-
|
|
163
|
-
|
|
213
|
+
for (const mt of meters) {
|
|
214
|
+
if (!Number.isSafeInteger(mt.fill) || mt.fill < 2 || mt.fill > 1_000_000) throw new Error(`meter ${mt.id}: fill must be 2 to 1,000,000`);
|
|
215
|
+
if (!modes.some((m) => m.meter === mt.id)) throw new Error(`meter ${mt.id}: no mode fills it`);
|
|
216
|
+
const rows = [...mt.rows].sort((a, b) => (a.pay || 0) - (b.pay || 0));
|
|
217
|
+
if (rows.length < 1) throw new Error(`meter ${mt.id}: an award needs rows`);
|
|
218
|
+
const table = `${game}-meter-${mt.id}.tsv`;
|
|
219
|
+
tables.set(table, rows.map((r) => {
|
|
220
|
+
if (r.weight <= 0n) throw new Error(`meter ${mt.id}: a weight is not positive`);
|
|
221
|
+
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
222
|
+
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
223
|
+
}).join('\n') + '\n');
|
|
224
|
+
const tiers = new Map();
|
|
225
|
+
rows.forEach((r, i) => { const m = r.pay || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
226
|
+
if (!tiers.size) throw new Error(`meter ${mt.id}: the award never pays`);
|
|
227
|
+
(manifest.meters ||= []).push({ id: mt.id, ...(mt.label ? { label: mt.label } : {}), fill: mt.fill, table,
|
|
228
|
+
award: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) });
|
|
229
|
+
const ev = meterRtp({ fill: 1, rows });
|
|
230
|
+
console.log(`meter ${mt.id}: fill ${mt.fill} award E ${bp4(ev.num, ev.den * 10000n)}× the reference adds ${meterRtp({ fill: mt.fill, rows }).bp} bp`);
|
|
231
|
+
}
|
|
232
|
+
tables.set('manifest.json', JSON.stringify(manifest) + '\n');
|
|
233
|
+
// The pack as it will be on disk, with what else is in dir already (the
|
|
234
|
+
// generator, often), measured before a file is written.
|
|
235
|
+
mkdirSync(dir, { recursive: true });
|
|
236
|
+
const others = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !tables.has(e.name));
|
|
237
|
+
const bytes = [...tables.values()].reduce((n, text) => n + Buffer.byteLength(text), 0) + others.reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
238
|
+
if (bytes > MAX_MATH_BYTES) throw new Error(mathTooLarge(bytes));
|
|
239
|
+
for (const [name, text] of tables) writeFileSync(join(dir, name), text);
|
|
240
|
+
for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp${r.meter ? ` (base ${r.baseRtp} + meter ${r.meter})` : ''} hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
|
|
164
241
|
return report;
|
|
165
242
|
}
|