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

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)