@energy8platform/shell 0.8.0 → 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.cjs.js CHANGED
@@ -1691,9 +1691,41 @@ class KeyboardController {
1691
1691
  }
1692
1692
  }
1693
1693
 
1694
+ /**
1695
+ * Can the player in front of this client actually press a key?
1696
+ *
1697
+ * The shell documents its shortcuts in a Hotkeys section and binds Spacebar to spin. On a phone
1698
+ * neither is reachable, and showing a keycap chart to someone holding a touchscreen is a promise
1699
+ * the game can't keep — a certification lab reads it as a feature offered where it doesn't work.
1700
+ *
1701
+ * The question is deliberately NOT "is the layout narrow" (a portrait desktop window still has a
1702
+ * keyboard) and NOT "is there a touchscreen" (a touch laptop has both). It is: what does the
1703
+ * PRIMARY pointer look like, and can it hover? Coarse-and-hoverless is a touchscreen, and a
1704
+ * touchscreen is the one case where the keys genuinely aren't there.
1705
+ *
1706
+ * A tablet with a keyboard case answers "coarse, no hover" too and loses the chart. That is the
1707
+ * right side to be wrong on: the chart is a convenience, and the keys keep working for anyone who
1708
+ * has them — the media query only decides what the shell ADVERTISES (hosts can still say outright,
1709
+ * via `features.hotkeys`, and the platform's own `device` field does exactly that).
1710
+ */
1711
+ /** A touchscreen: the primary pointer is a finger, and nothing can hover. */
1712
+ const TOUCH_ONLY = '(pointer: coarse) and (hover: none)';
1713
+ function keyboardCapable(win = typeof window === 'undefined' ? undefined : window) {
1714
+ // No window (SSR, node tests) or a browser too old for matchMedia: assume a keyboard rather than
1715
+ // silently stripping shortcuts from a desktop we simply failed to measure.
1716
+ if (typeof win?.matchMedia !== 'function')
1717
+ return true;
1718
+ try {
1719
+ return !win.matchMedia(TOUCH_ONLY).matches;
1720
+ }
1721
+ catch {
1722
+ return true;
1723
+ }
1724
+ }
1725
+
1694
1726
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1695
1727
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1696
- const PACKAGE_VERSION = '0.8.0';
1728
+ const PACKAGE_VERSION = '0.9.0';
1697
1729
 
1698
1730
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1699
1731
  function resolveConfig(config) {
@@ -1707,7 +1739,10 @@ function resolveConfig(config) {
1707
1739
  win: config.win,
1708
1740
  mode: config.mode,
1709
1741
  gameInfo: config.gameInfo,
1710
- features: config.features,
1742
+ // `hotkeys` unset means "decide for me": a touchscreen has no keys to press, so the shell
1743
+ // neither binds them nor advertises them there. A host that knows better — the platform's own
1744
+ // `device` field, a jurisdiction rule — says so outright and that wins. See core/device.ts.
1745
+ features: { ...config.features, hotkeys: config.features.hotkeys ?? keyboardCapable() },
1711
1746
  theme: config.theme,
1712
1747
  onBonusBuy: config.onBonusBuy,
1713
1748
  volumes: config.volumes,
@@ -1801,7 +1836,11 @@ class ShellController extends EventEmitter {
1801
1836
  this.renderer.renderBar();
1802
1837
  },
1803
1838
  toggleAutoplay: () => {
1804
- if (this.state.autoplay.active)
1839
+ // A halted run (stopped, but with spins still owed after a lost connection) counts as
1840
+ // "autoplay is on screen": the toggle retires its leftover count, exactly as it stops a
1841
+ // running one. Resuming those spins is the disc's job, not this one's.
1842
+ const { active, remaining } = this.state.autoplay;
1843
+ if (active || remaining > 0)
1805
1844
  a.stopAutoplay();
1806
1845
  else
1807
1846
  this.openAutoplayPicker();
@@ -2264,6 +2303,11 @@ const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
2264
2303
  /* the STOP glyph is a solid dark square, so the autoplay count is always pure white to read on it. */
2265
2304
  #${SHELL_ROOT_ID} .ge-spin-count { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
2266
2305
  font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; color:#fff; }
2306
+ /* autoplay halted with spins still owed (a lost connection): the run is stopped, so no STOP square —
2307
+ the auto glyph sits above the leftover count, both in the disc's own ink, and a tap resumes. */
2308
+ #${SHELL_ROOT_ID} .ge-shell-spin.ge-auto-paused { flex-direction:column; gap:1px; position:relative; }
2309
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-auto { display:flex; font-size:.46em; line-height:0; }
2310
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-count { position:static; inset:auto; color:inherit; font-size:20px; }
2267
2311
 
2268
2312
  /* BUY BONUS — round accent badge, 2-line label, text pulses + accent glow on hover */
2269
2313
  #${SHELL_ROOT_ID} .ge-shell-buybonus { pointer-events:auto; cursor:pointer; box-sizing:border-box;
@@ -3085,22 +3129,36 @@ function buyBtn(host) {
3085
3129
  function betLocked(host) {
3086
3130
  return host.state.busy || host.state.autoplay.active;
3087
3131
  }
3088
- /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
3132
+ /**
3133
+ * SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs; becomes an
3134
+ * autoplay glyph + the SAME countdown when a run was halted with spins still owed (a lost
3135
+ * connection), where a tap resumes it. That third state is what a certification lab means by "after
3136
+ * reconnection the counter is displayed correctly": the run stopped, but the spins the player asked
3137
+ * for are still on screen and one tap away, instead of silently reset to zero.
3138
+ */
3089
3139
  function spinButton(host) {
3090
3140
  const { state } = host;
3091
3141
  const sp = document.createElement('button');
3092
3142
  sp.className = 'ge-shell-spin';
3093
3143
  sp.dataset.ge = 'spin';
3144
+ const rem = state.autoplay.remaining;
3145
+ const count = Number.isFinite(rem) ? String(rem) : '∞';
3094
3146
  if (state.autoplay.active) {
3095
3147
  sp.classList.add('ge-stop');
3096
- const rem = state.autoplay.remaining;
3097
- const label = Number.isFinite(rem) ? String(rem) : '∞';
3098
- sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
3148
+ sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${count}</span>`;
3099
3149
  sp.addEventListener('click', () => {
3100
3150
  if (!sp.disabled)
3101
3151
  host.actions.stopAutoplay();
3102
3152
  });
3103
3153
  }
3154
+ else if (rem > 0) {
3155
+ sp.classList.add('ge-auto-paused');
3156
+ sp.innerHTML = `<span class="ge-spin-auto">${icon('autoplay')}</span><span class="ge-spin-count">${count}</span>`;
3157
+ sp.addEventListener('click', () => {
3158
+ if (!sp.disabled)
3159
+ host.actions.startAutoplay(rem);
3160
+ });
3161
+ }
3104
3162
  else {
3105
3163
  sp.innerHTML = icon('spin');
3106
3164
  if (state.busy)
@@ -3120,8 +3178,11 @@ function autoButton(host) {
3120
3178
  b.classList.add('ge-glow');
3121
3179
  return b;
3122
3180
  }
3181
+ /** Same button, three jobs: stop a running run, retire a halted run's leftover count (which frees
3182
+ * the disc for a manual spin again), or open the picker. */
3123
3183
  function onAutoplay(host) {
3124
- if (host.state.autoplay.active)
3184
+ const { active, remaining } = host.state.autoplay;
3185
+ if (active || remaining > 0)
3125
3186
  host.actions.stopAutoplay();
3126
3187
  else
3127
3188
  host.actions.openAutoplayPicker();
@@ -3477,10 +3538,15 @@ function openGameInfoModal(host) {
3477
3538
  onBack: () => { root.remove(); host.actions.openMenu(); },
3478
3539
  });
3479
3540
  root.dataset.ge = 'info-modal';
3480
- const rawSections = host.config.gameInfo.sections ?? [];
3481
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
3541
+ const allSections = host.config.gameInfo.sections ?? [];
3542
+ // Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
3543
+ // jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
3544
+ // there is no keyboard surface to document, and a game-supplied section is dropped along with the
3545
+ // auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
3546
+ const keys = host.config.features.hotkeys !== false;
3547
+ const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
3482
3548
  const sectionsWithHotkeys = [...rawSections];
3483
- if (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
3549
+ if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
3484
3550
  sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
3485
3551
  }
3486
3552
  const sections = sectionsWithHotkeys;