@energy8platform/shell 0.7.2 → 0.9.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/html.d.ts CHANGED
@@ -160,6 +160,11 @@ interface BonusOption {
160
160
  priceMultiplier: number;
161
161
  /** Per-option accent override. Falls back to the type default (bonus → purple, feature → gold). */
162
162
  accentColor?: string;
163
+ /** Options sharing a `groupedBy` key occupy ONE card slot and are flipped through with arrows —
164
+ * e.g. four same-priced Ante variants that each swap the character, its art and its volatility.
165
+ * Pixi shell only; the DOM shell ignores the key and keeps a card per option. Options with a
166
+ * `custom` renderer are never grouped. */
167
+ groupedBy?: string;
163
168
  /** Override the card UI. Return the card's inner content; the shell keeps the grid wrapper,
164
169
  * accent vars and live re-pricing, and runs the normal buy flow when you call `ctx.select()`.
165
170
  * Core uses `unknown`; each renderer re-exports a typed alias (ui/html → HTMLElement,
@@ -298,8 +303,13 @@ interface AutoplayConfig {
298
303
  }
299
304
  interface ShellFeatures {
300
305
  turbo: 0 | 1 | 2 | 3;
301
- /** Master keyboard-shortcut switch. Defaults to `true`; set `false` to disable ALL hotkeys
302
- * (overrides `spacebar` and any future hotkey). */
306
+ /** Master keyboard-shortcut switch: `false` disables ALL hotkeys (overrides `spacebar` and any
307
+ * future hotkey) AND hides the Hotkeys section of Game Info, including one the game supplied
308
+ * itself — a keycap chart for keys that do nothing is worse than no chart.
309
+ *
310
+ * Left unset, the shell measures the client (`core/device.ts`): a touchscreen has no keys to
311
+ * press, so it gets neither the shortcuts nor the chart. Set it explicitly when you know better
312
+ * than the media query — which is what the host does with the platform's `device` field. */
303
313
  hotkeys?: boolean;
304
314
  /** Spacebar starts a spin in base mode. Defaults to `true`; set `false` to disable the
305
315
  * keyboard shortcut (e.g. jurisdictions that forbid quick-spin keys). */
@@ -631,7 +641,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
631
641
  tokens: ShellTokens;
632
642
  layout: ShellLayoutMode;
633
643
  soundOn: boolean;
634
- readonly engineVersion = "0.7.2";
644
+ readonly engineVersion = "0.9.0";
635
645
  readonly actions: ShellActions;
636
646
  private renderer;
637
647
  private i18n;
@@ -776,7 +786,7 @@ interface I18n {
776
786
  declare function createI18n(opts: I18nOptions): I18n;
777
787
 
778
788
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
779
- declare const PACKAGE_VERSION = "0.7.2";
789
+ declare const PACKAGE_VERSION = "0.9.0";
780
790
 
781
791
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
782
792
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
package/dist/html.esm.js CHANGED
@@ -1689,9 +1689,41 @@ class KeyboardController {
1689
1689
  }
1690
1690
  }
1691
1691
 
1692
+ /**
1693
+ * Can the player in front of this client actually press a key?
1694
+ *
1695
+ * The shell documents its shortcuts in a Hotkeys section and binds Spacebar to spin. On a phone
1696
+ * neither is reachable, and showing a keycap chart to someone holding a touchscreen is a promise
1697
+ * the game can't keep — a certification lab reads it as a feature offered where it doesn't work.
1698
+ *
1699
+ * The question is deliberately NOT "is the layout narrow" (a portrait desktop window still has a
1700
+ * keyboard) and NOT "is there a touchscreen" (a touch laptop has both). It is: what does the
1701
+ * PRIMARY pointer look like, and can it hover? Coarse-and-hoverless is a touchscreen, and a
1702
+ * touchscreen is the one case where the keys genuinely aren't there.
1703
+ *
1704
+ * A tablet with a keyboard case answers "coarse, no hover" too and loses the chart. That is the
1705
+ * right side to be wrong on: the chart is a convenience, and the keys keep working for anyone who
1706
+ * has them — the media query only decides what the shell ADVERTISES (hosts can still say outright,
1707
+ * via `features.hotkeys`, and the platform's own `device` field does exactly that).
1708
+ */
1709
+ /** A touchscreen: the primary pointer is a finger, and nothing can hover. */
1710
+ const TOUCH_ONLY = '(pointer: coarse) and (hover: none)';
1711
+ function keyboardCapable(win = typeof window === 'undefined' ? undefined : window) {
1712
+ // No window (SSR, node tests) or a browser too old for matchMedia: assume a keyboard rather than
1713
+ // silently stripping shortcuts from a desktop we simply failed to measure.
1714
+ if (typeof win?.matchMedia !== 'function')
1715
+ return true;
1716
+ try {
1717
+ return !win.matchMedia(TOUCH_ONLY).matches;
1718
+ }
1719
+ catch {
1720
+ return true;
1721
+ }
1722
+ }
1723
+
1692
1724
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1693
1725
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1694
- const PACKAGE_VERSION = '0.7.2';
1726
+ const PACKAGE_VERSION = '0.9.0';
1695
1727
 
1696
1728
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1697
1729
  function resolveConfig(config) {
@@ -1705,7 +1737,10 @@ function resolveConfig(config) {
1705
1737
  win: config.win,
1706
1738
  mode: config.mode,
1707
1739
  gameInfo: config.gameInfo,
1708
- features: config.features,
1740
+ // `hotkeys` unset means "decide for me": a touchscreen has no keys to press, so the shell
1741
+ // neither binds them nor advertises them there. A host that knows better — the platform's own
1742
+ // `device` field, a jurisdiction rule — says so outright and that wins. See core/device.ts.
1743
+ features: { ...config.features, hotkeys: config.features.hotkeys ?? keyboardCapable() },
1709
1744
  theme: config.theme,
1710
1745
  onBonusBuy: config.onBonusBuy,
1711
1746
  volumes: config.volumes,
@@ -1799,7 +1834,11 @@ class ShellController extends EventEmitter {
1799
1834
  this.renderer.renderBar();
1800
1835
  },
1801
1836
  toggleAutoplay: () => {
1802
- if (this.state.autoplay.active)
1837
+ // A halted run (stopped, but with spins still owed after a lost connection) counts as
1838
+ // "autoplay is on screen": the toggle retires its leftover count, exactly as it stops a
1839
+ // running one. Resuming those spins is the disc's job, not this one's.
1840
+ const { active, remaining } = this.state.autoplay;
1841
+ if (active || remaining > 0)
1803
1842
  a.stopAutoplay();
1804
1843
  else
1805
1844
  this.openAutoplayPicker();
@@ -2262,6 +2301,11 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2262
2301
  /* the STOP glyph is a solid dark square, so the autoplay count is always pure white to read on it. */
2263
2302
  #${SHELL_ROOT_ID} .ge-spin-count { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
2264
2303
  font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; color:#fff; }
2304
+ /* autoplay halted with spins still owed (a lost connection): the run is stopped, so no STOP square —
2305
+ the auto glyph sits above the leftover count, both in the disc's own ink, and a tap resumes. */
2306
+ #${SHELL_ROOT_ID} .ge-shell-spin.ge-auto-paused { flex-direction:column; gap:1px; position:relative; }
2307
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-auto { display:flex; font-size:.46em; line-height:0; }
2308
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-count { position:static; inset:auto; color:inherit; font-size:20px; }
2265
2309
 
2266
2310
  /* BUY BONUS — round accent badge, 2-line label, text pulses + accent glow on hover */
2267
2311
  #${SHELL_ROOT_ID} .ge-shell-buybonus { pointer-events:auto; cursor:pointer; box-sizing:border-box;
@@ -3083,22 +3127,36 @@ function buyBtn(host) {
3083
3127
  function betLocked(host) {
3084
3128
  return host.state.busy || host.state.autoplay.active;
3085
3129
  }
3086
- /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
3130
+ /**
3131
+ * SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs; becomes an
3132
+ * autoplay glyph + the SAME countdown when a run was halted with spins still owed (a lost
3133
+ * connection), where a tap resumes it. That third state is what a certification lab means by "after
3134
+ * reconnection the counter is displayed correctly": the run stopped, but the spins the player asked
3135
+ * for are still on screen and one tap away, instead of silently reset to zero.
3136
+ */
3087
3137
  function spinButton(host) {
3088
3138
  const { state } = host;
3089
3139
  const sp = document.createElement('button');
3090
3140
  sp.className = 'ge-shell-spin';
3091
3141
  sp.dataset.ge = 'spin';
3142
+ const rem = state.autoplay.remaining;
3143
+ const count = Number.isFinite(rem) ? String(rem) : '∞';
3092
3144
  if (state.autoplay.active) {
3093
3145
  sp.classList.add('ge-stop');
3094
- const rem = state.autoplay.remaining;
3095
- const label = Number.isFinite(rem) ? String(rem) : '∞';
3096
- sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
3146
+ sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${count}</span>`;
3097
3147
  sp.addEventListener('click', () => {
3098
3148
  if (!sp.disabled)
3099
3149
  host.actions.stopAutoplay();
3100
3150
  });
3101
3151
  }
3152
+ else if (rem > 0) {
3153
+ sp.classList.add('ge-auto-paused');
3154
+ sp.innerHTML = `<span class="ge-spin-auto">${icon('autoplay')}</span><span class="ge-spin-count">${count}</span>`;
3155
+ sp.addEventListener('click', () => {
3156
+ if (!sp.disabled)
3157
+ host.actions.startAutoplay(rem);
3158
+ });
3159
+ }
3102
3160
  else {
3103
3161
  sp.innerHTML = icon('spin');
3104
3162
  if (state.busy)
@@ -3118,8 +3176,11 @@ function autoButton(host) {
3118
3176
  b.classList.add('ge-glow');
3119
3177
  return b;
3120
3178
  }
3179
+ /** Same button, three jobs: stop a running run, retire a halted run's leftover count (which frees
3180
+ * the disc for a manual spin again), or open the picker. */
3121
3181
  function onAutoplay(host) {
3122
- if (host.state.autoplay.active)
3182
+ const { active, remaining } = host.state.autoplay;
3183
+ if (active || remaining > 0)
3123
3184
  host.actions.stopAutoplay();
3124
3185
  else
3125
3186
  host.actions.openAutoplayPicker();
@@ -3475,10 +3536,15 @@ function openGameInfoModal(host) {
3475
3536
  onBack: () => { root.remove(); host.actions.openMenu(); },
3476
3537
  });
3477
3538
  root.dataset.ge = 'info-modal';
3478
- const rawSections = host.config.gameInfo.sections ?? [];
3479
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
3539
+ const allSections = host.config.gameInfo.sections ?? [];
3540
+ // Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
3541
+ // jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
3542
+ // there is no keyboard surface to document, and a game-supplied section is dropped along with the
3543
+ // auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
3544
+ const keys = host.config.features.hotkeys !== false;
3545
+ const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
3480
3546
  const sectionsWithHotkeys = [...rawSections];
3481
- if (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
3547
+ if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
3482
3548
  sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
3483
3549
  }
3484
3550
  const sections = sectionsWithHotkeys;