@energy8platform/shell 0.4.1 → 0.5.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.4.1",
3
+ "version": "0.5.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",
@@ -6,14 +6,31 @@ import { createI18n, type I18n } from './i18n';
6
6
  import { KeyboardController, type KeyboardHost } from './keyboard';
7
7
  import { PACKAGE_VERSION } from './version';
8
8
  import type {
9
- ShellConfig, ResolvedShellConfig, ShellState, ShellEvents, ShellMode,
10
- AutoplayOptions, FreeSpinsState, BonusOption, ThemeConfig, ModalOptions, ReplayModalOptions,
9
+ ShellConfig,
10
+ ResolvedShellConfig,
11
+ ShellState,
12
+ ShellEvents,
13
+ ShellMode,
14
+ AutoplayOptions,
15
+ FreeSpinsState,
16
+ BonusReadout,
17
+ BonusOption,
18
+ ThemeConfig,
19
+ ModalOptions,
20
+ ReplayModalOptions,
11
21
  } from './types';
12
22
  import type {
13
- ShellRenderer, ShellHost, ShellActions, OverlayHandle, OverlayRequest, ShellLayoutMode,
23
+ ShellRenderer,
24
+ ShellHost,
25
+ ShellActions,
26
+ OverlayHandle,
27
+ OverlayRequest,
28
+ ShellLayoutMode,
14
29
  } from './renderer';
15
30
 
16
- export interface CreateShellOptions extends ShellConfig { renderer: ShellRenderer; }
31
+ export interface CreateShellOptions extends ShellConfig {
32
+ renderer: ShellRenderer;
33
+ }
17
34
 
18
35
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
19
36
  export function resolveConfig(config: ShellConfig): ResolvedShellConfig {
@@ -79,12 +96,21 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
79
96
  }
80
97
 
81
98
  // ── ShellHost ──────────────────────────────────────────────────────────────
82
- t(text: string): string { return this.i18n.t(text); }
83
- formatCurrency(n: number, win = false): string { return formatCurrency(n, this.config.currency, win); }
84
- formatWin(value: number): string { return this.formatCurrency(value, true); }
99
+ t(text: string): string {
100
+ return this.i18n.t(text);
101
+ }
102
+ formatCurrency(n: number, win = false): string {
103
+ return formatCurrency(n, this.config.currency, win);
104
+ }
105
+ formatWin(value: number): string {
106
+ return this.formatCurrency(value, true);
107
+ }
85
108
  notifyResize(w: number, h: number): void {
86
109
  const layout: ShellLayoutMode = w !== 0 && h > w ? 'mobile' : 'wide';
87
- if (layout !== this.layout) { this.layout = layout; this.renderer.setLayout(layout); }
110
+ if (layout !== this.layout) {
111
+ this.layout = layout;
112
+ this.renderer.setLayout(layout);
113
+ }
88
114
  this.renderer.renderBar();
89
115
  }
90
116
 
@@ -94,7 +120,9 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
94
120
  stepBet: (dir) => {
95
121
  const next = stepBet(this.state, dir);
96
122
  if (next === this.state.bet) return;
97
- this.state.bet = next; this.emit('betChange', next); this.renderer.renderBar();
123
+ this.state.bet = next;
124
+ this.emit('betChange', next);
125
+ this.renderer.renderBar();
98
126
  },
99
127
  setBet: (n) => {
100
128
  if (n !== this.state.bet) {
@@ -105,7 +133,9 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
105
133
  },
106
134
  cycleTurbo: () => {
107
135
  const next = nextTurbo(this.state.turbo, this.config.features.turbo);
108
- this.state.turbo = next; this.emit('turboChange', next); this.renderer.renderBar();
136
+ this.state.turbo = next;
137
+ this.emit('turboChange', next);
138
+ this.renderer.renderBar();
109
139
  },
110
140
  toggleAutoplay: () => {
111
141
  if (this.state.autoplay.active) a.stopAutoplay();
@@ -113,11 +143,13 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
113
143
  },
114
144
  startAutoplay: (remaining) => {
115
145
  this.state.autoplay = { active: true, remaining };
116
- this.emit('autoplayStart', { active: true, remaining }); this.renderer.renderBar();
146
+ this.emit('autoplayStart', { active: true, remaining });
147
+ this.renderer.renderBar();
117
148
  },
118
149
  stopAutoplay: () => {
119
150
  this.state.autoplay = { active: false, remaining: 0 };
120
- this.emit('autoplayStop'); this.renderer.renderBar();
151
+ this.emit('autoplayStop');
152
+ this.renderer.renderBar();
121
153
  },
122
154
  openMenu: () => this.openMenu(),
123
155
  openSettings: () => this.openSettings(),
@@ -138,12 +170,24 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
138
170
  // eslint-disable-next-line @typescript-eslint/no-this-alias
139
171
  const self = this;
140
172
  const host: KeyboardHost = {
141
- get state() { return self.state; },
142
- get hotkeysEnabled() { return self.config.features.hotkeys !== false; },
143
- get spacebarEnabled() { return self.config.features.spacebar !== false; },
144
- get turboLevels() { return self.config.features.turbo; },
145
- get autoplayEnabled() { return self.config.features.autoplay != null; },
146
- get buyBonusEnabled() { return self.config.features.buyBonus !== false; },
173
+ get state() {
174
+ return self.state;
175
+ },
176
+ get hotkeysEnabled() {
177
+ return self.config.features.hotkeys !== false;
178
+ },
179
+ get spacebarEnabled() {
180
+ return self.config.features.spacebar !== false;
181
+ },
182
+ get turboLevels() {
183
+ return self.config.features.turbo;
184
+ },
185
+ get autoplayEnabled() {
186
+ return self.config.features.autoplay != null;
187
+ },
188
+ get buyBonusEnabled() {
189
+ return self.config.features.buyBonus !== false;
190
+ },
147
191
  hasOpenLayer: () => self.overlay !== null,
148
192
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
149
193
  spin: () => self.actions.spin(),
@@ -160,21 +204,51 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
160
204
  this.kbd.attach();
161
205
  }
162
206
 
163
- private pullFocus = (): void => { try { (globalThis as { focus?: () => void }).focus?.(); } catch { /* cross-origin */ } };
207
+ private pullFocus = (): void => {
208
+ try {
209
+ (globalThis as { focus?: () => void }).focus?.();
210
+ } catch {
211
+ /* cross-origin */
212
+ }
213
+ };
164
214
 
165
215
  // ── overlay flow ─────────────────────────────────────────────────────────────
166
216
  private show(req: OverlayRequest): void {
167
217
  this.closeModal();
168
218
  this.overlay = this.renderer.openOverlay(req) ?? null;
169
219
  }
170
- openMenu(): void { this.emit('menuOpen'); this.openSettings(); }
171
- openSettings(): void { this.emit('settingsOpen'); this.show({ kind: 'settings' }); }
172
- openInfo(): void { this.emit('infoOpen'); this.show({ kind: 'gameInfo' }); }
173
- openBuyBonus(): void { if (this.config.onBonusBuy) { this.config.onBonusBuy(); return; } this.show({ kind: 'buyBonus' }); }
174
- openBetPicker(): void { this.show({ kind: 'betPicker' }); }
175
- openAutoplayPicker(): void { this.show({ kind: 'autoplayPicker' }); }
176
- openReplay(opts: ReplayModalOptions): void { if (this.destroyed) return; this.show({ kind: 'replay', opts }); }
177
- openModal(opts: ModalOptions): void { this.show({ kind: 'modal', opts }); }
220
+ openMenu(): void {
221
+ this.emit('menuOpen');
222
+ this.openSettings();
223
+ }
224
+ openSettings(): void {
225
+ this.emit('settingsOpen');
226
+ this.show({ kind: 'settings' });
227
+ }
228
+ openInfo(): void {
229
+ this.emit('infoOpen');
230
+ this.show({ kind: 'gameInfo' });
231
+ }
232
+ openBuyBonus(): void {
233
+ if (this.config.onBonusBuy) {
234
+ this.config.onBonusBuy();
235
+ return;
236
+ }
237
+ this.show({ kind: 'buyBonus' });
238
+ }
239
+ openBetPicker(): void {
240
+ this.show({ kind: 'betPicker' });
241
+ }
242
+ openAutoplayPicker(): void {
243
+ this.show({ kind: 'autoplayPicker' });
244
+ }
245
+ openReplay(opts: ReplayModalOptions): void {
246
+ if (this.destroyed) return;
247
+ this.show({ kind: 'replay', opts });
248
+ }
249
+ openModal(opts: ModalOptions): void {
250
+ this.show({ kind: 'modal', opts });
251
+ }
178
252
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
179
253
  closeModal(): void {
180
254
  if (!this.overlay) return;
@@ -190,16 +264,22 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
190
264
  this.soundRefresh?.(on);
191
265
  this.renderer.refreshSoundIcon?.(on);
192
266
  }
193
- setSoundRefresh(fn: ((on: boolean) => void) | null): void { this.soundRefresh = fn; }
267
+ setSoundRefresh(fn: ((on: boolean) => void) | null): void {
268
+ this.soundRefresh = fn;
269
+ }
194
270
 
195
271
  // ── features ─────────────────────────────────────────────────────────────────
196
272
  activateFeature(bonus: BonusOption): void {
197
- this.state.activeFeature = bonus; this.emit('featureActivate', { id: bonus.id }); this.renderer.renderBar();
273
+ this.state.activeFeature = bonus;
274
+ this.emit('featureActivate', { id: bonus.id });
275
+ this.renderer.renderBar();
198
276
  }
199
277
  deactivateFeature(): void {
200
278
  const prev = this.state.activeFeature;
201
279
  if (!prev) return;
202
- this.state.activeFeature = null; this.emit('featureDeactivate', { id: prev.id }); this.renderer.renderBar();
280
+ this.state.activeFeature = null;
281
+ this.emit('featureDeactivate', { id: prev.id });
282
+ this.renderer.renderBar();
203
283
  }
204
284
 
205
285
  // ── game-facing public API (mirrors GameShell/PixiGameShell) ───────────────────
@@ -207,19 +287,79 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
207
287
  this.renderer.renderBar();
208
288
  if (to !== from) this.renderer.animateMoney(field, from, to);
209
289
  }
210
- setBalance(n: number): void { const from = this.prevBalance; this.state.balance = n; this.prevBalance = n; this.money('balance', from, n); }
211
- setWin(n: number): void { const from = this.prevWin; this.state.win = n; this.prevWin = n; this.money('win', from, n); }
212
- setBet(n: number): void { this.state.bet = n; this.renderer.renderBar(); }
213
- setMode(mode: ShellMode): void { if (mode === 'replay') this.state.replay = true; this.state.mode = mode; this.renderer.renderBar(); }
214
- setBusy(busy: boolean): void { this.state.busy = busy; this.renderer.renderBar(); this.kbd?.notifyBusyChanged(busy); }
215
- setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.renderer.renderBar(); }
216
- setTurbo(level: number): void { this.state.turbo = level; this.renderer.renderBar(); }
217
- setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.renderer.renderBar(); }
218
- setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.renderer.renderBar(); }
219
- setTheme(theme: ThemeConfig): void { this.config.theme = theme; this.tokens = resolveTheme(theme); this.renderer.applyTheme(this.tokens); this.renderer.renderBar(); }
220
- setLanguage(lang: string): void { this.config.language = lang; this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial }); this.renderer.renderBar(); }
221
- setSocial(isSocial: boolean): void { this.config.isSocial = isSocial; this.i18n = createI18n({ language: this.config.language, isSocial }); this.renderer.renderBar(); }
222
- setLayout(layout: ShellLayoutMode): void { if (layout === this.layout) return; this.layout = layout; this.renderer.setLayout(layout); this.renderer.renderBar(); }
290
+ setBalance(n: number): void {
291
+ const from = this.prevBalance;
292
+ this.state.balance = n;
293
+ this.prevBalance = n;
294
+ this.money('balance', from, n);
295
+ }
296
+ setWin(n: number): void {
297
+ const from = this.prevWin;
298
+ this.state.win = n;
299
+ this.prevWin = n;
300
+ this.money('win', from, n);
301
+ }
302
+ setBet(n: number): void {
303
+ this.state.bet = n;
304
+ this.renderer.renderBar();
305
+ }
306
+ setMode(mode: ShellMode): void {
307
+ if (mode === 'replay') this.state.replay = true;
308
+ this.state.mode = mode;
309
+ this.renderer.renderBar();
310
+ }
311
+ setBusy(busy: boolean): void {
312
+ this.state.busy = busy;
313
+ this.renderer.renderBar();
314
+ this.kbd?.notifyBusyChanged(busy);
315
+ }
316
+ setAutoplay(a: AutoplayOptions): void {
317
+ this.state.autoplay = a;
318
+ this.renderer.renderBar();
319
+ }
320
+ setTurbo(level: number): void {
321
+ this.state.turbo = level;
322
+ this.renderer.renderBar();
323
+ }
324
+ setBuyBonusEnabled(enabled: boolean): void {
325
+ this.state.buyBonusEnabled = enabled;
326
+ this.renderer.renderBar();
327
+ }
328
+ setFreeSpins(fs: FreeSpinsState): void {
329
+ this.state.freeSpins = fs;
330
+ this.state.bonus = null;
331
+ this.renderer.renderBar();
332
+ }
333
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
334
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
335
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
336
+ setBonus(b: BonusReadout): void {
337
+ this.state.bonus = { label: b.label, value: b.value };
338
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
339
+ this.renderer.renderBar();
340
+ }
341
+ setTheme(theme: ThemeConfig): void {
342
+ this.config.theme = theme;
343
+ this.tokens = resolveTheme(theme);
344
+ this.renderer.applyTheme(this.tokens);
345
+ this.renderer.renderBar();
346
+ }
347
+ setLanguage(lang: string): void {
348
+ this.config.language = lang;
349
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
350
+ this.renderer.renderBar();
351
+ }
352
+ setSocial(isSocial: boolean): void {
353
+ this.config.isSocial = isSocial;
354
+ this.i18n = createI18n({ language: this.config.language, isSocial });
355
+ this.renderer.renderBar();
356
+ }
357
+ setLayout(layout: ShellLayoutMode): void {
358
+ if (layout === this.layout) return;
359
+ this.layout = layout;
360
+ this.renderer.setLayout(layout);
361
+ this.renderer.renderBar();
362
+ }
223
363
 
224
364
  destroy(): Promise<void> {
225
365
  if (this.destroyed) return Promise.resolve();
package/src/core/state.ts CHANGED
@@ -13,6 +13,7 @@ export function createInitialState(config: ShellConfig): ShellState {
13
13
  turbo: 0,
14
14
  buyBonusEnabled: true,
15
15
  freeSpins: { current: 0, total: 0, totalWin: 0 },
16
+ bonus: null,
16
17
  activeFeature: null,
17
18
  };
18
19
  }
package/src/core/types.ts CHANGED
@@ -1,4 +1,8 @@
1
- export type ShellMode = 'base' | 'freeSpins' | 'replay';
1
+ /** `freeSpins` and `bonus` are the SAME bar layout (host-driven hero + Total Win); `freeSpins` is
2
+ * kept as a back-compat alias for the common case (its readout is derived current/total), while
3
+ * `bonus` pairs with `setBonus()` to show a game-supplied label + value (adventure, hold-and-spin,
4
+ * respins — anything that isn't a plain free-spins counter). */
5
+ export type ShellMode = 'base' | 'bonus' | 'freeSpins' | 'replay';
2
6
 
3
7
  export interface CurrencyConfig {
4
8
  symbol: string;
@@ -162,6 +166,20 @@ export interface FreeSpinsState {
162
166
  totalWin: number;
163
167
  }
164
168
 
169
+ /** A game-supplied bonus readout for the bar hero, set via `setBonus()`. Generalizes the
170
+ * free-spins counter: the shell renders `label` (localized via the shell translator, so a game
171
+ * i18n entry for it is honoured) + `value` (a pre-formatted string, shown verbatim — "2 / 10",
172
+ * "3", "×5", "7 coins") + the Total Win accumulator. The shell stays free of any per-game bonus
173
+ * concept — the label/value semantics live in the game/host. */
174
+ export interface BonusReadout {
175
+ /** Bar label, e.g. 'Free spins' | 'Adventure' | 'Hold & Spin'. Run through the shell translator. */
176
+ label: string;
177
+ /** Pre-formatted counter value shown verbatim (NOT translated). */
178
+ value: string;
179
+ /** Cumulative bonus win for the Total Win readout. */
180
+ totalWin: number;
181
+ }
182
+
165
183
  /** One footer button of a generic modal. Clicking it runs `on` (if any), then closes the modal. */
166
184
  export interface ModalAction {
167
185
  title: string;
@@ -229,9 +247,24 @@ export interface ShellConfig {
229
247
  }
230
248
 
231
249
  /** ShellConfig after the controller applies defaults (version, isSocial, replay, theme). No mount. */
232
- export type ResolvedShellConfig = Required<Pick<ShellConfig,
233
- 'language' | 'currency' | 'availableBets' | 'defaultBet' | 'balance' | 'win' | 'mode' | 'features' | 'gameInfo' | 'version' | 'isSocial' | 'replay'>>
234
- & Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy'>;
250
+ export type ResolvedShellConfig = Required<
251
+ Pick<
252
+ ShellConfig,
253
+ | 'language'
254
+ | 'currency'
255
+ | 'availableBets'
256
+ | 'defaultBet'
257
+ | 'balance'
258
+ | 'win'
259
+ | 'mode'
260
+ | 'features'
261
+ | 'gameInfo'
262
+ | 'version'
263
+ | 'isSocial'
264
+ | 'replay'
265
+ >
266
+ > &
267
+ Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy'>;
235
268
 
236
269
  export interface ShellState {
237
270
  mode: ShellMode;
@@ -248,6 +281,9 @@ export interface ShellState {
248
281
  turbo: number;
249
282
  buyBonusEnabled: boolean;
250
283
  freeSpins: FreeSpinsState;
284
+ /** Game-supplied bonus label + value override (from `setBonus()`). When null the bar derives the
285
+ * readout from `freeSpins` (label 'Free spins', value current/total) — the back-compat default. */
286
+ bonus: { label: string; value: string } | null;
251
287
  /** The currently activated `feature` option (e.g. Ante), or null. Drives the
252
288
  * effective-bet readout tint and the BUY BONUS → DISABLE toggle on the bar. */
253
289
  activeFeature: BonusOption | null;
@@ -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.4.1';
3
+ export const PACKAGE_VERSION = '0.5.0';
@@ -9,8 +9,12 @@ function readout(ge: string, label: string, value: string): HTMLElement {
9
9
  el.className = `ge-rd ge-${ge}`;
10
10
  // The value lives in its own inline-block span (.ge-rd-val) so it can be measured & shrunk to fit
11
11
  // (see fitReadouts) independently of the label, and so the count-up animates just the number.
12
- const lbl = document.createElement('span'); lbl.className = 'ge-lbl'; lbl.textContent = label;
13
- const val = document.createElement('span'); val.className = 'ge-rd-val'; val.textContent = value;
12
+ const lbl = document.createElement('span');
13
+ lbl.className = 'ge-lbl';
14
+ lbl.textContent = label;
15
+ const val = document.createElement('span');
16
+ val.className = 'ge-rd-val';
17
+ val.textContent = value;
14
18
  el.append(lbl, val);
15
19
  return el;
16
20
  }
@@ -25,12 +29,19 @@ function turboBtn(host: ShellHost, level: number): HTMLButtonElement {
25
29
  }
26
30
 
27
31
  /** A borderless icon button. */
28
- function iconBtn(ge: string, name: IconName, onClick: () => void, active = false): HTMLButtonElement {
32
+ function iconBtn(
33
+ ge: string,
34
+ name: IconName,
35
+ onClick: () => void,
36
+ active = false,
37
+ ): HTMLButtonElement {
29
38
  const b = document.createElement('button');
30
39
  b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
31
40
  b.dataset.ge = ge;
32
41
  b.innerHTML = icon(name);
33
- b.addEventListener('click', () => { if (!b.disabled) onClick(); });
42
+ b.addEventListener('click', () => {
43
+ if (!b.disabled) onClick();
44
+ });
34
45
  return b;
35
46
  }
36
47
 
@@ -49,16 +60,14 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
49
60
  // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
50
61
  // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
51
62
  const isBase = state.mode === 'base';
52
- const isFS = state.mode === 'freeSpins';
63
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
53
64
  // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
54
65
  // only when it's a free-spins replay (freeSpins.total > 0).
55
66
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
56
67
 
57
68
  // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
58
69
  // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
59
- const balance = state.replay
60
- ? null
61
- : readout('balance', host.t('Balance'), fmt(state.balance));
70
+ const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
62
71
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
63
72
  // the feature accent; the base state.bet is unchanged and returns once the feature is off.
64
73
  const feature = state.activeFeature;
@@ -75,27 +84,35 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
75
84
  const turbo = config.features.turbo > 0 ? turboBtn(host, state.turbo) : null;
76
85
 
77
86
  // interactive controls — base mode only
78
- let betDown: HTMLElement | null = null, betUp: HTMLElement | null = null;
79
- let spin: HTMLElement | null = null, auto: HTMLElement | null = null, buy: HTMLElement | null = null;
87
+ let betDown: HTMLElement | null = null,
88
+ betUp: HTMLElement | null = null;
89
+ let spin: HTMLElement | null = null,
90
+ auto: HTMLElement | null = null,
91
+ buy: HTMLElement | null = null;
80
92
  if (isBase) {
81
93
  betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
82
94
  betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
83
- betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
84
- betValue.addEventListener('click', () => { if (!betLocked(host)) host.actions.openBetPicker(); });
95
+ betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
96
+ betValue.addEventListener('click', () => {
97
+ if (!betLocked(host)) host.actions.openBetPicker();
98
+ });
85
99
  spin = spinButton(host);
86
100
  auto = config.features.autoplay ? autoButton(host) : null;
87
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
101
+ buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
88
102
  }
89
103
 
90
104
  const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
91
105
  // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
92
106
  // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
93
107
  const fs = state.freeSpins;
94
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
108
+ const fsText =
109
+ state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
95
110
  // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
96
111
  // style as the SPIN disc); Total Win stays an inline readout.
97
- const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
98
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
112
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
113
+ const fsTotalWin = showFsBlocks
114
+ ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
115
+ : null;
99
116
  // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
100
117
  const hero = isBase ? spin : fsHero;
101
118
 
@@ -117,12 +134,16 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
117
134
  // RIGHT (the controls): [bet (+ step)] · |divider| · [auto · SPIN-or-FS · turbo]
118
135
  const betKids: HTMLElement[] = [betValue];
119
136
  if (betUp && betDown) {
120
- const step = document.createElement('div'); step.className = 'ge-betstep'; step.append(betUp, betDown);
137
+ const step = document.createElement('div');
138
+ step.className = 'ge-betstep';
139
+ step.append(betUp, betDown);
121
140
  betKids.push(step);
122
141
  }
123
142
  const betGroup = plaque('ge-betgroup', betKids);
124
- const divider = document.createElement('div'); divider.className = 'ge-pl-divider';
125
- const spinWrap = document.createElement('div'); spinWrap.className = 'ge-spinwrap';
143
+ const divider = document.createElement('div');
144
+ divider.className = 'ge-pl-divider';
145
+ const spinWrap = document.createElement('div');
146
+ spinWrap.className = 'ge-spinwrap';
126
147
  spinWrap.append(...compact([auto, hero, turbo]));
127
148
  const right = zone('ge-zone-right', betGroup, divider, spinWrap);
128
149
 
@@ -138,11 +159,16 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
138
159
 
139
160
  /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
140
161
  * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
141
- function fsHeroPlaque(host: ShellHost, text: string): HTMLElement {
162
+ function fsHeroPlaque(host: ShellHost, text: string, label?: string): HTMLElement {
142
163
  const el = document.createElement('div');
143
- el.className = 'ge-fs-hero'; el.dataset.ge = 'fs-counter';
144
- const lbl = document.createElement('span'); lbl.className = 'ge-fs-lbl'; lbl.textContent = host.t('Free spins');
145
- const num = document.createElement('span'); num.className = 'ge-fs-num'; num.textContent = text;
164
+ el.className = 'ge-fs-hero';
165
+ el.dataset.ge = 'fs-counter';
166
+ const lbl = document.createElement('span');
167
+ lbl.className = 'ge-fs-lbl';
168
+ lbl.textContent = host.t(label ?? 'Free spins');
169
+ const num = document.createElement('span');
170
+ num.className = 'ge-fs-num';
171
+ num.textContent = text;
146
172
  el.append(lbl, num);
147
173
  return el;
148
174
  }
@@ -160,24 +186,32 @@ function plaque(cls: string, children: HTMLElement[]): HTMLElement {
160
186
  d.append(...children);
161
187
  return d;
162
188
  }
163
- function compact(items: (HTMLElement | null)[]): HTMLElement[] { return items.filter((x): x is HTMLElement => x !== null); }
189
+ function compact(items: (HTMLElement | null)[]): HTMLElement[] {
190
+ return items.filter((x): x is HTMLElement => x !== null);
191
+ }
164
192
 
165
193
  function buyBtn(host: ShellHost): HTMLButtonElement {
166
194
  const buy = document.createElement('button');
167
- buy.className = 'ge-shell-buybonus'; buy.dataset.ge = 'buybonus';
195
+ buy.className = 'ge-shell-buybonus';
196
+ buy.dataset.ge = 'buybonus';
168
197
  const feature = host.state.activeFeature;
169
198
  if (feature) {
170
199
  // A feature is active → this button turns into DISABLE (tinted with the feature accent).
171
200
  const accent = effectiveAccent(feature);
172
201
  buy.classList.add('ge-disable');
173
202
  buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
174
- buy.style.background = accent; buy.style.color = contrastText(accent);
175
- buy.addEventListener('click', () => { if (!buy.disabled) host.actions.deactivateFeature(); });
203
+ buy.style.background = accent;
204
+ buy.style.color = contrastText(accent);
205
+ buy.addEventListener('click', () => {
206
+ if (!buy.disabled) host.actions.deactivateFeature();
207
+ });
176
208
  } else {
177
209
  // Ticket icon (no text); keep the label for screen readers.
178
210
  buy.setAttribute('aria-label', host.t('BUY BONUS'));
179
211
  buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
180
- buy.addEventListener('click', () => { if (!buy.disabled) host.actions.openBuyBonus(); });
212
+ buy.addEventListener('click', () => {
213
+ if (!buy.disabled) host.actions.openBuyBonus();
214
+ });
181
215
  }
182
216
  return buy;
183
217
  }
@@ -190,17 +224,22 @@ function betLocked(host: ShellHost): boolean {
190
224
  function spinButton(host: ShellHost): HTMLButtonElement {
191
225
  const { state } = host;
192
226
  const sp = document.createElement('button');
193
- sp.className = 'ge-shell-spin'; sp.dataset.ge = 'spin';
227
+ sp.className = 'ge-shell-spin';
228
+ sp.dataset.ge = 'spin';
194
229
  if (state.autoplay.active) {
195
230
  sp.classList.add('ge-stop');
196
231
  const rem = state.autoplay.remaining;
197
232
  const label = Number.isFinite(rem) ? String(rem) : '∞';
198
233
  sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
199
- sp.addEventListener('click', () => { if (!sp.disabled) host.actions.stopAutoplay(); });
234
+ sp.addEventListener('click', () => {
235
+ if (!sp.disabled) host.actions.stopAutoplay();
236
+ });
200
237
  } else {
201
238
  sp.innerHTML = icon('spin');
202
239
  if (state.busy) sp.classList.add('ge-spinning');
203
- sp.addEventListener('click', () => { if (!sp.disabled) host.actions.spin(); });
240
+ sp.addEventListener('click', () => {
241
+ if (!sp.disabled) host.actions.spin();
242
+ });
204
243
  }
205
244
  return sp;
206
245
  }
@@ -230,7 +269,7 @@ function applyBusy(host: ShellHost, bar: HTMLElement): void {
230
269
  const i = host.state.availableBets.indexOf(host.state.bet);
231
270
  disable('bet-up', lockBet || i >= host.state.availableBets.length - 1);
232
271
  disable('bet-down', lockBet || i <= 0);
233
- disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
272
+ disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
234
273
  disable('autoplay', busy && !auto); // keep autoplay (stop) clickable through autoplay
235
274
  const betVal = bar.querySelector('[data-ge="bet-value"]') as HTMLElement | null;
236
275
  if (betVal) betVal.classList.toggle('ge-disabled', lockBet);