@energy8platform/platform-core 0.22.0 → 0.23.1

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.
@@ -43,10 +43,13 @@ export function renderBottomBar(shell: GameShell): HTMLElement {
43
43
  // menu icon button (always)
44
44
  const menu = iconBtn('menu', 'menu', () => shell.openMenu());
45
45
 
46
- // All three modes share the base plaque layout. FS/replay hide the controls that
47
- // don't apply; FS puts the spins counter in the centre pill (where WIN normally is).
46
+ // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
47
+ // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
48
48
  const isBase = state.mode === 'base';
49
49
  const isFS = state.mode === 'freeSpins';
50
+ // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
51
+ // only when it's a free-spins replay (freeSpins.total > 0).
52
+ const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
50
53
 
51
54
  const balance = readout('balance', shell.t('Balance'), fmt(state.balance));
52
55
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
@@ -72,22 +75,25 @@ export function renderBottomBar(shell: GameShell): HTMLElement {
72
75
  }
73
76
 
74
77
  const winEl = state.win > 0 ? readout('win', shell.t('Win'), fmt(state.win)) : null;
75
- // FS readouts the spins counter plus the accumulated/last win for the round.
76
- const fsCounter = isFS ? readout('fs-counter', shell.t('Free spins'), `${state.freeSpins.current} / ${state.freeSpins.total}`) : null;
77
- const fsTotalWin = isFS ? readout('fs-totalwin', shell.t('Total win'), fmt(state.freeSpins.totalWin)) : null;
78
- const fsLastWin = isFS ? readout('fs-lastwin', shell.t('Last win'), fmt(state.freeSpins.lastWin)) : null;
78
+ // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
79
+ const fsCounter = showFsBlocks ? readout('fs-counter', shell.t('Free spins'), `${state.freeSpins.current} / ${state.freeSpins.total}`) : null;
80
+ const fsTotalWin = showFsBlocks ? readout('fs-totalwin', shell.t('Total win'), fmt(state.freeSpins.totalWin)) : null;
79
81
 
80
82
  if (mobile) {
81
- // rows: [balance · win/(FS last+total)] · [menu · auto · (spin | FS counter) · turbo · buy] · [− bet +]
82
- bar.appendChild(plaque('ge-m-top ge-pl ge-pl-glass', compact([balance, winEl, fsLastWin, fsTotalWin])));
83
- const center = isBase ? spin : fsCounter;
84
- bar.appendChild(plaque('ge-m-controls ge-pl-dark', compact([menu, auto, center, turbo, buy])));
83
+ // rows: [balance · win] · [menu · auto · spin · FS counter · Total Win · turbo · buy] · [− bet +]
84
+ // FS counter + Total Win live in the controls row (alongside menu/turbo), not the top readouts.
85
+ bar.appendChild(plaque('ge-m-top ge-pl ge-pl-glass', compact([balance, winEl])));
86
+ const center = isBase ? spin : null;
87
+ bar.appendChild(plaque('ge-m-controls ge-pl-dark', compact([menu, auto, center, fsCounter, fsTotalWin, turbo, buy])));
85
88
  bar.appendChild(plaque('ge-m-bet ge-pl ge-pl-dark', compact([betDown, betValue, betUp])));
86
89
  } else {
87
- // LEFT: [menu] ⊐ BUY BONUS coin ⊏ [balance]
90
+ // LEFT: [menu] ⊐ BUY BONUS coin ⊏ [balance] · [Free Spins] · [Total Win]
91
+ // (the last two only render in FS / a free-spins replay)
88
92
  const menuPlaque = plaque('ge-pl ge-pl-dark ge-pl-menu', [menu]);
89
93
  const balPlaque = plaque('ge-pl ge-pl-glass ge-pl-bal', [balance]);
90
- const left = zone('ge-zone-left ge-zone-plaques', ...compact([menuPlaque, buy, balPlaque]));
94
+ const fsPlaque = fsCounter ? plaque('ge-pl ge-pl-glass ge-pl-fs', [fsCounter]) : null;
95
+ const totalWinPlaque = fsTotalWin ? plaque('ge-pl ge-pl-glass ge-pl-totalwin', [fsTotalWin]) : null;
96
+ const left = zone('ge-zone-left ge-zone-plaques', ...compact([menuPlaque, buy, balPlaque, fsPlaque, totalWinPlaque]));
91
97
 
92
98
  // RIGHT: [bet (+ step)] · |divider| · [auto · SPIN · turbo]
93
99
  const betKids: HTMLElement[] = [betValue];
@@ -101,10 +107,9 @@ export function renderBottomBar(shell: GameShell): HTMLElement {
101
107
  spinWrap.append(...compact([auto, spin, turbo]));
102
108
  const right = zone('ge-zone-right ge-zone-plaques', betPlaque, divider, spinWrap);
103
109
 
104
- // MIDDLE: FS last win · counter · total win plaque; base/replay → WIN pill (lifts on overflow)
110
+ // MIDDLE: per-spin WIN pill in every mode lifts above the bar on overflow.
105
111
  let middle: HTMLElement | null = null;
106
- if (isFS) middle = plaque('ge-pl ge-pl-glass ge-fscount', compact([fsLastWin, fsCounter, fsTotalWin]));
107
- else if (winEl) { winEl.classList.add('ge-winpill'); middle = winEl; }
112
+ if (winEl) { winEl.classList.add('ge-winpill'); middle = winEl; }
108
113
  bar.append(...compact([left, middle, right]));
109
114
  }
110
115
 
@@ -28,7 +28,7 @@ export function openGameInfoModal(shell: GameShell): HTMLElement {
28
28
 
29
29
  function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
30
30
  switch (s.type) {
31
- case 'modes': return sectionModes(s.modes, sec('info-modes', s.title, shell.t('Modes')));
31
+ case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
32
32
  case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
33
33
  case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
34
34
  case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
@@ -46,20 +46,20 @@ function sec(ge: string, title: string | undefined, fallback: string): HTMLEleme
46
46
  }
47
47
 
48
48
  // ── modes (rows — varying description lengths read better than fixed cards) ────
49
- function sectionModes(modes: GameMode[], el: HTMLElement): HTMLElement {
49
+ function sectionModes(shell: GameShell, modes: GameMode[], el: HTMLElement): HTMLElement {
50
50
  const list = document.createElement('div'); list.className = 'ge-gi-modes';
51
- for (const m of modes) list.appendChild(modeRow(m));
51
+ for (const m of modes) list.appendChild(modeRow(shell, m));
52
52
  el.appendChild(list);
53
53
  return el;
54
54
  }
55
- function modeRow(m: GameMode): HTMLElement {
55
+ function modeRow(shell: GameShell, m: GameMode): HTMLElement {
56
56
  const row = document.createElement('div'); row.className = 'ge-gi-mode';
57
57
  const stat = (label: string, val: string) =>
58
58
  `<span class="ge-gi-mode-st"><span>${label}</span><b>${val}</b></span>`;
59
59
  let stats = '';
60
- if (m.price != null) stats += stat('Price', m.price);
61
- if (typeof m.rtp === 'number') stats += stat('RTP', `${m.rtp}%`);
62
- if (m.maxWin != null) stats += stat('Max win', m.maxWin);
60
+ if (m.price != null) stats += stat(shell.t('Price'), m.price);
61
+ if (typeof m.rtp === 'number') stats += stat(shell.t('RTP'), `${m.rtp}%`);
62
+ if (m.maxWin != null) stats += stat(shell.t('Max win'), m.maxWin);
63
63
  row.innerHTML =
64
64
  `<div class="ge-gi-mode-top"><span class="ge-gi-mode-h">${m.title}</span>` +
65
65
  (stats ? `<span class="ge-gi-mode-stats">${stats}</span>` : '') + '</div>' +
package/src/shell/i18n.ts CHANGED
@@ -30,6 +30,7 @@ const RULES: ReadonlyArray<readonly [string, string]> = [
30
30
  ['paid', 'won'],
31
31
  ['bought', 'instantly triggered'],
32
32
  ['purchase', 'play'],
33
+ ['price', 'play'],
33
34
  ['deposit', 'get coins'],
34
35
  ['withdraw', 'redeem'],
35
36
  ['currency', 'token'],
@@ -7,6 +7,7 @@ export const SHELL_ROOT_ID = '__ge-game-shell__';
7
7
  export const SHELL_CSS = SHELL_FONT_CSS + `
8
8
  #${SHELL_ROOT_ID} {
9
9
  position: absolute; inset: 0;
10
+ container-type: size; /* query container → centred modals size in cq units (responsive on every screen) */
10
11
  pointer-events: none; z-index: 9000;
11
12
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
12
13
  color: var(--shell-fg);
@@ -218,16 +219,18 @@ export const SHELL_CSS = SHELL_FONT_CSS + `
218
219
  /* the buy-bonus scroll area is a SIZE CONTAINER, so the cards' cqh units measure the overlay
219
220
  (the popout frame) and not the browser window — cards fit without any vertical scroll. */
220
221
  #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-ov-scroll { container-type:size; }
221
- #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-ov-body { padding:clamp(8px,3cqh,16px); }
222
- #${SHELL_ROOT_ID} .ge-bb-grid { display:flex; gap:14px; overflow-x:auto; overflow-y:hidden; padding-bottom:6px;
222
+ /* buy-bonus uses the FULL overlay width (no 800px centre cap) so the card row isn't cropped at
223
+ the sides; small horizontal padding keeps the cards off the screen edges. */
224
+ #${SHELL_ROOT_ID} [data-ge="buybonus-overlay"] .ge-ov-body { max-width:none; padding:clamp(8px,3cqh,16px) clamp(12px,3vw,28px); }
225
+ #${SHELL_ROOT_ID} .ge-bb-grid { display:flex; gap:14px; justify-content:safe center; overflow-x:auto; overflow-y:hidden; padding-bottom:6px;
223
226
  scroll-snap-type:x proximity; -webkit-overflow-scrolling:touch; }
224
227
  /* the one knob that scales the whole card — cqh measures the overlay (popout frame), not the
225
228
  browser window, so cards shrink to fit the real container height. */
226
- #${SHELL_ROOT_ID} .ge-bb-grid .ge-bonus-card { flex:0 0 18.5em; scroll-snap-align:start;
227
- font-size:clamp(7px, 4cqh, 13px); }
229
+ #${SHELL_ROOT_ID} .ge-bb-grid .ge-bonus-card { flex:0 0 18em; scroll-snap-align:start;
230
+ font-size:clamp(7px, 3.6cqh, 12px); }
228
231
  /* mobile: vertical stack at a fixed, readable size — scroll the list, don't shrink the cards */
229
232
  #${SHELL_ROOT_ID}.ge-mobile .ge-bb-grid { display:flex; flex-direction:column; gap:14px; overflow:visible; }
230
- #${SHELL_ROOT_ID}.ge-mobile .ge-bb-grid .ge-bonus-card { flex:0 0 auto; font-size:13px; }
233
+ #${SHELL_ROOT_ID}.ge-mobile .ge-bb-grid .ge-bonus-card { flex:0 0 auto; font-size:12px; }
231
234
  #${SHELL_ROOT_ID} .ge-bonus-card { display:flex; flex-direction:column; border-radius:1.4em; overflow:hidden;
232
235
  background:var(--shell-plaque-glass); border:1px solid var(--shell-plaque-line); color:#fff; text-align:center;
233
236
  pointer-events:auto; cursor:pointer; transition:box-shadow .12s ease, background .12s ease; }
@@ -282,8 +285,10 @@ export const SHELL_CSS = SHELL_FONT_CSS + `
282
285
  border-radius:16px; padding:0 20px; gap:18px; }
283
286
  #${SHELL_ROOT_ID} .ge-pl-dark { background:var(--shell-plaque-dark); }
284
287
  #${SHELL_ROOT_ID} .ge-pl-glass { background:var(--shell-plaque-glass); }
285
- /* FS spins-counter plaque (wide) sits between balance and bet, glass like balance */
286
- #${SHELL_ROOT_ID} .ge-fscount { justify-content:center; min-width:150px; }
288
+ /* FS/replay left blocksFree Spins counter (compact) + Total Win, standalone glass plaques
289
+ sitting just right of the balance pill */
290
+ #${SHELL_ROOT_ID} .ge-pl-fs, #${SHELL_ROOT_ID} .ge-pl-totalwin { margin-left:8px; }
291
+ #${SHELL_ROOT_ID} .ge-pl-fs { padding:0 16px; }
287
292
  #${SHELL_ROOT_ID} .ge-pl .ge-rd { color:#fff; text-shadow:none; }
288
293
  #${SHELL_ROOT_ID} .ge-pl .ge-rd .ge-lbl { color:var(--shell-plaque-label); }
289
294
  #${SHELL_ROOT_ID} .ge-pl .ge-iconbtn { color:#fff; }
@@ -332,40 +337,44 @@ export const SHELL_CSS = SHELL_FONT_CSS + `
332
337
  align-items:center; justify-content:center; padding:clamp(10px,4vh,24px); box-sizing:border-box;
333
338
  background:rgba(12,17,28,.5); backdrop-filter:blur(var(--ge-sheet-blur,20px)) saturate(120%);
334
339
  -webkit-backdrop-filter:blur(var(--ge-sheet-blur,20px)) saturate(120%); animation:ge-ov-in .16s ease-out; }
335
- /* GameShell.fitModal() scales the whole card down (transform) so it fits short popouts uniformly */
336
- #${SHELL_ROOT_ID} .ge-modal-card { width:100%; max-width:420px; box-sizing:border-box; overflow:hidden;
337
- transform-origin:center center; background:var(--shell-plaque-solid); border-radius:20px; display:flex; flex-direction:column; }
340
+ /* Card sizes in cq units of the shell root responsive on EVERY screen, not just popouts. The
341
+ card's font-size is the one knob (clamped for readability); everything inside is em-relative so
342
+ the whole card scales as a unit. GameShell.fitModal() still transform-scales it down as a
343
+ backstop for very short popouts. */
344
+ #${SHELL_ROOT_ID} .ge-modal-card { font-size:clamp(11px, 2cqmin, 15px); width:100%; max-width:28em; box-sizing:border-box;
345
+ overflow:hidden; transform-origin:center center; background:var(--shell-plaque-solid); border-radius:1.3em;
346
+ display:flex; flex-direction:column; }
338
347
  /* ✕ pinned to the overlay corner (the screen), not the card */
339
348
  #${SHELL_ROOT_ID} .ge-modal-close { position:absolute; top:12px; right:12px; z-index:2; width:36px; height:36px;
340
349
  border:none; border-radius:50%; cursor:pointer; pointer-events:auto; background:var(--shell-plaque-dark); color:#fff;
341
350
  display:flex; align-items:center; justify-content:center; font-size:20px; transition:background .12s ease, color .12s ease; }
342
351
  #${SHELL_ROOT_ID} .ge-modal-close:hover { background:var(--shell-plaque-glass); color:var(--shell-accent); }
343
- #${SHELL_ROOT_ID} .ge-modal-body { padding:18px; display:flex; flex-direction:column; gap:16px; }
352
+ #${SHELL_ROOT_ID} .ge-modal-body { padding:1.2em; display:flex; flex-direction:column; gap:1.05em; }
344
353
  #${SHELL_ROOT_ID} .ge-modal-title { margin:0; text-align:center; color:var(--card-acc, var(--shell-accent));
345
- font-weight:800; letter-spacing:.04em; text-transform:uppercase; font-size:18px; }
346
- #${SHELL_ROOT_ID} .ge-modal-text { margin:0; text-align:center; color:rgba(255,255,255,.85); font-size:14px; line-height:1.5; }
347
- #${SHELL_ROOT_ID} .ge-sheet-grid { display:grid; gap:10px; }
354
+ font-weight:800; letter-spacing:.04em; text-transform:uppercase; font-size:1.2em; }
355
+ #${SHELL_ROOT_ID} .ge-modal-text { margin:0; text-align:center; color:rgba(255,255,255,.85); font-size:.93em; line-height:1.5; }
356
+ #${SHELL_ROOT_ID} .ge-sheet-grid { display:grid; gap:.65em; }
348
357
  #${SHELL_ROOT_ID} .ge-chip { pointer-events:auto; cursor:pointer; border:1px solid var(--shell-plaque-line);
349
- border-radius:12px; background:rgba(255,255,255,.04); color:#fff; font-size:15px; font-weight:700;
350
- font-variant-numeric:tabular-nums; padding:12px 8px; transition:background .12s ease, border-color .12s ease; }
358
+ border-radius:.8em; background:rgba(255,255,255,.04); color:#fff; font-size:1em; font-weight:700;
359
+ font-variant-numeric:tabular-nums; padding:.8em .55em; transition:background .12s ease, border-color .12s ease; }
351
360
  #${SHELL_ROOT_ID} .ge-chip:hover { background:var(--shell-plaque-glass-hover); }
352
361
  #${SHELL_ROOT_ID} .ge-chip.ge-on { border-color:var(--shell-accent); background:var(--shell-accent); color:#fff; }
353
362
  /* full-bleed footer button(s), flush to the card's bottom edge (card clips the corners) */
354
363
  #${SHELL_ROOT_ID} .ge-modal-actions { display:flex; }
355
364
  #${SHELL_ROOT_ID} .ge-modal-actions > * { flex:1; }
356
- #${SHELL_ROOT_ID} .ge-modal-btn { width:100%; border:none; padding:16px; font-size:15px; font-weight:800;
365
+ #${SHELL_ROOT_ID} .ge-modal-btn { width:100%; border:none; padding:1.05em; font-size:1em; font-weight:800;
357
366
  letter-spacing:.04em; text-transform:uppercase; cursor:pointer; pointer-events:auto; transition:filter .12s ease; }
358
367
  #${SHELL_ROOT_ID} .ge-modal-btn:hover:not([disabled]) { filter:brightness(1.08); }
359
368
  #${SHELL_ROOT_ID} .ge-modal-btn--accent { background:var(--card-acc, var(--shell-accent)); color:#fff; }
360
369
  #${SHELL_ROOT_ID} .ge-modal-btn--ghost { background:var(--shell-plaque-glass-hover); color:#fff; }
361
370
  /* replay summary — label/value rows, accented total-win row */
362
371
  #${SHELL_ROOT_ID} .ge-replay-rows { display:flex; flex-direction:column; }
363
- #${SHELL_ROOT_ID} .ge-replay-row { display:flex; justify-content:space-between; align-items:baseline; gap:16px; padding:11px 2px; }
372
+ #${SHELL_ROOT_ID} .ge-replay-row { display:flex; justify-content:space-between; align-items:baseline; gap:1.05em; padding:.73em .13em; }
364
373
  #${SHELL_ROOT_ID} .ge-replay-row + .ge-replay-row { border-top:1px solid var(--shell-plaque-line); }
365
- #${SHELL_ROOT_ID} .ge-replay-row span { color:var(--shell-plaque-label); text-transform:uppercase; letter-spacing:.07em; font-size:11px; font-weight:700; }
366
- #${SHELL_ROOT_ID} .ge-replay-row b { color:#fff; font-weight:800; font-size:15px; font-variant-numeric:tabular-nums; }
367
- #${SHELL_ROOT_ID} .ge-replay-total span { color:#fff; font-size:12px; }
368
- #${SHELL_ROOT_ID} .ge-replay-total b { color:var(--shell-accent); font-size:19px; }
374
+ #${SHELL_ROOT_ID} .ge-replay-row span { color:var(--shell-plaque-label); text-transform:uppercase; letter-spacing:.07em; font-size:.73em; font-weight:700; }
375
+ #${SHELL_ROOT_ID} .ge-replay-row b { color:#fff; font-weight:800; font-size:1em; font-variant-numeric:tabular-nums; }
376
+ #${SHELL_ROOT_ID} .ge-replay-total span { color:#fff; font-size:.8em; }
377
+ #${SHELL_ROOT_ID} .ge-replay-total b { color:var(--shell-accent); font-size:1.27em; }
369
378
 
370
379
  #${SHELL_ROOT_ID}.ge-shell-hidden { opacity:0; pointer-events:none; transition:opacity .25s ease; }
371
380
  `;
@@ -11,7 +11,7 @@ export function createInitialState(config: ShellConfig): ShellState {
11
11
  autoplay: { active: false, remaining: 0 },
12
12
  turbo: 0,
13
13
  buyBonusEnabled: true,
14
- freeSpins: { current: 0, total: 0, totalWin: 0, lastWin: 0 },
14
+ freeSpins: { current: 0, total: 0, totalWin: 0 },
15
15
  activeFeature: null,
16
16
  };
17
17
  }
@@ -117,7 +117,6 @@ export interface FreeSpinsState {
117
117
  current: number;
118
118
  total: number;
119
119
  totalWin: number;
120
- lastWin: number;
121
120
  }
122
121
 
123
122
  /** One footer button of a generic modal. Clicking it runs `on` (if any), then closes the modal. */