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

5
app/slots/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from app.slots.base import BaseSlot
from app.slots.forest_fortune import ForestFortuneSlot
from app.slots.registry import get_slot, list_slots
__all__ = ["BaseSlot", "ForestFortuneSlot", "get_slot", "list_slots"]

117
app/slots/base.py Normal file
View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from decimal import Decimal
from app.slots.rng import Rng, system_rng
from app.slots.types import Grid, SessionMetaState, SessionResult, SpinOutcome, SpinResult
class BaseSlot(ABC):
slug: str
title: str
description: str
status: str = "coming_soon"
min_bet: Decimal = Decimal("1.00")
max_bet: Decimal = Decimal("1000.00")
min_spin_count: int = 1
max_spin_count: int = 1000
@abstractmethod
def build_grid(self, rng: Rng) -> Grid:
raise NotImplementedError
@abstractmethod
def evaluate(self, grid: Grid, bet: Decimal, multiplier: int) -> SpinOutcome:
raise NotImplementedError
@abstractmethod
def session_meta_on_spin(self, state: SessionMetaState, outcome: SpinOutcome) -> SessionMetaState:
raise NotImplementedError
def validate_bet(self, bet: Decimal) -> None:
if bet < self.min_bet or bet > self.max_bet:
raise ValueError(f"Bet must be between {self.min_bet} and {self.max_bet}")
def validate_spin_count(self, spin_count: int) -> None:
if spin_count < self.min_spin_count or spin_count > self.max_spin_count:
raise ValueError(
f"spin_count must be between {self.min_spin_count} and {self.max_spin_count}"
)
def play_session(
self,
bet: Decimal,
spin_count: int,
rng: Rng | None = None,
) -> SessionResult:
if self.status != "available":
raise RuntimeError(f"Game '{self.slug}' is not available")
bet = bet.quantize(Decimal("0.01"))
self.validate_bet(bet)
self.validate_spin_count(spin_count)
rng = rng or system_rng()
meta = SessionMetaState()
spins: list[SpinResult] = []
total_win = Decimal("0.00")
trigger_count = 0
max_level = 1
meta_enabled = spin_count > 1
for index in range(spin_count):
level = meta.level
multiplier = meta.multiplier
grid = self.build_grid(rng)
outcome = self.evaluate(grid, bet, multiplier)
if meta_enabled and outcome.meta_triggered:
next_meta = self.session_meta_on_spin(meta, outcome)
if next_meta.triggered:
trigger_count += 1
meta = SessionMetaState(
level=next_meta.level,
multiplier=next_meta.multiplier,
triggered=False,
)
max_level = max(max_level, meta.level)
triggered = True
else:
triggered = False
spin = SpinResult(
index=index,
grid=outcome.grid,
line_wins=outcome.line_wins,
scatter_count=outcome.scatter_count,
scatter_win=outcome.scatter_win,
total_win=outcome.total_win,
level=level,
multiplier=multiplier,
meta_triggered=triggered,
)
spins.append(spin)
total_win += outcome.total_win
return SessionResult(
bet=bet,
spin_count=spin_count,
total_bet=(bet * spin_count).quantize(Decimal("0.01")),
total_win=total_win.quantize(Decimal("0.01")),
max_level=max_level,
trigger_count=trigger_count,
spins=spins,
)
def info(self) -> dict:
return {
"slug": self.slug,
"title": self.title,
"description": self.description,
"status": self.status,
"min_bet": self.min_bet,
"max_bet": self.max_bet,
"min_spin_count": self.min_spin_count,
"max_spin_count": self.max_spin_count,
}

29
app/slots/coming_soon.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from decimal import Decimal
from app.slots.base import BaseSlot
from app.slots.rng import Rng
from app.slots.types import Grid, SessionMetaState, SpinOutcome
class ComingSoonSlot(BaseSlot):
status = "coming_soon"
min_bet = Decimal("1.00")
max_bet = Decimal("100.00")
min_spin_count = 1
max_spin_count = 1
def __init__(self, slug: str, title: str, description: str) -> None:
self.slug = slug
self.title = title
self.description = description
def build_grid(self, rng: Rng) -> Grid:
raise RuntimeError(f"Game '{self.slug}' is not available")
def evaluate(self, grid: Grid, bet: Decimal, multiplier: int) -> SpinOutcome:
raise RuntimeError(f"Game '{self.slug}' is not available")
def session_meta_on_spin(self, state: SessionMetaState, outcome: SpinOutcome) -> SessionMetaState:
return state

192
app/slots/forest_fortune.py Normal file
View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from decimal import Decimal
from app.slots.base import BaseSlot
from app.slots.rng import Rng
from app.slots.types import Grid, PaylineWin, SessionMetaState, SpinOutcome
REELS = 5
ROWS = 5
LEAF = "leaf"
MUSHROOM = "mushroom"
PINE = "pine"
DEER = "deer"
FOX = "fox"
OAK = "oak"
WILD = "wild"
SCATTER = "scatter"
SYMBOLS = (LEAF, MUSHROOM, PINE, DEER, FOX, OAK, WILD, SCATTER)
# Weights per reel (index aligns with SYMBOLS)
REEL_WEIGHTS: list[list[float]] = [
[28, 24, 18, 12, 8, 5, 3, 2],
[26, 24, 18, 12, 8, 6, 4, 2],
[24, 22, 18, 14, 10, 6, 4, 2],
[26, 24, 18, 12, 8, 6, 4, 2],
[28, 24, 18, 12, 8, 5, 3, 2],
]
# Multipliers of total spin bet
PAYTABLE: dict[str, dict[int, Decimal]] = {
LEAF: {3: Decimal("0.20"), 4: Decimal("0.50"), 5: Decimal("1.50")},
MUSHROOM: {3: Decimal("0.25"), 4: Decimal("0.60"), 5: Decimal("2.00")},
PINE: {3: Decimal("0.35"), 4: Decimal("0.80"), 5: Decimal("2.50")},
DEER: {3: Decimal("0.50"), 4: Decimal("1.20"), 5: Decimal("4.00")},
FOX: {3: Decimal("0.70"), 4: Decimal("1.80"), 5: Decimal("6.00")},
OAK: {3: Decimal("1.00"), 4: Decimal("3.00"), 5: Decimal("10.00")},
WILD: {3: Decimal("1.50"), 4: Decimal("4.00"), 5: Decimal("15.00")},
}
SCATTER_TABLE: dict[int, Decimal] = {
3: Decimal("1.00"),
4: Decimal("3.00"),
5: Decimal("8.00"),
}
# Cap scatter pay at 5+ using the 5-table entry; more scatters still use 5 rate
SCATTER_TRIGGER = 3
PAYLINES: list[list[int]] = [
[0, 0, 0, 0, 0],
[1, 1, 1, 1, 1],
[2, 2, 2, 2, 2],
[3, 3, 3, 3, 3],
[4, 4, 4, 4, 4],
[0, 1, 2, 3, 4],
[4, 3, 2, 1, 0],
[0, 0, 1, 0, 0],
[4, 4, 3, 4, 4],
[1, 2, 3, 2, 1],
[3, 2, 1, 2, 3],
[0, 1, 0, 1, 0],
[4, 3, 4, 3, 4],
[2, 1, 0, 1, 2],
[2, 3, 4, 3, 2],
[0, 1, 2, 1, 0],
[4, 3, 2, 3, 4],
[1, 1, 2, 3, 3],
[3, 3, 2, 1, 1],
[0, 2, 4, 2, 0],
]
def _line_symbols(grid: Grid, path: list[int]) -> list[str]:
return [grid[reel][path[reel]] for reel in range(REELS)]
def evaluate_payline(symbols: list[str]) -> tuple[str, int] | None:
"""Return (symbol, count) for best left-to-right win, or None."""
if not symbols:
return None
# All wilds
if all(s == WILD for s in symbols):
return WILD, len(symbols)
# Find target symbol: first non-wild, non-scatter from the left
target: str | None = None
for s in symbols:
if s == SCATTER:
break
if s != WILD:
target = s
break
if target is None:
# Leading wilds then scatter/end — pay as wild streak only if length >= 3
count = 0
for s in symbols:
if s == WILD:
count += 1
else:
break
if count >= 3:
return WILD, count
return None
count = 0
for s in symbols:
if s == SCATTER:
break
if s == target or s == WILD:
count += 1
else:
break
if count >= 3:
return target, count
return None
def scatter_pay_key(count: int) -> int | None:
if count < 3:
return None
return min(count, 5)
class ForestFortuneSlot(BaseSlot):
slug = "forest-fortune"
title = "Лесная Удача"
description = "5×5 лесной слот. В пачке спинов духи поднимают множитель."
status = "available"
min_bet = Decimal("1.00")
max_bet = Decimal("1000.00")
min_spin_count = 1
max_spin_count = 1000
def build_grid(self, rng: Rng) -> Grid:
grid: Grid = []
for reel in range(REELS):
column = [
rng.choices(list(SYMBOLS), weights=REEL_WEIGHTS[reel], k=1)[0]
for _ in range(ROWS)
]
grid.append(column)
return grid
def evaluate(self, grid: Grid, bet: Decimal, multiplier: int) -> SpinOutcome:
line_wins: list[PaylineWin] = []
for line_index, path in enumerate(PAYLINES):
symbols = _line_symbols(grid, path)
result = evaluate_payline(symbols)
if result is None:
continue
symbol, count = result
rate = PAYTABLE.get(symbol, {}).get(count)
if rate is None:
continue
amount = (bet * rate * Decimal(multiplier)).quantize(Decimal("0.01"))
line_wins.append(
PaylineWin(
line_index=line_index,
symbol=symbol,
count=count,
amount=amount,
path=list(path),
)
)
scatter_count = sum(1 for reel in grid for cell in reel if cell == SCATTER)
scatter_win = Decimal("0.00")
key = scatter_pay_key(scatter_count)
if key is not None:
rate = SCATTER_TABLE[key]
scatter_win = (bet * rate * Decimal(multiplier)).quantize(Decimal("0.01"))
total = sum((w.amount for w in line_wins), Decimal("0.00")) + scatter_win
return SpinOutcome(
grid=grid,
line_wins=line_wins,
scatter_count=scatter_count,
scatter_win=scatter_win,
total_win=total.quantize(Decimal("0.01")),
meta_triggered=scatter_count >= SCATTER_TRIGGER,
)
def session_meta_on_spin(self, state: SessionMetaState, outcome: SpinOutcome) -> SessionMetaState:
if outcome.meta_triggered:
return state.after_trigger()
return SessionMetaState(level=state.level, multiplier=state.multiplier, triggered=False)

29
app/slots/registry.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from app.slots.base import BaseSlot
from app.slots.coming_soon import ComingSoonSlot
from app.slots.forest_fortune import ForestFortuneSlot
_SLOTS: list[BaseSlot] = [
ComingSoonSlot(
slug="raven-reels",
title="Вороньи Барабаны",
description="Слоты у врат Вырия. Скоро откроются.",
),
ComingSoonSlot(
slug="golden-gate",
title="Золотые Врата",
description="Портал удачи. Пока запечатан.",
),
ForestFortuneSlot(),
]
_BY_SLUG: dict[str, BaseSlot] = {slot.slug: slot for slot in _SLOTS}
def list_slots() -> list[BaseSlot]:
return list(_SLOTS)
def get_slot(slug: str) -> BaseSlot | None:
return _BY_SLUG.get(slug)

24
app/slots/rng.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import random
from typing import Any, Protocol, Sequence
class Rng(Protocol):
def random(self) -> float: ...
def choices(
self,
population: Sequence[Any],
weights: Sequence[float] | None = None,
*,
k: int = 1,
) -> list[Any]: ...
def system_rng() -> random.SystemRandom:
return random.SystemRandom()
def seeded_rng(seed: int | str | bytes | bytearray) -> random.Random:
return random.Random(seed)

96
app/slots/types.py Normal file
View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
# grid[reel][row], reel 0..4, row 0..4 (0 = top)
Grid = list[list[str]]
@dataclass
class PaylineWin:
line_index: int
symbol: str
count: int
amount: Decimal
path: list[int]
@dataclass
class SpinOutcome:
grid: Grid
line_wins: list[PaylineWin]
scatter_count: int
scatter_win: Decimal
total_win: Decimal
meta_triggered: bool
@dataclass
class SessionMetaState:
level: int = 1
multiplier: int = 1
triggered: bool = False
def after_trigger(self) -> SessionMetaState:
level = self.level + 1
return SessionMetaState(level=level, multiplier=level, triggered=True)
@dataclass
class SpinResult:
index: int
grid: Grid
line_wins: list[PaylineWin]
scatter_count: int
scatter_win: Decimal
total_win: Decimal
level: int
multiplier: int
meta_triggered: bool
@dataclass
class SessionResult:
bet: Decimal
spin_count: int
total_bet: Decimal
total_win: Decimal
max_level: int
trigger_count: int
spins: list[SpinResult] = field(default_factory=list)
def to_payload(self) -> dict[str, Any]:
return {
"bet": str(self.bet),
"spin_count": self.spin_count,
"total_bet": str(self.total_bet),
"total_win": str(self.total_win),
"max_level": self.max_level,
"trigger_count": self.trigger_count,
"spins": [
{
"index": s.index,
"grid": s.grid,
"line_wins": [
{
"line_index": w.line_index,
"symbol": w.symbol,
"count": w.count,
"amount": str(w.amount),
"path": w.path,
}
for w in s.line_wins
],
"scatter_count": s.scatter_count,
"scatter_win": str(s.scatter_win),
"total_win": str(s.total_win),
"level": s.level,
"multiplier": s.multiplier,
"meta_triggered": s.meta_triggered,
}
for s in self.spins
],
}