Add spin session functionality and update game mechanics

- 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.
This commit is contained in:
Redsandy
2026-08-12 15:27:24 +03:00
parent c9f66f330e
commit 5125b52583
18 changed files with 1023 additions and 32 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Test package

View File

@@ -0,0 +1,149 @@
"""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()

111
tests/test_spin_api.py Normal file
View File

@@ -0,0 +1,111 @@
"""API tests for spin endpoint and wallet debit/credit."""
from __future__ import annotations
import unittest
from decimal import Decimal
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.database import Base, get_db
from app.main import create_app
class SpinApiTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSession = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base.metadata.create_all(bind=engine)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
self.app = create_app()
self.app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(self.app)
self.TestingSession = TestingSession
login = self.client.post(
"/auth/dev-login",
json={"telegram_id": 424242, "username": "tester", "first_name": "Test"},
)
self.assertEqual(login.status_code, 200)
token = login.json()["access_token"]
self.headers = {"Authorization": f"Bearer {token}"}
agree = self.client.post("/me/agreement", headers=self.headers)
self.assertEqual(agree.status_code, 200)
deposit = self.client.post("/wallet/deposit", headers=self.headers, json={"amount": 1000})
self.assertEqual(deposit.status_code, 200)
self.assertEqual(Decimal(deposit.json()["balance"]), Decimal("1000.00"))
def test_list_games_includes_forest(self) -> None:
res = self.client.get("/games")
self.assertEqual(res.status_code, 200)
slugs = {g["slug"]: g for g in res.json()}
self.assertIn("forest-fortune", slugs)
self.assertEqual(slugs["forest-fortune"]["status"], "available")
self.assertEqual(slugs["forest-fortune"]["max_spin_count"], 1000)
def test_spin_debit_and_credit(self) -> None:
res = self.client.post(
"/games/forest-fortune/spin",
headers=self.headers,
json={"bet": 10, "spin_count": 3},
)
self.assertEqual(res.status_code, 200, res.text)
body = res.json()
self.assertEqual(body["spin_count"], 3)
self.assertEqual(Decimal(body["total_bet"]), Decimal("30.00"))
self.assertEqual(len(body["spins"]), 3)
expected = Decimal("1000.00") - Decimal(body["total_bet"]) + Decimal(body["total_win"])
self.assertEqual(Decimal(body["balance"]), expected)
def test_reject_spin_count_over_limit(self) -> None:
res = self.client.post(
"/games/forest-fortune/spin",
headers=self.headers,
json={"bet": 1, "spin_count": 1001},
)
self.assertEqual(res.status_code, 422)
def test_reject_spin_count_zero_via_validation(self) -> None:
res = self.client.post(
"/games/forest-fortune/spin",
headers=self.headers,
json={"bet": 1, "spin_count": 0},
)
self.assertEqual(res.status_code, 422)
def test_insufficient_balance(self) -> None:
res = self.client.post(
"/games/forest-fortune/spin",
headers=self.headers,
json={"bet": 500, "spin_count": 10},
)
self.assertEqual(res.status_code, 400)
self.assertIn("Insufficient", res.json()["detail"])
def test_coming_soon_unavailable(self) -> None:
res = self.client.post(
"/games/raven-reels/spin",
headers=self.headers,
json={"bet": 1, "spin_count": 1},
)
self.assertEqual(res.status_code, 503)
if __name__ == "__main__":
unittest.main()