"""Deterministic math-package builder for slot prototypes. This is not a legal certification by itself. It creates the reproducible math artifacts a lab normally needs: RNG spec, reel strips, pay scaling, simulation summary, and a stable hash of the math model. """ import copy import hashlib import json import random TARGET_RTP_BY_VOL = {"low": 0.94, "medium": 0.96, "high": 0.965} SAMPLES_BY_VOL = {"low": 2500, "medium": 4000, "high": 6000} def _stable_hash(data): payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _target_rtp(slot_config): value = slot_config.get("mathProfile", {}).get("rtpTarget") if value is None: value = TARGET_RTP_BY_VOL.get(slot_config.get("mathProfile", {}).get("volatility"), 0.96) try: value = float(value) except (TypeError, ValueError): value = 0.96 if value > 1: value = value / 100.0 return max(0.5, min(0.995, value)) def _role_map(slot_config): return {s["id"]: s.get("role", "regular") for s in slot_config.get("symbols", [])} def _is(role_map, sid, role): if role == "bonus": return role_map.get(sid) == "bonus" or "coin" in sid or sid == "collect" if role == "scatter": return role_map.get(sid) == "scatter" or "scatter" in sid if role == "wild": return role_map.get(sid) == "wild" or sid == "wild" return role_map.get(sid) == role def _build_reel_strips(slot_config, seed): cols = int(slot_config["reels"]["columns"]) strip_len = 64 if cols <= 5 else 72 rng = random.Random(seed) symbols = slot_config.get("symbols", []) weighted = [] for item in symbols: weight = max(1, int(item.get("weight", 1))) weighted.extend([item["id"]] * weight) if not weighted: weighted = ["symbol"] strips = [] for c in range(cols): reel = [weighted[(i * 7 + c * 11) % len(weighted)] for i in range(strip_len)] rng.shuffle(reel) strips.append(reel) return strips def _draw_grid(strips, rows, rng): grid = [] for reel in strips: start = rng.randrange(len(reel)) grid.append([reel[(start + r) % len(reel)] for r in range(rows)]) return grid def _count(grid): out = {} for col in grid: for sid in col: out[sid] = out.get(sid, 0) + 1 return out def _find_wins(slot_config, grid): role_map = _role_map(slot_config) counts = _count(grid) wilds = sum(v for k, v in counts.items() if _is(role_map, k, "wild")) min_match = int(slot_config.get("winRules", {}).get("minMatch", 4)) if slot_config.get("winRules", {}).get("evaluation") == "cluster_count": cols, rows = len(grid), len(grid[0]) if grid else 0 groups = [] regulars = [s["id"] for s in slot_config.get("symbols", []) if s.get("role") == "regular"] for sid in regulars: seen = set() for c in range(cols): for r in range(rows): if (c, r) in seen: continue if grid[c][r] != sid and not _is(role_map, grid[c][r], "wild"): continue stack = [(c, r)] seen.add((c, r)) n = 0 while stack: x, y = stack.pop() n += 1 for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): if nx < 0 or nx >= cols or ny < 0 or ny >= rows or (nx, ny) in seen: continue if grid[nx][ny] == sid or _is(role_map, grid[nx][ny], "wild"): seen.add((nx, ny)) stack.append((nx, ny)) if n >= min_match: groups.append((sid, min(6, n))) return groups wins = [] for sid, n in counts.items(): if _is(role_map, sid, "wild") or _is(role_map, sid, "scatter") or _is(role_map, sid, "bonus"): continue if n + wilds >= min_match: wins.append((sid, min(6, max(min_match, n + wilds)))) return wins def _table_pay(slot_config, wins): paytable = slot_config.get("paytable", {}) total = 0.0 for sid, n in wins: table = paytable.get(sid, {}) total += float(table.get(str(n), table.get(str(slot_config.get("winRules", {}).get("minMatch", 4)), n)) or 0) return total def _feature_pay(slot_config, sid, count): table = slot_config.get("paytable", {}).get(sid, {}) n = min(6, max(3, count)) return float(table.get(str(n), 0) or 0) def _hold_raw_pay(slot_config, grid, rng): features = slot_config.get("features", {}) rules = features.get("holdAndWin", {}) if not rules.get("enabled"): return 0.0 role_map = _role_map(slot_config) cols, rows = len(grid), len(grid[0]) if grid else 0 held = [[_is(role_map, grid[c][r], "bonus") for r in range(rows)] for c in range(cols)] if sum(1 for c in range(cols) for r in range(rows) if held[c][r]) < int(rules.get("triggerCount", 6)): return 0.0 respins = int(rules.get("respins", 3)) total = sum(1 + ((c + r) % 5) for c in range(cols) for r in range(rows) if held[c][r]) while respins > 0: new_coin = False for c in range(cols): for r in range(rows): if held[c][r]: continue if rng.random() < 0.18: held[c][r] = True total += 1 + ((c + r) % 5) new_coin = True respins = int(rules.get("respins", 3)) if new_coin else respins - 1 if all(held[c][r] for c in range(cols) for r in range(rows)): break return float(total) def _simulate_raw(slot_config, strips, seed, base_spins): rng = random.Random(seed) rows = int(slot_config["reels"]["rows"]) scatter_rules = slot_config.get("features", {}).get("scatterFreeSpins", {}) scatter_trigger = int(scatter_rules.get("triggerCount", 3)) scatter_award = int(scatter_rules.get("awardSpins", 8)) role_map = _role_map(slot_config) total_pay = 0.0 hit_spins = 0 free_spins_awarded = 0 free_spins_played = 0 hold_triggers = 0 spin_pays = [] for _ in range(base_spins): queue = [False] free_guard = 0 while queue: is_free = queue.pop(0) if is_free: free_spins_played += 1 grid = _draw_grid(strips, rows, rng) counts = _count(grid) pay = _table_pay(slot_config, _find_wins(slot_config, grid)) if scatter_rules.get("enabled"): scatter_count = sum(v for k, v in counts.items() if _is(role_map, k, "scatter")) if scatter_count >= scatter_trigger: pay += _feature_pay(slot_config, "scatter", scatter_count) award = min(scatter_award, 120 - free_guard) if award > 0: free_spins_awarded += award free_guard += award queue.extend([True] * award) hold_pay = _hold_raw_pay(slot_config, grid, rng) if hold_pay > 0: hold_triggers += 1 pay += hold_pay total_pay += pay if pay > 0: hit_spins += 1 spin_pays.append(pay) mean = total_pay / max(1, base_spins) variance = sum((p - mean) ** 2 for p in spin_pays) / max(1, len(spin_pays)) return { "rawRtp": mean, "hitFrequency": hit_spins / max(1, len(spin_pays)), "stdDevPerSpin": variance ** 0.5, "baseSpins": base_spins, "totalResolvedSpins": len(spin_pays), "freeSpinsAwarded": free_spins_awarded, "freeSpinsPlayed": free_spins_played, "holdAndWinTriggers": hold_triggers, } def build_math_model(slot_config): cfg = copy.deepcopy(slot_config) target_rtp = _target_rtp(cfg) seed_source = { "game": cfg.get("game", {}).get("id"), "reels": cfg.get("reels"), "symbols": cfg.get("symbols"), "features": cfg.get("features"), "targetRtp": target_rtp, } seed = int(_stable_hash(seed_source)[:12], 16) strips = _build_reel_strips(cfg, seed) samples = SAMPLES_BY_VOL.get(cfg.get("mathProfile", {}).get("volatility"), 30000) raw = _simulate_raw(cfg, strips, seed + 1, samples) raw_rtp = max(0.0001, raw["rawRtp"]) payout_scale = target_rtp / raw_rtp report = { "targetRtp": round(target_rtp, 6), "rawRtpBeforeScale": round(raw["rawRtp"], 6), "estimatedRtp": round(raw["rawRtp"] * payout_scale, 6), "payoutScale": round(payout_scale, 8), "hitFrequency": round(raw["hitFrequency"], 6), "stdDevPerSpinBeforeScale": round(raw["stdDevPerSpin"], 6), "stdDevPerSpin": round(raw["stdDevPerSpin"] * payout_scale, 6), "baseSpins": raw["baseSpins"], "totalResolvedSpins": raw["totalResolvedSpins"], "freeSpinsAwarded": raw["freeSpinsAwarded"], "freeSpinsPlayed": raw["freeSpinsPlayed"], "holdAndWinTriggers": raw["holdAndWinTriggers"], } math_model = { "status": "certification_candidate_not_lab_certified", "rng": { "algorithm": "deterministic_seeded_mt19937_for_simulation", "productionRequirement": "replace_with_regulator_approved_csprng_or_platform_rng", "seed": seed, }, "reelStrips": strips, "simulation": report, "payoutScale": report["payoutScale"], "notes": [ "Generated by deterministic workflow for review and lab handoff.", "This package is not a legal certification result without third-party lab approval.", ], } math_model["modelHash"] = _stable_hash(math_model) return math_model