- Introduced a new `spin_sessions` table to track user spin activities. - Updated the API to include a new endpoint for spinning games, allowing users to place bets and specify spin counts. - Enhanced game information structure and added validation for bets and spin counts. - Implemented the `Forest Fortune` slot game mechanics, including payline evaluation and scatter functionality. - Updated dependencies in `pyproject.toml` for development and added `httpx` for HTTP requests. - Added unit tests for the new slot mechanics and API endpoints to ensure functionality and reliability.
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""Unit tests for Forest Fortune slot mechanics and session meta."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from decimal import Decimal
|
|
|
|
from app.slots.forest_fortune import (
|
|
FOX,
|
|
OAK,
|
|
SCATTER,
|
|
WILD,
|
|
ForestFortuneSlot,
|
|
evaluate_payline,
|
|
)
|
|
from app.slots.rng import seeded_rng
|
|
from app.slots.types import SessionMetaState, SpinOutcome
|
|
|
|
|
|
class PaylineTests(unittest.TestCase):
|
|
def test_three_of_kind(self) -> None:
|
|
self.assertEqual(evaluate_payline([OAK, OAK, OAK, FOX, OAK]), (OAK, 3))
|
|
|
|
def test_break_stops_chain(self) -> None:
|
|
self.assertIsNone(evaluate_payline([OAK, OAK, FOX, OAK, OAK]))
|
|
|
|
def test_wild_extends(self) -> None:
|
|
self.assertEqual(evaluate_payline([OAK, WILD, OAK, FOX, FOX]), (OAK, 3))
|
|
|
|
def test_scatter_breaks_line(self) -> None:
|
|
self.assertIsNone(evaluate_payline([OAK, OAK, SCATTER, OAK, OAK]))
|
|
|
|
def test_all_wilds(self) -> None:
|
|
self.assertEqual(evaluate_payline([WILD, WILD, WILD, WILD, WILD]), (WILD, 5))
|
|
|
|
|
|
class SessionMetaTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.slot = ForestFortuneSlot()
|
|
|
|
def _forced_outcome(self, scatter_count: int, multiplier: int = 1) -> SpinOutcome:
|
|
grid = [["leaf"] * 5 for _ in range(5)]
|
|
placed = 0
|
|
for reel in range(5):
|
|
for row in range(5):
|
|
if placed < scatter_count:
|
|
grid[reel][row] = SCATTER
|
|
placed += 1
|
|
return self.slot.evaluate(grid, Decimal("10.00"), multiplier)
|
|
|
|
def test_single_spin_no_level_up_effect(self) -> None:
|
|
# Even with scatters, spin_count=1 keeps meta disabled in play_session
|
|
class ForceRng:
|
|
def __init__(self) -> None:
|
|
self.n = 0
|
|
|
|
def random(self) -> float:
|
|
return 0.5
|
|
|
|
def choices(self, population, weights=None, *, k=1):
|
|
# Force many scatters
|
|
return [SCATTER] * k
|
|
|
|
result = self.slot.play_session(Decimal("1.00"), 1, rng=ForceRng())
|
|
self.assertEqual(result.spin_count, 1)
|
|
self.assertEqual(result.max_level, 1)
|
|
self.assertEqual(result.trigger_count, 0)
|
|
self.assertFalse(result.spins[0].meta_triggered)
|
|
|
|
def test_pack_trigger_applies_to_next_spins(self) -> None:
|
|
scatters_on = {4} # 0-based index 4 => 5th spin
|
|
|
|
class ScriptedRng:
|
|
def __init__(self) -> None:
|
|
self.spin = -1
|
|
self.cell = 0
|
|
|
|
def random(self) -> float:
|
|
return 0.5
|
|
|
|
def choices(self, population, weights=None, *, k=1):
|
|
# New spin starts every 25 cells
|
|
if self.cell % 25 == 0:
|
|
self.spin += 1
|
|
self.cell += 1
|
|
if self.spin in scatters_on:
|
|
# first 3 cells of that spin are scatters, rest leaf
|
|
pos = (self.cell - 1) % 25
|
|
return [SCATTER if pos < 3 else "leaf"] * k
|
|
return ["leaf"] * k
|
|
|
|
result = self.slot.play_session(Decimal("1.00"), 10, rng=ScriptedRng())
|
|
self.assertEqual(result.trigger_count, 1)
|
|
self.assertEqual(result.max_level, 2)
|
|
# spins 0..4 at level 1; spin 4 triggers; spins 5..9 at level 2
|
|
self.assertEqual(result.spins[4].level, 1)
|
|
self.assertTrue(result.spins[4].meta_triggered)
|
|
self.assertEqual(result.spins[5].level, 2)
|
|
self.assertEqual(result.spins[5].multiplier, 2)
|
|
self.assertEqual(result.spins[9].multiplier, 2)
|
|
|
|
def test_second_trigger_triples(self) -> None:
|
|
scatters_on = {2, 5}
|
|
|
|
class ScriptedRng:
|
|
def __init__(self) -> None:
|
|
self.spin = -1
|
|
self.cell = 0
|
|
|
|
def random(self) -> float:
|
|
return 0.5
|
|
|
|
def choices(self, population, weights=None, *, k=1):
|
|
if self.cell % 25 == 0:
|
|
self.spin += 1
|
|
self.cell += 1
|
|
if self.spin in scatters_on:
|
|
pos = (self.cell - 1) % 25
|
|
return [SCATTER if pos < 3 else "leaf"] * k
|
|
return ["leaf"] * k
|
|
|
|
result = self.slot.play_session(Decimal("1.00"), 8, rng=ScriptedRng())
|
|
self.assertEqual(result.trigger_count, 2)
|
|
self.assertEqual(result.max_level, 3)
|
|
self.assertEqual(result.spins[3].multiplier, 2)
|
|
self.assertEqual(result.spins[6].multiplier, 3)
|
|
|
|
def test_evaluate_scatter_pay_and_trigger_flag(self) -> None:
|
|
outcome = self._forced_outcome(3, multiplier=2)
|
|
self.assertTrue(outcome.meta_triggered)
|
|
self.assertEqual(outcome.scatter_count, 3)
|
|
self.assertEqual(outcome.scatter_win, Decimal("20.00")) # 10 * 1.00 * 2
|
|
|
|
def test_meta_state_after_trigger(self) -> None:
|
|
state = SessionMetaState()
|
|
outcome = self._forced_outcome(3)
|
|
nxt = self.slot.session_meta_on_spin(state, outcome)
|
|
self.assertEqual(nxt.level, 2)
|
|
self.assertEqual(nxt.multiplier, 2)
|
|
self.assertTrue(nxt.triggered)
|
|
|
|
def test_seeded_session_runs(self) -> None:
|
|
result = self.slot.play_session(Decimal("5.00"), 5, rng=seeded_rng(42))
|
|
self.assertEqual(len(result.spins), 5)
|
|
self.assertEqual(result.total_bet, Decimal("25.00"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|