@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/shell",
3
- "version": "0.7.2",
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,29 @@
1
+ import type { BonusOption } from './types';
2
+
3
+ /** Collapse buy-bonus options that share a `groupedBy` key into one slot. A slot with more than one
4
+ * member is rendered by the pixi shell as a single card with arrows (the DOM shell ignores the key
5
+ * and keeps a card per option).
6
+ *
7
+ * A group takes the position of its FIRST member, so the strip order stays predictable however the
8
+ * members are scattered through the array. Options with a game-supplied `custom` renderer never
9
+ * join a group — the shell has no layout for their interior and could not place arrows over it. */
10
+ export function groupBonusSlots(bonuses: readonly BonusOption[]): BonusOption[][] {
11
+ const slots: BonusOption[][] = [];
12
+ const byKey = new Map<string, BonusOption[]>();
13
+ for (const bonus of bonuses) {
14
+ const key = bonus.groupedBy;
15
+ if (!key || bonus.custom) {
16
+ slots.push([bonus]);
17
+ continue;
18
+ }
19
+ const open = byKey.get(key);
20
+ if (open) {
21
+ open.push(bonus);
22
+ continue;
23
+ }
24
+ const slot = [bonus];
25
+ byKey.set(key, slot);
26
+ slots.push(slot);
27
+ }
28
+ return slots;
29
+ }
@@ -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
@@ -40,6 +40,11 @@ export interface BonusOption {
40
40
  priceMultiplier: number;
41
41
  /** Per-option accent override. Falls back to the type default (bonus → purple, feature → gold). */
42
42
  accentColor?: string;
43
+ /** Options sharing a `groupedBy` key occupy ONE card slot and are flipped through with arrows —
44
+ * e.g. four same-priced Ante variants that each swap the character, its art and its volatility.
45
+ * Pixi shell only; the DOM shell ignores the key and keeps a card per option. Options with a
46
+ * `custom` renderer are never grouped. */
47
+ groupedBy?: string;
43
48
  /** Override the card UI. Return the card's inner content; the shell keeps the grid wrapper,
44
49
  * accent vars and live re-pricing, and runs the normal buy flow when you call `ctx.select()`.
45
50
  * Core uses `unknown`; each renderer re-exports a typed alias (ui/html → HTMLElement,
@@ -151,8 +156,13 @@ export interface AutoplayConfig {
151
156
 
152
157
  export interface ShellFeatures {
153
158
  turbo: 0 | 1 | 2 | 3;
154
- /** Master keyboard-shortcut switch. Defaults to `true`; set `false` to disable ALL hotkeys
155
- * (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. */
156
166
  hotkeys?: boolean;
157
167
  /** Spacebar starts a spin in base mode. Defaults to `true`; set `false` to disable the
158
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.7.2';
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
  }
@@ -1,6 +1,7 @@
1
1
  import { Assets, Container, Graphics, Rectangle, Sprite, Text, Texture, type FederatedPointerEvent } from 'pixi.js';
2
2
  import type { PixiComponentContext, ShellLayer } from '../context';
3
3
  import type { BonusOption } from '@/core/types';
4
+ import { groupBonusSlots } from '@/core/bonusGroups';
4
5
  import { betDir } from '@/core/keyboard';
5
6
  import { effectiveAccent, contrastText } from '@/core/colors';
6
7
  import { makeText } from '../text';
@@ -23,11 +24,15 @@ export function openBuyBonus(host: PixiComponentContext): ShellLayer | null {
23
24
  return new BuyBonusOverlay(host, bonuses);
24
25
  }
25
26
 
26
- /** Pairing of a rendered CardView with its bonus data (affordability included). */
27
+ /** Pairing of a rendered CardView with its bonus data (affordability included). One entry per
28
+ * OPTION — the members of a `groupedBy` slot each get an entry pointing at the shared group card,
29
+ * so keyboard navigation walks every option even though they occupy one slot. */
27
30
  interface CardEntry {
28
31
  view: CardView;
29
32
  bonus: BonusOption;
30
33
  affordable: boolean;
34
+ /** Set on members of a grouped slot; flipping it brings this member's card to the front. */
35
+ group?: GroupCard;
31
36
  }
32
37
 
33
38
  class BuyBonusOverlay extends Container implements ShellLayer {
@@ -51,6 +56,9 @@ class BuyBonusOverlay extends Container implements ShellLayer {
51
56
  private cardEntries: CardEntry[] = [];
52
57
  /** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
53
58
  private focusIndex = -1;
59
+ /** Which member each `groupedBy` slot is showing, kept across rebuilds (bet steps, resizes) so a
60
+ * flipped-to variant doesn't snap back to the first one under the player. */
61
+ private groupIndex = new Map<string, number>();
54
62
  /** true when cards are stacked vertically (mobile, or a short landscape popout). */
55
63
  private stack = false;
56
64
  // Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
@@ -169,7 +177,9 @@ class BuyBonusOverlay extends Container implements ShellLayer {
169
177
  const top = this.headerH + 6;
170
178
  const areaH = this.h - top - this.footerH - 6;
171
179
  const gap = 14;
172
- const n = Math.max(1, this.bonuses.length);
180
+ // Grouped options share ONE slot (a carousel card), so the width fit counts SLOTS, not options.
181
+ const slots = groupBonusSlots(this.bonuses);
182
+ const n = Math.max(1, slots.length);
173
183
  // The whole card is laid out in `em`; pick the largest em that fits BOTH dimensions of the
174
184
  // available area, so the card is always fully visible (no horizontal clip, CTA never under the
175
185
  // footer). emH keeps the card height within the band between header and footer; emW makes the
@@ -182,16 +192,45 @@ class BuyBonusOverlay extends Container implements ShellLayer {
182
192
  const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
183
193
  const cardW = Math.min(18 * em, this.w - 48);
184
194
 
185
- const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, stack, areaH));
195
+ const cards: CardView[] = [];
196
+ this.cardEntries = [];
197
+ for (const slot of slots) {
198
+ if (slot.length === 1) {
199
+ const view = this.buildCard(slot[0], cardW, em, stack, areaH);
200
+ cards.push(view);
201
+ this.cardEntries.push({ view, bonus: slot[0], affordable: this.isAffordable(slot[0]) });
202
+ continue;
203
+ }
204
+ // Carousel slot: every member is built (the slot sizes to the tallest and flipping costs
205
+ // nothing), but only the current one is visible.
206
+ const key = slot[0].groupedBy as string;
207
+ // Flipping the card also moves keyboard focus onto the shown member: one cursor, not two
208
+ // (otherwise the next rebuild would snap the card back to whatever the focus still pointed at).
209
+ const group: GroupCard = new GroupCard((i) => {
210
+ this.groupIndex.set(key, i);
211
+ this.focusShownMember(group, i);
212
+ });
213
+ const members = slot.map(
214
+ (b, i) =>
215
+ this.buildCard(b, cardW, em, stack, areaH, {
216
+ index: i,
217
+ count: slot.length,
218
+ prevLabel: `bb-nav-prev:${key}`,
219
+ nextLabel: `bb-nav-next:${key}`,
220
+ onStep: (dir) => {
221
+ if (this.dragged) return; // a scroll gesture, not a tap
222
+ group.step(dir);
223
+ },
224
+ }) as BonusCard,
225
+ );
226
+ group.setCards(members, this.groupIndex.get(key) ?? 0);
227
+ cards.push(group);
228
+ for (let i = 0; i < slot.length; i++) {
229
+ this.cardEntries.push({ view: members[i], bonus: slot[i], affordable: this.isAffordable(slot[i]), group });
230
+ }
231
+ }
186
232
  const cardH = Math.max(...cards.map((c) => c.height));
187
233
  for (const c of cards) c.setHeight(cardH);
188
-
189
- // Rebuild card entries for keyboard navigation
190
- this.cardEntries = this.bonuses.map((b, i) => ({
191
- view: cards[i],
192
- bonus: b,
193
- affordable: this.isAffordable(b),
194
- }));
195
234
  // Restore or init keyboard focus on the first affordable card
196
235
  const affordable = this.cardEntries.filter((e) => e.affordable);
197
236
  if (affordable.length > 0) {
@@ -230,7 +269,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
230
269
  }
231
270
 
232
271
  // ── one card ──────────────────────────────────────────────────────────────
233
- private buildCard(bonus: BonusOption, cardW: number, em: number, stack: boolean, areaH: number): CardView {
272
+ private buildCard(bonus: BonusOption, cardW: number, em: number, stack: boolean, areaH: number, nav?: CardNav): CardView {
234
273
  const accent = effectiveAccent(bonus);
235
274
  const ink = contrastText(accent);
236
275
  const price = bonus.priceMultiplier * this.host.state.bet;
@@ -264,6 +303,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
264
303
  enabled,
265
304
  ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
266
305
  onSelect: select,
306
+ nav,
267
307
  });
268
308
  void stack;
269
309
  void areaH;
@@ -392,12 +432,12 @@ class BuyBonusOverlay extends Container implements ShellLayer {
392
432
  const r = 1.3 * em;
393
433
  card.addChild(
394
434
  footerButton(this.host, this.host.t('Cancel'), 'ghost', half, ctaH, 0, y, () => this.removeConfirm(), undefined, r, 0),
395
- footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
435
+ labelled('bb-confirm-ok', footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
396
436
  if (!this.isAffordable(bonus)) return;
397
437
  if (bonus.type === 'feature') this.host.actions.activateFeature(bonus);
398
438
  else this.host.actions.selectBuyBonus(bonus.id);
399
439
  this.host.closeLayer();
400
- }, ink, 0, r),
440
+ }, ink, 0, r)),
401
441
  );
402
442
  card.position.set((this.w - cardW) / 2, (this.h - cardH) / 2);
403
443
  layer.addChild(card);
@@ -430,14 +470,26 @@ class BuyBonusOverlay extends Container implements ShellLayer {
430
470
  this.buildFooter();
431
471
  }
432
472
 
473
+ /** Move keyboard focus onto the member a carousel slot just flipped to. Skipped when that member
474
+ * can't be bought at the current bet — focus only ever sits on affordable cards. */
475
+ private focusShownMember(group: GroupCard, index: number): void {
476
+ const card = group.cardAt(index);
477
+ const at = this.cardEntries.filter((ce) => ce.affordable).findIndex((ce) => ce.group === group && ce.view === card);
478
+ if (at < 0) return;
479
+ this.focusIndex = at;
480
+ this.applyFocusRing();
481
+ }
482
+
433
483
  /** Apply or clear the focus ring on affordable cards. */
434
484
  private applyFocusRing(): void {
435
485
  const affordable = this.cardEntries.filter((ce) => ce.affordable);
436
486
  for (let i = 0; i < affordable.length; i++) {
437
- const view = affordable[i].view;
438
- if (view instanceof BonusCard) {
439
- view.setFocused(i === this.focusIndex);
440
- }
487
+ const entry = affordable[i];
488
+ const focused = i === this.focusIndex;
489
+ // Focusing a member of a carousel slot brings it to the front — the keyboard walks options,
490
+ // and the card follows.
491
+ if (focused && entry.group) entry.group.showCard(entry.view);
492
+ if (entry.view instanceof BonusCard) entry.view.setFocused(focused);
441
493
  }
442
494
  }
443
495
 
@@ -550,6 +602,68 @@ class CustomCard implements CardView {
550
602
  }
551
603
  }
552
604
 
605
+ /** One slot shared by several options (`groupedBy`) — e.g. four same-priced Ante characters. Every
606
+ * member card is built (the slot sizes to the tallest, and flipping is then just a visibility
607
+ * swap, so the card never resizes under the player); one is visible at a time. */
608
+ class GroupCard implements CardView {
609
+ readonly node = new Container();
610
+ height = 0;
611
+ private cards: BonusCard[] = [];
612
+ private index = 0;
613
+ /** Reports the shown member back to the overlay, which keeps it across rebuilds. */
614
+ private onIndex: (index: number) => void;
615
+
616
+ constructor(onIndex: (index: number) => void) {
617
+ this.onIndex = onIndex;
618
+ }
619
+
620
+ setCards(cards: BonusCard[], index: number): void {
621
+ this.cards = cards;
622
+ for (const c of cards) this.node.addChild(c.node);
623
+ this.height = Math.max(...cards.map((c) => c.height));
624
+ this.show(index, false); // restoring a remembered position is not a player action
625
+ }
626
+
627
+ /** Flip to member `i`, wrapping around in both directions. */
628
+ show(i: number, notify = true): void {
629
+ const n = this.cards.length;
630
+ if (n === 0) return;
631
+ this.index = ((i % n) + n) % n;
632
+ for (let k = 0; k < n; k++) this.cards[k].node.visible = k === this.index;
633
+ if (notify) this.onIndex(this.index);
634
+ }
635
+
636
+ step(dir: 1 | -1): void {
637
+ this.show(this.index + dir);
638
+ }
639
+
640
+ cardAt(index: number): CardView | undefined {
641
+ return this.cards[index];
642
+ }
643
+
644
+ /** Bring a specific member's card to the front (keyboard focus landing on that option). */
645
+ showCard(card: CardView): void {
646
+ const i = this.cards.findIndex((c) => c === card);
647
+ if (i >= 0 && i !== this.index) this.show(i);
648
+ }
649
+
650
+ setHeight(total: number): void {
651
+ this.height = total;
652
+ for (const c of this.cards) c.setHeight(total);
653
+ }
654
+ }
655
+
656
+ /** Carousel chrome for a card that shares its slot with other options (`groupedBy`): arrows either
657
+ * side of the title and a dot per member under the description. */
658
+ interface CardNav {
659
+ /** This member's position in the slot (drives which dot is lit). */
660
+ index: number;
661
+ count: number;
662
+ prevLabel: string;
663
+ nextLabel: string;
664
+ onStep: (dir: 1 | -1) => void;
665
+ }
666
+
553
667
  interface BonusCardOpts {
554
668
  host: PixiComponentContext;
555
669
  bonus: BonusOption;
@@ -561,6 +675,7 @@ interface BonusCardOpts {
561
675
  enabled: boolean;
562
676
  ctaLabel: string;
563
677
  onSelect: () => void;
678
+ nav?: CardNav;
564
679
  }
565
680
 
566
681
  class BonusCard implements CardView {
@@ -587,9 +702,22 @@ class BonusCard implements CardView {
587
702
 
588
703
  const top = new Container();
589
704
  let y = 1.25 * em;
590
- const title = makeText(bonus.title, { size: 1.3 * em, weight: '800', color: titleColor, letterSpacing: 1.3 * em * 0.04, upper: true, align: 'center', wrapWidth: innerW });
705
+ // With arrows the title has to clear them on both sides, or a long name wraps under the glyphs.
706
+ const arrowSz = 1.5 * em;
707
+ const arrowPad = 0.45 * em;
708
+ const titleW = opts.nav ? innerW - 2 * (arrowSz + 2 * arrowPad) : innerW;
709
+ const title = makeText(bonus.title, { size: 1.3 * em, weight: '800', color: titleColor, letterSpacing: 1.3 * em * 0.04, upper: true, align: 'center', wrapWidth: titleW });
591
710
  title.position.set((cardW - title.width) / 2, y);
592
711
  top.addChild(title);
712
+ if (opts.nav) {
713
+ const boxSz = arrowSz + 2 * arrowPad;
714
+ const rowY = y + (title.height - boxSz) / 2;
715
+ const prev = arrowButton(opts.nav.prevLabel, -1, arrowSz, arrowPad, accent, opts.nav.onStep);
716
+ const next = arrowButton(opts.nav.nextLabel, 1, arrowSz, arrowPad, accent, opts.nav.onStep);
717
+ prev.position.set(0.6 * em, rowY);
718
+ next.position.set(cardW - 0.6 * em - boxSz, rowY);
719
+ top.addChild(prev, next);
720
+ }
593
721
  y += title.height + 0.75 * em;
594
722
  const thumb = thumbNode(host, bonus, accent, 6.2 * em);
595
723
  thumb.position.set((cardW - 6.2 * em) / 2, y);
@@ -599,6 +727,13 @@ class BonusCard implements CardView {
599
727
  desc.position.set((cardW - desc.width) / 2, y);
600
728
  top.addChild(desc);
601
729
  y += desc.height;
730
+ if (opts.nav) {
731
+ y += 0.7 * em;
732
+ const dots = dotRow(opts.nav.count, opts.nav.index, accent, 0.24 * em, 0.5 * em);
733
+ dots.position.set((cardW - dots.width) / 2, y);
734
+ top.addChild(dots);
735
+ y += 0.48 * em;
736
+ }
602
737
  this.topH = y;
603
738
 
604
739
  // bottom block: volatility + price
@@ -615,8 +750,9 @@ class BonusCard implements CardView {
615
750
  by += priceText.height;
616
751
  this.bottomH = by;
617
752
 
618
- this.cta = ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em);
753
+ this.cta = labelled(`bb-cta:${bonus.id}`, ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em));
619
754
 
755
+ this.node.label = `bb-card:${bonus.id}`;
620
756
  this.node.addChild(this.bg, top, this.bottomBlock, this.cta);
621
757
  if (opts.enabled) {
622
758
  this.node.eventMode = 'static';
@@ -672,6 +808,46 @@ class BonusCard implements CardView {
672
808
  }
673
809
 
674
810
  // ── shared card bits ─────────────────────────────────────────────────────────
811
+
812
+ /** Tag a node so the shell (and its tests) can find it in the display tree. */
813
+ function labelled<T extends Container>(label: string, node: T): T {
814
+ node.label = label;
815
+ return node;
816
+ }
817
+
818
+ /** One carousel arrow. `stopPropagation` keeps the tap off the card underneath, which would
819
+ * otherwise open the confirm dialog for the member being flipped away from. */
820
+ function arrowButton(label: string, dir: 1 | -1, size: number, pad: number, accent: string, onStep: (dir: 1 | -1) => void): Container {
821
+ const b = new Container();
822
+ b.label = label;
823
+ const glyph = makeIcon('chevronRight', size, '#ffffff');
824
+ if (dir === -1) glyph.spin = Math.PI; // no chevronLeft in the set — the glyph spins about its centre
825
+ glyph.position.set(pad, pad);
826
+ b.addChild(rectHit(size + pad * 2, size + pad * 2), glyph);
827
+ b.eventMode = 'static';
828
+ b.cursor = 'pointer';
829
+ b.on('pointerover', () => glyph.setColor(accent));
830
+ b.on('pointerout', () => glyph.setColor('#ffffff'));
831
+ b.on('pointertap', (e: FederatedPointerEvent) => {
832
+ e.stopPropagation();
833
+ onStep(dir);
834
+ });
835
+ return b;
836
+ }
837
+
838
+ /** Position indicator for a carousel slot: one dot per member, the current one in the accent. */
839
+ function dotRow(count: number, index: number, accent: string, r: number, gap: number): Container {
840
+ const c = new Container();
841
+ for (let i = 0; i < count; i++) {
842
+ const dot = new Graphics();
843
+ const on = i === index;
844
+ dot.circle(r, r, r).fill(on ? accent : 'rgba(255,255,255,.28)');
845
+ dot.position.set(i * (2 * r + gap), 0);
846
+ c.addChild(dot);
847
+ }
848
+ return c;
849
+ }
850
+
675
851
  function thumbNode(host: PixiComponentContext, bonus: BonusOption, accent: string, h: number): Container {
676
852
  const c = new Container();
677
853
  c.addChild(rectHit(h, h, 0)); // size anchor (transparent)
@@ -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;