From 5125b52583aeb6164a961488fa2749f680c534d7 Mon Sep 17 00:00:00 2001 From: Redsandy <34872843+Redsandyg@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:27:24 +0300 Subject: [PATCH] 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. --- README.md | 1 + alembic/versions/0002_spin_sessions.py | 40 ++++++ app/api/routes.py | 142 ++++++++++++++---- app/main.py | 2 +- app/models/__init__.py | 20 ++- app/schemas/__init__.py | 47 +++++- app/slots/__init__.py | 5 + app/slots/base.py | 117 +++++++++++++++ app/slots/coming_soon.py | 29 ++++ app/slots/forest_fortune.py | 192 +++++++++++++++++++++++++ app/slots/registry.py | 29 ++++ app/slots/rng.py | 24 ++++ app/slots/types.py | 96 +++++++++++++ pyproject.toml | 5 + tests/__init__.py | 1 + tests/test_forest_fortune.py | 149 +++++++++++++++++++ tests/test_spin_api.py | 111 ++++++++++++++ uv.lock | 45 ++++++ 18 files changed, 1023 insertions(+), 32 deletions(-) create mode 100644 alembic/versions/0002_spin_sessions.py create mode 100644 app/slots/__init__.py create mode 100644 app/slots/base.py create mode 100644 app/slots/coming_soon.py create mode 100644 app/slots/forest_fortune.py create mode 100644 app/slots/registry.py create mode 100644 app/slots/rng.py create mode 100644 app/slots/types.py create mode 100644 tests/__init__.py create mode 100644 tests/test_forest_fortune.py create mode 100644 tests/test_spin_api.py diff --git a/README.md b/README.md index 1d2daf5..950151f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ API docs: http://127.0.0.1:8000/docs - `GET /me`, `POST /me/agreement` - `GET /wallet`, `POST /wallet/deposit` (amounts: 100 / 500 / 1000) - `GET /games`, `GET /games/{slug}` +- `POST /games/{slug}/spin` — bet + spin_count (1…1000); Forest Fortune is playable No withdrawal endpoints by design. diff --git a/alembic/versions/0002_spin_sessions.py b/alembic/versions/0002_spin_sessions.py new file mode 100644 index 0000000..14a9669 --- /dev/null +++ b/alembic/versions/0002_spin_sessions.py @@ -0,0 +1,40 @@ +"""Add spin_sessions table.""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_spin_sessions" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "spin_sessions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("slug", sa.String(length=64), nullable=False), + sa.Column("bet", sa.Numeric(18, 2), nullable=False), + sa.Column("spin_count", sa.Integer(), nullable=False), + sa.Column("total_win", sa.Numeric(18, 2), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_spin_sessions_user_id", "spin_sessions", ["user_id"], unique=False) + op.create_index("ix_spin_sessions_slug", "spin_sessions", ["slug"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_spin_sessions_slug", table_name="spin_sessions") + op.drop_index("ix_spin_sessions_user_id", table_name="spin_sessions") + op.drop_table("spin_sessions") diff --git a/app/api/routes.py b/app/api/routes.py index e244638..8830814 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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 + ], + ) diff --git a/app/main.py b/app/main.py index 40d5e68..8db232b 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index b327d27..af5d3e5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 2a402cb..12c57ee 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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] diff --git a/app/slots/__init__.py b/app/slots/__init__.py new file mode 100644 index 0000000..18e021f --- /dev/null +++ b/app/slots/__init__.py @@ -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"] diff --git a/app/slots/base.py b/app/slots/base.py new file mode 100644 index 0000000..baaff9d --- /dev/null +++ b/app/slots/base.py @@ -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, + } diff --git a/app/slots/coming_soon.py b/app/slots/coming_soon.py new file mode 100644 index 0000000..9bd2bf4 --- /dev/null +++ b/app/slots/coming_soon.py @@ -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 diff --git a/app/slots/forest_fortune.py b/app/slots/forest_fortune.py new file mode 100644 index 0000000..958b368 --- /dev/null +++ b/app/slots/forest_fortune.py @@ -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) diff --git a/app/slots/registry.py b/app/slots/registry.py new file mode 100644 index 0000000..625bd59 --- /dev/null +++ b/app/slots/registry.py @@ -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) diff --git a/app/slots/rng.py b/app/slots/rng.py new file mode 100644 index 0000000..e0a3589 --- /dev/null +++ b/app/slots/rng.py @@ -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) diff --git a/app/slots/types.py b/app/slots/types.py new file mode 100644 index 0000000..41311ad --- /dev/null +++ b/app/slots/types.py @@ -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 + ], + } diff --git a/pyproject.toml b/pyproject.toml index 2c29602..53a2a70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,8 @@ dependencies = [ [tool.uv] package = false + +[dependency-groups] +dev = [ + "httpx>=0.28.1", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..66173ae --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package diff --git a/tests/test_forest_fortune.py b/tests/test_forest_fortune.py new file mode 100644 index 0000000..951e115 --- /dev/null +++ b/tests/test_forest_fortune.py @@ -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() diff --git a/tests/test_spin_api.py b/tests/test_spin_api.py new file mode 100644 index 0000000..cd2b48a --- /dev/null +++ b/tests/test_spin_api.py @@ -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() diff --git a/uv.lock b/uv.lock index 96e9c6f..6b1629e 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -262,6 +271,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -298,6 +320,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -724,6 +761,11 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = "==1.14.0" }, @@ -738,6 +780,9 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "httpx", specifier = ">=0.28.1" }] + [[package]] name = "watchfiles" version = "1.2.0"