@energy8platform/game-engine 0.38.0 → 0.40.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/slot.cjs.js CHANGED
@@ -1075,6 +1075,14 @@ class ReelSpinController {
1075
1075
  // override only what they need.
1076
1076
  //
1077
1077
  // Design notes are in docs/reels-analysis-and-design.md.
1078
+ /** Resolve a `PerReel<T>` for one reel. `undefined` (or a hole in the array) yields `fallback`. */
1079
+ function perReelValue(value, reel, fallback) {
1080
+ if (value === undefined)
1081
+ return fallback;
1082
+ if (Array.isArray(value))
1083
+ return value[reel] ?? fallback;
1084
+ return value;
1085
+ }
1078
1086
  const FEATURE_KEYS = [
1079
1087
  'reelModifier', // pre-spin
1080
1088
  'giant',
@@ -1120,6 +1128,11 @@ const DEFAULT_REEL_CONFIG = {
1120
1128
  intensity: 'full',
1121
1129
  slamStop: true,
1122
1130
  symbolsPerReel: 6,
1131
+ cellStagger: 24,
1132
+ reelStaggerFactor: 0.4,
1133
+ dropFallFactor: 0.6,
1134
+ dropOrder: 'top-down',
1135
+ dropSequence: 'parallel',
1123
1136
  },
1124
1137
  anticipation: {
1125
1138
  enabled: false,
@@ -1128,6 +1141,9 @@ const DEFAULT_REEL_CONFIG = {
1128
1141
  reels: 'trailing',
1129
1142
  slowdownFactor: 0.3,
1130
1143
  holdMs: 400,
1144
+ decide: null,
1145
+ progressiveSlowdown: 1,
1146
+ progressiveHoldMs: 0,
1131
1147
  zoom: { enabled: false, scale: 1.15, ms: 600 },
1132
1148
  },
1133
1149
  cascade: {
@@ -1254,11 +1270,28 @@ function mergeReelConfig(base, partial) {
1254
1270
  function resolveReelConfig(partial) {
1255
1271
  return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
1256
1272
  }
1273
+ /** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
1274
+ function isCloneableRecord(v) {
1275
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
1276
+ return false;
1277
+ const proto = Object.getPrototypeOf(v);
1278
+ return proto === Object.prototype || proto === null;
1279
+ }
1280
+ /**
1281
+ * Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
1282
+ * functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
1283
+ * anything that is not a plain object/array pass through by reference.
1284
+ */
1257
1285
  function structuredCloneSafe(v) {
1258
- // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
1259
- if (typeof structuredClone === 'function')
1260
- return structuredClone(v);
1261
- return JSON.parse(JSON.stringify(v));
1286
+ if (Array.isArray(v))
1287
+ return v.map((item) => structuredCloneSafe(item));
1288
+ if (isCloneableRecord(v)) {
1289
+ const out = {};
1290
+ for (const [k, val] of Object.entries(v))
1291
+ out[k] = structuredCloneSafe(val);
1292
+ return out;
1293
+ }
1294
+ return v;
1262
1295
  }
1263
1296
  /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
1264
1297
  function effectiveRowsPerReel(grid) {
@@ -1495,6 +1528,7 @@ class SpinEngine {
1495
1528
  _killed = false;
1496
1529
  _shaking = false;
1497
1530
  _temp = [];
1531
+ _deferred = new Set();
1498
1532
  constructor(grid, resolve, cfg, win) {
1499
1533
  this._grid = grid;
1500
1534
  this._resolve = resolve;
@@ -1538,6 +1572,8 @@ class SpinEngine {
1538
1572
  const cols = this._grid.cols;
1539
1573
  const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
1540
1574
  const anticipate = new Set(opts?.anticipateReels ?? []);
1575
+ const defer = new Set(opts?.deferReveal ?? []);
1576
+ const holds = [];
1541
1577
  const out = [];
1542
1578
  for (let reel = 0; reel < cols; reel++) {
1543
1579
  const idx = order(reel);
@@ -1553,23 +1589,70 @@ class SpinEngine {
1553
1589
  else
1554
1590
  stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
1555
1591
  const isAnticipated = anticipate.has(reel);
1556
- if (isAnticipated)
1557
- stopTime += (opts?.anticipateHoldMs ?? 0) * f;
1592
+ const hold = isAnticipated ? perReelValue(opts?.anticipateHoldMs, reel, 0) * f : 0;
1593
+ stopTime += hold;
1594
+ holds[reel] = hold;
1595
+ const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
1558
1596
  out.push({
1559
1597
  reel,
1560
1598
  stopTime,
1561
1599
  landing: data.targetGrid[reel] ?? [],
1562
1600
  settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
1563
1601
  anticipated: isAnticipated,
1602
+ slowdown: Math.max(1, 1 / (speed || 1)),
1603
+ deferred: defer.has(reel),
1564
1604
  });
1565
1605
  }
1606
+ if (this._cfg.style === 'cascade-drop')
1607
+ this._planDrop(out, holds, f, order);
1566
1608
  return out;
1567
1609
  }
1610
+ /**
1611
+ * `cascade-drop` lays its reels out on a different clock from the tape styles: a reel is a
1612
+ * sequence of per-cell arrivals, not one deceleration. Overwrite `stopTime` with the moment the
1613
+ * reel has fully landed and fill in `cellStopTimes`, so `plan()` stays the single source of truth
1614
+ * for WHEN anything happens — `_runDrop` below only executes these numbers.
1615
+ */
1616
+ _planDrop(out, holds, f, order) {
1617
+ const bottomUp = this._cfg.dropOrder === 'bottom-up';
1618
+ // walk the reels in STOP order, so `stopOrder: 'rtl'` reverses the drop as well
1619
+ const byPosition = [];
1620
+ for (let reel = 0; reel < out.length; reel++)
1621
+ byPosition[order(reel)] = reel;
1622
+ // the position from which reels stop overlapping and start queueing behind each other
1623
+ const chainFrom = this._cfg.dropSequence === 'chained'
1624
+ ? 0
1625
+ : this._cfg.dropSequence === 'chained-when-anticipated'
1626
+ ? byPosition.findIndex((reel) => out[reel].anticipated)
1627
+ : -1;
1628
+ let previousEnd = 0;
1629
+ for (let position = 0; position < byPosition.length; position++) {
1630
+ const p = out[byPosition[position]];
1631
+ const rows = this._grid.rowsOf(p.reel);
1632
+ const scale = f * p.slowdown;
1633
+ const gap = this._cfg.stopStagger * this._cfg.reelStaggerFactor * scale;
1634
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * scale;
1635
+ // by formula while reels may overlap; queued behind the previous reel once the chain starts
1636
+ const chained = chainFrom >= 0 && position > 0 && position >= chainFrom;
1637
+ const start = (chained ? previousEnd + gap : position * gap) + (holds[p.reel] ?? 0);
1638
+ const cells = [];
1639
+ for (let i = 0; i < rows; i++) {
1640
+ const row = bottomUp ? rows - 1 - i : i;
1641
+ cells[row] = start + i * this._cfg.cellStagger * scale + fall;
1642
+ }
1643
+ p.cellStopTimes = cells;
1644
+ p.stopTime = cells.length ? Math.max(...cells) : start;
1645
+ previousEnd = p.stopTime;
1646
+ }
1647
+ }
1568
1648
  /** Execute the spin for every reel concurrently. */
1569
1649
  async run(data, opts) {
1570
1650
  this._killed = false;
1571
1651
  this._temp = [];
1572
1652
  const plan = this.plan(data, opts);
1653
+ // remembered for skip(): a deferred reel's cells belong to the caller, not to us
1654
+ this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
1655
+ opts?.onPlan?.(plan);
1573
1656
  const f = this.scale(opts);
1574
1657
  await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
1575
1658
  this._cleanupTemp();
@@ -1587,9 +1670,9 @@ class SpinEngine {
1587
1670
  return this._runSwap(p, data, opts, f);
1588
1671
  }
1589
1672
  }
1590
- /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
1591
- slowOf(p, opts) {
1592
- return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
1673
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
1674
+ slowOf(p) {
1675
+ return p.slowdown;
1593
1676
  }
1594
1677
  // ── swap: cycle symbols quickly in the real cells, then land ──────────────
1595
1678
  async _runSwap(p, data, opts, f) {
@@ -1600,7 +1683,7 @@ class SpinEngine {
1600
1683
  const blur = this._applyBlur(cells, true);
1601
1684
  const tickMs = 1000 / 30;
1602
1685
  // anticipation makes the reel spin longer before it lands
1603
- const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
1686
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
1604
1687
  for (let i = 0; i < ticks; i++) {
1605
1688
  if (this._killed)
1606
1689
  break;
@@ -1609,8 +1692,24 @@ class SpinEngine {
1609
1692
  await Tween.delay(tickMs);
1610
1693
  }
1611
1694
  blur?.();
1612
- for (let r = 0; r < cells.length; r++)
1613
- cells[r].setData(p.landing[r] ?? { symbol: null });
1695
+ if (p.deferred) {
1696
+ // the caller owns this reel's result — go dark and unseated instead of handing it back.
1697
+ // NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
1698
+ // its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
1699
+ // the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
1700
+ cells.forEach((c) => {
1701
+ c.setData({ symbol: null });
1702
+ c.visible = false;
1703
+ });
1704
+ opts?.onReelStop?.(p.reel, p);
1705
+ return;
1706
+ }
1707
+ for (let r = 0; r < cells.length; r++) {
1708
+ const cell = p.landing[r] ?? { symbol: null };
1709
+ cells[r].setData(cell);
1710
+ opts?.onCellSeated?.(p.reel, r, cell);
1711
+ }
1712
+ opts?.onReelStop?.(p.reel, p);
1614
1713
  await this._settle(p.reel, p.settle, f);
1615
1714
  await this._frameShake(p.landing);
1616
1715
  }
@@ -1645,7 +1744,7 @@ class SpinEngine {
1645
1744
  this._grid.addChild(tape);
1646
1745
  this._temp.push(tape);
1647
1746
  const clearBlur = this._applyBlur([tape], true);
1648
- const slow = this.slowOf(p, opts);
1747
+ const slow = this.slowOf(p);
1649
1748
  // overshoot/settle honour the configured settle (amp in px, easing)
1650
1749
  const overshoot = p.settle.amp || step * 0.18;
1651
1750
  await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
@@ -1655,12 +1754,21 @@ class SpinEngine {
1655
1754
  }
1656
1755
  clearBlur?.();
1657
1756
  await Tween.to(tape, { y: restY }, Math.max(120, p.settle.ms), easingByName(this._cfg.settle.easing));
1658
- // hand the result back to the real cells
1659
- for (let r = 0; r < rows; r++)
1660
- realCells[r].setData(p.landing[r] ?? { symbol: null });
1661
- realCells.forEach((c) => (c.visible = true));
1757
+ // hand the result back to the real cells — unless the caller deferred the reveal, in which
1758
+ // case the tape simply goes away and the reel is left dark, unseated and owned by the caller
1759
+ if (!p.deferred) {
1760
+ for (let r = 0; r < rows; r++) {
1761
+ const cell = p.landing[r] ?? { symbol: null };
1762
+ realCells[r].setData(cell);
1763
+ opts?.onCellSeated?.(p.reel, r, cell);
1764
+ }
1765
+ realCells.forEach((c) => (c.visible = true));
1766
+ }
1662
1767
  tape.destroy();
1663
1768
  this._temp = this._temp.filter((t) => t !== tape);
1769
+ opts?.onReelStop?.(p.reel, p);
1770
+ if (p.deferred)
1771
+ return;
1664
1772
  // squash the real cells on impact when enabled
1665
1773
  if (this._cfg.squash.enabled)
1666
1774
  await Promise.all(realCells.map((c) => this._squashCell(c, f)));
@@ -1669,22 +1777,36 @@ class SpinEngine {
1669
1777
  // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
1670
1778
  async _runDrop(p, opts, f) {
1671
1779
  const rows = this._grid.rowsOf(p.reel);
1780
+ if (p.deferred) {
1781
+ // nothing to drop — the caller brings this reel in itself
1782
+ for (let r = 0; r < rows; r++)
1783
+ this._grid.getCell(p.reel, r).visible = false;
1784
+ opts?.onReelStop?.(p.reel, p);
1785
+ return;
1786
+ }
1672
1787
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
1673
- const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
1788
+ const slow = this.slowOf(p); // anticipation drops the reel in more slowly
1789
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * f * slow;
1790
+ const schedule = p.cellStopTimes ?? [];
1674
1791
  await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
1675
1792
  if (this._killed)
1676
1793
  return;
1677
1794
  const cell = this._grid.getCell(p.reel, r);
1678
1795
  const to = this._grid.cellPosition(p.reel, r);
1679
- cell.setData(p.landing[r] ?? { symbol: null });
1796
+ const data = p.landing[r] ?? { symbol: null };
1797
+ cell.setData(data);
1680
1798
  cell.position.set(to.x, to.y - step * (rows + 1));
1681
1799
  cell.alpha = 1;
1682
- const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
1800
+ // the plan says WHEN this cell seats; back off the fall to get when it must let go
1801
+ const delay = Math.max(0, (schedule[r] ?? fall) - fall);
1683
1802
  if (delay)
1684
1803
  await Tween.delay(delay);
1685
- await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * 0.6 * f * slow, easingByName(this._cfg.settle.easing));
1804
+ await Tween.to(cell, { 'position.y': to.y }, fall, easingByName(this._cfg.settle.easing));
1805
+ // the impact frame — fired before the squash so a game can sync its own hit feedback
1806
+ opts?.onCellSeated?.(p.reel, r, data);
1686
1807
  await this._squashCell(cell, f);
1687
1808
  }));
1809
+ opts?.onReelStop?.(p.reel, p);
1688
1810
  await this._frameShake(p.landing);
1689
1811
  }
1690
1812
  // ── shared helpers ────────────────────────────────────────────────────────
@@ -1748,13 +1870,17 @@ class SpinEngine {
1748
1870
  Tween.killTweensOf(this._grid);
1749
1871
  this._grid.x = 0; // undo any in-flight frame shake
1750
1872
  for (let c = 0; c < this._grid.cols; c++) {
1873
+ // a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
1874
+ const deferred = this._deferred.has(c);
1751
1875
  for (let r = 0; r < this._grid.rowsOf(c); r++) {
1752
1876
  const cell = this._grid.getCell(c, r);
1753
1877
  if (cell.destroyed)
1754
1878
  continue;
1755
1879
  Tween.killTweensOf(cell);
1756
- cell.visible = true;
1757
- cell.alpha = 1;
1880
+ if (!deferred) {
1881
+ cell.visible = true;
1882
+ cell.alpha = 1;
1883
+ }
1758
1884
  cell.filters = [];
1759
1885
  cell.scale.set(1);
1760
1886
  }
@@ -1767,6 +1893,8 @@ class SpinEngine {
1767
1893
  //
1768
1894
  // Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
1769
1895
  // the already-resolved landing grid (presentation only — never a secondary outcome decision).
1896
+ /** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
1897
+ const none = () => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
1770
1898
  class AnticipationController {
1771
1899
  _cfg;
1772
1900
  constructor(cfg) {
@@ -1790,19 +1918,23 @@ class AnticipationController {
1790
1918
  */
1791
1919
  decide(targetGrid) {
1792
1920
  if (!this._cfg.enabled)
1793
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1921
+ return none();
1922
+ // A game-supplied predicate replaces the symbol counting entirely.
1923
+ if (this._cfg.decide) {
1924
+ const custom = this._cfg.decide(targetGrid);
1925
+ if (!custom)
1926
+ return none();
1927
+ const o = Array.isArray(custom) ? { reels: custom } : custom;
1928
+ if (!o.reels?.length)
1929
+ return none();
1930
+ return this.build(o.reels.slice(), o.slowdown, o.holdMs);
1931
+ }
1794
1932
  if (Array.isArray(this._cfg.reels)) {
1795
1933
  // explicit reel list — arm only if the threshold is met somewhere on the board
1796
1934
  const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
1797
- const active = total >= this._cfg.threshold;
1798
- return active
1799
- ? {
1800
- active,
1801
- reels: this._cfg.reels.slice(),
1802
- slowdown: this._cfg.slowdownFactor,
1803
- holdMs: this._cfg.holdMs,
1804
- }
1805
- : { active: false, reels: [], slowdown: 1, holdMs: 0 };
1935
+ if (total < this._cfg.threshold)
1936
+ return none();
1937
+ return this.build(this._cfg.reels.slice());
1806
1938
  }
1807
1939
  // 'trailing': find the reel where the cumulative count hits the threshold
1808
1940
  let running = 0;
@@ -1815,13 +1947,37 @@ class AnticipationController {
1815
1947
  }
1816
1948
  }
1817
1949
  if (armReel < 0)
1818
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1950
+ return none();
1819
1951
  const reels = [];
1820
1952
  for (let c = armReel + 1; c < targetGrid.length; c++)
1821
1953
  reels.push(c);
1822
1954
  if (reels.length === 0)
1823
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1824
- return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
1955
+ return none();
1956
+ return this.build(reels);
1957
+ }
1958
+ /** Assemble a decision, applying the configured progression unless the caller pinned values. */
1959
+ build(reels, slowdown, holdMs) {
1960
+ return {
1961
+ active: true,
1962
+ reels,
1963
+ slowdown: slowdown ??
1964
+ this.ramp(reels, this._cfg.slowdownFactor, (base, i) => i === 0 ? base : base * Math.pow(this._cfg.progressiveSlowdown, i)),
1965
+ holdMs: holdMs ??
1966
+ this.ramp(reels, this._cfg.holdMs, (base, i) => base + this._cfg.progressiveHoldMs * i),
1967
+ };
1968
+ }
1969
+ /**
1970
+ * A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
1971
+ * read a per-reel value straight out of `plan()`.
1972
+ */
1973
+ ramp(reels, base, at) {
1974
+ if (at(base, 1) === base)
1975
+ return base;
1976
+ const out = [];
1977
+ reels.forEach((reel, i) => {
1978
+ out[reel] = at(base, i);
1979
+ });
1980
+ return out;
1825
1981
  }
1826
1982
  /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
1827
1983
  async zoomIn(grid) {
@@ -2873,6 +3029,33 @@ function createReelSystem(opts) {
2873
3029
  function ctx(freeSpins) {
2874
3030
  return { grid, resolve, cfg: config, fx, board, freeSpins, log };
2875
3031
  }
3032
+ /**
3033
+ * Which reels get the anticipation treatment for this spin. An explicit `anticipateReels` on the
3034
+ * run options WINS over the configured decision — passing it is how a game drives anticipation
3035
+ * from its own logic. Omit it and the configured `AnticipationController` decides.
3036
+ */
3037
+ function resolveAnticipation(target, runOpts) {
3038
+ const explicit = runOpts?.anticipateReels;
3039
+ if (!explicit)
3040
+ return anticipation.decide(target);
3041
+ if (!explicit.length)
3042
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
3043
+ return {
3044
+ active: true,
3045
+ reels: explicit.slice(),
3046
+ slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
3047
+ holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
3048
+ };
3049
+ }
3050
+ /** Fold a resolved decision back onto the caller's run options (everything else passes through). */
3051
+ function mergeAnticipation(runOpts, decision) {
3052
+ return {
3053
+ ...runOpts,
3054
+ anticipateReels: decision.active ? decision.reels : undefined,
3055
+ anticipateSlowdown: decision.slowdown,
3056
+ anticipateHoldMs: decision.holdMs,
3057
+ };
3058
+ }
2876
3059
  buildGrid();
2877
3060
  const api = {
2878
3061
  view,
@@ -2915,20 +3098,22 @@ function createReelSystem(opts) {
2915
3098
  update(partial) {
2916
3099
  api.setConfig(mergeReelConfig(config, partial));
2917
3100
  },
3101
+ anticipationFor(target, runOpts) {
3102
+ return resolveAnticipation(target, runOpts);
3103
+ },
3104
+ planSpin(target, runOpts) {
3105
+ const decision = resolveAnticipation(target, runOpts);
3106
+ return spin.plan({ targetGrid: target }, mergeAnticipation(runOpts, decision));
3107
+ },
2918
3108
  async spin(target, runOpts) {
2919
3109
  const data = { targetGrid: target };
2920
- const decision = anticipation.decide(target);
3110
+ const decision = resolveAnticipation(target, runOpts);
2921
3111
  let resetZoom = null;
2922
3112
  if (decision.active) {
2923
3113
  log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
2924
3114
  resetZoom = await anticipation.zoomIn(grid);
2925
3115
  }
2926
- await spin.run(data, {
2927
- ...runOpts,
2928
- anticipateReels: decision.active ? decision.reels : undefined,
2929
- anticipateSlowdown: decision.slowdown,
2930
- anticipateHoldMs: decision.holdMs,
2931
- });
3116
+ await spin.run(data, mergeAnticipation(runOpts, decision));
2932
3117
  if (resetZoom)
2933
3118
  await resetZoom();
2934
3119
  board = target;
@@ -3199,6 +3384,7 @@ exports.createReelSystem = createReelSystem;
3199
3384
  exports.easingByName = easingByName;
3200
3385
  exports.effectiveRowsPerReel = effectiveRowsPerReel;
3201
3386
  exports.mergeReelConfig = mergeReelConfig;
3387
+ exports.perReelValue = perReelValue;
3202
3388
  exports.pickTier = pickTier;
3203
3389
  exports.resolveGeometry = resolveGeometry;
3204
3390
  exports.resolveGridGeometry = resolveGridGeometry;