| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- """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
|