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

View File

@@ -1,3 +1,4 @@
import json
from datetime import datetime, timezone
from decimal import Decimal
@@ -8,41 +9,28 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import create_access_token, decode_access_token
from app.models import Deposit, User
from app.models import Deposit, SpinSession, User
from app.schemas import (
DepositRequest,
DepositResponse,
DevLoginRequest,
GameStub,
GameInfo,
PaylineWinResponse,
SessionSpinResponse,
SpinOutcomeResponse,
SpinRequest,
TelegramAuthRequest,
TokenResponse,
UserResponse,
WalletResponse,
)
from app.slots.registry import get_slot, list_slots
router = APIRouter()
security = HTTPBearer()
ALLOWED_DEPOSIT_AMOUNTS = {Decimal("100"), Decimal("500"), Decimal("1000")}
GAMES: list[GameStub] = [
GameStub(
slug="raven-reels",
title="Вороньи Барабаны",
description="Слоты у врат Вырия. Скоро откроются.",
),
GameStub(
slug="golden-gate",
title="Золотые Врата",
description="Портал удачи. Пока запечатан.",
),
GameStub(
slug="forest-fortune",
title="Лесная Удача",
description="Древние символы ещё не проснулись.",
),
]
def user_to_response(user: User) -> UserResponse:
return UserResponse(
@@ -71,6 +59,11 @@ def get_current_user(
return user
def slot_to_info(slot) -> GameInfo:
data = slot.info()
return GameInfo(**data)
@router.post("/auth/dev-login", response_model=TokenResponse)
def dev_login(payload: DevLoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = db.execute(select(User).where(User.telegram_id == payload.telegram_id)).scalar_one_or_none()
@@ -176,14 +169,105 @@ def deposit_stub(
)
@router.get("/games", response_model=list[GameStub])
def list_games() -> list[GameStub]:
return GAMES
@router.get("/games", response_model=list[GameInfo])
def list_games() -> list[GameInfo]:
return [slot_to_info(slot) for slot in list_slots()]
@router.get("/games/{slug}", response_model=GameStub)
def get_game(slug: str) -> GameStub:
for game in GAMES:
if game.slug == slug:
return game
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")
@router.get("/games/{slug}", response_model=GameInfo)
def get_game(slug: str) -> GameInfo:
slot = get_slot(slug)
if slot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")
return slot_to_info(slot)
@router.post("/games/{slug}/spin", response_model=SessionSpinResponse)
def spin_game(
slug: str,
payload: SpinRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SessionSpinResponse:
if user.agreement_accepted_at is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accept user agreement first",
)
slot = get_slot(slug)
if slot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")
if slot.status != "available":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Game is not available yet",
)
bet = payload.bet.quantize(Decimal("0.01"))
spin_count = payload.spin_count
try:
slot.validate_bet(bet)
slot.validate_spin_count(spin_count)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
total_bet = (bet * spin_count).quantize(Decimal("0.01"))
balance = user.balance or Decimal("0.00")
if balance < total_bet:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient balance")
user.balance = balance - total_bet
try:
session_result = slot.play_session(bet, spin_count)
except Exception:
db.rollback()
raise
user.balance = (user.balance or Decimal("0.00")) + session_result.total_win
record = SpinSession(
user_id=user.id,
slug=slug,
bet=bet,
spin_count=spin_count,
total_win=session_result.total_win,
payload_json=json.dumps(session_result.to_payload(), ensure_ascii=False),
)
db.add(record)
db.commit()
db.refresh(user)
return SessionSpinResponse(
slug=slug,
bet=session_result.bet,
spin_count=session_result.spin_count,
total_bet=session_result.total_bet,
total_win=session_result.total_win,
max_level=session_result.max_level,
trigger_count=session_result.trigger_count,
balance=user.balance,
spins=[
SpinOutcomeResponse(
index=s.index,
grid=s.grid,
line_wins=[
PaylineWinResponse(
line_index=w.line_index,
symbol=w.symbol,
count=w.count,
amount=w.amount,
path=w.path,
)
for w in s.line_wins
],
scatter_count=s.scatter_count,
scatter_win=s.scatter_win,
total_win=s.total_win,
level=s.level,
multiplier=s.multiplier,
meta_triggered=s.meta_triggered,
)
for s in session_result.spins
],
)

View File

@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.core.config import get_settings
from app.core.database import Base, engine
from app.models import Deposit, User # noqa: F401
from app.models import Deposit, SpinSession, User # noqa: F401
@asynccontextmanager

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, BigInteger, func
from sqlalchemy import DateTime, ForeignKey, Numeric, String, BigInteger, Text, Integer, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -25,6 +25,9 @@ class User(Base):
)
deposits: Mapped[list["Deposit"]] = relationship(back_populates="user", cascade="all, delete-orphan")
spin_sessions: Mapped[list["SpinSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Deposit(Base):
@@ -36,3 +39,18 @@ class Deposit(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user: Mapped[User] = relationship(back_populates="deposits")
class SpinSession(Base):
__tablename__ = "spin_sessions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
slug: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
bet: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
spin_count: Mapped[int] = mapped_column(Integer, nullable=False)
total_win: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user: Mapped[User] = relationship(back_populates="spin_sessions")

View File

@@ -49,8 +49,53 @@ class WalletResponse(BaseModel):
deposits: list[DepositResponse]
class GameStub(BaseModel):
class GameInfo(BaseModel):
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
# Backwards-compatible alias
GameStub = GameInfo
class SpinRequest(BaseModel):
bet: Decimal = Field(..., gt=0)
spin_count: int = Field(..., ge=1, le=1000)
class PaylineWinResponse(BaseModel):
line_index: int
symbol: str
count: int
amount: Decimal
path: list[int]
class SpinOutcomeResponse(BaseModel):
index: int
grid: list[list[str]]
line_wins: list[PaylineWinResponse]
scatter_count: int
scatter_win: Decimal
total_win: Decimal
level: int
multiplier: int
meta_triggered: bool
class SessionSpinResponse(BaseModel):
slug: str
bet: Decimal
spin_count: int
total_bet: Decimal
total_win: Decimal
max_level: int
trigger_count: int
balance: Decimal
spins: list[SpinOutcomeResponse]

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
],
}