- 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.
193 lines
5.5 KiB
Python
193 lines
5.5 KiB
Python
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)
|