slot_math.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """Deterministic math-package builder for slot prototypes.
  2. This is not a legal certification by itself. It creates the reproducible math
  3. artifacts a lab normally needs: RNG spec, reel strips, pay scaling, simulation
  4. summary, and a stable hash of the math model.
  5. """
  6. import copy
  7. import hashlib
  8. import json
  9. import random
  10. TARGET_RTP_BY_VOL = {"low": 0.94, "medium": 0.96, "high": 0.965}
  11. SAMPLES_BY_VOL = {"low": 2500, "medium": 4000, "high": 6000}
  12. def _stable_hash(data):
  13. payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
  14. return hashlib.sha256(payload).hexdigest()
  15. def _target_rtp(slot_config):
  16. value = slot_config.get("mathProfile", {}).get("rtpTarget")
  17. if value is None:
  18. value = TARGET_RTP_BY_VOL.get(slot_config.get("mathProfile", {}).get("volatility"), 0.96)
  19. try:
  20. value = float(value)
  21. except (TypeError, ValueError):
  22. value = 0.96
  23. if value > 1:
  24. value = value / 100.0
  25. return max(0.5, min(0.995, value))
  26. def _role_map(slot_config):
  27. return {s["id"]: s.get("role", "regular") for s in slot_config.get("symbols", [])}
  28. def _is(role_map, sid, role):
  29. if role == "bonus":
  30. return role_map.get(sid) == "bonus" or "coin" in sid or sid == "collect"
  31. if role == "scatter":
  32. return role_map.get(sid) == "scatter" or "scatter" in sid
  33. if role == "wild":
  34. return role_map.get(sid) == "wild" or sid == "wild"
  35. return role_map.get(sid) == role
  36. def _build_reel_strips(slot_config, seed):
  37. cols = int(slot_config["reels"]["columns"])
  38. strip_len = 64 if cols <= 5 else 72
  39. rng = random.Random(seed)
  40. symbols = slot_config.get("symbols", [])
  41. weighted = []
  42. for item in symbols:
  43. weight = max(1, int(item.get("weight", 1)))
  44. weighted.extend([item["id"]] * weight)
  45. if not weighted:
  46. weighted = ["symbol"]
  47. strips = []
  48. for c in range(cols):
  49. reel = [weighted[(i * 7 + c * 11) % len(weighted)] for i in range(strip_len)]
  50. rng.shuffle(reel)
  51. strips.append(reel)
  52. return strips
  53. def _draw_grid(strips, rows, rng):
  54. grid = []
  55. for reel in strips:
  56. start = rng.randrange(len(reel))
  57. grid.append([reel[(start + r) % len(reel)] for r in range(rows)])
  58. return grid
  59. def _count(grid):
  60. out = {}
  61. for col in grid:
  62. for sid in col:
  63. out[sid] = out.get(sid, 0) + 1
  64. return out
  65. def _find_wins(slot_config, grid):
  66. role_map = _role_map(slot_config)
  67. counts = _count(grid)
  68. wilds = sum(v for k, v in counts.items() if _is(role_map, k, "wild"))
  69. min_match = int(slot_config.get("winRules", {}).get("minMatch", 4))
  70. if slot_config.get("winRules", {}).get("evaluation") == "cluster_count":
  71. cols, rows = len(grid), len(grid[0]) if grid else 0
  72. groups = []
  73. regulars = [s["id"] for s in slot_config.get("symbols", []) if s.get("role") == "regular"]
  74. for sid in regulars:
  75. seen = set()
  76. for c in range(cols):
  77. for r in range(rows):
  78. if (c, r) in seen:
  79. continue
  80. if grid[c][r] != sid and not _is(role_map, grid[c][r], "wild"):
  81. continue
  82. stack = [(c, r)]
  83. seen.add((c, r))
  84. n = 0
  85. while stack:
  86. x, y = stack.pop()
  87. n += 1
  88. for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
  89. if nx < 0 or nx >= cols or ny < 0 or ny >= rows or (nx, ny) in seen:
  90. continue
  91. if grid[nx][ny] == sid or _is(role_map, grid[nx][ny], "wild"):
  92. seen.add((nx, ny))
  93. stack.append((nx, ny))
  94. if n >= min_match:
  95. groups.append((sid, min(6, n)))
  96. return groups
  97. wins = []
  98. for sid, n in counts.items():
  99. if _is(role_map, sid, "wild") or _is(role_map, sid, "scatter") or _is(role_map, sid, "bonus"):
  100. continue
  101. if n + wilds >= min_match:
  102. wins.append((sid, min(6, max(min_match, n + wilds))))
  103. return wins
  104. def _table_pay(slot_config, wins):
  105. paytable = slot_config.get("paytable", {})
  106. total = 0.0
  107. for sid, n in wins:
  108. table = paytable.get(sid, {})
  109. total += float(table.get(str(n), table.get(str(slot_config.get("winRules", {}).get("minMatch", 4)), n)) or 0)
  110. return total
  111. def _feature_pay(slot_config, sid, count):
  112. table = slot_config.get("paytable", {}).get(sid, {})
  113. n = min(6, max(3, count))
  114. return float(table.get(str(n), 0) or 0)
  115. def _hold_raw_pay(slot_config, grid, rng):
  116. features = slot_config.get("features", {})
  117. rules = features.get("holdAndWin", {})
  118. if not rules.get("enabled"):
  119. return 0.0
  120. role_map = _role_map(slot_config)
  121. cols, rows = len(grid), len(grid[0]) if grid else 0
  122. held = [[_is(role_map, grid[c][r], "bonus") for r in range(rows)] for c in range(cols)]
  123. if sum(1 for c in range(cols) for r in range(rows) if held[c][r]) < int(rules.get("triggerCount", 6)):
  124. return 0.0
  125. respins = int(rules.get("respins", 3))
  126. total = sum(1 + ((c + r) % 5) for c in range(cols) for r in range(rows) if held[c][r])
  127. while respins > 0:
  128. new_coin = False
  129. for c in range(cols):
  130. for r in range(rows):
  131. if held[c][r]:
  132. continue
  133. if rng.random() < 0.18:
  134. held[c][r] = True
  135. total += 1 + ((c + r) % 5)
  136. new_coin = True
  137. respins = int(rules.get("respins", 3)) if new_coin else respins - 1
  138. if all(held[c][r] for c in range(cols) for r in range(rows)):
  139. break
  140. return float(total)
  141. def _simulate_raw(slot_config, strips, seed, base_spins):
  142. rng = random.Random(seed)
  143. rows = int(slot_config["reels"]["rows"])
  144. scatter_rules = slot_config.get("features", {}).get("scatterFreeSpins", {})
  145. scatter_trigger = int(scatter_rules.get("triggerCount", 3))
  146. scatter_award = int(scatter_rules.get("awardSpins", 8))
  147. role_map = _role_map(slot_config)
  148. total_pay = 0.0
  149. hit_spins = 0
  150. free_spins_awarded = 0
  151. free_spins_played = 0
  152. hold_triggers = 0
  153. spin_pays = []
  154. for _ in range(base_spins):
  155. queue = [False]
  156. free_guard = 0
  157. while queue:
  158. is_free = queue.pop(0)
  159. if is_free:
  160. free_spins_played += 1
  161. grid = _draw_grid(strips, rows, rng)
  162. counts = _count(grid)
  163. pay = _table_pay(slot_config, _find_wins(slot_config, grid))
  164. if scatter_rules.get("enabled"):
  165. scatter_count = sum(v for k, v in counts.items() if _is(role_map, k, "scatter"))
  166. if scatter_count >= scatter_trigger:
  167. pay += _feature_pay(slot_config, "scatter", scatter_count)
  168. award = min(scatter_award, 120 - free_guard)
  169. if award > 0:
  170. free_spins_awarded += award
  171. free_guard += award
  172. queue.extend([True] * award)
  173. hold_pay = _hold_raw_pay(slot_config, grid, rng)
  174. if hold_pay > 0:
  175. hold_triggers += 1
  176. pay += hold_pay
  177. total_pay += pay
  178. if pay > 0:
  179. hit_spins += 1
  180. spin_pays.append(pay)
  181. mean = total_pay / max(1, base_spins)
  182. variance = sum((p - mean) ** 2 for p in spin_pays) / max(1, len(spin_pays))
  183. return {
  184. "rawRtp": mean,
  185. "hitFrequency": hit_spins / max(1, len(spin_pays)),
  186. "stdDevPerSpin": variance ** 0.5,
  187. "baseSpins": base_spins,
  188. "totalResolvedSpins": len(spin_pays),
  189. "freeSpinsAwarded": free_spins_awarded,
  190. "freeSpinsPlayed": free_spins_played,
  191. "holdAndWinTriggers": hold_triggers,
  192. }
  193. def build_math_model(slot_config):
  194. cfg = copy.deepcopy(slot_config)
  195. target_rtp = _target_rtp(cfg)
  196. seed_source = {
  197. "game": cfg.get("game", {}).get("id"),
  198. "reels": cfg.get("reels"),
  199. "symbols": cfg.get("symbols"),
  200. "features": cfg.get("features"),
  201. "targetRtp": target_rtp,
  202. }
  203. seed = int(_stable_hash(seed_source)[:12], 16)
  204. strips = _build_reel_strips(cfg, seed)
  205. samples = SAMPLES_BY_VOL.get(cfg.get("mathProfile", {}).get("volatility"), 30000)
  206. raw = _simulate_raw(cfg, strips, seed + 1, samples)
  207. raw_rtp = max(0.0001, raw["rawRtp"])
  208. payout_scale = target_rtp / raw_rtp
  209. report = {
  210. "targetRtp": round(target_rtp, 6),
  211. "rawRtpBeforeScale": round(raw["rawRtp"], 6),
  212. "estimatedRtp": round(raw["rawRtp"] * payout_scale, 6),
  213. "payoutScale": round(payout_scale, 8),
  214. "hitFrequency": round(raw["hitFrequency"], 6),
  215. "stdDevPerSpinBeforeScale": round(raw["stdDevPerSpin"], 6),
  216. "stdDevPerSpin": round(raw["stdDevPerSpin"] * payout_scale, 6),
  217. "baseSpins": raw["baseSpins"],
  218. "totalResolvedSpins": raw["totalResolvedSpins"],
  219. "freeSpinsAwarded": raw["freeSpinsAwarded"],
  220. "freeSpinsPlayed": raw["freeSpinsPlayed"],
  221. "holdAndWinTriggers": raw["holdAndWinTriggers"],
  222. }
  223. math_model = {
  224. "status": "certification_candidate_not_lab_certified",
  225. "rng": {
  226. "algorithm": "deterministic_seeded_mt19937_for_simulation",
  227. "productionRequirement": "replace_with_regulator_approved_csprng_or_platform_rng",
  228. "seed": seed,
  229. },
  230. "reelStrips": strips,
  231. "simulation": report,
  232. "payoutScale": report["payoutScale"],
  233. "notes": [
  234. "Generated by deterministic workflow for review and lab handoff.",
  235. "This package is not a legal certification result without third-party lab approval.",
  236. ],
  237. }
  238. math_model["modelHash"] = _stable_hash(math_model)
  239. return math_model