diff --git a/.github/workflows/recompress-books.yml b/.github/workflows/recompress-books.yml new file mode 100644 index 000000000..75ef97689 --- /dev/null +++ b/.github/workflows/recompress-books.yml @@ -0,0 +1,77 @@ +name: Recompress Books (Single Frame) + +on: + workflow_dispatch: + inputs: + run_id: + description: 'Run ID of the sim workflow to download artifacts from' + required: true + +jobs: + recompress: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install zstandard + run: pip install zstandard + + - name: Download original artifacts + env: + GH_TOKEN: ${{ github.token }} + run: gh run download ${{ github.event.inputs.run_id }} -n sugar-stack-math -D original -R ${{ github.repository }} + + - name: Recompress books as single-frame zstd + run: | + python3 - <<'PYEOF' + import zstandard as zstd + import os, glob + + os.makedirs("output/publish_files", exist_ok=True) + os.makedirs("output/configs", exist_ok=True) + + for src in glob.glob("original/publish_files/*.jsonl.zst"): + name = os.path.basename(src) + dst = f"output/publish_files/{name}" + print(f"Recompressing {name}...") + raw_path = dst + ".raw" + with open(raw_path, "wb") as raw_out: + dctx = zstd.ZstdDecompressor() + with open(src, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + raw_out.write(chunk) + cctx = zstd.ZstdCompressor(level=3) + with open(raw_path, "rb") as f_in, open(dst, "wb") as f_out: + cctx.copy_stream(f_in, f_out) + os.remove(raw_path) + print(f" Done: {os.path.getsize(dst)//1024//1024} MB") + + # Copy non-zst files as-is + for src in glob.glob("original/publish_files/*"): + if not src.endswith(".jsonl.zst"): + name = os.path.basename(src) + import shutil + shutil.copy2(src, f"output/publish_files/{name}") + + for src in glob.glob("original/configs/*"): + import shutil + shutil.copy2(src, f"output/configs/{os.path.basename(src)}") + + print("All done!") + PYEOF + + - name: Upload recompressed artifacts + uses: actions/upload-artifact@v4 + with: + name: sugar-stack-math-singleframe + path: | + output/publish_files/ + output/configs/ + retention-days: 30 diff --git a/.github/workflows/run-fight-club-sim.yml b/.github/workflows/run-fight-club-sim.yml new file mode 100644 index 000000000..f9054e816 --- /dev/null +++ b/.github/workflows/run-fight-club-sim.yml @@ -0,0 +1,34 @@ +name: Run Fight Club Simulation + +on: + workflow_dispatch: + +jobs: + simulate: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pip install numpy zstandard xlsxwriter matplotlib toml + + - name: Run Fight Club simulation + working-directory: games/fight_club + env: + PYTHONPATH: ${{ github.workspace }} + run: python3 run.py + + - name: Upload math artifacts + uses: actions/upload-artifact@v4 + with: + name: fight-club-math + path: games/fight_club/library/publish_files/ + retention-days: 30 diff --git a/.github/workflows/run-shogun-sim.yml b/.github/workflows/run-shogun-sim.yml new file mode 100644 index 000000000..9c0d0997b --- /dev/null +++ b/.github/workflows/run-shogun-sim.yml @@ -0,0 +1,34 @@ +name: Run Shogun Simulation + +on: + workflow_dispatch: + +jobs: + simulate: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pip install numpy zstandard xlsxwriter matplotlib toml + + - name: Run Shogun simulation + working-directory: games/shogun + env: + PYTHONPATH: ${{ github.workspace }} + run: python3 run.py + + - name: Upload math artifacts + uses: actions/upload-artifact@v4 + with: + name: shogun-math + path: games/1_1_shogun/library/publish_files/ + retention-days: 30 diff --git a/.github/workflows/run-sugar-stack-sim.yml b/.github/workflows/run-sugar-stack-sim.yml new file mode 100644 index 000000000..508bd0d9e --- /dev/null +++ b/.github/workflows/run-sugar-stack-sim.yml @@ -0,0 +1,36 @@ +name: Run Sugar Stack Simulation + +on: + workflow_dispatch: + +jobs: + simulate: + runs-on: ubuntu-latest + timeout-minutes: 360 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pip install numpy zstandard xlsxwriter matplotlib toml + + - name: Run Sugar Stack simulation + working-directory: games/fruits + env: + PYTHONPATH: ${{ github.workspace }} + run: python3 run.py + + - name: Upload math artifacts + uses: actions/upload-artifact@v4 + with: + name: sugar-stack-math + path: | + games/1_2_sugar_stack/library/publish_files/ + games/1_2_sugar_stack/library/configs/ + retention-days: 30 diff --git a/games/1_1_shogun/reels/BR0.csv b/games/1_1_shogun/reels/BR0.csv new file mode 100644 index 000000000..f37f71a81 --- /dev/null +++ b/games/1_1_shogun/reels/BR0.csv @@ -0,0 +1,200 @@ +J,10,K,oni,oni +J,A,oni,A,10 +A,geisha,Q,K,10 +geisha,J,Q,J,J +J,Q,K,oni,K +samurai,Q,oni,10,Q +dragon,K,10,Q,oni +samurai,10,10,A,A +J,geisha,J,10,geisha +10,Q,Q,J,samurai +J,10,10,10,geisha +geisha,10,Q,Q,10 +J,K,J,J,A +geisha,A,J,W,K +10,Q,K,geisha,dragon +geisha,oni,geisha,K,10 +K,J,A,J,oni +10,K,oni,dragon,10 +10,10,geisha,geisha,samurai +oni,K,K,10,oni +K,A,10,Q,10 +K,Q,K,oni,10 +SC,J,10,geisha,J +oni,J,K,Q,10 +dragon,A,10,geisha,oni +Q,J,oni,samurai,A +J,A,Q,10,geisha +oni,10,dragon,geisha,Q +Q,Q,oni,10,K +A,Q,10,Q,oni +Q,J,10,K,K +J,geisha,K,oni,10 +10,J,Q,A,10 +oni,10,J,dragon,samurai +10,Q,geisha,dragon,A +Q,geisha,A,10,Q +Q,oni,K,K,samurai +J,10,Q,A,K +A,geisha,J,J,J +10,J,10,Q,oni +10,10,A,J,10 +K,Q,Q,J,geisha +K,J,J,K,J +oni,J,dragon,A,10 +Q,geisha,A,K,J +J,10,K,10,J +A,J,geisha,oni,Q +geisha,A,A,geisha,J +10,A,K,oni,K +J,K,Q,10,A +Q,J,oni,J,J +SC,K,J,J,K +Q,Q,10,K,A +A,K,10,J,10 +10,10,A,K,Q +K,dragon,10,10,J +J,A,dragon,J,Q +10,K,J,10,J +A,10,J,Q,10 +J,J,10,Q,K +Q,K,J,samurai,Q +K,10,K,K,J +K,samurai,A,J,dragon +J,A,J,10,10 +J,dragon,K,A,Q +samurai,J,10,K,oni +J,Q,geisha,10,J +10,geisha,samurai,K,K +oni,K,J,geisha,Q +J,10,A,K,A +W,10,10,oni,oni +A,Q,J,Q,geisha +10,samurai,A,J,K +K,Q,J,Q,K +samurai,10,10,10,A +K,oni,10,J,10 +A,A,J,Q,K +J,samurai,A,10,J +10,10,dragon,samurai,10 +geisha,oni,Q,oni,oni +oni,K,oni,geisha,A +10,oni,Q,10,J +Q,10,Q,oni,10 +K,dragon,J,A,A +A,A,A,10,A +samurai,oni,K,A,Q +10,10,J,J,10 +samurai,K,samurai,J,10 +W,Q,oni,Q,A +A,geisha,10,10,SC +oni,A,W,oni,Q +oni,10,J,oni,J +J,10,J,K,K +A,K,Q,K,10 +oni,Q,K,A,dragon +K,J,Q,A,K +Q,K,A,10,dragon +geisha,K,oni,10,10 +J,oni,K,Q,J +J,geisha,samurai,dragon,Q +A,10,K,A,K +geisha,geisha,A,10,Q +10,Q,A,samurai,Q +oni,J,J,A,samurai +10,K,J,A,10 +A,samurai,Q,J,K +geisha,geisha,10,geisha,Q +J,A,10,10,A +A,oni,A,Q,J +oni,samurai,10,J,dragon +Q,A,oni,Q,A +J,samurai,K,10,K +K,10,W,samurai,W +samurai,dragon,K,10,K +oni,W,Q,J,oni +10,geisha,A,Q,K +Q,W,K,K,K +K,oni,samurai,Q,samurai +10,dragon,geisha,10,Q +K,10,Q,Q,10 +Q,10,J,A,K +Q,samurai,geisha,oni,geisha +10,J,A,10,geisha +10,oni,samurai,J,oni +J,A,Q,geisha,A +A,10,geisha,10,10 +J,Q,oni,A,K +K,Q,Q,geisha,geisha +Q,J,SC,10,A +K,10,K,oni,A +A,samurai,10,geisha,Q +geisha,K,K,A,A +10,10,geisha,oni,A +oni,oni,Q,geisha,10 +10,J,oni,Q,J +Q,oni,10,A,K +oni,oni,A,J,Q +Q,A,oni,Q,J +Q,K,geisha,10,oni +A,J,Q,K,geisha +samurai,Q,K,10,A +10,Q,oni,A,oni +K,K,oni,J,10 +Q,J,J,A,geisha +10,J,dragon,K,samurai +geisha,A,10,K,J +10,10,K,Q,Q +K,A,samurai,oni,dragon +oni,J,geisha,Q,Q +K,K,J,K,oni +10,geisha,J,J,Q +dragon,J,oni,K,SC +geisha,A,J,A,J +J,10,geisha,Q,Q +Q,10,10,J,Q +10,geisha,J,dragon,A +Q,oni,A,10,Q +10,K,10,samurai,10 +A,J,samurai,A,K +oni,oni,Q,J,10 +A,Q,oni,Q,J +A,oni,K,J,Q +K,K,10,dragon,J +J,oni,Q,A,samurai +10,J,A,J,geisha +A,A,samurai,10,oni +dragon,A,Q,10,10 +Q,J,K,samurai,J +Q,A,A,geisha,10 +K,Q,J,10,J +10,Q,J,oni,geisha +A,K,A,oni,K +K,K,samurai,10,samurai +K,samurai,K,Q,J +A,10,geisha,K,Q +K,K,Q,J,K +geisha,10,SC,K,J +J,J,10,10,oni +dragon,10,10,J,10 +samurai,Q,A,samurai,oni +K,K,10,W,A +K,Q,10,J,10 +geisha,10,J,K,Q +Q,J,K,K,K +10,K,10,samurai,J +10,A,J,K,A +J,10,10,J,geisha +Q,J,A,Q,10 +samurai,Q,samurai,J,J +10,K,Q,oni,K +Q,J,Q,A,10 +J,Q,geisha,10,samurai +dragon,10,10,samurai,W +oni,A,K,Q,geisha +10,oni,10,Q,J +K,10,Q,K,10 +J,dragon,10,K,Q +J,J,dragon,10,A +A,Q,oni,K,J +Q,samurai,J,A,J diff --git a/games/1_1_shogun/reels/FR0.csv b/games/1_1_shogun/reels/FR0.csv new file mode 100644 index 000000000..f3ca637dc --- /dev/null +++ b/games/1_1_shogun/reels/FR0.csv @@ -0,0 +1,136 @@ +J,samurai,J,Q,J +geisha,K,A,oni,Q +A,Q,J,10,dragon +geisha,A,samurai,Q,K +oni,10,A,A,10 +Q,K,A,K,A +J,geisha,10,10,geisha +geisha,K,oni,Q,K +W,K,J,A,J +10,Q,Q,10,J +Q,dragon,10,10,Q +Q,10,A,Q,J +K,geisha,geisha,A,geisha +oni,K,K,Q,K +10,J,K,dragon,J +K,J,J,J,J +J,geisha,K,K,A +10,oni,K,geisha,J +samurai,A,geisha,10,samurai +Q,A,10,J,K +J,J,K,J,dragon +oni,K,10,oni,oni +A,Q,Q,J,Q +J,10,Q,10,K +Q,Q,samurai,K,10 +A,J,Q,10,A +10,10,A,J,Q +dragon,J,oni,K,Q +Q,geisha,K,samurai,oni +A,Q,J,A,A +samurai,A,K,dragon,dragon +geisha,samurai,geisha,K,K +dragon,dragon,geisha,oni,oni +samurai,10,K,geisha,dragon +J,Q,Q,Q,Q +J,K,dragon,dragon,J +10,oni,A,oni,oni +oni,Q,Q,10,Q +J,samurai,K,dragon,W +J,samurai,geisha,dragon,A +10,geisha,oni,J,10 +J,oni,10,A,samurai +10,J,dragon,K,oni +Q,J,geisha,oni,oni +K,A,Q,Q,10 +A,Q,A,samurai,oni +K,samurai,samurai,J,K +samurai,10,A,A,Q +oni,samurai,A,10,K +10,oni,Q,dragon,Q +dragon,oni,W,J,10 +oni,Q,J,J,Q +A,Q,samurai,A,Q +A,J,A,geisha,K +samurai,J,oni,J,J +A,A,dragon,Q,oni +10,K,K,J,10 +K,A,10,J,K +10,10,Q,K,K +dragon,Q,J,A,J +A,A,J,J,Q +Q,A,10,A,samurai +A,dragon,J,geisha,samurai +K,K,Q,K,A +A,K,dragon,K,10 +10,Q,K,J,A +J,samurai,K,K,10 +J,K,A,oni,Q +oni,A,10,oni,geisha +Q,10,oni,A,J +samurai,J,J,J,J +J,J,oni,samurai,10 +A,geisha,J,samurai,10 +oni,10,J,10,geisha +oni,K,geisha,oni,10 +A,W,J,J,geisha +K,samurai,10,K,geisha +dragon,K,K,A,dragon +J,oni,geisha,geisha,geisha +10,10,10,A,samurai +oni,10,J,samurai,10 +J,A,J,Q,K +Q,J,samurai,oni,Q +Q,geisha,10,10,dragon +J,A,samurai,A,10 +geisha,oni,K,K,samurai +Q,samurai,oni,Q,geisha +Q,Q,A,10,Q +10,J,dragon,geisha,10 +J,dragon,10,samurai,A +K,geisha,10,Q,A +Q,samurai,K,10,J +10,oni,A,geisha,A +K,K,K,geisha,geisha +K,J,samurai,K,A +geisha,dragon,K,K,J +dragon,Q,Q,A,samurai +Q,Q,oni,Q,K +samurai,J,J,Q,oni +K,Q,oni,J,J +Q,J,geisha,Q,J +K,oni,oni,geisha,Q +10,geisha,samurai,oni,Q +A,geisha,geisha,geisha,K +10,dragon,Q,oni,Q +geisha,oni,A,A,samurai +Q,A,Q,dragon,dragon +K,geisha,10,10,10 +geisha,dragon,10,K,K +samurai,oni,Q,10,K +K,J,oni,samurai,A +samurai,10,Q,Q,samurai +A,A,Q,10,J +oni,geisha,geisha,Q,K +Q,10,A,samurai,A +K,J,oni,oni,oni +oni,oni,10,dragon,Q +samurai,10,J,oni,J +K,Q,dragon,W,geisha +Q,K,J,10,oni +dragon,J,dragon,oni,10 +oni,K,10,10,A +10,Q,A,Q,10 +A,10,Q,geisha,A +geisha,A,A,Q,oni +oni,A,geisha,K,dragon +geisha,K,samurai,samurai,geisha +dragon,10,oni,Q,10 +geisha,10,oni,A,J +J,dragon,10,geisha,A +geisha,10,J,A,oni +A,oni,samurai,K,samurai +10,A,Q,J,oni +K,oni,10,10,A +10,10,Q,J,10 +J,Q,dragon,samurai,geisha diff --git a/games/1_2_sugar_stack/__init__.py b/games/1_2_sugar_stack/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/games/1_2_sugar_stack/game_calculations.py b/games/1_2_sugar_stack/game_calculations.py new file mode 100644 index 000000000..0060d4983 --- /dev/null +++ b/games/1_2_sugar_stack/game_calculations.py @@ -0,0 +1,7 @@ +"""SUGAR STACK — Thin pass-through for game calculations.""" + +from src.executables.executables import Executables + + +class GameCalculations(Executables): + pass diff --git a/games/1_2_sugar_stack/game_config.py b/games/1_2_sugar_stack/game_config.py new file mode 100644 index 000000000..5dd249f5f --- /dev/null +++ b/games/1_2_sugar_stack/game_config.py @@ -0,0 +1,215 @@ +"""SUGAR STACK — Game Configuration.""" + +import os +from src.config.config import Config +from src.config.distributions import Distribution +from src.config.betmode import BetMode + + +class GameConfig(Config): + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + super().__init__() + self.game_id = "1_2_sugar_stack" + self.provider_number = 1 + self.working_name = "Sugar Stack" + self.wincap = 10000.0 + self.win_type = "lines" + self.rtp = 0.9600 + try: + self.construct_paths(self.game_id) + except TypeError: + self.construct_paths() + + # 5×5 grid + self.num_reels = 5 + self.num_rows = [5] * self.num_reels + + # ── Paytable ────────────────────────────────────────────── + # Wild pays same as top symbol (watermelon) + self.paytable = { + # Wild + (5, "W"): 15, (4, "W"): 6, (3, "W"): 2, + # Premium symbols + (5, "watermelon"): 15, (4, "watermelon"): 6, (3, "watermelon"): 2, + (5, "grape"): 10, (4, "grape"): 4, (3, "grape"): 1.5, + (5, "orange"): 8, (4, "orange"): 3, (3, "orange"): 1, + (5, "cherry"): 5, (4, "cherry"): 2, (3, "cherry"): 0.8, + (5, "plum"): 4, (4, "plum"): 1.5, (3, "plum"): 0.5, + (5, "lemon"): 3, (4, "lemon"): 1, (3, "lemon"): 0.3, + # Low-pay symbols + (5, "A"): 2, (4, "A"): 0.6, (3, "A"): 0.2, + (5, "K"): 1.5, (4, "K"): 0.4, (3, "K"): 0.15, + (5, "Q"): 1, (4, "Q"): 0.3, (3, "Q"): 0.1, + (5, "J"): 0.8, (4, "J"): 0.2, (3, "J"): 0.1, + (5, "10"): 0.8, (4, "10"): 0.2, (3, "10"): 0.1, + } + + # ── 20 Paylines on 5×5 grid ─────────────────────────────── + self.paylines = { + 1: [2, 2, 2, 2, 2], + 2: [0, 0, 0, 0, 0], + 3: [4, 4, 4, 4, 4], + 4: [0, 1, 2, 1, 0], + 5: [4, 3, 2, 3, 4], + 6: [0, 0, 1, 2, 2], + 7: [4, 4, 3, 2, 2], + 8: [1, 0, 1, 0, 1], + 9: [3, 4, 3, 4, 3], + 10: [1, 1, 0, 1, 1], + 11: [3, 3, 4, 3, 3], + 12: [2, 1, 0, 1, 2], + 13: [2, 3, 4, 3, 2], + 14: [0, 1, 1, 1, 0], + 15: [4, 3, 3, 3, 4], + 16: [1, 2, 3, 2, 1], + 17: [3, 2, 1, 2, 3], + 18: [0, 2, 4, 2, 0], + 19: [4, 2, 0, 2, 4], + 20: [2, 0, 2, 0, 2], + } + + self.include_padding = True + + # W is wild, SC is scatter (on reels 0, 2, 4 only) + self.special_symbols = { + "wild": ["W"], + "multiplier": ["W"], + "scatter": ["SC"], + } + + # Scatter trigger: 3 SC → 10 free spins (base and free game) + self.freespin_triggers = { + self.basegame_type: {3: 10}, + self.freegame_type: {3: 10}, + } + self.anticipation_triggers = { + self.basegame_type: 2, + self.freegame_type: 2, + } + + # ── Reels ───────────────────────────────────────────────── + reel_files = {"BR0": "BR0.csv", "FR0": "FR0.csv"} + self.reels = {} + for name, filename in reel_files.items(): + self.reels[name] = self.read_reels_csv(os.path.join(self.reels_path, filename)) + + self.padding_reels = { + self.basegame_type: self.reels["BR0"], + self.freegame_type: self.reels["FR0"], + } + + # ── Expanding Wild Multiplier Pool ──────────────────────── + wild_mult_base = {2: 200, 3: 80, 5: 20, 10: 5, 20: 2, 50: 1, 100: 1} + wild_mult_bonus = {2: 200, 3: 80, 5: 30, 10: 10, 20: 5, 50: 2, 100: 1} + + # ── Shared condition templates ──────────────────────────── + def _cond(force_fg, force_wincap, reel_base, reel_free=None): + c = { + "reel_weights": {self.basegame_type: {reel_base: 1}}, + "wild_mult_values": {self.basegame_type: wild_mult_base}, + "force_freegame": force_fg, + "force_wincap": force_wincap, + } + if reel_free: + c["reel_weights"][self.freegame_type] = {reel_free: 1} + c["wild_mult_values"][self.freegame_type] = wild_mult_bonus + return c + + freegame_cond = _cond( + force_fg=True, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + freegame_cond["scatter_triggers"] = {3: 1} + + basegame_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + zerowin_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond = _cond( + force_fg=True, force_wincap=True, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond["scatter_triggers"] = {3: 1} + wincap_cond["wild_mult_values"][self.freegame_type] = { + 2: 150, 3: 60, 5: 30, 10: 15, 20: 8, 50: 3, 100: 2 + } + + # ── Bonus mode conditions ────────────────────────────────── + bonus_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + } + super_bonus_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + "pre_placed_wilds": 1, + } + + # ── Bet Modes ───────────────────────────────────────────── + maxwins = { + "base": 10000, "bonus": 10000, "super_bonus": 10000, + } + + self.bet_modes = [ + BetMode( + name="base", + cost=1.0, + rtp=self.rtp, + max_win=maxwins["base"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=False, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["base"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.10, conditions=freegame_cond), + Distribution(criteria="0", quota=0.40, win_criteria=0.0, conditions=zerowin_cond), + Distribution(criteria="basegame", quota=0.489, conditions=basegame_cond), + ], + ), + BetMode( + name="bonus", + cost=150.0, + rtp=self.rtp, + max_win=maxwins["bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=bonus_cond), + ], + ), + BetMode( + name="super_bonus", + cost=300.0, + rtp=self.rtp, + max_win=maxwins["super_bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["super_bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=super_bonus_cond), + ], + ), + ] diff --git a/games/1_2_sugar_stack/game_events.py b/games/1_2_sugar_stack/game_events.py new file mode 100644 index 000000000..19a03a040 --- /dev/null +++ b/games/1_2_sugar_stack/game_events.py @@ -0,0 +1,55 @@ +"""SUGAR STACK — Custom game events.""" + +from copy import deepcopy +from src.events.event_constants import EventConstants +from src.events.events import json_ready_sym + +EXPANDING_WILD = "expandingWild" +UPDATE_STICKY_WILDS = "updateStickyWilds" +SCATTER_TRIGGER = "scatterTrigger" + + +def expanding_wild_event(gamestate, reel_index: int, multiplier: int) -> None: + """Emitted when a wild expands to fill an entire reel column.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = [ + {"reel": reel_index, "row": row + offset} + for row in range(gamestate.config.num_rows[reel_index]) + ] + event = { + "index": len(gamestate.book.events), + "type": EXPANDING_WILD, + "reel": reel_index, + "multiplier": multiplier, + "positions": positions, + } + gamestate.book.add_event(event) + + +def update_sticky_wilds_event(gamestate) -> None: + """Emitted at the start of each free spin to show existing sticky wild reels.""" + existing = deepcopy(gamestate.sticky_wild_reels) + event = { + "index": len(gamestate.book.events), + "type": UPDATE_STICKY_WILDS, + "stickyWildReels": existing, + } + gamestate.book.add_event(event) + + +def scatter_trigger_event(gamestate, scatter_positions: list) -> None: + """Emitted when 3 scatters trigger free spins.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = deepcopy(scatter_positions) + if gamestate.config.include_padding: + for p in positions: + p["row"] += 1 + + event = { + "index": len(gamestate.book.events), + "type": SCATTER_TRIGGER, + "totalFs": gamestate.tot_fs, + "scatterPositions": positions, + } + assert gamestate.tot_fs > 0, "tot_fs must be >0 when emitting scatterTrigger" + gamestate.book.add_event(event) diff --git a/games/1_2_sugar_stack/game_executables.py b/games/1_2_sugar_stack/game_executables.py new file mode 100644 index 000000000..97a6c7e68 --- /dev/null +++ b/games/1_2_sugar_stack/game_executables.py @@ -0,0 +1,112 @@ +"""SUGAR STACK — Core game mechanics: Expanding Wilds with Multipliers.""" + +import random +from game_calculations import GameCalculations +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, + scatter_trigger_event, +) +from src.calculations.statistics import get_random_outcome +from src.events.events import update_freespin_event + + +class GameExecutables(GameCalculations): + + def find_wild_reels(self) -> list: + """Find all reels that contain at least one W symbol.""" + wild_reels = [] + for reel in range(self.config.num_reels): + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "W": + wild_reels.append(reel) + break + return wild_reels + + def expand_wild_reel(self, reel_index: int) -> None: + """Expand a wild to fill the entire reel column.""" + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + self.board[reel_index][row] = sym + + def assign_wild_reel_multiplier(self, reel_index: int) -> int: + """Assign a random multiplier from the pool to an expanded wild reel.""" + conditions = self.get_current_distribution_conditions() + mult = get_random_outcome(conditions["wild_mult_values"][self.gametype]) + + for row in range(self.config.num_rows[reel_index]): + sym = self.board[reel_index][row] + if sym.name == "W": + sym.assign_attribute({"multiplier": mult}) + + return mult + + def apply_expanding_wilds(self) -> list: + """ + Find all wilds on the board, expand them to fill their reel, + assign a multiplier per reel, and emit events. + """ + wild_reels = self.find_wild_reels() + expanded = [] + + for reel_index in wild_reels: + self.expand_wild_reel(reel_index) + mult = self.assign_wild_reel_multiplier(reel_index) + expanded.append({"reel": reel_index, "mult": mult}) + expanding_wild_event(self, reel_index, mult) + + return expanded + + def find_scatter_positions(self) -> list: + """Find all SC scatter positions on the board (reels 0, 2, 4 only).""" + positions = [] + for reel in [0, 2, 4]: + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "SC": + positions.append({"reel": reel, "row": row}) + return positions + + def check_scatter_trigger(self) -> tuple: + """Check if 3 or more scatters are on the board.""" + positions = self.find_scatter_positions() + triggered = len(positions) >= 3 + return triggered, positions + + def trigger_freespins_from_scatter(self, scatter_positions: list) -> None: + """3 scatters → 10 free spins.""" + self.record({ + "kind": "scatter", + "symbol": "SC", + "gametype": self.gametype, + }) + self.tot_fs = 10 + scatter_trigger_event(self, scatter_positions) + self.run_freespin() + + def restore_sticky_wilds(self) -> None: + """Restore all sticky wild reels from previous free spins.""" + for sw in self.sticky_wild_reels: + reel_index = sw["reel"] + mult = sw["mult"] + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + sym.assign_attribute({"multiplier": mult}) + self.board[reel_index][row] = sym + + def add_sticky_wild_reels(self, expanded_wilds: list) -> None: + """Add newly expanded wild reels to the sticky list.""" + existing_reels = {sw["reel"] for sw in self.sticky_wild_reels} + for ew in expanded_wilds: + if ew["reel"] not in existing_reels: + self.sticky_wild_reels.append(ew) + existing_reels.add(ew["reel"]) + + def pre_place_expanding_wild(self) -> None: + """Pre-place one expanding wild on a random reel for super_bonus.""" + available = list(range(self.config.num_reels)) + chosen_reel = random.choice(available) + self.expand_wild_reel(chosen_reel) + mult = self.assign_wild_reel_multiplier(chosen_reel) + entry = {"reel": chosen_reel, "mult": mult} + self.sticky_wild_reels.append(entry) + expanding_wild_event(self, chosen_reel, mult) diff --git a/games/1_2_sugar_stack/game_optimization.py b/games/1_2_sugar_stack/game_optimization.py new file mode 100644 index 000000000..ee9c67529 --- /dev/null +++ b/games/1_2_sugar_stack/game_optimization.py @@ -0,0 +1,97 @@ +"""SUGAR STACK — Optimization parameters.""" + +from optimization_program.optimization_config import ( + ConstructScaling, + ConstructParameters, + ConstructConditions, + verify_optimization_input, +) + + +class OptimizationSetup: + """Optimization parameters for each SUGAR STACK bet mode.""" + + def __init__(self, game_config): + self.game_config = game_config + wincaps = {bm.get_name(): bm.get_wincap() for bm in game_config.bet_modes} + + self.game_config.opt_params = { + "base": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, 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.40, hr=50, search_conditions={"kind": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.55).return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "basegame", "scale_factor": 1.2, "win_range": (1, 5), "probability": 1.0}, + {"criteria": "basegame", "scale_factor": 1.5, "win_range": (10, 30), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 1000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (2000, 5000), "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(), + }, + "bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.95, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.9, "win_range": (10, 50), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 2000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (3000, 7000), "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(), + }, + "super_bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["super_bonus"], search_conditions=wincaps["super_bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.95, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (100, 500), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.3, "win_range": (5000, 10000), "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) diff --git a/games/1_2_sugar_stack/game_override.py b/games/1_2_sugar_stack/game_override.py new file mode 100644 index 000000000..b1200d2ef --- /dev/null +++ b/games/1_2_sugar_stack/game_override.py @@ -0,0 +1,46 @@ +"""SUGAR STACK — State overrides (book reset, special symbol functions, repeat check).""" + +from game_executables import GameExecutables +from src.calculations.statistics import get_random_outcome + + +class GameStateOverride(GameExecutables): + + def reset_book(self) -> None: + """Reset game-specific state alongside the base book reset.""" + super().reset_book() + self.sticky_wild_reels = [] + + def assign_special_sym_function(self) -> None: + """ + Register per-symbol attribute functions. + W gets its multiplier assigned AFTER the board is drawn + (via apply_expanding_wilds), so this function intentionally + does nothing — it just satisfies the abstract interface requirement. + """ + self.special_symbol_functions = { + "W": [], + "SC": [], + } + + def check_repeat(self) -> None: + """ + Extend the base repeat check: + - Honour force_freegame (scatter trigger required). + - Honour win_criteria for wincap simulations. + - Ensure non-zero-win criteria actually produce wins. + """ + if self.repeat is False: + conditions = self.get_current_distribution_conditions() + 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 + return + + if conditions.get("force_freegame") and not self.triggered_freegame: + self.repeat = True + return + + if self.criteria not in ("0",) and win_criteria is None and self.win_manager.running_bet_win == 0.0: + self.repeat = True diff --git a/games/1_2_sugar_stack/gamestate.py b/games/1_2_sugar_stack/gamestate.py new file mode 100644 index 000000000..61161a8d5 --- /dev/null +++ b/games/1_2_sugar_stack/gamestate.py @@ -0,0 +1,94 @@ +"""SUGAR STACK — GameState: orchestrates base game and free spins with expanding wilds.""" + +from game_override import GameStateOverride +from src.calculations.lines import Lines +from src.calculations.statistics import get_random_outcome +from src.events.events import reveal_event +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, +) + + +class GameState(GameStateOverride): + """ + Base game flow: + 1. Draw board from reel strips. + 2. Find wilds → expand to fill reel column → assign random multiplier. + 3. Evaluate paylines (wild multipliers multiply together on multi-wild lines). + 4. If 3 SC scatters on reels 0/2/4 → trigger 10 free spins. + + Free spins: + 1. Restore existing sticky expanded wilds (multipliers persist). + 2. Draw new board, find new wilds → expand + assign multiplier → become sticky. + 3. Evaluate paylines. + Repeat for 10 spins (retrigger possible with 3 more scatters). + """ + + def run_spin(self, sim, simulation_seed=None) -> None: + self.reset_seed(sim) + self.repeat = True + + while self.repeat: + self.reset_book() + self.draw_board(emit_event=True) + + self.apply_expanding_wilds() + + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.trigger_freespins_from_scatter(scatter_positions) + + self.evaluate_finalwin() + self.check_repeat() + + self.imprint_wins() + + def run_freespin(self) -> None: + """10-spin free spins with sticky expanding wilds and multipliers.""" + self.reset_fs_spin() + self.sticky_wild_reels = [] + + conditions = self.get_current_distribution_conditions() + pre_placed = conditions.get("pre_placed_wilds", 0) + if pre_placed > 0: + self.draw_board(emit_event=False) + for _ in range(pre_placed): + self.pre_place_expanding_wild() + + while self.fs < self.tot_fs and not self.wincap_triggered: + self.update_freespin() + self.draw_board(emit_event=False) + + self.restore_sticky_wilds() + if self.sticky_wild_reels: + update_sticky_wilds_event(self) + + expanded = self.apply_expanding_wilds() + + if expanded: + self.add_sticky_wild_reels(expanded) + + reveal_event(self) + + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.tot_fs += 10 + + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + self.end_freespin() diff --git a/games/1_2_sugar_stack/reels/BR0.csv b/games/1_2_sugar_stack/reels/BR0.csv new file mode 100644 index 000000000..8ea7cd9af --- /dev/null +++ b/games/1_2_sugar_stack/reels/BR0.csv @@ -0,0 +1,101 @@ +10,J,Q,cherry,lemon +J,A,plum,A,10 +A,orange,Q,K,10 +orange,J,Q,J,J +J,Q,K,plum,K +10,lemon,cherry,Q,lemon +K,watermelon,10,J,Q +Q,K,lemon,10,A +lemon,10,A,lemon,cherry +A,cherry,J,A,10 +cherry,Q,10,cherry,J +10,J,grape,K,plum +J,plum,Q,10,K +K,A,K,J,Q +Q,10,plum,Q,A +plum,K,A,A,10 +A,lemon,J,plum,lemon +10,J,10,K,J +lemon,Q,cherry,10,K +K,cherry,K,lemon,Q +J,A,lemon,J,A +Q,10,Q,Q,cherry +10,grape,A,cherry,10 +A,K,J,A,J +cherry,lemon,10,10,Q +K,J,K,K,plum +lemon,Q,plum,J,K +J,plum,Q,plum,A +Q,A,A,Q,10 +A,10,cherry,A,lemon +10,K,J,lemon,J +K,cherry,10,10,K +SC,J,SC,K,Q +J,Q,K,J,A +plum,A,lemon,Q,10 +Q,10,Q,A,cherry +A,lemon,A,10,J +10,K,J,cherry,K +K,J,10,K,SC +lemon,Q,K,J,A +J,A,Q,plum,10 +Q,plum,A,Q,lemon +A,10,lemon,A,J +cherry,K,J,10,K +10,cherry,10,lemon,Q +K,J,K,K,A +SC,Q,SC,J,10 +J,A,K,Q,cherry +Q,lemon,Q,A,J +A,10,A,cherry,K +10,K,lemon,10,Q +K,J,J,K,A +lemon,Q,10,J,10 +J,plum,K,plum,lemon +grape,A,Q,Q,J +Q,10,A,A,K +A,K,lemon,10,Q +10,cherry,J,lemon,A +K,J,10,K,W +W,Q,K,J,cherry +J,A,Q,Q,10 +Q,lemon,A,A,J +A,10,cherry,10,K +10,K,J,cherry,Q +K,J,10,K,A +lemon,Q,K,J,10 +J,A,plum,Q,lemon +Q,10,Q,A,J +A,cherry,A,10,K +cherry,K,lemon,lemon,Q +10,J,J,K,A +K,Q,10,J,10 +SC,A,SC,Q,lemon +J,10,K,plum,J +Q,lemon,Q,A,K +A,K,A,10,W +10,J,lemon,cherry,A +K,Q,J,K,10 +lemon,A,10,J,cherry +J,10,K,Q,J +Q,cherry,Q,A,K +A,K,A,lemon,Q +10,J,cherry,10,A +K,plum,J,K,10 +cherry,Q,10,J,lemon +J,A,K,Q,J +Q,lemon,plum,A,K +A,10,Q,10,Q +10,K,A,cherry,A +K,J,lemon,K,10 +lemon,Q,J,J,cherry +SC,A,SC,Q,J +J,10,10,A,K +Q,cherry,K,lemon,Q +A,K,Q,10,A +10,lemon,A,K,10 +K,J,lemon,J,grape +plum,Q,J,Q,J +Q,A,10,A,K +A,10,K,cherry,Q +cherry,K,Q,K,A diff --git a/games/1_2_sugar_stack/reels/FR0.csv b/games/1_2_sugar_stack/reels/FR0.csv new file mode 100644 index 000000000..e01a2efb6 --- /dev/null +++ b/games/1_2_sugar_stack/reels/FR0.csv @@ -0,0 +1,70 @@ +10,J,Q,cherry,lemon +J,K,plum,A,10 +A,orange,Q,K,cherry +orange,J,Q,J,J +J,Q,K,plum,K +10,lemon,cherry,Q,lemon +K,watermelon,10,J,Q +Q,K,lemon,10,A +lemon,10,A,lemon,cherry +K,cherry,J,A,10 +cherry,Q,10,cherry,J +10,J,grape,K,plum +J,plum,Q,10,K +K,A,K,J,Q +Q,10,plum,Q,A +plum,K,A,A,J +A,lemon,K,plum,lemon +10,J,10,K,J +lemon,Q,cherry,10,K +K,cherry,K,lemon,Q +J,W,lemon,J,A +Q,10,Q,Q,cherry +10,grape,A,cherry,10 +A,K,J,K,J +cherry,lemon,10,10,Q +K,J,K,K,plum +lemon,Q,plum,J,K +W,plum,Q,plum,A +Q,A,A,Q,10 +A,10,cherry,A,lemon +10,K,J,lemon,K +K,cherry,10,10,K +SC,J,SC,K,Q +J,Q,K,J,A +plum,A,lemon,Q,10 +Q,A,Q,A,cherry +A,lemon,A,10,J +10,K,J,cherry,K +K,J,10,K,SC +lemon,Q,K,J,A +J,A,Q,plum,10 +Q,plum,W,Q,lemon +A,10,lemon,A,J +cherry,K,J,10,K +10,cherry,10,lemon,Q +K,J,K,K,A +SC,Q,SC,J,10 +J,A,K,Q,cherry +Q,lemon,Q,W,J +Q,10,A,cherry,K +10,K,lemon,10,Q +K,J,J,K,A +lemon,Q,10,J,10 +J,plum,K,plum,lemon +grape,A,Q,Q,W +Q,10,A,A,K +A,K,lemon,10,Q +10,watermelon,J,lemon,A +K,J,10,K,A +J,Q,K,J,cherry +J,A,Q,Q,10 +Q,lemon,A,A,J +A,10,cherry,10,K +10,K,J,cherry,Q +K,J,10,K,A +lemon,Q,K,J,10 +J,10,plum,Q,lemon +Q,10,Q,A,J +A,cherry,A,10,K +cherry,K,lemon,lemon,Q diff --git a/games/1_2_sugar_stack/run.py b/games/1_2_sugar_stack/run.py new file mode 100644 index 000000000..27ff9aa1b --- /dev/null +++ b/games/1_2_sugar_stack/run.py @@ -0,0 +1,75 @@ +"""SUGAR STACK — Main simulation entry point.""" + +import os +import sys + +_game_dir = os.path.dirname(os.path.abspath(__file__)) +_sdk_root = os.path.dirname(os.path.dirname(_game_dir)) +for _p in [ + os.path.join(_sdk_root, "env", "src", "stakeengine"), + _game_dir, +]: + if _p not in sys.path: + sys.path.insert(0, _p) + +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 = 4 + rust_threads = 16 + batching_size = 5000 + compression = True + profiling = False + + num_sim_args = { + "base": 10000, + "bonus": 10000, + "super_bonus": 10000, + } + + run_conditions = { + "run_sims": True, + "run_optimization": True, + "run_analysis": False, + "run_format_checks": False, + } + + target_modes = list(num_sim_args.keys()) + + config = GameConfig() + gamestate = GameState(config) + + if run_conditions["run_optimization"]: + 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 = [{"kind": "scatter"}] + create_stat_sheet(gamestate, custom_keys=custom_keys) + + if run_conditions["run_format_checks"]: + execute_all_tests(config) diff --git a/games/fight_club/game_calculations.py b/games/fight_club/game_calculations.py new file mode 100644 index 000000000..f2db049cd --- /dev/null +++ b/games/fight_club/game_calculations.py @@ -0,0 +1,31 @@ +"""Fight Club — Cluster evaluation using KO Reels global multiplier.""" + +from src.executables.executables import Executables +from src.calculations.cluster import Cluster + + +class GameCalculations(Executables): + """ + Uses the base Cluster.evaluate_clusters() with global_multiplier. + No grid position multipliers — Fight Club uses a single KO Reels multiplier + that grows with each cascade. + """ + + def get_clusters_update_wins(self): + """Find clusters on board and update win manager using KO global multiplier.""" + clusters = Cluster.get_clusters(self.board, "wild") + return_data = { + "totalWin": 0, + "wins": [], + } + self.board, self.win_data = Cluster.evaluate_clusters( + config=self.config, + board=self.board, + clusters=clusters, + 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"] diff --git a/games/fight_club/game_config.py b/games/fight_club/game_config.py new file mode 100644 index 000000000..fbbcc1a7d --- /dev/null +++ b/games/fight_club/game_config.py @@ -0,0 +1,328 @@ +"""Fight Club — Underground Fight Tournament Slot (Cascade Cluster Pays)""" + +import os +from src.config.config import Config +from src.config.distributions import Distribution +from src.config.betmode import BetMode + + +class GameConfig(Config): + """Singleton Fight Club game configuration class.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + super().__init__() + self.game_id = "fight_club" + self.provider_number = 0 + self.working_name = "Fight Club" + self.wincap = 15000.0 + self.win_type = "cluster" + self.rtp = 0.9700 + self.construct_paths() + + # Game Dimensions — 6 reels x 5 rows + self.num_reels = 6 + self.num_rows = [5] * self.num_reels + + # Cluster size tiers: 5-7, 8-10, 11-14, 15-30 + t1, t2, t3, t4 = (5, 7), (8, 10), (11, 14), (15, 30) + + # Paytable — (cluster_size_range, symbol): payout x bet + # Premium fighters: H1=Bull, H2=Viper, H3=IronJaw, H4=Ghost, H5=Rookie + # Low pay gear: L1=Knuckles, L2=Gloves, L3=Mouthguard, L4=Tape, L5=Towel + pay_group = { + # Bull — top premium + (t1, "H1"): 2.0, + (t2, "H1"): 5.0, + (t3, "H1"): 20.0, + (t4, "H1"): 50.0, + # Viper + (t1, "H2"): 1.5, + (t2, "H2"): 4.0, + (t3, "H2"): 15.0, + (t4, "H2"): 40.0, + # Iron Jaw + (t1, "H3"): 1.0, + (t2, "H3"): 3.0, + (t3, "H3"): 10.0, + (t4, "H3"): 30.0, + # Ghost + (t1, "H4"): 0.8, + (t2, "H4"): 2.0, + (t3, "H4"): 8.0, + (t4, "H4"): 20.0, + # Rookie + (t1, "H5"): 0.5, + (t2, "H5"): 1.5, + (t3, "H5"): 5.0, + (t4, "H5"): 15.0, + # Brass Knuckles + (t1, "L1"): 0.4, + (t2, "L1"): 1.0, + (t3, "L1"): 3.0, + (t4, "L1"): 10.0, + # Boxing Gloves + (t1, "L2"): 0.3, + (t2, "L2"): 0.8, + (t3, "L2"): 2.5, + (t4, "L2"): 8.0, + # Mouthguard + (t1, "L3"): 0.2, + (t2, "L3"): 0.5, + (t3, "L3"): 2.0, + (t4, "L3"): 5.0, + # Tape Roll + (t1, "L4"): 0.15, + (t2, "L4"): 0.4, + (t3, "L4"): 1.5, + (t4, "L4"): 4.0, + # Towel + (t1, "L5"): 0.1, + (t2, "L5"): 0.3, + (t3, "L5"): 1.0, + (t4, "L5"): 3.0, + } + self.paytable = self.convert_range_table(pay_group) + + self.include_padding = True + self.special_symbols = {"wild": ["W"], "scatter": ["S"]} + + # Scatter triggers: 4+ Fight Cards trigger bonus + self.freespin_triggers = { + self.basegame_type: {4: 8, 5: 10, 6: 12}, + self.freegame_type: {3: 3, 4: 5, 5: 8, 6: 10}, + } + self.anticipation_triggers = { + self.basegame_type: min(self.freespin_triggers[self.basegame_type].keys()) - 1, + self.freegame_type: min(self.freespin_triggers[self.freegame_type].keys()) - 1, + } + + # KO Reels multiplier config (used by game_executables) + self.ko_mult_increment = { + "base": 1, + "exhibition": 1, + "title_fight": 1, + "death_match": 2, + } + self.ko_mult_start = { + "base": 1, + "exhibition": 2, + "title_fight": 1, + "death_match": 3, + } + self.ko_mult_carries_over = { + "base": False, + "exhibition": False, + "title_fight": True, + "death_match": True, + } + + self.maximum_board_mult = 512 + + # Fighter symbols for Death Match random fighter->WILD + self.fighter_symbols = ["H1", "H2", "H3", "H4", "H5"] + + # Reel strips + reels = {"BR0": "BR0.csv", "FR0": "FR0.csv", "WCAP": "WCAP.csv"} + self.reels = {} + for r, f in reels.items(): + self.reels[r] = self.read_reels_csv(os.path.join(self.reels_path, f)) + + mode_maxwins = { + "base": 15000, + "exhibition": 15000, + "title_fight": 15000, + "death_match": 15000, + } + + self.bet_modes = [ + # BASE MODE — standard play + BetMode( + name="base", + cost=1.0, + rtp=self.rtp, + max_win=mode_maxwins["base"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=False, + distributions=[ + Distribution( + criteria="wincap", + quota=0.001, + win_criteria=mode_maxwins["base"], + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1, "WCAP": 5}, + }, + "scatter_triggers": {4: 1, 5: 2}, + "force_wincap": True, + "force_freegame": True, + }, + ), + Distribution( + criteria="freegame", + quota=0.1, + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {4: 5, 5: 1}, + "force_wincap": False, + "force_freegame": True, + }, + ), + Distribution( + criteria="0", + quota=0.4, + win_criteria=0.0, + conditions={ + "reel_weights": {self.basegame_type: {"BR0": 1}}, + "force_wincap": False, + "force_freegame": False, + }, + ), + Distribution( + criteria="basegame", + quota=0.5, + conditions={ + "reel_weights": {self.basegame_type: {"BR0": 1}}, + "force_wincap": False, + "force_freegame": False, + }, + ), + ], + ), + # EXHIBITION MATCH — 75x buy, 8 FS, KO starts at 2x, resets each spin + BetMode( + name="exhibition", + cost=75.0, + rtp=self.rtp, + max_win=mode_maxwins["exhibition"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=True, + distributions=[ + Distribution( + criteria="wincap", + quota=0.001, + win_criteria=mode_maxwins["exhibition"], + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1, "WCAP": 5}, + }, + "scatter_triggers": {4: 1, 5: 2}, + "force_wincap": True, + "force_freegame": True, + "freespin_override": 8, + }, + ), + Distribution( + criteria="freegame", + quota=0.999, + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {4: 5, 5: 1}, + "force_wincap": False, + "force_freegame": True, + "freespin_override": 8, + }, + ), + ], + ), + # TITLE FIGHT — 150x buy, 10 FS, KO starts at 1x, carries over + BetMode( + name="title_fight", + cost=150.0, + rtp=self.rtp, + max_win=mode_maxwins["title_fight"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=True, + distributions=[ + Distribution( + criteria="wincap", + quota=0.001, + win_criteria=mode_maxwins["title_fight"], + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1, "WCAP": 5}, + }, + "scatter_triggers": {4: 1, 5: 2}, + "force_wincap": True, + "force_freegame": True, + "freespin_override": 10, + }, + ), + Distribution( + criteria="freegame", + quota=0.999, + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {4: 5, 5: 1}, + "force_wincap": False, + "force_freegame": True, + "freespin_override": 10, + }, + ), + ], + ), + # DEATH MATCH — 300x buy, 5 FS, KO starts at 3x, +2x per cascade, fighter->WILD + BetMode( + name="death_match", + cost=300.0, + rtp=self.rtp, + max_win=mode_maxwins["death_match"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=True, + distributions=[ + Distribution( + criteria="wincap", + quota=0.001, + win_criteria=mode_maxwins["death_match"], + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1, "WCAP": 5}, + }, + "scatter_triggers": {4: 1, 5: 2}, + "force_wincap": True, + "force_freegame": True, + "freespin_override": 5, + "death_match": True, + }, + ), + Distribution( + criteria="freegame", + quota=0.999, + conditions={ + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {4: 5, 5: 1}, + "force_wincap": False, + "force_freegame": True, + "freespin_override": 5, + "death_match": True, + }, + ), + ], + ), + ] diff --git a/games/fight_club/game_events.py b/games/fight_club/game_events.py new file mode 100644 index 000000000..6b531049e --- /dev/null +++ b/games/fight_club/game_events.py @@ -0,0 +1,25 @@ +"""Fight Club — Custom events for KO Reels and Death Match mechanics.""" + +KO_MULT_UPDATE = "koMultUpdate" +FIGHTER_TO_WILD = "fighterToWild" + + +def ko_mult_event(gamestate): + """Emit the current KO Reels multiplier value after each cascade.""" + event = { + "index": len(gamestate.book.events), + "type": KO_MULT_UPDATE, + "koMultiplier": gamestate.global_multiplier, + } + gamestate.book.add_event(event) + + +def fighter_wild_event(gamestate, fighter_symbol, positions): + """Emit event when a random fighter is converted to WILD in Death Match.""" + event = { + "index": len(gamestate.book.events), + "type": FIGHTER_TO_WILD, + "fighter": fighter_symbol, + "positions": positions, + } + gamestate.book.add_event(event) diff --git a/games/fight_club/game_executables.py b/games/fight_club/game_executables.py new file mode 100644 index 000000000..b33c85da3 --- /dev/null +++ b/games/fight_club/game_executables.py @@ -0,0 +1,75 @@ +"""Fight Club — Core mechanics: KO Reels multiplier + Death Match fighter->WILD.""" + +import random +from game_calculations import GameCalculations +from game_events import ko_mult_event, fighter_wild_event +from src.events.events import update_freespin_event + + +class GameExecutables(GameCalculations): + """Game-specific grouped functions for Fight Club.""" + + def get_current_mode_name(self): + """Get the current bet mode name.""" + return self.config.bet_modes[self.betmode].get_name() + + def reset_ko_mult(self): + """Reset KO multiplier to the starting value for the current mode.""" + if not hasattr(self, "betmode") or self.betmode is None: + self.global_multiplier = 1 + return + mode = self.get_current_mode_name() + self.global_multiplier = self.config.ko_mult_start.get(mode, 1) + + def increment_ko_mult(self): + """Increment KO multiplier by the mode's increment value.""" + mode = self.get_current_mode_name() + increment = self.config.ko_mult_increment.get(mode, 1) + self.global_multiplier += increment + self.global_multiplier = min(self.global_multiplier, self.config.maximum_board_mult) + ko_mult_event(self) + + def should_ko_carry_over(self): + """Check if KO multiplier carries over between spins in current mode.""" + mode = self.get_current_mode_name() + return self.config.ko_mult_carries_over.get(mode, False) + + def is_death_match(self): + """Check if current distribution has death_match flag.""" + conditions = self.get_current_distribution_conditions() + return conditions.get("death_match", False) + + def convert_random_fighter_to_wild(self): + """ + Death Match: pick a random fighter symbol and convert + all instances on the board to WILD. + """ + fighter = random.choice(self.config.fighter_symbols) + converted_positions = [] + + for reel in range(self.config.num_reels): + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == fighter: + self.board[reel][row] = self.create_symbol("W") + converted_positions.append({"reel": reel, "row": row}) + + if converted_positions: + fighter_wild_event(self, fighter, converted_positions) + + return fighter, converted_positions + + def get_freespin_count_for_mode(self): + """Get fixed free spin count from freespin_override, or from scatter triggers.""" + conditions = self.get_current_distribution_conditions() + override = conditions.get("freespin_override", None) + if override is not None: + return override + scatter_count = self.count_special_symbols("scatter") + return self.config.freespin_triggers[self.gametype].get(scatter_count, 8) + + 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.win_data = {} diff --git a/games/fight_club/game_optimization.py b/games/fight_club/game_optimization.py new file mode 100644 index 000000000..9d38182ca --- /dev/null +++ b/games/fight_club/game_optimization.py @@ -0,0 +1,137 @@ +"""Fight Club — Optimization targets for all 4 bet modes.""" + +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.01, 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.40, hr=200, search_conditions={"symbol": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.56).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": (5000, 10000), "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(), + }, + "exhibition": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["exhibition"], search_conditions=wincaps["exhibition"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.96, 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": (5000, 10000), "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(), + }, + "title_fight": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["title_fight"], search_conditions=wincaps["title_fight"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.96, 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": (5000, 10000), "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(), + }, + "death_match": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["death_match"], search_conditions=wincaps["death_match"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.96, 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, 3000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (5000, 12000), "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) diff --git a/games/fight_club/game_override.py b/games/fight_club/game_override.py new file mode 100644 index 000000000..5c1cbe8ec --- /dev/null +++ b/games/fight_club/game_override.py @@ -0,0 +1,34 @@ +"""Fight Club — State overrides for KO Reels and mode-specific logic.""" + +from game_executables import GameExecutables + + +class GameStateOverride(GameExecutables): + """Override or extend universal state.py functions for Fight Club.""" + + def reset_book(self): + super().reset_book() + self.tumble_win = 0 + # Reset KO multiplier at the start of each base spin + self.reset_ko_mult() + + def reset_fs_spin(self): + super().reset_fs_spin() + # Reset KO mult to mode start value at beginning of free spins + self.reset_ko_mult() + + def assign_special_sym_function(self): + pass + + def check_repeat(self) -> None: + """Checks if the spin failed a criteria constraint.""" + 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/fight_club/gamestate.py b/games/fight_club/gamestate.py new file mode 100644 index 000000000..c42630321 --- /dev/null +++ b/games/fight_club/gamestate.py @@ -0,0 +1,115 @@ +"""Fight Club — GameState: cascade cluster pays with KO Reels multiplier.""" + +from game_override import GameStateOverride +from game_events import ko_mult_event +from src.events.events import fs_trigger_event + + +class GameState(GameStateOverride): + """ + Base game flow: + 1. Draw board from reel strips + 2. Find clusters (5+ adjacent matching symbols) + 3. If wins: shatter winning symbols, drop new ones (cascade/tumble) + 4. Each cascade increments KO Reels multiplier + 5. Repeat until no more wins + 6. If 4+ scatters: trigger free spins + + Free spins (3 types): + Exhibition (8 FS): KO starts at 2x, resets each spin + Title Fight (10 FS): KO starts at 1x, carries over between spins + Death Match (5 FS): KO starts at 3x, +2x per cascade, carries over, + random fighter->WILD each spin + """ + + def run_spin(self, sim, simulation_seed=None) -> None: + self.reset_seed(sim) + self.repeat = True + + while self.repeat: + self.reset_book() + self.draw_board() + + # Emit initial KO multiplier + ko_mult_event(self) + + # Evaluate clusters and cascade + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + while self.win_data["totalWin"] > 0 and not self.wincap_triggered: + # Each cascade = KO multiplier increases + self.increment_ko_mult() + self.tumble_game_board() + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + self.set_end_tumble_event() + self.win_manager.update_gametype_wins(self.gametype) + + # Check scatter trigger for free spins + 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_from_base(self, scatter_key="scatter") -> None: + """Override to use mode-specific free spin count.""" + self.record( + { + "kind": self.count_special_symbols(scatter_key), + "symbol": scatter_key, + "gametype": self.gametype, + } + ) + # Set FS count: use freespin_override if set, otherwise scatter-based + self.tot_fs = self.get_freespin_count_for_mode() + + basegame_trigger, freegame_trigger = True, False + fs_trigger_event(self, basegame_trigger=basegame_trigger, freegame_trigger=freegame_trigger) + + self.run_freespin() + + def run_freespin(self): + """Free spins with KO Reels multiplier and optional Death Match mechanic.""" + self.reset_fs_spin() + + death_match = self.is_death_match() + + while self.fs < self.tot_fs and not self.wincap_triggered: + self.update_freespin() + + # Reset KO mult per spin if mode doesn't carry over + if not self.should_ko_carry_over(): + self.reset_ko_mult() + + self.draw_board() + + # Death Match: convert a random fighter to WILD each spin + if death_match: + self.convert_random_fighter_to_wild() + + # Emit initial KO multiplier for this spin + ko_mult_event(self) + + # Evaluate clusters and cascade + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + while self.win_data["totalWin"] > 0 and not self.wincap_triggered: + self.increment_ko_mult() + self.tumble_game_board() + self.get_clusters_update_wins() + self.emit_tumble_win_events() + + self.set_end_tumble_event() + self.win_manager.update_gametype_wins(self.gametype) + + # Check for retrigger + if self.check_fs_condition(): + self.update_fs_retrigger_amt() + + self.end_freespin() diff --git a/games/fight_club/generate_reels.py b/games/fight_club/generate_reels.py new file mode 100644 index 000000000..1319c5ce2 --- /dev/null +++ b/games/fight_club/generate_reels.py @@ -0,0 +1,135 @@ +"""Fight Club — Reel strip generator for 6x5 cascade cluster game.""" + +import csv +import os +import random + +# Symbols per reel for base game (BR0) +# 6 reels, each with ~200 positions +# Low pay symbols appear more frequently, premiums less +# W (wild) rare, S (scatter) on specific reels only + +BASE_WEIGHTS = { + "H1": 4, # Bull (rarest premium) + "H2": 5, # Viper + "H3": 7, # Iron Jaw + "H4": 9, # Ghost + "H5": 11, # Rookie (most common premium) + "L1": 14, # Brass Knuckles + "L2": 16, # Boxing Gloves + "L3": 18, # Mouthguard + "L4": 20, # Tape Roll + "L5": 22, # Towel (most common) + "W": 3, # Wild + "S": 2, # Scatter +} + +# Free game weights: more wilds, no scatters (triggered separately) +FREE_WEIGHTS = { + "H1": 5, + "H2": 6, + "H3": 8, + "H4": 10, + "H5": 12, + "L1": 14, + "L2": 16, + "L3": 17, + "L4": 18, + "L5": 20, + "W": 5, + "S": 0, +} + +# Wincap reels: very high wild frequency for forced max wins +WCAP_WEIGHTS = { + "H1": 8, + "H2": 8, + "H3": 8, + "H4": 6, + "H5": 6, + "L1": 5, + "L2": 5, + "L3": 4, + "L4": 4, + "L5": 4, + "W": 15, + "S": 2, +} + +NUM_REELS = 6 +REEL_LENGTH = 200 +WCAP_LENGTH = 150 + + +def build_weighted_pool(weights): + """Create a flat list of symbols based on weights.""" + pool = [] + for sym, count in weights.items(): + if count > 0: + pool.extend([sym] * count) + return pool + + +def generate_reel(pool, length): + """Generate a single reel strip, shuffled to avoid 3+ consecutive same symbols.""" + reel = [] + for _ in range(length): + reel.append(random.choice(pool)) + + # Shuffle to break up runs of 3+ same symbol + max_passes = 5 + for _ in range(max_passes): + changed = False + for i in range(2, len(reel)): + if reel[i] == reel[i-1] == reel[i-2]: + # Swap with a random position that won't create another run + candidates = [j for j in range(len(reel)) if j != i and j != i-1 and j != i-2] + if candidates: + swap_idx = random.choice(candidates) + reel[i], reel[swap_idx] = reel[swap_idx], reel[i] + changed = True + if not changed: + break + + return reel + + +def write_csv(filename, reels_data): + """Write reel strips to CSV. Each column = one reel.""" + with open(filename, "w", newline="") as f: + writer = csv.writer(f) + num_rows = len(reels_data[0]) + for row_idx in range(num_rows): + row = [reels_data[reel][row_idx] for reel in range(len(reels_data))] + writer.writerow(row) + + +def main(): + random.seed(42) # Reproducible + + reels_dir = os.path.join(os.path.dirname(__file__), "reels") + os.makedirs(reels_dir, exist_ok=True) + + # BR0 — Base game reels + base_pool = build_weighted_pool(BASE_WEIGHTS) + br0 = [generate_reel(base_pool, REEL_LENGTH) for _ in range(NUM_REELS)] + write_csv(os.path.join(reels_dir, "BR0.csv"), br0) + print(f"BR0.csv: {NUM_REELS} reels x {REEL_LENGTH} rows") + + # FR0 — Free game reels + free_pool = build_weighted_pool(FREE_WEIGHTS) + fr0 = [generate_reel(free_pool, REEL_LENGTH) for _ in range(NUM_REELS)] + write_csv(os.path.join(reels_dir, "FR0.csv"), fr0) + print(f"FR0.csv: {NUM_REELS} reels x {REEL_LENGTH} rows") + + # WCAP — Wincap reels + wcap_pool = build_weighted_pool(WCAP_WEIGHTS) + wcap = [generate_reel(wcap_pool, WCAP_LENGTH) for _ in range(NUM_REELS)] + write_csv(os.path.join(reels_dir, "WCAP.csv"), wcap) + print(f"WCAP.csv: {NUM_REELS} reels x {WCAP_LENGTH} rows") + + print("Done! Reel strips generated in reels/") + + +if __name__ == "__main__": + main() diff --git a/games/fight_club/readme.txt b/games/fight_club/readme.txt new file mode 100644 index 000000000..054863af8 --- /dev/null +++ b/games/fight_club/readme.txt @@ -0,0 +1,18 @@ +# Cluster-based win game + +Clusters of 5 or more like-symbols are removed from the board, and symbols above on the reelstrip +fall to fill their place. + +#### Basegame: +Standard tumbling game with Scatter and Wild symbols. +Minimum of 4 Scatter symbols are required for freeSpin triggers + +#### 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 + + +#### 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. \ No newline at end of file diff --git a/games/fight_club/reels/BR0.csv b/games/fight_club/reels/BR0.csv new file mode 100644 index 000000000..0351b4483 --- /dev/null +++ b/games/fight_club/reels/BR0.csv @@ -0,0 +1,200 @@ +H5,L2,L3,H1,L5,L4 +H2,H4,L2,L1,L1,H5 +L3,L5,L5,H4,L4,L4 +L2,H5,L3,L2,L1,L4 +L2,H3,L5,L5,S,H3 +H5,H1,L5,L5,H5,L4 +H5,H4,H4,L2,H4,L3 +H4,L2,L2,L5,L3,L1 +L5,L1,S,L5,L5,H5 +H2,L5,L5,H4,L4,L5 +H2,L5,L1,H2,S,H4 +H4,H5,H4,H4,L3,L2 +L2,L2,L3,L3,H1,H2 +L2,L4,L4,L2,L3,H3 +S,H3,H4,L4,L3,L4 +H2,L1,L2,L2,L5,L2 +L2,L4,L3,L3,L1,H5 +L5,H1,L2,L4,L5,L2 +L2,L4,L2,L5,L5,H4 +L5,L3,L1,L4,L4,L2 +L3,L5,H2,L5,L4,L2 +H1,L3,H3,L4,H4,L4 +L1,L5,L2,L4,L5,L2 +L5,L5,L5,L5,L3,L1 +L4,L1,H4,L3,L1,H1 +L3,L1,L5,L3,L2,L3 +L1,L3,L5,L2,L4,L1 +L2,L2,L1,L2,L2,H5 +L4,H3,L4,H5,L5,L2 +H5,H3,W,L1,H3,L1 +H4,L3,L4,L3,L3,H5 +L4,H3,L2,H5,L5,H2 +H4,H3,L1,L1,L4,H5 +L4,L5,H1,L1,L4,H1 +L4,W,H5,L2,L1,L4 +L3,L1,L5,L5,W,L2 +H3,H3,L2,L3,H3,L3 +L5,S,L1,L3,H5,H2 +H5,H4,L5,H5,W,L1 +L4,L1,H3,L1,L4,L3 +H4,H4,L2,L3,H5,H3 +L3,H4,H5,L2,L5,H5 +L4,L2,L5,L4,H5,L5 +L1,L4,H5,L1,L5,H5 +H4,H5,L5,L3,H1,H4 +H3,L2,L3,H1,L1,L5 +L2,H3,L5,H5,L5,L5 +L3,H4,S,L3,L1,L4 +H4,L5,L5,H3,H4,H5 +L2,L3,L5,H3,L5,L5 +H5,L3,L1,L3,L3,W +L4,L2,L5,H5,L4,L2 +L3,L3,L5,L5,L4,H3 +L5,L2,L3,H5,H4,L3 +L4,L3,L2,H1,L4,L5 +L1,L4,L3,L3,L4,H2 +L4,H5,L5,L5,L3,H3 +L4,L3,L2,L5,L5,L5 +L2,L5,L3,W,H3,L4 +L3,L3,L5,L4,H4,L5 +H4,H4,H4,L1,L2,H5 +L1,H1,L3,H3,L3,L5 +L2,L5,L2,L2,L2,L5 +L1,H5,L3,L5,L4,H4 +L5,H4,L4,H5,L5,H4 +L4,L2,L3,L5,H5,L3 +L3,S,H4,L4,H5,L1 +L2,L3,H5,L5,L5,H4 +L3,H5,L1,H4,L1,H5 +H3,L4,L2,H3,L3,L3 +L2,H4,L4,L1,H2,L3 +H2,L2,L1,L1,H3,L4 +L3,L4,L2,L3,L3,L3 +L4,L3,L5,H4,H3,L5 +L3,L1,L5,L2,L3,S +H4,L5,L1,H5,L4,L5 +L2,L3,L4,L5,L4,H5 +L3,H1,L5,L2,L5,H5 +L2,L3,L5,L4,L1,L2 +W,H5,H3,L5,L2,L5 +L4,L5,L2,L5,L5,H4 +L5,L3,L5,L3,L1,L2 +L1,H5,L4,L5,L1,L5 +L3,H5,H2,L3,L4,L4 +H5,L1,L4,H3,H4,L5 +L2,L3,L5,H5,L4,L4 +L3,L3,H1,L2,L2,L5 +L5,L2,L4,L2,W,H4 +L4,L4,L3,L3,L1,L3 +L4,L2,L4,H4,L2,L5 +L2,L3,L5,L1,L5,L3 +H5,S,L2,L2,L2,L2 +S,L5,L5,L1,L5,L4 +W,L2,L2,H4,L2,L1 +H4,H3,L3,L1,H1,L5 +H3,H4,L5,H1,L5,H4 +H5,L5,L5,L5,L3,H4 +L1,L3,H2,L5,L1,L5 +L1,H3,L4,L3,H4,H4 +L5,H1,L4,L3,L5,L5 +H4,L4,H4,H2,L4,H4 +L4,H5,L1,L2,L3,L4 +L4,L3,L5,L3,L5,H5 +L5,L1,H5,L3,L2,H3 +L2,L5,H2,L5,L5,L4 +H1,L5,L4,H4,L3,H5 +H5,H1,H2,L2,L2,L5 +L3,H5,H4,L3,L4,L4 +L4,H4,L5,L2,L5,L5 +H5,L1,H5,L5,H5,H3 +L3,H3,L5,H5,L2,L3 +L5,L4,L1,L2,L4,L3 +L1,L1,H3,L1,L4,L4 +L5,L5,L3,L3,L3,H5 +H1,H5,L4,L1,L3,S +L3,H3,L3,H4,H2,L2 +W,L3,L2,H3,L1,L1 +L1,L4,L5,L1,L3,L5 +L5,H3,L3,L3,H1,L2 +H5,L4,L4,L3,H3,H5 +L3,L2,L4,L5,W,L4 +S,L2,L3,H5,L3,L4 +L2,H5,L5,L5,L2,H5 +L1,L4,L2,L5,L4,L3 +L4,L5,H4,L4,L2,L2 +L1,L1,L5,L3,L1,L5 +H1,L2,H2,L5,L2,H2 +L3,L1,H3,W,H5,L3 +L5,L1,L4,L5,H4,H2 +H2,L5,L2,H4,H3,L1 +H5,H2,H4,H3,L3,L3 +L4,L1,H3,L5,L5,L3 +L3,L4,H2,L3,H2,L4 +L2,L5,L2,L2,L4,L4 +H3,L2,L2,H2,H5,H1 +L2,L3,H2,H4,H4,L1 +H4,L1,L1,L2,L3,L1 +H4,H5,L2,H2,L3,L4 +L5,L4,H5,L3,L5,H4 +H4,H3,L5,H3,L1,L1 +H5,L5,H5,L1,L2,H2 +H5,L2,L2,L5,H5,H4 +L5,L2,L5,L5,L4,L2 +L1,L5,L2,L3,W,L4 +L3,L4,L4,L1,L3,L5 +L5,L3,L5,L5,L1,L5 +L2,L2,H5,L5,L2,L4 +L2,L2,L1,H4,L5,L5 +L3,H2,L3,L5,L3,L4 +L4,L1,H5,L4,L4,L3 +L4,L4,H2,L5,H5,L3 +L5,L4,L3,L4,L5,H4 +L5,L3,L4,L3,H4,H3 +H5,H4,L4,H5,L1,L1 +L2,L3,L2,L1,L2,L1 +L2,L4,L4,L4,L4,H3 +H4,S,L2,L5,L4,H4 +L4,L4,H5,W,H4,L3 +H2,L4,L3,L3,L4,L5 +L2,H2,H5,L4,H1,L5 +L2,H5,H3,H3,L3,L1 +H1,L3,L4,L5,H5,L5 +H4,L1,L5,H4,L5,L5 +H3,L3,L4,L3,L4,L3 +L2,H3,H4,L2,L3,L2 +H4,H5,S,L3,L4,H5 +H2,L5,L4,H5,L4,L4 +L4,L4,H1,L4,H5,L5 +H4,L3,L5,H1,L2,H5 +L2,L5,L5,L5,L5,L3 +L3,S,H5,L5,H2,L5 +L5,H5,L5,H3,L3,L3 +L2,L4,L4,L1,L2,H3 +H5,L1,L5,L4,H4,L2 +L5,L2,L1,W,L5,L4 +L2,H3,L5,L5,L3,H3 +L5,L5,L1,H3,L5,H1 +L5,H1,L3,L2,H5,L2 +L1,L2,L5,L3,H5,L3 +H4,L4,L5,H5,H3,L2 +H4,L5,L3,L3,H3,H5 +L5,H4,L3,L5,L3,L2 +L4,L4,L5,L5,W,L3 +L5,L3,L2,H5,H5,L3 +L5,H5,H4,H2,H4,H5 +S,L3,L3,L2,L2,H1 +H3,S,L5,L1,H5,W +H5,L3,L2,L3,L4,L5 +H3,L5,L5,H1,L5,L1 +L4,L3,L4,L5,L4,H5 +L4,L4,L4,H4,L5,L4 +H5,L3,H2,L2,L1,L2 +L2,H5,W,H5,L5,W +L1,L1,L3,L5,H5,L4 +L1,L5,L1,H5,L5,H4 +L5,L4,L5,L1,L5,L4 +H5,L1,L2,W,L3,H3 +L5,L3,L4,L3,H2,L5 +L1,L4,L3,S,L4,H2 +L3,H1,L4,L3,L2,L5 diff --git a/games/fight_club/reels/FR0.csv b/games/fight_club/reels/FR0.csv new file mode 100644 index 000000000..7def3b83b --- /dev/null +++ b/games/fight_club/reels/FR0.csv @@ -0,0 +1,200 @@ +L5,L5,H5,L4,H3,H4 +L4,H5,H5,L4,L2,W +L5,L5,L3,L5,H3,L4 +L3,L5,L1,H5,L4,H3 +H5,H1,L1,L3,L5,H4 +L4,H4,L3,H2,L2,L5 +H2,H1,L2,L3,L2,L2 +L3,L2,L4,H4,H3,H4 +L1,L2,L3,L4,H5,L3 +L5,H5,H4,L5,W,L4 +L4,L5,L2,L2,L3,L3 +H4,H4,L1,L5,L2,H2 +L5,L3,L2,L1,L3,L2 +H4,L2,H5,L1,L3,L5 +L2,L4,L3,L2,L2,L5 +L5,H5,H4,L2,L3,L2 +L4,H3,W,H5,H5,L4 +H4,L2,L1,H4,L2,L4 +L4,L5,H5,L2,L4,L5 +L3,L5,L1,L2,L3,L1 +L3,H3,L3,H3,L2,L4 +L2,H4,H2,L2,H3,H4 +L3,W,H2,L2,L1,L3 +L1,H1,H4,H3,L5,L2 +H4,L2,H3,H4,W,H3 +W,H5,L2,L4,H3,H4 +H5,L3,L1,L3,L4,L2 +W,L5,L5,L5,L4,H5 +L1,H1,H2,H4,L3,L4 +L4,L4,W,H5,L5,H5 +L4,L2,L3,H1,L4,L4 +H5,L5,L3,H5,H5,L3 +L2,L1,L5,L4,L3,L4 +H4,H4,L2,L5,L4,H4 +H5,L4,L4,L5,L1,H4 +L2,H3,L3,L1,L5,H1 +L1,W,L5,L3,L2,L3 +L1,W,H3,L3,L2,L5 +H5,H2,H3,H3,L3,L4 +H4,L4,H5,H3,H5,L2 +L1,L5,L5,H4,L5,H4 +W,H3,L5,L2,H3,H5 +L5,L4,H2,H2,L4,H4 +L5,L4,L5,L1,L5,L1 +L3,L2,L1,L5,H5,L5 +L3,H1,L3,L1,L4,L5 +H5,L4,H5,H2,L2,L4 +L5,H3,L3,L4,L2,H4 +H3,L4,L3,W,L1,H2 +L5,L2,L4,L1,L3,H4 +L5,H4,L4,L3,H4,L4 +L3,L3,H5,H2,L5,H4 +L2,H5,L4,H1,L4,L3 +H3,H3,H4,L3,H4,L4 +L4,L4,L3,H4,L1,H1 +W,L3,L2,L3,H3,L3 +H3,L1,L5,L3,L2,L4 +L3,L5,H5,L5,L4,L4 +L5,L5,L2,W,H2,H4 +L5,L1,L5,H5,H3,L2 +H2,H5,L2,W,L1,L1 +H3,H3,H5,L5,L2,L4 +L4,L5,H4,L2,L5,L1 +L3,H2,H3,L1,L1,H5 +H4,L3,L3,H4,L3,L2 +H4,L1,L4,L3,L3,L3 +W,H3,L5,L1,H1,L2 +L4,L1,L2,L5,L1,W +L5,H2,H5,L2,L1,H5 +H2,L3,L3,L1,H5,H3 +L5,L3,L3,H1,L5,L1 +L1,L4,L1,L3,L2,L5 +L3,L5,H5,L3,L1,L2 +L1,L2,W,L1,L4,L5 +W,H2,L5,L1,L2,L3 +H5,L1,W,L4,L3,L5 +H3,L3,L3,L5,L3,H4 +L5,L4,W,L3,L4,L4 +H4,H3,H2,H4,L5,L2 +L3,L3,H4,L4,L4,L2 +H4,L2,L4,H4,L3,W +W,H5,W,L1,L2,L1 +L1,L4,L5,H5,L4,L5 +H2,L5,L2,L5,W,H4 +L2,L4,L2,L3,L5,H5 +L5,L5,L4,L2,H4,L3 +L5,L4,H3,H1,L5,H1 +H5,L3,H3,L2,L3,L4 +L4,L1,L3,L4,L1,L3 +L4,W,W,L2,L4,L4 +L3,W,L5,L5,L3,L3 +L4,L4,L3,L2,L4,L5 +L4,L2,H1,L3,H3,L3 +L3,H4,H4,L3,L2,L5 +H3,L5,L5,H1,H5,H5 +L3,H4,H5,L2,H1,L3 +H3,L5,L2,L4,L2,L3 +L3,L1,L4,L2,L5,L1 +H4,L3,L4,H3,L3,L5 +L4,L3,L5,H5,H5,L3 +L3,H4,H3,L5,L1,L1 +L2,H4,L4,L3,L3,L4 +H5,L3,H3,H5,L2,L3 +L3,L3,L1,L4,L4,L3 +H4,L5,L4,W,L2,L5 +H5,L5,L3,L3,W,L2 +L4,H5,H3,H5,L3,L3 +L3,L1,L4,L3,L2,L4 +L4,L5,W,L4,L5,L3 +H5,L4,L5,L2,L5,L4 +H4,L5,L3,L2,L1,L4 +L3,H2,H5,H5,L1,H5 +L4,L4,H5,L5,L4,L1 +L3,L3,H4,H5,L1,L1 +H5,L2,L4,L5,H5,H2 +H4,H3,L4,L4,L5,L5 +L5,H4,L3,L5,L1,L1 +W,L4,L4,L4,H3,L5 +L4,L4,H5,L2,H2,L3 +H1,H5,L1,L2,H4,H3 +L4,H2,W,H4,H3,L4 +L3,H5,L4,L5,H1,W +L1,L5,W,L4,L4,H5 +L1,H2,H2,H4,H5,L3 +L3,H5,H3,H4,L2,L2 +L5,H3,H2,H3,H3,L2 +L1,L2,H5,W,H5,H5 +L2,L4,L3,L1,H1,L5 +H5,L4,L5,H5,L2,L4 +H5,L3,L5,L1,W,H3 +H4,H2,H5,L3,L5,L5 +L3,L5,H5,L5,L4,L5 +H4,L5,L3,H5,H3,L4 +W,L4,L3,L1,L5,W +H2,L4,L1,L5,L5,L5 +L3,L5,L4,H4,H1,H2 +H5,H4,L3,W,H1,L4 +L4,H5,L3,L5,L4,H4 +H5,L4,W,H3,H1,H4 +L1,L4,W,L5,H1,L2 +L1,L3,L5,H5,L2,L4 +L5,L3,L3,L5,L3,L1 +L5,L2,L5,L5,H1,H3 +H3,H5,H1,H3,W,L4 +L4,H2,L4,L5,L5,L3 +L4,L1,L3,L3,L1,L5 +L2,W,H4,H2,H4,H4 +L5,L4,L5,L4,H4,H1 +L3,H5,L3,L2,H5,H4 +L5,L2,L5,H1,L2,L2 +L2,L2,L5,L2,L1,L2 +L2,L5,L2,H3,L2,W +L3,L1,L2,H3,L4,L2 +L5,L3,H3,L5,L2,L5 +L1,L5,L5,L4,L4,L4 +L4,L1,L1,H3,H4,L4 +L5,H5,L4,L3,L4,L5 +L5,H5,H5,H3,L4,W +L3,H3,L2,L5,L5,H5 +L4,L5,H2,H2,L2,L4 +W,L1,H4,L3,L2,H2 +L5,L5,L1,L4,L3,L5 +L1,H4,H1,L1,H4,H4 +L1,L3,L5,H5,H2,L3 +H5,L4,L3,L5,H4,L5 +L2,H3,L5,L4,L4,H4 +H3,L3,H5,L4,L4,H5 +L5,L3,L4,L5,H1,H5 +L4,H5,L1,L4,L5,L4 +H4,L1,L4,L4,H3,L3 +H5,L4,W,H4,H1,L3 +L3,W,L5,H5,L1,L5 +H4,L2,H3,L4,H4,L1 +L1,H5,H5,H5,W,L5 +L2,L1,L1,L1,L5,L4 +L5,H5,L3,L4,L3,L5 +H5,L2,L4,H5,H4,H4 +L5,W,L4,L2,H2,L5 +H4,H2,L3,H1,L2,L5 +L2,L4,L1,H2,L5,L3 +L5,L4,L5,L3,L5,H3 +L4,L5,L3,L5,L2,L3 +H2,H5,L4,L5,H3,H3 +L5,H4,L2,L4,H4,H5 +H3,H3,L5,L2,L3,H2 +L4,L3,L1,L2,H2,L3 +W,L4,L2,L5,L1,H4 +L4,L5,L3,L4,L3,L1 +L2,L4,L3,H5,L4,L2 +L4,L4,L2,L2,L1,L1 +H4,H3,L3,L1,H5,H5 +L4,H5,L3,H4,L4,L3 +L2,L3,L1,H2,H4,L5 +H2,H3,L5,L5,L3,L5 +L3,L5,L3,H1,L1,L2 +H4,L5,L5,L2,L2,L4 +L3,L4,L4,L1,L4,L1 +H5,H5,L2,H3,L3,L1 +H5,L1,L3,H4,L4,L5 +H2,H5,H5,H2,H5,L4 diff --git a/games/fight_club/reels/WCAP.csv b/games/fight_club/reels/WCAP.csv new file mode 100644 index 000000000..2484c90a5 --- /dev/null +++ b/games/fight_club/reels/WCAP.csv @@ -0,0 +1,150 @@ +H1,L5,H1,L3,H2,H5 +H4,H2,L4,W,H5,H2 +L5,H2,W,H2,H5,H1 +L5,L4,L1,S,W,L4 +L3,W,W,H2,W,S +H1,H5,L4,W,H1,W +H4,L3,L5,H3,H4,W +H4,W,L4,L4,S,H3 +H5,W,H2,H2,L3,H1 +H2,L2,W,W,L2,L3 +S,H2,W,H1,H3,H1 +H2,H1,H3,L5,H3,L5 +W,H2,H5,H3,H5,H2 +H3,W,H2,H5,L1,L1 +L3,L2,L1,L1,L2,W +L2,H2,H2,H5,H1,H4 +H4,W,W,L1,L2,H2 +W,L4,H4,L4,W,H1 +H2,H4,H3,L2,W,H4 +H5,L5,W,L1,H3,H3 +W,H4,L2,L5,W,L1 +W,W,L4,H5,H4,H2 +L1,H5,H2,H4,W,W +L3,L2,L1,H2,W,H2 +L4,L1,L5,H4,L1,L1 +H2,L2,H5,H3,H3,L4 +L2,W,H1,S,W,W +L2,S,H5,H2,H1,W +W,H3,H2,H3,H2,H5 +H3,W,L5,H2,H1,H2 +L1,W,H2,H3,H4,W +H2,L2,W,L5,H4,L3 +H3,H1,H1,W,H1,H3 +L1,H1,L1,L1,W,L3 +H2,H2,H3,L4,W,L3 +H5,W,H2,H2,H1,W +L1,H1,H1,W,L2,H3 +H2,H2,H3,L2,H4,L5 +H3,H3,H1,H4,H3,H1 +L3,L5,H3,L5,L2,H5 +H3,W,W,W,H3,L5 +W,H1,L2,W,L2,W +L3,L5,W,H5,H1,H5 +L4,H4,W,H2,H1,H4 +H3,H3,H5,H2,H3,H5 +S,L1,H3,H3,H3,W +L3,H3,L3,L1,H2,H1 +L5,H5,H3,H1,W,H3 +H3,H2,H5,H4,L3,H2 +W,L3,H2,L2,H2,H2 +W,H5,H1,H3,L3,L2 +H3,H2,L2,H2,L4,W +W,L3,L5,H5,H2,L4 +H3,H3,H5,L2,L2,H4 +W,H1,W,L4,L1,W +L1,L4,H2,W,L2,H1 +H3,L1,H5,H1,H3,L3 +H3,H4,S,L1,H3,L3 +L1,L5,H2,H5,L5,L4 +L5,H2,W,H3,W,W +H1,H2,W,H1,L1,W +L2,H1,L3,L4,H3,S +H1,H4,L3,L5,W,H5 +W,W,H1,W,L2,W +H3,H2,W,W,H4,L1 +H4,H3,W,H5,H3,H2 +L3,H4,H3,H2,L3,L4 +W,W,L3,W,L1,H1 +W,L5,H3,H2,L1,W +H1,H1,H5,H3,H3,S +L4,H1,H2,H2,H3,W +W,L2,S,H1,H1,S +L4,H2,H2,H1,S,H3 +L5,L5,H4,H4,L4,H2 +W,H3,W,H3,W,L5 +H3,W,H1,H4,H1,H3 +H2,H2,H1,L4,H3,H3 +W,H4,L1,L3,L1,H4 +H1,L3,H5,H2,H4,H4 +H4,H2,H1,S,H5,H3 +L1,H2,W,H5,H2,H1 +H1,S,L3,H2,W,L5 +H5,L1,L3,H1,H3,H2 +H4,L3,H3,H2,L2,L5 +W,L1,H3,H4,H2,H4 +L1,H3,H1,L5,H5,H3 +H3,L3,W,H3,L2,H5 +W,H3,L4,H2,L1,L1 +W,H3,H4,L2,H3,H2 +H5,H2,L2,H2,H2,H2 +W,W,H5,H1,L2,L3 +W,W,L4,W,L5,W +H2,H1,L1,H1,H1,L4 +S,H3,H5,H3,H5,W +H2,L5,H2,S,H4,L2 +H5,L2,S,L5,H5,H5 +W,H4,L3,L4,H2,W +L3,H3,H2,W,L2,W +W,L4,W,H1,H5,H1 +W,L5,H1,L3,H2,W +L5,H4,H3,L5,H1,L4 +W,H2,H4,H3,H1,H2 +H4,H2,W,L2,L3,L4 +L5,H3,H1,H4,L5,H3 +H2,H2,L4,H3,L4,H3 +H5,L3,H2,H5,H3,W +L1,L2,W,H5,L4,S +H1,L5,L1,L5,W,H2 +L5,L1,H1,H3,L3,H2 +W,H3,L2,H1,L2,W +L2,H5,H2,H5,W,H2 +H2,H5,W,L1,L3,L1 +L5,H2,W,W,H2,W +H2,H5,H1,L4,W,L2 +H5,W,L3,W,S,L4 +H4,L1,H4,W,H4,H5 +L2,H1,L1,H1,H3,L3 +L5,L1,W,H2,L5,W +H3,H4,L1,H5,H2,S +H3,W,H5,L3,H1,W +H4,W,W,H3,L1,H1 +H4,H4,L3,L4,H1,H3 +H1,L4,W,H4,L1,H5 +W,L1,H4,W,H5,L4 +L2,H1,W,H5,H2,H3 +W,H5,H5,W,H2,L4 +H5,W,H3,H1,L2,H1 +W,L3,H1,L3,H3,L4 +H3,H2,L4,L2,L3,L2 +L2,H5,H1,W,H3,H5 +L1,W,H4,W,H2,H1 +L1,H2,W,L1,W,W +S,W,L2,L2,H2,H5 +H5,H1,H1,W,L2,L3 +W,L3,L2,W,W,H1 +H2,L1,H1,L2,H1,L2 +H3,H3,W,L3,L5,L1 +L4,L3,L1,H5,H3,L1 +H1,W,H2,H3,L5,H5 +H5,L4,H4,H1,H3,W +H3,L3,W,L1,H1,H2 +H3,W,H5,H4,H2,H4 +H5,H3,L4,H1,L2,H3 +H3,W,W,H5,H4,L1 +L2,H2,H1,H1,H4,L5 +H5,H1,H3,W,L4,L2 +L4,H2,H5,W,W,H5 +W,H1,H2,L2,W,L4 +H3,H5,H1,S,H5,H2 +S,H4,H4,H4,L1,H4 diff --git a/games/fight_club/run.py b/games/fight_club/run.py new file mode 100644 index 000000000..b45867d46 --- /dev/null +++ b/games/fight_club/run.py @@ -0,0 +1,62 @@ +"""Fight Club — Main simulation entry point.""" + +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 = 10 + rust_threads = 20 + batching_size = 50000 + compression = True + profiling = False + + num_sim_args = { + "base": int(1e5), + "exhibition": int(1e5), + "title_fight": int(1e5), + "death_match": int(1e5), + } + + run_conditions = { + "run_sims": True, + "run_optimization": True, + "run_analysis": True, + "run_format_checks": True, + } + target_modes = ["base", "exhibition", "title_fight", "death_match"] + + 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) diff --git a/games/fruits/__init__.py b/games/fruits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/games/fruits/frontend/assets/symbols_atlas.json b/games/fruits/frontend/assets/symbols_atlas.json new file mode 100644 index 000000000..5f63b071b --- /dev/null +++ b/games/fruits/frontend/assets/symbols_atlas.json @@ -0,0 +1,273 @@ +{ + "frames": { + "10": { + "frame": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "A": { + "frame": { + "x": 1024, + "y": 0, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "J": { + "frame": { + "x": 2048, + "y": 0, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "K": { + "frame": { + "x": 3072, + "y": 0, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "Q": { + "frame": { + "x": 0, + "y": 1024, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "cherry": { + "frame": { + "x": 1024, + "y": 1024, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "grape": { + "frame": { + "x": 2048, + "y": 1024, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "lemon": { + "frame": { + "x": 3072, + "y": 1024, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "orange": { + "frame": { + "x": 0, + "y": 2048, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "plum": { + "frame": { + "x": 1024, + "y": 2048, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "scatter": { + "frame": { + "x": 2048, + "y": 2048, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "watermelon": { + "frame": { + "x": 3072, + "y": 2048, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + }, + "wild": { + "frame": { + "x": 0, + "y": 3072, + "w": 1024, + "h": 1024 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 1024, + "h": 1024 + }, + "sourceSize": { + "w": 1024, + "h": 1024 + } + } + }, + "meta": { + "image": "symbols_atlas.png", + "format": "RGBA8888", + "size": { + "w": 4096, + "h": 4096 + }, + "scale": "1" + } +} \ No newline at end of file diff --git a/games/fruits/frontend/assets/symbols_atlas.png b/games/fruits/frontend/assets/symbols_atlas.png new file mode 100644 index 000000000..cd4f32b3e Binary files /dev/null and b/games/fruits/frontend/assets/symbols_atlas.png differ diff --git a/games/fruits/frontend/index.html b/games/fruits/frontend/index.html new file mode 100644 index 000000000..d438d81cc --- /dev/null +++ b/games/fruits/frontend/index.html @@ -0,0 +1,7920 @@ + + + + + + +
.
+
.
+SUGAR STACK + + + + + + + + + diff --git a/games/fruits/game_calculations.py b/games/fruits/game_calculations.py new file mode 100644 index 000000000..0060d4983 --- /dev/null +++ b/games/fruits/game_calculations.py @@ -0,0 +1,7 @@ +"""SUGAR STACK — Thin pass-through for game calculations.""" + +from src.executables.executables import Executables + + +class GameCalculations(Executables): + pass diff --git a/games/fruits/game_config.py b/games/fruits/game_config.py new file mode 100644 index 000000000..e37899d2e --- /dev/null +++ b/games/fruits/game_config.py @@ -0,0 +1,245 @@ +"""SUGAR STACK — Game Configuration.""" + +import os +from src.config.config import Config +from src.config.distributions import Distribution +from src.config.betmode import BetMode + + +class GameConfig(Config): + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + super().__init__() + self.game_id = "1_2_sugar_stack" + self.provider_number = 1 + self.working_name = "Sugar Stack" + self.wincap = 10000.0 + self.win_type = "lines" + self.rtp = 0.9600 + try: + self.construct_paths(self.game_id) + except TypeError: + self.construct_paths() + + # 5×5 grid + self.num_reels = 5 + self.num_rows = [5] * self.num_reels + + # ── Paytable ────────────────────────────────────────────── + # Wild pays same as top symbol (watermelon) + self.paytable = { + # Wild + (5, "W"): 15, (4, "W"): 6, (3, "W"): 2, + # Premium symbols + (5, "watermelon"): 15, (4, "watermelon"): 6, (3, "watermelon"): 2, + (5, "grape"): 10, (4, "grape"): 4, (3, "grape"): 1.5, + (5, "orange"): 8, (4, "orange"): 3, (3, "orange"): 1, + (5, "cherry"): 5, (4, "cherry"): 2, (3, "cherry"): 0.8, + (5, "plum"): 4, (4, "plum"): 1.5, (3, "plum"): 0.5, + (5, "lemon"): 3, (4, "lemon"): 1, (3, "lemon"): 0.3, + # Low-pay symbols + (5, "A"): 2, (4, "A"): 0.6, (3, "A"): 0.2, + (5, "K"): 1.5, (4, "K"): 0.4, (3, "K"): 0.15, + (5, "Q"): 1, (4, "Q"): 0.3, (3, "Q"): 0.1, + (5, "J"): 0.8, (4, "J"): 0.2, (3, "J"): 0.1, + (5, "10"): 0.8, (4, "10"): 0.2, (3, "10"): 0.1, + } + + # ── 20 Paylines on 5×5 grid ─────────────────────────────── + self.paylines = { + 1: [2, 2, 2, 2, 2], + 2: [0, 0, 0, 0, 0], + 3: [4, 4, 4, 4, 4], + 4: [0, 1, 2, 1, 0], + 5: [4, 3, 2, 3, 4], + 6: [0, 0, 1, 2, 2], + 7: [4, 4, 3, 2, 2], + 8: [1, 0, 1, 0, 1], + 9: [3, 4, 3, 4, 3], + 10: [1, 1, 0, 1, 1], + 11: [3, 3, 4, 3, 3], + 12: [2, 1, 0, 1, 2], + 13: [2, 3, 4, 3, 2], + 14: [0, 1, 1, 1, 0], + 15: [4, 3, 3, 3, 4], + 16: [1, 2, 3, 2, 1], + 17: [3, 2, 1, 2, 3], + 18: [0, 2, 4, 2, 0], + 19: [4, 2, 0, 2, 4], + 20: [2, 0, 2, 0, 2], + } + + self.include_padding = True + + # W is wild, SC is scatter (on reels 0, 2, 4 only) + self.special_symbols = { + "wild": ["W"], + "scatter": ["SC"], + } + + # Scatter trigger: 3 SC → 10 free spins (base game only, no retrigger) + self.freespin_triggers = { + self.basegame_type: {3: 10}, + } + self.anticipation_triggers = { + self.basegame_type: 2, + self.freegame_type: 2, + } + + # ── Reels ───────────────────────────────────────────────── + reel_files = {"BR0": "BR0.csv", "FR0": "FR0.csv"} + self.reels = {} + for name, filename in reel_files.items(): + self.reels[name] = self.read_reels_csv(os.path.join(self.reels_path, filename)) + + self.padding_reels = { + self.basegame_type: self.reels["BR0"], + self.freegame_type: self.reels["FR0"], + } + + # ── Expanding Wild Multiplier Pool ──────────────────────── + # Base game: higher big-mult weights for visual bait (most don't connect) + wild_mult_base = {2: 160, 3: 70, 5: 20, 10: 10, 20: 6, 50: 4, 100: 3} + + # Free spins: escalating pools (early → mid → late) + wild_mult_bonus = {2: 200, 3: 80, 5: 30, 10: 10, 20: 5, 50: 2, 100: 1} + wild_mult_fs_early = {2: 220, 3: 90, 5: 20, 10: 5, 20: 2, 50: 1} + wild_mult_fs_mid = {2: 100, 3: 80, 5: 40, 10: 15, 20: 5, 50: 2} + wild_mult_fs_late = {3: 50, 5: 40, 10: 20, 20: 10, 50: 4, 100: 2} + + # ── Shared condition templates ──────────────────────────── + def _cond(force_fg, force_wincap, reel_base, reel_free=None): + c = { + "reel_weights": {self.basegame_type: {reel_base: 1}}, + "wild_mult_values": {self.basegame_type: wild_mult_base}, + "force_freegame": force_fg, + "force_wincap": force_wincap, + } + if reel_free: + c["reel_weights"][self.freegame_type] = {reel_free: 1} + c["wild_mult_values"][self.freegame_type] = wild_mult_bonus + c["wild_mult_escalation"] = { + "early": wild_mult_fs_early, + "mid": wild_mult_fs_mid, + "late": wild_mult_fs_late, + } + return c + + freegame_cond = _cond( + force_fg=True, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + freegame_cond["scatter_triggers"] = {3: 1} + + basegame_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + zerowin_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond = _cond( + force_fg=True, force_wincap=True, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond["scatter_triggers"] = {3: 1} + wincap_cond["wild_mult_values"][self.freegame_type] = { + 2: 150, 3: 60, 5: 30, 10: 15, 20: 8, 50: 3, 100: 2 + } + wincap_cond["wild_mult_escalation"] = { + "early": {2: 180, 3: 70, 5: 25, 10: 8, 20: 4, 50: 2}, + "mid": {2: 80, 3: 60, 5: 35, 10: 18, 20: 8, 50: 3, 100: 1}, + "late": {3: 40, 5: 30, 10: 25, 20: 15, 50: 5, 100: 3}, + } + + # ── Bonus mode conditions ────────────────────────────────── + bonus_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + } + super_bonus_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + "pre_placed_wilds": 1, + } + + # ── Bet Modes ───────────────────────────────────────────── + maxwins = { + "base": 10000, "double_chance": 10000, + "bonus": 10000, "super_bonus": 10000, + } + + self.bet_modes = [ + BetMode( + name="base", + cost=1.0, + rtp=self.rtp, + max_win=maxwins["base"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=False, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["base"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.10, conditions=freegame_cond), + Distribution(criteria="0", quota=0.40, win_criteria=0.0, conditions=zerowin_cond), + Distribution(criteria="basegame", quota=0.489, conditions=basegame_cond), + ], + ), + BetMode( + name="double_chance", + cost=1.5, + rtp=self.rtp, + max_win=maxwins["double_chance"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=False, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["double_chance"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.20, conditions=freegame_cond), + Distribution(criteria="0", quota=0.35, win_criteria=0.0, conditions=zerowin_cond), + Distribution(criteria="basegame", quota=0.449, conditions=basegame_cond), + ], + ), + BetMode( + name="bonus", + cost=150.0, + rtp=self.rtp, + max_win=maxwins["bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=bonus_cond), + ], + ), + BetMode( + name="super_bonus", + cost=300.0, + rtp=self.rtp, + max_win=maxwins["super_bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["super_bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=super_bonus_cond), + ], + ), + ] diff --git a/games/fruits/game_events.py b/games/fruits/game_events.py new file mode 100644 index 000000000..19a03a040 --- /dev/null +++ b/games/fruits/game_events.py @@ -0,0 +1,55 @@ +"""SUGAR STACK — Custom game events.""" + +from copy import deepcopy +from src.events.event_constants import EventConstants +from src.events.events import json_ready_sym + +EXPANDING_WILD = "expandingWild" +UPDATE_STICKY_WILDS = "updateStickyWilds" +SCATTER_TRIGGER = "scatterTrigger" + + +def expanding_wild_event(gamestate, reel_index: int, multiplier: int) -> None: + """Emitted when a wild expands to fill an entire reel column.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = [ + {"reel": reel_index, "row": row + offset} + for row in range(gamestate.config.num_rows[reel_index]) + ] + event = { + "index": len(gamestate.book.events), + "type": EXPANDING_WILD, + "reel": reel_index, + "multiplier": multiplier, + "positions": positions, + } + gamestate.book.add_event(event) + + +def update_sticky_wilds_event(gamestate) -> None: + """Emitted at the start of each free spin to show existing sticky wild reels.""" + existing = deepcopy(gamestate.sticky_wild_reels) + event = { + "index": len(gamestate.book.events), + "type": UPDATE_STICKY_WILDS, + "stickyWildReels": existing, + } + gamestate.book.add_event(event) + + +def scatter_trigger_event(gamestate, scatter_positions: list) -> None: + """Emitted when 3 scatters trigger free spins.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = deepcopy(scatter_positions) + if gamestate.config.include_padding: + for p in positions: + p["row"] += 1 + + event = { + "index": len(gamestate.book.events), + "type": SCATTER_TRIGGER, + "totalFs": gamestate.tot_fs, + "scatterPositions": positions, + } + assert gamestate.tot_fs > 0, "tot_fs must be >0 when emitting scatterTrigger" + gamestate.book.add_event(event) diff --git a/games/fruits/game_executables.py b/games/fruits/game_executables.py new file mode 100644 index 000000000..aa7f06120 --- /dev/null +++ b/games/fruits/game_executables.py @@ -0,0 +1,145 @@ +"""SUGAR STACK — Core game mechanics: Expanding Wilds with Multipliers.""" + +import random +from game_calculations import GameCalculations +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, + scatter_trigger_event, +) +from src.calculations.statistics import get_random_outcome +from src.events.events import update_freespin_event + + +class GameExecutables(GameCalculations): + + def _get_wild_mult_pool(self) -> dict: + """Get the correct multiplier pool based on game phase. + + During free spins with escalation configured: + Spins 1-4 → early pool (mostly small mults) + Spins 5-7 → mid pool (balanced) + Spins 8-10 → late pool (big mults dominate) + Falls back to the standard pool if no escalation is set. + """ + conditions = self.get_current_distribution_conditions() + escalation = conditions.get("wild_mult_escalation") + + if escalation and hasattr(self, "fs") and hasattr(self, "tot_fs") and self.tot_fs > 0: + progress = self.fs / self.tot_fs + if progress <= 0.4: + return escalation["early"] + elif progress <= 0.7: + return escalation["mid"] + else: + return escalation["late"] + + return conditions["wild_mult_values"][self.gametype] + + def find_wild_reels(self) -> list: + """Find all reels that contain at least one W symbol.""" + wild_reels = [] + for reel in range(self.config.num_reels): + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "W": + wild_reels.append(reel) + break + return wild_reels + + def expand_wild_reel(self, reel_index: int) -> None: + """Expand a wild to fill the entire reel column.""" + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + self.board[reel_index][row] = sym + + def assign_wild_reel_multiplier(self, reel_index: int) -> int: + """Assign a random multiplier from the escalation-aware pool.""" + pool = self._get_wild_mult_pool() + mult = get_random_outcome(pool) + + for row in range(self.config.num_rows[reel_index]): + sym = self.board[reel_index][row] + if sym.name == "W": + sym.assign_attribute({"multiplier": mult}) + + return mult + + def apply_expanding_wilds(self) -> list: + """ + Find all wilds on the board, expand them to fill their reel, + assign a multiplier per reel, and emit events. + Skips reels already restored by restore_sticky_wilds() to avoid + re-rolling their multiplier (which would mismatch the updateStickyWilds event). + """ + wild_reels = self.find_wild_reels() + expanded = [] + existing_sticky = {sw["reel"] for sw in self.sticky_wild_reels} if hasattr(self, 'sticky_wild_reels') else set() + + for reel_index in wild_reels: + if reel_index in existing_sticky: + continue + self.expand_wild_reel(reel_index) + mult = self.assign_wild_reel_multiplier(reel_index) + expanded.append({"reel": reel_index, "mult": mult}) + expanding_wild_event(self, reel_index, mult) + + return expanded + + def find_scatter_positions(self) -> list: + """Find all SC scatter positions on the board (reels 0, 2, 4 only).""" + positions = [] + for reel in [0, 2, 4]: + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "SC": + positions.append({"reel": reel, "row": row}) + return positions + + def check_scatter_trigger(self) -> tuple: + """Check if 3 or more scatters are on the board.""" + positions = self.find_scatter_positions() + triggered = len(positions) >= 3 + return triggered, positions + + def trigger_freespins_from_scatter(self, scatter_positions: list) -> None: + """3 scatters → 10 free spins.""" + self.record({ + "kind": "scatter", + "symbol": "SC", + "gametype": self.gametype, + }) + self.tot_fs = 10 + scatter_trigger_event(self, scatter_positions) + self.run_freespin() + + def restore_sticky_wilds(self) -> None: + """Restore sticky wild reels with RE-ROLLED multipliers each spin.""" + pool = self._get_wild_mult_pool() + + for sw in self.sticky_wild_reels: + reel_index = sw["reel"] + # Re-roll multiplier from current phase pool + new_mult = get_random_outcome(pool) + sw["mult"] = new_mult + + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + sym.assign_attribute({"multiplier": new_mult}) + self.board[reel_index][row] = sym + + def add_sticky_wild_reels(self, expanded_wilds: list) -> None: + """Add newly expanded wild reels to the sticky list.""" + existing_reels = {sw["reel"] for sw in self.sticky_wild_reels} + for ew in expanded_wilds: + if ew["reel"] not in existing_reels: + self.sticky_wild_reels.append(ew) + existing_reels.add(ew["reel"]) + + def pre_place_expanding_wild(self) -> None: + """Pre-place one expanding wild on a random reel for super_bonus.""" + available = list(range(self.config.num_reels)) + chosen_reel = random.choice(available) + self.expand_wild_reel(chosen_reel) + mult = self.assign_wild_reel_multiplier(chosen_reel) + entry = {"reel": chosen_reel, "mult": mult} + self.sticky_wild_reels.append(entry) + expanding_wild_event(self, chosen_reel, mult) diff --git a/games/fruits/game_optimization.py b/games/fruits/game_optimization.py new file mode 100644 index 000000000..c0735586a --- /dev/null +++ b/games/fruits/game_optimization.py @@ -0,0 +1,126 @@ +"""SUGAR STACK — Optimization parameters.""" + +from optimization_program.optimization_config import ( + ConstructScaling, + ConstructParameters, + ConstructConditions, + verify_optimization_input, +) + + +class OptimizationSetup: + """Optimization parameters for each SUGAR STACK bet mode.""" + + def __init__(self, game_config): + self.game_config = game_config + wincaps = {bm.get_name(): bm.get_wincap() for bm in game_config.bet_modes} + + self.game_config.opt_params = { + "base": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, 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.40, hr=50, search_conditions={"kind": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.55).return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "basegame", "scale_factor": 1.2, "win_range": (1, 5), "probability": 1.0}, + {"criteria": "basegame", "scale_factor": 1.5, "win_range": (10, 30), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 1000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (2000, 5000), "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(), + }, + "double_chance": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["double_chance"], search_conditions=wincaps["double_chance"] + ).return_dict(), + "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), + "freegame": ConstructConditions( + rtp=0.50, hr=25, search_conditions={"kind": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.45).return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "basegame", "scale_factor": 1.2, "win_range": (1, 5), "probability": 1.0}, + {"criteria": "basegame", "scale_factor": 1.5, "win_range": (10, 30), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 1000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (2000, 5000), "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(), + }, + "bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.95, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.9, "win_range": (10, 50), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 2000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (3000, 7000), "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(), + }, + "super_bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["super_bonus"], search_conditions=wincaps["super_bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.95, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (100, 500), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.3, "win_range": (5000, 10000), "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) diff --git a/games/fruits/game_override.py b/games/fruits/game_override.py new file mode 100644 index 000000000..b1200d2ef --- /dev/null +++ b/games/fruits/game_override.py @@ -0,0 +1,46 @@ +"""SUGAR STACK — State overrides (book reset, special symbol functions, repeat check).""" + +from game_executables import GameExecutables +from src.calculations.statistics import get_random_outcome + + +class GameStateOverride(GameExecutables): + + def reset_book(self) -> None: + """Reset game-specific state alongside the base book reset.""" + super().reset_book() + self.sticky_wild_reels = [] + + def assign_special_sym_function(self) -> None: + """ + Register per-symbol attribute functions. + W gets its multiplier assigned AFTER the board is drawn + (via apply_expanding_wilds), so this function intentionally + does nothing — it just satisfies the abstract interface requirement. + """ + self.special_symbol_functions = { + "W": [], + "SC": [], + } + + def check_repeat(self) -> None: + """ + Extend the base repeat check: + - Honour force_freegame (scatter trigger required). + - Honour win_criteria for wincap simulations. + - Ensure non-zero-win criteria actually produce wins. + """ + if self.repeat is False: + conditions = self.get_current_distribution_conditions() + 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 + return + + if conditions.get("force_freegame") and not self.triggered_freegame: + self.repeat = True + return + + if self.criteria not in ("0",) and win_criteria is None and self.win_manager.running_bet_win == 0.0: + self.repeat = True diff --git a/games/fruits/gamestate.py b/games/fruits/gamestate.py new file mode 100644 index 000000000..c20355fac --- /dev/null +++ b/games/fruits/gamestate.py @@ -0,0 +1,117 @@ +"""SUGAR STACK — GameState: orchestrates base game and free spins with expanding wilds.""" + +from copy import deepcopy +from game_override import GameStateOverride +from src.calculations.lines import Lines +from src.calculations.statistics import get_random_outcome +from src.events.events import reveal_event +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, +) + + +class GameState(GameStateOverride): + """ + Base game flow: + 1. Draw board from reel strips. + 2. Find wilds → expand to fill reel column → assign random multiplier. + 3. Evaluate paylines (wild multipliers multiply together on multi-wild lines). + 4. If 3 SC scatters on reels 0/2/4 → trigger 10 free spins. + + Free spins: + 1. Restore existing sticky expanded wilds (multipliers re-rolled each spin). + 2. Draw new board, find new wilds → expand + assign multiplier → become sticky. + 3. Evaluate paylines. + 4. Dead spin protection: if sticky wilds present and zero win, re-draw once. + Repeat for 10 spins (retrigger possible with 3 more scatters). + """ + + def run_spin(self, sim, simulation_seed=None) -> None: + self.reset_seed(sim) + self.repeat = True + + while self.repeat: + self.reset_book() + self.draw_board(emit_event=True) + + self.apply_expanding_wilds() + + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.trigger_freespins_from_scatter(scatter_positions) + + self.evaluate_finalwin() + self.check_repeat() + + self.imprint_wins() + + def run_freespin(self) -> None: + """10-spin free spins with sticky expanding wilds, escalating multipliers, + and dead spin protection.""" + self.reset_fs_spin() + self.sticky_wild_reels = [] + + conditions = self.get_current_distribution_conditions() + pre_placed = conditions.get("pre_placed_wilds", 0) + if pre_placed > 0: + self.draw_board(emit_event=False) + for _ in range(pre_placed): + self.pre_place_expanding_wild() + + while self.fs < self.tot_fs and not self.wincap_triggered: + self.update_freespin() + + # Dead spin protection: if sticky wilds exist, allow 1 re-draw on zero win + max_attempts = 2 if self.sticky_wild_reels else 1 + sticky_snapshot = deepcopy(self.sticky_wild_reels) + + for attempt in range(max_attempts): + event_mark = len(self.book.events) + + self.draw_board(emit_event=False) + + self.restore_sticky_wilds() + if self.sticky_wild_reels: + update_sticky_wilds_event(self) + + expanded = self.apply_expanding_wilds() + + if expanded: + self.add_sticky_wild_reels(expanded) + + reveal_event(self) + + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.tot_fs += 10 + + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + + # If zero win with sticky wilds and we have a retry left, re-draw + if (self.win_data["totalWin"] == 0 + and attempt < max_attempts - 1 + and not triggered): + # Rollback: truncate events and restore sticky state + self.book.events = self.book.events[:event_mark] + self.sticky_wild_reels = deepcopy(sticky_snapshot) + continue + + break + + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + self.end_freespin() diff --git a/games/fruits/reels/BR0.csv b/games/fruits/reels/BR0.csv new file mode 100644 index 000000000..8ea7cd9af --- /dev/null +++ b/games/fruits/reels/BR0.csv @@ -0,0 +1,101 @@ +10,J,Q,cherry,lemon +J,A,plum,A,10 +A,orange,Q,K,10 +orange,J,Q,J,J +J,Q,K,plum,K +10,lemon,cherry,Q,lemon +K,watermelon,10,J,Q +Q,K,lemon,10,A +lemon,10,A,lemon,cherry +A,cherry,J,A,10 +cherry,Q,10,cherry,J +10,J,grape,K,plum +J,plum,Q,10,K +K,A,K,J,Q +Q,10,plum,Q,A +plum,K,A,A,10 +A,lemon,J,plum,lemon +10,J,10,K,J +lemon,Q,cherry,10,K +K,cherry,K,lemon,Q +J,A,lemon,J,A +Q,10,Q,Q,cherry +10,grape,A,cherry,10 +A,K,J,A,J +cherry,lemon,10,10,Q +K,J,K,K,plum +lemon,Q,plum,J,K +J,plum,Q,plum,A +Q,A,A,Q,10 +A,10,cherry,A,lemon +10,K,J,lemon,J +K,cherry,10,10,K +SC,J,SC,K,Q +J,Q,K,J,A +plum,A,lemon,Q,10 +Q,10,Q,A,cherry +A,lemon,A,10,J +10,K,J,cherry,K +K,J,10,K,SC +lemon,Q,K,J,A +J,A,Q,plum,10 +Q,plum,A,Q,lemon +A,10,lemon,A,J +cherry,K,J,10,K +10,cherry,10,lemon,Q +K,J,K,K,A +SC,Q,SC,J,10 +J,A,K,Q,cherry +Q,lemon,Q,A,J +A,10,A,cherry,K +10,K,lemon,10,Q +K,J,J,K,A +lemon,Q,10,J,10 +J,plum,K,plum,lemon +grape,A,Q,Q,J +Q,10,A,A,K +A,K,lemon,10,Q +10,cherry,J,lemon,A +K,J,10,K,W +W,Q,K,J,cherry +J,A,Q,Q,10 +Q,lemon,A,A,J +A,10,cherry,10,K +10,K,J,cherry,Q +K,J,10,K,A +lemon,Q,K,J,10 +J,A,plum,Q,lemon +Q,10,Q,A,J +A,cherry,A,10,K +cherry,K,lemon,lemon,Q +10,J,J,K,A +K,Q,10,J,10 +SC,A,SC,Q,lemon +J,10,K,plum,J +Q,lemon,Q,A,K +A,K,A,10,W +10,J,lemon,cherry,A +K,Q,J,K,10 +lemon,A,10,J,cherry +J,10,K,Q,J +Q,cherry,Q,A,K +A,K,A,lemon,Q +10,J,cherry,10,A +K,plum,J,K,10 +cherry,Q,10,J,lemon +J,A,K,Q,J +Q,lemon,plum,A,K +A,10,Q,10,Q +10,K,A,cherry,A +K,J,lemon,K,10 +lemon,Q,J,J,cherry +SC,A,SC,Q,J +J,10,10,A,K +Q,cherry,K,lemon,Q +A,K,Q,10,A +10,lemon,A,K,10 +K,J,lemon,J,grape +plum,Q,J,Q,J +Q,A,10,A,K +A,10,K,cherry,Q +cherry,K,Q,K,A diff --git a/games/fruits/reels/FR0.csv b/games/fruits/reels/FR0.csv new file mode 100644 index 000000000..a5e76ee07 --- /dev/null +++ b/games/fruits/reels/FR0.csv @@ -0,0 +1,70 @@ +10,J,Q,cherry,lemon +J,W,plum,A,10 +A,orange,Q,K,cherry +orange,J,Q,J,J +J,Q,K,plum,K +10,lemon,cherry,Q,lemon +K,watermelon,10,W,Q +Q,K,lemon,10,A +lemon,10,A,lemon,cherry +W,cherry,J,A,10 +cherry,Q,10,cherry,J +10,J,grape,K,plum +J,plum,Q,10,K +K,A,K,J,Q +Q,10,plum,Q,A +plum,K,A,A,W +A,lemon,W,plum,lemon +10,J,10,K,J +lemon,Q,cherry,10,K +K,cherry,K,lemon,Q +J,W,lemon,J,A +Q,10,Q,Q,cherry +10,grape,A,cherry,10 +A,K,J,W,J +cherry,lemon,10,10,Q +K,J,K,K,plum +lemon,Q,plum,J,K +W,plum,Q,plum,A +Q,A,A,Q,10 +A,10,cherry,A,lemon +10,K,J,lemon,W +K,cherry,10,10,K +K,J,lemon,K,Q +J,Q,K,J,A +plum,A,lemon,Q,10 +Q,W,Q,A,cherry +A,lemon,A,10,J +10,K,J,cherry,K +K,J,10,K,plum +lemon,Q,K,J,A +J,A,Q,plum,10 +Q,plum,W,Q,lemon +A,10,lemon,A,J +cherry,K,J,10,K +10,cherry,10,lemon,Q +K,J,K,K,A +J,Q,cherry,J,10 +J,A,K,Q,cherry +Q,lemon,Q,W,J +W,10,A,cherry,K +10,K,lemon,10,Q +K,J,J,K,A +lemon,Q,10,J,10 +J,plum,K,plum,lemon +grape,A,Q,Q,W +Q,10,A,A,K +A,K,lemon,10,Q +10,watermelon,J,lemon,A +K,J,10,K,W +W,Q,K,J,cherry +J,A,Q,Q,10 +Q,lemon,A,A,J +A,10,cherry,W,K +10,K,W,cherry,Q +K,J,10,K,A +lemon,Q,K,J,10 +J,W,plum,Q,lemon +Q,10,Q,A,J +A,cherry,A,10,K +cherry,K,lemon,lemon,Q diff --git a/games/fruits/run.py b/games/fruits/run.py new file mode 100644 index 000000000..48d965b7e --- /dev/null +++ b/games/fruits/run.py @@ -0,0 +1,76 @@ +"""SUGAR STACK — Main simulation entry point.""" + +import os +import sys + +_game_dir = os.path.dirname(os.path.abspath(__file__)) +_sdk_root = os.path.dirname(os.path.dirname(_game_dir)) +for _p in [ + os.path.join(_sdk_root, "env", "src", "stakeengine"), + _game_dir, +]: + if _p not in sys.path: + sys.path.insert(0, _p) + +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 = 4 + rust_threads = 16 + batching_size = 5000 + compression = True + profiling = False + + num_sim_args = { + "base": 100000, + "double_chance": 50000, + "bonus": 20000, + "super_bonus": 15000, + } + + run_conditions = { + "run_sims": True, + "run_optimization": True, + "run_analysis": False, + "run_format_checks": False, + } + + target_modes = list(num_sim_args.keys()) + + config = GameConfig() + gamestate = GameState(config) + + if run_conditions["run_optimization"]: + 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 = [{"kind": "scatter"}] + create_stat_sheet(gamestate, custom_keys=custom_keys) + + if run_conditions["run_format_checks"]: + execute_all_tests(config) diff --git a/games/shogun/__init__.py b/games/shogun/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/games/shogun/__init__.py @@ -0,0 +1 @@ + diff --git a/games/shogun/fix_and_optimize.sh b/games/shogun/fix_and_optimize.sh new file mode 100755 index 000000000..77ffad6e0 --- /dev/null +++ b/games/shogun/fix_and_optimize.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -e + +SDK="/workspaces/math-sdk" +ENV_LIB="$SDK/env/src/stakeengine/games/1_1_shogun/library" +DEST_LIB="$SDK/games/1_1_shogun/library" + +echo "=== Step 1: Copy sim output to correct location ===" +mkdir -p "$DEST_LIB" +cp -r "$ENV_LIB"/* "$DEST_LIB"/ +echo "Copied library files OK" + +echo "=== Step 1b: Generate correct math_config.json ===" +MCFG="$DEST_LIB/configs/math_config.json" +python3 << 'PYEOF' +import json, os + +mcfg_path = os.environ.get("MCFG_PATH", "/workspaces/math-sdk/games/1_1_shogun/library/configs/math_config.json") +os.makedirs(os.path.dirname(mcfg_path), exist_ok=True) + +# Sims ran with wincap=15000 (old config), so math_config must match actual data +# Wincap fence: win_range=(15000,15000), empty search, avg_win="15000" +# Zero fence: win_range=(0,0), avg_win="0" +# Freegame/basegame: win_range=(-1,-1) means no win filter +# hr="x" means auto-calculate from remaining probability +# Field types: avg_win/hr/rtp are STRINGS (Option), win_range are f64 + +config = { + "game_id": "1_1_shogun", + "bet_modes": [ + {"bet_mode": "base", "cost": 1.0, "rtp": 0.97, "max_win": 15000.0}, + {"bet_mode": "buy_bonus", "cost": 150.0, "rtp": 0.97, "max_win": 15000.0}, + {"bet_mode": "super_buy_bonus", "cost": 300.0, "rtp": 0.97, "max_win": 15000.0}, + ], + "fences": [ + { + "bet_mode": "base", + "fences": [ + {"name": "wincap", "rtp": "0.01", "avg_win": "15000", + "identity_condition": {"search": [], "win_range_start": 15000.0, "win_range_end": 15000.0, "opposite": False}}, + {"name": "0", "rtp": "0", "avg_win": "0", + "identity_condition": {"search": [], "win_range_start": 0.0, "win_range_end": 0.0, "opposite": False}}, + {"name": "freegame", "rtp": "0.40", "hr": "50", + "identity_condition": {"search": [{"name": "kind", "value": "scatter"}], "win_range_start": -1.0, "win_range_end": -1.0, "opposite": False}}, + {"name": "basegame", "rtp": "0.56", "hr": "3.5", + "identity_condition": {"search": [], "win_range_start": -1.0, "win_range_end": -1.0, "opposite": False}}, + ] + }, + { + "bet_mode": "buy_bonus", + "fences": [ + {"name": "wincap", "rtp": "0.01", "avg_win": "15000", + "identity_condition": {"search": [], "win_range_start": 15000.0, "win_range_end": 15000.0, "opposite": False}}, + {"name": "freegame", "rtp": "0.96", "hr": "x", + "identity_condition": {"search": [], "win_range_start": -1.0, "win_range_end": -1.0, "opposite": False}}, + ] + }, + { + "bet_mode": "super_buy_bonus", + "fences": [ + {"name": "wincap", "rtp": "0.01", "avg_win": "15000", + "identity_condition": {"search": [], "win_range_start": 15000.0, "win_range_end": 15000.0, "opposite": False}}, + {"name": "freegame", "rtp": "0.96", "hr": "x", + "identity_condition": {"search": [], "win_range_start": -1.0, "win_range_end": -1.0, "opposite": False}}, + ] + }, + ], + "dresses": [ + { + "bet_mode": "base", + "dresses": [ + {"fence": "basegame", "scale_factor": "1.2", "identity_condition_win_range": [1.0, 5.0], "prob": 1.0}, + {"fence": "basegame", "scale_factor": "1.5", "identity_condition_win_range": [10.0, 30.0], "prob": 1.0}, + {"fence": "freegame", "scale_factor": "0.8", "identity_condition_win_range": [500.0, 1000.0], "prob": 1.0}, + {"fence": "freegame", "scale_factor": "1.2", "identity_condition_win_range": [2000.0, 5000.0], "prob": 1.0}, + ] + }, + { + "bet_mode": "buy_bonus", + "dresses": [ + {"fence": "freegame", "scale_factor": "0.9", "identity_condition_win_range": [10.0, 50.0], "prob": 1.0}, + {"fence": "freegame", "scale_factor": "0.8", "identity_condition_win_range": [500.0, 2000.0], "prob": 1.0}, + {"fence": "freegame", "scale_factor": "1.2", "identity_condition_win_range": [3000.0, 7000.0], "prob": 1.0}, + ] + }, + { + "bet_mode": "super_buy_bonus", + "dresses": [ + {"fence": "freegame", "scale_factor": "0.8", "identity_condition_win_range": [100.0, 500.0], "prob": 1.0}, + {"fence": "freegame", "scale_factor": "1.3", "identity_condition_win_range": [5000.0, 10000.0], "prob": 1.0}, + ] + }, + ], + "bias": [ + {"bet_mode": "base", "bias": []}, + {"bet_mode": "buy_bonus", "bias": []}, + {"bet_mode": "super_buy_bonus", "bias": []}, + ], +} + +with open(mcfg_path, "w") as f: + json.dump(config, f, indent=2) +print("Generated math_config.json OK") +PYEOF + +echo "=== Step 2: Source cargo ===" +. /usr/local/cargo/env + +echo "=== Step 3: Run optimizer for all modes ===" +cd "$SDK/games/shogun" +python3 run_optimizer.py + +echo "=== DONE ===" diff --git a/games/shogun/frontend/index.html b/games/shogun/frontend/index.html new file mode 100644 index 000000000..2097eef9e --- /dev/null +++ b/games/shogun/frontend/index.html @@ -0,0 +1,5799 @@ + + + + + +SHOGUN + + + + + + + + + diff --git a/games/shogun/game_calculations.py b/games/shogun/game_calculations.py new file mode 100644 index 000000000..dded3ad30 --- /dev/null +++ b/games/shogun/game_calculations.py @@ -0,0 +1,7 @@ +"""SHOGUN — Thin pass-through for game calculations.""" + +from src.executables.executables import Executables + + +class GameCalculations(Executables): + pass diff --git a/games/shogun/game_config.py b/games/shogun/game_config.py new file mode 100644 index 000000000..d6fd7752b --- /dev/null +++ b/games/shogun/game_config.py @@ -0,0 +1,220 @@ +"""SHOGUN — Game Configuration.""" + +import os +from src.config.config import Config +from src.config.distributions import Distribution +from src.config.betmode import BetMode + + +class GameConfig(Config): + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + super().__init__() + self.game_id = "1_1_shogun" + self.provider_number = 1 + self.working_name = "Shogun" + self.wincap = 15000.0 + self.win_type = "lines" + self.rtp = 0.9700 + try: + self.construct_paths(self.game_id) + except TypeError: + self.construct_paths() + + # 5×5 grid + self.num_reels = 5 + self.num_rows = [5] * self.num_reels + + # ── Paytable ────────────────────────────────────────────── + # Wild pays same as top symbol (dragon) + self.paytable = { + # Wild + (5, "W"): 20, (4, "W"): 8, (3, "W"): 3, + # Premium symbols + (5, "dragon"): 20, (4, "dragon"): 8, (3, "dragon"): 3, + (5, "samurai"): 12, (4, "samurai"): 5, (3, "samurai"): 1.5, + (5, "geisha"): 8, (4, "geisha"): 2.5, (3, "geisha"): 0.8, + (5, "oni"): 5, (4, "oni"): 1.5, (3, "oni"): 0.5, + # Low-pay symbols + (5, "A"): 3, (4, "A"): 0.8, (3, "A"): 0.3, + (5, "K"): 2, (4, "K"): 0.5, (3, "K"): 0.2, + (5, "Q"): 1.5, (4, "Q"): 0.3, (3, "Q"): 0.1, + (5, "J"): 1, (4, "J"): 0.2, (3, "J"): 0.1, + (5, "10"): 1, (4, "10"): 0.2, (3, "10"): 0.1, + } + + # ── 20 Paylines on 5×5 grid ─────────────────────────────── + self.paylines = { + 1: [2, 2, 2, 2, 2], + 2: [0, 0, 0, 0, 0], + 3: [4, 4, 4, 4, 4], + 4: [1, 1, 1, 1, 1], + 5: [3, 3, 3, 3, 3], + 6: [0, 1, 2, 1, 0], + 7: [4, 3, 2, 3, 4], + 8: [2, 1, 0, 1, 2], + 9: [2, 3, 4, 3, 2], + 10: [0, 1, 2, 3, 4], + 11: [4, 3, 2, 1, 0], + 12: [0, 0, 1, 2, 2], + 13: [4, 4, 3, 2, 2], + 14: [2, 2, 1, 0, 0], + 15: [2, 2, 3, 4, 4], + 16: [1, 2, 3, 2, 1], + 17: [3, 2, 1, 2, 3], + 18: [0, 1, 1, 1, 0], + 19: [4, 3, 3, 3, 4], + 20: [1, 0, 1, 0, 1], + } + + self.include_padding = True + + # W is wild, SC is scatter (on reels 0, 2, 4 only) + self.special_symbols = { + "wild": ["W"], + "multiplier": ["W"], + "scatter": ["SC"], + } + + # Scatter trigger: 3 SC → 10 free spins (base and free game) + self.freespin_triggers = { + self.basegame_type: {3: 10}, + self.freegame_type: {3: 10}, + } + self.anticipation_triggers = { + self.basegame_type: 2, + self.freegame_type: 2, + } + + # ── Reels ───────────────────────────────────────────────── + reel_files = {"BR0": "BR0.csv", "FR0": "FR0.csv"} + self.reels = {} + for name, filename in reel_files.items(): + self.reels[name] = self.read_reels_csv(os.path.join(self.reels_path, filename)) + + self.padding_reels = { + self.basegame_type: self.reels["BR0"], + self.freegame_type: self.reels["FR0"], + } + + # ── Expanding Wild Multiplier Pool ──────────────────────── + # When a wild expands, it gets a random multiplier from this pool. + # Base game: Zeus/Hades style — multipliers on every wild (lower avg) + wild_mult_base = {2: 200, 3: 80, 5: 20, 10: 5, 20: 2, 50: 1, 100: 1} + + # Free game multiplier pool — higher avg, 100× possible + wild_mult_bonus = {2: 200, 3: 80, 5: 30, 10: 10, 20: 5, 50: 2, 100: 1} + + # ── Shared condition templates ──────────────────────────── + def _cond(force_fg, force_wincap, reel_base, reel_free=None): + c = { + "reel_weights": {self.basegame_type: {reel_base: 1}}, + "wild_mult_values": {self.basegame_type: wild_mult_base}, + "force_freegame": force_fg, + "force_wincap": force_wincap, + } + if reel_free: + c["reel_weights"][self.freegame_type] = {reel_free: 1} + c["wild_mult_values"][self.freegame_type] = wild_mult_bonus + return c + + freegame_cond = _cond( + force_fg=True, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + freegame_cond["scatter_triggers"] = {3: 1} + + basegame_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + zerowin_cond = _cond( + force_fg=False, force_wincap=False, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond = _cond( + force_fg=True, force_wincap=True, + reel_base="BR0", reel_free="FR0", + ) + wincap_cond["scatter_triggers"] = {3: 1} + # Override wincap multiplier pools with heavy high-end values + wincap_cond["wild_mult_values"][self.freegame_type] = { + 2: 150, 3: 60, 5: 30, 10: 15, 20: 8, 50: 3, 100: 2 + } + + # ── Bonus buy conditions ─────────────────────────────────── + buy_bonus_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + } + super_buy_cond = { + **freegame_cond, + "reel_weights": { + self.basegame_type: {"BR0": 1}, + self.freegame_type: {"FR0": 1}, + }, + "scatter_triggers": {3: 1}, + "pre_placed_wilds": 1, # 1 pre-placed expanding wild with multiplier + } + + # ── Bet Modes ───────────────────────────────────────────── + maxwins = { + "base": 15000, "buy_bonus": 15000, "super_buy_bonus": 15000, + } + + self.bet_modes = [ + BetMode( + name="base", + cost=1.0, + rtp=self.rtp, + max_win=maxwins["base"], + auto_close_disabled=False, + is_feature=True, + is_buybonus=False, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["base"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.10, conditions=freegame_cond), + Distribution(criteria="0", quota=0.40, win_criteria=0.0, conditions=zerowin_cond), + Distribution(criteria="basegame", quota=0.499, conditions=basegame_cond), + ], + ), + # ── Buy Bonus — 150x bet, guaranteed 10 free spins ── + BetMode( + name="buy_bonus", + cost=150.0, + rtp=self.rtp, + max_win=maxwins["buy_bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["buy_bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=buy_bonus_cond), + ], + ), + # ── Super Buy Bonus — 300x bet, guaranteed 10 FS + 1 pre-placed expanding wild ── + BetMode( + name="super_buy_bonus", + cost=300.0, + rtp=self.rtp, + max_win=maxwins["super_buy_bonus"], + auto_close_disabled=False, + is_feature=False, + is_buybonus=True, + distributions=[ + Distribution(criteria="wincap", quota=0.001, win_criteria=maxwins["super_buy_bonus"], conditions=wincap_cond), + Distribution(criteria="freegame", quota=0.999, conditions=super_buy_cond), + ], + ), + ] diff --git a/games/shogun/game_events.py b/games/shogun/game_events.py new file mode 100644 index 000000000..851dcdd78 --- /dev/null +++ b/games/shogun/game_events.py @@ -0,0 +1,56 @@ +"""SHOGUN — Custom game events.""" + +from copy import deepcopy +from src.events.event_constants import EventConstants +from src.events.events import json_ready_sym + +# Custom event type strings +EXPANDING_WILD = "expandingWild" +UPDATE_STICKY_WILDS = "updateStickyWilds" +SCATTER_TRIGGER = "scatterTrigger" + + +def expanding_wild_event(gamestate, reel_index: int, multiplier: int) -> None: + """Emitted when a wild expands to fill an entire reel column.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = [ + {"reel": reel_index, "row": row + offset} + for row in range(gamestate.config.num_rows[reel_index]) + ] + event = { + "index": len(gamestate.book.events), + "type": EXPANDING_WILD, + "reel": reel_index, + "multiplier": multiplier, + "positions": positions, + } + gamestate.book.add_event(event) + + +def update_sticky_wilds_event(gamestate) -> None: + """Emitted at the start of each free spin to show existing sticky wild reels.""" + existing = deepcopy(gamestate.sticky_wild_reels) + event = { + "index": len(gamestate.book.events), + "type": UPDATE_STICKY_WILDS, + "stickyWildReels": existing, + } + gamestate.book.add_event(event) + + +def scatter_trigger_event(gamestate, scatter_positions: list) -> None: + """Emitted when 3 scatters trigger free spins.""" + offset = 1 if gamestate.config.include_padding else 0 + positions = deepcopy(scatter_positions) + if gamestate.config.include_padding: + for p in positions: + p["row"] += 1 + + event = { + "index": len(gamestate.book.events), + "type": SCATTER_TRIGGER, + "totalFs": gamestate.tot_fs, + "scatterPositions": positions, + } + assert gamestate.tot_fs > 0, "tot_fs must be >0 when emitting scatterTrigger" + gamestate.book.add_event(event) diff --git a/games/shogun/game_executables.py b/games/shogun/game_executables.py new file mode 100644 index 000000000..97948066b --- /dev/null +++ b/games/shogun/game_executables.py @@ -0,0 +1,140 @@ +"""SHOGUN — Core game mechanics: Expanding Wilds with Multipliers.""" + +import random +from game_calculations import GameCalculations +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, + scatter_trigger_event, +) +from src.calculations.statistics import get_random_outcome +from src.events.events import update_freespin_event + + +class GameExecutables(GameCalculations): + + # ── Expanding Wild Logic ───────────────────────────────────────────────── + + def find_wild_reels(self) -> list: + """Find all reels that contain at least one W symbol.""" + wild_reels = [] + for reel in range(self.config.num_reels): + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "W": + wild_reels.append(reel) + break + return wild_reels + + def expand_wild_reel(self, reel_index: int) -> None: + """ + Expand a wild to fill the entire reel column. + All positions in the reel become W symbols. + """ + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + self.board[reel_index][row] = sym + + def assign_wild_reel_multiplier(self, reel_index: int) -> int: + """ + Assign a random multiplier from the pool to an expanded wild reel. + The multiplier is applied to all W symbols on that reel. + """ + conditions = self.get_current_distribution_conditions() + mult = get_random_outcome(conditions["wild_mult_values"][self.gametype]) + + for row in range(self.config.num_rows[reel_index]): + sym = self.board[reel_index][row] + if sym.name == "W": + sym.assign_attribute({"multiplier": mult}) + + return mult + + def apply_expanding_wilds(self) -> list: + """ + Find all wilds on the board, expand them to fill their reel, + assign a multiplier per reel, and emit events. + Returns list of dicts: [{"reel": int, "mult": int}, ...] + """ + wild_reels = self.find_wild_reels() + expanded = [] + + for reel_index in wild_reels: + self.expand_wild_reel(reel_index) + mult = self.assign_wild_reel_multiplier(reel_index) + expanded.append({"reel": reel_index, "mult": mult}) + expanding_wild_event(self, reel_index, mult) + + return expanded + + # ── Scatter Detection ──────────────────────────────────────────────────── + + def find_scatter_positions(self) -> list: + """Find all SC scatter positions on the board (reels 0, 2, 4 only).""" + positions = [] + for reel in [0, 2, 4]: + for row in range(self.config.num_rows[reel]): + if self.board[reel][row].name == "SC": + positions.append({"reel": reel, "row": row}) + return positions + + def check_scatter_trigger(self) -> tuple: + """ + Check if 3 or more scatters are on the board. + Returns (triggered: bool, scatter_positions: list). + """ + positions = self.find_scatter_positions() + triggered = len(positions) >= 3 + return triggered, positions + + def trigger_freespins_from_scatter(self, scatter_positions: list) -> None: + """ + Trigger free spins from scatter symbols. + 3 scatters → 10 free spins (both base and free game retrigger). + """ + self.record({ + "kind": "scatter", + "symbol": "SC", + "gametype": self.gametype, + }) + self.tot_fs = 10 + scatter_trigger_event(self, scatter_positions) + self.run_freespin() + + # ── Sticky Wild Management (Free Spins) ────────────────────────────────── + + def restore_sticky_wilds(self) -> None: + """ + Restore all sticky wild reels from previous free spins onto the board. + Their multipliers are preserved. + """ + for sw in self.sticky_wild_reels: + reel_index = sw["reel"] + mult = sw["mult"] + for row in range(self.config.num_rows[reel_index]): + sym = self.create_symbol("W") + sym.assign_attribute({"multiplier": mult}) + self.board[reel_index][row] = sym + + def add_sticky_wild_reels(self, expanded_wilds: list) -> None: + """ + Add newly expanded wild reels to the sticky list. + Only adds reels not already in sticky_wild_reels. + """ + existing_reels = {sw["reel"] for sw in self.sticky_wild_reels} + for ew in expanded_wilds: + if ew["reel"] not in existing_reels: + self.sticky_wild_reels.append(ew) + existing_reels.add(ew["reel"]) + + def pre_place_expanding_wild(self) -> None: + """ + Pre-place one expanding wild on a random reel for super_buy_bonus. + Expands the reel, assigns multiplier, and adds to sticky list. + """ + available = list(range(self.config.num_reels)) + chosen_reel = random.choice(available) + self.expand_wild_reel(chosen_reel) + mult = self.assign_wild_reel_multiplier(chosen_reel) + entry = {"reel": chosen_reel, "mult": mult} + self.sticky_wild_reels.append(entry) + expanding_wild_event(self, chosen_reel, mult) diff --git a/games/shogun/game_optimization.py b/games/shogun/game_optimization.py new file mode 100644 index 000000000..f47ca3588 --- /dev/null +++ b/games/shogun/game_optimization.py @@ -0,0 +1,97 @@ +"""SHOGUN — Optimization parameters.""" + +from optimization_program.optimization_config import ( + ConstructScaling, + ConstructParameters, + ConstructConditions, + verify_optimization_input, +) + + +class OptimizationSetup: + """Optimization parameters for each SHOGUN bet mode.""" + + def __init__(self, game_config): + self.game_config = game_config + wincaps = {bm.get_name(): bm.get_wincap() for bm in game_config.bet_modes} + + self.game_config.opt_params = { + "base": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, 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.40, hr=50, search_conditions={"kind": "scatter"} + ).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.56).return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "basegame", "scale_factor": 1.2, "win_range": (1, 5), "probability": 1.0}, + {"criteria": "basegame", "scale_factor": 1.5, "win_range": (10, 30), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 1000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (2000, 5000), "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(), + }, + "buy_bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["buy_bonus"], search_conditions=wincaps["buy_bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.9, "win_range": (10, 50), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (500, 2000), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.2, "win_range": (3000, 7000), "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(), + }, + "super_buy_bonus": { + "conditions": { + "wincap": ConstructConditions( + rtp=0.01, av_win=wincaps["super_buy_bonus"], search_conditions=wincaps["super_buy_bonus"] + ).return_dict(), + "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + }, + "scaling": ConstructScaling([ + {"criteria": "freegame", "scale_factor": 0.8, "win_range": (100, 500), "probability": 1.0}, + {"criteria": "freegame", "scale_factor": 1.3, "win_range": (5000, 10000), "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) diff --git a/games/shogun/game_override.py b/games/shogun/game_override.py new file mode 100644 index 000000000..ea2fdc70f --- /dev/null +++ b/games/shogun/game_override.py @@ -0,0 +1,50 @@ +"""SHOGUN — State overrides (book reset, special symbol functions, repeat check).""" + +from game_executables import GameExecutables +from src.calculations.statistics import get_random_outcome + + +class GameStateOverride(GameExecutables): + + def reset_book(self) -> None: + """Reset game-specific state alongside the base book reset.""" + super().reset_book() + # Free-spin sticky wild tracking + self.sticky_wild_reels = [] # [{"reel": int, "mult": int}, ...] + + def assign_special_sym_function(self) -> None: + """ + Register per-symbol attribute functions. + W gets its multiplier assigned AFTER the board is drawn + (via apply_expanding_wilds), so this function intentionally + does nothing — it just satisfies the abstract interface requirement. + """ + self.special_symbol_functions = { + "W": [], + "SC": [], + } + + def check_repeat(self) -> None: + """ + Extend the base repeat check: + - Honour force_freegame (scatter trigger required). + - Honour win_criteria for wincap simulations. + - Ensure non-zero-win criteria actually produce wins. + """ + if self.repeat is False: + conditions = self.get_current_distribution_conditions() + win_criteria = self.get_current_betmode_distributions().get_win_criteria() + + # Must reach exact win_criteria for wincap distributions + if win_criteria is not None and self.final_win != win_criteria: + self.repeat = True + return + + # Free spins must trigger when force_freegame=True + if conditions.get("force_freegame") and not self.triggered_freegame: + self.repeat = True + return + + # Non-zero criteria must produce a positive win + if self.criteria not in ("0",) and win_criteria is None and self.win_manager.running_bet_win == 0.0: + self.repeat = True diff --git a/games/shogun/gamestate.py b/games/shogun/gamestate.py new file mode 100644 index 000000000..2df62a928 --- /dev/null +++ b/games/shogun/gamestate.py @@ -0,0 +1,109 @@ +"""SHOGUN — GameState: orchestrates base game and free spins with expanding wilds.""" + +from game_override import GameStateOverride +from src.calculations.lines import Lines +from src.calculations.statistics import get_random_outcome +from src.events.events import reveal_event +from game_events import ( + expanding_wild_event, + update_sticky_wilds_event, +) + + +class GameState(GameStateOverride): + """ + Base game flow: + 1. Draw board from reel strips. + 2. Find wilds → expand to fill reel column → assign random multiplier. + 3. Evaluate paylines (wild multipliers multiply together on multi-wild lines). + 4. If 3 SC scatters on reels 0/2/4 → trigger 10 free spins. + + Free spins: + 1. Restore existing sticky expanded wilds (multipliers persist). + 2. Draw new board, find new wilds → expand + assign multiplier → become sticky. + 3. Evaluate paylines. + Repeat for 10 spins (retrigger possible with 3 more scatters). + """ + + # ── Base game ───────────────────────────────────────────────────────────── + + def run_spin(self, sim, simulation_seed=None) -> None: + self.reset_seed(sim) + self.repeat = True + + while self.repeat: + self.reset_book() + self.draw_board(emit_event=True) + + # Find and expand wilds, assign multipliers + self.apply_expanding_wilds() + + # Evaluate paylines + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + # Scatter trigger: 3 SC on reels 0, 2, 4 + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.trigger_freespins_from_scatter(scatter_positions) + + self.evaluate_finalwin() + self.check_repeat() + + self.imprint_wins() + + # ── Free spins with sticky expanding wilds ──────────────────────────────── + + def run_freespin(self) -> None: + """10-spin free spins with sticky expanding wilds and multipliers.""" + self.reset_fs_spin() + self.sticky_wild_reels = [] + + # Super buy bonus: pre-place 1 expanding wild before free spins start + conditions = self.get_current_distribution_conditions() + pre_placed = conditions.get("pre_placed_wilds", 0) + if pre_placed > 0: + # Draw initial board for pre-placement context + self.draw_board(emit_event=False) + for _ in range(pre_placed): + self.pre_place_expanding_wild() + + while self.fs < self.tot_fs and not self.wincap_triggered: + self.update_freespin() + self.draw_board(emit_event=False) + + # 1. Restore sticky wilds from previous spins + self.restore_sticky_wilds() + if self.sticky_wild_reels: + update_sticky_wilds_event(self) + + # 2. Find new wilds on non-sticky reels, expand + assign multipliers + expanded = self.apply_expanding_wilds() + + # 3. New expanded wilds become sticky + if expanded: + self.add_sticky_wild_reels(expanded) + + # Emit board reveal + reveal_event(self) + + # 4. Check for scatter retrigger (3 SC → +10 free spins) + triggered, scatter_positions = self.check_scatter_trigger() + if triggered: + self.tot_fs += 10 + + # 5. Evaluate paylines + self.win_data = Lines.get_lines( + self.board, self.config, global_multiplier=self.global_multiplier + ) + Lines.record_lines_wins(self) + self.win_manager.update_spinwin(self.win_data["totalWin"]) + Lines.emit_linewin_events(self) + self.win_manager.update_gametype_wins(self.gametype) + + self.end_freespin() diff --git a/games/shogun/generate_reels.py b/games/shogun/generate_reels.py new file mode 100644 index 000000000..0c7d4b2a6 --- /dev/null +++ b/games/shogun/generate_reels.py @@ -0,0 +1,150 @@ +"""Generate reel strip CSV files for SHOGUN.""" + +import os +import random + + +# Symbols: dragon, samurai, geisha, oni, A, K, Q, J, 10, W (wild), SC (scatter) +# SC only on reels 0, 2, 4 + +def make_paying_symbols( + dragon=3, samurai=4, geisha=5, oni=6, + A=8, K=8, Q=9, J=8, ten=8, +): + """Create the list of paying symbols for a single reel strip.""" + syms = [] + syms += ["dragon"] * dragon + syms += ["samurai"] * samurai + syms += ["geisha"] * geisha + syms += ["oni"] * oni + syms += ["A"] * A + syms += ["K"] * K + syms += ["Q"] * Q + syms += ["J"] * J + syms += ["10"] * ten + return syms + + +def interleave_symbols(symbols, seed=42): + """Shuffle symbols but prevent runs of 3+ identical symbols.""" + random.seed(seed) + shuffled = symbols[:] + random.shuffle(shuffled) + + # Reduce long runs: swap offending symbols with later positions + for attempt in range(5): + changed = False + for i in range(2, len(shuffled)): + if shuffled[i] == shuffled[i - 1] == shuffled[i - 2]: + for j in range(i + 1, len(shuffled)): + if shuffled[j] != shuffled[i]: + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + changed = True + break + if not changed: + break + return shuffled + + +def insert_symbol(symbols, sym_name, count=1, seed=42): + """Insert symbol(s) at pseudo-random positions.""" + random.seed(seed + 99) + for _ in range(count): + pos = random.randint(0, len(symbols)) + symbols.insert(pos, sym_name) + return symbols + + +def generate_base_reels(path, length=628, seed_base=100): + """ + Base game reels (BR0) — matches reference 0_0_expwilds (~628 rows): + - 4 W (wild) per reel (~0.64% wild frequency) + - 2 SC (scatter) per scatter reel (reels 0, 2, 4) + """ + reels = [] + scatter_reels = {0, 2, 4} + wild_count = 4 + + for i in range(5): + syms = make_paying_symbols() # 59 symbols + scatter_count = 2 if i in scatter_reels else 0 + specials = wild_count + scatter_count + needed = length - len(syms) - specials + # Pad with mixed low-pays to fill the strip + extras = (["A", "K", "Q", "J", "10"] * (needed // 5 + 2))[:needed] + syms += extras + + syms = interleave_symbols(syms, seed=seed_base + i * 17) + + # Insert wilds + syms = insert_symbol(syms, "W", count=wild_count, seed=seed_base + i * 17) + + # Insert scatters on reels 0, 2, 4 + if i in scatter_reels: + syms = insert_symbol(syms, "SC", count=scatter_count, seed=seed_base + i * 17 + 50) + + # Trim or pad to exact length + syms = syms[:length] + while len(syms) < length: + syms.append("Q") + + assert len(syms) == length, f"Reel {i} length {len(syms)} != {length}" + reels.append(syms) + + _write_csv(path, "BR0.csv", reels, length) + + +def generate_freegame_reels(path, length=620, seed_base=500): + """ + Free game reels (FR0) — matches reference 0_0_expwilds (~620 rows): + - No SC (scatters) + - 3 W (wild) per reel (~0.48% wild freq, ~10% chance per reel per spin) + - Wilds that land expand to fill the whole reel and become sticky + """ + reels = [] + wild_count = 3 # 3 wilds per reel on 620 symbols + for i in range(5): + syms = make_paying_symbols( + dragon=3, samurai=4, geisha=5, oni=6, + A=8, K=8, Q=9, J=8, ten=8, + ) # 59 symbols + + needed = length - len(syms) - wild_count + extras = (["A", "K", "Q", "J", "10"] * (needed // 5 + 2))[:needed] + syms += extras + + syms = interleave_symbols(syms, seed=seed_base + i * 23) + + # Insert 3 wilds per reel + syms = insert_symbol(syms, "W", count=wild_count, seed=seed_base + i * 23) + + syms = syms[:length] + while len(syms) < length: + syms.append("J") + + assert len(syms) == length, f"FR reel {i} length {len(syms)} != {length}" + reels.append(syms) + + _write_csv(path, "FR0.csv", reels, length) + + +def _write_csv(path, filename, reels, length): + num_reels = len(reels) + filepath = os.path.join(path, filename) + with open(filepath, "w") as f: + for row in range(length): + line = ",".join(reels[col][row] for col in range(num_reels)) + f.write(line + "\n") + print(f" Generated {filepath} ({length} rows x {num_reels} reels)") + + +def generate_all(reels_dir): + os.makedirs(reels_dir, exist_ok=True) + generate_base_reels(reels_dir) + generate_freegame_reels(reels_dir) + print("Reel generation complete.") + + +if __name__ == "__main__": + script_dir = os.path.dirname(os.path.abspath(__file__)) + generate_all(os.path.join(script_dir, "reels")) diff --git a/games/shogun/patch_and_run.sh b/games/shogun/patch_and_run.sh new file mode 100644 index 000000000..704ff2341 --- /dev/null +++ b/games/shogun/patch_and_run.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -e +cd /workspaces/math-sdk + +echo "=== Patching events.py to remove double deepcopy ===" +cat > /tmp/patch_events.py << 'PYEOF' +import os + +path = "env/src/stakeengine/src/events/events.py" +with open(path) as f: + lines = f.readlines() + +# Find and replace the win_info_event function body +output = [] +i = 0 +while i < len(lines): + # Detect start of the old code block + if "win_data_copy = {}" in lines[i]: + # Skip all old lines until "gamestate.book.add_event(event)" + while i < len(lines) and "gamestate.book.add_event(event)" not in lines[i]: + i += 1 + i += 1 # skip the add_event line too + + # Insert new optimized code + output.append(" wins = []\n") + output.append(" for w in gamestate.win_data[\"wins\"]:\n") + output.append(" if include_padding_index:\n") + output.append(" new_positions = [{\"reel\": p[\"reel\"], \"row\": p[\"row\"] + 1} for p in w[\"positions\"]]\n") + output.append(" else:\n") + output.append(" new_positions = [{\"reel\": p[\"reel\"], \"row\": p[\"row\"]} for p in w[\"positions\"]]\n") + output.append(" win_copy = {\n") + output.append(" \"symbol\": w[\"symbol\"],\n") + output.append(" \"kind\": w[\"kind\"],\n") + output.append(" \"win\": int(round(min(w[\"win\"], gamestate.config.wincap) * 100, 0)),\n") + output.append(" \"positions\": new_positions,\n") + output.append(" }\n") + output.append(" if \"meta\" in w:\n") + output.append(" meta = dict(w[\"meta\"])\n") + output.append(" meta[\"winWithoutMult\"] = int(min(w[\"meta\"][\"winWithoutMult\"] * 100, gamestate.config.wincap * 100))\n") + output.append(" if \"overlay\" in meta and include_padding_index:\n") + output.append(" meta[\"overlay\"] = dict(meta[\"overlay\"])\n") + output.append(" meta[\"overlay\"][\"row\"] += 1\n") + output.append(" win_copy[\"meta\"] = meta\n") + output.append(" wins.append(win_copy)\n") + output.append("\n") + output.append(" event = {\n") + output.append(" \"index\": len(gamestate.book.events),\n") + output.append(" \"type\": EventConstants.WIN_DATA.value,\n") + output.append(" \"totalWin\": int(round(min(gamestate.win_data[\"totalWin\"], gamestate.config.wincap) * 100, 0)),\n") + output.append(" \"wins\": wins,\n") + output.append(" }\n") + output.append(" gamestate.book.add_event(event)\n") + else: + output.append(lines[i]) + i += 1 + +with open(path, "w") as f: + f.writelines(output) +print("PATCHED events.py OK") +PYEOF + +python3 /tmp/patch_events.py + +echo "=== Pulling latest code ===" +git pull + +echo "=== Running sims (1000 per mode, wincap=15000) ===" +cd games/shogun +python3 run.py diff --git a/games/shogun/run.py b/games/shogun/run.py new file mode 100644 index 000000000..d155ec7b0 --- /dev/null +++ b/games/shogun/run.py @@ -0,0 +1,85 @@ +"""SHOGUN — Main simulation entry point.""" + +import os +import sys + +# Add SDK root paths so imports work whether run locally or from GitHub Actions +_game_dir = os.path.dirname(os.path.abspath(__file__)) +_sdk_root = os.path.dirname(os.path.dirname(_game_dir)) # .../stake-engine-sdk +for _p in [ + os.path.join(_sdk_root, "env", "src", "stakeengine"), + _game_dir, +]: + if _p not in sys.path: + sys.path.insert(0, _p) + +# Ensure reels exist before config loads them +_game_dir = os.path.dirname(os.path.abspath(__file__)) +_reels_dir = os.path.join(os.path.dirname(_game_dir), "1_1_shogun", "reels") +if not os.path.exists(os.path.join(_reels_dir, "BR0.csv")): + print("Reel strips not found — generating...") + sys.path.insert(0, _game_dir) + from generate_reels import generate_all + generate_all(_reels_dir) + +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 = 4 + rust_threads = 16 + batching_size = 5000 + compression = True # Production output + profiling = False + + num_sim_args = { + "base": 100000, + "buy_bonus": 100000, + "super_buy_bonus": 100000, + } + + run_conditions = { + "run_sims": True, + "run_optimization": True, + "run_analysis": False, + "run_format_checks": False, # SDK uses hardcoded 'Games/' path (capital G), incompatible with CI + } + + target_modes = list(num_sim_args.keys()) + + config = GameConfig() + gamestate = GameState(config) + + if run_conditions["run_optimization"]: + 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 = [{"kind": "scatter"}] + create_stat_sheet(gamestate, custom_keys=custom_keys) + + if run_conditions["run_format_checks"]: + execute_all_tests(config) diff --git a/games/shogun/run_optimizer.py b/games/shogun/run_optimizer.py new file mode 100644 index 000000000..f496db5a2 --- /dev/null +++ b/games/shogun/run_optimizer.py @@ -0,0 +1,78 @@ +"""Run the Rust optimizer for all SHOGUN bet modes.""" + +import os +import subprocess + +SDK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OPT_DIR = os.path.join(SDK_ROOT, "optimization_program") +SETUP_TOML = os.path.join(OPT_DIR, "src", "setup.toml") + +MODES = ["base", "buy_bonus", "super_buy_bonus"] + +MODE_CONFIGS = { + "base": { + "test_spins": "[50, 100, 200]", + "test_spins_weights": "[0.3, 0.4, 0.3]", + }, + "buy_bonus": { + "test_spins": "[10, 20, 50]", + "test_spins_weights": "[0.6, 0.2, 0.2]", + }, + "super_buy_bonus": { + "test_spins": "[10, 20, 50]", + "test_spins_weights": "[0.6, 0.2, 0.2]", + }, +} + + +def write_setup_toml(mode): + cfg = MODE_CONFIGS[mode] + content = f'''num_show_pigs = 5000 +num_pigs_per_fence = 10000 +min_mean_to_median = 4 +max_mean_to_median = 8 +pmb_rtp = 1.0 +simulation_trials = 5000 +test_spins = {cfg["test_spins"]} +test_spins_weights = {cfg["test_spins_weights"]} +score_type = "rtp" +max_trial_dist = 15 +game_name = "1_1_shogun" +path_to_games = "../games/" +run_1000_batch = false +bet_type = "{mode}" +threads_for_fence_construction = 16 +threads_for_show_construction = 16 +''' + with open(SETUP_TOML, "w") as f: + f.write(content) + + +def run_mode(mode): + write_setup_toml(mode) + print(f"\n{'='*60}") + print(f"Running optimizer for mode: {mode}") + print(f"{'='*60}") + + cargo_bin = os.path.join(os.path.expanduser("~"), ".cargo", "bin") + env = {**os.environ, "PATH": cargo_bin + os.pathsep + os.environ.get("PATH", "")} + + result = subprocess.run( + ["cargo", "run", "--release"], + cwd=OPT_DIR, + env=env, + ) + if result.returncode != 0: + print(f"ERROR: Optimizer failed for mode {mode} (exit {result.returncode})") + return False + print(f"OK: {mode} optimization complete") + return True + + +if __name__ == "__main__": + for mode in MODES: + if not run_mode(mode): + print(f"Stopping due to error in mode: {mode}") + break + else: + print("\nAll modes optimized successfully!") diff --git a/optimization_program/run_script.py b/optimization_program/run_script.py index d1aedc867..fb7dfe236 100644 --- a/optimization_program/run_script.py +++ b/optimization_program/run_script.py @@ -58,11 +58,12 @@ def run_rust_script(): stderr=subprocess.PIPE, text=True, cwd=OPTIMIZATION_PATH, - check=True, env={**os.environ, "PATH": updated_path}, ) if result.returncode == 0: print(result.stdout) else: print("Error in optimization program.") - print(result.stderr) + print("STDOUT:", result.stdout) + print("STDERR:", result.stderr) + raise subprocess.CalledProcessError(result.returncode, ["cargo", "run", "--release"]) diff --git a/src/wins/multiplier_strategy.py b/src/wins/multiplier_strategy.py index eb2f3a9cb..ccdb4752e 100644 --- a/src/wins/multiplier_strategy.py +++ b/src/wins/multiplier_strategy.py @@ -46,4 +46,4 @@ def apply_combined_mult( ) -> tuple: """Apply symbol multipliers and then global multiplier""" win, sym_mult = apply_added_symbol_mult(board, win_amount, positions, multiplier_key) - return (win * global_multiplier , sym_mult * global_multiplier) + return (round(win * global_multiplier, 2), sym_mult * global_multiplier)