From fcd4f4b8a972ed04f67dc4b619bed19318913bac Mon Sep 17 00:00:00 2001 From: Francesko Date: Thu, 20 Aug 2026 15:19:31 +0200 Subject: [PATCH 1/5] Add RIN cluster math game (0_0_rin) --- games/0_0_rin/game_events.py | 27 ++++++++++++++ games/0_0_rin/game_override.py | 40 +++++++++++++++++++++ games/0_0_rin/gamestate.py | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 games/0_0_rin/game_events.py create mode 100644 games/0_0_rin/game_override.py create mode 100644 games/0_0_rin/gamestate.py diff --git a/games/0_0_rin/game_events.py b/games/0_0_rin/game_events.py new file mode 100644 index 00000000..5d44fa29 --- /dev/null +++ b/games/0_0_rin/game_events.py @@ -0,0 +1,27 @@ +from copy import deepcopy + +APPLY_TUMBLE_MULTIPLIER = "applyMultiplierToTumble" +UPDATE_GRID = "updateGrid" +RIN_DASH = "rinDash" + + +def update_grid_mult_event(gamestate): + """Pass updated position multipliers after a win.""" + event = { + "index": len(gamestate.book.events), + "type": UPDATE_GRID, + "gridMultipliers": deepcopy(gamestate.position_multipliers), + } + gamestate.book.add_event(event) + + +def rin_dash_event(gamestate, axis: str, line: int, positions: list): + """Emit a Rin Dash: one full row or reel converted to sticky wilds for this spin.""" + event = { + "index": len(gamestate.book.events), + "type": RIN_DASH, + "axis": axis, + "line": line, + "positions": deepcopy(positions), + } + gamestate.book.add_event(event) diff --git a/games/0_0_rin/game_override.py b/games/0_0_rin/game_override.py new file mode 100644 index 00000000..a67510e1 --- /dev/null +++ b/games/0_0_rin/game_override.py @@ -0,0 +1,40 @@ +from game_executables import GameExecutables + + +class GameStateOverride(GameExecutables): + """ + This class is is used to override or extend universal state.py functions. + e.g: A specific game may have custom book properties to reset + """ + + def reset_book(self): + # Reset global values used across multiple projects + super().reset_book() + # Reset parameters relevant to local game only + self.tumble_win = 0 + # Clear the position-multiplier grid every spin so a previous freegame's + # sticky grid cannot leak into a later base spin (the grid is re-zeroed at + # freegame start by reset_fs_spin and accumulates only within a feature). + self.reset_grid_mults() + self.reset_rin_dash_stickies() + + def reset_fs_spin(self): + super().reset_fs_spin() + self.reset_grid_mults() + self.reset_rin_dash_stickies() + + def assign_special_sym_function(self): + pass + + def check_repeat(self) -> None: + """Checks if the spin failed a criteria constraint at any point.""" + if self.repeat is False: + win_criteria = self.get_current_betmode_distributions().get_win_criteria() + if win_criteria is not None and self.final_win != win_criteria: + self.repeat = True + + if self.get_current_distribution_conditions()["force_freegame"] and not (self.triggered_freegame): + self.repeat = True + + if self.win_manager.running_bet_win == 0 and self.criteria != "0": + self.repeat = True diff --git a/games/0_0_rin/gamestate.py b/games/0_0_rin/gamestate.py new file mode 100644 index 00000000..512f9e05 --- /dev/null +++ b/games/0_0_rin/gamestate.py @@ -0,0 +1,66 @@ +from game_override import GameStateOverride +from game_events import update_grid_mult_event + + +class GameState(GameStateOverride): + """Core function handling simulation results.""" + + def run_spin(self, sim, simulation_seed=None): + self.reset_seed(sim) + self.repeat = True + while self.repeat: + # Reset simulation variables and draw a new board based on the betmode criteria. + self.reset_book() + self.draw_board() + + # Smaller chance to Rin Dash immediately after the reveal. + self.try_rin_dash(after_reveal=True) + + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + while self.win_data["totalWin"] > 0 and not (self.wincap_triggered): + self.tumble_game_board() + # After a winning tumble, chance to dash (sticky for remaining tumbles). + self.try_rin_dash(after_reveal=False) + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + self.set_end_tumble_event() + self.win_manager.update_gametype_wins(self.gametype) + + if self.check_fs_condition() and self.check_freespin_entry(): + self.run_freespin_from_base() + + self.evaluate_finalwin() + self.check_repeat() + + self.imprint_wins() + + def run_freespin(self): + self.reset_fs_spin() + while self.fs < self.tot_fs: + self.update_freespin() + self.draw_board() + update_grid_mult_event(self) + # Apply game-specific actions (i.e special symbol attributes before or after evaluation) + # Night Shift: at least one Rin Dash per freespin, then post-tumble chance. + self.try_rin_dash(after_reveal=True, force=True) + + self.get_clusters_update_wins() + self.emit_tumble_win_events() + self.update_grid_mults() + while self.win_data["totalWin"] > 0 and not (self.wincap_triggered): + self.tumble_game_board() + self.try_rin_dash(after_reveal=False) + self.get_clusters_update_wins() + self.emit_tumble_win_events() + self.update_grid_mults() + + self.set_end_tumble_event() + self.win_manager.update_gametype_wins(self.gametype) + + if self.check_fs_condition(): + self.update_fs_retrigger_amt() + + self.end_freespin() From 47ef8f6ddf5765b934caa004821738abe2087f82 Mon Sep 17 00:00:00 2001 From: Francesko Date: Thu, 20 Aug 2026 15:20:00 +0200 Subject: [PATCH 2/5] Add RIN run.py and readme --- games/0_0_rin/readme.txt | 82 ++++++++++++++++++++++++++++++++++++++++ games/0_0_rin/run.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 games/0_0_rin/readme.txt create mode 100644 games/0_0_rin/run.py diff --git a/games/0_0_rin/readme.txt b/games/0_0_rin/readme.txt new file mode 100644 index 00000000..65303991 --- /dev/null +++ b/games/0_0_rin/readme.txt @@ -0,0 +1,82 @@ +# RIN — cluster tumble game (7x7) + +game_id: 0_0_rin +working_name: RIN +win_type: cluster +grid: 7x7 +wincap: 5000 +rtp: 0.96 + +Clusters of 5 or more like-symbols are removed from the board, and symbols above on the reelstrip +fall to fill their place. Wild (W) substitutes in clusters. Scatter (S) triggers Night Shift freespins. + +#### Symbol mapping (IDs unchanged from cluster sample) +H1 Rin +H2 Fox mask +H3 Lucky cat +H4 Neon 営業中 +L1 canned coffee +L2 onigiri +L3 lighter +L4 magazine +W foxfire wild +S door chime scatter + +#### Basegame +Standard tumbling cluster game with Scatter and Wild symbols. +Minimum of 4 Scatter symbols are required for Night Shift (freeSpin) triggers. +{4: 10, 5: 12, 6: 15, 7: 18, 8: 20} freespins (copied from cluster). + +Rin Dash (see below): ~5% chance after reveal, ~15% chance after each winning tumble. + +#### Night Shift (freegame) +Same basegame rule, except grid positions have multipliers. Grid positions start in a 'deactivated' state. Once one win occurs, +the position is 'activated' starting with a 1x multiplier - for every winning cluster, the multiplier value at that position is increased by +1 for every winning position. +A minimum of 3 scatters are required for re-triggers +{3: 5, 4: 8, 5: 10, 6: 12, 7: 15, 8: 18} extra spins (copied from cluster). + +Rin Dash: at least one dash is forced after every Night Shift reveal, plus the post-tumble chance. + +#### Buy-bonus +BetMode name="bonus", cost=200. +is_buybonus=True, is_feature=False — this is how the SDK actually exposes a Clocked-in buy-bonus +(src/config/constants.py ISBUYBONUSMAPPING["bonus"]=True, ISFEATUREMAPPING["bonus"]=False; +0_0_lines / 0_0_ways / 0_0_scatter / 0_0_expwilds all set bonus this way). +The cluster sample leaves is_buybonus=False; that is not used here. + +#### Rin Dash +After the reveal, and again after each winning tumble, a dash may convert one full axis +(a row or a reel, line index 0-6) into wild W. Those cells stay sticky wilds for the rest +of THIS spin's tumbles only (cleared on reset_book / each new Night Shift spin). + +tumble_game_board is overridden: tumble_board() cascades exploded cells from the reelstrip, +tumbleBoard is emitted, then sticky W cells are written back so the next cluster eval sees them. +Frontend should treat rinDash.positions as sticky overlays for remaining tumbles of the spin +(tumbleBoard newSymbols are the raw cascade, not the sticky rewrite). + +Event contract (board indices 0-6, no padding-row offset — unlike winInfo which adds +1): + +{ + "index": , + "type": "rinDash", + "axis": "row" | "reel", + "line": <0-6>, + "positions": [{"reel": int, "row": int}, ...] +} + +axis "row": line is the row index; positions are all 7 cells in that row. +axis "reel": line is the reel index; positions are all 7 cells in that reel. + +#### How to run +From the math-sdk root (after `make setup` / venv): + + python3 games/0_0_rin/run.py + make run GAME=0_0_rin + +run.py defaults to num_sim_args of 10 per mode so a smoke run is possible. +Optimization / analysis / format checks are off by default; flip run_conditions and raise +num_sim_args for a full distribution pass. + +#### Notes +Because of the separation between basegame and freegame types - there is an additional freespin entry check to check of the criteria requires a forced +freespin condition. Otherwise, occurences of Scatter symbols tumbling onto the board during basegame criteria may appear. diff --git a/games/0_0_rin/run.py b/games/0_0_rin/run.py new file mode 100644 index 00000000..448c5bf6 --- /dev/null +++ b/games/0_0_rin/run.py @@ -0,0 +1,61 @@ +"""Main file for generating results for RIN cluster game.""" + +from gamestate import GameState +from game_config import GameConfig +from game_optimization import OptimizationSetup +from optimization_program.run_script import OptimizationExecution +from utils.game_analytics.run_analysis import create_stat_sheet +from utils.rgs_verification import execute_all_tests +from src.state.run_sims import create_books +from src.write_data.write_configs import generate_configs + +if __name__ == "__main__": + + num_threads = 1 + rust_threads = 20 + batching_size = 10 + compression = True + profiling = False + + # Small by default so `python3 games/0_0_rin/run.py` is a viable smoke run. + num_sim_args = { + "base": 10, + "bonus": 10, + } + + run_conditions = { + "run_sims": True, + "run_optimization": False, + "run_analysis": False, + "run_format_checks": False, + } + target_modes = ["base", "bonus"] + + config = GameConfig() + gamestate = GameState(config) + if run_conditions["run_optimization"] or run_conditions["run_analysis"]: + optimization_setup_class = OptimizationSetup(config) + + if run_conditions["run_sims"]: + create_books( + gamestate, + config, + num_sim_args, + batching_size, + num_threads, + compression, + profiling, + ) + + generate_configs(gamestate) + + if run_conditions["run_optimization"]: + OptimizationExecution().run_all_modes(config, target_modes, rust_threads) + generate_configs(gamestate) + + if run_conditions["run_analysis"]: + custom_keys = [{"symbol": "scatter"}] + create_stat_sheet(gamestate, custom_keys=custom_keys) + + if run_conditions["run_format_checks"]: + execute_all_tests(config) From ee61fd36742585c9ac834b36f3f4b1cc4c2779bb Mon Sep 17 00:00:00 2001 From: Francesko Date: Thu, 20 Aug 2026 15:20:11 +0200 Subject: [PATCH 3/5] Add RIN cluster evaluation with grid multipliers --- games/0_0_rin/game_calculations.py | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 games/0_0_rin/game_calculations.py diff --git a/games/0_0_rin/game_calculations.py b/games/0_0_rin/game_calculations.py new file mode 100644 index 00000000..9fc4c9a2 --- /dev/null +++ b/games/0_0_rin/game_calculations.py @@ -0,0 +1,68 @@ +from src.executables.executables import Executables +from src.calculations.cluster import Cluster +from src.calculations.board import Board +from src.config.config import Config + + +class GameCalculations(Executables): + """ + This function will override the evaluate_clusters() function in cluster.py + This is to account for the grid multiplier in winning positions. + """ + + # Override cluster evaluation functions to include grid position multipliers + def evaluate_clusters_with_grid( + self, + config: Config, + board: Board, + clusters: dict, + pos_mult_grid: list, + global_multiplier: int = 1, + return_data: dict = {"totalWin": 0, "wins": []}, + ) -> type: + """ + Determine payout amount from cluster, including symbol multiplier and global multiplier value. + Game specific function which takes into account position multipliers. + """ + exploding_symbols = [] + total_win = 0 + for sym in clusters: + for cluster in clusters[sym]: + syms_in_cluster = len(cluster) + if (syms_in_cluster, sym) in config.paytable: + board_mult = 0 + for positions in cluster: + board_mult += pos_mult_grid[positions[0]][positions[1]] + board_mult = max(board_mult, 1) + sym_win = config.paytable[(syms_in_cluster, sym)] + symwin_mult = sym_win * board_mult * global_multiplier + total_win += symwin_mult + json_positions = [{"reel": p[0], "row": p[1]} for p in cluster] + + central_pos = Cluster.get_central_cluster_position(json_positions) + return_data["wins"] += [ + { + "symbol": sym, + "clusterSize": syms_in_cluster, + "win": symwin_mult, + "positions": json_positions, + "meta": { + "globalMult": global_multiplier, + "clusterMult": board_mult, + "winWithoutMult": sym_win, + "overlay": {"reel": central_pos[0], "row": central_pos[1]}, + }, + } + ] + + for positions in cluster: + board[positions[0]][positions[1]].explode = True + if { + "reel": positions[0], + "row": positions[1], + } not in exploding_symbols: + exploding_symbols.append({"reel": positions[0], "row": positions[1]}) + + return_data["totalWin"] += total_win + + return board, return_data From 0b8c2007c0688d5795b751ac47a82a9a84499aea Mon Sep 17 00:00:00 2001 From: Francesko Date: Thu, 20 Aug 2026 15:20:26 +0200 Subject: [PATCH 4/5] Add RIN dash and sticky wild executables --- games/0_0_rin/game_executables.py | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 games/0_0_rin/game_executables.py diff --git a/games/0_0_rin/game_executables.py b/games/0_0_rin/game_executables.py new file mode 100644 index 00000000..03e3c2d4 --- /dev/null +++ b/games/0_0_rin/game_executables.py @@ -0,0 +1,118 @@ +from game_calculations import GameCalculations +from src.calculations.cluster import Cluster +from src.calculations.statistics import get_random_outcome +from src.events.events import update_freespin_event, tumble_board_event +from game_events import update_grid_mult_event, rin_dash_event + + +class GameExecutables(GameCalculations): + """Game dependent grouped functions.""" + + def reset_grid_mults(self): + """Initialize all grid position multipliers.""" + self.position_multipliers = [ + [0 for _ in range(self.config.num_rows[reel])] for reel in range(self.config.num_reels) + ] + + def reset_rin_dash_stickies(self): + """Rin Dash wilds persist only for the remaining tumbles of the current spin.""" + self.rin_dash_stickies = [] + + def update_grid_mults(self): + """All positions start with 1x. If there is a win in that position, the grid point + is 'activated' and all subsequent wins on that position will double the grid value.""" + if self.win_data["totalWin"] > 0: + for win in self.win_data["wins"]: + for pos in win["positions"]: + if self.position_multipliers[pos["reel"]][pos["row"]] == 0: + self.position_multipliers[pos["reel"]][pos["row"]] = 1 + else: + self.position_multipliers[pos["reel"]][pos["row"]] += 1 + self.position_multipliers[pos["reel"]][pos["row"]] = min( + self.position_multipliers[pos["reel"]][pos["row"]], self.config.maximum_board_mult + ) + update_grid_mult_event(self) + + def get_clusters_update_wins(self): + """Find clusters on board and update win manager.""" + clusters = Cluster.get_clusters(self.board, "wild") + return_data = { + "totalWin": 0, + "wins": [], + } + self.board, self.win_data = self.evaluate_clusters_with_grid( + config=self.config, + board=self.board, + clusters=clusters, + pos_mult_grid=self.position_multipliers, + global_multiplier=self.global_multiplier, + return_data=return_data, + ) + + Cluster.record_cluster_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + self.win_manager.tumble_win = self.win_data["totalWin"] + + def update_freespin(self) -> None: + """Called before a new reveal during freegame.""" + self.fs += 1 + update_freespin_event(self) + self.win_manager.reset_spin_win() + self.tumblewin_mult = 0 + self.win_data = {} + # Each Night Shift spin has its own dash stickies (not carried across freespins). + self.reset_rin_dash_stickies() + + def tumble_game_board(self): + """Remove winning symbols, cascade, then restore Rin Dash sticky wilds. + + tumble_board() (src/calculations/tumble.py) drops exploded cells and pulls + new symbols from the reelstrip. Sticky wilds are position-locked for the + rest of this spin, so they are written back after the cascade and before + the next cluster evaluation. tumbleBoard is emitted from the raw cascade + so the frontend can animate gravity; sticky cells are already known from + the rinDash event(s) on this spin. + """ + self.tumble_board() + tumble_board_event(self) + self.apply_rin_dash_stickies() + + def apply_rin_dash_stickies(self): + """Force stored Rin Dash cells to wild W after a tumble.""" + if not getattr(self, "rin_dash_stickies", None): + return + for pos in self.rin_dash_stickies: + reel, row = pos["reel"], pos["row"] + self.board[reel][row] = self.create_symbol("W") + self.board[reel][row].assign_attribute({"locked": True}) + self.get_special_symbols_on_board() + + def try_rin_dash(self, after_reveal: bool = False, force: bool = False) -> bool: + """Roll for a Rin Dash. Night Shift reveal uses force=True (at least one per FS).""" + if not force: + weights = ( + self.config.rin_dash_reveal_chance if after_reveal else self.config.rin_dash_tumble_chance + ) + if not get_random_outcome(weights): + return False + self.execute_rin_dash() + return True + + def execute_rin_dash(self) -> None: + """Pick a row or reel, convert those cells to sticky wilds, emit rinDash.""" + axis = get_random_outcome({"row": 1, "reel": 1}) + if axis == "row": + line = get_random_outcome({i: 1 for i in range(self.config.num_rows[0])}) + positions = [{"reel": reel, "row": line} for reel in range(self.config.num_reels)] + else: + line = get_random_outcome({i: 1 for i in range(self.config.num_reels)}) + positions = [{"reel": line, "row": row} for row in range(self.config.num_rows[line])] + + for pos in positions: + self.board[pos["reel"]][pos["row"]] = self.create_symbol("W") + self.board[pos["reel"]][pos["row"]].assign_attribute({"locked": True}) + if pos not in self.rin_dash_stickies: + self.rin_dash_stickies.append(pos) + + self.get_special_symbols_on_board() + rin_dash_event(self, axis=axis, line=line, positions=positions) From 9b101d3b42e3f1efeed2dd68da4882cc84248987 Mon Sep 17 00:00:00 2001 From: Francesko Date: Thu, 20 Aug 2026 15:21:04 +0200 Subject: [PATCH 5/5] Add RIN optimization setup scaled to 0.96 RTP --- games/0_0_rin/game_optimization.py | 110 +++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 games/0_0_rin/game_optimization.py diff --git a/games/0_0_rin/game_optimization.py b/games/0_0_rin/game_optimization.py new file mode 100644 index 00000000..52e33557 --- /dev/null +++ b/games/0_0_rin/game_optimization.py @@ -0,0 +1,110 @@ +"""Set conditions/parameters for optimization program program""" + +from optimization_program.optimization_config import ( + ConstructScaling, + ConstructParameters, + ConstructFenceBias, + ConstructConditions, + verify_optimization_input, +) + + +class OptimizationSetup: + """""" + + def __init__(self, game_config): + self.game_config = game_config + wincaps = {} + for bm in game_config.bet_modes: + wincaps[bm.get_name()] = bm.get_wincap() + self.game_config.opt_params = { + "base": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] + ).return_dict(), + "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), + "freegame": ConstructConditions( + rtp=0.375, hr=200, search_conditions={"symbol": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.584).return_dict(), + }, + "scaling": ConstructScaling( + [ + {"criteria": "basegame", "scale_factor": 1.2, "win_range": (1, 2), "probability": 1.0}, + {"criteria": "basegame", "scale_factor": 1.5, "win_range": (10, 20), "probability": 1.0}, + { + "criteria": "freegame", + "scale_factor": 0.8, + "win_range": (1000, 2000), + "probability": 1.0, + }, + { + "criteria": "freegame", + "scale_factor": 1.2, + "win_range": (3000, 4000), + "probability": 1.0, + }, + ] + ).return_dict(), + "parameters": ConstructParameters( + num_show=5000, + num_per_fence=10000, + min_m2m=4, + max_m2m=8, + pmb_rtp=1.0, + sim_trials=5000, + test_spins=[50, 100, 200], + test_weights=[0.3, 0.4, 0.3], + score_type="rtp", + ).return_dict(), + "distribution_bias": ConstructFenceBias( + applied_criteria=["basegame"], + bias_ranges=[(0.5, 1.5)], + bias_weights=[0.4], + ).return_dict(), + }, + "bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.959, hr="x").return_dict(), + }, + "scaling": ConstructScaling( + [ + { + "criteria": "freegame", + "scale_factor": 0.9, + "win_range": (20, 50), + "probability": 1.0, + }, + { + "criteria": "freegame", + "scale_factor": 0.8, + "win_range": (1000, 2000), + "probability": 1.0, + }, + { + "criteria": "freegame", + "scale_factor": 1.2, + "win_range": (3000, 4000), + "probability": 1.0, + }, + ] + ).return_dict(), + "parameters": ConstructParameters( + num_show=5000, + num_per_fence=10000, + min_m2m=4, + max_m2m=8, + pmb_rtp=1.0, + sim_trials=5000, + test_spins=[10, 20, 50], + test_weights=[0.6, 0.2, 0.2], + score_type="rtp", + ).return_dict(), + }, + } + + verify_optimization_input(self.game_config, self.game_config.opt_params)