@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/shell",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Energy8 branded game shell — one logic core, pluggable html/pixi renderers behind a stable contract.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -5,6 +5,7 @@ import { formatCurrency } from './format';
5
5
  import { createI18n, type I18n } from './i18n';
6
6
  import { KeyboardController, type KeyboardHost } from './keyboard';
7
7
  import { DEFAULT_MENU, rangeBounds, seedMenuValues, type MenuItem, type MenuRangeItem } from './menu';
8
+ import { keyboardCapable } from './device';
8
9
  import { PACKAGE_VERSION } from './version';
9
10
  import type {
10
11
  ShellConfig,
@@ -46,7 +47,10 @@ export function resolveConfig(config: ShellConfig): ResolvedShellConfig {
46
47
  win: config.win,
47
48
  mode: config.mode,
48
49
  gameInfo: config.gameInfo,
49
- features: config.features,
50
+ // `hotkeys` unset means "decide for me": a touchscreen has no keys to press, so the shell
51
+ // neither binds them nor advertises them there. A host that knows better — the platform's own
52
+ // `device` field, a jurisdiction rule — says so outright and that wins. See core/device.ts.
53
+ features: { ...config.features, hotkeys: config.features.hotkeys ?? keyboardCapable() },
50
54
  theme: config.theme,
51
55
  onBonusBuy: config.onBonusBuy,
52
56
  volumes: config.volumes,
@@ -145,7 +149,11 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
145
149
  this.renderer.renderBar();
146
150
  },
147
151
  toggleAutoplay: () => {
148
- if (this.state.autoplay.active) a.stopAutoplay();
152
+ // A halted run (stopped, but with spins still owed after a lost connection) counts as
153
+ // "autoplay is on screen": the toggle retires its leftover count, exactly as it stops a
154
+ // running one. Resuming those spins is the disc's job, not this one's.
155
+ const { active, remaining } = this.state.autoplay;
156
+ if (active || remaining > 0) a.stopAutoplay();
149
157
  else this.openAutoplayPicker();
150
158
  },
151
159
  startAutoplay: (remaining) => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Can the player in front of this client actually press a key?
3
+ *
4
+ * The shell documents its shortcuts in a Hotkeys section and binds Spacebar to spin. On a phone
5
+ * neither is reachable, and showing a keycap chart to someone holding a touchscreen is a promise
6
+ * the game can't keep — a certification lab reads it as a feature offered where it doesn't work.
7
+ *
8
+ * The question is deliberately NOT "is the layout narrow" (a portrait desktop window still has a
9
+ * keyboard) and NOT "is there a touchscreen" (a touch laptop has both). It is: what does the
10
+ * PRIMARY pointer look like, and can it hover? Coarse-and-hoverless is a touchscreen, and a
11
+ * touchscreen is the one case where the keys genuinely aren't there.
12
+ *
13
+ * A tablet with a keyboard case answers "coarse, no hover" too and loses the chart. That is the
14
+ * right side to be wrong on: the chart is a convenience, and the keys keep working for anyone who
15
+ * has them — the media query only decides what the shell ADVERTISES (hosts can still say outright,
16
+ * via `features.hotkeys`, and the platform's own `device` field does exactly that).
17
+ */
18
+
19
+ interface MediaQueryHost {
20
+ matchMedia?(query: string): { matches: boolean };
21
+ }
22
+
23
+ /** A touchscreen: the primary pointer is a finger, and nothing can hover. */
24
+ const TOUCH_ONLY = '(pointer: coarse) and (hover: none)';
25
+
26
+ export function keyboardCapable(
27
+ win: MediaQueryHost | undefined = typeof window === 'undefined' ? undefined : window,
28
+ ): boolean {
29
+ // No window (SSR, node tests) or a browser too old for matchMedia: assume a keyboard rather than
30
+ // silently stripping shortcuts from a desktop we simply failed to measure.
31
+ if (typeof win?.matchMedia !== 'function') return true;
32
+ try {
33
+ return !win.matchMedia(TOUCH_ONLY).matches;
34
+ } catch {
35
+ return true;
36
+ }
37
+ }
package/src/core/types.ts CHANGED
@@ -156,8 +156,13 @@ export interface AutoplayConfig {
156
156
 
157
157
  export interface ShellFeatures {
158
158
  turbo: 0 | 1 | 2 | 3;
159
- /** Master keyboard-shortcut switch. Defaults to `true`; set `false` to disable ALL hotkeys
160
- * (overrides `spacebar` and any future hotkey). */
159
+ /** Master keyboard-shortcut switch: `false` disables ALL hotkeys (overrides `spacebar` and any
160
+ * future hotkey) AND hides the Hotkeys section of Game Info, including one the game supplied
161
+ * itself — a keycap chart for keys that do nothing is worse than no chart.
162
+ *
163
+ * Left unset, the shell measures the client (`core/device.ts`): a touchscreen has no keys to
164
+ * press, so it gets neither the shortcuts nor the chart. Set it explicitly when you know better
165
+ * than the media query — which is what the host does with the platform's `device` field. */
161
166
  hotkeys?: boolean;
162
167
  /** Spacebar starts a spin in base mode. Defaults to `true`; set `false` to disable the
163
168
  * keyboard shortcut (e.g. jurisdictions that forbid quick-spin keys). */
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
2
2
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
3
- export const PACKAGE_VERSION = '0.8.0';
3
+ export const PACKAGE_VERSION = '0.9.0';
@@ -225,20 +225,32 @@ function betLocked(host: ShellHost): boolean {
225
225
  return host.state.busy || host.state.autoplay.active;
226
226
  }
227
227
 
228
- /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
228
+ /**
229
+ * SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs; becomes an
230
+ * autoplay glyph + the SAME countdown when a run was halted with spins still owed (a lost
231
+ * connection), where a tap resumes it. That third state is what a certification lab means by "after
232
+ * reconnection the counter is displayed correctly": the run stopped, but the spins the player asked
233
+ * for are still on screen and one tap away, instead of silently reset to zero.
234
+ */
229
235
  function spinButton(host: ShellHost): HTMLButtonElement {
230
236
  const { state } = host;
231
237
  const sp = document.createElement('button');
232
238
  sp.className = 'ge-shell-spin';
233
239
  sp.dataset.ge = 'spin';
240
+ const rem = state.autoplay.remaining;
241
+ const count = Number.isFinite(rem) ? String(rem) : '∞';
234
242
  if (state.autoplay.active) {
235
243
  sp.classList.add('ge-stop');
236
- const rem = state.autoplay.remaining;
237
- const label = Number.isFinite(rem) ? String(rem) : '∞';
238
- sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
244
+ sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${count}</span>`;
239
245
  sp.addEventListener('click', () => {
240
246
  if (!sp.disabled) host.actions.stopAutoplay();
241
247
  });
248
+ } else if (rem > 0) {
249
+ sp.classList.add('ge-auto-paused');
250
+ sp.innerHTML = `<span class="ge-spin-auto">${icon('autoplay')}</span><span class="ge-spin-count">${count}</span>`;
251
+ sp.addEventListener('click', () => {
252
+ if (!sp.disabled) host.actions.startAutoplay(rem);
253
+ });
242
254
  } else {
243
255
  sp.innerHTML = icon('spin');
244
256
  if (state.busy) sp.classList.add('ge-spinning');
@@ -257,8 +269,11 @@ function autoButton(host: ShellHost): HTMLButtonElement {
257
269
  return b;
258
270
  }
259
271
 
272
+ /** Same button, three jobs: stop a running run, retire a halted run's leftover count (which frees
273
+ * the disc for a manual spin again), or open the picker. */
260
274
  function onAutoplay(host: ShellHost): void {
261
- if (host.state.autoplay.active) host.actions.stopAutoplay();
275
+ const { active, remaining } = host.state.autoplay;
276
+ if (active || remaining > 0) host.actions.stopAutoplay();
262
277
  else host.actions.openAutoplayPicker();
263
278
  }
264
279
 
@@ -24,10 +24,15 @@ export function openGameInfoModal(host: ShellHost): GameInfoModal {
24
24
  });
25
25
  root.dataset.ge = 'info-modal';
26
26
 
27
- const rawSections = host.config.gameInfo.sections ?? [];
28
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
27
+ const allSections = host.config.gameInfo.sections ?? [];
28
+ // Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
29
+ // jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
30
+ // there is no keyboard surface to document, and a game-supplied section is dropped along with the
31
+ // auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
32
+ const keys = host.config.features.hotkeys !== false;
33
+ const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
29
34
  const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
30
- if (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
35
+ if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
31
36
  sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
32
37
  }
33
38
  const sections = sectionsWithHotkeys;
@@ -57,6 +57,11 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
57
57
  /* the STOP glyph is a solid dark square, so the autoplay count is always pure white to read on it. */
58
58
  #${SHELL_ROOT_ID} .ge-spin-count { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
59
59
  font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; color:#fff; }
60
+ /* autoplay halted with spins still owed (a lost connection): the run is stopped, so no STOP square —
61
+ the auto glyph sits above the leftover count, both in the disc's own ink, and a tap resumes. */
62
+ #${SHELL_ROOT_ID} .ge-shell-spin.ge-auto-paused { flex-direction:column; gap:1px; position:relative; }
63
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-auto { display:flex; font-size:.46em; line-height:0; }
64
+ #${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-count { position:static; inset:auto; color:inherit; font-size:20px; }
60
65
 
61
66
  /* BUY BONUS — round accent badge, 2-line label, text pulses + accent glow on hover */
62
67
  #${SHELL_ROOT_ID} .ge-shell-buybonus { pointer-events:auto; cursor:pointer; box-sizing:border-box;
@@ -13,9 +13,19 @@ import {
13
13
  FsHero,
14
14
  divider,
15
15
  } from '../primitives/widgets';
16
+ import type { SpinAutoplayMode } from '../primitives/widgets';
16
17
  import type { IconName } from '../icons';
17
18
 
18
19
  // ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
20
+ /** Which of the disc's three faces the autoplay state calls for — see `SpinAutoplayMode`. A run
21
+ * that halted with spins still owed (`!active && remaining > 0`) keeps its counter on the disc. */
22
+ function autoplayDiscMode(state: {
23
+ autoplay: { active: boolean; remaining: number };
24
+ }): SpinAutoplayMode {
25
+ if (state.autoplay.active) return 'running';
26
+ return state.autoplay.remaining > 0 ? 'paused' : 'off';
27
+ }
28
+
19
29
  const BAR_H = 68; // continuous dark panel height
20
30
  const SPIN = 84; // hero disc — pops above/below the bar
21
31
  const SPIN_POP = (SPIN - BAR_H) / 2; // 8 — how far the disc sticks out top/bottom
@@ -296,8 +306,9 @@ export class BottomBar extends Container {
296
306
  ticker: this.host.ticker,
297
307
  onSpin: () => this.host.actions.spin(),
298
308
  onStop: () => this.stopAutoplay(),
309
+ onResume: () => this.resumeAutoplay(),
299
310
  });
300
- if (state.autoplay.active) this.spin.setAutoplay(true, state.autoplay.remaining);
311
+ this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
301
312
  if (state.busy) this.spin.setBusy(true);
302
313
  spinWrap.add(this.spin);
303
314
  } else if (showFsBlocks) {
@@ -373,8 +384,9 @@ export class BottomBar extends Container {
373
384
  ticker: this.host.ticker,
374
385
  onSpin: () => this.host.actions.spin(),
375
386
  onStop: () => this.stopAutoplay(),
387
+ onResume: () => this.resumeAutoplay(),
376
388
  });
377
- if (state.autoplay.active) this.spin.setAutoplay(true, state.autoplay.remaining);
389
+ this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
378
390
  if (state.busy) this.spin.setBusy(true);
379
391
  hero = this.spin;
380
392
  } else if (showFsBlocks) {
@@ -569,13 +581,20 @@ export class BottomBar extends Container {
569
581
  private onTurbo(): void {
570
582
  this.host.actions.cycleTurbo();
571
583
  }
584
+ /** Same button, three jobs: stop a running run, retire a halted run's leftover count (freeing the
585
+ * disc for a manual spin again), or open the picker. Mirrors the DOM bar's `onAutoplay`. */
572
586
  private onAutoplay(): void {
573
- if (this.host.state.autoplay.active) this.stopAutoplay();
587
+ const { active, remaining } = this.host.state.autoplay;
588
+ if (active || remaining > 0) this.stopAutoplay();
574
589
  else this.host.actions.openAutoplayPicker();
575
590
  }
576
591
  private stopAutoplay(): void {
577
592
  this.host.actions.stopAutoplay();
578
593
  }
594
+ /** Halted run, tapped: play out the spins it still owes. */
595
+ private resumeAutoplay(): void {
596
+ this.host.actions.startAutoplay(this.host.state.autoplay.remaining);
597
+ }
579
598
  private betLocked(): boolean {
580
599
  return this.host.state.busy || this.host.state.autoplay.active;
581
600
  }
@@ -37,10 +37,15 @@ export function openGameInfo(host: PixiComponentContext): ShellLayer {
37
37
 
38
38
  function buildBody(host: PixiComponentContext, width: number): Container {
39
39
  const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 12 });
40
- const rawSections = host.config.gameInfo.sections ?? [];
41
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
40
+ const allSections = host.config.gameInfo.sections ?? [];
41
+ // Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
42
+ // jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
43
+ // there is no keyboard surface to document, and a game-supplied section is dropped along with the
44
+ // auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
45
+ const keys = host.config.features.hotkeys !== false;
46
+ const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
42
47
  const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
43
- if (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
48
+ if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
44
49
  sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
45
50
  }
46
51
  const sections = sectionsWithHotkeys;
@@ -362,8 +362,20 @@ export interface SpinDiscOpts {
362
362
  ticker: Ticker;
363
363
  onSpin: () => void;
364
364
  onStop: () => void;
365
+ /** Tapped in the halted-autoplay state — resume the run with the count still on the disc. */
366
+ onResume?: () => void;
365
367
  }
366
368
 
369
+ /**
370
+ * What the disc is showing:
371
+ * - `off` — an ordinary SPIN disc;
372
+ * - `running` — STOP glyph over the live countdown;
373
+ * - `paused` — a run halted with spins still owed (a lost connection). The count stays put and a
374
+ * tap resumes; clearing it is the autoplay button's job. Certification asks for exactly this:
375
+ * the run stops, and after reconnection the counter is still there and still correct.
376
+ */
377
+ export type SpinAutoplayMode = 'off' | 'running' | 'paused';
378
+
367
379
  export class SpinDisc extends Container implements Sizable {
368
380
  private size: number;
369
381
  private glyphSize: number;
@@ -375,13 +387,14 @@ export class SpinDisc extends Container implements Sizable {
375
387
  private glyph: IconView;
376
388
  private dim = new Graphics();
377
389
  private countText?: Text;
378
- private mode: 'spin' | 'stop' = 'spin';
390
+ private mode: 'spin' | 'stop' | 'resume' = 'spin';
379
391
  private _busy = false;
380
392
  private _disabled = false;
381
393
  private hovering = false;
382
394
  private rotTick?: (t: Ticker) => void;
383
395
  private onSpin: () => void;
384
396
  private onStop: () => void;
397
+ private onResume: () => void;
385
398
 
386
399
  constructor(opts: SpinDiscOpts) {
387
400
  super();
@@ -391,6 +404,7 @@ export class SpinDisc extends Container implements Sizable {
391
404
  this.ticker = opts.ticker;
392
405
  this.onSpin = opts.onSpin;
393
406
  this.onStop = opts.onStop;
407
+ this.onResume = opts.onResume ?? opts.onSpin;
394
408
  this.disc = new Graphics();
395
409
  this.glyph = makeIcon('spin', this.glyphSize, this.tokens.btnInk);
396
410
  this.glyph.position.set((this.size - this.glyphSize) / 2, (this.size - this.glyphSize) / 2);
@@ -409,6 +423,7 @@ export class SpinDisc extends Container implements Sizable {
409
423
  attachPress(this, 0.94, () => {
410
424
  if (this._disabled) return;
411
425
  if (this.mode === 'stop') this.onStop();
426
+ else if (this.mode === 'resume') this.onResume();
412
427
  else this.onSpin();
413
428
  });
414
429
  }
@@ -420,8 +435,10 @@ export class SpinDisc extends Container implements Sizable {
420
435
  drawDisc(this.disc, this.size, this.tokens.btn, 4);
421
436
  const glyphColor = hot ? this.tokens.accent : this.tokens.btnInk;
422
437
  this.glyph.setColor(glyphColor);
423
- // the count sits ON the solid (btnInk) STOP square, so it must be light to read
424
- if (this.countText) this.countText.style.fill = '#ffffff';
438
+ // the count sits ON the solid (btnInk) STOP square, so it must be light to read; halted, it
439
+ // sits on the bare disc instead and takes the disc's own ink.
440
+ if (this.countText)
441
+ this.countText.style.fill = this.mode === 'resume' ? this.tokens.btnInk : '#ffffff';
425
442
  // Disabled (mid-spin / can't spin): darken OPAQUELY (≈ filter:grayscale(.4) brightness(.62))
426
443
  // with a dark veil over the disc — not alpha, which would let the bright board show through and
427
444
  // read as a translucent/missing button (what looked "transparent" while spinning).
@@ -431,31 +448,44 @@ export class SpinDisc extends Container implements Sizable {
431
448
  }
432
449
  }
433
450
 
434
- /** STOP glyph + remaining-count, when autoplay runs. */
435
- setAutoplay(active: boolean, remaining: number): void {
436
- if (active) {
437
- this.mode = 'stop';
438
- this.stopRotation();
439
- // STOP glyph at the disc's full size (like SPIN); the count is centred on top of it.
440
- this.glyph.visible = false;
441
- this.stopGlyph();
442
- if (!this.countText) {
443
- this.countText = makeText('', { size: 22, weight: '800', color: '#ffffff', align: 'center', family: NUM_FONT_FAMILY });
444
- this.addChild(this.countText); // added after the STOP glyph → renders on top of it
445
- }
446
- const label = Number.isFinite(remaining) ? String(remaining) : '∞';
447
- setText(this.countText, label);
448
- this.countText.position.set((this.size - this.countText.width) / 2, (this.size - this.countText.height) / 2);
449
- } else {
451
+ /** STOP glyph + remaining-count while autoplay runs; auto glyph + the same count once it halts. */
452
+ setAutoplay(mode: SpinAutoplayMode, remaining: number): void {
453
+ if (mode === 'off') {
450
454
  this.mode = 'spin';
451
455
  this.glyph.visible = true;
452
456
  this.removeStopGlyph();
457
+ this.removeAutoGlyph();
453
458
  if (this.countText) {
454
459
  this.removeChild(this.countText);
455
460
  this.countText.destroy();
456
461
  this.countText = undefined;
457
462
  }
463
+ this.paint();
464
+ return;
465
+ }
466
+ const running = mode === 'running';
467
+ this.mode = running ? 'stop' : 'resume';
468
+ this.stopRotation();
469
+ this.glyph.visible = false;
470
+ // Running: a solid STOP square at the disc's full size (like SPIN), count centred on top of it.
471
+ // Halted: the auto glyph, smaller and lifted, with the count under it — no dark square, because
472
+ // nothing is running to stop, and a white count would have nothing to read against.
473
+ if (running) {
474
+ this.removeAutoGlyph();
475
+ this.stopGlyph();
476
+ } else {
477
+ this.removeStopGlyph();
478
+ this.autoGlyph();
458
479
  }
480
+ if (!this.countText) {
481
+ this.countText = makeText('', { size: 22, weight: '800', color: '#ffffff', align: 'center', family: NUM_FONT_FAMILY });
482
+ this.addChild(this.countText); // added after the glyph → renders on top of it
483
+ }
484
+ setText(this.countText, Number.isFinite(remaining) ? String(remaining) : '∞');
485
+ const cy = running
486
+ ? (this.size - this.countText.height) / 2
487
+ : this.size * 0.56; // sits under the lifted auto glyph
488
+ this.countText.position.set((this.size - this.countText.width) / 2, cy);
459
489
  this.paint();
460
490
  }
461
491
 
@@ -475,6 +505,22 @@ export class SpinDisc extends Container implements Sizable {
475
505
  }
476
506
  }
477
507
 
508
+ private autoGlyphView?: IconView;
509
+ private autoGlyph(): void {
510
+ if (this.autoGlyphView) return;
511
+ const size = this.glyphSize * 0.42;
512
+ this.autoGlyphView = makeIcon('autoplay', size, this.tokens.btnInk);
513
+ this.autoGlyphView.position.set((this.size - size) / 2, this.size * 0.2);
514
+ this.addChild(this.autoGlyphView);
515
+ }
516
+ private removeAutoGlyph(): void {
517
+ if (this.autoGlyphView) {
518
+ this.removeChild(this.autoGlyphView);
519
+ this.autoGlyphView.destroy();
520
+ this.autoGlyphView = undefined;
521
+ }
522
+ }
523
+
478
524
  setBusy(busy: boolean): void {
479
525
  this._busy = busy;
480
526
  if (busy && this.mode === 'spin') this.startRotation();