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