From 12ab91ff0683116a716887cbe65552e510285ce9 Mon Sep 17 00:00:00 2001 From: Otar Davitashvili Date: Mon, 22 Jun 2026 00:32:15 +0400 Subject: [PATCH] Spin lock, gravity drops, jackpot-coherent crazy mode, win-beat polish Spin lifecycle: - Add engine/spin-lock.js state machine (idle/starting/animating) and guard spin() before any await so spamming the button can't start concurrent spins (fixes mid-spin glitches). Released on success/error/cancel. Vitest coverage. Drop animation: - Refill cells now start exactly where they last rested (drop the 1.15x overshoot) so they no longer hop upward before falling. - Spin-end board exit uses true t^2 gravity instead of a near-linear slide. - Remove the at-rest pre-draw in animateRound that flashed the generated board for a frame before it dropped. Win beat: - Deliberate sequence per 8+ catch: hold/celebrate (2.5s opening, 1.5s chained tumbles) -> explode fully -> then drop the refill (no overlap), committed synchronously so cleared cells never flash old symbols. - Celebration glow sustains with a shimmer across the hold; winner halo breathes with the pulse. Go Crazy: - Boost premium-symbol density and add same-type clustering so boards visibly flood with high-value symbols (gated entirely behind crazy mode; base RTP unchanged). Co-Authored-By: Claude Opus 4.8 --- client/engine/crazy-mode.js | 41 ++++++++ client/engine/matrix.js | 29 ++++++ client/engine/spin-engine.js | 25 ++++- client/engine/spin-lock.js | 71 +++++++++++++ client/engine/symbols.js | 14 ++- client/index.html | 1 + client/main.js | 176 ++++++++++++++++++++------------ platform/test/spin-lock.test.ts | 79 ++++++++++++++ 8 files changed, 367 insertions(+), 69 deletions(-) create mode 100644 client/engine/spin-lock.js create mode 100644 platform/test/spin-lock.test.ts diff --git a/client/engine/crazy-mode.js b/client/engine/crazy-mode.js index 6889f04..2c653b8 100644 --- a/client/engine/crazy-mode.js +++ b/client/engine/crazy-mode.js @@ -23,6 +23,43 @@ freeSpinPersistentMultiplierStart: 0 }; + // Go Crazy is about EXPECTATION, not just bigger payouts. Base mode makes the + // high-value symbols the rarest (see symbols.js scarcity ladder); crazy mode + // INVERTS that so the premium symbols become the common ones — the board + // visibly fills with the good stuff so every spin feels like a near-jackpot. + // Absolute weights (replace the base ladder entirely when crazy is on). + const CRAZY_SYMBOL_WEIGHTS = { + TOP_CROWN: 1.0, + HOURGLASS: 0.95, + RING: 0.9, + CHALICE: 0.85, + RED_GEM: 0.8, + PURPLE_TRIANGLE: 0.72, + YELLOW_HEX: 0.62, + GREEN_TRIANGLE: 0.56, + BLUE_DIAMOND: 0.5 + }; + + // Same-symbol flood: on SOME crazy spins, pick one premium symbol and stamp a + // cluster of it onto the opening board so the player sees a wall of one + // high-value type — the "I'm about to win huge" rush. Kept occasional (not + // every spin) and modest in size so it builds ANTICIPATION and lands real big + // wins without turning the game into a guaranteed max-cap money printer (which + // would remove all suspense). + const CRAZY_CLUSTER = { + probability: 0.4, + minCells: 5, + maxCells: 8 + }; + + function getSymbolWeightOverrides(crazyMode) { + return crazyMode ? CRAZY_SYMBOL_WEIGHTS : null; + } + + function getClusterPlan(crazyMode) { + return crazyMode ? CRAZY_CLUSTER : null; + } + function getRates({ crazyMode, isFreeSpin, anteEnabled }) { if (crazyMode) { const scatterChance = isFreeSpin @@ -69,8 +106,12 @@ root.SlotEngine = root.SlotEngine || {}; root.SlotEngine.CrazyMode = { CRAZY_OVERRIDES, + CRAZY_SYMBOL_WEIGHTS, + CRAZY_CLUSTER, getRates, getMultiplierWeights, + getSymbolWeightOverrides, + getClusterPlan, getPayoutScalers, shouldForceFreeSpinTrigger, persistentMultiplierStart diff --git a/client/engine/matrix.js b/client/engine/matrix.js index d9472d0..38674da 100644 --- a/client/engine/matrix.js +++ b/client/engine/matrix.js @@ -96,6 +96,34 @@ return { matrix: nextMatrix, multipliers: Array.from(byPos.values()) }; } + function applyCrazyClustering(matrix, plan) { + // Stamp a wall of ONE premium symbol onto the board so a crazy spin + // visibly screams "huge win incoming". Picks the target via the (crazy) + // weighted table, which favors high-value symbols, then floods a random + // batch of regular cells with it. Never touches SCATTER/MULTI cells so + // free-spin and multiplier logic is unaffected. + if (!plan) return matrix; + if (randomFloat() >= Number(plan.probability || 0)) return matrix; + const next = matrix.map((row) => row.slice()); + const target = weightedRegularSymbol(tables); + const minCells = Math.max(1, Number(plan.minCells || 1)); + const maxCells = Math.max(minCells, Number(plan.maxCells || minCells)); + const want = minCells + randomInt(maxCells - minCells + 1); + const total = LAYOUT_ROWS * LAYOUT_REELS; + let converted = 0; + let attempts = 0; + while (converted < want && attempts < total * 5) { + attempts += 1; + const row = randomInt(LAYOUT_ROWS); + const col = randomInt(LAYOUT_REELS); + const cell = next[row][col]; + if (cell === SCATTER_SYMBOL || cell === MULTI_SYMBOL || cell === target) continue; + next[row][col] = target; + converted += 1; + } + return next; + } + function countSymbol(matrix, symbol) { let count = 0; for (let row = 0; row < matrix.length; row += 1) { @@ -123,6 +151,7 @@ sanitizeMultipliersForMatrix, forceScatterTrigger, injectForcedMultiplier, + applyCrazyClustering, countSymbol, toPublicMultipliers }; diff --git a/client/engine/spin-engine.js b/client/engine/spin-engine.js index 0e88732..6741dba 100644 --- a/client/engine/spin-engine.js +++ b/client/engine/spin-engine.js @@ -163,8 +163,9 @@ const { scatterChance, multiChance } = CrazyMode.getRates({ crazyMode, isFreeSpin, anteEnabled }); const multiplierWeights = CrazyMode.getMultiplierWeights(crazyMode); - const ctx = crazyMode || multiplierWeights - ? this._cloneContextForWeights(multiplierWeights) + const symbolWeightOverrides = CrazyMode.getSymbolWeightOverrides(crazyMode); + const ctx = crazyMode || multiplierWeights || symbolWeightOverrides + ? this._cloneContextForWeights(multiplierWeights, symbolWeightOverrides) : this.ctx; let { matrix, multipliers } = ctx.matrixCtx.createMatrixWithMetaRates(scatterChance, multiChance); @@ -178,6 +179,15 @@ } multipliers = ctx.matrixCtx.sanitizeMultipliersForMatrix(matrix, multipliers); + // Crazy Mode: flood the opening board with one premium symbol so the + // player feels a huge win is imminent. Runs AFTER forced scatter/multiplier + // injection (clustering never overwrites SCATTER/MULTI cells) so feature + // triggers are preserved. + if (crazyMode) { + matrix = ctx.matrixCtx.applyCrazyClustering(matrix, CrazyMode.getClusterPlan(crazyMode)); + multipliers = ctx.matrixCtx.sanitizeMultipliersForMatrix(matrix, multipliers); + } + let nearMissInfo = null; if (!isFreeSpin && !crazyMode) { const initialScatterPeek = ctx.matrixCtx.countSymbol(matrix, SCATTER_SYMBOL); @@ -351,10 +361,15 @@ }; } - _cloneContextForWeights(weights) { - if (!weights) return this.ctx; + _cloneContextForWeights(weights, symbolWeightOverrides = null) { + if (!weights && !symbolWeightOverrides) return this.ctx; const { Symbols, Multipliers, Matrix, Payouts, Tumble, NearMiss } = root.SlotEngine; - const tables = this.ctx.tables; + // Crazy symbol overrides rebuild the weighted symbol tables so BOTH the + // initial board and the tumble refills lean premium; otherwise reuse the + // base tables unchanged. + const tables = symbolWeightOverrides + ? Symbols.buildSymbolTables(this.rules, { symbolWeightOverrides }) + : this.ctx.tables; const multiplierValues = this.ctx.multiplierValues; const matrixCtx = Matrix.makeMatrixContext({ rules: this.rules, diff --git a/client/engine/spin-lock.js b/client/engine/spin-lock.js new file mode 100644 index 0000000..31b7e1b --- /dev/null +++ b/client/engine/spin-lock.js @@ -0,0 +1,71 @@ +(function (root) { + "use strict"; + + // Single source of truth for "is a spin currently in flight?". + // + // The spin lifecycle has THREE phases and the lock must cover ALL of them so + // rapid clicks can never start a second concurrent spin: + // + // idle → no spin in flight; a tap may start one. + // starting → bet locked, board dropping off, network round being resolved. + // The result is NOT on screen yet. Taps here are IGNORED (there + // is nothing to fast-stop — the spin hasn't begun visually). + // animating → the result is committed and the reels are playing it out. + // Taps here request a fast-stop (drop everything immediately). + // + // Previously the only guard was `roundAnimating`, which flips true AFTER the + // dropOff + network await — leaving the entire `starting` window unguarded, so + // a second click spawned a whole second spin (the "spin 1, flash of spin 2, + // lightning with no multiplier" glitch). This lock closes that window. + const PHASE = Object.freeze({ + IDLE: "idle", + STARTING: "starting", + ANIMATING: "animating" + }); + + function createSpinLock() { + let phase = PHASE.IDLE; + + return { + PHASE, + /** @returns {string} current lifecycle phase. */ + get phase() { + return phase; + }, + /** True while a spin occupies any non-idle phase. */ + isLocked() { + return phase !== PHASE.IDLE; + }, + /** True only once the result is committed and the reels are animating. */ + isAnimating() { + return phase === PHASE.ANIMATING; + }, + /** + * Attempt to begin a spin. Synchronous and atomic — call it as the very + * first statement of the spin handler, before any `await`. + * @returns {boolean} true if this caller now owns the spin; false if a + * spin is already in flight (caller must NOT proceed). + */ + tryAcquire() { + if (phase !== PHASE.IDLE) return false; + phase = PHASE.STARTING; + return true; + }, + /** Promote the in-flight spin to the visible animation phase. */ + markAnimating() { + if (phase === PHASE.STARTING) phase = PHASE.ANIMATING; + }, + /** Release the lock. Safe to call from any phase (success/error/cancel). */ + release() { + phase = PHASE.IDLE; + } + }; + } + + root.SlotEngine = root.SlotEngine || {}; + root.SlotEngine.SpinLock = { createSpinLock, PHASE }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = { createSpinLock, PHASE }; + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/client/engine/symbols.js b/client/engine/symbols.js index 950fd75..db4e000 100644 --- a/client/engine/symbols.js +++ b/client/engine/symbols.js @@ -4,7 +4,11 @@ const SCATTER_SYMBOL = "SCATTER"; const MULTI_SYMBOL = "MULTI"; - function buildSymbolTables(rules) { + function buildSymbolTables(rules, options = {}) { + // `symbolWeightOverrides` (code → absolute weight) replaces the default + // scarcity ladder for any symbol it names. Used by Crazy Mode to make the + // premium symbols common (see crazy-mode.js CRAZY_SYMBOL_WEIGHTS). + const overrides = options.symbolWeightOverrides || null; const symbolPayouts = new Map(); const scatterPayouts = {}; const regularSymbols = []; @@ -31,6 +35,14 @@ else if (symbol.includes("YELLOW_HEX")) w = 0.9; else if (symbol.includes("GREEN_TRIANGLE")) w = 0.95; else if (symbol.includes("BLUE_DIAMOND")) w = 1.0; + if (overrides) { + for (const [code, weight] of Object.entries(overrides)) { + if (symbol.includes(code) && Number.isFinite(Number(weight))) { + w = Number(weight); + break; + } + } + } symbolWeights.set(symbol, w); } return { symbolPayouts, scatterPayouts, regularSymbols, symbolWeights }; diff --git a/client/index.html b/client/index.html index 2255cad..e66efbb 100644 --- a/client/index.html +++ b/client/index.html @@ -330,6 +330,7 @@

Game Rules

+ diff --git a/client/main.js b/client/main.js index f2cf1fc..1809d69 100644 --- a/client/main.js +++ b/client/main.js @@ -138,6 +138,11 @@ const state = { autoplayInitial: 0 }; +// Single lifecycle lock guarding the whole spin (dropOff → network → animation). +// Set the instant a spin is requested — before any await — so spamming the spin +// button can never start a second concurrent spin. See engine/spin-lock.js. +const spinLock = window.SlotEngine.SpinLock.createSpinLock(); + const rows = 5; const cols = 6; const payoutGroups = ["8-9", "10-11", "12-30"]; @@ -194,26 +199,19 @@ const ANIMATION_TIMING = { spinBurst: 420, oldBoardDropOff: 820, introDrop: 820, - // Tumble cascade: refill the blasted cells with CONTINUOUS motion. The drop - // now starts while the explosion is still flashing (its particles mask the - // emptied cells) and falls quickly, so incoming symbols arrive as the blast - // clears instead of leaving a ~0.5s empty gap. tumbleDropOverlap = how long - // after the explode begins the new symbols start falling; tumbleDrop = fall - // duration. Tune live for feel. + // Tumble cascade refill fall duration. The drop now runs AFTER the explosion + // has fully finished (see the deliberate win beat below), so the refill reads + // as a clean gravity drop into the cleared cells rather than overlapping the + // burst. Tune live for feel. tumbleDrop: 640, - // Smaller overlap so the surviving symbols above the blast start falling - // almost WITH the explosion instead of freezing in place for ~0.2s and then - // dropping (which read as a sharp hitch). The explosion particles still mask - // the emptied cells, and falling cells are immune to the blast fade (#3), so - // the cascade now flows like the spin-start drop the player likes. - tumbleDropOverlap: 120, - markSmall: 600, - markMedium: 680, - markGreat: 800, - // Large clusters (8+ caught symbols) hold the mark longer so the player can - // clearly read every symbol in the match before it explodes. The base mark - // above is extended by markPerExtraSymbol for each symbol past the threshold, - // capped by markBigClusterCap so very large boards stay responsive. + // Deliberate win beat (no overlap): the caught cluster is CELEBRATED (held + + // glowing) for a clear moment, THEN it explodes, and only once the explosion + // fully finishes do the refill symbols drop in. celebrateHoldFirst is the + // headline hold for the first/opening catch; chained tumble catches use the + // shorter celebrateHoldChain so long cascades keep their momentum. Very large + // clusters get a little extra read-time (markPerExtraSymbol, capped). + celebrateHoldFirst: 2500, + celebrateHoldChain: 1500, markBigClusterThreshold: 8, markPerExtraSymbol: 34, markBigClusterCap: 540, @@ -1390,17 +1388,25 @@ class ReelCanvasRenderer { if (count <= 0) return 0; const delay = this.dropDelay(row, col, count); const elapsed = performance.now() - drop.start - delay; - const distance = count * rowStep * 1.15; + // A refilling symbol must START exactly where it was last seen — one cell + // per `count` directly above its destination — so the motion is continuous. + // The old `* 1.15` overshoot started it ~0.15 cell HIGHER than its prior + // resting spot, so at drop-commit the symbol visibly hopped UP before + // falling (the reported glitch). Exit drops keep a little extra travel so + // symbols fully clear the board on the way out. + const distance = count * rowStep; + const exitDistance = count * rowStep * 1.15; if (drop.exit && elapsed < 0) return 0; if (elapsed < 0) return -distance; const progress = clamp(elapsed / drop.duration, 0, 1); if (drop.exit) { - // Spin-end exit: symbols fall OUT of the board under gravity. The old - // progress**4 curve left them hovering motionless then yanked them away - // (sharp). This starts moving immediately with a small velocity and - // accelerates downward — a smooth, readable "drop out of the game". - const exitT = progress * (0.25 + 0.75 * progress); - return lerp(0, distance, exitT); + // Spin-end exit: symbols fall OUT of the board under real gravity. From + // rest, displacement under constant acceleration is ∝ t², so the symbols + // start nearly still and accelerate downward — the satisfying "the floor + // dropped out" feel, instead of the near-linear constant-speed slide the + // old curve produced. + const exitT = progress * progress; + return lerp(0, exitDistance, exitT); } // Gravity-style fall: the symbol ACCELERATES downward like a real drop // instead of the old decelerating ease (which read as a sharp snap). It @@ -1616,9 +1622,23 @@ class ReelCanvasRenderer { const pulse = this.fx.pulse ? clamp((performance.now() - this.fx.pulse.start) / this.fx.pulse.duration, 0, 1) : 0; - // Sharper attack, longer glow tail — feels punchier than a symmetric sine. + // Celebration glow: quick attack, then SUSTAIN at a high plateau with a + // gentle shimmer for the whole (now multi-second) hold, then a short + // release at the tail. The old curve faded back to 0 by the end, so a 2.5s + // celebration visibly "died" before the explosion — this keeps the caught + // cluster alive and breathing right up until it bursts. const pulseGlow = this.fx.pulse - ? (pulse < 0.28 ? easeOutCubic(pulse / 0.28) : 1 - easeInOutCubic((pulse - 0.28) / 0.72)) + ? (() => { + const attack = 0.1; + const release = 0.16; + const plateau = 0.84; + let env; + if (pulse < attack) env = easeOutCubic(pulse / attack); + else if (pulse > 1 - release) env = 1 - easeInOutCubic((pulse - (1 - release)) / release); + else env = 1; + const shimmer = 0.16 * (0.5 + 0.5 * Math.sin((performance.now() - this.fx.pulse.start) * 0.011)); + return clamp(env * (plateau + shimmer), 0, 1); + })() : 0; const blast = this.fx.blast ? clamp((performance.now() - this.fx.blast.start) / this.fx.blast.duration, 0, 1) @@ -1995,9 +2015,13 @@ class ReelCanvasRenderer { } if (isWinner) { + // Halo breathes with the celebration pulse so the held cluster reads + // as actively celebrating, not statically lit. A floor keeps it + // clearly marked even at the shimmer's low point. + const haloPulse = 0.55 + 0.45 * pulseGlow; const halo = ctx.createRadialGradient(x, yFloat, coreR * 0.28, x, yFloat, ringR * 1.55); - halo.addColorStop(0, `rgba(215, 236, 255, ${0.6 * blastFade})`); - halo.addColorStop(0.35, `rgba(150, 188, 255, ${0.24 * blastFade})`); + halo.addColorStop(0, `rgba(215, 236, 255, ${0.6 * blastFade * haloPulse})`); + halo.addColorStop(0.35, `rgba(150, 188, 255, ${0.24 * blastFade * haloPulse})`); halo.addColorStop(1, "rgba(150, 188, 255, 0)"); ctx.fillStyle = halo; ctx.beginPath(); @@ -3179,10 +3203,6 @@ function renderCaughtLines(wins = [], multipliers = []) { }); } -function delayedDropAfter(ms, ...dropArgs) { - return animationSleep(ms).then(() => reelRenderer.drop(...dropArgs)); -} - function scatterPositionsFromPayload(payload = {}) { const steps = Array.isArray(payload.tumble_steps) && payload.tumble_steps.length ? payload.tumble_steps @@ -3480,6 +3500,9 @@ function applyRoundStats(payload, bet, wagerOverride) { } async function animateRound(payload, bet, wagerOverride, options = {}) { + // The result is now committed and the reels are about to play it out: promote + // the lock to the ANIMATING phase so a tap from here on means "fast-stop". + spinLock.markAnimating(); state.roundAnimating = true; el.spinBtn?.classList.add("is-spinning"); el.spinBtn?.setAttribute("aria-label", "Stop spin"); @@ -3534,11 +3557,14 @@ async function animateRound(payload, bet, wagerOverride, options = {}) { } else { await reelRenderer.dropOff(); } - // Commit the new spin's first matrix to the canvas immediately so the - // visible board is always the current payload, even if intro is aborted - // or the prior round left fx state mid-animation. + // Make the new matrix current WITHOUT drawing it at rest. The old code + // force-drew the full board in its resting position here, one frame before + // the intro drop began — so the player saw the generated rows sitting in + // place and then snap upward to fall (the "I see the rows right before they + // fall" glitch). intro() below commits the board together with the fall + // offset on the same synchronous tick, so the very first painted frame + // already shows the symbols entering from above — no resting flash, no blank. reelRenderer.setBoard(steps[0].matrix, { multipliers: steps[0].multipliers || [] }); - reelRenderer.forceDrawNow(); try { const m = steps[0].matrix || []; const tag = `[anim] id=${String(payload?.spin_id || "?").slice(0, 8)} m[0][0]=${m?.[0]?.[0] || "?"}`; @@ -3586,41 +3612,52 @@ async function animateRound(payload, bet, wagerOverride, options = {}) { ? reelRenderer.celebrateCluster(prevWinning, prevDominant, prevTier) : Promise.resolve(); const dropMap = buildDropMap(prev.matrix, prevWinning); - const dropPromise = delayedDropAfter( - Math.round(ANIMATION_TIMING.tumbleDropOverlap * turboScale()), + // Deliberate beat (no overlap): + // 1) the caught cluster was just held + celebrated (the 2.5s mark), + // 2) it EXPLODES here and we wait for the burst to fully finish, + // 3) only THEN do the refill symbols drop in. + // The win chip + cluster celebration ride along with the burst. The + // drop is awaited immediately after the explode resolves (no awaited + // gap on the slower chip/celebrate promises in between) so the cleared + // cells can never flash the old symbols back before the refill commits. + await reelRenderer.explode( + prevWinning, + Math.round(ANIMATION_TIMING.explode * turboScale()), + prevTier + ); + await reelRenderer.drop( step.matrix, step.multipliers || [], dropMap, Math.round(ANIMATION_TIMING.tumbleDrop * turboScale()), { animateMultipliers: true } ); - await Promise.all([ - reelRenderer.explode(prevWinning, Math.round(ANIMATION_TIMING.explode * turboScale()), prevTier), - chipPromise, - celebratePromise, - dropPromise - ]); + await Promise.all([chipPromise, celebratePromise]); } else if (prevWinning.length === 0 && Array.isArray(prev?.multipliers) && prev.multipliers.length > 0) { // multipliers may persist on a non-winning matrix; nothing to do. } } const tier = winTier(step?.ways_wins || [], bet); - let pulseDuration = tier === "blast-great" - ? ANIMATION_TIMING.markGreat - : tier === "blast-medium" - ? ANIMATION_TIMING.markMedium - : ANIMATION_TIMING.markSmall; - // Slow the mark further the more symbols were caught (8+), so the player - // can clearly see every symbol that is part of a large match before it - // resolves. Scales with cluster size and is capped to stay responsive. + const stepWin = Number(step?.win_total || 0); + // Celebration hold for the caught cluster. Every win here is an 8+ catch + // (min match = 8), so the headline opening catch is held for the full + // celebrateHoldFirst (2.5s) and chained tumble catches use the shorter + // celebrateHoldChain so a long cascade keeps moving. Very large clusters + // get a little extra read-time, capped, so huge boards stay watchable. const markedCount = (step.winning_positions || []).length; - if (markedCount >= ANIMATION_TIMING.markBigClusterThreshold) { - pulseDuration += Math.min( - ANIMATION_TIMING.markBigClusterCap, - (markedCount - ANIMATION_TIMING.markBigClusterThreshold) * ANIMATION_TIMING.markPerExtraSymbol - ); + const isWinStep = markedCount > 0 && stepWin > 0; + let pulseDuration = 0; + if (isWinStep) { + pulseDuration = i === 0 + ? ANIMATION_TIMING.celebrateHoldFirst + : ANIMATION_TIMING.celebrateHoldChain; + if (markedCount >= ANIMATION_TIMING.markBigClusterThreshold) { + pulseDuration += Math.min( + ANIMATION_TIMING.markBigClusterCap, + (markedCount - ANIMATION_TIMING.markBigClusterThreshold) * ANIMATION_TIMING.markPerExtraSymbol + ); + } } - const stepWin = Number(step?.win_total || 0); const stepMaxMultiplier = maxMultiplierInStep(step); const mTier = multiplierEventTier(stepMaxMultiplier); if ((step.winning_positions || []).length && stepWin > 0) { @@ -3822,9 +3859,18 @@ async function spin(options = {}) { stopAutoplay(); return; } - if (state.roundAnimating) { - requestFastStop(); - return; + // Lifecycle lock. Autoplay spins are already serialized by their driving + // loop (and manual interference is blocked by the guards above), so only + // manual spins contend for the lock. Acquire BEFORE any await so a rapid + // double-tap can never slip a second spin through the network window. + const manual = !options.autoplay; + if (manual) { + if (!spinLock.tryAcquire()) { + // Already in flight: once the result is visibly animating a tap means + // "fast-stop"; during the pre-animation/network window it's a no-op. + if (spinLock.isAnimating()) requestFastStop(); + return; + } } state.fastStopRequested = false; el.spinBtn?.classList.remove("is-fast-stopping"); @@ -3882,6 +3928,10 @@ async function spin(options = {}) { el.resultDump.textContent = `Error: ${err.message}`; pushGameMessage(`Spin error: ${err.message}`, "error"); } finally { + // Release reliably on every exit path — success, error, or fast-stop + // cancel. Held across the trailing bonus flow above so manual taps stay + // blocked until the whole lifecycle (including bonus autoplay) settles. + if (manual) spinLock.release(); if (!state.bonusAutoplay) setControls(false); } } diff --git a/platform/test/spin-lock.test.ts b/platform/test/spin-lock.test.ts new file mode 100644 index 0000000..a860d86 --- /dev/null +++ b/platform/test/spin-lock.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +// The lock is the single source of truth for the client spin lifecycle. It is a +// pure state machine (no DOM), so we exercise it directly here. The same file is +// loaded into the browser as an IIFE; this CommonJS export is the test seam. +import { createSpinLock, PHASE } from "../../client/engine/spin-lock.js"; + +describe("spin lifecycle lock", () => { + it("starts idle and unlocked", () => { + const lock = createSpinLock(); + expect(lock.phase).toBe(PHASE.IDLE); + expect(lock.isLocked()).toBe(false); + expect(lock.isAnimating()).toBe(false); + }); + + it("admits exactly one spin under a burst of rapid acquire attempts", () => { + const lock = createSpinLock(); + // Simulate spamming the spin button: 20 synchronous taps before release. + const results = Array.from({ length: 20 }, () => lock.tryAcquire()); + const acquired = results.filter(Boolean); + expect(acquired).toHaveLength(1); + expect(results[0]).toBe(true); // the first tap wins + expect(lock.isLocked()).toBe(true); + }); + + it("ignores taps during the starting (network) window, then releases", () => { + const lock = createSpinLock(); + expect(lock.tryAcquire()).toBe(true); + expect(lock.phase).toBe(PHASE.STARTING); + // While starting, a tap neither re-acquires nor counts as animating, so the + // caller treats it as a no-op (not a fast-stop). + expect(lock.tryAcquire()).toBe(false); + expect(lock.isAnimating()).toBe(false); + lock.release(); + expect(lock.phase).toBe(PHASE.IDLE); + }); + + it("reports the animating phase once the result is committed", () => { + const lock = createSpinLock(); + lock.tryAcquire(); + lock.markAnimating(); + expect(lock.phase).toBe(PHASE.ANIMATING); + expect(lock.isAnimating()).toBe(true); + // A tap now would be routed to fast-stop by the caller; the lock itself + // still refuses to start a second spin. + expect(lock.tryAcquire()).toBe(false); + }); + + it("markAnimating is a no-op from idle (cannot animate without acquiring)", () => { + const lock = createSpinLock(); + lock.markAnimating(); + expect(lock.phase).toBe(PHASE.IDLE); + }); + + it("re-admits a new spin after release (success path)", () => { + const lock = createSpinLock(); + lock.tryAcquire(); + lock.markAnimating(); + lock.release(); + expect(lock.tryAcquire()).toBe(true); + }); + + it("release is safe and idempotent from any phase (error/cancel paths)", () => { + const lock = createSpinLock(); + lock.tryAcquire(); + lock.release(); + lock.release(); // double release must not throw or wedge the lock + expect(lock.isLocked()).toBe(false); + expect(lock.tryAcquire()).toBe(true); + }); + + it("keeps independent lock instances isolated", () => { + const a = createSpinLock(); + const b = createSpinLock(); + a.tryAcquire(); + expect(a.isLocked()).toBe(true); + expect(b.isLocked()).toBe(false); + expect(b.tryAcquire()).toBe(true); + }); +});