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.
Files changed (72) hide show
  1. package/engine-cli/hbengine.mjs +106 -36
  2. package/engine-cli/outage.mjs +9 -0
  3. package/engine-cli/templates.mjs +1 -0
  4. package/engine-cli/upload-errors.mjs +18 -0
  5. package/engine-cli/upload.mjs +172 -0
  6. package/engine-conformance/analyse.mjs +155 -15
  7. package/engine-conformance/bundle.mjs +23 -5
  8. package/engine-conformance/census-worker.mjs +9 -0
  9. package/engine-conformance/chrome-flags.mjs +19 -0
  10. package/engine-conformance/diagnostic-text.mjs +19 -0
  11. package/engine-conformance/diagnostics.mjs +52 -0
  12. package/engine-conformance/focus.mjs +42 -0
  13. package/engine-conformance/hints.mjs +86 -0
  14. package/engine-conformance/js-releases.json +2365 -0
  15. package/engine-conformance/js-releases.mjs +75 -0
  16. package/engine-conformance/run.mjs +226 -39
  17. package/engine-conformance/scan.mjs +71 -18
  18. package/engine-conformance/sound.mjs +45 -0
  19. package/engine-conformance/travel.mjs +132 -6
  20. package/engine-examples/keno/math-kit/pack.mjs +87 -10
  21. package/engine-examples/lines/math-kit/pack.mjs +87 -10
  22. package/engine-examples/plinko/math-kit/pack.mjs +87 -10
  23. package/engine-examples/plinko-jar/AGENTS.md +3 -0
  24. package/engine-examples/plinko-jar/README.md +16 -0
  25. package/engine-examples/plinko-jar/bundle/assets.json +1 -0
  26. package/engine-examples/plinko-jar/bundle/game.css +10 -0
  27. package/engine-examples/plinko-jar/bundle/game.js +113 -0
  28. package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
  29. package/engine-examples/plinko-jar/bundle/index.html +16 -0
  30. package/engine-examples/plinko-jar/bundle/model.json +1 -0
  31. package/engine-examples/plinko-jar/math/gen.mjs +47 -0
  32. package/engine-examples/plinko-jar/math/manifest.json +1 -0
  33. package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
  34. package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
  35. package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
  36. package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
  37. package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
  38. package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
  39. package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
  40. package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
  41. package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
  42. package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
  43. package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
  44. package/engine-examples/plinko-jar/math-kit/package.json +8 -0
  45. package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
  46. package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
  47. package/engine-examples/tumble/math-kit/pack.mjs +87 -10
  48. package/engine-math/pack.mjs +87 -10
  49. package/engine-mcp/server.mjs +30 -14
  50. package/engine-sdk/GUIDE.md +234 -32
  51. package/engine-sdk/bet-field.js +16 -3
  52. package/engine-sdk/bet.js +88 -0
  53. package/engine-sdk/frames.js +75 -0
  54. package/engine-sdk/progress.js +128 -0
  55. package/engine-sdk/protocol.js +3 -0
  56. package/engine-sdk/sdk.d.ts +35 -0
  57. package/engine-sdk/sdk.js +9 -0
  58. package/engine-sdk/ui.js +3 -0
  59. package/engine-shell/core/rgs.js +1 -0
  60. package/engine-shell/core/router.js +46 -54
  61. package/engine-shell/host/audio-session.js +24 -0
  62. package/engine-shell/host/boot.js +5 -2
  63. package/engine-shell/host/engine-v1.js +17 -4
  64. package/engine-shell/host/fair.js +27 -0
  65. package/engine-shell/host/index.html +44 -9
  66. package/engine-shell/host/intro.js +99 -0
  67. package/engine-shell/host/page.js +25 -5
  68. package/engine-shell/host/sandbox-panel.js +23 -3
  69. package/engine-shell/host/sandbox-scenarios.js +23 -11
  70. package/engine-shell/host/site-bridge.js +4 -2
  71. package/engine-shell/verifiers/engine-verify.js +12 -3
  72. package/package.json +2 -1
@@ -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
- mkdirSync(dir, { recursive: true });
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.length > 4096) throw new Error(`${mode.id}: a row's data is over 4096 bytes`);
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
- writeFileSync(join(dir, table), lines.join('\n') + '\n');
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
- report.push({ mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n });
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
- writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest) + '\n');
163
- for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
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
  }
@@ -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
- mkdirSync(dir, { recursive: true });
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.length > 4096) throw new Error(`${mode.id}: a row's data is over 4096 bytes`);
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
- writeFileSync(join(dir, table), lines.join('\n') + '\n');
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
- report.push({ mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n });
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
- writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest) + '\n');
163
- for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
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
  }
@@ -0,0 +1,3 @@
1
+ # For agents
2
+
3
+ This is a House Engine game (Housebets). Read the guide first: `engine_guide` (MCP) or `engine-sdk/GUIDE.md`. Then `hbengine verify`, `hbengine dev`, `hbengine upload` (or the `engine_*` tools). The math is `math/`, the frontend `bundle/`; never compute money or outcomes in the frontend.
@@ -0,0 +1,16 @@
1
+ # Plinko with a jar — a meter that pays
2
+
3
+ The plinko board with a **jar** (a House Engine meter, GUIDE §2.2). Every drop
4
+ pours its stake into the jar; the jar is full after 200 × its reference stake
5
+ (the stake of the drop that started it) and the drop that fills it also pays
6
+ 2×, 5×, 10×, 25× or 50× the reference (8× on average). The server keeps the jar
7
+ per player and coin, in the same transaction as each drop: nothing in the page
8
+ can change it, and it is there again on any device.
9
+
10
+ RTP: each bet is its base (≈ 95%) + 8 / 200 = 4% from the jar, exactly; the
11
+ verifier checks both, and the base alone stays above 90%.
12
+
13
+ node math/gen.mjs && npx hbengine verify .
14
+
15
+ The page reads the jar with `engine.meters()` at boot and `round.meter` after
16
+ each drop; `round.payout` already includes the jar's bonus.
@@ -0,0 +1 @@
1
+ { "groups": { "boot": { "files": ["index.html", "game.js", "game.css", "model.json"] } } }
@@ -0,0 +1,10 @@
1
+ body { margin: 0; background: #08141a; }
2
+ .plinko { position: relative; width: 100%; height: 100%; min-height: 360px; display: grid; place-items: center; padding: 12px; box-sizing: border-box; }
3
+ canvas { display: block; }
4
+ .last { position: absolute; top: 12px; right: 14px; font: 700 15px/20px 'Red Hat Display', 'Figtree', system-ui, sans-serif; color: #8F96A8; font-variant-numeric: tabular-nums; }
5
+ .last.win { color: #FDDC73; }
6
+ .jar { position: absolute; top: 12px; left: 14px; display: grid; justify-items: center; gap: 4px; }
7
+ .jar-glass { width: 30px; height: 64px; border: 2px solid #5b6b78; border-radius: 6px 6px 10px 10px; overflow: hidden; display: flex; align-items: flex-end; background: #0D2A38; }
8
+ .jar-fill { width: 100%; height: 0; background: linear-gradient(#FDDC73, #c99a2e); transition: height .4s ease; }
9
+ .jar.full .jar-glass { border-color: #FDDC73; box-shadow: 0 0 14px #FDDC7399; }
10
+ .jar-text { font: 700 12px/16px 'Red Hat Display', 'Figtree', system-ui, sans-serif; color: #8F96A8; font-variant-numeric: tabular-nums; }
@@ -0,0 +1,113 @@
1
+ // Plinko with a jar (GUIDE §2.2, meters): the board is the plinko template's;
2
+ // the jar is a meter the server keeps. Every drop pours its stake in; the drop
3
+ // that fills it also pays the jar's bonus, already in its payout. The page
4
+ // only shows the jar: engine.meters() at boot, round.meter after each drop.
5
+ import { mount } from '/engine-sdk/ui.js';
6
+
7
+ const board = await (await fetch('./model.json')).json();
8
+ const el = document.getElementById('game'), zone = document.getElementById('zone'), last = document.getElementById('last');
9
+ const canvas = document.getElementById('board'), ctx = canvas.getContext('2d');
10
+ const jar = document.getElementById('jar'), jarFill = document.getElementById('jar-fill'), jarText = document.getElementById('jar-text');
11
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ let mode = Object.keys(board)[0], ball = null, lit = -1, size = { w: 0, h: 0, gap: 0 };
13
+
14
+ // Bucket colours: deep tones by how far from the centre (the big shapes stay muted).
15
+ const tone = (k, n) => { const d = Math.abs(k - n / 2) / (n / 2); return d > 0.85 ? '#7e1f3a' : d > 0.55 ? '#7d5410' : d > 0.25 ? '#0d5f6e' : '#0D2A38'; };
16
+ // Every multiplier is a whole 0.001×: shown exactly, trailing zeros dropped.
17
+ const label = (bp) => String(bp / 10000) + '×';
18
+
19
+ function layout() {
20
+ const n = board[mode].length - 1, dpr = devicePixelRatio || 1;
21
+ const w = Math.min(zone.clientWidth - 24, 640), h = Math.min(zone.clientHeight - 24, w * 0.95);
22
+ const gap = Math.min(w / (n + 2), h / (n + 2.4));
23
+ size = { w: gap * (n + 2), h: gap * (n + 2.4), gap, n };
24
+ canvas.width = size.w * dpr; canvas.height = size.h * dpr;
25
+ canvas.style.width = size.w + 'px'; canvas.style.height = size.h + 'px';
26
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
27
+ draw();
28
+ }
29
+ const peg = (row, i) => ({ x: size.w / 2 + (i - row / 2) * size.gap, y: size.gap * (0.9 + row) });
30
+
31
+ function draw() {
32
+ const { w, h, gap, n } = size, pays = board[mode];
33
+ ctx.clearRect(0, 0, w, h);
34
+ ctx.fillStyle = '#5b6b78';
35
+ for (let r = 1; r <= n; r++) for (let i = 0; i <= r; i++) { const p = peg(r, i); ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(2, gap * 0.09), 0, 7); ctx.fill(); }
36
+ const by = gap * (n + 1.25), bh = gap * 0.85;
37
+ ctx.font = `700 ${Math.max(9, Math.min(13, gap * 0.34))}px 'Red Hat Display', Figtree, system-ui, sans-serif`;
38
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
39
+ pays.forEach((bp, k) => {
40
+ const x = size.w / 2 + (k - n / 2) * gap;
41
+ ctx.fillStyle = tone(k, n);
42
+ ctx.beginPath(); ctx.roundRect(x - gap * 0.46, by, gap * 0.92, bh, 5); ctx.fill();
43
+ if (k === lit) { ctx.strokeStyle = '#FDDC73'; ctx.lineWidth = 2; ctx.stroke(); }
44
+ ctx.fillStyle = '#F9F9FB';
45
+ ctx.fillText(label(bp), x, by + bh / 2);
46
+ });
47
+ if (ball) { ctx.fillStyle = '#F9F9FB'; ctx.beginPath(); ctx.arc(ball.x, ball.y, gap * 0.2, 0, 7); ctx.fill(); }
48
+ }
49
+
50
+ // Which of the n steps go right: k of them, shuffled by the round's nonce.
51
+ function path(n, k, seed) {
52
+ const steps = Array.from({ length: n }, (_, i) => i < k);
53
+ let s = (seed >>> 0) || 1;
54
+ for (let i = n - 1; i > 0; i--) { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; const j = (s >>> 0) % (i + 1); [steps[i], steps[j]] = [steps[j], steps[i]]; }
55
+ return steps;
56
+ }
57
+
58
+ function fall(steps) {
59
+ const { gap } = size;
60
+ const points = [{ x: size.w / 2, y: gap * 0.1 }];
61
+ let i = 0;
62
+ steps.forEach((right, r) => { if (right) i++; const p = peg(r + 1, i); points.push({ x: p.x + (right ? -0.5 : 0.5) * gap, y: p.y - gap * 0.35 }); });
63
+ const end = size.w / 2 + (i - steps.length / 2) * gap;
64
+ points.push({ x: end, y: gap * (steps.length + 1.6) });
65
+ const per = 90, total = per * (points.length - 1);
66
+ return new Promise((done) => {
67
+ const t0 = performance.now();
68
+ const frame = (t) => {
69
+ const f = Math.min(1, (t - t0) / total), seg = Math.min(points.length - 2, Math.floor(f * (points.length - 1))), u = f * (points.length - 1) - seg;
70
+ const a = points[seg], b = points[seg + 1];
71
+ ball = { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u - Math.sin(u * Math.PI) * gap * 0.25 };
72
+ draw();
73
+ if (f < 1) requestAnimationFrame(frame); else done();
74
+ };
75
+ requestAnimationFrame(frame);
76
+ });
77
+ }
78
+
79
+ // The jar, from the server's figures: filled ÷ target (target 0: empty).
80
+ function showJar(v) {
81
+ const f = Number(v.filled), t = Number(v.target), pct = t > 0 ? Math.min(100, (f / t) * 100) : 0;
82
+ jarFill.style.height = pct + '%';
83
+ jar.setAttribute('aria-valuenow', String(Math.round(pct)));
84
+ jarText.textContent = 'Jar ' + Math.floor(pct) + '%';
85
+ }
86
+
87
+ const game = await mount(el, {
88
+ async reveal(round) {
89
+ const k = round.data[0].k, n = size.n;
90
+ lit = -1; last.textContent = ''; last.classList.remove('win');
91
+ await fall(path(n, k, Number(round.nonce) || round.id?.length || 1));
92
+ ball = null; lit = k; draw();
93
+ last.textContent = label(board[mode][k]);
94
+ last.classList.toggle('win', round.payout > 0 && board[mode][k] >= 10000);
95
+ const m = round.meter;
96
+ if (m && m.award) {
97
+ // Full: the jar pays x × its reference stake on this drop.
98
+ showJar({ filled: 1, target: 1 });
99
+ jar.classList.add('full');
100
+ last.textContent += ' + jar ' + m.award.data.x + '× ' + game.engine.money.format(m.award.reference);
101
+ last.classList.add('win');
102
+ await wait(1200);
103
+ jar.classList.remove('full');
104
+ }
105
+ if (m) showJar(m.after);
106
+ await wait(150);
107
+ },
108
+ });
109
+ for (const m of await game.engine.meters()) if (m.id === 'jar') showJar(m);
110
+ mode = game.mode;
111
+ new ResizeObserver(layout).observe(zone);
112
+ layout();
113
+ el.addEventListener('select', (e) => { if (e.detail.mode !== mode) { mode = e.detail.mode; lit = -1; layout(); } });
@@ -0,0 +1,4 @@
1
+ {
2
+ "stake": { "page": "index.html", "selector": "hb-game hb-bet-amount input", "value": "1.00" },
3
+ "bet": { "page": "index.html", "selector": "hb-game [data-main]" }
4
+ }
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Plinko jar</title>
7
+ <link rel="stylesheet" href="./game.css">
8
+ <script type="module" src="/engine-sdk/bet-field.js"></script>
9
+ <script type="module" src="./game.js"></script>
10
+ </head>
11
+ <body>
12
+ <hb-game id="game">
13
+ <div slot="zone" class="plinko" id="zone"><canvas id="board" aria-label="Plinko board"></canvas><div class="jar" id="jar" role="meter" aria-label="Jar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="jar-glass"><div class="jar-fill" id="jar-fill"></div></div><div class="jar-text" id="jar-text">Jar 0%</div></div><div class="last" id="last" aria-live="polite"></div></div>
14
+ </hb-game>
15
+ </body>
16
+ </html>
@@ -0,0 +1 @@
1
+ {"8-low":[55000,21000,11000,10000,3570,10000,11000,21000,55000],"8-medium":[130000,30000,13000,7000,2570,7000,13000,30000,130000],"8-high":[290000,40000,15000,3000,510,3000,15000,40000,290000],"12-low":[100000,30000,16000,14000,11000,10000,3230,10000,11000,14000,16000,30000,100000],"12-medium":[330000,110000,40000,20000,11000,6000,1230,6000,11000,20000,40000,110000,330000],"12-high":[1700000,240000,81000,20000,7000,2000,170,2000,7000,20000,81000,240000,1700000],"16-low":[160000,90000,20000,14000,14000,12000,11000,10000,2960,10000,11000,12000,14000,14000,20000,90000,160000],"16-medium":[1100000,410000,100000,50000,30000,15000,10000,5000,960,5000,10000,15000,30000,50000,100000,410000,1100000],"16-high":[10000000,1200000,220000,80000,35000,15000,2000,2000,6640,2000,2000,15000,35000,80000,220000,1200000,10000000]}
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Plinko with a jar: a meter (GUIDE §2.2) that fills with the stakes and,
3
+ // when full, pays a bonus on the drop that fills it.
4
+ //
5
+ // The board is the plinko template's, its centre multiplier solved for 95%
6
+ // (rounded down to 0.001×, so each mode lands just under). The jar is one
7
+ // meter shared by all nine modes: it is full after 200 × its reference stake
8
+ // (the stake of the drop that started it) and then pays 2×, 5×, 10×, 25× or
9
+ // 50× the reference, 8× on average: 8 / 200 = 4% on top of every bet. Every
10
+ // bet is 95% + 4% ≈ 99%, and a player who never fills the jar has a 95% game.
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { binomial, writePack } from '../math-kit/pack.mjs';
15
+
16
+ const DIR = dirname(fileURLToPath(import.meta.url));
17
+ const project = join(DIR, '..', 'hbengine.json');
18
+ const GAME = process.argv[2] || (existsSync(project) ? JSON.parse(readFileSync(project, 'utf8')).slug : 'plinko-jar');
19
+ const BASE = 9500n;
20
+ const EDGES = {
21
+ 8: { low: [5.5, 2.1, 1.1, 1], medium: [13, 3, 1.3, 0.7], high: [29, 4, 1.5, 0.3] },
22
+ 12: { low: [10, 3, 1.6, 1.4, 1.1, 1], medium: [33, 11, 4, 2, 1.1, 0.6], high: [170, 24, 8.1, 2, 0.7, 0.2] },
23
+ 16: { low: [16, 9, 2, 1.4, 1.4, 1.2, 1.1, 1], medium: [110, 41, 10, 5, 3, 1.5, 1, 0.5], high: [1000, 120, 22, 8, 3.5, 1.5, 0.2, 0.2] },
24
+ };
25
+ // The jar's award: [multiple of the reference, weight]; E = 8×.
26
+ const JAR = [[2, 50], [5, 20], [10, 20], [25, 4], [50, 6]];
27
+ const FILL = 200;
28
+
29
+ const modes = [], board = {};
30
+ for (const [n, risks] of Object.entries(EDGES)) for (const [risk, edge] of Object.entries(risks)) {
31
+ const rows = Number(n), c = rows / 2;
32
+ const bp = edge.map((x) => BigInt(Math.round(x * 10000)));
33
+ const pays = [...bp, 0n, ...[...bp].reverse()];
34
+ const total = 1n << BigInt(rows);
35
+ const rest = pays.reduce((s, m, k) => s + binomial(rows, k) * m, 0n);
36
+ const centre = ((BASE * total - rest) / binomial(rows, c) / 10n) * 10n;
37
+ if (centre <= 0n) throw new Error(`${n}-${risk}: the edges alone pay over 95%`);
38
+ pays[c] = centre;
39
+ board[`${n}-${risk}`] = pays.map(Number);
40
+ modes.push({
41
+ id: `${n}-${risk}`, label: `${n} rows · ${risk}`, meter: 'jar', bets: [{ id: 'drop', label: 'Drop' }],
42
+ rows: pays.map((m, k) => ({ weight: binomial(rows, k), data: { k }, pay: { drop: Number(m) } })),
43
+ });
44
+ }
45
+ const meters = [{ id: 'jar', label: 'Jar', fill: FILL, rows: JAR.map(([x, w]) => ({ weight: BigInt(w), data: { x }, pay: x * 10000 })) }];
46
+ writePack(DIR, { game: GAME, rtpBp: 9900, modes, meters });
47
+ writeFileSync(join(DIR, '..', 'bundle', 'model.json'), JSON.stringify(board) + '\n');
@@ -0,0 +1 @@
1
+ {"format":"house-engine-math/1","game":"plinko-jar","rtpBp":9900,"modes":[{"id":"8-low","label":"8 rows · low","kind":"table","table":"plinko-jar-8-low.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":3570},{"outcomes":[[1,2]],"multBp":10000},{"outcomes":[[3,4]],"multBp":11000},{"outcomes":[[5,6]],"multBp":21000},{"outcomes":[[7,8]],"multBp":55000}]}]},{"id":"8-medium","label":"8 rows · medium","kind":"table","table":"plinko-jar-8-medium.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":2570},{"outcomes":[[1,2]],"multBp":7000},{"outcomes":[[3,4]],"multBp":13000},{"outcomes":[[5,6]],"multBp":30000},{"outcomes":[[7,8]],"multBp":130000}]}]},{"id":"8-high","label":"8 rows · high","kind":"table","table":"plinko-jar-8-high.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":510},{"outcomes":[[1,2]],"multBp":3000},{"outcomes":[[3,4]],"multBp":15000},{"outcomes":[[5,6]],"multBp":40000},{"outcomes":[[7,8]],"multBp":290000}]}]},{"id":"12-low","label":"12 rows · low","kind":"table","table":"plinko-jar-12-low.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":3230},{"outcomes":[[1,2]],"multBp":10000},{"outcomes":[[3,4]],"multBp":11000},{"outcomes":[[5,6]],"multBp":14000},{"outcomes":[[7,8]],"multBp":16000},{"outcomes":[[9,10]],"multBp":30000},{"outcomes":[[11,12]],"multBp":100000}]}]},{"id":"12-medium","label":"12 rows · medium","kind":"table","table":"plinko-jar-12-medium.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":1230},{"outcomes":[[1,2]],"multBp":6000},{"outcomes":[[3,4]],"multBp":11000},{"outcomes":[[5,6]],"multBp":20000},{"outcomes":[[7,8]],"multBp":40000},{"outcomes":[[9,10]],"multBp":110000},{"outcomes":[[11,12]],"multBp":330000}]}]},{"id":"12-high","label":"12 rows · high","kind":"table","table":"plinko-jar-12-high.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":170},{"outcomes":[[1,2]],"multBp":2000},{"outcomes":[[3,4]],"multBp":7000},{"outcomes":[[5,6]],"multBp":20000},{"outcomes":[[7,8]],"multBp":81000},{"outcomes":[[9,10]],"multBp":240000},{"outcomes":[[11,12]],"multBp":1700000}]}]},{"id":"16-low","label":"16 rows · low","kind":"table","table":"plinko-jar-16-low.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":2960},{"outcomes":[[1,2]],"multBp":10000},{"outcomes":[[3,4]],"multBp":11000},{"outcomes":[[5,6]],"multBp":12000},{"outcomes":[[7,10]],"multBp":14000},{"outcomes":[[11,12]],"multBp":20000},{"outcomes":[[13,14]],"multBp":90000},{"outcomes":[[15,16]],"multBp":160000}]}]},{"id":"16-medium","label":"16 rows · medium","kind":"table","table":"plinko-jar-16-medium.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,0]],"multBp":960},{"outcomes":[[1,2]],"multBp":5000},{"outcomes":[[3,4]],"multBp":10000},{"outcomes":[[5,6]],"multBp":15000},{"outcomes":[[7,8]],"multBp":30000},{"outcomes":[[9,10]],"multBp":50000},{"outcomes":[[11,12]],"multBp":100000},{"outcomes":[[13,14]],"multBp":410000},{"outcomes":[[15,16]],"multBp":1100000}]}]},{"id":"16-high","label":"16 rows · high","kind":"table","table":"plinko-jar-16-high.tsv","meter":"jar","bets":[{"id":"drop","label":"Drop","tiers":[{"outcomes":[[0,3]],"multBp":2000},{"outcomes":[[4,4]],"multBp":6640},{"outcomes":[[5,6]],"multBp":15000},{"outcomes":[[7,8]],"multBp":35000},{"outcomes":[[9,10]],"multBp":80000},{"outcomes":[[11,12]],"multBp":220000},{"outcomes":[[13,14]],"multBp":1200000},{"outcomes":[[15,16]],"multBp":10000000}]}]}],"meters":[{"id":"jar","label":"Jar","fill":200,"table":"plinko-jar-meter-jar.tsv","award":[{"outcomes":[[0,0]],"multBp":20000},{"outcomes":[[1,1]],"multBp":50000},{"outcomes":[[2,2]],"multBp":100000},{"outcomes":[[3,3]],"multBp":250000},{"outcomes":[[4,4]],"multBp":500000}]}]}
@@ -0,0 +1,13 @@
1
+ 924 {"k":6}
2
+ 792 {"k":5}
3
+ 792 {"k":7}
4
+ 495 {"k":4}
5
+ 495 {"k":8}
6
+ 220 {"k":3}
7
+ 220 {"k":9}
8
+ 66 {"k":2}
9
+ 66 {"k":10}
10
+ 12 {"k":1}
11
+ 12 {"k":11}
12
+ 1 {"k":0}
13
+ 1 {"k":12}
@@ -0,0 +1,13 @@
1
+ 924 {"k":6}
2
+ 792 {"k":5}
3
+ 792 {"k":7}
4
+ 495 {"k":4}
5
+ 495 {"k":8}
6
+ 220 {"k":3}
7
+ 220 {"k":9}
8
+ 66 {"k":2}
9
+ 66 {"k":10}
10
+ 12 {"k":1}
11
+ 12 {"k":11}
12
+ 1 {"k":0}
13
+ 1 {"k":12}
@@ -0,0 +1,13 @@
1
+ 924 {"k":6}
2
+ 792 {"k":5}
3
+ 792 {"k":7}
4
+ 495 {"k":4}
5
+ 495 {"k":8}
6
+ 220 {"k":3}
7
+ 220 {"k":9}
8
+ 66 {"k":2}
9
+ 66 {"k":10}
10
+ 12 {"k":1}
11
+ 12 {"k":11}
12
+ 1 {"k":0}
13
+ 1 {"k":12}
@@ -0,0 +1,17 @@
1
+ 8008 {"k":6}
2
+ 11440 {"k":7}
3
+ 11440 {"k":9}
4
+ 8008 {"k":10}
5
+ 12870 {"k":8}
6
+ 4368 {"k":5}
7
+ 4368 {"k":11}
8
+ 1820 {"k":4}
9
+ 1820 {"k":12}
10
+ 560 {"k":3}
11
+ 560 {"k":13}
12
+ 120 {"k":2}
13
+ 120 {"k":14}
14
+ 16 {"k":1}
15
+ 16 {"k":15}
16
+ 1 {"k":0}
17
+ 1 {"k":16}
@@ -0,0 +1,17 @@
1
+ 12870 {"k":8}
2
+ 11440 {"k":7}
3
+ 11440 {"k":9}
4
+ 8008 {"k":6}
5
+ 8008 {"k":10}
6
+ 4368 {"k":5}
7
+ 4368 {"k":11}
8
+ 560 {"k":3}
9
+ 1820 {"k":4}
10
+ 1820 {"k":12}
11
+ 560 {"k":13}
12
+ 120 {"k":2}
13
+ 120 {"k":14}
14
+ 16 {"k":1}
15
+ 16 {"k":15}
16
+ 1 {"k":0}
17
+ 1 {"k":16}
@@ -0,0 +1,17 @@
1
+ 12870 {"k":8}
2
+ 11440 {"k":7}
3
+ 11440 {"k":9}
4
+ 8008 {"k":6}
5
+ 8008 {"k":10}
6
+ 4368 {"k":5}
7
+ 4368 {"k":11}
8
+ 1820 {"k":4}
9
+ 1820 {"k":12}
10
+ 560 {"k":3}
11
+ 560 {"k":13}
12
+ 120 {"k":2}
13
+ 120 {"k":14}
14
+ 16 {"k":1}
15
+ 16 {"k":15}
16
+ 1 {"k":0}
17
+ 1 {"k":16}
@@ -0,0 +1,9 @@
1
+ 70 {"k":4}
2
+ 56 {"k":3}
3
+ 56 {"k":5}
4
+ 28 {"k":2}
5
+ 28 {"k":6}
6
+ 8 {"k":1}
7
+ 8 {"k":7}
8
+ 1 {"k":0}
9
+ 1 {"k":8}