@energy8platform/platform-core 0.19.0 → 0.21.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.
Files changed (43) hide show
  1. package/README.md +238 -1
  2. package/dist/dev-bridge.cjs.js +104 -0
  3. package/dist/dev-bridge.cjs.js.map +1 -1
  4. package/dist/dev-bridge.d.ts +64 -1
  5. package/dist/dev-bridge.esm.js +104 -0
  6. package/dist/dev-bridge.esm.js.map +1 -1
  7. package/dist/index.cjs.js +2053 -0
  8. package/dist/index.cjs.js.map +1 -1
  9. package/dist/index.d.ts +372 -2
  10. package/dist/index.esm.js +2051 -1
  11. package/dist/index.esm.js.map +1 -1
  12. package/dist/shell.cjs.js +1993 -0
  13. package/dist/shell.cjs.js.map +1 -0
  14. package/dist/shell.d.ts +320 -0
  15. package/dist/shell.esm.js +1989 -0
  16. package/dist/shell.esm.js.map +1 -0
  17. package/package.json +7 -2
  18. package/scripts/build-shell-font.mjs +64 -0
  19. package/src/PlatformSession.ts +10 -0
  20. package/src/dev-bridge/DevBridge.ts +160 -2
  21. package/src/dev-bridge/index.ts +1 -1
  22. package/src/index.ts +17 -1
  23. package/src/shell/GameShell.ts +294 -0
  24. package/src/shell/INTER-LICENSE.txt +93 -0
  25. package/src/shell/colors.ts +32 -0
  26. package/src/shell/components/BottomBar.ts +217 -0
  27. package/src/shell/components/BuyBonus.ts +163 -0
  28. package/src/shell/components/GameInfo.ts +253 -0
  29. package/src/shell/components/Modal.ts +36 -0
  30. package/src/shell/components/ReplayModal.ts +56 -0
  31. package/src/shell/components/Settings.ts +60 -0
  32. package/src/shell/components/icons.ts +40 -0
  33. package/src/shell/components/pickers.ts +76 -0
  34. package/src/shell/components/primitives.ts +84 -0
  35. package/src/shell/fonts.ts +13 -0
  36. package/src/shell/format.ts +36 -0
  37. package/src/shell/i18n.ts +67 -0
  38. package/src/shell/index.ts +20 -0
  39. package/src/shell/motion.ts +43 -0
  40. package/src/shell/shell.css.ts +371 -0
  41. package/src/shell/state.ts +30 -0
  42. package/src/shell/theme.ts +56 -0
  43. package/src/shell/types.ts +191 -0
@@ -0,0 +1,163 @@
1
+ import type { GameShell } from '../GameShell';
2
+ import type { BonusOption } from '../types';
3
+ import { formatCurrency } from '../format';
4
+ import { stepBet } from '../state';
5
+ import { effectiveAccent, contrastText } from '../colors';
6
+ import { createOverlay, createCardModal } from './primitives';
7
+ import { icon, type IconName } from './icons';
8
+
9
+ /** Buy-bonus overlay — a grid of art-forward cards, one per option. */
10
+ export function openBuyBonusOverlay(shell: GameShell): HTMLElement | null {
11
+ const bonuses = shell.config.features.buyBonus;
12
+ if (bonuses === false || bonuses.length === 0) return null;
13
+
14
+ const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => root.remove() });
15
+ root.dataset.ge = 'buybonus-overlay';
16
+ // Re-render the grid whenever the bet changes so every card's price stays live.
17
+ const renderGrid = () => {
18
+ body.innerHTML = '';
19
+ const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
20
+ for (const bonus of bonuses) grid.appendChild(buildCard(shell, bonus, root));
21
+ body.appendChild(grid);
22
+ };
23
+ renderGrid();
24
+ root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
25
+ return root;
26
+ }
27
+
28
+ /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
29
+ * Stepping repaints the value, re-prices the cards, and updates the control bar. */
30
+ function buildBetBar(shell: GameShell, onChange: () => void): HTMLElement {
31
+ const bar = document.createElement('div'); bar.className = 'ge-bb-betbar';
32
+ const pill = document.createElement('div'); pill.className = 'ge-bb-betpill';
33
+ const val = document.createElement('div'); val.className = 'ge-bb-betval';
34
+ const down = stepButton('bb-bet-down', 'minus');
35
+ const up = stepButton('bb-bet-up', 'plus');
36
+ // Mirror the control bar: disable a stepper at the end of the bet range, and lock both
37
+ // while busy — so changing the stake behaves identically here and on the bottom bar.
38
+ const paint = () => {
39
+ val.innerHTML = `<span>${shell.t('Bet')}</span><b>${formatCurrency(shell.state.bet, shell.config.currency)}</b>`;
40
+ const i = shell.state.availableBets.indexOf(shell.state.bet);
41
+ down.disabled = shell.state.busy || i <= 0;
42
+ up.disabled = shell.state.busy || i >= shell.state.availableBets.length - 1;
43
+ };
44
+ const step = (dir: 1 | -1) => () => {
45
+ const next = stepBet(shell.state, dir);
46
+ if (next === shell.state.bet) return;
47
+ shell.state.bet = next; shell.emit('betChange', next); shell.render();
48
+ paint(); onChange();
49
+ };
50
+ down.addEventListener('click', step(-1));
51
+ up.addEventListener('click', step(1));
52
+ paint();
53
+ pill.append(down, val, up);
54
+ bar.appendChild(pill);
55
+ return bar;
56
+ }
57
+
58
+ function stepButton(ge: string, name: IconName): HTMLButtonElement {
59
+ const b = document.createElement('button');
60
+ b.className = 'ge-bb-betstep'; b.dataset.ge = ge; b.innerHTML = icon(name);
61
+ return b;
62
+ }
63
+
64
+ /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
65
+ * Clicking (when affordable) opens the confirmation modal. */
66
+ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
67
+ const accent = effectiveAccent(bonus);
68
+ const card = document.createElement('div');
69
+ card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
70
+ card.style.setProperty('--card-acc', accent);
71
+ card.style.setProperty('--card-ink', contrastText(accent));
72
+ card.appendChild(cardBody(shell, bonus));
73
+
74
+ const cta = document.createElement('button');
75
+ cta.className = 'ge-bonus-cta'; cta.dataset.ge = `bonus-cta-${bonus.id}`;
76
+ cta.textContent = shell.t(actionLabel(bonus));
77
+ card.appendChild(cta);
78
+
79
+ const enabled = isAffordable(shell, bonus);
80
+ if (!enabled) {
81
+ card.classList.add('ge-bonus-off');
82
+ cta.disabled = true;
83
+ } else {
84
+ // Stack the confirm on top of the overlay grid (cancel returns to the grid).
85
+ card.addEventListener('click', () => { overlay.appendChild(buildConfirm(shell, bonus, overlay)); shell.fitModals(); });
86
+ }
87
+ return card;
88
+ }
89
+
90
+ /** The shared card interior (everything above the action area), reused by the confirm modal. */
91
+ function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
92
+ const price = bonus.priceMultiplier * shell.state.bet;
93
+ const wrap = document.createElement('div'); wrap.className = 'ge-bonus-body';
94
+ wrap.innerHTML =
95
+ `<div class="ge-bonus-title">${bonus.title}</div>` +
96
+ `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
97
+ `<div class="ge-bonus-desc">${bonus.description}</div>` +
98
+ `<div class="ge-bonus-spacer"></div>` +
99
+ (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
100
+ `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
101
+ return wrap;
102
+ }
103
+
104
+ /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
105
+ * preview body and a full-bleed Cancel + action footer. */
106
+ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
107
+ const accent = effectiveAccent(bonus);
108
+ const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => ui.root.remove() });
109
+
110
+ const price = bonus.priceMultiplier * shell.state.bet;
111
+ const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
112
+ preview.innerHTML =
113
+ `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
114
+ `<div class="ge-bonus-desc">${bonus.description}</div>` +
115
+ (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
116
+ `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
117
+ ui.body.appendChild(preview);
118
+
119
+ const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
120
+ const cancel = document.createElement('button');
121
+ cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
122
+ cancel.textContent = shell.t('Cancel');
123
+ cancel.addEventListener('click', () => ui.root.remove());
124
+ const buy = document.createElement('button');
125
+ buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
126
+ buy.textContent = shell.t(actionLabel(bonus));
127
+ buy.style.color = contrastText(accent); // bg comes from --card-acc on the card
128
+ buy.addEventListener('click', () => {
129
+ // Re-check at click time: the confirm modal stays open across state changes, so a spin
130
+ // starting (busy), buy-bonus being disabled, or the balance dropping must block the purchase.
131
+ if (!isAffordable(shell, bonus)) return;
132
+ if (bonus.type === 'feature') shell.activateFeature(bonus);
133
+ else shell.emit('buyBonusSelect', { id: bonus.id });
134
+ ui.root.remove();
135
+ overlay.remove();
136
+ });
137
+ actions.append(cancel, buy);
138
+ ui.card.appendChild(actions);
139
+
140
+ return ui.root;
141
+ }
142
+
143
+ function thumb(bonus: BonusOption): string {
144
+ if (bonus.thumbnail) return `<img src="${bonus.thumbnail}" alt="${bonus.title}">`;
145
+ return `<span class="ge-bonus-thumb-ph">${icon('gift')}</span>`;
146
+ }
147
+
148
+ /** Volatility as five lightning bolts (the supplied SVG); `level` lit in the accent, rest dimmed. */
149
+ function volatility(level: number): string {
150
+ const n = Math.max(0, Math.min(5, level));
151
+ const bolt = icon('lightning');
152
+ return `<span class="ge-bonus-vol-on">${bolt.repeat(n)}</span>` +
153
+ `<span class="ge-bonus-vol-off">${bolt.repeat(5 - n)}</span>`;
154
+ }
155
+
156
+ function actionLabel(bonus: BonusOption): string {
157
+ return bonus.type === 'feature' ? 'Activate' : 'Buy';
158
+ }
159
+
160
+ function isAffordable(shell: GameShell, bonus: BonusOption): boolean {
161
+ if (shell.state.busy || !shell.state.buyBonusEnabled) return false;
162
+ return bonus.priceMultiplier * shell.state.bet <= shell.state.balance;
163
+ }
@@ -0,0 +1,253 @@
1
+ import type { GameShell } from '../GameShell';
2
+ import type { CellRef, GameInfoSection, GameMode, PaytableRow, PaylineDef, WinSection } from '../types';
3
+ import { createOverlay, twoLine } from './primitives';
4
+ import { icon } from './icons';
5
+
6
+ const SVG_NS = 'http://www.w3.org/2000/svg';
7
+
8
+ export function openGameInfoModal(shell: GameShell): HTMLElement {
9
+ const { root, body } = createOverlay({
10
+ title: shell.t('Game info'),
11
+ onClose: () => root.remove(),
12
+ onBack: () => { root.remove(); shell.openSettings(); },
13
+ });
14
+ root.dataset.ge = 'info-modal';
15
+
16
+ const sections = shell.config.gameInfo.sections ?? [];
17
+ // Default placement: modes first, controls second, the rest in declaration order.
18
+ // An explicit `order` overrides; ties keep declaration order (stable).
19
+ const base = (s: GameInfoSection, i: number): number =>
20
+ s.order ?? (s.type === 'modes' ? -2 : s.type === 'controls' ? -1 : i);
21
+ sections
22
+ .map((s, i) => ({ s, i, k: base(s, i) }))
23
+ .sort((a, b) => a.k - b.k || a.i - b.i)
24
+ .forEach(({ s }) => body.appendChild(renderSection(shell, s)));
25
+
26
+ return root;
27
+ }
28
+
29
+ function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
30
+ switch (s.type) {
31
+ case 'modes': return sectionModes(s.modes, sec('info-modes', s.title, shell.t('Modes')));
32
+ case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
33
+ case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
34
+ case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
35
+ case 'custom': return sectionCustom(s, sec('info-custom', s.title, ''));
36
+ }
37
+ }
38
+
39
+ /** A titled glass-plaque section shell. */
40
+ function sec(ge: string, title: string | undefined, fallback: string): HTMLElement {
41
+ const el = document.createElement('section');
42
+ el.dataset.ge = ge; el.className = 'ge-gi-sec';
43
+ const t = title ?? fallback;
44
+ if (t) { const h = document.createElement('h3'); h.textContent = t; el.appendChild(h); }
45
+ return el;
46
+ }
47
+
48
+ // ── modes (rows — varying description lengths read better than fixed cards) ────
49
+ function sectionModes(modes: GameMode[], el: HTMLElement): HTMLElement {
50
+ const list = document.createElement('div'); list.className = 'ge-gi-modes';
51
+ for (const m of modes) list.appendChild(modeRow(m));
52
+ el.appendChild(list);
53
+ return el;
54
+ }
55
+ function modeRow(m: GameMode): HTMLElement {
56
+ const row = document.createElement('div'); row.className = 'ge-gi-mode';
57
+ const stat = (label: string, val: string) =>
58
+ `<span class="ge-gi-mode-st"><span>${label}</span><b>${val}</b></span>`;
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);
63
+ row.innerHTML =
64
+ `<div class="ge-gi-mode-top"><span class="ge-gi-mode-h">${m.title}</span>` +
65
+ (stats ? `<span class="ge-gi-mode-stats">${stats}</span>` : '') + '</div>' +
66
+ (m.description ? `<p class="ge-gi-mode-desc">${m.description}</p>` : '');
67
+ return row;
68
+ }
69
+
70
+ // ── controls (auto-generated, split into a gameplay block and a menu/overlay block) ──
71
+ type CtlRow = { vis: string; name: string; desc: string; on: boolean };
72
+
73
+ function sectionControls(shell: GameShell, el: HTMLElement): HTMLElement {
74
+ const { features } = shell.config;
75
+ const slot = (inner: string, cls = '') => `<span class="ge-gi-ctl-ic ${cls}">${inner}</span>`;
76
+ const buyLabel = twoLine(shell.t('BUY BONUS'));
77
+ const buyBadge = slot(`<span class="ge-shell-buybonus"><span>${buyLabel}</span></span>`);
78
+
79
+ // Block 1 — gameplay. Bet is split into two rows: one to raise, one to lower.
80
+ const game: CtlRow[] = [
81
+ { vis: slot(icon('spin')), name: 'Spin', desc: 'Start a spin at the current bet.', on: true },
82
+ { vis: slot(icon('plus')), name: 'Raise bet', desc: 'Increase your stake.', on: true },
83
+ { vis: slot(icon('minus')), name: 'Lower bet', desc: 'Decrease your stake.', on: true },
84
+ { vis: slot(icon('autoplay')), name: 'Autoplay', desc: 'Spin automatically a set number of times.', on: features.autoplay },
85
+ { vis: slot(icon('turbo1')), name: 'Turbo', desc: 'Speed up spin animations.', on: features.turbo > 0 },
86
+ { vis: buyBadge, name: 'Buy bonus', desc: 'Pay a fixed cost to enter a bonus feature.', on: features.buyBonus !== false },
87
+ ];
88
+ // Block 2 — menu & overlay chrome (always available).
89
+ const menu: CtlRow[] = [
90
+ { vis: slot(icon('menu')), name: 'Menu', desc: 'Open settings and game info.', on: true },
91
+ { vis: slot(icon('soundOn')), name: 'Sound', desc: 'Mute or unmute the game.', on: true },
92
+ { vis: slot(icon('info')), name: 'Game info', desc: 'Open the paytable and rules.', on: true },
93
+ { vis: slot(icon('close')), name: 'Close', desc: 'Dismiss the current overlay.', on: true },
94
+ ];
95
+
96
+ el.appendChild(ctlBlock(shell, 'Game', game));
97
+ el.appendChild(ctlBlock(shell, 'Menu & info', menu));
98
+ return el;
99
+ }
100
+
101
+ function ctlBlock(shell: GameShell, label: string, rows: CtlRow[]): HTMLElement {
102
+ const block = document.createElement('div'); block.className = 'ge-gi-ctl-block';
103
+ const h = document.createElement('h4'); h.className = 'ge-gi-ctl-block-h'; h.textContent = shell.t(label);
104
+ block.appendChild(h);
105
+ for (const r of rows.filter((x) => x.on)) {
106
+ const row = document.createElement('div'); row.className = 'ge-gi-ctl';
107
+ row.innerHTML = `${r.vis}<div class="ge-gi-ctl-tx"><b>${shell.t(r.name)}</b><span>${shell.t(r.desc)}</span></div>`;
108
+ block.appendChild(row);
109
+ }
110
+ return block;
111
+ }
112
+
113
+ // ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
114
+ function sectionPaytable(rows: PaytableRow[], el: HTMLElement): HTMLElement {
115
+ const grid = document.createElement('div'); grid.className = 'ge-gi-pt-grid';
116
+ for (const r of rows) grid.appendChild(paytableCard(r));
117
+ el.appendChild(grid);
118
+ return el;
119
+ }
120
+ function paytableCard(r: PaytableRow): HTMLElement {
121
+ const card = document.createElement('div'); card.className = 'ge-gi-pt-card';
122
+ const sym = document.createElement('div'); sym.className = 'ge-gi-pt-sym';
123
+ if (r.symbol.image) {
124
+ const img = document.createElement('img'); img.src = r.symbol.image; img.alt = r.symbol.text ?? '';
125
+ sym.appendChild(img);
126
+ }
127
+ if (r.symbol.text) {
128
+ const t = document.createElement('span'); t.textContent = r.symbol.text; sym.appendChild(t);
129
+ }
130
+ const wins = document.createElement('div'); wins.className = 'ge-gi-pt-wins';
131
+ for (const w of r.wins) {
132
+ const wi = document.createElement('span'); wi.className = 'ge-gi-pt-win';
133
+ wi.innerHTML = (w.count ? `<i>${w.count}</i> ` : '') + `<b>x${w.multiplier}</b>`;
134
+ wins.appendChild(wi);
135
+ }
136
+ card.append(sym, wins);
137
+ return card;
138
+ }
139
+
140
+ // ── wins (one section = one pay type; cells filled in the accent colour, no line) ──
141
+ function winFallbackTitle(kind: WinSection['kind']): string {
142
+ return { classic: 'Paylines', cluster: 'Cluster pays', anywhere: 'Pays anywhere', ways: 'Ways to win' }[kind];
143
+ }
144
+
145
+ function sectionWins(s: WinSection, el: HTMLElement): HTMLElement {
146
+ if (s.kind === 'classic') {
147
+ if (s.description) el.appendChild(winDesc(s.description));
148
+ const wrap = document.createElement('div'); wrap.className = 'ge-gi-pl-grid';
149
+ s.lines.forEach((line, i) => {
150
+ const def: PaylineDef = Array.isArray(line) ? { pattern: line } : line;
151
+ wrap.appendChild(lineItem(s.grid, def, i + 1));
152
+ });
153
+ el.appendChild(wrap);
154
+ } else if (s.kind === 'cluster' || s.kind === 'anywhere') {
155
+ badge(el, `min ${s.minCount}`);
156
+ const row = document.createElement('div'); row.className = 'ge-gi-win-row';
157
+ const example = s.example ?? (s.kind === 'cluster' ? clusterExample(s.grid, s.minCount) : anywhereExample(s.grid, s.minCount));
158
+ row.appendChild(gridSvg(s.grid, example));
159
+ if (s.description) row.appendChild(winDesc(s.description));
160
+ el.appendChild(row);
161
+ } else {
162
+ if (s.description) el.appendChild(winDesc(s.description));
163
+ const two = document.createElement('div'); two.className = 'ge-gi-win-two';
164
+ two.append(
165
+ waysCol('✓ wins', 'ge-gi-win-ok', s.grid, s.winExample ?? waysWin(s.grid)),
166
+ waysCol('✗ no win', 'ge-gi-win-no', s.grid, s.loseExample ?? waysLose(s.grid)),
167
+ );
168
+ el.appendChild(two);
169
+ }
170
+ return el;
171
+ }
172
+
173
+ function winDesc(text: string): HTMLElement {
174
+ const p = document.createElement('p'); p.className = 'ge-gi-win-desc'; p.textContent = text;
175
+ return p;
176
+ }
177
+ /** Append a "min N" pill to the section header. */
178
+ function badge(el: HTMLElement, text: string): void {
179
+ const h = el.querySelector('h3');
180
+ if (!h) return;
181
+ const b = document.createElement('span'); b.className = 'ge-gi-win-badge'; b.textContent = text;
182
+ h.appendChild(b);
183
+ }
184
+
185
+ /** A cols×rows grid SVG; `on` cells are filled in the accent colour, the rest are faint. */
186
+ function gridSvg(grid: { cols: number; rows: number }, on: CellRef[]): SVGSVGElement {
187
+ const { cols, rows } = grid;
188
+ const W = 100, H = Math.round((rows / cols) * 100);
189
+ const cw = W / cols, ch = H / rows;
190
+ const svg = document.createElementNS(SVG_NS, 'svg');
191
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
192
+ svg.setAttribute('class', 'ge-gi-pl-svg');
193
+ const onSet = new Set(on.map(([c, r]) => `${c},${r}`));
194
+ for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
195
+ const r = document.createElementNS(SVG_NS, 'rect');
196
+ r.setAttribute('x', String(x * cw + 1)); r.setAttribute('y', String(y * ch + 1));
197
+ r.setAttribute('width', String(cw - 2)); r.setAttribute('height', String(ch - 2));
198
+ r.setAttribute('rx', '2'); r.setAttribute('class', onSet.has(`${x},${y}`) ? 'ge-gi-pl-on' : 'ge-gi-pl-cell');
199
+ svg.appendChild(r);
200
+ }
201
+ return svg;
202
+ }
203
+
204
+ /** A classic payline: number caption on top, filled cells (no connecting line). */
205
+ function lineItem(grid: { cols: number; rows: number }, def: PaylineDef, n: number): HTMLElement {
206
+ const item = document.createElement('div'); item.className = 'ge-gi-pl-item';
207
+ const cap = document.createElement('span'); cap.className = 'ge-gi-pl-cap'; cap.textContent = String(n);
208
+ const on: CellRef[] = def.pattern.map((rowIdx, col) => [col, rowIdx]);
209
+ item.append(cap, gridSvg(grid, on)); // caption first → renders above the grid
210
+ return item;
211
+ }
212
+
213
+ function waysCol(tag: string, tagCls: string, grid: { cols: number; rows: number }, cells: CellRef[]): HTMLElement {
214
+ const col = document.createElement('div'); col.className = 'ge-gi-win-col';
215
+ const t = document.createElement('span'); t.className = `ge-gi-win-tag ${tagCls}`; t.textContent = tag;
216
+ col.append(t, gridSvg(grid, cells));
217
+ return col;
218
+ }
219
+
220
+ // Default illustrations (used when the section omits an explicit example).
221
+ function clusterExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
222
+ const w = Math.min(grid.cols, Math.max(1, Math.ceil(Math.sqrt(n))));
223
+ const cells: CellRef[] = [];
224
+ for (let y = 0; y < grid.rows && cells.length < n; y++)
225
+ for (let x = 0; x < w && cells.length < n; x++) cells.push([x, y]);
226
+ return cells;
227
+ }
228
+ function anywhereExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
229
+ const count = Math.min(n, grid.cols * grid.rows);
230
+ const cells: CellRef[] = [];
231
+ for (let i = 0; i < count; i++) cells.push([Math.floor((i * grid.cols) / count), (i * 2 + 1) % grid.rows]);
232
+ return cells;
233
+ }
234
+ function waysWin(grid: { cols: number; rows: number }): CellRef[] {
235
+ const cells: CellRef[] = [];
236
+ for (let c = 0; c < grid.cols; c++) cells.push([c, c % grid.rows]); // one symbol on every reel
237
+ return cells;
238
+ }
239
+ function waysLose(grid: { cols: number; rows: number }): CellRef[] {
240
+ const gap = Math.floor(grid.cols / 2);
241
+ return waysWin(grid).filter(([c]) => c !== gap); // a broken chain (reel `gap` empty)
242
+ }
243
+
244
+ // ── custom ───────────────────────────────────────────────────────────────────
245
+ function sectionCustom(s: Extract<GameInfoSection, { type: 'custom' }>, el: HTMLElement): HTMLElement {
246
+ if (s.node) {
247
+ el.appendChild(s.node);
248
+ } else if (s.html) {
249
+ const d = document.createElement('div'); d.className = 'ge-gi-custom'; d.innerHTML = s.html;
250
+ el.appendChild(d);
251
+ }
252
+ return el;
253
+ }
@@ -0,0 +1,36 @@
1
+ import type { ModalOptions } from '../types';
2
+ import { contrastText } from '../colors';
3
+ import { createCardModal } from './primitives';
4
+
5
+ /** Build a generic, externally-triggered modal (title + body text + optional action buttons),
6
+ * on the shared card-modal chrome. Each action runs its `on` then closes; the ✕ (if
7
+ * `availableClose`) and the actions are the only ways to dismiss. See GameShell.openModal. */
8
+ export function buildModal(opts: ModalOptions): HTMLElement {
9
+ const ui = createCardModal({
10
+ ge: 'modal',
11
+ title: opts.title,
12
+ closable: opts.availableClose,
13
+ blur: opts.blurLevel,
14
+ onClose: () => ui.root.remove(),
15
+ });
16
+
17
+ const text = document.createElement('p');
18
+ text.className = 'ge-modal-text'; text.dataset.ge = 'modal-body';
19
+ text.textContent = opts.body;
20
+ ui.body.appendChild(text);
21
+
22
+ if (opts.actions?.length) {
23
+ const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
24
+ for (const a of opts.actions) {
25
+ const btn = document.createElement('button');
26
+ btn.className = 'ge-modal-btn'; btn.dataset.ge = 'modal-action';
27
+ btn.textContent = a.title;
28
+ if (a.color) { btn.style.background = a.color; btn.style.color = contrastText(a.color); }
29
+ else btn.classList.add('ge-modal-btn--ghost');
30
+ btn.addEventListener('click', () => { a.on?.(); ui.root.remove(); });
31
+ actions.appendChild(btn);
32
+ }
33
+ ui.card.appendChild(actions);
34
+ }
35
+ return ui.root;
36
+ }
@@ -0,0 +1,56 @@
1
+ import type { GameShell } from '../GameShell';
2
+ import type { ReplayModalOptions } from '../types';
3
+ import { formatCurrency } from '../format';
4
+ import { createCardModal } from './primitives';
5
+
6
+ /** The replay summary modal — built on the shared card chrome, but NOT dismissable: no ✕,
7
+ * and the backdrop never closes it. The only way out is START REPLAY, which closes the
8
+ * modal, runs `onReplay`, then reopens it (whether the handler resolves OR rejects, so a
9
+ * failed replay can't strand the user). */
10
+ export function buildReplayModal(shell: GameShell, opts: ReplayModalOptions): HTMLElement {
11
+ const { bonusId, bet, payoutMultiplier } = opts;
12
+ const fmt = (n: number) => formatCurrency(n, shell.config.currency);
13
+ const bonus = Array.isArray(shell.config.features.buyBonus)
14
+ ? shell.config.features.buyBonus.find((b) => b.id === bonusId)
15
+ : undefined;
16
+ const mode = bonus?.title ?? bonusId;
17
+ const costMultiplier = bonus?.priceMultiplier ?? 1;
18
+
19
+ const ui = createCardModal({
20
+ ge: 'replay-modal',
21
+ title: shell.t('Replay'),
22
+ closable: false, // no ✕; the backdrop never dismisses it either
23
+ onClose: () => {}, // unused — there is no close affordance
24
+ });
25
+
26
+ const rows = document.createElement('div'); rows.className = 'ge-replay-rows';
27
+ const row = (label: string, value: string, total = false): void => {
28
+ const r = document.createElement('div'); r.className = `ge-replay-row${total ? ' ge-replay-total' : ''}`;
29
+ const l = document.createElement('span'); l.textContent = shell.t(label);
30
+ const v = document.createElement('b'); v.textContent = value;
31
+ r.append(l, v); rows.appendChild(r);
32
+ };
33
+ row('Mode', mode);
34
+ row('Base bet', fmt(bet));
35
+ row('Cost multiplier', `${costMultiplier}×`);
36
+ row('Total cost bet', fmt(bet * costMultiplier));
37
+ row('Payout multiplier', `${payoutMultiplier}×`);
38
+ row('Total win', fmt(payoutMultiplier * bet), true);
39
+ ui.body.appendChild(rows);
40
+
41
+ const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
42
+ const btn = document.createElement('button');
43
+ btn.className = 'ge-modal-btn ge-modal-btn--accent'; btn.dataset.ge = 'replay-start';
44
+ btn.textContent = shell.t('Start replay');
45
+ btn.addEventListener('click', () => {
46
+ ui.root.remove(); // close immediately
47
+ // Reopen after the handler settles. On rejection we still reopen — this modal is the only
48
+ // way out of replay mode, so a failed play must not strand the user on an empty screen.
49
+ const reopen = (): void => { shell.openReplay(opts); };
50
+ Promise.resolve(opts.onReplay()).then(reopen, reopen);
51
+ });
52
+ actions.appendChild(btn);
53
+ ui.card.appendChild(actions);
54
+
55
+ return ui.root;
56
+ }
@@ -0,0 +1,60 @@
1
+ import type { GameShell } from '../GameShell';
2
+ import { createOverlay } from './primitives';
3
+ import { icon } from './icons';
4
+
5
+ export function openSettingsModal(shell: GameShell): HTMLElement {
6
+ const { root, body } = createOverlay({ title: shell.t('Settings'), onClose: () => root.remove() });
7
+ root.dataset.ge = 'settings-modal';
8
+
9
+ // Sound on/off (starts on) — full-width row with a speaker icon button
10
+ const sound = (() => {
11
+ let on = true;
12
+ const btn = document.createElement('button');
13
+ btn.className = 'ge-snd ge-active'; btn.dataset.ge = 'setting-sound';
14
+ btn.setAttribute('aria-label', 'Sound');
15
+ const paint = () => {
16
+ btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
17
+ btn.classList.toggle('ge-active', on);
18
+ btn.setAttribute('aria-pressed', String(on));
19
+ };
20
+ paint();
21
+ btn.addEventListener('click', () => {
22
+ on = !on; paint();
23
+ shell.emit('settingChange', { key: 'sound', value: on });
24
+ });
25
+ const row = document.createElement('div'); row.className = 'ge-ov-row';
26
+ row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`; row.appendChild(btn);
27
+ return row;
28
+ })();
29
+ body.appendChild(sound);
30
+
31
+ // Volume sliders — full-width column rows with a live value readout
32
+ const slider = (key: string, label: string) => {
33
+ const row = document.createElement('div'); row.className = 'ge-ov-row ge-col';
34
+ const head = document.createElement('div'); head.className = 'ge-row-head';
35
+ const val = document.createElement('span'); val.className = 'ge-val'; val.textContent = '100%';
36
+ head.innerHTML = `<span>${label}</span>`; head.appendChild(val);
37
+ const input = document.createElement('input');
38
+ input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05'; input.value = '1';
39
+ input.className = 'ge-slider'; input.dataset.ge = `setting-${key}`;
40
+ input.addEventListener('input', () => {
41
+ val.textContent = `${Math.round(Number(input.value) * 100)}%`;
42
+ shell.emit('settingChange', { key, value: Number(input.value) });
43
+ });
44
+ row.append(head, input);
45
+ return row;
46
+ };
47
+ body.appendChild(slider('master', shell.t('Master volume')));
48
+ body.appendChild(slider('music', shell.t('Music')));
49
+ body.appendChild(slider('sfx', shell.t('SFX')));
50
+
51
+ // Game info — full-width row button that opens its own overlay
52
+ const gameInfo = document.createElement('button');
53
+ gameInfo.className = 'ge-ov-row'; gameInfo.dataset.ge = 'game-info-btn';
54
+ gameInfo.style.marginTop = '6px';
55
+ gameInfo.innerHTML = `<span style="width:22px;font-size:22px">${icon('info')}</span><span class="ge-grow">${shell.t('Game info')}</span><span style="width:20px;font-size:20px;color:var(--shell-muted)">${icon('chevronRight')}</span>`;
56
+ gameInfo.addEventListener('click', () => { root.remove(); shell.openInfo(); });
57
+ body.appendChild(gameInfo);
58
+
59
+ return root;
60
+ }
@@ -0,0 +1,40 @@
1
+ // Sharp monochrome icon set, traced from the brand sheet. Every glyph uses
2
+ // currentColor so the caller's color cascades (active states recolour via CSS);
3
+ // hollow shapes set fill-rule="evenodd". All return an inline <svg> sized 1em.
4
+ const SVGS: Record<string, string> = {
5
+ // sharp angular set, traced from the brand sheet — monochrome currentColor
6
+ // (glow via CSS drop-shadow). Hollow shapes rely on fill-rule="evenodd".
7
+ // hollow loop ring + two arrowheads (renders dark-on-bright inside SPIN)
8
+ spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.92 4.68 L13.91 2.81 L10.55 1.6 L12.05 3.47 L11.95 3.56 L10.93 3.65 L9.25 4.12 L7.94 4.77 L7.01 5.42 L5.61 6.82 L4.58 8.41 L4.03 9.53 L3.28 11.67 L2.91 13.91 L4.49 16.52 L4.68 16.62 L6.54 14.94 L6.17 14.01 L6.08 13.17 L5.98 13.07 L5.98 11.49 L6.08 10.93 L6.45 9.71 L6.82 8.97 L7.48 8.04 L8.22 7.29 L9.25 6.54 L9.99 6.17 L11.21 5.8 L11.77 5.7 L13.73 5.7 L13.82 5.8 L14.57 5.89 L15.87 6.45 L16.06 6.45 L17.92 4.86Z"/><path d="M19.6 7.66 L19.42 7.57 L17.83 9.16 L18.11 9.9 L18.2 10.74 L18.3 10.83 L18.3 12.7 L18.2 12.79 L18.11 13.54 L17.46 15.03 L16.9 15.87 L15.87 16.9 L14.94 17.55 L14.1 17.92 L13.17 18.2 L12.05 18.39 L11.02 18.39 L10.93 18.3 L9.9 18.2 L9.06 17.92 L8.41 17.55 L8.22 17.55 L6.73 18.76 L6.26 19.32 L11.21 21.56 L12.7 22.03 L13.45 22.4 L13.73 22.4 L12.23 20.53 L12.33 20.44 L13.54 20.35 L15.31 19.79 L16.8 18.95 L17.64 18.3 L18.76 17.08 L19.51 15.96 L20.16 14.66 L20.25 14.19 L20.53 13.63 L20.72 12.98 L20.72 12.61 L20.91 12.14 L21.09 10.37 L19.97 8.5Z"/></g>`,
9
+ turbo: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.16 1.71 L16.47 1.71 L16.36 1.6 L13.23 1.6 L12.78 1.71 L11.78 3.39 L10.55 5.85 L10.32 6.07 L8.31 9.99 L8.09 10.21 L7.19 12.11 L12 12.34 L14.46 9.88 L14.24 9.65 L12.89 9.54 L12.89 9.2 L17.03 4.4Z"/><path d="M19.83 6.97 L18.93 7.64 L16.36 9.99 L9.65 16.47 L4.17 21.62 L4.17 22.4 L5.4 21.39 L13.34 13.9 L13.45 14.12 L10.43 20.16 L10.21 20.83 L9.65 21.95 L9.76 21.95 L19.27 10.32 L19.27 10.21 L17.59 10.1 L17.48 9.88 L18.71 8.53Z"/></g>`,
10
+ // bolt with 1/2/3 speed lines — escalating turbo level
11
+ turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.82 1.6 L21.59 1.72 L21.01 1.72 L20.44 1.95 L18.7 2.29 L18.12 2.29 L18.01 2.41 L16.39 2.64 L14.77 3.1 L13.73 5.41 L13.73 5.64 L13.27 6.45 L13.27 6.68 L12.81 7.49 L12.81 7.72 L12.35 8.53 L12.35 8.76 L11.77 9.8 L11.42 10.84 L10.15 13.5 L10.15 13.73 L14.54 13.73 L14.66 13.85 L13.39 16.16 L12.81 16.97 L11.54 19.4 L11.31 19.63 L11.08 20.2 L10.04 21.82 L9.8 22.4 L9.92 22.4 L20.9 11.19 L22.05 10.15 L22.05 10.04 L16.62 10.15 L16.51 10.27 L15.7 10.27 L15.58 10.15 L15.81 9.69 L16.28 9.23 L21.48 2.29Z"/><path d="M8.53 12.46 L7.72 12.58 L7.26 12.81 L6.92 12.81 L6.45 13.04 L6.11 13.04 L5.64 13.27 L3.68 13.73 L1.95 14.31 L1.95 14.43 L7.61 14.43Z"/></g>`,
12
+ turbo2: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 2.68 L18.38 3.61 L15.4 4.43 L14.68 5.98 L13.96 7.93 L13.65 8.45 L13.65 8.65 L13.34 9.17 L13.34 9.37 L13.03 9.89 L13.03 10.1 L12.72 10.61 L12.72 10.82 L12.41 11.33 L12.41 11.54 L12.1 12.05 L12.1 12.26 L11.79 12.77 L11.49 13.7 L15.3 13.7 L15.5 13.9 L14.47 15.55 L14.16 16.27 L13.96 16.48 L11.28 21.32 L11.9 20.91 L20.65 11.95 L22.09 10.61 L22.09 10.51 L20.55 10.51 L20.44 10.61 L16.32 10.71 L16.22 10.51 L19.83 6.08Z"/><path d="M10.04 15.04 L9.43 15.14 L8.81 14.93 L7.98 14.93 L7.88 15.04 L6.34 15.14 L6.23 15.24 L5.62 15.24 L4.28 15.55 L2.84 15.76 L1.6 16.17 L10.04 16.17Z"/><path d="M10.56 9.99 L9.43 10.1 L9.32 10.2 L7.37 10.51 L6.85 10.71 L6.44 10.71 L5.51 11.02 L3.04 11.54 L2.94 11.74 L5.1 11.85 L5.2 11.95 L6.65 11.95 L6.75 12.05 L9.01 12.05 L9.12 12.15 L9.73 12.15 L10.56 10.3Z"/></g>`,
13
+ turbo3: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 3 L22 3 L20.3 3.5 L19.9 3.5 L19.5 3.7 L19.1 3.7 L15.9 4.6 L13.9 9.1 L13.9 9.3 L13.6 10 L13.2 10.7 L13.2 10.9 L12.6 12.1 L12.3 13 L12 13.6 L15.8 13.6 L15.9 13.8 L14.4 16.3 L13.7 17.7 L13.2 18.4 L12.8 19.1 L12.5 19.8 L12 20.5 L11.9 21 L22.2 10.6 L22.2 10.5 L16.8 10.6 L16.7 10.4 L22.3 3.3Z"/><path d="M11 11.3 L9.9 11.3 L9.8 11.4 L7.8 11.6 L6 11.9 L5.4 12.1 L3.9 12.3 L3.1 12.5 L3.1 12.6 L5.6 12.7 L5.7 12.8 L7.9 12.8 L8 12.9 L10.4 12.9 L11 11.6Z"/><path d="M12.7 7.3 L12 7.3 L11.9 7.4 L10.7 7.5 L10.1 7.7 L9.6 7.7 L7.2 8.2 L6.7 8.4 L5.5 8.6 L5.6 8.8 L7 8.8 L7.1 8.9 L8.5 8.9 L8.6 9 L12 9.1Z"/><path d="M10.9 15.4 L10.3 15.7 L10 15.7 L9.1 15.4 L7.8 15.5 L7.7 15.6 L6.9 15.6 L6.8 15.7 L6 15.7 L5.9 15.8 L4.1 16 L3.5 16.2 L2.5 16.3 L1.6 16.6 L2.6 16.6 L2.7 16.7 L10.9 16.7Z"/></g>`,
14
+ autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.48 5.36 L15.02 1.6 L14.82 1.6 L15.62 3.28 L15.52 3.78 L14.82 3.78 L14.72 3.88 L12.64 3.88 L12.54 3.78 L5.71 3.78 L3.23 6.35 L3.23 16.26 L3.33 16.26 L4.82 14.48 L4.82 12.5 L4.72 12.4 L4.72 7.25 L4.82 7.05 L6.4 5.56 L8.29 5.56 L8.38 5.46 L19.48 5.46Z"/><path d="M9.87 8.83 L9.87 15.27 L9.97 15.27 L11.26 14.38 L15.12 12.1 L15.12 12 L11.16 9.62 L10.66 9.23Z"/><path d="M20.67 7.94 L20.17 8.34 L19.18 9.52 L19.18 16.85 L17.5 18.44 L17.1 18.54 L4.42 18.54 L4.42 18.64 L7.3 21.21 L8.78 22.4 L8.78 22 L8.09 20.42 L8.19 20.22 L18.29 20.22 L20.77 17.65 L20.67 17.35Z"/></g>`,
15
+ // hollow square — stop autoplay / halt
16
+ stop: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 3.04 L21.1 4.82 L21.1 9.47 L21.03 9.54 L21.03 12.41 L21.1 12.96 L21.03 18.84 L21.1 19.18 L20.35 20.01 L19.39 20.89 L17.27 20.89 L16.72 20.96 L4.75 20.96 L4.68 20.89 L3.04 22.33 L4.13 22.33 L4.2 22.4 L6.66 22.4 L6.73 22.33 L9.13 22.33 L9.19 22.4 L20.48 22.4 L22.4 20.48Z"/><path d="M20.96 1.6 L3.38 1.6 L1.6 3.45 L1.6 20.96 L2.9 19.12 L2.9 4.75 L4.54 3.11 L19.12 3.11 L19.46 2.97Z"/></g>`,
17
+ menu: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.52 L19.61 13.52 L22.27 10.73 L22.4 10.35 L4.39 10.35 L2.36 12.51Z"/><path d="M1.6 6.04 L19.61 6.04 L22.15 3.38 L22.4 2.87 L4.39 2.87 L2.36 5.02Z"/><path d="M1.6 21 L3.25 21 L3.38 21.13 L4.9 21.13 L5.02 21 L19.61 21 L22.4 17.96 L4.52 17.83 L4.26 17.96 L2.36 19.99Z"/></g>`,
18
+ betUp: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.01 15.43 L19.18 13.07 L14.68 6.75 L12.32 3.64 L12 3.42 L8.57 7.93 L6.42 11.04 L2.89 15.75 L2.78 16.07 L12 8.14 L12.21 8.14 L20.68 15.43Z"/><path d="M22.4 20.26 L20.04 17.79 L12.21 10.28 L12 10.28 L6.53 15.43 L6.42 15.65 L1.81 20.15 L1.6 20.58 L12 14.04 L12.21 14.04 L12.75 14.47 L18.11 17.68 L18.65 18.11 L19.51 18.54 L22.29 20.36Z"/></g>`,
19
+ betDown: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 2.74 L1.84 3.22 L5.57 7.43 L11.7 14.04 L12.06 14.28 L12.42 14.04 L18.79 7.19 L22.4 2.98 L22.28 2.86 L18.55 5.51 L12.06 10.44 L5.81 5.63Z"/><path d="M20.84 9.35 L18.07 11.52 L12.18 16.57 L11.94 16.57 L4.49 10.2 L3.52 9.48 L3.4 9.6 L8.57 16.57 L8.81 17.05 L10.26 18.85 L10.5 19.33 L11.34 20.3 L11.58 20.78 L12.18 21.26 L18.43 12.84Z"/></g>`,
20
+ minus: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.26 L12.42 13.26 L12.5 13.18 L19.81 13.26 L22.4 10.82 L4.27 10.74Z"/></g>`,
21
+ plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.16 1.6 L12.26 3.17 L10.77 5.42 L10.77 10.73 L10.69 10.8 L4.33 10.8 L1.79 13.2 L10.69 13.2 L10.77 13.27 L10.77 22.4 L12.41 19.71 L13.16 18.66 L13.16 13.27 L13.23 13.2 L19.59 13.2 L22.21 10.8 L13.23 10.8 L13.16 10.73Z"/></g>`,
22
+ gift: `<rect x="4" y="9" width="16" height="11" rx="2" fill="currentColor"/><path d="M9 9a2.5 2.5 0 1 1 3-3 2.5 2.5 0 1 1 3 3z" fill="currentColor"/><rect x="11" y="9" width="2" height="11" fill="rgba(0,0,0,.35)"/>`,
23
+ info: `<g fill="currentColor" fill-rule="evenodd"><path d="M14.22 7.78 L11.13 7.86 L9.4 9.21 L9.17 9.29 L10.15 9.29 L10.23 9.59 L9.32 13.73 L8.87 17.12 L8.57 18.78 L8.5 19.69 L8.27 20.74 L8.12 22.32 L8.04 22.4 L13.62 17.95 L11.96 17.95 L11.89 17.58 L12.41 15.69 L12.49 15.01 L12.87 13.73 L12.87 13.43 L13.24 12.15 L13.77 9.97 L14 8.68 L14.22 8.08Z"/><path d="M15.96 1.6 L12.94 1.6 L11.74 3.18 L11.51 3.63 L10.08 5.67 L12.79 5.59 L14.37 3.71Z"/></g>`,
24
+ soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M16.61 6.68 L17.16 8.4 L17.4 9.97 L17.4 13.88 L17.32 13.95 L17.16 15.44 L16.61 17.16 L18.88 13.72 L18.88 12.86 L18.96 12.78 L18.88 10.12Z"/><path d="M19.27 4.34 L19.58 4.88 L19.66 5.28 L20.05 6.14 L20.6 7.93 L20.91 9.5 L20.91 10.44 L20.99 10.51 L20.99 13.25 L20.91 13.33 L20.91 14.19 L20.76 15.13 L20.05 17.63 L19.27 19.43 L20.29 18.02 L21.15 16.61 L22.4 14.19 L22.4 9.58 L21.31 7.39 L20.29 5.74Z"/><path d="M13.17 1.76 L10.28 4.34 L6.29 8.17 L1.6 8.25 L1.6 15.6 L2.15 15.75 L6.29 16.3 L9.97 19.58 L13.17 22.24Z"/><path d="M11.69 5.59 L11.92 5.67 L11.92 19.04 L11.69 19.12 L7.07 14.82 L4.57 14.5 L4.42 14.35 L4.42 10.51 L4.57 10.36 L7.07 10.28Z"/></g>`,
25
+ soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.68 13.18 L12.53 14.4 L12.53 18.44 L12.3 18.51 L10.4 16.76 L9.33 17.83 L12.84 20.88 L13.75 21.56 L13.75 13.18Z"/><path d="M17.71 8.69 L17.49 8.91 L17.56 9.9 L17.64 9.98 L17.64 13.1 L17.33 14.7 L17.03 15.39 L18.78 12.88 L18.78 11.66 L18.86 11.58 L18.78 10.29Z"/><path d="M19.47 6.63 L19.85 7.47 L20.46 9.6 L20.53 10.13 L20.53 13.03 L20.08 15.01 L19.31 16.91 L20.61 14.93 L21.3 13.49 L21.52 13.18 L21.52 9.9 L20.76 8.53Z"/><path d="M20.91 2.21 L16.5 6.63 L13.98 9.3 L13.75 9.22 L13.75 1.6 L10.86 4.19 L7.05 7.85 L2.4 7.92 L2.48 8 L2.48 15.16 L6.51 15.7 L7.73 14.32 L5.3 14.02 L5.22 13.94 L5.22 10.13 L5.75 9.98 L7.81 9.9 L12.3 5.33 L12.53 5.41 L12.53 10.9 L10.32 13.26 L6.9 17.14 L2.55 22.4 L6.9 18.36 L9.79 15.47 L13.52 11.5 L18.25 5.71Z"/></g>`,
26
+ close: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.16 1.6 L21.3 2.09 L20.44 2.82 L16.89 5.27 L12.12 10.29 L10.53 8.82 L7.35 5.39 L2.21 2.09 L5.88 7.23 L8.21 9.55 L10.41 12 L5.76 16.89 L2.33 21.54 L1.84 22.4 L2.58 22.03 L2.95 21.67 L4.17 20.93 L5.88 19.59 L7.23 18.73 L12 13.71 L12.12 13.71 L17.02 18.73 L22.16 22.16 L22.16 21.91 L20.2 19.46 L18.48 17.02 L13.71 12.12 L13.71 11.88 L18.48 6.86 L20.32 4.17 L21.67 2.46Z"/></g>`,
27
+ back: `<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
28
+ chevronRight: `<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
29
+ star: `<path d="M12 3l2.6 5.6 6.1.7-4.5 4.2 1.2 6L12 16.9 6.6 19.5l1.2-6L3.3 9.3l6.1-.7z" fill="currentColor"/>`,
30
+ // volatility bolt (buy-bonus cards) — supplied art, scaled from its 1254 viewBox into 24×24
31
+ lightning: `<path transform="scale(0.019139)" d="M747,205L433,629L622,633L497,986L801,550L614,547Z" fill="currentColor"/>`,
32
+ };
33
+
34
+ export type IconName = keyof typeof SVGS;
35
+ export const ICON_NAMES = Object.keys(SVGS) as IconName[];
36
+
37
+ /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
38
+ export function icon(name: IconName): string {
39
+ return `<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">${SVGS[name]}</svg>`;
40
+ }