@energy8platform/game-engine 0.33.7 → 0.33.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.33.7",
3
+ "version": "0.33.9",
4
4
  "description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -94,7 +94,7 @@
94
94
  },
95
95
  "dependencies": {
96
96
  "@energy8platform/platform-core": ">=0.30.1",
97
- "@energy8platform/shell": ">=0.6.0"
97
+ "@energy8platform/shell": ">=0.6.6"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "@energy8platform/game-sdk": "^2.9.0",
@@ -4,21 +4,24 @@
4
4
  * The wallet emits balance changes at two moments: the DEBIT lands when `play()` is called (before
5
5
  * the scene animates), and the CREDIT (win) lands when the round settles — `/wallet/end-round`
6
6
  * fires asynchronously AFTER the final segment's ack, i.e. after `present()`. The HUD-timing rule is
7
- * "balance updates only after present()", so:
8
- * - while a segment is between `beginPlay()` and `afterPresent()`, balance changes are BUFFERED
9
- * (the debit must not flash before the animation),
10
- * - `afterPresent()` paints the latest buffered value (the debit),
11
- * - any later change (the async win credit) paints immediately, because the gate is open again.
7
+ * "the stake leaves your balance the instant you spin; the win is only added after the animation":
8
+ * - a DEBIT (balance goes DOWN) paints IMMEDIATELY, even between `beginPlay()` and
9
+ * `afterPresent()` — the player must see the stake deducted the moment they press spin,
10
+ * - a CREDIT (balance goes UP) that lands DURING that window is BUFFERED — the win must not
11
+ * update the balance before the animation plays out,
12
+ * - `afterPresent()` flushes any buffered credit,
13
+ * - a credit that lands after the window (the usual case — end-round settles after the final ack)
14
+ * paints immediately, because the gate is open again.
12
15
  *
13
16
  * `balance` always reflects the true latest wallet value (regardless of gating) so an affordability
14
17
  * guard can read it. Pure + unit-testable: the shell paint is injected.
15
18
  */
16
19
  export interface BalanceGate {
17
- /** Record a wallet balance change. Paints it now unless a present is in flight. */
20
+ /** Record a wallet balance change. Debits paint now; a credit mid-present waits for afterPresent. */
18
21
  onBalance(amount: number): void;
19
- /** A play() was issued — suppress painting until the matching afterPresent() (debit must wait). */
22
+ /** A play() was issued — buffer credits until the matching afterPresent() (debits still paint). */
20
23
  beginPlay(): void;
21
- /** A segment finished animating — paint the latest balance and re-open the gate. */
24
+ /** A segment finished animating — flush any buffered credit and re-open the gate. */
22
25
  afterPresent(): void;
23
26
  /** The latest wallet balance, painted or not (for affordability checks). */
24
27
  readonly balance: number;
@@ -26,17 +29,25 @@ export interface BalanceGate {
26
29
 
27
30
  export function createBalanceGate(paint: (amount: number) => void, initial = 0): BalanceGate {
28
31
  let latest = initial;
32
+ let painted = initial;
29
33
  let suppressed = false;
34
+ const show = (amount: number): void => {
35
+ painted = amount;
36
+ paint(amount);
37
+ };
30
38
  return {
31
39
  onBalance(amount: number): void {
32
40
  latest = amount;
33
- if (!suppressed) paint(amount);
41
+ // During play→present, hold a CREDIT (balance rising) back so the win doesn't post before the
42
+ // animation. A DEBIT (balance falling — the stake) always paints now: spin deducts instantly.
43
+ if (suppressed && amount > painted) return;
44
+ show(amount);
34
45
  },
35
46
  beginPlay(): void {
36
47
  suppressed = true;
37
48
  },
38
49
  afterPresent(): void {
39
- paint(latest);
50
+ if (latest !== painted) show(latest); // flush a credit that landed mid-present
40
51
  suppressed = false;
41
52
  },
42
53
  get balance(): number {
@@ -245,10 +245,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
245
245
  // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
246
246
  shell.setVisible(!!gameScene());
247
247
  game.scenes.on('change', () => shell!.setVisible(!!gameScene()));
248
- // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
249
- // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
250
- // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
251
- // is the single source for both the displayed balance and `ensureAffordable`.
248
+ // The gate tracks the live wallet (for the affordability guard) and PAINTS the balance per the
249
+ // HUD-timing rule: the debit paints immediately (the stake leaves the balance on spin); a win
250
+ // credit landing during play→present is held to afterPresent so it doesn't post before the
251
+ // animation; the async credit (/wallet/end-round, after the final ack) paints when it lands.
252
+ // `balanceGate` is the single source for both the displayed balance and `ensureAffordable`.
252
253
  const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
253
254
  ps?.on('balanceUpdate', (d: { balance: number }) => {
254
255
  balanceGate.onBalance(d.balance);
@@ -297,6 +298,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
297
298
  overlayCtl.resize(width, height),
298
299
  );
299
300
 
301
+ // Progressive WIN readout for cascade/tumble scenes. The window is open from the moment a
302
+ // segment starts (WIN cleared to 0) until the host has painted that segment's final win; outside
303
+ // it the host owns the readout alone. See winReporter.ts.
304
+ const { createWinReporter } = await import('./winReporter');
305
+ const winReporter = createWinReporter((amount, opts) => shell!.setWin(amount, opts));
306
+
300
307
  // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
301
308
  sceneApi = {
302
309
  audio: createSceneAudio(game.audio),
@@ -305,6 +312,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
305
312
  get safeArea() {
306
313
  return shell!.safeArea;
307
314
  },
315
+ reportWin: winReporter.report,
308
316
  },
309
317
  formatAmount: (v) => shell!.formatWin(v),
310
318
  get bet() {
@@ -542,7 +550,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
542
550
  // animation has finished — returning void would reopen it instantly, over a running animation.
543
551
  return runRound<T>(
544
552
  {
545
- // Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
553
+ // Open the play→present window: the debit still paints immediately, but a win credit that
554
+ // lands mid-animation is held until afterPresent (HUD timing).
546
555
  play: (a, b, rid) => {
547
556
  balanceGate.beginPlay();
548
557
  return slotPlay.play(a, b, rid);
@@ -560,12 +569,15 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
560
569
  // then counts UP to this segment's delta. prevWin (the cumulative-delta tracker) is
561
570
  // untouched; this only resets the DISPLAY.
562
571
  shell!.setWin(0, { animate: false });
572
+ winReporter.open(); // the scene may now grow WIN per cascade step
563
573
  },
564
574
  onSpinStart: () => scene.onSpinStart?.(),
565
575
  onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
566
576
  afterPresent: (r) => {
567
577
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
568
- // bonus counter (totalWin) via settle(), not the WIN readout.
578
+ // bonus counter (totalWin) via settle(), not the WIN readout. A scene that reported
579
+ // per-step wins already landed on this number, so the count-up is a no-op there.
580
+ winReporter.close();
569
581
  shell!.setWin(r.totalWin - prevWin);
570
582
  prevWin = r.totalWin;
571
583
  balanceGate.afterPresent();
@@ -579,6 +591,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
579
591
  .catch(showPlayError)
580
592
  .finally(() => {
581
593
  presenting = false;
594
+ winReporter.close(); // also closes the window when a segment threw mid-flight
582
595
  shell!.setBusy(false);
583
596
  });
584
597
  };
@@ -625,11 +638,13 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
625
638
  shell!.setMode(bonusShellMode);
626
639
  }
627
640
  shell!.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
641
+ if (animate) winReporter.open(); // a fast-forward drain doesn't present → no reports expected
628
642
  if (animate) await scene.onSpin(r, ctx);
629
643
  if (inBonus) {
630
644
  const v = fsView(raw, r.totalWin);
631
645
  if (v) applyBonusReadout(r, v, ctx.mode);
632
646
  }
647
+ winReporter.close();
633
648
  shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
634
649
  prevWin = r.totalWin;
635
650
  ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
@@ -653,6 +668,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
653
668
  shell!.setWin(r.totalWin);
654
669
  }
655
670
  } finally {
671
+ winReporter.close(); // also closes the window when a drained segment threw
656
672
  shell!.setBusy(false);
657
673
  }
658
674
  };
package/src/host/index.ts CHANGED
@@ -25,6 +25,9 @@ export type {
25
25
  SlotSceneController, RenderContext, SceneApi, SceneAudio, SceneOverlay, SceneShell,
26
26
  OverlayShowOptions, AutoplaySceneState,
27
27
  } from './sceneController';
28
+ // Progressive (cascade/tumble) WIN readout — the gate behind `api.shell.reportWin`.
29
+ export { createWinReporter } from './winReporter';
30
+ export type { WinReporter, WinReportOptions } from './winReporter';
28
31
  // Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
29
32
  // authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
30
33
  export { socialize } from '@energy8platform/shell';
@@ -1,5 +1,6 @@
1
1
  import type { Container } from 'pixi.js';
2
2
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
3
+ import type { WinReportOptions } from './winReporter';
3
4
 
4
5
  /** Everything a scene needs to render one segment. The host builds it per segment. */
5
6
  export interface RenderContext {
@@ -57,6 +58,25 @@ export interface SceneOverlay {
57
58
  export interface SceneShell {
58
59
  /** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
59
60
  readonly safeArea: { top: number; right: number; bottom: number; left: number };
61
+ /**
62
+ * Grow the shell's WIN readout WHILE a segment presents — for cascade/tumble games that pay in
63
+ * steps instead of one lump at the end.
64
+ *
65
+ * `amountSoFar` is ABSOLUTE: the win accumulated by this segment up to now (not the step's
66
+ * delta), so a re-report, a skip, or an aborted step can just restate the truth. The host owns
67
+ * the readout around it — it clears WIN to 0 when the segment starts and sets the segment's
68
+ * final value once `onSpin` resolves (counting up from your last report, so matching numbers
69
+ * produce no jump). In a bonus this moves WIN only; the Total Win accumulator still lands once
70
+ * per segment.
71
+ *
72
+ * Only honoured while a segment is presenting (inside `onSpin`); calls from anywhere else are
73
+ * ignored, so a scene still ticking after an abort can't overwrite the host's final number.
74
+ *
75
+ * `durationMs` sets this count-up's length (default 450ms) — pass your step length (or a shorter
76
+ * one under turbo) so each count-up finishes before the next step lands. `{ animate: false }`
77
+ * snaps, e.g. when collapsing to the final value on skip.
78
+ */
79
+ reportWin(amountSoFar: number, opts?: WinReportOptions): void;
60
80
  }
61
81
 
62
82
  export interface AutoplaySceneState {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Gate for the PROGRESSIVE (cascade / tumble) WIN readout.
3
+ *
4
+ * By default the WIN readout is entirely host-driven: cleared to 0 when a segment starts, set to
5
+ * that segment's win once `onSpin` resolves. A cascade game pays in steps, so its scene wants the
6
+ * number to climb WHILE the segment presents — `api.shell.reportWin(amountSoFar)`.
7
+ *
8
+ * The gate keeps the host in charge:
9
+ * - reports are honoured ONLY between `open()` (segment start, WIN already cleared) and `close()`
10
+ * (the host paints the segment's final value) — a scene still animating after an abort, or one
11
+ * reporting from a mode transition, can't overwrite the host's number,
12
+ * - `amountSoFar` is ABSOLUTE (the segment's win up to now, not the step delta), so a re-report or
13
+ * a collapse-to-final on skip is idempotent. Reports should be non-decreasing within a segment;
14
+ * a lower value counts the readout back DOWN,
15
+ * - garbage (NaN / Infinity) is dropped and negatives clamp to 0, so a math bug can't paint "NaN"
16
+ * into the bar.
17
+ *
18
+ * Pure + unit-testable: the shell paint is injected.
19
+ */
20
+ export interface WinReportOptions {
21
+ /** `false` snaps instead of counting up (e.g. collapsing to the final value on skip). */
22
+ animate?: boolean;
23
+ /** Count-up length in ms (shell default 450) — pass the cascade step's length. */
24
+ durationMs?: number;
25
+ }
26
+
27
+ export interface WinReporter {
28
+ /** Scene-facing (`api.shell.reportWin`): the win accumulated by the presenting segment so far. */
29
+ report(amountSoFar: number, opts?: WinReportOptions): void;
30
+ /** A segment starts presenting — the scene may now grow WIN. */
31
+ open(): void;
32
+ /** The host takes the readout back (final value, round end, or an error). */
33
+ close(): void;
34
+ /** True while reports are honoured. */
35
+ readonly accepting: boolean;
36
+ }
37
+
38
+ export function createWinReporter(
39
+ paint: (amount: number, opts?: WinReportOptions) => void,
40
+ ): WinReporter {
41
+ let accepting = false;
42
+ return {
43
+ report(amountSoFar: number, opts?: WinReportOptions): void {
44
+ if (!accepting || !Number.isFinite(amountSoFar)) return;
45
+ paint(Math.max(0, amountSoFar), opts);
46
+ },
47
+ open(): void {
48
+ accepting = true;
49
+ },
50
+ close(): void {
51
+ accepting = false;
52
+ },
53
+ get accepting(): boolean {
54
+ return accepting;
55
+ },
56
+ };
57
+ }
package/src/slot/index.ts CHANGED
@@ -73,7 +73,7 @@ export { FEATURES, FEATURE_LIST } from './features';
73
73
  export type { ReelFeature, FeatureContext } from './features';
74
74
 
75
75
  export { createReelSystem } from './system/ReelSystem';
76
- export type { ReelSystem, CreateReelSystemOptions } from './system/ReelSystem';
76
+ export type { ReelSystem, CreateReelSystemOptions, StepRunOpts } from './system/ReelSystem';
77
77
 
78
78
  export { pickTier, tierIndexAtValue } from './overlay/tiers';
79
79
  export type { WinTier } from './overlay/tiers';
@@ -35,6 +35,19 @@ export interface CreateReelSystemOptions {
35
35
  features?: ReelFeature[];
36
36
  }
37
37
 
38
+ /** Options for a stepped chain (`cascade` / `reelStep`). */
39
+ export interface StepRunOpts<TStep> {
40
+ turbo?: boolean;
41
+ /** Carry the running multiplier across free spins (with `cascade.multiplier.persistInFreeSpins`). */
42
+ freeSpins?: boolean;
43
+ /**
44
+ * Fires after each step has settled, with the step's index and the running multiplier AFTER it.
45
+ * The hook for a step-by-step payout readout: report the win accrued so far to the shell via
46
+ * `api.shell.reportWin(...)`. Awaited, so an async hook paces the chain.
47
+ */
48
+ onStep?: (index: number, step: TStep, multiplier: number) => void | Promise<void>;
49
+ }
50
+
38
51
  export interface ReelSystem {
39
52
  /** Root container — add this to your scene. */
40
53
  readonly view: Container;
@@ -52,14 +65,16 @@ export interface ReelSystem {
52
65
  /** Replace the whole config. */
53
66
  setConfig(config: ReelSystemConfig): void;
54
67
  spin(target: CellData[][], opts?: SpinRunOpts): Promise<void>;
55
- /** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier carries over instead of resetting. */
56
- cascade(steps: TumbleStep[], opts?: { turbo?: boolean; freeSpins?: boolean }): Promise<void>;
68
+ /** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier
69
+ * carries over instead of resetting. Generic in the step type, so `onStep` hands back the game's
70
+ * own step (with its per-step win) rather than the bare TumbleStep. */
71
+ cascade<TStep extends TumbleStep>(steps: TStep[], opts?: StepRunOpts<TStep>): Promise<void>;
57
72
  /**
58
73
  * Run a ReelStep™ chain: each step pays its winning cells, then scrolls every reel down by
59
74
  * `shifts[col]` positions (0 = reel stays put). Multiplier carries over with `freeSpins` +
60
75
  * `cascade.multiplier.persistInFreeSpins`, same as `cascade`.
61
76
  */
62
- reelStep(steps: ReelStepData[], opts?: { turbo?: boolean; freeSpins?: boolean }): Promise<void>;
77
+ reelStep<TStep extends ReelStepData>(steps: TStep[], opts?: StepRunOpts<TStep>): Promise<void>;
63
78
  /** Current running cascade / reel-step multiplier. */
64
79
  readonly multiplier: number;
65
80
  /** Register a custom feature (or override a built-in by reusing its key). */
@@ -252,6 +267,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
252
267
  for (let i = 0; i < steps.length; i++) {
253
268
  await tumble.step(steps[i], i, cOpts);
254
269
  board = steps[i].settledGrid;
270
+ await cOpts?.onStep?.(i, steps[i], tumble.multiplier);
255
271
  }
256
272
  if (config.cascade.multiplier.enabled) log?.(`Cascade multiplier ×${tumble.multiplier}`);
257
273
  },
@@ -262,6 +278,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
262
278
  for (let i = 0; i < steps.length; i++) {
263
279
  await reelStepCtl.step(steps[i], i, rOpts);
264
280
  board = steps[i].settledGrid;
281
+ await rOpts?.onStep?.(i, steps[i], reelStepCtl.multiplier);
265
282
  }
266
283
  if (config.cascade.multiplier.enabled)
267
284
  log?.(`ReelStep multiplier ×${reelStepCtl.multiplier}`);