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