@energy8platform/game-engine 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +121 -0
  2. package/dist/animation.cjs.js +18 -3
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.esm.js +18 -3
  5. package/dist/animation.esm.js.map +1 -1
  6. package/dist/core.cjs.js +51 -159
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.esm.js +52 -160
  9. package/dist/core.esm.js.map +1 -1
  10. package/dist/host.cjs.js +88 -189
  11. package/dist/host.cjs.js.map +1 -1
  12. package/dist/host.d.ts +6 -0
  13. package/dist/host.esm.js +90 -191
  14. package/dist/host.esm.js.map +1 -1
  15. package/dist/index.cjs.js +51 -159
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +10 -11
  18. package/dist/index.esm.js +52 -160
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/react.cjs.js +18 -3
  21. package/dist/react.cjs.js.map +1 -1
  22. package/dist/react.esm.js +18 -3
  23. package/dist/react.esm.js.map +1 -1
  24. package/dist/slot.cjs.js +1872 -31
  25. package/dist/slot.cjs.js.map +1 -1
  26. package/dist/slot.d.ts +555 -5
  27. package/dist/slot.esm.js +1857 -33
  28. package/dist/slot.esm.js.map +1 -1
  29. package/dist/ui.cjs.js +18 -3
  30. package/dist/ui.cjs.js.map +1 -1
  31. package/dist/ui.esm.js +18 -3
  32. package/dist/ui.esm.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/animation/Tween.ts +14 -3
  35. package/src/core/GameApplication.ts +6 -5
  36. package/src/host/shellConfig.ts +44 -31
  37. package/src/loading/LoadingScene.ts +31 -174
  38. package/src/slot/anim/easing-map.ts +16 -2
  39. package/src/slot/cascade/TumbleController.ts +230 -0
  40. package/src/slot/config/ReelSystemConfig.ts +559 -0
  41. package/src/slot/config/presets.ts +222 -0
  42. package/src/slot/features/extra.ts +189 -0
  43. package/src/slot/features/index.ts +44 -0
  44. package/src/slot/features/symbols.ts +183 -0
  45. package/src/slot/features/types.ts +136 -0
  46. package/src/slot/features/wilds.ts +151 -0
  47. package/src/slot/grid/ReelGrid.ts +93 -24
  48. package/src/slot/grid/SymbolCell.ts +45 -11
  49. package/src/slot/index.ts +61 -0
  50. package/src/slot/motion/AnticipationController.ts +96 -0
  51. package/src/slot/motion/SpinEngine.ts +384 -0
  52. package/src/slot/system/ReelSystem.ts +295 -0
package/dist/host.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Ticker, Assets, Container, Text, Application, Graphics, TextureSource } from 'pixi.js';
2
2
  import { createPlatformSession } from '@energy8platform/platform-core';
3
- import { buildLogoSVG, LOADER_BAR_MAX_WIDTH, createCSSPreloader, removeCSSPreloader } from '@energy8platform/platform-core/loading';
4
- import { socialize } from '@energy8platform/platform-core/shell';
3
+ import { waitCSSPreloaderTap, removeCSSPreloader, setCSSPreloaderProgress, createCSSPreloader } from '@energy8platform/platform-core/loading';
4
+ import { socialize, createI18n } from '@energy8platform/platform-core/shell';
5
5
  export { socialize } from '@energy8platform/platform-core/shell';
6
6
 
7
7
  // ─── Scale Modes ───────────────────────────────────────────
@@ -187,6 +187,10 @@ class Tween {
187
187
  * @param onUpdate - Progress callback (0..1)
188
188
  */
189
189
  static to(target, props, duration, easing, onUpdate) {
190
+ // A destroyed (Pixi) target has null transform fields — skip rather than throw. This guards
191
+ // animations whose target is torn down mid-flight (e.g. a reel grid rebuilt during a spin).
192
+ if (target == null || target.destroyed)
193
+ return Promise.resolve();
190
194
  return new Promise((resolve) => {
191
195
  // Capture starting values
192
196
  const from = {};
@@ -212,6 +216,8 @@ class Tween {
212
216
  * Animate properties from given values to current values.
213
217
  */
214
218
  static from(target, props, duration, easing, onUpdate) {
219
+ if (target == null || target.destroyed)
220
+ return Promise.resolve();
215
221
  // Capture current values as "to"
216
222
  const to = {};
217
223
  for (const key of Object.keys(props)) {
@@ -224,6 +230,8 @@ class Tween {
224
230
  * Animate from one set of values to another.
225
231
  */
226
232
  static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
233
+ if (target == null || target.destroyed)
234
+ return Promise.resolve();
227
235
  // Set starting values
228
236
  for (const key of Object.keys(fromProps)) {
229
237
  Tween.setProperty(target, key, fromProps[key]);
@@ -297,6 +305,11 @@ class Tween {
297
305
  const dt = ticker.deltaMS;
298
306
  const completed = [];
299
307
  for (const tw of Tween._tweens) {
308
+ // target torn down mid-tween → finish it quietly
309
+ if (tw.target?.destroyed) {
310
+ completed.push(tw);
311
+ continue;
312
+ }
300
313
  tw.elapsed += dt;
301
314
  if (tw.elapsed < tw.delay)
302
315
  continue;
@@ -334,9 +347,9 @@ class Tween {
334
347
  const parts = key.split('.');
335
348
  let obj = target;
336
349
  for (let i = 0; i < parts.length - 1; i++) {
337
- obj = obj[parts[i]];
350
+ obj = obj?.[parts[i]];
338
351
  }
339
- return obj[parts[parts.length - 1]] ?? 0;
352
+ return obj?.[parts[parts.length - 1]] ?? 0;
340
353
  }
341
354
  /**
342
355
  * Set a potentially nested property.
@@ -345,8 +358,10 @@ class Tween {
345
358
  const parts = key.split('.');
346
359
  let obj = target;
347
360
  for (let i = 0; i < parts.length - 1; i++) {
348
- obj = obj[parts[i]];
361
+ obj = obj?.[parts[i]];
349
362
  }
363
+ if (obj == null)
364
+ return;
350
365
  obj[parts[parts.length - 1]] = value;
351
366
  }
352
367
  }
@@ -1516,34 +1531,20 @@ class Scene {
1516
1531
  }
1517
1532
 
1518
1533
  /**
1519
- * Build the loading scene variant of the logo SVG.
1520
- * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1521
- */
1522
- function buildLoadingLogoSVG() {
1523
- return buildLogoSVG({
1524
- idPrefix: 'ls',
1525
- svgStyle: 'width:100%;height:auto;',
1526
- clipRectId: 'ge-loader-rect',
1527
- textId: 'ge-loader-pct',
1528
- textContent: '0%',
1529
- });
1530
- }
1531
- /**
1532
- * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1534
+ * Built-in loading screen.
1533
1535
  *
1534
- * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1535
- * The loader bar fill width is driven by asset loading progress.
1536
+ * It does NOT render its own overlay the CSS preloader created at boot
1537
+ * (`createPlatformSession`/`GameApplication.start`) stays on screen, and this
1538
+ * scene merely drives it: asset-load progress → `setCSSPreloaderProgress`,
1539
+ * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1540
+ * `removeCSSPreloader` before entering the game. One continuous overlay from
1541
+ * boot to gameplay — no second logo, no mid-load flash.
1536
1542
  */
1537
1543
  class LoadingScene extends Scene {
1538
1544
  _engine;
1539
1545
  _targetScene;
1540
1546
  _targetData;
1541
1547
  _config;
1542
- // HTML overlay
1543
- _overlay = null;
1544
- _loaderRect = null;
1545
- _percentEl = null;
1546
- _tapToStartEl = null;
1547
1548
  // State
1548
1549
  _displayedProgress = 0;
1549
1550
  _targetProgress = 0;
@@ -1556,8 +1557,6 @@ class LoadingScene extends Scene {
1556
1557
  this._targetData = targetData;
1557
1558
  this._config = engine.config.loading ?? {};
1558
1559
  this._startTime = Date.now();
1559
- // Create the HTML overlay with the SVG logo
1560
- this.createOverlay();
1561
1560
  // Initialize asset manager
1562
1561
  await this._engine.assets.init();
1563
1562
  // Initialize audio manager
@@ -1603,110 +1602,29 @@ class LoadingScene extends Scene {
1603
1602
  // Final snap to 100%
1604
1603
  this._displayedProgress = 1;
1605
1604
  this.updateLoaderBar(1);
1606
- // Show "Tap to Start" or transition directly
1607
- if (this._config.tapToStart !== false) {
1608
- await this.showTapToStart();
1609
- }
1610
- else {
1611
- await this.transitionToGame();
1612
- }
1605
+ // Wait for the player's tap resolves immediately when tapToStart is
1606
+ // false (the preloader honours that flag) — then enter the game.
1607
+ await waitCSSPreloaderTap();
1608
+ await this.transitionToGame();
1613
1609
  }
1614
1610
  onUpdate(dt) {
1615
- // Smooth progress bar fill via HTML (during active loading)
1611
+ // Smooth progress bar fill (during active loading)
1616
1612
  if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1617
1613
  this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1618
1614
  this.updateLoaderBar(this._displayedProgress);
1619
1615
  }
1620
1616
  }
1621
1617
  onResize(_width, _height) {
1622
- // Overlay is CSS-based, auto-resizes
1618
+ // The preloader overlay is CSS-based and auto-resizes.
1623
1619
  }
1624
1620
  onDestroy() {
1625
- this.removeOverlay();
1626
- }
1627
- // ─── HTML Overlay ──────────────────────────────────────
1628
- createOverlay() {
1629
- const bgColor = typeof this._config.backgroundColor === 'string'
1630
- ? this._config.backgroundColor
1631
- : typeof this._config.backgroundColor === 'number'
1632
- ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1633
- : '#0a0a1a';
1634
- const bgGradient = this._config.backgroundGradient ??
1635
- `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1636
- this._overlay = document.createElement('div');
1637
- this._overlay.id = '__ge-loading-overlay__';
1638
- this._overlay.innerHTML = `
1639
- <div class="ge-loading-content">
1640
- ${buildLoadingLogoSVG()}
1641
- </div>
1642
- `;
1643
- const style = document.createElement('style');
1644
- style.id = '__ge-loading-style__';
1645
- style.textContent = `
1646
- #__ge-loading-overlay__ {
1647
- position: absolute;
1648
- top: 0; left: 0;
1649
- width: 100%; height: 100%;
1650
- background: ${bgGradient};
1651
- display: flex;
1652
- align-items: center;
1653
- justify-content: center;
1654
- z-index: 9999;
1655
- transition: opacity 0.5s ease-out;
1656
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1657
- }
1658
- #__ge-loading-overlay__.ge-fade-out {
1659
- opacity: 0;
1660
- pointer-events: none;
1661
- }
1662
- .ge-loading-content {
1663
- display: flex;
1664
- flex-direction: column;
1665
- align-items: center;
1666
- width: 75%;
1667
- max-width: 650px;
1668
- }
1669
- .ge-loading-content svg {
1670
- filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1671
- cursor: default;
1672
- }
1673
-
1674
- .ge-svg-pulse {
1675
- animation: ge-tap-pulse 1.2s ease-in-out infinite;
1676
- }
1677
- @keyframes ge-tap-pulse {
1678
- 0%, 100% { opacity: 0.5; }
1679
- 50% { opacity: 1; }
1680
- }
1681
- `;
1682
- // Get the container that holds the canvas
1683
- const container = this._engine.app?.canvas?.parentElement;
1684
- if (container) {
1685
- container.style.position = container.style.position || 'relative';
1686
- container.appendChild(style);
1687
- container.appendChild(this._overlay);
1688
- }
1689
- // Cache the SVG loader rect for progress updates
1690
- this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1691
- this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1692
- }
1693
- removeOverlay() {
1694
- this._overlay?.remove();
1695
- document.getElementById('__ge-loading-style__')?.remove();
1696
- this._overlay = null;
1697
- this._loaderRect = null;
1698
- this._percentEl = null;
1699
- this._tapToStartEl = null;
1621
+ // Defensive: ensure the preloader is gone even if we never transitioned
1622
+ // (e.g. the scene was popped externally). Idempotent.
1623
+ void removeCSSPreloader(this.hostElement());
1700
1624
  }
1701
1625
  // ─── Progress ──────────────────────────────────────────
1702
1626
  updateLoaderBar(progress) {
1703
- if (this._loaderRect) {
1704
- this._loaderRect.setAttribute('width', String(LOADER_BAR_MAX_WIDTH * progress));
1705
- }
1706
- if (this._percentEl) {
1707
- const pct = Math.round(progress * 100);
1708
- this._percentEl.textContent = `${pct}%`;
1709
- }
1627
+ setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
1710
1628
  }
1711
1629
  /**
1712
1630
  * Smoothly animate the displayed progress from its current value to `target`
@@ -1736,49 +1654,20 @@ class LoadingScene extends Scene {
1736
1654
  requestAnimationFrame(tick);
1737
1655
  });
1738
1656
  }
1739
- // ─── Tap to Start ─────────────────────────────────────
1740
- async showTapToStart() {
1741
- const tapText = this._config.tapToStartText ?? 'TAP TO START';
1742
- // Reuse the same SVG text element — replace percentage with tap text
1743
- if (this._percentEl) {
1744
- const el = this._percentEl;
1745
- el.textContent = tapText;
1746
- el.setAttribute('fill', '#ffffff');
1747
- el.classList.add('ge-svg-pulse');
1748
- this._tapToStartEl = el;
1749
- }
1750
- // Make overlay clickable
1751
- if (this._overlay) {
1752
- this._overlay.style.cursor = 'pointer';
1753
- }
1754
- // Wait for tap
1755
- return new Promise((resolve) => {
1756
- const handler = async () => {
1757
- this._overlay?.removeEventListener('click', handler);
1758
- await this.transitionToGame();
1759
- resolve();
1760
- };
1761
- // Listen on the full overlay for easier mobile tap
1762
- this._overlay?.addEventListener('click', handler);
1763
- });
1764
- }
1765
1657
  // ─── Transition ────────────────────────────────────────
1658
+ /** The DOM element hosting the canvas + preloader overlay. */
1659
+ hostElement() {
1660
+ return this._engine?.app?.canvas?.parentElement ?? document.body;
1661
+ }
1766
1662
  async transitionToGame() {
1767
- // Fade out the HTML overlay
1768
- if (this._overlay) {
1769
- this._overlay.classList.add('ge-fade-out');
1770
- await new Promise((resolve) => {
1771
- this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1772
- // Safety timeout
1773
- setTimeout(resolve, 600);
1774
- });
1775
- }
1776
- // Remove overlay
1777
- this.removeOverlay();
1663
+ // Fade out and remove the shared CSS preloader (resolves after the fade).
1664
+ await removeCSSPreloader(this.hostElement());
1778
1665
  // Navigate to the target scene, always passing the engine reference
1779
1666
  await this._engine.scenes.goto(this._targetScene, {
1780
1667
  engine: this._engine,
1781
- ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1668
+ ...(this._targetData && typeof this._targetData === 'object'
1669
+ ? this._targetData
1670
+ : { data: this._targetData }),
1782
1671
  });
1783
1672
  }
1784
1673
  }
@@ -2005,17 +1894,20 @@ class GameApplication extends EventEmitter {
2005
1894
  // 6. Initialize sub-systems
2006
1895
  this.initSubSystems();
2007
1896
  this.emit('initialized');
2008
- // 7. Remove CSS preloader, show Canvas loading screen
2009
- removeCSSPreloader(this._container);
2010
- // 8. Load assets with loading screen
1897
+ // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1898
+ // its progress/tap and removes it before entering the game, so there's
1899
+ // a single continuous overlay from boot to gameplay (no logo flash).
2011
1900
  await this.loadAssets(firstScene, sceneData);
2012
1901
  this.emit('loaded');
2013
- // 9. Start the game loop
1902
+ // 8. Start the game loop
2014
1903
  this._running = true;
2015
1904
  this.emit('started');
2016
1905
  }
2017
1906
  catch (err) {
2018
1907
  console.error('[GameEngine] Failed to start:', err);
1908
+ // Tear down the preloader so a failure doesn't strand the brand frame.
1909
+ if (this._container)
1910
+ removeCSSPreloader(this._container);
2019
1911
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
2020
1912
  throw err;
2021
1913
  }
@@ -2878,8 +2770,8 @@ async function createSlotGame(opts) {
2878
2770
  }
2879
2771
 
2880
2772
  // packages/game-engine/src/host/shellConfig.ts
2881
- // `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
2882
- // types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
2773
+ // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
2774
+ // pixi-shell re-exports only types so we import directly from platform-core.
2883
2775
  /**
2884
2776
  * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2885
2777
  * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
@@ -2934,8 +2826,9 @@ function stakeForAction(model, action, bet) {
2934
2826
  const cost = (model.spec.actions?.[action]?.cost ?? 1);
2935
2827
  return cost * bet;
2936
2828
  }
2937
- /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
2938
- function toBonusOptions(model) {
2829
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
2830
+ * @param t Optional translator applied to `title` and `description` before they enter the shell. */
2831
+ function toBonusOptions(model, t = (s) => s) {
2939
2832
  const out = [];
2940
2833
  for (const [key, action] of Object.entries(model.spec.actions)) {
2941
2834
  const role = action.role ?? 'base';
@@ -2944,15 +2837,18 @@ function toBonusOptions(model) {
2944
2837
  out.push({
2945
2838
  id: key,
2946
2839
  type: role === 'buy' ? 'bonus' : 'feature',
2947
- title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
2948
- description: action.description ?? '',
2840
+ title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
2841
+ description: t(action.description ?? ''),
2949
2842
  priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
2843
+ // Volatility (1–5 bolts) is part of the spec action SSOT; forward it so the buy card shows it.
2844
+ ...(action.volatility != null ? { volatility: action.volatility } : {}),
2950
2845
  });
2951
2846
  }
2952
2847
  return out;
2953
2848
  }
2954
- /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
2955
- function paytableSection(model) {
2849
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
2850
+ * @param t Optional translator applied to symbol names before they enter the shell. */
2851
+ function paytableSection(model, t = (s) => s) {
2956
2852
  const symbols = model.paytable?.symbols ?? [];
2957
2853
  const rows = [];
2958
2854
  for (const s of symbols) {
@@ -2962,11 +2858,13 @@ function paytableSection(model) {
2962
2858
  .sort((a, b) => Number(a.count) - Number(b.count));
2963
2859
  if (!wins.length)
2964
2860
  continue;
2965
- rows.push({ symbol: { text: s.name ?? s.id }, wins });
2861
+ rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
2966
2862
  }
2967
2863
  if (!rows.length)
2968
2864
  return null;
2969
- return { type: 'paytable', title: 'PAYTABLE', rows };
2865
+ // No literal title — the shell renders `s.title ?? host.t('Paytable')`, so the heading is
2866
+ // localized (matching how the modes/wins sections rely on the shell's translated fallback).
2867
+ return { type: 'paytable', rows };
2970
2868
  }
2971
2869
  /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
2972
2870
  function winsSection(model) {
@@ -3008,14 +2906,15 @@ function orderDisclaimerLast(sections) {
3008
2906
  * gets a real info panel for free (paytable, win illustration, controls, and the Stake
3009
2907
  * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
3010
2908
  * section identity (see `mergeGameInfo`), not wholesale-replaced.
2909
+ * @param t Optional translator applied to spec-derived player-facing strings (symbol names, mode titles, etc.).
3011
2910
  */
3012
- function defaultGameInfo(model, runtime) {
2911
+ function defaultGameInfo(model, runtime, t = (s) => s) {
3013
2912
  const sections = [];
3014
2913
  sections.push(winsSection(model));
3015
- const pay = paytableSection(model);
2914
+ const pay = paytableSection(model, t);
3016
2915
  if (pay)
3017
2916
  sections.push(pay);
3018
- const modes = modesSection(model);
2917
+ const modes = modesSection(model, t);
3019
2918
  if (modes)
3020
2919
  sections.push(modes);
3021
2920
  sections.push({ type: 'controls' });
@@ -3028,8 +2927,9 @@ function defaultGameInfo(model, runtime) {
3028
2927
  * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
3029
2928
  * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
3030
2929
  * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
3031
- * already drops them — free spins are part of a bonus, not a purchasable mode). */
3032
- function modesSection(model) {
2930
+ * already drops them — free spins are part of a bonus, not a purchasable mode).
2931
+ * @param t Optional translator applied to row `title` and `description` before they enter the shell. */
2932
+ function modesSection(model, t = (s) => s) {
3033
2933
  const modes = model.mathModes ?? [];
3034
2934
  if (!modes.length)
3035
2935
  return null;
@@ -3037,7 +2937,7 @@ function modesSection(model) {
3037
2937
  const action = model.spec.actions[m.action];
3038
2938
  const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
3039
2939
  const row = {
3040
- title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
2940
+ title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
3041
2941
  maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
3042
2942
  };
3043
2943
  // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
@@ -3046,7 +2946,7 @@ function modesSection(model) {
3046
2946
  if (typeof m.rtp === 'number')
3047
2947
  row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
3048
2948
  if (action?.description)
3049
- row.description = action.description;
2949
+ row.description = t(action.description);
3050
2950
  return row;
3051
2951
  });
3052
2952
  return { type: 'modes', title: 'MODES', modes: rows };
@@ -3143,18 +3043,17 @@ function buildShellConfig(opts, model, runtime) {
3143
3043
  // host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
3144
3044
  const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
3145
3045
  const isSocial = runtime.social ?? false;
3146
- // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
3147
- // social mode so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
3148
- // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
3149
- // social mode just because the author wrote it in their own info section. (Custom sections built
3150
- // from a raw DOM `node` can't be rewritten automatically author owns the node and can call the
3151
- // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
3152
- // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
3153
- // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
3154
- // still socialized below as a safety net.
3155
- const t = isSocial ? socialize : (text) => text;
3046
+ // Build the merged resolver: game i18n map + shell LOCALES (via createI18n), then socialize on
3047
+ // top for English social mode. For non-English languages, createI18n already handles the
3048
+ // translation lookup; social rewriting is English-only and applied separately after.
3049
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` is the resolver so
3050
+ // authors can wrap player-facing copy explicitly; the full merged set is still socialized below
3051
+ // (section pass) as a safety net so restricted words can't slip through even if t() was missed.
3052
+ const { t } = createI18n({ language: runtime.language ?? 'en', isSocial, messages: opts.i18n });
3156
3053
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
3157
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
3054
+ // Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
3055
+ // pre-translated before they reach the shell renderer.
3056
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
3158
3057
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
3159
3058
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
3160
3059
  if (isSocial) {
@@ -3164,8 +3063,8 @@ function buildShellConfig(opts, model, runtime) {
3164
3063
  }
3165
3064
  // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
3166
3065
  gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
3167
- // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
3168
- const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
3066
+ // Buy-bonus cards: apply t() to spec-derived options (pre-translate), then socialize for en+social.
3067
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model, t), isSocial);
3169
3068
  // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
3170
3069
  const features = {
3171
3070
  turbo: 0,