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 @@