@open-slot-ui/core 0.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,2271 @@
1
+ // src/signal.ts
2
+ var Signal = class {
3
+ _value;
4
+ subs = /* @__PURE__ */ new Set();
5
+ constructor(initial) {
6
+ this._value = initial;
7
+ }
8
+ get() {
9
+ return this._value;
10
+ }
11
+ set(next) {
12
+ if (Object.is(next, this._value)) return;
13
+ this._value = next;
14
+ for (const fn of [...this.subs]) fn(next);
15
+ }
16
+ /** Mutate-in-place then notify (for objects whose identity is stable). */
17
+ update(mutate) {
18
+ mutate(this._value);
19
+ for (const fn of [...this.subs]) fn(this._value);
20
+ }
21
+ subscribe(fn) {
22
+ this.subs.add(fn);
23
+ return () => {
24
+ this.subs.delete(fn);
25
+ };
26
+ }
27
+ get size() {
28
+ return this.subs.size;
29
+ }
30
+ };
31
+ function effect(fn, deps) {
32
+ const run = () => fn();
33
+ const disposers = deps.map((d) => d.subscribe(run));
34
+ run();
35
+ return () => {
36
+ for (const d of disposers) d();
37
+ };
38
+ }
39
+
40
+ // src/events.ts
41
+ var EventBus = class {
42
+ map = /* @__PURE__ */ new Map();
43
+ on(type, fn) {
44
+ let set = this.map.get(type);
45
+ if (!set) {
46
+ set = /* @__PURE__ */ new Set();
47
+ this.map.set(type, set);
48
+ }
49
+ const wrapped = fn;
50
+ set.add(wrapped);
51
+ return () => {
52
+ set?.delete(wrapped);
53
+ };
54
+ }
55
+ emit(type, payload) {
56
+ const set = this.map.get(type);
57
+ if (!set) return;
58
+ for (const fn of [...set]) fn(payload);
59
+ }
60
+ };
61
+
62
+ // src/theme/tokens.ts
63
+ var defaultTheme = {
64
+ color: {
65
+ accent: "#ffc935",
66
+ accentText: "#1a1a1a",
67
+ surface: "#161b22",
68
+ surfaceAlt: "#0b0e13",
69
+ text: "#ffffff",
70
+ textDim: "#7a8290",
71
+ disabled: "#3a3f47"
72
+ },
73
+ radius: { pill: 999, card: 16 },
74
+ space: { sm: 8, md: 16, lg: 24 },
75
+ type: { family: "system-ui, sans-serif", size: { sm: 14, md: 18, lg: 28 } },
76
+ motion: { fast: 120, base: 200, slow: 360 }
77
+ };
78
+ function isObject(v) {
79
+ return typeof v === "object" && v !== null && !Array.isArray(v);
80
+ }
81
+ function merge(base, patch) {
82
+ const out = { ...base };
83
+ for (const key of Object.keys(patch)) {
84
+ const pv = patch[key];
85
+ const bv = base[key];
86
+ out[key] = isObject(pv) && isObject(bv) ? merge(bv, pv) : pv;
87
+ }
88
+ return out;
89
+ }
90
+ function extendTheme(base, patch) {
91
+ return merge(base, patch);
92
+ }
93
+
94
+ // src/layout/screen.ts
95
+ var defaultLayoutConfig = {
96
+ refLandscape: [1920, 1080],
97
+ refPortrait: [1080, 1920],
98
+ portraitBelowAspect: 0.85,
99
+ breakpoints: { mobile: 480, tablet: 840 }
100
+ };
101
+ function breakpointFor(width, height, cfg) {
102
+ const short = Math.min(Math.max(width, 1), Math.max(height, 1));
103
+ if (short <= cfg.breakpoints.mobile) return "mobile";
104
+ if (short <= cfg.breakpoints.tablet) return "tablet";
105
+ return "desktop";
106
+ }
107
+ function computeScreen(width, height, cfg) {
108
+ const w = Math.max(width, 1);
109
+ const h = Math.max(height, 1);
110
+ const orientation = w / h < cfg.portraitBelowAspect ? "portrait" : "landscape";
111
+ const [rw, rh] = orientation === "portrait" ? cfg.refPortrait : cfg.refLandscape;
112
+ const scale = Math.min(w / rw, h / rh);
113
+ return { width: w, height: h, orientation, breakpoint: breakpointFor(w, h, cfg), scale };
114
+ }
115
+
116
+ // src/layout/anchor.ts
117
+ function anchorFactors(anchor) {
118
+ const ax = anchor.includes("left") ? 0 : anchor.includes("right") ? 1 : 0.5;
119
+ const ay = anchor.includes("top") ? 0 : anchor.includes("bottom") ? 1 : 0.5;
120
+ return [ax, ay];
121
+ }
122
+ function resolvePlacement(spec, screen) {
123
+ const [ax, ay] = anchorFactors(spec.anchor);
124
+ const [ox, oy] = spec.offset ?? [0, 0];
125
+ return {
126
+ x: ax * screen.width + ox * screen.scale,
127
+ y: ay * screen.height + oy * screen.scale,
128
+ scale: screen.scale * (spec.scale ?? 1),
129
+ rotation: (spec.rotation ?? 0) * Math.PI / 180
130
+ };
131
+ }
132
+
133
+ // src/transition/Transition.ts
134
+ var Squish = class {
135
+ constructor(scale = 0.92, ms = 120) {
136
+ this.scale = scale;
137
+ this.ms = ms;
138
+ }
139
+ scale;
140
+ ms;
141
+ kind = "squish";
142
+ };
143
+ var Pulse = class {
144
+ constructor(scale = 1.06, ms = 160) {
145
+ this.scale = scale;
146
+ this.ms = ms;
147
+ }
148
+ scale;
149
+ ms;
150
+ kind = "pulse";
151
+ };
152
+ var Turn = class {
153
+ /** Rotations per second; loops until the state changes. */
154
+ constructor(rps = 1, loop = true) {
155
+ this.rps = rps;
156
+ this.loop = loop;
157
+ }
158
+ rps;
159
+ loop;
160
+ kind = "turn";
161
+ };
162
+ var Fade = class {
163
+ constructor(alpha = 0.4, ms = 160) {
164
+ this.alpha = alpha;
165
+ this.ms = ms;
166
+ }
167
+ alpha;
168
+ ms;
169
+ kind = "fade";
170
+ };
171
+ var Sequence = class {
172
+ constructor(steps) {
173
+ this.steps = steps;
174
+ }
175
+ steps;
176
+ kind = "sequence";
177
+ };
178
+ var Parallel = class {
179
+ constructor(steps) {
180
+ this.steps = steps;
181
+ }
182
+ steps;
183
+ kind = "parallel";
184
+ };
185
+
186
+ // src/control/Control.ts
187
+ var Control = class {
188
+ id;
189
+ role;
190
+ layout;
191
+ /** Current state name, observable. */
192
+ state;
193
+ /** The renderer sets this so `inspect()` can report bounds/animation. */
194
+ viewInspect;
195
+ /**
196
+ * Optional host gate (e.g. a ref-counted input lock). When set, the control is
197
+ * interactable only if its state allows it AND the gate returns true. Set per
198
+ * instance — by OpenUI on register — so multiple HUDs on one page never share
199
+ * lock state. Interactability stays *derived*, never stored (Charter P6).
200
+ */
201
+ gate;
202
+ disposers = [];
203
+ transitionSubs = /* @__PURE__ */ new Set();
204
+ constructor(opts, initial) {
205
+ this.id = opts.id;
206
+ this.role = opts.role;
207
+ this.layout = opts.layout;
208
+ this.state = new Signal(initial);
209
+ }
210
+ get current() {
211
+ return this.state.get();
212
+ }
213
+ /** Derived, never stored (Charter P6). Folds in the optional host gate. */
214
+ get interactable() {
215
+ const base = this.states[this.current]?.interactable ?? false;
216
+ return base && (this.gate?.() ?? true);
217
+ }
218
+ /** Transition to a new state. Throws on unknown states (illegal states unrepresentable). */
219
+ setState(to) {
220
+ if (!(to in this.states)) {
221
+ throw new Error(`[open-ui] control "${this.id}": unknown state "${to}"`);
222
+ }
223
+ const from = this.current;
224
+ if (to === from) return;
225
+ this.state.set(to);
226
+ const t = this.states[to]?.transition;
227
+ for (const fn of [...this.transitionSubs]) fn(t, to, from);
228
+ }
229
+ /** The renderer subscribes to play the entry transition for each state change. */
230
+ onTransition(fn) {
231
+ this.transitionSubs.add(fn);
232
+ return () => {
233
+ this.transitionSubs.delete(fn);
234
+ };
235
+ }
236
+ inspect() {
237
+ return this.viewInspect?.() ?? { bounds: null, animating: false };
238
+ }
239
+ /** Leak-free teardown (Charter P12). */
240
+ dispose() {
241
+ for (const d of this.disposers) d();
242
+ this.disposers.length = 0;
243
+ this.transitionSubs.clear();
244
+ this.viewInspect = void 0;
245
+ }
246
+ };
247
+
248
+ // src/controls/SpinControl.ts
249
+ var SpinControl = class extends Control {
250
+ constructor(opts, bus) {
251
+ super({ id: opts.id ?? "spin", role: "button", layout: opts.layout }, "idle");
252
+ this.bus = bus;
253
+ this.holdToSpin = opts.holdToSpin ?? false;
254
+ }
255
+ bus;
256
+ states = {
257
+ idle: { interactable: true, transition: new Squish(0.94, 120) },
258
+ hover: { interactable: true, transition: new Pulse(1.06, 160) },
259
+ pressed: { interactable: true, transition: new Squish(0.86, 70) },
260
+ // Spinning no longer rotates — the button just squishes on press and sits.
261
+ spinning: { interactable: false, transition: new Squish(0.94, 120) },
262
+ stop: { interactable: true, transition: new Pulse(1.08, 140) },
263
+ auto: { interactable: true, transition: new Pulse(1.06, 160) },
264
+ disabled: { interactable: false, transition: new Fade(0.4, 160) }
265
+ };
266
+ /**
267
+ * When true, the view arms press-and-hold: holding the spin button fires a
268
+ * `holdSpinStarted` and the host turbo-spins until release (`holdSpinStopped`).
269
+ * A quick tap still spins once. Toggleable via the `spin.press` config.
270
+ */
271
+ holdToSpin = false;
272
+ /**
273
+ * Whether tapping mid-spin slam-stops/skips the reels. Stake Engine's
274
+ * `disabledSlamstop` jurisdiction flag sets this false → the button then LOCKS
275
+ * (dims, no tap) during a spin instead of skipping. Observable so the view can
276
+ * re-dim if it flips at runtime (e.g. on `applyJurisdiction`).
277
+ */
278
+ allowSlamStop = new Signal(true);
279
+ /**
280
+ * Whether the keyboard (Space / Enter) can trigger a spin. Stake's
281
+ * `disabledSpacebar` sets this false (and turns off hold-to-spin). The renderer
282
+ * reads it; RTS 14D requires a fresh press per cycle (no auto-repeat).
283
+ */
284
+ allowKeyboard = new Signal(true);
285
+ /**
286
+ * Remaining free spins (0 = normal play). Set it > 0 and the spin button switches
287
+ * to its free-spins face — a big remaining count over an "FS" label — via the view;
288
+ * back to 0 returns the normal play button. The host drives the loop; this is just
289
+ * the display. Set with {@link setFreeSpins}.
290
+ */
291
+ freeSpins = new Signal(0);
292
+ /**
293
+ * Derived (Charter P6), with the slam-stop guard folded in: when slam-stop is
294
+ * disabled, the in-spin `stop`/`auto` affordance is locked — the button can't be
295
+ * tapped to skip; it just dims.
296
+ */
297
+ get interactable() {
298
+ if (!this.allowSlamStop.get() && (this.current === "auto" || this.current === "stop")) return false;
299
+ return super.interactable;
300
+ }
301
+ // ---- façade (the game talks to these) ----
302
+ busy() {
303
+ this.setState("spinning");
304
+ }
305
+ idle() {
306
+ this.setState("idle");
307
+ }
308
+ stopState() {
309
+ this.setState("stop");
310
+ }
311
+ /** Show the autoplay-engaged look (press to stop). */
312
+ auto() {
313
+ this.setState("auto");
314
+ }
315
+ enable() {
316
+ if (this.current === "disabled") this.setState("idle");
317
+ }
318
+ disable() {
319
+ this.setState("disabled");
320
+ }
321
+ // ---- input (the view calls this on a valid press-release) ----
322
+ activate() {
323
+ if (this.current === "auto") {
324
+ if (this.allowSlamStop.get()) this.bus.emit("skipRequested", void 0);
325
+ return;
326
+ }
327
+ if (this.interactable) this.bus.emit("spinRequested", void 0);
328
+ }
329
+ /** View calls this when a press is held past the hold threshold. Returns whether
330
+ * hold-to-spin engaged (so the view knows to wait for release). */
331
+ holdBegin() {
332
+ if (!this.holdToSpin || !this.interactable) return false;
333
+ this.bus.emit("holdSpinStarted", void 0);
334
+ return true;
335
+ }
336
+ /** View calls this on release after a hold — the host stops the turbo loop. */
337
+ holdEnd() {
338
+ this.bus.emit("holdSpinStopped", void 0);
339
+ }
340
+ /** Switch the button to / from its free-spins face. `n > 0` shows "n FS"; `0`
341
+ * restores the normal play button. Non-number/negative is clamped to 0 (P11). */
342
+ setFreeSpins(n) {
343
+ this.freeSpins.set(typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0);
344
+ }
345
+ };
346
+
347
+ // src/safe.ts
348
+ function safeAmount(n, lastGood) {
349
+ return typeof n === "number" && Number.isFinite(n) ? n : lastGood;
350
+ }
351
+ function clampDecimals(d) {
352
+ if (typeof d !== "number" || !Number.isFinite(d)) return 2;
353
+ return Math.max(0, Math.min(8, Math.trunc(d)));
354
+ }
355
+ function clampDigits(n) {
356
+ if (typeof n !== "number" || !Number.isFinite(n)) return 9;
357
+ return Math.max(1, Math.min(18, Math.trunc(n)));
358
+ }
359
+ function clampMinDigits(n) {
360
+ if (typeof n !== "number" || !Number.isFinite(n)) return 0;
361
+ return Math.max(0, Math.min(18, Math.trunc(n)));
362
+ }
363
+ function integerDigits(value) {
364
+ if (typeof value !== "number" || !Number.isFinite(value)) return 1;
365
+ return String(Math.floor(Math.abs(value))).length;
366
+ }
367
+ function displayDigits(value, minTotal, decimals) {
368
+ const d = clampDecimals(decimals);
369
+ const min = clampMinDigits(minTotal);
370
+ return Math.min(18, Math.max(min, integerDigits(value) + d, d + 1));
371
+ }
372
+ function valueFitMaxWidth(digits, columnWidth) {
373
+ return clampDigits(digits) * columnWidth + 64;
374
+ }
375
+
376
+ // src/controls/ValueDisplay.ts
377
+ var ValueDisplay = class extends Control {
378
+ states = { idle: { interactable: false } };
379
+ value = new Signal(0);
380
+ currency;
381
+ label;
382
+ /** Minimum odometer column width (0 = auto: sized tightly to the value, grows as it
383
+ * does). Read by the renderer at build time, so set it before mount (or declaratively
384
+ * via `controls.{id}.digits`). */
385
+ digits;
386
+ constructor(opts) {
387
+ super({ id: opts.id, role: "status", layout: opts.layout }, "idle");
388
+ const c = opts.currency;
389
+ this.currency = new Signal(
390
+ c && typeof c.code === "string" ? { ...c, decimals: clampDecimals(c.decimals) } : { code: "USD", decimals: 2 }
391
+ );
392
+ this.label = opts.label;
393
+ this.digits = clampMinDigits(opts.digits ?? 0);
394
+ if (opts.initial != null) this.value.set(opts.initial);
395
+ }
396
+ /** Set the minimum odometer column width (clamped 0..18; 0 = auto). Read by the
397
+ * renderer at build time, so set it before mount (or via `controls.{id}.digits`). */
398
+ setDigits(n) {
399
+ this.digits = clampMinDigits(n);
400
+ }
401
+ /** Never-reject: NaN/Infinity/non-number degrade to a no-op, keeping the last
402
+ * good value — malformed host data never throws into the render loop (P11). */
403
+ set(amount) {
404
+ this.value.set(safeAmount(amount, this.value.get()));
405
+ }
406
+ get() {
407
+ return this.value.get();
408
+ }
409
+ /** Keep the prior spec on malformed input; clamp decimals to 0..8 (P11). */
410
+ setCurrency(spec) {
411
+ if (!spec || typeof spec.code !== "string") return;
412
+ this.currency.set({ ...spec, decimals: clampDecimals(spec.decimals) });
413
+ }
414
+ /** Integer minor units for the counter (no float drift). */
415
+ get minorUnits() {
416
+ return Math.round(this.value.get() * Math.pow(10, this.currency.get().decimals));
417
+ }
418
+ };
419
+
420
+ // src/controls/ButtonControl.ts
421
+ var ButtonControl = class extends Control {
422
+ constructor(opts, bus) {
423
+ super({ id: opts.id, role: opts.role ?? "button", layout: opts.layout }, "idle");
424
+ this.bus = bus;
425
+ this.label = opts.label;
426
+ }
427
+ bus;
428
+ states = {
429
+ idle: { interactable: true, transition: new Squish(0.94, 110) },
430
+ hover: { interactable: true, transition: new Pulse(1.05, 140) },
431
+ pressed: { interactable: true, transition: new Squish(0.88, 70) },
432
+ disabled: { interactable: false, transition: new Fade(0.4, 150) }
433
+ };
434
+ label;
435
+ enable() {
436
+ if (this.current === "disabled") this.setState("idle");
437
+ }
438
+ disable() {
439
+ this.setState("disabled");
440
+ }
441
+ /** The view calls this on a valid press-release. */
442
+ activate() {
443
+ if (this.interactable) this.bus?.emit("buttonActivated", { id: this.id });
444
+ }
445
+ };
446
+
447
+ // src/controls/SliderControl.ts
448
+ var clamp01 = (v) => Math.min(1, Math.max(0, v));
449
+ var SliderControl = class extends Control {
450
+ constructor(opts, bus) {
451
+ super({ id: opts.id, role: "slider", layout: opts.layout }, "idle");
452
+ this.bus = bus;
453
+ this.label = opts.label;
454
+ if (opts.initial != null) this.value.set(clamp01(opts.initial));
455
+ }
456
+ bus;
457
+ states = {
458
+ idle: { interactable: true },
459
+ dragging: { interactable: true }
460
+ };
461
+ value = new Signal(0.5);
462
+ label;
463
+ /** Set the normalized value (0..1) and notify. */
464
+ setNormalized(v) {
465
+ const c = clamp01(v);
466
+ if (c === this.value.get()) return;
467
+ this.value.set(c);
468
+ this.bus?.emit("valueChanged", { id: this.id, value: c });
469
+ }
470
+ beginDrag() {
471
+ this.setState("dragging");
472
+ }
473
+ endDrag() {
474
+ this.setState("idle");
475
+ }
476
+ };
477
+
478
+ // src/controls/PanelControl.ts
479
+ var PanelControl = class extends Control {
480
+ constructor(opts, bus) {
481
+ super({ id: opts.id, role: "dialog", layout: opts.layout ?? { anchor: "center" } }, "closed");
482
+ this.bus = bus;
483
+ this.variant = opts.variant;
484
+ this.title = opts.title;
485
+ }
486
+ bus;
487
+ states = {
488
+ closed: { interactable: false },
489
+ open: { interactable: true }
490
+ };
491
+ variant;
492
+ title;
493
+ get isOpen() {
494
+ return this.current === "open";
495
+ }
496
+ openPanel() {
497
+ if (this.isOpen) return;
498
+ this.setState("open");
499
+ this.bus?.emit("panelToggled", { id: this.id, open: true });
500
+ }
501
+ closePanel() {
502
+ if (!this.isOpen) return;
503
+ this.setState("closed");
504
+ this.bus?.emit("panelToggled", { id: this.id, open: false });
505
+ }
506
+ toggle() {
507
+ if (this.isOpen) this.closePanel();
508
+ else this.openPanel();
509
+ }
510
+ };
511
+
512
+ // src/controls/ToggleControl.ts
513
+ var ToggleControl = class extends Control {
514
+ constructor(opts, bus) {
515
+ super({ id: opts.id, role: "switch", layout: opts.layout }, opts.on ? "on" : "off");
516
+ this.bus = bus;
517
+ }
518
+ bus;
519
+ states = {
520
+ off: { interactable: true, transition: new Squish(0.92, 110) },
521
+ on: { interactable: true, transition: new Pulse(1.08, 150) },
522
+ disabled: { interactable: false, transition: new Fade(0.4, 150) }
523
+ };
524
+ get isOn() {
525
+ return this.current === "on";
526
+ }
527
+ set(on) {
528
+ const next = on ? "on" : "off";
529
+ if (next === this.current) return;
530
+ this.setState(next);
531
+ this.bus?.emit("toggled", { id: this.id, on });
532
+ }
533
+ /** Called by the view on a valid press-release. */
534
+ toggle() {
535
+ if (this.current === "disabled") return;
536
+ this.set(!this.isOn);
537
+ }
538
+ enable() {
539
+ if (this.current === "disabled") this.setState("off");
540
+ }
541
+ disable() {
542
+ this.setState("disabled");
543
+ }
544
+ };
545
+
546
+ // src/controls/TurboControl.ts
547
+ var TurboControl = class _TurboControl extends Control {
548
+ constructor(opts, bus) {
549
+ const modes = opts.modes?.length ? opts.modes.slice() : ["off", "on"];
550
+ const start = clampIndex(opts.index ?? 0, modes.length);
551
+ super({ id: opts.id, role: "switch", layout: opts.layout }, modes[start]);
552
+ this.bus = bus;
553
+ this._modes = modes;
554
+ this.states = _TurboControl.buildStates(modes);
555
+ this.index = new Signal(start);
556
+ }
557
+ bus;
558
+ states;
559
+ _modes;
560
+ /** Current mode index, observable (0 = off). */
561
+ index;
562
+ static buildStates(modes) {
563
+ const states = {};
564
+ modes.forEach((m, i) => {
565
+ states[m] = { interactable: true, transition: i === 0 ? new Squish(0.92, 110) : new Pulse(1.06 + i * 0.02, 150) };
566
+ });
567
+ states.disabled = { interactable: false, transition: new Fade(0.4, 150) };
568
+ return states;
569
+ }
570
+ /** The ordered modes (the first is "off"). */
571
+ get modes() {
572
+ return this._modes;
573
+ }
574
+ /** Number of modes (2 = toggle, 3 = three-mode switcher). */
575
+ get modeCount() {
576
+ return this._modes.length;
577
+ }
578
+ /** The current mode name. */
579
+ get mode() {
580
+ return this.current;
581
+ }
582
+ /** True for any mode past "off" — the 2-mode `isOn` semantics, preserved. */
583
+ get isOn() {
584
+ return this.current !== "disabled" && this.index.get() > 0;
585
+ }
586
+ /** Replace the mode ladder (e.g. switch a game from 2-mode to 3-mode at runtime). */
587
+ setModes(modes) {
588
+ if (!modes.length) return;
589
+ this._modes = modes.slice();
590
+ this.states = _TurboControl.buildStates(this._modes);
591
+ this.goTo(clampIndex(this.index.get(), this._modes.length), false);
592
+ }
593
+ /** Advance to the next mode, wrapping back to "off". The view's tap handler. */
594
+ cycle() {
595
+ if (this.current === "disabled") return;
596
+ this.goTo((this.index.get() + 1) % this._modes.length);
597
+ }
598
+ /** Jump to a specific mode index (clamped). */
599
+ setIndex(i) {
600
+ if (this.current === "disabled") return;
601
+ this.goTo(clampIndex(i, this._modes.length));
602
+ }
603
+ /** 2-mode convenience: off (index 0) or on (index 1). */
604
+ set(on) {
605
+ this.goTo(on ? Math.min(1, this._modes.length - 1) : 0);
606
+ }
607
+ /** Alias kept so old `toggle()` callers compile — same as `cycle()`. */
608
+ toggle() {
609
+ this.cycle();
610
+ }
611
+ goTo(i, emit = true) {
612
+ const next = this._modes[i];
613
+ const changed = next !== this.current || i !== this.index.get();
614
+ this.index.set(i);
615
+ this.setState(next);
616
+ if (emit && changed) {
617
+ this.bus?.emit("turboChanged", { id: this.id, mode: next, index: i });
618
+ this.bus?.emit("toggled", { id: this.id, on: i > 0 });
619
+ }
620
+ }
621
+ enable() {
622
+ if (this.current === "disabled") this.goTo(this.index.get(), false);
623
+ }
624
+ disable() {
625
+ this.setState("disabled");
626
+ }
627
+ };
628
+ function clampIndex(i, len) {
629
+ if (!Number.isFinite(i)) return 0;
630
+ return Math.max(0, Math.min(Math.floor(i), len - 1));
631
+ }
632
+
633
+ // src/controls/StepperControl.ts
634
+ var StepperControl = class extends Control {
635
+ constructor(opts, bus) {
636
+ super({ id: opts.id, role: "spinbutton", layout: opts.layout }, "idle");
637
+ this.bus = bus;
638
+ this.levels = opts.levels.length ? opts.levels : [0];
639
+ this.index = new Signal(this.clampIndex(opts.index ?? 0));
640
+ this.label = opts.label;
641
+ }
642
+ bus;
643
+ states = { idle: { interactable: true } };
644
+ index;
645
+ /** Optional caption (e.g. "Bet"). */
646
+ label;
647
+ levels;
648
+ clampIndex(i) {
649
+ return Math.max(0, Math.min(this.levels.length - 1, i));
650
+ }
651
+ get value() {
652
+ return this.levels[this.index.get()] ?? 0;
653
+ }
654
+ get canInc() {
655
+ return this.index.get() < this.levels.length - 1;
656
+ }
657
+ get canDec() {
658
+ return this.index.get() > 0;
659
+ }
660
+ inc() {
661
+ if (this.canInc) this.setIndex(this.index.get() + 1);
662
+ }
663
+ dec() {
664
+ if (this.canDec) this.setIndex(this.index.get() - 1);
665
+ }
666
+ setIndex(i) {
667
+ const next = this.clampIndex(i);
668
+ if (next === this.index.get()) return;
669
+ this.index.set(next);
670
+ this.bus?.emit("valueChanged", { id: this.id, value: this.value });
671
+ }
672
+ /** Replace the level list (e.g. when the currency/limits change). */
673
+ setLevels(levels, index = 0) {
674
+ const prior = this.value;
675
+ if (levels.length) this.levels = levels;
676
+ this.index.set(this.clampIndex(index));
677
+ if (this.value !== prior) this.bus?.emit("valueChanged", { id: this.id, value: this.value });
678
+ }
679
+ };
680
+
681
+ // src/controls/AutoplayControl.ts
682
+ var AutoplayControl = class extends Control {
683
+ constructor(opts, bus) {
684
+ super({ id: opts.id, role: "button", layout: opts.layout }, "idle");
685
+ this.bus = bus;
686
+ this._options = opts.options ?? [10, 25, 50, 100, Infinity];
687
+ this._lossLimitOptions = opts.lossLimitOptions ?? [];
688
+ this._winLimitOptions = opts.winLimitOptions ?? [];
689
+ this.mode = opts.mode ?? "options";
690
+ this.count = new Signal(0);
691
+ }
692
+ bus;
693
+ states = {
694
+ idle: { interactable: true, transition: new Squish(0.94, 110) },
695
+ picking: { interactable: true },
696
+ active: { interactable: true, transition: new Pulse(1.06, 150) },
697
+ disabled: { interactable: false, transition: new Fade(0.4, 150) }
698
+ };
699
+ /** Remaining spins while active (Infinity = ∞). 0 when not active. */
700
+ count;
701
+ _options;
702
+ _lossLimitOptions;
703
+ _winLimitOptions;
704
+ /** Active stop-on-total-loss (multiples of bet; Infinity = no limit). */
705
+ lossLimit = Infinity;
706
+ /** Active stop-on-single-win (multiples of bet; Infinity = no limit). */
707
+ singleWinLimit = Infinity;
708
+ /** Running net loss this autoplay session (bet − win, summed). */
709
+ netLoss = 0;
710
+ /** Tap behavior (`'options'` opens the picker, `'infinite'` starts immediately). */
711
+ mode;
712
+ /** Count choices shown in the picker (Infinity allowed = ∞). */
713
+ get options() {
714
+ return this._options;
715
+ }
716
+ /** Loss-limit choices for the picker (multiples of bet; Infinity = no limit). */
717
+ get lossLimitOptions() {
718
+ return this._lossLimitOptions;
719
+ }
720
+ /** Single-win-stop choices for the picker (multiples of bet; Infinity = no limit). */
721
+ get winLimitOptions() {
722
+ return this._winLimitOptions;
723
+ }
724
+ /** Replace the picker's count choices (e.g. when limits change). */
725
+ setOptions(options) {
726
+ if (options.length) this._options = options.slice();
727
+ }
728
+ /** Replace the picker's loss-limit / single-win-stop choices. */
729
+ setLossLimitOptions(options) {
730
+ this._lossLimitOptions = Array.isArray(options) ? options.slice() : [];
731
+ }
732
+ setWinLimitOptions(options) {
733
+ this._winLimitOptions = Array.isArray(options) ? options.slice() : [];
734
+ }
735
+ get isActive() {
736
+ return this.current === "active";
737
+ }
738
+ openPicker() {
739
+ if (this.current === "idle") this.setState("picking");
740
+ }
741
+ cancelPicker() {
742
+ if (this.current === "picking") this.setState("idle");
743
+ }
744
+ /** Choose a count (+ optional RG limits) from the picker → start autoplay. */
745
+ pick(count, limits = {}) {
746
+ if (this.current !== "picking") return;
747
+ this.begin(count, limits);
748
+ }
749
+ /** Start autoplay with a count (Infinity = ∞) + optional RG limits, from idle or
750
+ * the picker. The limits are enforced as the host reports each round's outcome
751
+ * via {@link reportResult}. */
752
+ begin(count, limits = {}) {
753
+ if (this.current !== "idle" && this.current !== "picking") return;
754
+ this.lossLimit = limits.lossLimit ?? Infinity;
755
+ this.singleWinLimit = limits.singleWinLimit ?? Infinity;
756
+ this.netLoss = 0;
757
+ this.count.set(count);
758
+ this.setState("active");
759
+ this.bus?.emit("autoplayStarted", { count, lossLimit: this.lossLimit, singleWinLimit: this.singleWinLimit });
760
+ }
761
+ /**
762
+ * The host reports one completed autoplay round's outcome (major units) — this is
763
+ * the responsible-gambling guardrail. It advances the remaining count and stops
764
+ * autoplay if: the count is exhausted, the cumulative net loss reaches the
765
+ * loss-limit, or this single win reaches the single-win-stop. No-op when autoplay
766
+ * isn't active or the inputs are malformed (Charter P11).
767
+ */
768
+ reportResult(win, bet) {
769
+ if (this.current !== "active") return;
770
+ if (!Number.isFinite(win) || !Number.isFinite(bet)) return;
771
+ this.netLoss += bet - win;
772
+ const c = this.count.get();
773
+ if (Number.isFinite(c)) this.count.set(Math.max(0, c - 1));
774
+ const winHit = Number.isFinite(this.singleWinLimit) && win >= this.singleWinLimit * bet;
775
+ const lossHit = Number.isFinite(this.lossLimit) && this.netLoss >= this.lossLimit * bet;
776
+ if (this.count.get() <= 0 || winHit || lossHit) this.stop();
777
+ }
778
+ stop() {
779
+ if (this.current !== "active") return;
780
+ this.count.set(0);
781
+ this.setState("idle");
782
+ this.bus?.emit("autoplayStopped", void 0);
783
+ }
784
+ /** Host updates the remaining count during play. Infinity is valid (∞); only
785
+ * NaN/non-number is rejected, so bad host data never corrupts the badge (P11). */
786
+ setCount(n) {
787
+ if (typeof n !== "number" || Number.isNaN(n)) return;
788
+ this.count.set(n);
789
+ }
790
+ /** The view's tap handler. In `'infinite'` mode, idle starts straight away;
791
+ * in `'options'` mode, idle opens the picker. Active always stops. */
792
+ press() {
793
+ if (this.current === "idle") {
794
+ if (this.mode === "infinite") this.begin(Infinity);
795
+ else this.openPicker();
796
+ } else if (this.current === "active") this.stop();
797
+ else if (this.current === "picking") this.cancelPicker();
798
+ }
799
+ disable() {
800
+ this.setState("disabled");
801
+ }
802
+ enable() {
803
+ if (this.current === "disabled") this.setState("idle");
804
+ }
805
+ };
806
+
807
+ // src/controls/ReadoutControl.ts
808
+ var ReadoutControl = class extends Control {
809
+ states = { idle: { interactable: false } };
810
+ value = new Signal(0);
811
+ /** Whether a `'duration'` readout is currently advancing. */
812
+ running = new Signal(false);
813
+ kind;
814
+ label;
815
+ decimals;
816
+ signed;
817
+ /** Present for the `'currency'` kind so the view can format + react to changes. */
818
+ currency;
819
+ constructor(opts) {
820
+ super({ id: opts.id, role: "status", layout: opts.layout }, "idle");
821
+ this.kind = opts.kind ?? "plain";
822
+ this.label = opts.label;
823
+ this.decimals = opts.decimals ?? (this.kind === "percent" ? 1 : 0);
824
+ this.signed = opts.signed ?? true;
825
+ if (opts.currency) this.currency = new Signal(opts.currency);
826
+ if (opts.value != null) this.value.set(safeAmount(opts.value, 0));
827
+ }
828
+ /** Never-reject: malformed input keeps the last good value (P11). */
829
+ set(v) {
830
+ this.value.set(safeAmount(v, this.value.get()));
831
+ }
832
+ get() {
833
+ return this.value.get();
834
+ }
835
+ setCurrency(spec) {
836
+ if (this.currency && spec && typeof spec.code === "string") this.currency.set(spec);
837
+ }
838
+ // ── duration / session timer ────────────────────────────────────────────────
839
+ /** Advance an elapsed-time readout by `deltaSec` (the renderer calls this each frame). */
840
+ tick(deltaSec) {
841
+ if (this.kind !== "duration" || !this.running.get()) return;
842
+ if (!Number.isFinite(deltaSec) || deltaSec <= 0) return;
843
+ this.value.set(this.value.get() + deltaSec);
844
+ }
845
+ start() {
846
+ this.running.set(true);
847
+ }
848
+ stop() {
849
+ this.running.set(false);
850
+ }
851
+ reset() {
852
+ this.value.set(0);
853
+ }
854
+ };
855
+
856
+ // src/controls/CardControl.ts
857
+ var CardControl = class extends Control {
858
+ constructor(opts, bus) {
859
+ super({ id: opts.id, role: "button", layout: opts.layout }, "idle");
860
+ this.bus = bus;
861
+ this.title = opts.title;
862
+ this.price = opts.price;
863
+ this.image = opts.image;
864
+ }
865
+ bus;
866
+ states = {
867
+ idle: { interactable: true, transition: new Squish(0.96, 110) },
868
+ hover: { interactable: true, transition: new Pulse(1.04, 140) },
869
+ pressed: { interactable: true, transition: new Squish(0.9, 70) },
870
+ disabled: { interactable: false, transition: new Fade(0.4, 150) }
871
+ };
872
+ title;
873
+ price;
874
+ image;
875
+ enable() {
876
+ if (this.current === "disabled") this.setState("idle");
877
+ }
878
+ disable() {
879
+ this.setState("disabled");
880
+ }
881
+ /** The view calls this on a valid press-release. */
882
+ activate() {
883
+ if (this.interactable) this.bus?.emit("cardActivated", { id: this.id });
884
+ }
885
+ };
886
+
887
+ // src/controls/SelectControl.ts
888
+ var SelectControl = class extends Control {
889
+ constructor(opts, bus) {
890
+ if (!opts.options || opts.options.length === 0) {
891
+ throw new Error(`[open-ui] select "${opts.id}": needs at least one option`);
892
+ }
893
+ super({ id: opts.id, role: opts.role ?? "listbox", layout: opts.layout }, "closed");
894
+ this.bus = bus;
895
+ this._options = opts.options.slice();
896
+ this.caption = opts.label;
897
+ this.index = new Signal(this.clamp(opts.index ?? 0));
898
+ }
899
+ bus;
900
+ states = {
901
+ closed: { interactable: true, transition: new Squish(0.96, 100) },
902
+ open: { interactable: true, transition: new Pulse(1.04, 140) },
903
+ disabled: { interactable: false, transition: new Fade(0.4, 150) }
904
+ };
905
+ index;
906
+ /** Caption (e.g. "Language") — distinct from the selected option's label. */
907
+ caption;
908
+ _options;
909
+ clamp(i) {
910
+ if (!Number.isFinite(i)) return 0;
911
+ return Math.max(0, Math.min(this._options.length - 1, Math.trunc(i)));
912
+ }
913
+ get options() {
914
+ return this._options;
915
+ }
916
+ get value() {
917
+ return this._options[this.index.get()]?.value ?? "";
918
+ }
919
+ get optionLabel() {
920
+ return this._options[this.index.get()]?.label ?? "";
921
+ }
922
+ get isOpen() {
923
+ return this.current === "open";
924
+ }
925
+ openList() {
926
+ if (this.current === "closed") this.setState("open");
927
+ }
928
+ closeList() {
929
+ if (this.current === "open") this.setState("closed");
930
+ }
931
+ /** Choose by value (the view's list-item handler). Closes the list. */
932
+ choose(value) {
933
+ const i = this._options.findIndex((o) => o.value === value);
934
+ if (i < 0) return;
935
+ if (i !== this.index.get()) {
936
+ this.index.set(i);
937
+ this.bus?.emit("optionSelected", { id: this.id, value: this.value, index: i });
938
+ }
939
+ if (this.current === "open") this.setState("closed");
940
+ }
941
+ /** Jump to an index (clamped). Emits only if it changed. */
942
+ setIndex(i) {
943
+ const next = this.clamp(i);
944
+ if (next === this.index.get()) return;
945
+ this.index.set(next);
946
+ this.bus?.emit("optionSelected", { id: this.id, value: this.value, index: next });
947
+ }
948
+ /** Advance to the next option, wrapping (the view's cycle-on-tap handler). */
949
+ cycle() {
950
+ if (this._options.length < 2) return;
951
+ this.setIndex((this.index.get() + 1) % this._options.length);
952
+ }
953
+ /** Replace the option list (e.g. when limits or locale change). */
954
+ setOptions(options, index = 0) {
955
+ if (!options.length) return;
956
+ this._options = options.slice();
957
+ this.index.set(this.clamp(index));
958
+ this.bus?.emit("optionSelected", { id: this.id, value: this.value, index: this.index.get() });
959
+ }
960
+ enable() {
961
+ if (this.current === "disabled") this.setState("closed");
962
+ }
963
+ disable() {
964
+ this.setState("disabled");
965
+ }
966
+ };
967
+
968
+ // src/theme/presets.ts
969
+ var themePresets = Object.freeze({
970
+ default: defaultTheme
971
+ });
972
+ var HEX = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
973
+ var RGB = /^rgba?\(\s*[0-9.\s,%/]+\)$/i;
974
+ function isSafeColor(v) {
975
+ return typeof v === "string" && (HEX.test(v.trim()) || RGB.test(v.trim()));
976
+ }
977
+ var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
978
+ function sanitizeThemeOverrides(ov, onIssue) {
979
+ const out = {};
980
+ if (ov.color) {
981
+ const color = {};
982
+ for (const [k, v] of Object.entries(ov.color)) {
983
+ if (isSafeColor(v)) color[k] = v.trim();
984
+ else onIssue?.({ level: "warn", path: `theme.color.${k}`, code: "bad-color", message: `"${String(v)}" is not a valid colour \u2014 kept the theme default` });
985
+ }
986
+ if (Object.keys(color).length) out.color = color;
987
+ }
988
+ if (ov.radius) {
989
+ const radius = {};
990
+ for (const [k, v] of Object.entries(ov.radius)) {
991
+ if (typeof v === "number" && Number.isFinite(v)) radius[k] = clamp(v, 0, 999);
992
+ else onIssue?.({ level: "warn", path: `theme.radius.${k}`, code: "bad-radius", message: `radius "${String(v)}" must be a finite number \u2014 ignored` });
993
+ }
994
+ if (Object.keys(radius).length) out.radius = radius;
995
+ }
996
+ if (ov.type?.family != null) {
997
+ if (typeof ov.type.family === "string" && ov.type.family.trim()) out.type = { family: ov.type.family };
998
+ else onIssue?.({ level: "warn", path: "theme.type.family", code: "bad-family", message: "font family must be a non-empty string \u2014 ignored" });
999
+ }
1000
+ return out;
1001
+ }
1002
+ function isPresetChoice(c) {
1003
+ return typeof c === "object" && c !== null && ("preset" in c || "overrides" in c);
1004
+ }
1005
+ function resolveTheme(choice, onIssue) {
1006
+ if (!choice) return defaultTheme;
1007
+ if (typeof choice === "string") return themePresets[choice] ?? defaultTheme;
1008
+ if (isPresetChoice(choice)) {
1009
+ const base = choice.preset ? themePresets[choice.preset] ?? defaultTheme : defaultTheme;
1010
+ if (!choice.overrides) return base;
1011
+ return Object.freeze(extendTheme(base, sanitizeThemeOverrides(choice.overrides, onIssue)));
1012
+ }
1013
+ return Object.freeze(extendTheme(defaultTheme, sanitizeThemeOverrides(choice, onIssue)));
1014
+ }
1015
+
1016
+ // src/registry/ControlRegistry.ts
1017
+ var CENTER = { anchor: "center" };
1018
+ var ControlRegistry = class _ControlRegistry {
1019
+ factories = /* @__PURE__ */ new Map();
1020
+ register(kind, factory) {
1021
+ this.factories.set(kind, factory);
1022
+ }
1023
+ has(kind) {
1024
+ return this.factories.has(kind);
1025
+ }
1026
+ /** Build the Control for a block, or null for non-control blocks / unknown kinds. */
1027
+ resolve(block, bus) {
1028
+ return this.factories.get(block.kind)?.(block, bus) ?? null;
1029
+ }
1030
+ /** A registry with every built-in block kind wired to its reference control. */
1031
+ static defaults() {
1032
+ const r = new _ControlRegistry();
1033
+ r.register("slider", (b, bus) => b.kind === "slider" ? new SliderControl({ id: b.id, layout: CENTER, label: b.label, initial: b.initial }, bus) : null);
1034
+ r.register("toggle", (b, bus) => b.kind === "toggle" ? new ToggleControl({ id: b.id, layout: CENTER, on: b.on }, bus) : null);
1035
+ r.register("button", (b, bus) => b.kind === "button" ? new ButtonControl({ id: b.id, layout: CENTER, label: b.label, role: b.role }, bus) : null);
1036
+ r.register("select", (b, bus) => b.kind === "select" ? new SelectControl({ id: b.id, layout: CENTER, options: b.options, index: b.index, label: b.label }, bus) : null);
1037
+ r.register("stepper", (b, bus) => b.kind === "stepper" ? new StepperControl({ id: b.id, layout: CENTER, levels: b.levels, index: b.index, label: b.label }, bus) : null);
1038
+ r.register("value", (b) => b.kind === "value" ? new ValueDisplay({ id: b.id, layout: CENTER, label: b.label, currency: b.currency ?? { code: "USD", decimals: 2 }, initial: b.initial }) : null);
1039
+ return r;
1040
+ }
1041
+ };
1042
+
1043
+ // src/spec/types.ts
1044
+ var BLOCK_KINDS = [
1045
+ "slider",
1046
+ "toggle",
1047
+ "button",
1048
+ "select",
1049
+ "stepper",
1050
+ "value",
1051
+ "text",
1052
+ "heading",
1053
+ "subheading",
1054
+ "callout",
1055
+ "stat-grid",
1056
+ "steps",
1057
+ "table",
1058
+ "paytable",
1059
+ "image",
1060
+ "media",
1061
+ "cards",
1062
+ "legal",
1063
+ "divider",
1064
+ "group"
1065
+ ];
1066
+
1067
+ // src/spec/validateSpec.ts
1068
+ var ANCHORS = /* @__PURE__ */ new Set([
1069
+ "top-left",
1070
+ "top-center",
1071
+ "top-right",
1072
+ "center-left",
1073
+ "center",
1074
+ "center-right",
1075
+ "bottom-left",
1076
+ "bottom-center",
1077
+ "bottom-right"
1078
+ ]);
1079
+ var AUTOPLAY_MODES = /* @__PURE__ */ new Set(["options", "infinite"]);
1080
+ var SPIN_PRESS = /* @__PURE__ */ new Set(["tap", "hold-to-spin"]);
1081
+ var RESPONSIVE_KEYS = /* @__PURE__ */ new Set(["mobile", "tablet", "desktop", "portrait", "landscape"]);
1082
+ function validateSpec(spec) {
1083
+ const issues = [];
1084
+ const add = (level, path, code, message) => {
1085
+ issues.push({ level, path, code, message });
1086
+ };
1087
+ const ids = /* @__PURE__ */ new Set();
1088
+ const seeId = (id, path) => {
1089
+ if (!id || !id.trim()) add("error", path, "blank-id", "control id must be a non-empty string");
1090
+ else if (ids.has(id)) add("error", path, "dup-id", `duplicate control id "${id}"`);
1091
+ else ids.add(id);
1092
+ };
1093
+ const checkLayout = (anchor, path) => {
1094
+ if (anchor && !ANCHORS.has(anchor)) add("error", path, "bad-anchor", `unknown anchor "${anchor}"`);
1095
+ };
1096
+ try {
1097
+ if (spec.betLadder) {
1098
+ const lv = spec.betLadder.levels;
1099
+ if (!Array.isArray(lv) || lv.length === 0) {
1100
+ add("error", "betLadder.levels", "empty-levels", "bet ladder needs at least one level");
1101
+ } else if (spec.betLadder.index != null && (spec.betLadder.index < 0 || spec.betLadder.index >= lv.length)) {
1102
+ add("error", "betLadder.index", "index-oor", `index ${spec.betLadder.index} is out of range 0..${lv.length - 1}`);
1103
+ }
1104
+ }
1105
+ if (spec.autoplay?.options) {
1106
+ spec.autoplay.options.forEach((o, i) => {
1107
+ if (!(typeof o === "number" && o > 0)) {
1108
+ add("error", `autoplay.options[${i}]`, "bad-option", `autoplay option must be > 0 (Infinity allowed), got ${String(o)}`);
1109
+ }
1110
+ });
1111
+ }
1112
+ if (spec.autoplay?.mode && !AUTOPLAY_MODES.has(spec.autoplay.mode)) {
1113
+ add("error", "autoplay.mode", "bad-mode", `autoplay.mode must be 'options' or 'infinite', got "${String(spec.autoplay.mode)}"`);
1114
+ }
1115
+ for (const key of ["lossLimits", "winLimits"]) {
1116
+ const arr = spec.autoplay?.[key];
1117
+ if (arr) {
1118
+ arr.forEach((o, i) => {
1119
+ if (!(typeof o === "number" && o > 0)) add("error", `autoplay.${key}[${i}]`, "bad-limit", `autoplay ${key} must be > 0 (Infinity allowed), got ${String(o)}`);
1120
+ });
1121
+ }
1122
+ }
1123
+ if (spec.turbo) {
1124
+ const m = spec.turbo.modes;
1125
+ if (Array.isArray(m)) {
1126
+ if (m.length < 2) add("error", "turbo.modes", "turbo-too-few", "a turbo ladder needs at least 2 modes (first = off)");
1127
+ if (m.some((x) => typeof x !== "string" || !x.trim())) add("error", "turbo.modes", "turbo-bad-mode", "turbo mode names must be non-empty strings");
1128
+ if (new Set(m).size !== m.length) add("error", "turbo.modes", "turbo-dup-mode", "turbo mode names must be unique");
1129
+ } else if (m != null && m !== 2 && m !== 3) {
1130
+ add("error", "turbo.modes", "turbo-bad-count", `turbo.modes preset must be 2 or 3, got ${String(m)}`);
1131
+ }
1132
+ const len = Array.isArray(m) ? m.length : m ?? 2;
1133
+ if (spec.turbo.index != null && (spec.turbo.index < 0 || spec.turbo.index >= len)) {
1134
+ add("error", "turbo.index", "index-oor", `turbo.index ${spec.turbo.index} is out of range 0..${len - 1}`);
1135
+ }
1136
+ }
1137
+ if (spec.spin?.press && !SPIN_PRESS.has(spec.spin.press)) {
1138
+ add("error", "spin.press", "bad-press", `spin.press must be 'tap' or 'hold-to-spin', got "${String(spec.spin.press)}"`);
1139
+ }
1140
+ if (spec.responsive) {
1141
+ for (const [key, ov] of Object.entries(spec.responsive)) {
1142
+ if (!RESPONSIVE_KEYS.has(key)) {
1143
+ add("error", `responsive.${key}`, "bad-bucket", `unknown responsive bucket "${key}" (expected ${[...RESPONSIVE_KEYS].join(", ")})`);
1144
+ }
1145
+ if (ov?.controls) {
1146
+ for (const [id, co] of Object.entries(ov.controls)) {
1147
+ checkLayout(co.layout?.anchor, `responsive.${key}.controls.${id}.layout.anchor`);
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ if (spec.currency && typeof spec.currency === "object" && typeof spec.currency.decimals === "number") {
1153
+ const d = spec.currency.decimals;
1154
+ if (d < 0 || d > 8) add("warn", "currency.decimals", "decimals-range", `decimals ${d} clamped to 0..8`);
1155
+ }
1156
+ if (spec.jurisdiction) {
1157
+ for (const [k, v] of Object.entries(spec.jurisdiction)) {
1158
+ if (k === "minimumRoundDuration") {
1159
+ if (v != null && !(typeof v === "number" && Number.isFinite(v) && v >= 0)) {
1160
+ add("error", `jurisdiction.${k}`, "bad-duration", `minimumRoundDuration must be a non-negative number, got ${String(v)}`);
1161
+ }
1162
+ } else if (v != null && typeof v !== "boolean") {
1163
+ add("error", `jurisdiction.${k}`, "bad-flag", `jurisdiction.${k} must be a boolean, got ${String(v)}`);
1164
+ }
1165
+ }
1166
+ }
1167
+ if (spec.rtp != null && !(typeof spec.rtp === "number" && Number.isFinite(spec.rtp))) {
1168
+ add("error", "rtp", "bad-rtp", `rtp must be a number, got ${String(spec.rtp)}`);
1169
+ }
1170
+ if (spec.statusBar != null && spec.statusBar !== "top" && spec.statusBar !== "bottom") {
1171
+ add("error", "statusBar", "bad-statusbar", `statusBar must be 'top' or 'bottom', got "${String(spec.statusBar)}"`);
1172
+ }
1173
+ if (spec.realityCheck != null && !(typeof spec.realityCheck.everyMinutes === "number" && spec.realityCheck.everyMinutes > 0)) {
1174
+ add("error", "realityCheck.everyMinutes", "bad-interval", "realityCheck.everyMinutes must be a number > 0");
1175
+ }
1176
+ if (spec.controls) {
1177
+ for (const [id, ov] of Object.entries(spec.controls)) {
1178
+ checkLayout(ov.layout?.anchor, `controls.${id}.layout.anchor`);
1179
+ if (ov.digits != null) {
1180
+ if (!(typeof ov.digits === "number" && Number.isFinite(ov.digits))) {
1181
+ add("error", `controls.${id}.digits`, "bad-digits", `digits must be a number, got ${String(ov.digits)}`);
1182
+ } else if (ov.digits < 0 || ov.digits > 18) {
1183
+ add("warn", `controls.${id}.digits`, "digits-range", `digits ${ov.digits} clamped to 0..18 (0 = auto)`);
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+ const walkBlocks = (blocks, base) => {
1189
+ blocks?.forEach((b, i) => {
1190
+ const p = `${base}[${i}]`;
1191
+ if (!BLOCK_KINDS.includes(b.kind)) {
1192
+ add("error", `${p}.kind`, "unknown-kind", `unknown block kind "${String(b.kind)}"`);
1193
+ return;
1194
+ }
1195
+ seeId(b.id, `${p}.id`);
1196
+ if (b.kind === "select" && (!b.options || b.options.length === 0)) {
1197
+ add("error", `${p}.options`, "select-empty", "a select block needs at least one option");
1198
+ }
1199
+ if (b.kind === "slider" && b.initial != null && (b.initial < 0 || b.initial > 1)) {
1200
+ add("error", `${p}.initial`, "slider-range", `slider initial ${b.initial} must be 0..1`);
1201
+ }
1202
+ if (b.kind === "stepper" && (!b.levels || b.levels.length === 0)) {
1203
+ add("error", `${p}.levels`, "empty-levels", "a stepper block needs at least one level");
1204
+ }
1205
+ if (b.kind === "stat-grid" && (!b.items || b.items.length === 0)) {
1206
+ add("warn", `${p}.items`, "empty-grid", "a stat-grid block has no items");
1207
+ }
1208
+ if (b.kind === "steps" && (!b.items || b.items.length === 0)) {
1209
+ add("warn", `${p}.items`, "empty-steps", "a steps block has no items");
1210
+ }
1211
+ if (b.kind === "paytable" && (!b.rows || b.rows.length === 0)) {
1212
+ add("warn", `${p}.rows`, "empty-paytable", "a paytable block has no rows");
1213
+ }
1214
+ if (b.kind === "image" && (!b.src || !b.src.trim())) {
1215
+ add("error", `${p}.src`, "image-src", "an image block needs a src");
1216
+ }
1217
+ if (b.kind === "image" && (b.width != null && b.width <= 0 || b.height != null && b.height <= 0)) {
1218
+ add("error", `${p}.width`, "image-dims", "image width/height must be > 0 when set");
1219
+ }
1220
+ if (b.kind === "media" && (!b.src || !b.src.trim())) {
1221
+ add("error", `${p}.src`, "image-src", "a media block needs an image src");
1222
+ }
1223
+ if (b.kind === "media" && (b.width != null && b.width <= 0 || b.height != null && b.height <= 0)) {
1224
+ add("error", `${p}.width`, "image-dims", "media width/height must be > 0 when set");
1225
+ }
1226
+ if (b.kind === "cards" && (!b.items || b.items.length === 0)) {
1227
+ add("warn", `${p}.items`, "empty-cards", "a cards block has no items");
1228
+ }
1229
+ if (b.kind === "table" && (!b.rows || b.rows.length === 0)) {
1230
+ add("warn", `${p}.rows`, "empty-table", "a table block has no rows");
1231
+ }
1232
+ if (b.kind === "group") walkBlocks(b.children, `${p}.children`);
1233
+ });
1234
+ };
1235
+ if (spec.menu?.banner && (!spec.menu.banner.src || !spec.menu.banner.src.trim())) {
1236
+ add("error", "menu.banner.src", "image-src", "a menu banner needs a src");
1237
+ }
1238
+ walkBlocks(spec.menu?.settings, "menu.settings");
1239
+ walkBlocks(spec.menu?.paytable, "menu.paytable");
1240
+ walkBlocks(spec.menu?.rules, "menu.rules");
1241
+ walkBlocks(spec.rules, "rules");
1242
+ spec.panels?.forEach((pn, i) => {
1243
+ seeId(pn.id, `panels[${i}].id`);
1244
+ checkLayout(pn.layout?.anchor, `panels[${i}].layout.anchor`);
1245
+ walkBlocks(pn.blocks, `panels[${i}].blocks`);
1246
+ });
1247
+ } catch (e) {
1248
+ add("error", "", "validator-crash", e instanceof Error ? e.message : String(e));
1249
+ }
1250
+ return { ok: !issues.some((i) => i.level === "error"), issues };
1251
+ }
1252
+
1253
+ // src/spec/buildPanel.ts
1254
+ function buildBlocks(blocks, bus, registry = ControlRegistry.defaults(), reuse) {
1255
+ const controls = [];
1256
+ const walk = (bs) => {
1257
+ for (const b of bs) {
1258
+ if (b.kind === "group") {
1259
+ walk(b.children);
1260
+ continue;
1261
+ }
1262
+ if (b.kind === "text") continue;
1263
+ const existing = reuse?.(b.id);
1264
+ if (existing) {
1265
+ controls.push(existing);
1266
+ continue;
1267
+ }
1268
+ const c = registry.resolve(b, bus);
1269
+ if (c) controls.push(c);
1270
+ }
1271
+ };
1272
+ walk(blocks);
1273
+ return controls;
1274
+ }
1275
+ function buildPanel(spec, bus, registry = ControlRegistry.defaults()) {
1276
+ const panel = new PanelControl(
1277
+ { id: spec.id, variant: spec.variant, title: spec.title, layout: spec.layout },
1278
+ bus
1279
+ );
1280
+ const controls = buildBlocks(spec.blocks, bus, registry);
1281
+ return { panel, controls, blocks: spec.blocks };
1282
+ }
1283
+ function buttonBlocks(blocks) {
1284
+ const out = [];
1285
+ const walk = (bs) => {
1286
+ for (const b of bs) {
1287
+ if (b.kind === "button") out.push(b);
1288
+ else if (b.kind === "group") walk(b.children);
1289
+ }
1290
+ };
1291
+ walk(blocks);
1292
+ return out;
1293
+ }
1294
+
1295
+ // src/spec/jurisdiction.ts
1296
+ function applyJurisdiction(ui, jur) {
1297
+ if (!jur) return;
1298
+ const lockHidden = (id) => {
1299
+ ui.forceHidden.add(id);
1300
+ ui.setHidden(id, true);
1301
+ };
1302
+ if (jur.disabledFullscreen) lockHidden("fullscreen");
1303
+ if (jur.disabledTurbo) lockHidden("turbo");
1304
+ if (jur.disabledAutoplay) {
1305
+ lockHidden("autoplay");
1306
+ ui.autoplay.disable();
1307
+ }
1308
+ if (jur.disabledBuyFeature) {
1309
+ lockHidden("bonus");
1310
+ ui.bonusButton.disable();
1311
+ }
1312
+ if (jur.disabledSuperTurbo && ui.turbo.modeCount > 2) ui.turbo.setModes(["off", "on"]);
1313
+ if (jur.disabledSlamstop) ui.spin.allowSlamStop.set(false);
1314
+ if (jur.disabledSpacebar) {
1315
+ ui.spin.holdToSpin = false;
1316
+ ui.spin.allowKeyboard.set(false);
1317
+ }
1318
+ if (jur.displayRTP != null) ui.setHidden("rtp", !jur.displayRTP);
1319
+ if (jur.displayNetPosition != null) ui.setHidden("net-position", !jur.displayNetPosition);
1320
+ if (jur.displaySessionTimer != null) {
1321
+ ui.setHidden("session-timer", !jur.displaySessionTimer);
1322
+ if (jur.displaySessionTimer) ui.sessionTimer.start();
1323
+ else ui.sessionTimer.stop();
1324
+ }
1325
+ if (jur.socialCasino != null) ui.setSocial(jur.socialCasino);
1326
+ if (typeof jur.minimumRoundDuration === "number" && Number.isFinite(jur.minimumRoundDuration)) {
1327
+ ui.minimumRoundDuration = Math.max(0, jur.minimumRoundDuration);
1328
+ }
1329
+ }
1330
+
1331
+ // src/notice.ts
1332
+ var RGS_ERROR_KEYS = Object.freeze({
1333
+ ERR_IPB: { title: "openui.err.insufficient.title", message: "openui.err.insufficient.message" },
1334
+ ERR_IS: { title: "openui.err.session.title", message: "openui.err.session.message" },
1335
+ ERR_ATE: { title: "openui.err.session.title", message: "openui.err.session.message" },
1336
+ ERR_GLE: { title: "openui.err.limit.title", message: "openui.err.limit.message" },
1337
+ ERR_BE: { title: "openui.err.activebet.title", message: "openui.err.activebet.message" },
1338
+ ERR_BNF: { title: "openui.err.generic.title", message: "openui.err.generic.message" },
1339
+ ERR_LOC: { title: "openui.err.location.title", message: "openui.err.location.message" },
1340
+ ERR_MAINTENANCE: { title: "openui.err.maintenance.title", message: "openui.err.maintenance.message" },
1341
+ ERR_VAL: { title: "openui.err.generic.title", message: "openui.err.generic.message" },
1342
+ ERR_GEN: { title: "openui.err.generic.title", message: "openui.err.generic.message" },
1343
+ ERR_UE: { title: "openui.err.generic.title", message: "openui.err.generic.message" },
1344
+ ERR_TIMEOUT: { title: "openui.err.connection.title", message: "openui.err.connection.message" }
1345
+ });
1346
+ var DEFAULT_NOTICE_ACTION = { label: "openui.ok", variant: "primary" };
1347
+ function errorBlocks(message, title, tone) {
1348
+ return [
1349
+ { kind: "heading", id: "notice-title", text: title },
1350
+ { kind: "callout", id: "notice-body", tone, text: message }
1351
+ ];
1352
+ }
1353
+
1354
+ // src/i18n/translator.ts
1355
+ var openuiDefaults = {
1356
+ "openui.spin": "Spin",
1357
+ "openui.stop": "Stop",
1358
+ "openui.autoplay": "Autoplay",
1359
+ "openui.bet": "Bet",
1360
+ "openui.balance": "Balance",
1361
+ "openui.win": "Win",
1362
+ "openui.menu": "Menu",
1363
+ "openui.rules": "Rules",
1364
+ "openui.paytable": "Paytable",
1365
+ "openui.settings": "Settings",
1366
+ "openui.close": "Close",
1367
+ "openui.language": "Language",
1368
+ // compliance readouts + autoplay limit headings
1369
+ "openui.rtp": "RTP",
1370
+ "openui.net": "Net",
1371
+ "openui.session": "Session",
1372
+ "openui.lossLimit": "Stop on loss",
1373
+ "openui.winLimit": "Stop on single win",
1374
+ // notice / error modal
1375
+ "openui.ok": "OK",
1376
+ "openui.cancel": "Cancel",
1377
+ "openui.confirm": "Confirm",
1378
+ "openui.continue": "Continue",
1379
+ "openui.reload": "Reload",
1380
+ "openui.error": "Error",
1381
+ "openui.notice": "Notice",
1382
+ "openui.replay": "Replay",
1383
+ "openui.buyFeature.title": "Buy feature",
1384
+ "openui.buyFeature.message": "Buy this feature now?",
1385
+ "openui.freeSpins": "FS",
1386
+ // ── social / sweepstakes wording (used when `ui.social` is on; see OpenUI.t) ──
1387
+ // open-ui resolves `<key>.social` first in social mode, falling back to the base
1388
+ // key. These are the gambling-loaded terms a sweepstakes jurisdiction must avoid;
1389
+ // override any of them (or add `openui.bet.social` etc.) in your messages dict.
1390
+ "openui.buyFeature.title.social": "Play bonus",
1391
+ "openui.buyFeature.message.social": "Play this bonus now?",
1392
+ "openui.win.social": "Prize",
1393
+ // reality check (RTS 13) — {{minutes}} is interpolated by open-ui's scheduler
1394
+ "openui.realityCheck.title": "Reality check",
1395
+ "openui.realityCheck.message": "You've been playing for {{minutes}} minutes. Take a moment before continuing.",
1396
+ // RGS error defaults (override via your messages dict or per-call)
1397
+ "openui.err.generic.title": "Something went wrong",
1398
+ "openui.err.generic.message": "Sorry, something went wrong. Please try again.",
1399
+ "openui.err.insufficient.title": "Insufficient funds",
1400
+ "openui.err.insufficient.message": "You don't have enough balance to place this bet.",
1401
+ "openui.err.session.title": "Session expired",
1402
+ "openui.err.session.message": "Your session has expired. Reload the game to continue.",
1403
+ "openui.err.limit.title": "Limit reached",
1404
+ "openui.err.limit.message": "A gambling limit has been reached. Please try again later.",
1405
+ "openui.err.activebet.title": "Round in progress",
1406
+ "openui.err.activebet.message": "You already have a round in progress. Reload to finish it.",
1407
+ "openui.err.location.title": "Unavailable here",
1408
+ "openui.err.location.message": "This game is not available in your location.",
1409
+ "openui.err.maintenance.title": "Under maintenance",
1410
+ "openui.err.maintenance.message": "The game is briefly down for maintenance. Please try again soon.",
1411
+ "openui.err.connection.title": "Connection lost",
1412
+ "openui.err.connection.message": "A stable connection is required. Reload to finish any open round."
1413
+ };
1414
+ function interpolate(template, vars) {
1415
+ if (!vars) return template;
1416
+ return template.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(vars[k] ?? `{{${k}}}`));
1417
+ }
1418
+ var DictionaryTranslator = class {
1419
+ constructor(messages, locale) {
1420
+ this.messages = messages;
1421
+ this._locale = locale;
1422
+ }
1423
+ messages;
1424
+ _locale;
1425
+ subs = /* @__PURE__ */ new Set();
1426
+ get locale() {
1427
+ return this._locale;
1428
+ }
1429
+ setLocale(next) {
1430
+ if (next === this._locale) return;
1431
+ this._locale = next;
1432
+ for (const cb of [...this.subs]) cb(next);
1433
+ }
1434
+ t(key, vars) {
1435
+ const resolved = this.messages[this._locale]?.[key] ?? openuiDefaults[key] ?? key;
1436
+ return interpolate(resolved, vars);
1437
+ }
1438
+ onChange(cb) {
1439
+ this.subs.add(cb);
1440
+ return () => {
1441
+ this.subs.delete(cb);
1442
+ };
1443
+ }
1444
+ };
1445
+ function dictionary(messages, locale) {
1446
+ return new DictionaryTranslator(messages, locale);
1447
+ }
1448
+
1449
+ // src/format/currency.ts
1450
+ var ZERO_DECIMAL = ["JPY", "KRW", "VND", "CLP", "ISK", "HUF", "TWD", "UGX", "XAF", "XOF", "PYG", "RWF"];
1451
+ var CRYPTO = { BTC: 8, ETH: 8, LTC: 8, BCH: 8, DOGE: 8, XRP: 6, SOL: 6, TRX: 6, USDT: 2, USDC: 2 };
1452
+ var CURRENCY_TABLE = Object.freeze({
1453
+ ...Object.fromEntries(ZERO_DECIMAL.map((c) => [c, { decimals: 0 }])),
1454
+ ...Object.fromEntries(Object.entries(CRYPTO).map(([c, d]) => [c, { decimals: d }])),
1455
+ // Stake social currencies: Gold Coins + Stake Cash — shown as GC/SC, not fiat.
1456
+ XGC: { decimals: 2, display: "GC", social: true },
1457
+ XSC: { decimals: 2, display: "SC", social: true },
1458
+ GC: { decimals: 2, display: "GC", social: true },
1459
+ SC: { decimals: 2, display: "SC", social: true }
1460
+ });
1461
+ var upper = (code) => typeof code === "string" ? code.toUpperCase() : "";
1462
+ function isSocialCurrency(code) {
1463
+ return CURRENCY_TABLE[upper(code)]?.social === true;
1464
+ }
1465
+ function resolveCurrency(code, overrides = {}) {
1466
+ const info = CURRENCY_TABLE[upper(code)] ?? { decimals: 2 };
1467
+ return {
1468
+ code: overrides.code ?? info.display ?? code,
1469
+ decimals: clampDecimals(overrides.decimals ?? info.decimals),
1470
+ position: overrides.position ?? "suffix",
1471
+ separator: overrides.separator ?? ",",
1472
+ decimalChar: overrides.decimalChar ?? "."
1473
+ };
1474
+ }
1475
+ function formatAmount(value, spec, opts = {}) {
1476
+ const decimals = clampDecimals(spec.decimals);
1477
+ const sep = spec.separator ?? ",";
1478
+ const dot = spec.decimalChar ?? ".";
1479
+ const n = Number.isFinite(value) ? value : 0;
1480
+ const fixed = Math.abs(n).toFixed(decimals);
1481
+ const [int = "0", frac] = fixed.split(".");
1482
+ const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
1483
+ const body = frac ? `${grouped}${dot}${frac}` : grouped;
1484
+ const sign = n < 0 ? "-" : opts.signed ? "+" : "";
1485
+ const number = `${sign}${body}`;
1486
+ return (spec.position ?? "suffix") === "prefix" ? `${spec.code} ${number}` : `${number} ${spec.code}`;
1487
+ }
1488
+
1489
+ // src/OpenUI.ts
1490
+ var OpenUI = class {
1491
+ theme;
1492
+ layoutConfig;
1493
+ bus = new EventBus();
1494
+ screen;
1495
+ /** i18n translator (the port — no hard i18next dep, Charter B5/B8). */
1496
+ translator;
1497
+ /** Current locale, observable — views re-render their text when it changes. */
1498
+ locale;
1499
+ /**
1500
+ * Ref-counted input lock. While the count is above zero, `locked` is true and
1501
+ * EVERY registered control reports not-interactable through its derived getter
1502
+ * (Charter P6/G7) — no per-button bookkeeping, no desync. Instance-scoped: the
1503
+ * gate each control receives in `register` reads THIS ui's `locked`.
1504
+ */
1505
+ locked = new Signal(false);
1506
+ lockCount = 0;
1507
+ /** Every subscription this ui owns, torn down by `dispose()` (Charter P12). */
1508
+ disposers = [];
1509
+ /** Control ids the spec marked hidden — still registered (so they stay
1510
+ * introspectable in `snapshot()`) but skipped by the renderer (Charter P10). */
1511
+ hidden = /* @__PURE__ */ new Set();
1512
+ /** Ids hidden UNCONDITIONALLY (e.g. a jurisdiction `disabled*` flag): `setHidden`
1513
+ * refuses to re-show these, so a responsive resize can't undo a compliance hide. */
1514
+ forceHidden = /* @__PURE__ */ new Set();
1515
+ /** Master mute (music+sfx), observable so an icon can reflect it. */
1516
+ muted = new Signal(false);
1517
+ /** Social / sweepstakes mode: swaps gambling wording (Bet/Buy → social terms) and,
1518
+ * paired with a social coin, shows GC/SC. Toggle with {@link setSocial}. */
1519
+ social = new Signal(false);
1520
+ /** Declarative blocks backing the menu-style notice/error modal. */
1521
+ noticeBlocks = new Signal([]);
1522
+ /** Action buttons for the notice/error modal (a single dismiss when empty). */
1523
+ noticeActions = new Signal([]);
1524
+ /** Whether the open notice is a BLOCKING/fatal modal — no backdrop/✕ dismiss and
1525
+ * the HUD is locked; it can be removed only in code (`hideNotice`). */
1526
+ noticeBlocking = new Signal(false);
1527
+ noticeLockHeld = false;
1528
+ /** Cumulative amount staked this session (major units) — RTS 12 "money spent". */
1529
+ totalStaked = new Signal(0);
1530
+ /** Cumulative amount won this session (major units). */
1531
+ totalWon = new Signal(0);
1532
+ /** The Stake jurisdiction config currently applied (read-back for guards/queries). */
1533
+ jurisdiction = {};
1534
+ /** Replay mode (Stake `replay=true`): the HUD locks + a REPLAY badge shows. */
1535
+ replay = new Signal(false);
1536
+ /** Game name + version (shown in the menu footer; for support / certification). */
1537
+ gameInfo = {};
1538
+ /** Minimum round duration (ms) from jurisdiction — stored for the GAME to enforce. */
1539
+ minimumRoundDuration = 0;
1540
+ sessionNet = 0;
1541
+ prevVolumes = null;
1542
+ /** The frozen UISpec `createUI` built this from, if any — authored = run = tested. */
1543
+ spec;
1544
+ /** Typed convenience handles for the controls. */
1545
+ spin;
1546
+ balance;
1547
+ bet;
1548
+ settingsButton;
1549
+ settingsPanel;
1550
+ musicSlider;
1551
+ sfxSlider;
1552
+ rulesButton;
1553
+ infoPanel;
1554
+ infoClose;
1555
+ turbo;
1556
+ autoplay;
1557
+ bonusButton;
1558
+ betStepper;
1559
+ betPlus;
1560
+ betMinus;
1561
+ fullscreenButton;
1562
+ muteButton;
1563
+ /** Compliance display readouts — hidden until a jurisdiction reveals them. */
1564
+ rtp;
1565
+ netPosition;
1566
+ sessionTimer;
1567
+ /** Menu-style notice/error modal (content set via `showNotice`). */
1568
+ noticePanel;
1569
+ controls = /* @__PURE__ */ new Map();
1570
+ constructor(opts = {}) {
1571
+ this.theme = opts.theme ?? defaultTheme;
1572
+ this.layoutConfig = opts.layout ?? defaultLayoutConfig;
1573
+ this.screen = new Signal(computeScreen(1920, 1080, this.layoutConfig));
1574
+ this.translator = opts.translator ?? new DictionaryTranslator({}, "en");
1575
+ this.locale = new Signal(this.translator.locale);
1576
+ this.disposers.push(
1577
+ this.translator.onChange((loc) => {
1578
+ if (this.locale.get() === loc) return;
1579
+ this.locale.set(loc);
1580
+ this.bus.emit("localeChanged", loc);
1581
+ })
1582
+ );
1583
+ this.spin = new SpinControl({ layout: { anchor: "bottom-center", offset: [0, -440] } }, this.bus);
1584
+ this.balance = new ValueDisplay({
1585
+ id: "balance",
1586
+ label: "openui.balance",
1587
+ // i18n key → localizes + swaps in social mode
1588
+ layout: { anchor: "bottom-left", offset: [220, -96] },
1589
+ currency: { code: "USD", decimals: 2 },
1590
+ initial: 1e3
1591
+ });
1592
+ this.bet = new ValueDisplay({
1593
+ id: "bet",
1594
+ label: "openui.bet",
1595
+ layout: { anchor: "bottom-right", offset: [-220, -96] },
1596
+ currency: { code: "USD", decimals: 2 },
1597
+ initial: 1
1598
+ });
1599
+ this.settingsButton = new ButtonControl(
1600
+ { id: "settings", layout: { anchor: "bottom-center", offset: [400, -440] } },
1601
+ this.bus
1602
+ );
1603
+ this.settingsPanel = new PanelControl(
1604
+ { id: "settings-panel", variant: "popover", layout: { anchor: "bottom-right", offset: [-24, -470] } },
1605
+ this.bus
1606
+ );
1607
+ this.musicSlider = new SliderControl({ id: "music", label: "Music", layout: { anchor: "center" }, initial: 0.7 }, this.bus);
1608
+ this.sfxSlider = new SliderControl({ id: "sfx", label: "Sound", layout: { anchor: "center" }, initial: 0.5 }, this.bus);
1609
+ this.rulesButton = new ButtonControl({ id: "rules", label: "Rules", layout: { anchor: "center" } }, this.bus);
1610
+ this.infoPanel = new PanelControl({ id: "info-panel", variant: "modal", title: "Game Info", layout: { anchor: "center" } }, this.bus);
1611
+ this.infoClose = new ButtonControl({ id: "info-close", layout: { anchor: "top-right" } }, this.bus);
1612
+ this.bonusButton = new ButtonControl({ id: "bonus", layout: { anchor: "bottom-center", offset: [-400, -440] } }, this.bus);
1613
+ this.autoplay = new AutoplayControl(
1614
+ { id: "autoplay", layout: { anchor: "bottom-center", offset: [-225, -440] }, options: [5, 10, 25, 50, 100, Infinity] },
1615
+ this.bus
1616
+ );
1617
+ this.turbo = new TurboControl({ id: "turbo", layout: { anchor: "bottom-center", offset: [225, -440] } }, this.bus);
1618
+ this.betMinus = new ButtonControl({ id: "bet-minus", layout: { anchor: "bottom-center", offset: [-150, -270] } }, this.bus);
1619
+ this.betPlus = new ButtonControl({ id: "bet-plus", layout: { anchor: "bottom-center", offset: [150, -270] } }, this.bus);
1620
+ this.betStepper = new StepperControl({ id: "bet-stepper", layout: { anchor: "center" }, levels: [0.5, 1, 2, 5, 10, 20], index: 1 }, this.bus);
1621
+ this.muteButton = new ButtonControl({ id: "mute", layout: { anchor: "top-right", offset: [-110, 28] } }, this.bus);
1622
+ this.fullscreenButton = new ButtonControl({ id: "fullscreen", layout: { anchor: "top-right", offset: [-52, 28] } }, this.bus);
1623
+ this.rtp = new ReadoutControl({ id: "rtp", kind: "percent", label: "RTP", layout: { anchor: "top-left", offset: [120, 96] } });
1624
+ this.netPosition = new ReadoutControl({ id: "net-position", kind: "currency", label: "Net", currency: { code: "USD", decimals: 2 }, layout: { anchor: "top-center", offset: [0, 56] } });
1625
+ this.sessionTimer = new ReadoutControl({ id: "session-timer", kind: "duration", label: "Session", layout: { anchor: "top-left", offset: [120, 56] } });
1626
+ this.noticePanel = new PanelControl({ id: "notice-panel", variant: "modal", layout: { anchor: "center" } }, this.bus);
1627
+ for (const c of [
1628
+ this.spin,
1629
+ this.balance,
1630
+ this.bet,
1631
+ this.settingsButton,
1632
+ this.settingsPanel,
1633
+ this.musicSlider,
1634
+ this.sfxSlider,
1635
+ this.rulesButton,
1636
+ this.infoPanel,
1637
+ this.infoClose,
1638
+ this.bonusButton,
1639
+ this.autoplay,
1640
+ this.turbo,
1641
+ this.betMinus,
1642
+ this.betPlus,
1643
+ this.betStepper,
1644
+ this.muteButton,
1645
+ this.fullscreenButton,
1646
+ this.rtp,
1647
+ this.netPosition,
1648
+ this.sessionTimer,
1649
+ this.noticePanel
1650
+ ]) {
1651
+ this.register(c);
1652
+ }
1653
+ this.hidden.add("rtp");
1654
+ this.hidden.add("net-position");
1655
+ this.hidden.add("session-timer");
1656
+ this.bus.on("buttonActivated", ({ id }) => {
1657
+ if (id === "settings") this.settingsPanel.toggle();
1658
+ else if (id === "rules") {
1659
+ this.settingsPanel.closePanel();
1660
+ this.infoPanel.openPanel();
1661
+ } else if (id === "info-close") {
1662
+ this.infoPanel.closePanel();
1663
+ } else if (id === "bet-plus") {
1664
+ this.betStepper.inc();
1665
+ } else if (id === "bet-minus") {
1666
+ this.betStepper.dec();
1667
+ } else if (id === "mute") {
1668
+ this.toggleMute();
1669
+ }
1670
+ });
1671
+ this.bus.on("valueChanged", ({ id, value }) => {
1672
+ if (id === "bet-stepper") this.bet.set(value);
1673
+ });
1674
+ const syncStepperButtons = () => {
1675
+ this.betStepper.canInc ? this.betPlus.enable() : this.betPlus.disable();
1676
+ this.betStepper.canDec ? this.betMinus.enable() : this.betMinus.disable();
1677
+ };
1678
+ this.disposers.push(this.betStepper.index.subscribe(syncStepperButtons));
1679
+ this.bet.set(this.betStepper.value);
1680
+ syncStepperButtons();
1681
+ this.disposers.push(
1682
+ this.noticePanel.state.subscribe(() => {
1683
+ if (this.noticePanel.isOpen) return;
1684
+ if (this.noticeLockHeld) {
1685
+ this.unlock();
1686
+ this.noticeLockHeld = false;
1687
+ }
1688
+ if (this.noticeBlocking.get()) this.noticeBlocking.set(false);
1689
+ this.bus.emit("noticeDismissed", void 0);
1690
+ })
1691
+ );
1692
+ if (opts.startMuted) this.setMuted(true);
1693
+ }
1694
+ register(control) {
1695
+ this.controls.set(control.id, control);
1696
+ control.gate = () => !this.locked.get();
1697
+ this.disposers.push(
1698
+ control.onTransition((_t, to, from) => {
1699
+ this.bus.emit("stateChanged", { id: control.id, from, to });
1700
+ })
1701
+ );
1702
+ }
1703
+ control(id) {
1704
+ return this.controls.get(id);
1705
+ }
1706
+ all() {
1707
+ return [...this.controls.values()];
1708
+ }
1709
+ /** Renderer calls this on resize; recomputes breakpoint + fit scale. */
1710
+ setScreen(width, height) {
1711
+ this.screen.set(computeScreen(width, height, this.layoutConfig));
1712
+ }
1713
+ /**
1714
+ * Hide or show a control at runtime and tell the renderer (Charter P10). The
1715
+ * control stays registered — still in `snapshot()` — but the view is not drawn.
1716
+ * Used by the responsive layer to drop controls on small screens.
1717
+ */
1718
+ setHidden(id, hidden) {
1719
+ if (!hidden && this.forceHidden.has(id)) return;
1720
+ const was = this.hidden.has(id);
1721
+ if (hidden === was) return;
1722
+ if (hidden) this.hidden.add(id);
1723
+ else this.hidden.delete(id);
1724
+ this.bus.emit("visibilityChanged", { id, hidden });
1725
+ }
1726
+ /** Push the ref-counted input lock (e.g. for the duration of a spin round). */
1727
+ lock() {
1728
+ this.lockCount += 1;
1729
+ if (this.lockCount === 1) this.locked.set(true);
1730
+ }
1731
+ /** Pop the lock; the HUD becomes interactable again only at zero. */
1732
+ unlock() {
1733
+ if (this.lockCount === 0) return;
1734
+ this.lockCount -= 1;
1735
+ if (this.lockCount === 0) this.locked.set(false);
1736
+ }
1737
+ /** Toggle master mute (music + sfx). */
1738
+ toggleMute() {
1739
+ this.setMuted(!this.muted.get());
1740
+ }
1741
+ /** Mute/unmute music+sfx, remembering the levels to restore on unmute. */
1742
+ setMuted(m) {
1743
+ if (m === this.muted.get()) return;
1744
+ if (m) {
1745
+ this.prevVolumes = { music: this.musicSlider.value.get(), sfx: this.sfxSlider.value.get() };
1746
+ this.musicSlider.setNormalized(0);
1747
+ this.sfxSlider.setNormalized(0);
1748
+ } else if (this.prevVolumes) {
1749
+ this.musicSlider.setNormalized(this.prevVolumes.music);
1750
+ this.sfxSlider.setNormalized(this.prevVolumes.sfx);
1751
+ this.prevVolumes = null;
1752
+ }
1753
+ this.muted.set(m);
1754
+ }
1755
+ /**
1756
+ * Report one completed round (major units): updates the net-position readout and,
1757
+ * while autoplay is running, advances its count + enforces its RG loss/single-win
1758
+ * limits. One feed point powers both `displayNetPosition` and the autoplay stops.
1759
+ */
1760
+ reportRound(win, bet) {
1761
+ if (!Number.isFinite(win) || !Number.isFinite(bet)) return;
1762
+ this.sessionNet += win - bet;
1763
+ this.netPosition.set(this.sessionNet);
1764
+ this.totalStaked.set(this.totalStaked.get() + bet);
1765
+ this.totalWon.set(this.totalWon.get() + win);
1766
+ if (this.autoplay.isActive) {
1767
+ this.autoplay.reportResult(win, bet);
1768
+ if (this.autoplay.isActive && this.balance.get() < this.bet.get()) this.autoplay.stop();
1769
+ }
1770
+ }
1771
+ /** Reset the running session net + aggregates + timer (e.g. on a fresh session). */
1772
+ resetSession() {
1773
+ this.sessionNet = 0;
1774
+ this.netPosition.set(0);
1775
+ this.totalStaked.set(0);
1776
+ this.totalWon.set(0);
1777
+ this.sessionTimer.reset();
1778
+ }
1779
+ /** Show the menu-style notice modal: declarative blocks + optional action buttons.
1780
+ * Every string (block text + button labels) runs through `ui.t`, so pass exact
1781
+ * literals OR your own i18n keys. Omit `actions` → a single dismiss button. */
1782
+ showNotice(blocks, actions, opts = {}) {
1783
+ const blocking = !!opts.blocking;
1784
+ this.noticeBlocks.set(Array.isArray(blocks) ? blocks : []);
1785
+ this.noticeActions.set(actions && actions.length ? actions : blocking ? [] : [DEFAULT_NOTICE_ACTION]);
1786
+ this.noticeBlocking.set(blocking);
1787
+ if (blocking && !this.noticeLockHeld) {
1788
+ this.lock();
1789
+ this.noticeLockHeld = true;
1790
+ } else if (!blocking && this.noticeLockHeld) {
1791
+ this.unlock();
1792
+ this.noticeLockHeld = false;
1793
+ }
1794
+ this.noticePanel.openPanel();
1795
+ this.bus.emit("noticeShown", { blocking });
1796
+ }
1797
+ /** Show a simple error/notice: a title + a tone-coloured message. `message` and
1798
+ * `opts.title` are literal-or-key (localized via `ui.t`); `opts.actions` adds
1799
+ * custom buttons (e.g. a "Reload"). `opts.blocking` → a fatal modal (locks the
1800
+ * HUD, no backdrop/✕ dismiss; removable only via `hideNotice`). */
1801
+ showError(message, opts = {}) {
1802
+ this.showNotice(errorBlocks(message, opts.title ?? "openui.error", opts.tone ?? "warning"), opts.actions, {
1803
+ blocking: opts.blocking
1804
+ });
1805
+ }
1806
+ /** Show a FATAL error: a blocking modal that locks the HUD and can be removed only
1807
+ * in code (`hideNotice`) — for unrecoverable RGS states. Pass `actions` (e.g. a
1808
+ * "Reload" that reloads the page) for the only way out. */
1809
+ showFatal(message, opts = {}) {
1810
+ this.showError(message, { ...opts, blocking: true });
1811
+ }
1812
+ /** Show the default (localizable, overridable) message for an RGS status code.
1813
+ * Override the exact text per call via `opts.title` / `opts.message`. Stops an
1814
+ * active autoplay by default (`opts.stopAutoplay = false` to keep it). Pass
1815
+ * `opts.blocking` for unrecoverable codes (session expiry, maintenance). */
1816
+ showRgsError(code, opts = {}) {
1817
+ const def = RGS_ERROR_KEYS[code] ?? RGS_ERROR_KEYS.ERR_GEN;
1818
+ if (opts.stopAutoplay !== false && this.autoplay.isActive) this.autoplay.stop();
1819
+ this.showError(opts.message ?? def.message, {
1820
+ title: opts.title ?? def.title,
1821
+ tone: opts.tone ?? "warning",
1822
+ actions: opts.actions,
1823
+ blocking: opts.blocking
1824
+ });
1825
+ }
1826
+ /** Close the notice / error modal. */
1827
+ hideNotice() {
1828
+ this.noticePanel.closePanel();
1829
+ }
1830
+ /** Enter/leave replay mode (Stake `replay=true`) — locks the HUD so no real bet is
1831
+ * placed; the renderer shows a REPLAY badge while it's on. */
1832
+ setReplay(on) {
1833
+ if (on === this.replay.get()) return;
1834
+ this.replay.set(on);
1835
+ if (on) this.lock();
1836
+ else this.unlock();
1837
+ }
1838
+ /** Apply a Stake Engine `jurisdiction` config to the live HUD (whole switchboard).
1839
+ * The merged config is stored for `isDisabled()` read-back. */
1840
+ applyJurisdiction(jur) {
1841
+ this.jurisdiction = Object.freeze({ ...this.jurisdiction, ...jur });
1842
+ applyJurisdiction(this, jur);
1843
+ }
1844
+ /** Tear down every subscription and control this ui created (Charter P12). */
1845
+ dispose() {
1846
+ for (const d of this.disposers) d();
1847
+ this.disposers.length = 0;
1848
+ for (const c of this.controls.values()) c.dispose();
1849
+ this.controls.clear();
1850
+ }
1851
+ /**
1852
+ * Switch language. Drives the translator (if it can self-switch) and emits
1853
+ * `localeChanged`; controls' views, subscribed to `locale`, re-render their text.
1854
+ */
1855
+ setLocale(next) {
1856
+ this.translator.setLocale(next);
1857
+ }
1858
+ /** Translate a key (or pass plain text through) via the active translator. In
1859
+ * social mode, a `<key>.social` variant wins when one resolves — so gambling
1860
+ * wording (Bet/Buy feature) swaps to sweepstakes terms automatically. */
1861
+ t(key, vars) {
1862
+ if (this.social.get()) {
1863
+ const sk = `${key}.social`;
1864
+ const s = this.translator.t(sk, vars);
1865
+ if (s !== sk) return s;
1866
+ }
1867
+ return this.translator.t(key, vars);
1868
+ }
1869
+ /**
1870
+ * Turn social / sweepstakes mode on or off (one switch). Swaps gambling wording
1871
+ * (Bet/Buy feature → social terms via `<key>.social` i18n) AND, when a `coin` is
1872
+ * given (e.g. `'GC'`/`'SC'`/`'XGC'`), shows balance/bet/net in that coin. Wording
1873
+ * re-renders live; the host can still override any `.social` key.
1874
+ */
1875
+ setSocial(on, coin) {
1876
+ if (on !== this.social.get()) {
1877
+ this.social.set(on);
1878
+ this.locale.update(() => {
1879
+ });
1880
+ }
1881
+ if (coin) {
1882
+ const cur = resolveCurrency(coin);
1883
+ this.balance.setCurrency(cur);
1884
+ this.bet.setCurrency(cur);
1885
+ this.netPosition.setCurrency(cur);
1886
+ }
1887
+ }
1888
+ /** Read-back: did the applied jurisdiction disable this feature? (real guard, not
1889
+ * just a hide — `confirmBuy`/autoplay entry points consult this). */
1890
+ isDisabled(feature) {
1891
+ const j = this.jurisdiction;
1892
+ return feature === "autoplay" && !!j.disabledAutoplay || feature === "buyFeature" && !!j.disabledBuyFeature || feature === "turbo" && !!j.disabledTurbo || feature === "superTurbo" && !!j.disabledSuperTurbo || feature === "fullscreen" && !!j.disabledFullscreen || feature === "slamstop" && !!j.disabledSlamstop || feature === "spacebar" && !!j.disabledSpacebar;
1893
+ }
1894
+ /**
1895
+ * Start a reality-check reminder (RTS 13). Every `everyMinutes` (WALL-CLOCK, so a
1896
+ * backgrounded tab can't cheat it) it emits a `realityCheck` event, stops autoplay,
1897
+ * and — unless `showModal: false` — shows an acknowledge modal. You provide only
1898
+ * the interval + (optional) title/message text; `{{minutes}}`/`{{spent}}`/`{{won}}`
1899
+ * are interpolated. Returns a disposer (also torn down by `dispose()`).
1900
+ */
1901
+ startRealityCheck(opts) {
1902
+ const everyMs = Math.max(1, opts.everyMinutes) * 6e4;
1903
+ let last = Date.now();
1904
+ let timer;
1905
+ const tick = () => {
1906
+ if (this.noticePanel.isOpen) {
1907
+ timer = setTimeout(tick, 1e3);
1908
+ return;
1909
+ }
1910
+ const elapsedMs = Date.now() - last;
1911
+ last = Date.now();
1912
+ this.fireRealityCheck(opts, elapsedMs);
1913
+ timer = setTimeout(tick, everyMs);
1914
+ };
1915
+ timer = setTimeout(tick, everyMs);
1916
+ const dispose = () => {
1917
+ if (timer) clearTimeout(timer);
1918
+ timer = void 0;
1919
+ };
1920
+ this.disposers.push(dispose);
1921
+ return dispose;
1922
+ }
1923
+ /** Fire one reality check now: emit the event (+ session totals), stop autoplay,
1924
+ * and show the modal unless `showModal:false`. Exposed for manual triggers / tests. */
1925
+ fireRealityCheck(opts, elapsedMs = 0) {
1926
+ const minutes = Math.max(1, Math.round(opts.everyMinutes));
1927
+ const totalStaked = this.totalStaked.get();
1928
+ const totalWon = this.totalWon.get();
1929
+ this.bus.emit("realityCheck", { minutes, elapsedMs, totalStaked, totalWon });
1930
+ if (this.autoplay.isActive) this.autoplay.stop();
1931
+ if (opts.showModal === false) return;
1932
+ const vars = { minutes, spent: totalStaked, won: totalWon };
1933
+ this.showNotice(
1934
+ [
1935
+ { kind: "heading", id: "rc-title", text: this.t(opts.title ?? "openui.realityCheck.title", vars) },
1936
+ { kind: "callout", id: "rc-body", tone: "info", text: this.t(opts.message ?? "openui.realityCheck.message", vars) }
1937
+ ],
1938
+ opts.actions ?? [{ label: "openui.continue", variant: "primary" }]
1939
+ );
1940
+ }
1941
+ on(type, fn) {
1942
+ return this.bus.on(type, fn);
1943
+ }
1944
+ /** Serializable snapshot for the introspection / e2e API (Charter P10). */
1945
+ snapshot() {
1946
+ return this.all().map((c) => {
1947
+ const { bounds, animating } = c.inspect();
1948
+ return {
1949
+ id: c.id,
1950
+ role: c.role,
1951
+ state: c.current,
1952
+ interactable: c.interactable,
1953
+ bounds,
1954
+ animating
1955
+ };
1956
+ });
1957
+ }
1958
+ };
1959
+
1960
+ // src/spec/responsive.ts
1961
+ function cloneLayout(l) {
1962
+ return { anchor: l.anchor, offset: l.offset ? [l.offset[0], l.offset[1]] : void 0, scale: l.scale };
1963
+ }
1964
+ function activeKeys(screen) {
1965
+ return [screen.orientation, screen.breakpoint];
1966
+ }
1967
+ function installResponsive(ui, responsive) {
1968
+ const ids = /* @__PURE__ */ new Set();
1969
+ for (const key of Object.keys(responsive)) {
1970
+ const ov = responsive[key];
1971
+ if (ov?.controls) for (const id of Object.keys(ov.controls)) ids.add(id);
1972
+ }
1973
+ if (!ids.size) return () => {
1974
+ };
1975
+ const base = /* @__PURE__ */ new Map();
1976
+ for (const id of ids) {
1977
+ const c = ui.control(id);
1978
+ if (!c) continue;
1979
+ base.set(id, { layout: cloneLayout(c.layout), hidden: ui.hidden.has(id) });
1980
+ }
1981
+ const apply = (screen) => {
1982
+ const keys = activeKeys(screen);
1983
+ for (const [id, b] of base) {
1984
+ const c = ui.control(id);
1985
+ if (!c) continue;
1986
+ let layout = b.layout;
1987
+ let hidden = b.hidden;
1988
+ for (const key of keys) {
1989
+ const ov = responsive[key]?.controls?.[id];
1990
+ if (!ov) continue;
1991
+ if (ov.layout) layout = ov.layout;
1992
+ if (ov.hidden != null) hidden = ov.hidden;
1993
+ }
1994
+ c.layout = cloneLayout(layout);
1995
+ ui.setHidden(id, hidden);
1996
+ }
1997
+ };
1998
+ const off = ui.screen.subscribe(apply);
1999
+ apply(ui.screen.get());
2000
+ return off;
2001
+ }
2002
+
2003
+ // src/spec/createUI.ts
2004
+ function resolveTurboModes(modes) {
2005
+ if (Array.isArray(modes)) return modes.length ? modes : ["off", "on"];
2006
+ if (modes === 3) return ["off", "turbo", "super"];
2007
+ return ["off", "on"];
2008
+ }
2009
+ function defineUI(spec) {
2010
+ return spec;
2011
+ }
2012
+ var ANALYTICS_EVENTS = [
2013
+ "stateChanged",
2014
+ "buttonActivated",
2015
+ "valueChanged",
2016
+ "toggled",
2017
+ "optionSelected",
2018
+ "panelToggled",
2019
+ "autoplayStarted",
2020
+ "autoplayStopped"
2021
+ ];
2022
+ function createUI(spec = {}, hooks = {}) {
2023
+ const { issues } = validateSpec(spec);
2024
+ for (const issue of issues) hooks.onDataIssue?.(issue);
2025
+ const translator = spec.locale ? new DictionaryTranslator(spec.locale.messages, spec.locale.locale) : void 0;
2026
+ const theme = resolveTheme(spec.theme, (i) => hooks.onDataIssue?.(i));
2027
+ const ui = new OpenUI({ theme, layout: spec.layout, translator, startMuted: spec.audio?.startMuted });
2028
+ if (spec.currency) {
2029
+ const cur = typeof spec.currency === "string" ? resolveCurrency(spec.currency) : spec.currency;
2030
+ ui.balance.setCurrency(cur);
2031
+ ui.bet.setCurrency(cur);
2032
+ ui.netPosition.setCurrency(cur);
2033
+ }
2034
+ if (spec.social) {
2035
+ if (typeof spec.social === "object") ui.setSocial(true, spec.social.coin);
2036
+ else ui.setSocial(true);
2037
+ }
2038
+ if (typeof spec.rtp === "number") ui.rtp.set(spec.rtp);
2039
+ if (spec.game) ui.gameInfo = { name: spec.game.name, version: spec.game.version };
2040
+ if (spec.betLadder?.levels?.length) {
2041
+ ui.betStepper.setLevels(spec.betLadder.levels, spec.betLadder.index ?? 0);
2042
+ ui.bet.set(ui.betStepper.value);
2043
+ }
2044
+ if (spec.autoplay?.options?.length) {
2045
+ ui.autoplay.setOptions(spec.autoplay.options);
2046
+ }
2047
+ if (spec.autoplay?.mode) {
2048
+ ui.autoplay.mode = spec.autoplay.mode;
2049
+ }
2050
+ if (spec.autoplay?.lossLimits) ui.autoplay.setLossLimitOptions(spec.autoplay.lossLimits);
2051
+ if (spec.autoplay?.winLimits) ui.autoplay.setWinLimitOptions(spec.autoplay.winLimits);
2052
+ if (spec.turbo) {
2053
+ ui.turbo.setModes(resolveTurboModes(spec.turbo.modes));
2054
+ if (spec.turbo.index != null) ui.turbo.setIndex(spec.turbo.index);
2055
+ }
2056
+ if (spec.spin?.press) {
2057
+ ui.spin.holdToSpin = spec.spin.press === "hold-to-spin";
2058
+ }
2059
+ if (spec.controls) {
2060
+ for (const [id, ov] of Object.entries(spec.controls)) {
2061
+ const c = ui.control(id);
2062
+ if (!c) continue;
2063
+ if (ov.layout) c.layout = ov.layout;
2064
+ if (ov.hidden) ui.hidden.add(id);
2065
+ const disable = c.disable;
2066
+ if (ov.disabled && typeof disable === "function") disable.call(c);
2067
+ if (c instanceof ValueDisplay) {
2068
+ if (ov.currency) c.setCurrency(ov.currency);
2069
+ if (ov.initial != null) c.set(ov.initial);
2070
+ if (ov.digits != null) c.setDigits(ov.digits);
2071
+ }
2072
+ }
2073
+ }
2074
+ if (spec.jurisdiction) ui.applyJurisdiction(spec.jurisdiction);
2075
+ if (spec.responsive) {
2076
+ installResponsive(ui, spec.responsive);
2077
+ }
2078
+ if (spec.lockDuringSpin !== false) {
2079
+ let wasSpinning = false;
2080
+ ui.spin.state.subscribe((s) => {
2081
+ const now = s === "spinning";
2082
+ if (now && !wasSpinning) ui.lock();
2083
+ else if (!now && wasSpinning) ui.unlock();
2084
+ wasSpinning = now;
2085
+ });
2086
+ }
2087
+ if (spec.realityCheck && spec.realityCheck.everyMinutes > 0) {
2088
+ ui.startRealityCheck(spec.realityCheck);
2089
+ }
2090
+ const localeSelectId = spec.localeSelectId ?? "lang";
2091
+ ui.on("optionSelected", ({ id, value }) => {
2092
+ if (id === localeSelectId) ui.setLocale(value);
2093
+ });
2094
+ if (hooks.onAnalyticsEvent) {
2095
+ const forward = hooks.onAnalyticsEvent;
2096
+ for (const type of ANALYTICS_EVENTS) {
2097
+ ui.on(type, (payload) => forward(type, payload));
2098
+ }
2099
+ }
2100
+ ui.spec = Object.freeze(spec);
2101
+ return ui;
2102
+ }
2103
+
2104
+ // src/spec/menu.ts
2105
+ var DEFAULT_MENU_TITLES = { settings: "Settings", paytable: "Paytable", rules: "Rules" };
2106
+ var LOCALE_LABELS = {
2107
+ en: "English",
2108
+ es: "Espa\xF1ol",
2109
+ pt: "Portugu\xEAs",
2110
+ de: "Deutsch",
2111
+ fr: "Fran\xE7ais",
2112
+ ru: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
2113
+ tr: "T\xFCrk\xE7e",
2114
+ zh: "\u4E2D\u6587",
2115
+ ja: "\u65E5\u672C\u8A9E",
2116
+ ar: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629",
2117
+ pl: "Polski",
2118
+ ko: "\uD55C\uAD6D\uC5B4",
2119
+ hi: "\u0939\u093F\u0928\u094D\u0926\u0940",
2120
+ id: "Bahasa Indonesia",
2121
+ vi: "Ti\u1EBFng Vi\u1EC7t",
2122
+ fi: "Suomi",
2123
+ it: "Italiano",
2124
+ nl: "Nederlands"
2125
+ };
2126
+ function composeMenu(menu, opts = {}) {
2127
+ const titles = { ...DEFAULT_MENU_TITLES, ...menu?.titles };
2128
+ const out = [];
2129
+ if (menu?.banner?.src) {
2130
+ out.push({ kind: "image", id: "menu-banner", src: menu.banner.src, width: menu.banner.width, height: menu.banner.height });
2131
+ }
2132
+ out.push({ kind: "heading", id: "menu-sec-settings", text: titles.settings });
2133
+ out.push({ kind: "slider", id: "music", label: "Music" });
2134
+ out.push({ kind: "slider", id: "sfx", label: "Sound" });
2135
+ const locales = opts.locales ?? [];
2136
+ if (locales.length >= 2) {
2137
+ out.push({
2138
+ kind: "select",
2139
+ id: opts.localeSelectId ?? "lang",
2140
+ label: "Language",
2141
+ options: locales.map((code) => ({ value: code, label: LOCALE_LABELS[code] ?? code.toUpperCase() }))
2142
+ });
2143
+ }
2144
+ if (menu?.settings?.length) out.push(...menu.settings);
2145
+ if (menu?.paytable?.length) {
2146
+ out.push({ kind: "heading", id: "menu-sec-paytable", text: titles.paytable });
2147
+ out.push(...menu.paytable);
2148
+ }
2149
+ const rules = menu?.rules?.length ? menu.rules : opts.rulesFallback;
2150
+ if (rules?.length) {
2151
+ out.push({ kind: "heading", id: "menu-sec-rules", text: titles.rules });
2152
+ out.push(...rules);
2153
+ }
2154
+ return out;
2155
+ }
2156
+
2157
+ // src/spec/defaultHudSpec.ts
2158
+ var defaultHudSpec = Object.freeze({
2159
+ meta: { id: "open-ui-reference-hud", version: 1 },
2160
+ currency: { code: "USD", decimals: 2 },
2161
+ betLadder: { levels: [0.5, 1, 2, 5, 10, 20], index: 1 },
2162
+ autoplay: { options: [5, 10, 25, 50, 100, Infinity] },
2163
+ lockDuringSpin: true
2164
+ });
2165
+
2166
+ // src/EventLog.ts
2167
+ var ALL_EVENTS = [
2168
+ "spinRequested",
2169
+ "skipRequested",
2170
+ "stateChanged",
2171
+ "buttonActivated",
2172
+ "valueChanged",
2173
+ "panelToggled",
2174
+ "toggled",
2175
+ "optionSelected",
2176
+ "autoplayStarted",
2177
+ "autoplayStopped",
2178
+ "cardActivated",
2179
+ "localeChanged"
2180
+ ];
2181
+ var EventLog = class {
2182
+ buf = [];
2183
+ cap;
2184
+ clock;
2185
+ disposers = [];
2186
+ constructor(bus, opts = {}) {
2187
+ this.cap = opts.capacity ?? 200;
2188
+ this.clock = opts.now ?? defaultNow;
2189
+ for (const type of ALL_EVENTS) {
2190
+ this.disposers.push(bus.on(type, (payload) => this.record(type, payload)));
2191
+ }
2192
+ }
2193
+ record(type, payload) {
2194
+ this.buf.push({ t: this.clock(), type, payload });
2195
+ if (this.buf.length > this.cap) this.buf.shift();
2196
+ }
2197
+ /** The most recent payload of `type`, or undefined. */
2198
+ last(type) {
2199
+ for (let i = this.buf.length - 1; i >= 0; i--) {
2200
+ if (this.buf[i].type === type) return this.buf[i].payload;
2201
+ }
2202
+ return void 0;
2203
+ }
2204
+ /** How many of `type` are in the buffer. */
2205
+ count(type) {
2206
+ let n = 0;
2207
+ for (const e of this.buf) if (e.type === type) n += 1;
2208
+ return n;
2209
+ }
2210
+ /** Every event at or after timestamp `t`, in order. */
2211
+ since(t) {
2212
+ return this.buf.filter((e) => e.t >= t);
2213
+ }
2214
+ clear() {
2215
+ this.buf.length = 0;
2216
+ }
2217
+ dispose() {
2218
+ for (const d of this.disposers) d();
2219
+ this.disposers.length = 0;
2220
+ }
2221
+ };
2222
+ function defaultNow() {
2223
+ if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
2224
+ return Date.now();
2225
+ }
2226
+
2227
+ // src/bet.ts
2228
+ var API_AMOUNT_DIVISOR = 1e6;
2229
+ function buildBetLadder(cfg, divisor = API_AMOUNT_DIVISOR) {
2230
+ let minor;
2231
+ if (cfg.betLevels?.length) {
2232
+ minor = cfg.betLevels.slice();
2233
+ } else {
2234
+ const min = cfg.minBet ?? 0;
2235
+ const max = Math.max(min, cfg.maxBet ?? min);
2236
+ const step = cfg.stepBet && cfg.stepBet > 0 ? cfg.stepBet : max - min || 1;
2237
+ minor = [];
2238
+ for (let v = min; v <= max && minor.length < 500; v += step) minor.push(v);
2239
+ if (!minor.length) minor = [min];
2240
+ }
2241
+ const levels = minor.map((v) => v / divisor);
2242
+ const want = (cfg.defaultBetLevel ?? minor[0]) / divisor;
2243
+ const found = levels.findIndex((v) => v >= want);
2244
+ return { levels, index: found < 0 ? 0 : found };
2245
+ }
2246
+ function clampBet(amount, cfg, divisor = API_AMOUNT_DIVISOR) {
2247
+ const min = (cfg.minBet ?? 0) / divisor;
2248
+ const max = (cfg.maxBet ?? Infinity) / divisor;
2249
+ const step = (cfg.stepBet ?? 0) / divisor;
2250
+ let v = Math.min(max, Math.max(min, Number.isFinite(amount) ? amount : min));
2251
+ if (step > 0) v = min + Math.round((v - min) / step) * step;
2252
+ return Math.min(max, Math.max(min, v));
2253
+ }
2254
+
2255
+ // src/win.ts
2256
+ var DEFAULT_WIN_TIERS = Object.freeze([
2257
+ { name: "big", minMultiplier: 10 },
2258
+ { name: "mega", minMultiplier: 50 },
2259
+ { name: "epic", minMultiplier: 100 }
2260
+ ]);
2261
+ function winTier(win, stake, tiers = DEFAULT_WIN_TIERS) {
2262
+ if (!Number.isFinite(win) || !Number.isFinite(stake) || stake <= 0 || win <= stake) return "none";
2263
+ const mult = win / stake;
2264
+ let name = "win";
2265
+ for (const t of tiers) if (mult >= t.minMultiplier) name = t.name;
2266
+ return name;
2267
+ }
2268
+
2269
+ export { API_AMOUNT_DIVISOR, AutoplayControl, BLOCK_KINDS, ButtonControl, CURRENCY_TABLE, CardControl, Control, ControlRegistry, DEFAULT_MENU_TITLES, DEFAULT_NOTICE_ACTION, DEFAULT_WIN_TIERS, DictionaryTranslator, EventBus, EventLog, Fade, LOCALE_LABELS, OpenUI, PanelControl, Parallel, Pulse, RGS_ERROR_KEYS, ReadoutControl, SelectControl, Sequence, Signal, SliderControl, SpinControl, Squish, StepperControl, ToggleControl, TurboControl, Turn, ValueDisplay, applyJurisdiction, breakpointFor, buildBetLadder, buildBlocks, buildPanel, buttonBlocks, clampBet, clampDecimals, clampDigits, clampMinDigits, composeMenu, computeScreen, createUI, defaultHudSpec, defaultLayoutConfig, defaultTheme, defineUI, dictionary, displayDigits, effect, errorBlocks, extendTheme, formatAmount, installResponsive, integerDigits, isSafeColor, isSocialCurrency, openuiDefaults, resolveCurrency, resolvePlacement, resolveTheme, resolveTurboModes, safeAmount, sanitizeThemeOverrides, themePresets, validateSpec, valueFitMaxWidth, winTier };
2270
+ //# sourceMappingURL=index.js.map
2271
+ //# sourceMappingURL=index.js.map