@open-slot-ui/core 0.8.3 → 0.10.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/dist/index.cjs CHANGED
@@ -437,12 +437,136 @@ function valueFitMaxWidth(digits, columnWidth) {
437
437
  return clampDigits(digits) * columnWidth + 64;
438
438
  }
439
439
 
440
+ // src/format/currency.ts
441
+ var CURRENCY_TABLE = Object.freeze({
442
+ // ── fiat — symbol BEFORE the amount ──────────────────────────────────────────
443
+ USD: { symbol: "$", decimals: 2 },
444
+ CAD: { symbol: "CA$", decimals: 2 },
445
+ JPY: { symbol: "\xA5", decimals: 0 },
446
+ EUR: { symbol: "\u20AC", decimals: 2 },
447
+ RUB: { symbol: "\u20BD", decimals: 2 },
448
+ CNY: { symbol: "CN\xA5", decimals: 2 },
449
+ PHP: { symbol: "\u20B1", decimals: 2 },
450
+ INR: { symbol: "\u20B9", decimals: 2 },
451
+ IDR: { symbol: "Rp", decimals: 0 },
452
+ KRW: { symbol: "\u20A9", decimals: 0 },
453
+ BRL: { symbol: "R$", decimals: 2 },
454
+ MXN: { symbol: "MX$", decimals: 2 },
455
+ TRY: { symbol: "\u20BA", decimals: 2 },
456
+ GBP: { symbol: "\xA3", decimals: 2 },
457
+ NGN: { symbol: "\u20A6", decimals: 2 },
458
+ TWD: { symbol: "NT$", decimals: 2 },
459
+ SGD: { symbol: "SG$", decimals: 2 },
460
+ MYR: { symbol: "RM", decimals: 2 },
461
+ CRC: { symbol: "\u20A1", decimals: 2 },
462
+ // three-decimal dinars/rials (1 unit = 1000 fils) — the >2-decimal path must format these
463
+ KWD: { symbol: "KD", decimals: 3 },
464
+ JOD: { symbol: "JD", decimals: 3 },
465
+ BHD: { symbol: "BD", decimals: 3 },
466
+ OMR: { symbol: "OMR", decimals: 3 },
467
+ TND: { symbol: "DT", decimals: 3 },
468
+ LYD: { symbol: "LD", decimals: 3 },
469
+ IQD: { symbol: "IQD", decimals: 3 },
470
+ // ── fiat — symbol AFTER the amount ───────────────────────────────────────────
471
+ DKK: { symbol: "kr", decimals: 2, symbolAfter: true },
472
+ NOK: { symbol: "kr", decimals: 2, symbolAfter: true },
473
+ PLN: { symbol: "z\u0142", decimals: 2, symbolAfter: true },
474
+ VND: { symbol: "\u20AB", decimals: 0, symbolAfter: true },
475
+ CLP: { symbol: "CLP", decimals: 0, symbolAfter: true },
476
+ ARS: { symbol: "ARS", decimals: 2, symbolAfter: true },
477
+ PEN: { symbol: "S/", decimals: 2, symbolAfter: true },
478
+ // ── crypto (8-dp, so sub-cent + high precision format correctly) ─────────────
479
+ BTC: { symbol: "\u20BF", decimals: 8 },
480
+ ETH: { symbol: "\u039E", decimals: 8 },
481
+ LTC: { symbol: "\u0141", decimals: 8 },
482
+ BCH: { symbol: "BCH", decimals: 8 },
483
+ DOGE: { symbol: "\xD0", decimals: 8 },
484
+ USDT: { symbol: "\u20AE", decimals: 2 },
485
+ USDC: { symbol: "USDC", decimals: 2 },
486
+ // ── Stake social coins — Gold Coins / Stake Cash (shown as GC / SC) ───────────
487
+ XGC: { symbol: "GC", decimals: 2, social: true },
488
+ XSC: { symbol: "SC", decimals: 2, social: true },
489
+ GC: { symbol: "GC", decimals: 2, social: true },
490
+ SC: { symbol: "SC", decimals: 2, social: true }
491
+ });
492
+ var upper = (code) => typeof code === "string" ? code.toUpperCase() : "";
493
+ function isSocialCurrency(code) {
494
+ return CURRENCY_TABLE[upper(code)]?.social === true;
495
+ }
496
+ function resolveCurrency(code, overrides = {}) {
497
+ const info = CURRENCY_TABLE[upper(code)];
498
+ if (!info) {
499
+ return {
500
+ code: overrides.code ?? code,
501
+ decimals: clampDecimals(overrides.decimals ?? 2),
502
+ position: overrides.position ?? "suffix",
503
+ separator: overrides.separator ?? ",",
504
+ decimalChar: overrides.decimalChar ?? ".",
505
+ ...overrides.symbol ? { symbol: overrides.symbol, display: overrides.display ?? "symbol" } : {}
506
+ };
507
+ }
508
+ return {
509
+ // Social coins carry their DISPLAY code (XGC → "GC", XSC → "SC"); fiat/crypto keep
510
+ // their ISO code (the symbol is what's actually rendered under `display:'symbol'`).
511
+ code: overrides.code ?? (info.social ? info.symbol : code),
512
+ symbol: overrides.symbol ?? info.symbol,
513
+ display: overrides.display ?? "symbol",
514
+ position: overrides.position ?? (info.symbolAfter ? "suffix" : "prefix"),
515
+ decimals: clampDecimals(overrides.decimals ?? info.decimals),
516
+ separator: overrides.separator ?? ",",
517
+ decimalChar: overrides.decimalChar ?? "."
518
+ };
519
+ }
520
+ function neededDecimals(value, base) {
521
+ const b = clampDecimals(base);
522
+ if (!Number.isFinite(value) || value === 0) return b;
523
+ let scaled = Math.round(Math.abs(value) * 1e8);
524
+ let d = 8;
525
+ while (d > b && scaled % 10 === 0) {
526
+ scaled /= 10;
527
+ d--;
528
+ }
529
+ return d;
530
+ }
531
+ function formatAmountPrecise(value, spec, opts = {}) {
532
+ const base = clampDecimals(spec.decimals ?? 2);
533
+ const d = neededDecimals(value, base);
534
+ return d === base ? formatAmount(value, spec, opts) : formatAmount(value, { ...spec, decimals: d }, opts);
535
+ }
536
+ function formatAmount(value, spec, opts = {}) {
537
+ const decimals = clampDecimals(spec.decimals);
538
+ const sep = spec.separator ?? ",";
539
+ const dot = spec.decimalChar ?? ".";
540
+ const n = Number.isFinite(value) ? value : 0;
541
+ const fixed = Math.abs(n).toFixed(decimals);
542
+ const [int = "0", frac] = fixed.split(".");
543
+ const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
544
+ const body = frac ? `${grouped}${dot}${frac}` : grouped;
545
+ const sign = n < 0 ? "-" : opts.signed ? "+" : "";
546
+ const number = `${sign}${body}`;
547
+ const symbolMode = spec.display === "symbol" && !!spec.symbol;
548
+ const affix = symbolMode ? spec.symbol : spec.code;
549
+ const prefix = (spec.position ?? "suffix") === "prefix";
550
+ const gap = symbolMode && prefix ? "" : " ";
551
+ return prefix ? `${affix}${gap}${number}` : `${number}${gap}${affix}`;
552
+ }
553
+
440
554
  // src/controls/ValueDisplay.ts
441
555
  var ValueDisplay = class extends Control {
442
556
  states = { idle: { interactable: false } };
443
557
  value = new Signal(0);
444
558
  currency;
445
559
  label;
560
+ /**
561
+ * Show a value at the precision it ACTUALLY carries (default on): when the value has
562
+ * more fraction digits than the currency's native precision — a sub-unit win like a
563
+ * USD 0.01 bet × a ×0.2 face = 0.002, or the 999.982 balance it leaves behind — the
564
+ * display decimals bump just enough to reveal them ($0.002), and drop back to the
565
+ * native look (5.00) once the value is clean again. Set `false` for hard-fixed decimals.
566
+ */
567
+ autoPrecision = true;
568
+ /** The currency's NATIVE precision — what `autoPrecision` returns to on clean values. */
569
+ baseDecimals;
446
570
  /** Minimum odometer column width (0 = auto: sized tightly to the value, grows as it
447
571
  * does). Read by the renderer at build time, so set it before mount (or declaratively
448
572
  * via `controls.{id}.digits`). */
@@ -453,9 +577,11 @@ var ValueDisplay = class extends Control {
453
577
  this.currency = new Signal(
454
578
  c && typeof c.code === "string" ? { ...c, decimals: clampDecimals(c.decimals) } : { code: "USD", decimals: 2 }
455
579
  );
580
+ this.baseDecimals = this.currency.get().decimals;
456
581
  this.label = opts.label;
457
582
  this.digits = clampMinDigits(opts.digits ?? 0);
458
583
  if (opts.initial != null) this.value.set(opts.initial);
584
+ this.applyPrecision();
459
585
  }
460
586
  /** Set the minimum odometer column width (clamped 0..18; 0 = auto). Read by the
461
587
  * renderer at build time, so set it before mount (or via `controls.{id}.digits`). */
@@ -466,14 +592,27 @@ var ValueDisplay = class extends Control {
466
592
  * good value — malformed host data never throws into the render loop (P11). */
467
593
  set(amount) {
468
594
  this.value.set(safeAmount(amount, this.value.get()));
595
+ this.applyPrecision();
469
596
  }
470
597
  get() {
471
598
  return this.value.get();
472
599
  }
473
- /** Keep the prior spec on malformed input; clamp decimals to 0..8 (P11). */
600
+ /** Keep the prior spec on malformed input; clamp decimals to 0..8 (P11). The given
601
+ * decimals become the NATIVE precision (`autoPrecision` bumps above it as needed). */
474
602
  setCurrency(spec) {
475
603
  if (!spec || typeof spec.code !== "string") return;
476
- this.currency.set({ ...spec, decimals: clampDecimals(spec.decimals) });
604
+ this.baseDecimals = clampDecimals(spec.decimals);
605
+ this.currency.set({ ...spec, decimals: this.baseDecimals });
606
+ this.applyPrecision();
607
+ }
608
+ /** Bump the display decimals to what the current value needs — never below the
609
+ * currency's native precision — and drop back once the value is clean again.
610
+ * No-op (and no signal churn) when the shown precision is already right. */
611
+ applyPrecision() {
612
+ if (!this.autoPrecision) return;
613
+ const want = neededDecimals(this.value.get(), this.baseDecimals);
614
+ const cur = this.currency.get();
615
+ if (cur.decimals !== want) this.currency.set({ ...cur, decimals: want });
477
616
  }
478
617
  /** Accent emphasis: the renderer tints the value in the theme accent while on.
479
618
  * For "this number is modified" moments — e.g. a bet display showing the
@@ -1132,6 +1271,7 @@ var BLOCK_KINDS = [
1132
1271
  "subheading",
1133
1272
  "callout",
1134
1273
  "stat-grid",
1274
+ "mode-stats",
1135
1275
  "steps",
1136
1276
  "table",
1137
1277
  "paytable",
@@ -1272,6 +1412,9 @@ function validateSpec(spec) {
1272
1412
  return;
1273
1413
  }
1274
1414
  seeId(b.id, `${p}.id`);
1415
+ if (b.explains && b.explains !== "freeSpins" && spec.facts?.modes?.length && !spec.facts.modes.some((m) => m.id === b.explains)) {
1416
+ add("warn", `${p}.explains`, "explains-unknown-mode", `explains "${b.explains}" matches no declared mode id (facts.modes)`);
1417
+ }
1275
1418
  if (b.kind === "select" && (!b.options || b.options.length === 0)) {
1276
1419
  add("error", `${p}.options`, "select-empty", "a select block needs at least one option");
1277
1420
  }
@@ -1284,6 +1427,9 @@ function validateSpec(spec) {
1284
1427
  if (b.kind === "stat-grid" && (!b.items || b.items.length === 0)) {
1285
1428
  add("warn", `${p}.items`, "empty-grid", "a stat-grid block has no items");
1286
1429
  }
1430
+ if (b.kind === "mode-stats" && !spec.facts?.modes?.length) {
1431
+ add("warn", `${p}`, "mode-stats-no-facts", "a mode-stats block renders from spec.facts.modes, but no modes are declared");
1432
+ }
1287
1433
  if (b.kind === "steps" && (!b.items || b.items.length === 0)) {
1288
1434
  add("warn", `${p}.items`, "empty-steps", "a steps block has no items");
1289
1435
  }
@@ -1479,6 +1625,10 @@ var openuiDefaults = {
1479
1625
  // reality check (RTS 13) — {{minutes}} is interpolated by open-ui's scheduler
1480
1626
  "openui.realityCheck.title": "Reality check",
1481
1627
  "openui.realityCheck.message": "You've been playing for {{minutes}} minutes. Take a moment before continuing.",
1628
+ // Rules-completeness audit (the info menu's "forgotten declarations" card)
1629
+ "openui.rulesAudit.title": "Rules incomplete",
1630
+ "openui.rulesAudit.required": "Missing required information:",
1631
+ "openui.rulesAudit.recommended": "Highly recommended:",
1482
1632
  // RGS error defaults (override via your messages dict or per-call)
1483
1633
  "openui.err.generic.title": "Something went wrong",
1484
1634
  "openui.err.generic.message": "Sorry, something went wrong. Please try again.",
@@ -1517,11 +1667,13 @@ var openuiSocialDefaults = {
1517
1667
  "openui.replay.costMultiplier": "Feature Multiplier",
1518
1668
  "openui.replay.payoutMultiplier": "Final Multiplier",
1519
1669
  "openui.err.insufficient.message": "You don't have enough balance for this play.",
1520
- "openui.err.limit.message": "A play limit has been reached. Please try again later."
1670
+ "openui.err.limit.message": "A play limit has been reached. Please try again later.",
1671
+ // The auto mode-stats grid's label words (used as their own keys at render time).
1672
+ "Max win": "Max prize"
1521
1673
  };
1522
1674
  function interpolate(template, vars) {
1523
1675
  if (!vars) return template;
1524
- return template.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(vars[k] ?? `{{${k}}}`));
1676
+ return template.replace(/\{\{([\w.-]+)\}\}/g, (_m, k) => String(vars[k] ?? `{{${k}}}`));
1525
1677
  }
1526
1678
  var DictionaryTranslator = class {
1527
1679
  constructor(messages, locale, socialMessages = {}) {
@@ -1561,98 +1713,245 @@ function dictionary(messages, locale, socialMessages) {
1561
1713
  return new DictionaryTranslator(messages, locale, socialMessages);
1562
1714
  }
1563
1715
 
1564
- // src/format/currency.ts
1565
- var CURRENCY_TABLE = Object.freeze({
1566
- // ── fiat symbol BEFORE the amount ──────────────────────────────────────────
1567
- USD: { symbol: "$", decimals: 2 },
1568
- CAD: { symbol: "CA$", decimals: 2 },
1569
- JPY: { symbol: "\xA5", decimals: 0 },
1570
- EUR: { symbol: "\u20AC", decimals: 2 },
1571
- RUB: { symbol: "\u20BD", decimals: 2 },
1572
- CNY: { symbol: "CN\xA5", decimals: 2 },
1573
- PHP: { symbol: "\u20B1", decimals: 2 },
1574
- INR: { symbol: "\u20B9", decimals: 2 },
1575
- IDR: { symbol: "Rp", decimals: 0 },
1576
- KRW: { symbol: "\u20A9", decimals: 0 },
1577
- BRL: { symbol: "R$", decimals: 2 },
1578
- MXN: { symbol: "MX$", decimals: 2 },
1579
- TRY: { symbol: "\u20BA", decimals: 2 },
1580
- GBP: { symbol: "\xA3", decimals: 2 },
1581
- NGN: { symbol: "\u20A6", decimals: 2 },
1582
- TWD: { symbol: "NT$", decimals: 2 },
1583
- SGD: { symbol: "SG$", decimals: 2 },
1584
- MYR: { symbol: "RM", decimals: 2 },
1585
- CRC: { symbol: "\u20A1", decimals: 2 },
1586
- // three-decimal dinars (1 unit = 1000 fils) — the >2-decimal path must format these
1587
- KWD: { symbol: "KD", decimals: 3 },
1588
- JOD: { symbol: "JD", decimals: 3 },
1589
- BHD: { symbol: "BD", decimals: 3 },
1590
- // ── fiat — symbol AFTER the amount ───────────────────────────────────────────
1591
- DKK: { symbol: "kr", decimals: 2, symbolAfter: true },
1592
- NOK: { symbol: "kr", decimals: 2, symbolAfter: true },
1593
- PLN: { symbol: "z\u0142", decimals: 2, symbolAfter: true },
1594
- VND: { symbol: "\u20AB", decimals: 0, symbolAfter: true },
1595
- CLP: { symbol: "CLP", decimals: 0, symbolAfter: true },
1596
- ARS: { symbol: "ARS", decimals: 2, symbolAfter: true },
1597
- PEN: { symbol: "S/", decimals: 2, symbolAfter: true },
1598
- // ── crypto (8-dp, so sub-cent + high precision format correctly) ─────────────
1599
- BTC: { symbol: "\u20BF", decimals: 8 },
1600
- ETH: { symbol: "\u039E", decimals: 8 },
1601
- LTC: { symbol: "\u0141", decimals: 8 },
1602
- BCH: { symbol: "BCH", decimals: 8 },
1603
- DOGE: { symbol: "\xD0", decimals: 8 },
1604
- USDT: { symbol: "\u20AE", decimals: 2 },
1605
- USDC: { symbol: "USDC", decimals: 2 },
1606
- // ── Stake social coins — Gold Coins / Stake Cash (shown as GC / SC) ───────────
1607
- XGC: { symbol: "GC", decimals: 2, social: true },
1608
- XSC: { symbol: "SC", decimals: 2, social: true },
1609
- GC: { symbol: "GC", decimals: 2, social: true },
1610
- SC: { symbol: "SC", decimals: 2, social: true }
1611
- });
1612
- var upper = (code) => typeof code === "string" ? code.toUpperCase() : "";
1613
- function isSocialCurrency(code) {
1614
- return CURRENCY_TABLE[upper(code)]?.social === true;
1615
- }
1616
- function resolveCurrency(code, overrides = {}) {
1617
- const info = CURRENCY_TABLE[upper(code)];
1618
- if (!info) {
1619
- return {
1620
- code: overrides.code ?? code,
1621
- decimals: clampDecimals(overrides.decimals ?? 2),
1622
- position: overrides.position ?? "suffix",
1623
- separator: overrides.separator ?? ",",
1624
- decimalChar: overrides.decimalChar ?? ".",
1625
- ...overrides.symbol ? { symbol: overrides.symbol, display: overrides.display ?? "symbol" } : {}
1626
- };
1716
+ // src/spec/facts.ts
1717
+ function mergeFacts(base, add) {
1718
+ const modes = [...base.modes ?? []];
1719
+ for (const m of add.modes ?? []) {
1720
+ const i = modes.findIndex((x) => x.id === m.id);
1721
+ if (i < 0) modes.push({ ...m });
1722
+ else {
1723
+ const merged = { ...modes[i] };
1724
+ for (const [k, v] of Object.entries(m)) if (v !== void 0) merged[k] = v;
1725
+ modes[i] = merged;
1726
+ }
1627
1727
  }
1628
1728
  return {
1629
- // Social coins carry their DISPLAY code (XGC → "GC", XSC → "SC"); fiat/crypto keep
1630
- // their ISO code (the symbol is what's actually rendered under `display:'symbol'`).
1631
- code: overrides.code ?? (info.social ? info.symbol : code),
1632
- symbol: overrides.symbol ?? info.symbol,
1633
- display: overrides.display ?? "symbol",
1634
- position: overrides.position ?? (info.symbolAfter ? "suffix" : "prefix"),
1635
- decimals: clampDecimals(overrides.decimals ?? info.decimals),
1636
- separator: overrides.separator ?? ",",
1637
- decimalChar: overrides.decimalChar ?? "."
1729
+ ...modes.length ? { modes } : {},
1730
+ freeSpins: add.freeSpins !== void 0 ? add.freeSpins : base.freeSpins,
1731
+ volatility: add.volatility ?? base.volatility,
1732
+ maxWinCapX: add.maxWinCapX ?? base.maxWinCapX
1638
1733
  };
1639
1734
  }
1640
- function formatAmount(value, spec, opts = {}) {
1641
- const decimals = clampDecimals(spec.decimals);
1642
- const sep = spec.separator ?? ",";
1643
- const dot = spec.decimalChar ?? ".";
1644
- const n = Number.isFinite(value) ? value : 0;
1645
- const fixed = Math.abs(n).toFixed(decimals);
1646
- const [int = "0", frac] = fixed.split(".");
1647
- const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
1648
- const body = frac ? `${grouped}${dot}${frac}` : grouped;
1649
- const sign = n < 0 ? "-" : opts.signed ? "+" : "";
1650
- const number = `${sign}${body}`;
1651
- const symbolMode = spec.display === "symbol" && !!spec.symbol;
1652
- const affix = symbolMode ? spec.symbol : spec.code;
1653
- const prefix = (spec.position ?? "suffix") === "prefix";
1654
- const gap = symbolMode && prefix ? "" : " ";
1655
- return prefix ? `${affix}${gap}${number}` : `${number}${gap}${affix}`;
1735
+ function formatRtp(rtp) {
1736
+ return `${rtp.toFixed(2)}%`;
1737
+ }
1738
+ function formatTimes(x) {
1739
+ return `${x.toLocaleString("en-US", { maximumFractionDigits: 2 })}\xD7`;
1740
+ }
1741
+ function modeStatsItems(facts, tr = (s) => s) {
1742
+ const out = [];
1743
+ for (const m of facts?.modes ?? []) {
1744
+ if (m.rtp != null) out.push({ label: `${tr("RTP")} \xB7 ${tr(m.name)}`, value: formatRtp(m.rtp) });
1745
+ if (m.maxWinX != null) out.push({ label: `${tr("Max win")} \xB7 ${tr(m.name)}`, value: formatTimes(m.maxWinX) });
1746
+ }
1747
+ if (facts?.volatility) out.push({ label: tr("Volatility"), value: tr(facts.volatility) });
1748
+ if (facts?.maxWinCapX != null) out.push({ label: tr("Round cap"), value: formatTimes(facts.maxWinCapX) });
1749
+ return out;
1750
+ }
1751
+ function factsVars(facts, extra) {
1752
+ const vars = {};
1753
+ for (const m of facts?.modes ?? []) {
1754
+ if (!m.id) continue;
1755
+ vars[`name.${m.id}`] = m.name;
1756
+ if (m.rtp != null) vars[`rtp.${m.id}`] = formatRtp(m.rtp);
1757
+ if (m.maxWinX != null) vars[`maxWin.${m.id}`] = formatTimes(m.maxWinX);
1758
+ if (m.cost != null) vars[`cost.${m.id}`] = formatTimes(m.cost);
1759
+ }
1760
+ const fs = facts?.freeSpins;
1761
+ if (fs) {
1762
+ if (fs.count != null) vars["freeSpins.count"] = fs.count;
1763
+ if (fs.retrigger != null) vars["freeSpins.retrigger"] = fs.retrigger ? "can" : "cannot";
1764
+ }
1765
+ if (facts?.volatility) vars["volatility"] = facts.volatility;
1766
+ if (facts?.maxWinCapX != null) vars["maxWinCap"] = formatTimes(facts.maxWinCapX);
1767
+ return extra ? { ...vars, ...extra } : vars;
1768
+ }
1769
+ function scanBlocks(blocks, resolve = (s) => s) {
1770
+ const scan = { text: "", headings: "", covers: /* @__PURE__ */ new Set(), hasModeStats: false, hasLegal: false, sections: [] };
1771
+ let section = { heading: "", text: "", explains: /* @__PURE__ */ new Set(), covers: /* @__PURE__ */ new Set() };
1772
+ scan.sections.push(section);
1773
+ const addText = (t) => {
1774
+ if (typeof t === "string" && t) {
1775
+ const r = resolve(t);
1776
+ scan.text += `
1777
+ ${r}`;
1778
+ section.text += `
1779
+ ${r}`;
1780
+ }
1781
+ };
1782
+ const walk = (list) => {
1783
+ for (const b of list ?? []) {
1784
+ if (b.kind === "heading" || b.kind === "subheading") {
1785
+ section = { heading: resolve(b.text), text: "", explains: /* @__PURE__ */ new Set(), covers: /* @__PURE__ */ new Set() };
1786
+ scan.sections.push(section);
1787
+ }
1788
+ for (const c of b.covers ?? []) {
1789
+ scan.covers.add(c);
1790
+ section.covers.add(c);
1791
+ }
1792
+ if (b.explains) section.explains.add(b.explains);
1793
+ const anyB = b;
1794
+ switch (b.kind) {
1795
+ case "mode-stats":
1796
+ scan.hasModeStats = true;
1797
+ (b.extras ?? []).forEach((it) => {
1798
+ addText(it.label);
1799
+ addText(it.value);
1800
+ });
1801
+ break;
1802
+ case "legal":
1803
+ scan.hasLegal = true;
1804
+ addText(b.text);
1805
+ break;
1806
+ case "heading":
1807
+ case "subheading": {
1808
+ const r = resolve(b.text);
1809
+ scan.headings += `
1810
+ ${r}`;
1811
+ scan.text += `
1812
+ ${r}`;
1813
+ break;
1814
+ }
1815
+ case "text":
1816
+ addText(b.text);
1817
+ break;
1818
+ case "callout":
1819
+ addText(b.title);
1820
+ addText(b.text);
1821
+ break;
1822
+ case "media":
1823
+ addText(b.title);
1824
+ addText(b.text);
1825
+ break;
1826
+ case "steps":
1827
+ b.items.forEach(addText);
1828
+ break;
1829
+ case "table":
1830
+ (b.columns ?? []).forEach(addText);
1831
+ b.rows.forEach((r) => r.forEach(addText));
1832
+ break;
1833
+ case "paytable":
1834
+ b.rows.forEach((r) => {
1835
+ addText(r.symbol);
1836
+ addText(r.payouts);
1837
+ });
1838
+ break;
1839
+ case "cards":
1840
+ b.items.forEach((it) => {
1841
+ addText(it.title);
1842
+ addText(it.text);
1843
+ });
1844
+ break;
1845
+ case "stat-grid":
1846
+ b.items.forEach((it) => {
1847
+ addText(it.label);
1848
+ addText(it.value);
1849
+ });
1850
+ break;
1851
+ case "group":
1852
+ addText(b.title);
1853
+ walk(b.children);
1854
+ break;
1855
+ default:
1856
+ addText(anyB.label);
1857
+ addText(anyB.hint);
1858
+ break;
1859
+ }
1860
+ }
1861
+ };
1862
+ walk(blocks);
1863
+ return scan;
1864
+ }
1865
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1866
+ function mentionsRtp(text, rtp) {
1867
+ const s = String(rtp);
1868
+ const [int = "", frac = ""] = s.split(".");
1869
+ const body = frac ? `${escapeRe(int)}[.,]${escapeRe(frac)}0*` : `${escapeRe(int)}(?:[.,]0{1,2})?`;
1870
+ return new RegExp(`\\b${body}\\s*%`).test(text);
1871
+ }
1872
+ function mentionsTimes(text, x) {
1873
+ const digits = String(Math.trunc(Math.abs(x)));
1874
+ let grouped = "";
1875
+ for (let i = 0; i < digits.length; i++) {
1876
+ if (i > 0 && (digits.length - i) % 3 === 0) grouped += "[,.\\s\\u00a0]?";
1877
+ grouped += digits[i];
1878
+ }
1879
+ const frac = Math.abs(x) % 1 !== 0 ? `[.,]\\d+` : `(?:[.,]\\d+)?`;
1880
+ return new RegExp(`\\b${grouped}${frac}\\s*[x\xD7]`, "i").test(text);
1881
+ }
1882
+ var mentionsName = (text, name) => text.toLowerCase().includes(name.toLowerCase());
1883
+ function mentionsCost(text, cost) {
1884
+ if (mentionsTimes(text, cost) || mentionsTimes(text, cost + 1)) return true;
1885
+ if (cost > 0 && cost < 1) return new RegExp(`\\b${Math.round(cost * 100)}\\s*%`).test(text);
1886
+ return false;
1887
+ }
1888
+ function findModeSection(sections, m) {
1889
+ return sections.find(
1890
+ (s) => s.explains.has(m.id) || s.covers.has(`feature:${m.id}`) || s.heading !== "" && m.name !== "" && mentionsName(s.heading, m.name) || (m.kind === "base" || !m.kind) && /how to play|about the game/i.test(s.heading)
1891
+ );
1892
+ }
1893
+ var MIN_SECTION_PROSE = 40;
1894
+ function auditRules(facts, rules, opts = {}) {
1895
+ const out = [];
1896
+ try {
1897
+ const scan = scanBlocks(rules, opts.resolve);
1898
+ const add = (level, code, topic, message) => {
1899
+ out.push({ level, code, topic, message });
1900
+ };
1901
+ const covered = (topic) => scan.covers.has(topic);
1902
+ for (const m of facts?.modes ?? []) {
1903
+ const nameIn = mentionsName(scan.text, m.name);
1904
+ if (m.rtp == null) {
1905
+ add("required", "facts-missing-rtp", `rtp:${m.id}`, `\u201C${m.name}\u201D declares no RTP \u2014 set facts.modes[].rtp so the rules can state it.`);
1906
+ } else if (!(scan.hasModeStats || covered(`rtp:${m.id}`) || nameIn && mentionsRtp(scan.text, m.rtp))) {
1907
+ add("required", "rules-missing-rtp", `rtp:${m.id}`, `The RTP of \u201C${m.name}\u201D (${formatRtp(m.rtp)}) is not stated in the rules.`);
1908
+ }
1909
+ if (m.maxWinX == null) {
1910
+ add("required", "facts-missing-maxwin", `maxwin:${m.id}`, `\u201C${m.name}\u201D declares no max win \u2014 set facts.modes[].maxWinX so the rules can state it.`);
1911
+ } else if (!(scan.hasModeStats || covered(`maxwin:${m.id}`) || nameIn && mentionsTimes(scan.text, m.maxWinX))) {
1912
+ add("required", "rules-missing-maxwin", `maxwin:${m.id}`, `The max win of \u201C${m.name}\u201D (${formatTimes(m.maxWinX)}) is not stated in the rules.`);
1913
+ }
1914
+ if (!m.id) continue;
1915
+ const sec = findModeSection(scan.sections, m);
1916
+ if (!sec) {
1917
+ add("required", "rules-missing-mode-section", `section:${m.id}`, `\u201C${m.name}\u201D has no rules section of its own \u2014 add a heading naming it (or tag its blocks with explains: "${m.id}") and explain how the mode plays.`);
1918
+ } else {
1919
+ if (sec.text.trim().length < MIN_SECTION_PROSE) {
1920
+ add("required", "rules-mode-section-empty", `section:${m.id}`, `The \u201C${m.name}\u201D section has a heading but no real explanation \u2014 describe how the mode actually plays.`);
1921
+ }
1922
+ if (m.kind && m.kind !== "base" && m.cost != null && !(sec.covers.has(`cost:${m.id}`) || mentionsCost(sec.text, m.cost))) {
1923
+ add("required", "rules-mode-missing-cost", `cost:${m.id}`, `State what \u201C${m.name}\u201D costs (${formatTimes(m.cost)} the bet \u2014 e.g. via {{cost.${m.id}}}) inside its own section.`);
1924
+ }
1925
+ }
1926
+ }
1927
+ const fs = facts?.freeSpins;
1928
+ if (fs === void 0) {
1929
+ if (facts?.modes?.length) {
1930
+ add("recommended", "facts-missing-freespins", "freespins", "Free-spins info is highly recommended: declare facts.freeSpins { count, retrigger } \u2014 or freeSpins: false if the game has none.");
1931
+ }
1932
+ } else if (fs !== false) {
1933
+ const fsIn = covered("freespins") || /free\s*spins?/i.test(scan.text);
1934
+ if (!fsIn) {
1935
+ add("recommended", "rules-missing-freespins", "freespins", "The rules never mention the free spins this game declares.");
1936
+ } else {
1937
+ if (fs.count != null && !covered("freespins:count") && !new RegExp(`\\b${fs.count}\\b[\\s\\S]{0,60}?spins?|spins?[\\s\\S]{0,60}?\\b${fs.count}\\b`, "i").test(scan.text)) {
1938
+ add("recommended", "rules-missing-fs-count", "freespins:count", `State that the bonus awards exactly ${fs.count} free spins.`);
1939
+ }
1940
+ if (fs.retrigger != null && !covered("freespins:retrigger") && !/re-?trigger/i.test(scan.text)) {
1941
+ add("recommended", "rules-missing-fs-retrigger", "freespins:retrigger", `State that free spins ${fs.retrigger ? "CAN" : "CANNOT"} be retriggered.`);
1942
+ }
1943
+ }
1944
+ }
1945
+ if (rules?.length && !scan.hasLegal && !/malfunction/i.test(scan.text)) {
1946
+ add("recommended", "rules-missing-legal", "legal", "Add the platform legal disclaimer (a `legal` block).");
1947
+ }
1948
+ if (rules?.length && !covered("controls") && !/\bcontrols?\b/i.test(scan.headings)) {
1949
+ add("recommended", "rules-missing-controls", "controls", 'Describe every interactive control (a \u201CControls\u201D section or covers: ["controls"]).');
1950
+ }
1951
+ out.sort((a, b) => a.level === b.level ? 0 : a.level === "required" ? -1 : 1);
1952
+ } catch {
1953
+ }
1954
+ return out;
1656
1955
  }
1657
1956
 
1658
1957
  // src/OpenUI.ts
@@ -1704,6 +2003,11 @@ var OpenUI = class {
1704
2003
  replay = new Signal(false);
1705
2004
  /** Game name + version (shown in the menu footer; for support / certification). */
1706
2005
  gameInfo = {};
2006
+ /** Declared GAME FACTS (modes + RTP/max win, free spins, volatility, cap) — seeded
2007
+ * from `spec.facts`, extended at runtime via {@link declareFacts} (the buy-feature
2008
+ * modal declares its configured features automatically). Drives the `mode-stats`
2009
+ * rules block + the rules-completeness audit in the info menu. */
2010
+ facts = new Signal({});
1707
2011
  /** Minimum round duration (ms) from jurisdiction — stored for the GAME to enforce. */
1708
2012
  minimumRoundDuration = 0;
1709
2013
  /** Buy-feature confirm threshold, as a multiple of the base bet. A play (buy or
@@ -1711,6 +2015,13 @@ var OpenUI = class {
1711
2015
  * — Stake requires that bet modes above 2× never activate on a single click. `0`
1712
2016
  * (the default) = off. Set via `spec.buyFeature.confirmAboveCost` or a jurisdiction. */
1713
2017
  confirmBuyAboveCost = 0;
2018
+ /** When autoplay stops while the balance can no longer cover the next round —
2019
+ * whether the built-in RG enforcement stopped it, the game's own pre-check did, or
2020
+ * the player pressed Stop already broke — pop the SAME insufficient-funds modal a
2021
+ * manual spin shows (ERR_IPB) instead of ending silently. A normal stop with funds
2022
+ * in hand shows nothing. Default on; `spec.autoplay.insufficientFundsNotice: false`
2023
+ * opts out. */
2024
+ autoplayInsufficientNotice = true;
1714
2025
  sessionNet = 0;
1715
2026
  prevVolumes = null;
1716
2027
  /** The frozen UISpec `createUI` built this from, if any — authored = run = tested. */
@@ -1850,6 +2161,13 @@ var OpenUI = class {
1850
2161
  this.disposers.push(this.betStepper.index.subscribe(syncStepperButtons));
1851
2162
  this.bet.set(this.betStepper.value);
1852
2163
  syncStepperButtons();
2164
+ this.disposers.push(
2165
+ this.bus.on("autoplayStopped", () => {
2166
+ if (!this.autoplayInsufficientNotice || this.replay.get()) return;
2167
+ if (this.noticePanel.isOpen) return;
2168
+ if (this.balance.get() < this.bet.get()) this.showRgsError("ERR_IPB");
2169
+ })
2170
+ );
1853
2171
  this.disposers.push(
1854
2172
  this.noticePanel.state.subscribe(() => {
1855
2173
  if (this.noticePanel.isOpen) return;
@@ -1940,6 +2258,12 @@ var OpenUI = class {
1940
2258
  if (this.autoplay.isActive && this.balance.get() < this.bet.get()) this.autoplay.stop();
1941
2259
  }
1942
2260
  }
2261
+ /** Merge more game facts into {@link facts} (modes merge by id; defined incoming
2262
+ * fields win). The info menu re-audits + re-renders its rules automatically. */
2263
+ declareFacts(add) {
2264
+ if (!add || typeof add !== "object") return;
2265
+ this.facts.set(mergeFacts(this.facts.get(), add));
2266
+ }
1943
2267
  /** Reset the running session net + aggregates + timer (e.g. on a fresh session). */
1944
2268
  resetSession() {
1945
2269
  this.sessionNet = 0;
@@ -2273,6 +2597,12 @@ function walkBlock(b, src, out) {
2273
2597
  add(it.value);
2274
2598
  });
2275
2599
  break;
2600
+ case "mode-stats":
2601
+ (b.extras ?? []).forEach((it) => {
2602
+ add(it.label);
2603
+ add(it.value);
2604
+ });
2605
+ break;
2276
2606
  case "group":
2277
2607
  add(b.title);
2278
2608
  b.children.forEach((c, i) => walkBlock(c, `${src}.children[${i}]`, out));
@@ -2366,6 +2696,7 @@ function createUI(spec = {}, hooks = {}) {
2366
2696
  }
2367
2697
  if (typeof spec.rtp === "number") ui.rtp.set(spec.rtp);
2368
2698
  if (spec.game) ui.gameInfo = { name: spec.game.name, version: spec.game.version };
2699
+ if (spec.facts) ui.declareFacts(spec.facts);
2369
2700
  if (spec.betLadder?.levels?.length) {
2370
2701
  ui.betStepper.setLevels(spec.betLadder.levels, spec.betLadder.index ?? 0);
2371
2702
  ui.bet.set(ui.betStepper.value);
@@ -2378,6 +2709,7 @@ function createUI(spec = {}, hooks = {}) {
2378
2709
  }
2379
2710
  if (spec.autoplay?.lossLimits) ui.autoplay.setLossLimitOptions(spec.autoplay.lossLimits);
2380
2711
  if (spec.autoplay?.winLimits) ui.autoplay.setWinLimitOptions(spec.autoplay.winLimits);
2712
+ if (spec.autoplay?.insufficientFundsNotice === false) ui.autoplayInsufficientNotice = false;
2381
2713
  if (spec.turbo) {
2382
2714
  ui.turbo.setModes(resolveTurboModes(spec.turbo.modes));
2383
2715
  if (spec.turbo.index != null) ui.turbo.setIndex(spec.turbo.index);
@@ -2493,6 +2825,43 @@ function composeMenu(menu, opts = {}) {
2493
2825
  return out;
2494
2826
  }
2495
2827
 
2828
+ // src/spec/rulesDoc.ts
2829
+ function isRulesDoc(doc) {
2830
+ if (!doc || typeof doc !== "object") return false;
2831
+ const d = doc;
2832
+ return d.version === 1 && Array.isArray(d.blocks);
2833
+ }
2834
+ var mergeLocaleMaps = (under, over) => {
2835
+ const out = {};
2836
+ for (const src of [under, over]) {
2837
+ for (const [loc, map] of Object.entries(src ?? {})) out[loc] = { ...out[loc], ...map };
2838
+ }
2839
+ return out;
2840
+ };
2841
+ function applyRulesDoc(spec, doc) {
2842
+ if (!isRulesDoc(doc)) return spec;
2843
+ const messages = mergeLocaleMaps(doc.messages, spec.locale?.messages);
2844
+ const socialMessages = mergeLocaleMaps(doc.socialMessages, spec.locale?.socialMessages);
2845
+ return {
2846
+ ...spec,
2847
+ menu: { ...spec.menu, rules: doc.blocks },
2848
+ locale: {
2849
+ locale: spec.locale?.locale ?? "en",
2850
+ messages: Object.keys(messages).length ? messages : { en: {} },
2851
+ ...Object.keys(socialMessages).length ? { socialMessages } : {}
2852
+ },
2853
+ ...doc.facts ? { facts: mergeFacts(doc.facts, spec.facts ?? {}) } : {}
2854
+ };
2855
+ }
2856
+ function auditRulesDoc(doc, opts = {}) {
2857
+ if (!isRulesDoc(doc)) return [];
2858
+ const facts = opts.facts ? mergeFacts(doc.facts ?? {}, opts.facts) : doc.facts;
2859
+ const dict = doc.messages?.[opts.locale ?? "en"] ?? {};
2860
+ const vars = factsVars(facts);
2861
+ const resolve = (s) => (dict[s] ?? s).replace(/\{\{([\w.-]+)\}\}/g, (_m, k) => String(vars[k] ?? `{{${k}}}`));
2862
+ return auditRules(facts, doc.blocks, { resolve });
2863
+ }
2864
+
2496
2865
  // src/spec/defaultHudSpec.ts
2497
2866
  var defaultHudSpec = Object.freeze({
2498
2867
  meta: { id: "open-ui-reference-hud", version: 1 },
@@ -2582,6 +2951,12 @@ function buildBetLadder(cfg, divisor = API_AMOUNT_DIVISOR) {
2582
2951
  const found = levels.findIndex((v) => v >= want);
2583
2952
  return { levels, index: found < 0 ? 0 : found };
2584
2953
  }
2954
+ function resolveBetLadder(availableBets, defaultBet, fallback = [0.2, 0.5, 1, 2, 5, 10]) {
2955
+ const levels = availableBets?.length ? availableBets.slice() : fallback.slice();
2956
+ const exact = levels.indexOf(defaultBet);
2957
+ const index = exact >= 0 ? exact : Math.max(0, levels.findIndex((b) => b >= defaultBet));
2958
+ return { levels, index };
2959
+ }
2585
2960
  function clampBet(amount, cfg, divisor = API_AMOUNT_DIVISOR) {
2586
2961
  const min = (cfg.minBet ?? 0) / divisor;
2587
2962
  const max = (cfg.maxBet ?? Infinity) / divisor;
@@ -2624,6 +2999,9 @@ exports.TurboControl = TurboControl;
2624
2999
  exports.Turn = Turn;
2625
3000
  exports.ValueDisplay = ValueDisplay;
2626
3001
  exports.applyJurisdiction = applyJurisdiction;
3002
+ exports.applyRulesDoc = applyRulesDoc;
3003
+ exports.auditRules = auditRules;
3004
+ exports.auditRulesDoc = auditRulesDoc;
2627
3005
  exports.breakpointFor = breakpointFor;
2628
3006
  exports.buildBetLadder = buildBetLadder;
2629
3007
  exports.buildBlocks = buildBlocks;
@@ -2648,16 +3026,25 @@ exports.displayDigits = displayDigits;
2648
3026
  exports.effect = effect;
2649
3027
  exports.errorBlocks = errorBlocks;
2650
3028
  exports.extendTheme = extendTheme;
3029
+ exports.factsVars = factsVars;
2651
3030
  exports.findForbiddenPhrases = findForbiddenPhrases;
2652
3031
  exports.formatAmount = formatAmount;
3032
+ exports.formatAmountPrecise = formatAmountPrecise;
3033
+ exports.formatRtp = formatRtp;
3034
+ exports.formatTimes = formatTimes;
2653
3035
  exports.installResponsive = installResponsive;
2654
3036
  exports.integerDigits = integerDigits;
3037
+ exports.isRulesDoc = isRulesDoc;
2655
3038
  exports.isSafeColor = isSafeColor;
2656
3039
  exports.isSocialCurrency = isSocialCurrency;
2657
3040
  exports.landscapeDefaultLayouts = landscapeDefaultLayouts;
3041
+ exports.mergeFacts = mergeFacts;
3042
+ exports.modeStatsItems = modeStatsItems;
3043
+ exports.neededDecimals = neededDecimals;
2658
3044
  exports.openuiDefaults = openuiDefaults;
2659
3045
  exports.openuiSocialDefaults = openuiSocialDefaults;
2660
3046
  exports.portraitDefaultLayouts = portraitDefaultLayouts;
3047
+ exports.resolveBetLadder = resolveBetLadder;
2661
3048
  exports.resolveCurrency = resolveCurrency;
2662
3049
  exports.resolvePlacement = resolvePlacement;
2663
3050
  exports.resolveTheme = resolveTheme;