Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
223 lines
7.1 KiB
Python
223 lines
7.1 KiB
Python
"""In-memory representation of a scopone scientifico game.
|
|
|
|
The whole mutable game lives in :class:`GameState`, which is serialized to
|
|
and from plain JSON for storage in Redis (see :mod:`scopa.store`). Keeping
|
|
the representation JSON-native means the store needs no custom codecs and
|
|
the state is inspectable with ``redis-cli``.
|
|
|
|
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
|
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
|
A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
SUITS = ("D", "C", "S", "B")
|
|
RANKS = tuple(range(1, 11))
|
|
|
|
# Teams are derived from the seat: seats 0 and 2 form team A (index 0),
|
|
# seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
|
|
# other, as in real scopone scientifico.
|
|
TEAM_A = 0
|
|
TEAM_B = 1
|
|
TEAM_NAMES = ("A", "B")
|
|
|
|
PHASE_LOBBY = "lobby"
|
|
PHASE_PLAYING = "playing"
|
|
PHASE_FINISHED = "finished"
|
|
|
|
DEFAULT_TARGET_SCORE = 11
|
|
|
|
|
|
def team_of(seat: int) -> int:
|
|
return seat % 2
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Card:
|
|
rank: int
|
|
suit: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.suit not in SUITS:
|
|
raise ValueError(f"invalid suit: {self.suit!r}")
|
|
if self.rank not in RANKS:
|
|
raise ValueError(f"invalid rank: {self.rank!r}")
|
|
|
|
@property
|
|
def code(self) -> str:
|
|
return f"{self.rank:02d}{self.suit}"
|
|
|
|
@staticmethod
|
|
def parse(code: str) -> "Card":
|
|
code = str(code).upper()
|
|
if len(code) != 3 or not code[:2].isdigit():
|
|
raise ValueError(f"invalid card code: {code!r}")
|
|
return Card(rank=int(code[:2]), suit=code[2])
|
|
|
|
def to_json(self) -> str:
|
|
return self.code
|
|
|
|
@staticmethod
|
|
def from_json(value: Any) -> "Card":
|
|
return Card.parse(str(value))
|
|
|
|
|
|
def parse_card(code: Any) -> Card:
|
|
"""Parse a card code, raising :class:`ValueError` on malformed input."""
|
|
try:
|
|
return Card.parse(str(code))
|
|
except ValueError:
|
|
raise
|
|
|
|
|
|
@dataclass
|
|
class Move:
|
|
"""Record of a single play, broadcast so every client can show who
|
|
played which card and what it captured."""
|
|
|
|
seat: int
|
|
name: str
|
|
card: str
|
|
captured: List[str] = field(default_factory=list)
|
|
scopa: bool = False
|
|
|
|
def to_json(self) -> Dict[str, Any]:
|
|
return {
|
|
"seat": self.seat,
|
|
"name": self.name,
|
|
"card": self.card,
|
|
"captured": list(self.captured),
|
|
"scopa": self.scopa,
|
|
}
|
|
|
|
@staticmethod
|
|
def from_json(data: Dict[str, Any]) -> "Move":
|
|
return Move(
|
|
seat=int(data["seat"]),
|
|
name=str(data["name"]),
|
|
card=str(data["card"]),
|
|
captured=[str(c) for c in data.get("captured", [])],
|
|
scopa=bool(data.get("scopa", False)),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PlayerState:
|
|
sub: str
|
|
name: str
|
|
seat: int
|
|
hand: List[Card] = field(default_factory=list)
|
|
captured: List[Card] = field(default_factory=list)
|
|
scope: int = 0
|
|
|
|
@property
|
|
def team(self) -> int:
|
|
return team_of(self.seat)
|
|
|
|
def to_json(self) -> Dict[str, Any]:
|
|
return {
|
|
"sub": self.sub,
|
|
"name": self.name,
|
|
"seat": self.seat,
|
|
"hand": [c.to_json() for c in self.hand],
|
|
"captured": [c.to_json() for c in self.captured],
|
|
"scope": self.scope,
|
|
}
|
|
|
|
@staticmethod
|
|
def from_json(data: Dict[str, Any]) -> "PlayerState":
|
|
return PlayerState(
|
|
sub=str(data["sub"]),
|
|
name=str(data["name"]),
|
|
seat=int(data["seat"]),
|
|
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
|
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
|
scope=int(data.get("scope", 0)),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class GameState:
|
|
id: str
|
|
join_code: str
|
|
creator_sub: str
|
|
target_score: int = DEFAULT_TARGET_SCORE
|
|
phase: str = PHASE_LOBBY
|
|
players: List[PlayerState] = field(default_factory=list)
|
|
table: List[Card] = field(default_factory=list)
|
|
dealer: int = 0
|
|
turn: int = 0
|
|
hand_number: int = 1
|
|
scores: List[int] = field(default_factory=lambda: [0, 0])
|
|
winner: Optional[int] = None
|
|
last_taker: Optional[int] = None
|
|
# Per-hand points awarded, for a compact audit trail in the API.
|
|
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
|
stats_saved: bool = False
|
|
# ISO-8601 timestamps, used when the match result is written to Postgres.
|
|
created_at: Optional[str] = None
|
|
finished_at: Optional[str] = None
|
|
# The most recent play in the current hand, for move announcements.
|
|
last_move: Optional[Move] = None
|
|
|
|
# -- serialization ----------------------------------------------------
|
|
|
|
def to_json(self) -> Dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"join_code": self.join_code,
|
|
"creator_sub": self.creator_sub,
|
|
"target_score": self.target_score,
|
|
"phase": self.phase,
|
|
"players": [p.to_json() for p in self.players],
|
|
"table": [c.to_json() for c in self.table],
|
|
"dealer": self.dealer,
|
|
"turn": self.turn,
|
|
"hand_number": self.hand_number,
|
|
"scores": list(self.scores),
|
|
"winner": self.winner,
|
|
"last_taker": self.last_taker,
|
|
"hand_scores": list(self.hand_scores),
|
|
"stats_saved": self.stats_saved,
|
|
"created_at": self.created_at,
|
|
"finished_at": self.finished_at,
|
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
|
}
|
|
|
|
@staticmethod
|
|
def from_json(data: Dict[str, Any]) -> "GameState":
|
|
return GameState(
|
|
id=str(data["id"]),
|
|
join_code=str(data["join_code"]),
|
|
creator_sub=str(data.get("creator_sub", "")),
|
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
|
table=[Card.from_json(c) for c in data.get("table", [])],
|
|
dealer=int(data.get("dealer", 0)),
|
|
turn=int(data.get("turn", 0)),
|
|
hand_number=int(data.get("hand_number", 1)),
|
|
scores=[int(x) for x in data.get("scores", [0, 0])],
|
|
winner=data.get("winner"),
|
|
last_taker=data.get("last_taker"),
|
|
hand_scores=list(data.get("hand_scores", [])),
|
|
stats_saved=bool(data.get("stats_saved", False)),
|
|
created_at=data.get("created_at"),
|
|
finished_at=data.get("finished_at"),
|
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
|
)
|
|
|
|
# -- helpers ----------------------------------------------------------
|
|
|
|
def player_for(self, sub: str) -> Optional[PlayerState]:
|
|
for player in self.players:
|
|
if player.sub == sub:
|
|
return player
|
|
return None
|
|
|
|
def seated(self, sub: str) -> bool:
|
|
return self.player_for(sub) is not None
|