@energy8platform/game-engine 0.34.3 → 0.35.1

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/host.cjs.js CHANGED
@@ -1596,6 +1596,14 @@ class Scene {
1596
1596
  * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1597
1597
  * `removeCSSPreloader` before entering the game. One continuous overlay from
1598
1598
  * boot to gameplay — no second logo, no mid-load flash.
1599
+ *
1600
+ * When the game supplied its own overlay (`loading.externalOverlay`, e.g.
1601
+ * Artube's `LoaderViewController`), this scene is also the HAND-OVER point: that
1602
+ * overlay covered the gap this scene's existence ends — the bundle download,
1603
+ * Pixi init and the SDK handshake, none of which the engine can paint over. The
1604
+ * first thing `onEnter` does is mount the preloader, wait for it to be painted,
1605
+ * and dismiss the game's overlay. Everything after that line is identical on
1606
+ * every platform.
1599
1607
  */
1600
1608
  class LoadingScene extends Scene {
1601
1609
  _engine;
@@ -1613,6 +1621,10 @@ class LoadingScene extends Scene {
1613
1621
  this._targetScene = targetScene;
1614
1622
  this._targetData = targetData;
1615
1623
  this._config = engine.config.loading ?? {};
1624
+ // Take the screen from a game-supplied loading overlay, if there is one. Before any awaited
1625
+ // work: from here on the player is looking at OUR loading screen, and `_startTime` (which
1626
+ // `minDisplayTime` is measured from) must start when that becomes true.
1627
+ await this.takeOverFromExternalOverlay();
1616
1628
  this._startTime = Date.now();
1617
1629
  // Initialize asset manager
1618
1630
  await this._engine.assets.init();
@@ -1659,8 +1671,10 @@ class LoadingScene extends Scene {
1659
1671
  // Final snap to 100%
1660
1672
  this._displayedProgress = 1;
1661
1673
  this.updateLoaderBar(1);
1662
- // Wait for the player's tap — resolves immediately when tapToStart is
1663
- // false (the preloader honours that flag) then enter the game.
1674
+ // Wait for the player's tap — resolves immediately when tapToStart is false — then enter the
1675
+ // game. This is the preloader's gate and it reads the preloader's config, so it means the same
1676
+ // thing on every target: a game-supplied overlay has no say in it, and by now no part in the
1677
+ // screen either. It was dismissed at the hand-over above; the player is looking at ours.
1664
1678
  await loading.waitCSSPreloaderTap();
1665
1679
  await this.transitionToGame();
1666
1680
  }
@@ -1679,6 +1693,59 @@ class LoadingScene extends Scene {
1679
1693
  // (e.g. the scene was popped externally). Idempotent.
1680
1694
  void loading.removeCSSPreloader(this.hostElement());
1681
1695
  }
1696
+ // ─── Hand-over from a game-supplied overlay ────────────
1697
+ /**
1698
+ * Swap a game-supplied loading overlay for the engine's own loading screen.
1699
+ *
1700
+ * The overlay (Artube's) has been on screen since before this bundle was fetched, covering a gap
1701
+ * nothing of ours could. Its job ends here, at the first frame the engine paints; the player then
1702
+ * gets the game's own brand, progress bar and tap-to-start, exactly as on every other target.
1703
+ *
1704
+ * The order of the four steps is the whole design, and each is wrong on its own:
1705
+ *
1706
+ * 0. Wait out whatever the overlay is still owed on screen (`externalOverlayMinDisplayTime`,
1707
+ * default 1.5s, plus room for a phase crossfade already in flight). The gap this overlay
1708
+ * covers can be under half a second, which is not long enough for a partner's brand to
1709
+ * register. Waiting here — BEFORE mounting ours — rather than after is what keeps the two
1710
+ * screens' timelines from overlapping: our splash and brand floor start when the player can
1711
+ * actually see them, not behind someone else's overlay. On any boot slower than the floor
1712
+ * this step costs nothing, and on a non-Artube target it is not reached at all.
1713
+ * 1. Mount the preloader, opaque and full-bleed, while theirs is still up. Both are on screen
1714
+ * together for a few frames, so there is never a moment with neither, whatever happens next.
1715
+ * 2. Wait for that frame to actually be PAINTED — mounting only queues it. Dismissing theirs
1716
+ * before the paint is precisely the flash of bare background this ordering exists to avoid.
1717
+ * Two `requestAnimationFrame`s: the first callback runs before the frame it belongs to is
1718
+ * composited, the second after. Two frames is also enough for Pixi's own rAF-driven ticker
1719
+ * to have rendered this scene at least once, so "the loading scene has painted" is literally
1720
+ * true by the time step 3 runs.
1721
+ * 3. Only then dismiss theirs. Their `hideLoader()` plays a 0.3s fade and removes the element.
1722
+ * Not waiting for that fade is deliberate — it is an animation on someone else's element,
1723
+ * and blocking a boot on it would be a hang waiting to happen.
1724
+ *
1725
+ * Which of the two is visually on top is the host page's business, not ours, and it does NOT
1726
+ * change the guarantee. On a typical game page (`#game { position: fixed; inset: 0 }`) the fixed
1727
+ * container establishes a stacking context, so the preloader's z-index is scoped inside it and
1728
+ * Artube's `position: fixed; z-index: 9999` sits above — their fade then crossfades onto our
1729
+ * loading screen, which is what was observed live and looks right. On a page where ours wins
1730
+ * instead, their fade simply plays underneath, unseen. Either way the seam is covered, because
1731
+ * what step 2 buys is that OUR screen is already painted before theirs starts going away.
1732
+ */
1733
+ async takeOverFromExternalOverlay() {
1734
+ if (!loading.hasExternalOverlay())
1735
+ return;
1736
+ await loading.externalOverlayHold();
1737
+ loading.createCSSPreloader(this.hostElement(), this._config);
1738
+ await this.nextPaint();
1739
+ loading.releaseExternalOverlay();
1740
+ }
1741
+ /** Resolves after the browser has composited at least one frame (see step 2 above). */
1742
+ nextPaint() {
1743
+ if (typeof requestAnimationFrame !== 'function')
1744
+ return Promise.resolve();
1745
+ return new Promise((resolve) => {
1746
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
1747
+ });
1748
+ }
1682
1749
  // ─── Progress ──────────────────────────────────────────
1683
1750
  updateLoaderBar(progress) {
1684
1751
  loading.setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
@@ -1933,14 +2000,37 @@ class GameApplication extends EventEmitter {
1933
2000
  return;
1934
2001
  }
1935
2002
  try {
2003
+ // 0. Adopt a game-supplied loading overlay (`loading.externalOverlay`) BEFORE anything that
2004
+ // can throw. Such an overlay is already on screen — Artube's is injected into index.html,
2005
+ // so it paints before this bundle is even fetched — and until the engine has adopted it,
2006
+ // the catch below has no way to take it down. A bad `container` selector (step 1) would
2007
+ // otherwise strand it on screen forever. It needs no container of ours.
2008
+ // Adoption is also where its minimum display time starts counting, which is why the
2009
+ // config value is handed over here rather than read at the hand-over: this is the
2010
+ // earliest moment the engine runs, and the overlay has been on screen since before it.
2011
+ const external = this.config.loading?.externalOverlay;
2012
+ if (external)
2013
+ loading.adoptExternalOverlay(external, this.config.loading?.externalOverlayMinDisplayTime);
1936
2014
  // 1. Resolve container element
1937
2015
  this._container = this.resolveContainer();
1938
- // 2. Show CSS preloader immediately (before PixiJS)
1939
- loading.createCSSPreloader(this._container, this.config.loading);
2016
+ // 2. Show the CSS preloader immediately (before PixiJS) — UNLESS a game-supplied overlay is
2017
+ // already covering the screen. In that case the preloader is mounted later, by LoadingScene
2018
+ // at its first frame, which is where the hand-over happens. Mounting it here instead would
2019
+ // put our brand over theirs for the whole of Pixi init and the SDK handshake, i.e. hand
2020
+ // over long before the gap the external overlay exists to cover has closed.
2021
+ if (!loading.hasExternalOverlay())
2022
+ loading.createCSSPreloader(this._container, this.config.loading);
1940
2023
  // 3. Initialize PixiJS
1941
2024
  await this.initPixi();
2025
+ // Milestones through the pre-first-frame gap, for a game-supplied overlay only (no-ops
2026
+ // otherwise, so the built-in preloader's behaviour is untouched). They are also what makes
2027
+ // Artube's loader crossfade from its dark partner phase to its branded one: that transition
2028
+ // fires on the first progress above zero, and without it the player would never see the
2029
+ // brand the loader exists to show. Values are honest weights of what remains, not a timer.
2030
+ loading.advanceExternalOverlay(0.35);
1942
2031
  // 4. Initialize SDK (if enabled)
1943
2032
  await this.initSDK();
2033
+ loading.advanceExternalOverlay(0.7);
1944
2034
  // 4b. Mount the branded game shell after the SDK handshake (optional)
1945
2035
  if (this.config.shell) {
1946
2036
  const { createGameShell } = await import('@energy8platform/shell/html');
@@ -1950,10 +2040,17 @@ class GameApplication extends EventEmitter {
1950
2040
  this.applySDKConfig();
1951
2041
  // 6. Initialize sub-systems
1952
2042
  this.initSubSystems();
2043
+ loading.advanceExternalOverlay(0.85);
1953
2044
  this.emit('initialized');
1954
2045
  // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1955
2046
  // its progress/tap and removes it before entering the game, so there's
1956
2047
  // a single continuous overlay from boot to gameplay (no logo flash).
2048
+ //
2049
+ // With a game-supplied overlay the sequence has one extra step at the
2050
+ // front: LoadingScene MOUNTS the preloader, waits for its first painted
2051
+ // frame, and only then dismisses the external overlay. From that frame
2052
+ // on this path and every other are identical — same brand, same bar,
2053
+ // same tap-to-start.
1957
2054
  await this.loadAssets(firstScene, sceneData);
1958
2055
  this.emit('loaded');
1959
2056
  // 8. Start the game loop
@@ -1962,9 +2059,13 @@ class GameApplication extends EventEmitter {
1962
2059
  }
1963
2060
  catch (err) {
1964
2061
  console.error('[GameEngine] Failed to start:', err);
1965
- // Tear down the preloader so a failure doesn't strand the brand frame.
1966
- if (this._container)
1967
- loading.removeCSSPreloader(this._container);
2062
+ // Tear down both possible overlays so a failure strands neither brand frame. BOTH calls run:
2063
+ // a throw during the hand-over window can leave the preloader mounted AND the external
2064
+ // overlay still adopted, and each call is a no-op when there is nothing to remove. The
2065
+ // container may never have resolved (step 1 is inside this try), hence the `document.body`
2066
+ // fallback — the external overlay ignores the element entirely.
2067
+ loading.releaseExternalOverlay();
2068
+ void loading.removeCSSPreloader(this._container ?? document.body);
1968
2069
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
1969
2070
  throw err;
1970
2071
  }
@@ -2159,21 +2260,33 @@ class IntroScene extends Scene {
2159
2260
 
2160
2261
  /**
2161
2262
  * Pure: map host options to a GameApplicationConfig with sane defaults.
2162
- * `isStakeNow` is computed by the orchestrator (kept out of here so this
2163
- * stays a pure, renderer-free function).
2263
+ * `isStakeNow` / `isArtubeNow` are computed by the orchestrator (kept out of
2264
+ * here so this stays a pure, renderer-free function).
2265
+ *
2266
+ * Both host bridges run IN-PROCESS with the game, so either one means the SDK
2267
+ * must be in `devMode` — that is what makes it talk over the in-memory channel
2268
+ * the bridge listens on instead of postMessage-ing an outer host that isn't
2269
+ * there. (`dev` is the third, unrelated reason for the same flag: DevBridge.)
2164
2270
  */
2165
- function buildAppConfig(opts, isStakeNow) {
2271
+ function buildAppConfig(opts, isStakeNow, isArtubeNow = false) {
2166
2272
  return {
2167
2273
  container: opts.container ?? '#game',
2168
2274
  designWidth: opts.design?.width ?? 1920,
2169
2275
  designHeight: opts.design?.height ?? 1080,
2170
2276
  scaleMode: opts.scaleMode ?? ScaleMode.FILL,
2171
2277
  orientation: opts.orientation ?? Orientation.ANY,
2172
- loading: opts.loading ?? { tapToStart: false, minDisplayTime: 600 },
2278
+ // MERGED, not replaced. `opts.loading ?? {…}` looked equivalent and was not: a game that
2279
+ // passes ANY loading option loses every default it did not restate, so `{ minDisplayTime: 900 }`
2280
+ // silently re-armed tap-to-start (the engine's own default for that flag is `true`, for
2281
+ // backwards compatibility with direct GameApplication users). The Artube target made it
2282
+ // visible — a game supplying only `externalOverlay` waited for a tap there and nowhere else,
2283
+ // i.e. the same source line behaved differently per platform. Spreading `opts.loading` last
2284
+ // keeps every explicit value winning while unset keys stay on the host's defaults.
2285
+ loading: { tapToStart: false, minDisplayTime: 600, ...opts.loading },
2173
2286
  manifest: opts.manifest,
2174
2287
  audio: opts.audio,
2175
2288
  pixi: opts.pixi,
2176
- sdk: { devMode: isStakeNow || (opts.dev ?? false) },
2289
+ sdk: { devMode: isStakeNow || isArtubeNow || (opts.dev ?? false) },
2177
2290
  debug: opts.dev ?? false,
2178
2291
  };
2179
2292
  }
@@ -2302,7 +2415,7 @@ function installGlobalErrorHandlers(container, fatal = (m) => showFatalError(con
2302
2415
 
2303
2416
  // packages/game-engine/src/host/createSlotGame.ts
2304
2417
  /**
2305
- * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
2418
+ * One-call slot bootstrap: preboot → (optional Stake / Artube bridge) → GameApplication
2306
2419
  * → register scene → start. Collapses the per-game main.ts boilerplate.
2307
2420
  *
2308
2421
  * Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
@@ -2317,6 +2430,25 @@ async function createSlotGame(opts) {
2317
2430
  // Declared up front so `fatal` can route errors through the shell's own modal once it exists.
2318
2431
  let shell = null;
2319
2432
  const fatal = (message) => {
2433
+ // Take down a game-supplied loading overlay (`loading.externalOverlay`) first. Several fatal
2434
+ // paths below — a refused Artube launch, a bridge that cannot connect — happen BEFORE
2435
+ // `GameApplication.start()`, so its own error path never runs and nothing else would ever
2436
+ // dismiss the overlay. Artube's is already on screen from index.html at z-index 9999: leaving
2437
+ // it up means the player stares at a frozen loading screen, and a custom `onFatalError`
2438
+ // renderer would be hidden underneath it entirely.
2439
+ //
2440
+ // `releaseExternalOverlay` handles the case where the engine already adopted it (and keeps the
2441
+ // dismissal idempotent, which matters now that the normal hand-over also dismisses it). Its
2442
+ // `false` means the engine never got that far — those are exactly the pre-boot refusals, where
2443
+ // hiding the game's overlay directly is the only thing that can work.
2444
+ if (!loading.releaseExternalOverlay()) {
2445
+ try {
2446
+ opts.loading?.externalOverlay?.hideLoader();
2447
+ }
2448
+ catch {
2449
+ /* the overlay is the game's; a throw here must not swallow the error we came to report */
2450
+ }
2451
+ }
2320
2452
  if (opts.onFatalError)
2321
2453
  return opts.onFatalError(message);
2322
2454
  // Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
@@ -2386,7 +2518,66 @@ async function createSlotGame(opts) {
2386
2518
  }
2387
2519
  }
2388
2520
  }
2389
- const game = new GameApplication(buildAppConfig(opts, isStakeNow));
2521
+ let artubeBridge = null;
2522
+ let isArtubeNow = false;
2523
+ // `!isStakeNow`: both bridges install themselves in-process on the SAME SDK memory channel, so
2524
+ // whichever launch already claimed the game wins. (The two launch shapes are disjoint in practice
2525
+ // — Stake's marker is `sessionID`/`replay`, Artube's is `sessionId` — so this never fires; it just
2526
+ // makes the precedence explicit rather than leaving two bridges racing.)
2527
+ if (opts.artube && !isStakeNow) {
2528
+ // The GAME supplies the loader (see `ArtubeIntegration.load`): a bare
2529
+ // `import('@energy8platform/artube-bridge')` here would be resolved statically by every bundler
2530
+ // and would break — loudly or, under Vite, silently — every game that never installed the
2531
+ // package. Classifier and bridge come from the same module, so this is one load.
2532
+ let artube;
2533
+ try {
2534
+ artube = await opts.artube.load();
2535
+ }
2536
+ catch (err) {
2537
+ fatal('Could not start the game.');
2538
+ throw err;
2539
+ }
2540
+ // Security gate, the Artube counterpart of the Stake one above. Artube's only launch marker is
2541
+ // `sessionId`, and unlike Stake there is no attacker-suppliable server address to validate
2542
+ // (`apiBase` is derived from the launch URL's own PATH, never from a query param — see
2543
+ // ArtubeUrlParams.apiBase). What IS reachable is stripping the session: a URL
2544
+ // that carries `sessionId` with an empty/blank value claims a session it doesn't have, fails the
2545
+ // "is this Artube?" check, and would silently fall through to the offline/dev bridge — the
2546
+ // free-play hole. 'artube' = a real launch (load the bridge); 'offline' = no marker at all, a
2547
+ // genuine dev launch.
2548
+ //
2549
+ // What URL classification cannot catch: a marker removed ENTIRELY is indistinguishable from a
2550
+ // dev launch. In a production BUILD there is nothing to fall through to anyway — the DevBridge
2551
+ // bootstrapper is injected by a Vite plugin with `apply: 'serve'`, so no build carries one,
2552
+ // whatever BUILD_TARGET says. Under a plain `npm run dev` the bootstrapper HAS already started a
2553
+ // DevBridge before this code runs (it wraps the entry module), so there the protection is this
2554
+ // gate refusing to start the game — not the absence of a bridge.
2555
+ const launch = artube.classifyArtubeLaunch(location.href);
2556
+ if (launch === 'blocked') {
2557
+ fatal('Invalid game session. Please relaunch the game from the lobby.');
2558
+ throw new Error('createSlotGame: refusing to run — Artube launch with a missing or blank sessionId');
2559
+ }
2560
+ isArtubeNow = launch === 'artube';
2561
+ if (isArtubeNow) {
2562
+ try {
2563
+ artubeBridge = new artube.ArtubeBridge({
2564
+ // In-process over the SDK's MemoryChannel (see buildAppConfig's devMode).
2565
+ devMode: true,
2566
+ gameId: opts.model.spec.id,
2567
+ url: location.href,
2568
+ // Derived from the launch path in production; both fields are escape hatches (see ArtubeIntegration).
2569
+ ...(opts.artube.apiBase ? { apiBase: opts.artube.apiBase } : {}),
2570
+ ...(opts.artube.demoBalance != null ? { demoBalance: opts.artube.demoBalance } : {}),
2571
+ });
2572
+ await artubeBridge.ready();
2573
+ }
2574
+ catch (err) {
2575
+ fatal('Could not connect to the game server. Please reload.');
2576
+ throw err;
2577
+ }
2578
+ }
2579
+ }
2580
+ const game = new GameApplication(buildAppConfig(opts, isStakeNow, isArtubeNow));
2390
2581
  // Register EVERY scene up front so any of them can navigate to any other.
2391
2582
  for (const { key, scene } of opts.scenes)
2392
2583
  game.scenes.register(key, scene);
@@ -2451,6 +2642,8 @@ async function createSlotGame(opts) {
2451
2642
  const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2452
2643
  const ps = game.platformSession;
2453
2644
  const balance = game.initData?.balance ?? 0;
2645
+ // Replay is a STAKE concept (a shared link that re-plays one recorded round). Artube has no
2646
+ // equivalent, so an Artube launch is always 'base' — nothing to mirror here.
2454
2647
  const isReplay = !!stakeBridge?.isReplay;
2455
2648
  const mode = isReplay ? 'replay' : 'base';
2456
2649
  // initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
@@ -2465,17 +2658,26 @@ async function createSlotGame(opts) {
2465
2658
  // carrier for the same value (INIT only ever has a session on a resume). Both are ignored when
2466
2659
  // absent or 0 so an ordinary launch still starts on the default.
2467
2660
  const resumedBet = config?.activeRound?.bet || initData?.session?.betAmount || undefined;
2661
+ // Artube states its per-session default bet as an INDEX into the platform's ladder; resolve it
2662
+ // against that SAME ladder so `runtime.defaultBet` is an amount on both platforms.
2663
+ const artubeDefaultBet = isArtubeNow
2664
+ ? config?.betLevels?.[config?.artube?.defaultBetIndex ?? -1]
2665
+ : undefined;
2468
2666
  const { resolveCurrency } = await Promise.resolve().then(function () { return shellConfig; });
2469
2667
  // SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
2470
2668
  // (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
2471
2669
  // is absent and we only have the spec's currency CODE — resolve it through the SAME table
2472
2670
  // (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
2473
2671
  // stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
2672
+ // On Artube the session currency is the PLATFORM's (per player, and 'FUN' for demo sessions) and
2673
+ // arrives as a bare code on initData — there is no meta object. It outranks the spec's static
2674
+ // code, which would otherwise show every Artube player the spec's currency symbol.
2675
+ const currencyCode = (isArtubeNow ? initData?.currency : undefined) || opts.model.spec.currency;
2474
2676
  let currencyMeta = config?.currency;
2475
- if (!currencyMeta?.symbol && opts.model.spec.currency) {
2677
+ if (!currencyMeta?.symbol && currencyCode) {
2476
2678
  try {
2477
2679
  const { lookupCurrency } = await import('@energy8platform/stake-bridge');
2478
- currencyMeta = lookupCurrency(opts.model.spec.currency);
2680
+ currencyMeta = lookupCurrency(currencyCode);
2479
2681
  }
2480
2682
  catch {
2481
2683
  /* stake-bridge not installed — resolveCurrency falls back to the code */
@@ -2483,16 +2685,17 @@ async function createSlotGame(opts) {
2483
2685
  }
2484
2686
  const runtime = {
2485
2687
  balance,
2486
- currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
2688
+ currency: resolveCurrency(currencyMeta, currencyCode),
2487
2689
  language: initData?.lang,
2488
2690
  mode,
2489
2691
  social: config?.socialMode,
2490
2692
  disclaimerLines: config?.disclaimerLines,
2491
2693
  jurisdiction: config?.jurisdiction,
2492
- // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
2493
- // absent on dev/devBridge → buildShellConfig falls back to the spec.
2694
+ // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
2695
+ // backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
2696
+ // the spec.
2494
2697
  betLevels: config?.betLevels,
2495
- defaultBet: resumedBet ?? config?.stake?.defaultBetLevel ?? config?.defaultBet,
2698
+ defaultBet: resumedBet ?? config?.stake?.defaultBetLevel ?? artubeDefaultBet ?? config?.defaultBet,
2496
2699
  // Hard stake window; the bridge rejects anything outside it before /bet/play.
2497
2700
  minBet: config?.stake?.minBet,
2498
2701
  maxBet: config?.stake?.maxBet,
@@ -2501,9 +2704,13 @@ async function createSlotGame(opts) {
2501
2704
  // spec's (EUR-shaped) ladder here would put the game on bets the wallet can't honour — every
2502
2705
  // spin rejected on a high-denomination currency (ARS minBet 50), or silently mispriced. Fail
2503
2706
  // where the cause is visible instead of at the first spin.
2504
- if (isStakeNow && !runtime.betLevels?.length) {
2707
+ // Artube is the same requirement by a different route: the wire carries a bet INDEX, not an
2708
+ // amount, and the bridge maps the amount the game plays to the NEAREST rung of the platform's
2709
+ // ladder — so a spec-shaped ladder wouldn't be rejected, it would silently charge a different
2710
+ // price than the bar shows. Refuse there too.
2711
+ if ((isStakeNow || isArtubeNow) && !runtime.betLevels?.length) {
2505
2712
  fatal('Could not load the bet levels for your currency. Please relaunch the game.');
2506
- throw new Error('createSlotGame: Stake launch returned no config.betLevels — refusing to fall back to the spec ladder');
2713
+ throw new Error(`createSlotGame: ${isStakeNow ? 'Stake' : 'Artube'} launch returned no config.betLevels — refusing to fall back to the spec ladder`);
2507
2714
  }
2508
2715
  if (opts.dev) {
2509
2716
  // Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
@@ -3046,7 +3253,7 @@ async function createSlotGame(opts) {
3046
3253
  });
3047
3254
  }
3048
3255
  }
3049
- return { game, stakeBridge, shell };
3256
+ return { game, stakeBridge, artubeBridge, shell };
3050
3257
  }
3051
3258
 
3052
3259
  // packages/game-engine/src/host/shellConfig.ts