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:
5
app/slots/__init__.py
Normal file
5
app/slots/__init__.py
Normal 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
117
app/slots/base.py
Normal 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
29
app/slots/coming_soon.py
Normal 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
192
app/slots/forest_fortune.py
Normal 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
29
app/slots/registry.py
Normal 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
24
app/slots/rng.py
Normal 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
96
app/slots/types.py
Normal 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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user