Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions client/engine/crazy-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions client/engine/matrix.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -123,6 +151,7 @@
sanitizeMultipliersForMatrix,
forceScatterTrigger,
injectForcedMultiplier,
applyCrazyClustering,
countSymbol,
toPublicMultipliers
};
Expand Down
25 changes: 20 additions & 5 deletions client/engine/spin-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions client/engine/spin-lock.js
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 13 additions & 1 deletion client/engine/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ <h2 id="rulesTitle">Game Rules</h2>
<script src="engine/session-store.js?v=20260523-1"></script>
<script src="engine/spin-engine.js?v=20260523-1"></script>
<script src="engine/simulator.js?v=20260523-1"></script>
<script src="engine/spin-lock.js?v=20260523-1"></script>
<script src="main.js?v=20260523-1"></script>
</body>
</html>
Loading
Loading