@energy8platform/game-engine 0.35.1 → 0.37.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.
@@ -0,0 +1,148 @@
1
+ import type { Shell } from '@energy8platform/shell/pixi';
2
+
3
+ /**
4
+ * Per-game persistence of the player's shell preferences — turbo level and every menu value
5
+ * (sound, music, sfx, and any custom row) — in `localStorage`.
6
+ *
7
+ * Opt-in via `createSlotGame({ persistSettings })`. A game that doesn't ask for it behaves exactly
8
+ * as before: nothing is read, nothing is written, no storage is touched.
9
+ *
10
+ * Scoped per game, because these are per-game preferences: a player who mutes one slot has not
11
+ * asked to mute the next one. The key is `e8:<gameId>:settings`.
12
+ */
13
+ export interface PersistSettingsOptions {
14
+ /** Key namespace. Defaults to the game id, so two games never share a preference set. */
15
+ key?: string;
16
+ /** Backing store. Defaults to `window.localStorage`; pass one in tests, or `null` to disable. */
17
+ storage?: Storage | null;
18
+ }
19
+
20
+ /** What we keep. `menu` covers presets and custom rows alike — `setMenuValue` routes both. */
21
+ export interface StoredSettings {
22
+ turbo?: number;
23
+ menu?: Record<string, boolean | number>;
24
+ }
25
+
26
+ export function settingsKey(gameId: string): string {
27
+ return `e8:${gameId}:settings`;
28
+ }
29
+
30
+ /**
31
+ * `localStorage` is not reliably there and not reliably usable: Safari's private mode, a browser
32
+ * configured to block storage, and a full quota can all make even a READ throw. A saved turbo level
33
+ * is never worth failing a game boot over, so every access degrades to "no persistence".
34
+ */
35
+ function resolveStorage(explicit?: Storage | null): Storage | null {
36
+ if (explicit !== undefined) return explicit;
37
+ try {
38
+ return typeof localStorage === 'undefined' ? null : localStorage;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** A menu value is a boolean or a finite number — nothing else may reach the shell. */
45
+ function isMenuValue(v: unknown): v is boolean | number {
46
+ return typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v));
47
+ }
48
+
49
+ /**
50
+ * Read and VALIDATE. Whatever is in `localStorage` is player-writable — anyone can open devtools
51
+ * and put a string, an object, or `turbo: 99` in there. It is parsed as untrusted input: a bad
52
+ * field is dropped, not coerced, and a bad blob yields nothing at all.
53
+ */
54
+ export function readSettings(storage: Storage | null, key: string): StoredSettings {
55
+ if (!storage) return {};
56
+ let raw: string | null;
57
+ try {
58
+ raw = storage.getItem(key);
59
+ } catch {
60
+ return {};
61
+ }
62
+ if (!raw) return {};
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ } catch {
67
+ return {};
68
+ }
69
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
70
+ const src = parsed as Record<string, unknown>;
71
+ const out: StoredSettings = {};
72
+ if (typeof src.turbo === 'number' && Number.isInteger(src.turbo) && src.turbo >= 0) {
73
+ out.turbo = src.turbo;
74
+ }
75
+ if (src.menu && typeof src.menu === 'object' && !Array.isArray(src.menu)) {
76
+ const menu: Record<string, boolean | number> = {};
77
+ for (const [id, value] of Object.entries(src.menu as Record<string, unknown>)) {
78
+ if (isMenuValue(value)) menu[id] = value;
79
+ }
80
+ if (Object.keys(menu).length) out.menu = menu;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function writeSettings(storage: Storage | null, key: string, value: StoredSettings): void {
86
+ if (!storage) return;
87
+ try {
88
+ storage.setItem(key, JSON.stringify(value));
89
+ } catch {
90
+ // Quota, private mode, or storage disabled mid-session. The player keeps playing; only the
91
+ // memory of their preference is lost, and that is not worth an error in their face.
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Restore the stored preferences onto a freshly created shell, then keep them in step.
97
+ *
98
+ * Returns an unsubscribe function.
99
+ *
100
+ * Two things are deliberate here:
101
+ *
102
+ * - **`maxTurbo` clamps the restored level.** `features.turbo` is the CEILING the shell offers
103
+ * (`applyJurisdiction` lowers it where super-turbo or turbo is forbidden), while `state.turbo`
104
+ * is what the player currently has. Restoring a saved 3 into a jurisdiction capped at 1 would
105
+ * hand back exactly what the restriction took away, so the stored value is clamped, never
106
+ * trusted.
107
+ * - **Subscription happens AFTER restoring.** `setMenuValue` emits `settingChange`, so subscribing
108
+ * first would have the restore write back what it just read. (`setTurbo` is quiet — only the
109
+ * bar tap emits `turboChange` — which is why restoring the level can't echo. Do not "fix" that
110
+ * asymmetry without moving this subscription.)
111
+ */
112
+ export function attachSettingsStore(
113
+ shell: Shell,
114
+ opts: { key: string; storage?: Storage | null; maxTurbo?: number },
115
+ ): () => void {
116
+ const storage = resolveStorage(opts.storage);
117
+ const key = settingsKey(opts.key);
118
+ const stored = readSettings(storage, key);
119
+
120
+ if (stored.turbo !== undefined) {
121
+ shell.setTurbo(Math.min(stored.turbo, opts.maxTurbo ?? stored.turbo));
122
+ }
123
+ for (const [id, value] of Object.entries(stored.menu ?? {})) {
124
+ // `setMenuValue` routes presets to their own homes (sound → setSound, music/sfx → setVolume)
125
+ // and clamps custom ranges, so one loop covers every row and the shell owns the bounds.
126
+ shell.setMenuValue(id, value);
127
+ }
128
+
129
+ const live: StoredSettings = { ...stored };
130
+ const flush = (): void => writeSettings(storage, key, live);
131
+
132
+ const onTurbo = (level: number): void => {
133
+ live.turbo = level;
134
+ flush();
135
+ };
136
+ const onSetting = ({ key: id, value }: { key: string; value: unknown }): void => {
137
+ if (!isMenuValue(value)) return;
138
+ live.menu = { ...live.menu, [id]: value };
139
+ flush();
140
+ };
141
+ shell.on('turboChange', onTurbo);
142
+ shell.on('settingChange', onSetting);
143
+
144
+ return () => {
145
+ shell.off('turboChange', onTurbo);
146
+ shell.off('settingChange', onSetting);
147
+ };
148
+ }
@@ -225,8 +225,13 @@ function paytableSection(model: GameModel, t: (s: string) => string = (s) => s):
225
225
  return { type: 'paytable', rows };
226
226
  }
227
227
 
228
- /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
229
- function winsSection(model: GameModel): GameInfoSection {
228
+ /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint.
229
+ *
230
+ * Every mechanic that has an illustration names itself. An unrecognised one renders NOTHING
231
+ * rather than borrowing `anywhere`'s picture: the section exists to show the player how wins
232
+ * form, and a drawing of the wrong mechanic teaches them something false. Silence is the honest
233
+ * answer — the rest of the info screen (paytable, modes, controls) still renders. */
234
+ function winsSection(model: GameModel): GameInfoSection | null {
230
235
  const { cols, rows } = model.spec.grid;
231
236
  const grid = { cols, rows };
232
237
  switch (model.spec.mechanic) {
@@ -234,8 +239,10 @@ function winsSection(model: GameModel): GameInfoSection {
234
239
  return { type: 'wins', kind: 'cluster', minCount: 5, grid } as GameInfoSection;
235
240
  case 'ways':
236
241
  return { type: 'wins', kind: 'ways', grid } as GameInfoSection;
237
- default:
242
+ case 'anywhere':
238
243
  return { type: 'wins', kind: 'anywhere', minCount: 3, grid } as GameInfoSection;
244
+ default:
245
+ return null;
239
246
  }
240
247
  }
241
248
 
@@ -290,7 +297,8 @@ export function defaultGameInfo(
290
297
  tDisclaimer: (s: string) => string = t,
291
298
  ): GameInfoContent {
292
299
  const sections: GameInfoSection[] = [];
293
- sections.push(winsSection(model));
300
+ const wins = winsSection(model);
301
+ if (wins) sections.push(wins);
294
302
  const pay = paytableSection(model, t);
295
303
  if (pay) sections.push(pay);
296
304
  const modes = modesSection(model, t);
package/src/host/types.ts CHANGED
@@ -7,6 +7,7 @@ import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../t
7
7
  import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
8
  import type { GameApplication } from '../core';
9
9
  import type { SlotShellOptions } from './shellConfig';
10
+ import type { PersistSettingsOptions } from './settingsStore';
10
11
  import type {
11
12
  SlotSpinResultBase,
12
13
  SlotResultNormalizer,
@@ -148,6 +149,12 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
148
149
  * renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas).
149
150
  * Default: the built-in Pixi shell (`createPixiShell`). */
150
151
  shellFactory?: ShellFactory;
152
+ /** Remember the player's turbo level and menu values (sound, music, sfx, custom rows) in
153
+ * `localStorage`, scoped to this game. Off by default — a game that doesn't ask touches no
154
+ * storage at all. `true` keys on the game id; pass an object to set the key or supply the
155
+ * store. Restored values are still subject to jurisdiction limits, and unreadable or tampered
156
+ * storage degrades to "don't persist" rather than failing the boot. */
157
+ persistSettings?: boolean | PersistSettingsOptions;
151
158
  /** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
152
159
  * to disable the gesture (e.g. games where a tap means something else). */
153
160
  skipGesture?: boolean;