Huge v2 uplift, now deployable with real user management and tooling!

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-27 11:32:15 -05:00
parent c912a56c2d
commit bea85e6b28
61 changed files with 25153 additions and 362 deletions

19
server/models/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Models package for Golf game V2."""
from .events import EventType, GameEvent
from .game_state import RebuiltGameState, rebuild_state, CardState, PlayerState, GamePhase
from .user import UserRole, User, UserSession, GuestSession
__all__ = [
"EventType",
"GameEvent",
"RebuiltGameState",
"rebuild_state",
"CardState",
"PlayerState",
"GamePhase",
"UserRole",
"User",
"UserSession",
"GuestSession",
]

574
server/models/events.py Normal file
View File

@@ -0,0 +1,574 @@
"""
Event definitions for Golf game event sourcing.
All game actions are stored as immutable events, enabling:
- Full game replay from any point
- Audit trails for all player actions
- Stats aggregation from event streams
- Deterministic state reconstruction
Events are the single source of truth for game state.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any
import json
class EventType(str, Enum):
"""All possible event types in a Golf game."""
# Lifecycle events
GAME_CREATED = "game_created"
PLAYER_JOINED = "player_joined"
PLAYER_LEFT = "player_left"
GAME_STARTED = "game_started"
ROUND_STARTED = "round_started"
ROUND_ENDED = "round_ended"
GAME_ENDED = "game_ended"
# Gameplay events
INITIAL_FLIP = "initial_flip"
CARD_DRAWN = "card_drawn"
CARD_SWAPPED = "card_swapped"
CARD_DISCARDED = "card_discarded"
CARD_FLIPPED = "card_flipped"
FLIP_SKIPPED = "flip_skipped"
FLIP_AS_ACTION = "flip_as_action"
KNOCK_EARLY = "knock_early"
@dataclass
class GameEvent:
"""
Base class for all game events.
Events are immutable records of actions that occurred in a game.
They contain all information needed to reconstruct game state.
Attributes:
event_type: The type of event (from EventType enum).
game_id: UUID of the game this event belongs to.
sequence_num: Monotonically increasing sequence number within game.
timestamp: When the event occurred (UTC).
player_id: ID of player who triggered the event (if applicable).
data: Event-specific payload data.
"""
event_type: EventType
game_id: str
sequence_num: int
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
player_id: Optional[str] = None
data: dict = field(default_factory=dict)
def to_dict(self) -> dict:
"""Serialize event to dictionary for JSON storage."""
return {
"event_type": self.event_type.value,
"game_id": self.game_id,
"sequence_num": self.sequence_num,
"timestamp": self.timestamp.isoformat(),
"player_id": self.player_id,
"data": self.data,
}
def to_json(self) -> str:
"""Serialize event to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, d: dict) -> "GameEvent":
"""Deserialize event from dictionary."""
timestamp = d["timestamp"]
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
return cls(
event_type=EventType(d["event_type"]),
game_id=d["game_id"],
sequence_num=d["sequence_num"],
timestamp=timestamp,
player_id=d.get("player_id"),
data=d.get("data", {}),
)
@classmethod
def from_json(cls, json_str: str) -> "GameEvent":
"""Deserialize event from JSON string."""
return cls.from_dict(json.loads(json_str))
# =============================================================================
# Event Factory Functions
# =============================================================================
# These provide type-safe event construction with proper data structures.
def game_created(
game_id: str,
sequence_num: int,
room_code: str,
host_id: str,
options: dict,
) -> GameEvent:
"""
Create a GameCreated event.
Emitted when a new game room is created.
Args:
game_id: UUID for the new game.
sequence_num: Should be 1 (first event).
room_code: 4-letter room code.
host_id: Player ID of the host.
options: GameOptions as dict.
"""
return GameEvent(
event_type=EventType.GAME_CREATED,
game_id=game_id,
sequence_num=sequence_num,
player_id=host_id,
data={
"room_code": room_code,
"host_id": host_id,
"options": options,
},
)
def player_joined(
game_id: str,
sequence_num: int,
player_id: str,
player_name: str,
is_cpu: bool = False,
cpu_profile: Optional[str] = None,
) -> GameEvent:
"""
Create a PlayerJoined event.
Emitted when a player (human or CPU) joins the game.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Unique player identifier.
player_name: Display name.
is_cpu: Whether this is a CPU player.
cpu_profile: CPU profile name (for AI replay analysis).
"""
return GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"player_name": player_name,
"is_cpu": is_cpu,
"cpu_profile": cpu_profile,
},
)
def player_left(
game_id: str,
sequence_num: int,
player_id: str,
reason: str = "left",
) -> GameEvent:
"""
Create a PlayerLeft event.
Emitted when a player leaves the game.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: ID of player who left.
reason: Why they left (left, disconnected, kicked).
"""
return GameEvent(
event_type=EventType.PLAYER_LEFT,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={"reason": reason},
)
def game_started(
game_id: str,
sequence_num: int,
player_order: list[str],
num_decks: int,
num_rounds: int,
options: dict,
) -> GameEvent:
"""
Create a GameStarted event.
Emitted when the host starts the game. This locks in settings
but doesn't deal cards (that's RoundStarted).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_order: List of player IDs in turn order.
num_decks: Number of card decks being used.
num_rounds: Total rounds to play.
options: Final GameOptions as dict.
"""
return GameEvent(
event_type=EventType.GAME_STARTED,
game_id=game_id,
sequence_num=sequence_num,
data={
"player_order": player_order,
"num_decks": num_decks,
"num_rounds": num_rounds,
"options": options,
},
)
def round_started(
game_id: str,
sequence_num: int,
round_num: int,
deck_seed: int,
dealt_cards: dict[str, list[dict]],
first_discard: dict,
) -> GameEvent:
"""
Create a RoundStarted event.
Emitted at the start of each round. Contains all information
needed to recreate the initial state deterministically.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
round_num: Round number (1-indexed).
deck_seed: Random seed used for deck shuffle.
dealt_cards: Map of player_id -> list of 6 card dicts.
Cards include {rank, suit} (face_up always False).
first_discard: The first card on the discard pile.
"""
return GameEvent(
event_type=EventType.ROUND_STARTED,
game_id=game_id,
sequence_num=sequence_num,
data={
"round_num": round_num,
"deck_seed": deck_seed,
"dealt_cards": dealt_cards,
"first_discard": first_discard,
},
)
def round_ended(
game_id: str,
sequence_num: int,
round_num: int,
scores: dict[str, int],
final_hands: dict[str, list[dict]],
finisher_id: Optional[str] = None,
) -> GameEvent:
"""
Create a RoundEnded event.
Emitted when a round completes and scores are calculated.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
round_num: Round that just ended.
scores: Map of player_id -> round score.
final_hands: Map of player_id -> final 6 cards (all revealed).
finisher_id: ID of player who went out first (if any).
"""
return GameEvent(
event_type=EventType.ROUND_ENDED,
game_id=game_id,
sequence_num=sequence_num,
data={
"round_num": round_num,
"scores": scores,
"final_hands": final_hands,
"finisher_id": finisher_id,
},
)
def game_ended(
game_id: str,
sequence_num: int,
final_scores: dict[str, int],
rounds_won: dict[str, int],
winner_id: Optional[str] = None,
) -> GameEvent:
"""
Create a GameEnded event.
Emitted when all rounds are complete.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
final_scores: Map of player_id -> total score.
rounds_won: Map of player_id -> rounds won count.
winner_id: ID of overall winner (lowest total score).
"""
return GameEvent(
event_type=EventType.GAME_ENDED,
game_id=game_id,
sequence_num=sequence_num,
data={
"final_scores": final_scores,
"rounds_won": rounds_won,
"winner_id": winner_id,
},
)
def initial_flip(
game_id: str,
sequence_num: int,
player_id: str,
positions: list[int],
cards: list[dict],
) -> GameEvent:
"""
Create an InitialFlip event.
Emitted when a player flips their initial cards at round start.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who flipped.
positions: Card positions that were flipped (0-5).
cards: The cards that were revealed [{rank, suit}, ...].
"""
return GameEvent(
event_type=EventType.INITIAL_FLIP,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"positions": positions,
"cards": cards,
},
)
def card_drawn(
game_id: str,
sequence_num: int,
player_id: str,
source: str,
card: dict,
) -> GameEvent:
"""
Create a CardDrawn event.
Emitted when a player draws a card from deck or discard.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who drew.
source: "deck" or "discard".
card: The card drawn {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_DRAWN,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"source": source,
"card": card,
},
)
def card_swapped(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
new_card: dict,
old_card: dict,
) -> GameEvent:
"""
Create a CardSwapped event.
Emitted when a player swaps their drawn card with a hand card.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who swapped.
position: Hand position (0-5) where swap occurred.
new_card: Card placed into hand {rank, suit}.
old_card: Card removed from hand {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_SWAPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"new_card": new_card,
"old_card": old_card,
},
)
def card_discarded(
game_id: str,
sequence_num: int,
player_id: str,
card: dict,
) -> GameEvent:
"""
Create a CardDiscarded event.
Emitted when a player discards their drawn card without swapping.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who discarded.
card: The card discarded {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_DISCARDED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={"card": card},
)
def card_flipped(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
card: dict,
) -> GameEvent:
"""
Create a CardFlipped event.
Emitted when a player flips a card after discarding (flip_on_discard mode).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who flipped.
position: Position of flipped card (0-5).
card: The card revealed {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_FLIPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"card": card,
},
)
def flip_skipped(
game_id: str,
sequence_num: int,
player_id: str,
) -> GameEvent:
"""
Create a FlipSkipped event.
Emitted when a player skips the optional flip (endgame mode).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who skipped.
"""
return GameEvent(
event_type=EventType.FLIP_SKIPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={},
)
def flip_as_action(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
card: dict,
) -> GameEvent:
"""
Create a FlipAsAction event.
Emitted when a player uses their turn to flip a card (house rule).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who used flip-as-action.
position: Position of flipped card (0-5).
card: The card revealed {rank, suit}.
"""
return GameEvent(
event_type=EventType.FLIP_AS_ACTION,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"card": card,
},
)
def knock_early(
game_id: str,
sequence_num: int,
player_id: str,
positions: list[int],
cards: list[dict],
) -> GameEvent:
"""
Create a KnockEarly event.
Emitted when a player knocks early to reveal remaining cards.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who knocked.
positions: Positions of cards that were face-down.
cards: The cards revealed [{rank, suit}, ...].
"""
return GameEvent(
event_type=EventType.KNOCK_EARLY,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"positions": positions,
"cards": cards,
},
)

535
server/models/game_state.py Normal file
View File

@@ -0,0 +1,535 @@
"""
Game state rebuilder for event sourcing.
This module provides the ability to reconstruct game state from an event stream.
The RebuiltGameState class mirrors the Game class structure but is built
entirely from events rather than direct mutation.
Usage:
events = await event_store.get_events(game_id)
state = rebuild_state(events)
print(state.phase, state.current_player_id)
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from models.events import GameEvent, EventType
class GamePhase(str, Enum):
"""Game phases matching game.py GamePhase."""
WAITING = "waiting"
INITIAL_FLIP = "initial_flip"
PLAYING = "playing"
FINAL_TURN = "final_turn"
ROUND_OVER = "round_over"
GAME_OVER = "game_over"
@dataclass
class CardState:
"""
A card's state during replay.
Attributes:
rank: Card rank (A, 2-10, J, Q, K, or Joker).
suit: Card suit (hearts, diamonds, clubs, spades).
face_up: Whether the card is visible.
"""
rank: str
suit: str
face_up: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary for comparison."""
return {
"rank": self.rank,
"suit": self.suit,
"face_up": self.face_up,
}
@classmethod
def from_dict(cls, d: dict) -> "CardState":
"""Create from dictionary."""
return cls(
rank=d["rank"],
suit=d["suit"],
face_up=d.get("face_up", False),
)
@dataclass
class PlayerState:
"""
A player's state during replay.
Attributes:
id: Unique player identifier.
name: Display name.
cards: The player's 6-card hand.
score: Current round score.
total_score: Cumulative score across rounds.
rounds_won: Number of rounds won.
is_cpu: Whether this is a CPU player.
cpu_profile: CPU profile name (for AI analysis).
"""
id: str
name: str
cards: list[CardState] = field(default_factory=list)
score: int = 0
total_score: int = 0
rounds_won: int = 0
is_cpu: bool = False
cpu_profile: Optional[str] = None
def all_face_up(self) -> bool:
"""Check if all cards are revealed."""
return all(card.face_up for card in self.cards)
@dataclass
class RebuiltGameState:
"""
Game state rebuilt from events.
This class reconstructs the full game state by applying events in sequence.
It mirrors the structure of the Game class from game.py but is immutable
and derived entirely from events.
Attributes:
game_id: UUID of the game.
room_code: 4-letter room code.
phase: Current game phase.
players: Map of player_id -> PlayerState.
player_order: List of player IDs in turn order.
current_player_idx: Index of current player in player_order.
deck_remaining: Cards left in deck (approximated).
discard_pile: Cards in discard pile (most recent at end).
drawn_card: Card currently held by active player.
current_round: Current round number (1-indexed).
total_rounds: Total rounds in game.
options: GameOptions as dict.
sequence_num: Last applied event sequence.
finisher_id: Player who went out first this round.
initial_flips_done: Set of player IDs who completed initial flips.
"""
game_id: str
room_code: str = ""
phase: GamePhase = GamePhase.WAITING
players: dict[str, PlayerState] = field(default_factory=dict)
player_order: list[str] = field(default_factory=list)
current_player_idx: int = 0
deck_remaining: int = 0
discard_pile: list[CardState] = field(default_factory=list)
drawn_card: Optional[CardState] = None
drawn_from_discard: bool = False
current_round: int = 0
total_rounds: int = 1
options: dict = field(default_factory=dict)
sequence_num: int = 0
finisher_id: Optional[str] = None
players_with_final_turn: set = field(default_factory=set)
initial_flips_done: set = field(default_factory=set)
host_id: Optional[str] = None
def apply(self, event: GameEvent) -> "RebuiltGameState":
"""
Apply an event to produce new state.
Events must be applied in sequence order.
Args:
event: The event to apply.
Returns:
self for chaining.
Raises:
ValueError: If event is out of sequence or unknown type.
"""
# Validate sequence (first event can be 1, then must be sequential)
expected_seq = self.sequence_num + 1 if self.sequence_num > 0 else 1
if event.sequence_num != expected_seq:
raise ValueError(
f"Expected sequence {expected_seq}, got {event.sequence_num}"
)
# Dispatch to handler
handler = getattr(self, f"_apply_{event.event_type.value}", None)
if handler is None:
raise ValueError(f"Unknown event type: {event.event_type}")
handler(event)
self.sequence_num = event.sequence_num
return self
# -------------------------------------------------------------------------
# Lifecycle Event Handlers
# -------------------------------------------------------------------------
def _apply_game_created(self, event: GameEvent) -> None:
"""Handle game_created event."""
self.room_code = event.data["room_code"]
self.host_id = event.data["host_id"]
self.options = event.data.get("options", {})
def _apply_player_joined(self, event: GameEvent) -> None:
"""Handle player_joined event."""
player_id = event.player_id
self.players[player_id] = PlayerState(
id=player_id,
name=event.data["player_name"],
is_cpu=event.data.get("is_cpu", False),
cpu_profile=event.data.get("cpu_profile"),
)
def _apply_player_left(self, event: GameEvent) -> None:
"""Handle player_left event."""
player_id = event.player_id
if player_id in self.players:
del self.players[player_id]
if player_id in self.player_order:
self.player_order.remove(player_id)
# Adjust current player index if needed
if self.current_player_idx >= len(self.player_order):
self.current_player_idx = 0
def _apply_game_started(self, event: GameEvent) -> None:
"""Handle game_started event."""
self.player_order = event.data["player_order"]
self.total_rounds = event.data["num_rounds"]
self.options = event.data.get("options", self.options)
# Note: round_started will set up the actual round
def _apply_round_started(self, event: GameEvent) -> None:
"""Handle round_started event."""
self.current_round = event.data["round_num"]
self.finisher_id = None
self.players_with_final_turn = set()
self.initial_flips_done = set()
self.drawn_card = None
self.drawn_from_discard = False
self.current_player_idx = 0
self.discard_pile = []
# Deal cards to players (all face-down)
dealt_cards = event.data["dealt_cards"]
for player_id, cards_data in dealt_cards.items():
if player_id in self.players:
self.players[player_id].cards = [
CardState.from_dict(c) for c in cards_data
]
# Reset round score
self.players[player_id].score = 0
# Start discard pile
first_discard = event.data.get("first_discard")
if first_discard:
card = CardState.from_dict(first_discard)
card.face_up = True
self.discard_pile.append(card)
# Set phase based on initial_flips setting
initial_flips = self.options.get("initial_flips", 2)
if initial_flips == 0:
self.phase = GamePhase.PLAYING
else:
self.phase = GamePhase.INITIAL_FLIP
# Approximate deck size (we don't track exact cards)
num_decks = self.options.get("num_decks", 1)
cards_per_deck = 52
if self.options.get("use_jokers"):
if self.options.get("lucky_swing"):
cards_per_deck += 1 # Single joker
else:
cards_per_deck += 2 # Two jokers
total_cards = num_decks * cards_per_deck
dealt_count = len(self.players) * 6 + 1 # 6 per player + 1 discard
self.deck_remaining = total_cards - dealt_count
def _apply_round_ended(self, event: GameEvent) -> None:
"""Handle round_ended event."""
self.phase = GamePhase.ROUND_OVER
scores = event.data["scores"]
# Update player scores
for player_id, score in scores.items():
if player_id in self.players:
self.players[player_id].score = score
self.players[player_id].total_score += score
# Determine round winner (lowest score)
if scores:
min_score = min(scores.values())
for player_id, score in scores.items():
if score == min_score and player_id in self.players:
self.players[player_id].rounds_won += 1
# Apply final hands if provided
final_hands = event.data.get("final_hands", {})
for player_id, cards_data in final_hands.items():
if player_id in self.players:
self.players[player_id].cards = [
CardState.from_dict(c) for c in cards_data
]
# Ensure all cards are face up
for card in self.players[player_id].cards:
card.face_up = True
def _apply_game_ended(self, event: GameEvent) -> None:
"""Handle game_ended event."""
self.phase = GamePhase.GAME_OVER
# Final scores are already tracked in players
# -------------------------------------------------------------------------
# Gameplay Event Handlers
# -------------------------------------------------------------------------
def _apply_initial_flip(self, event: GameEvent) -> None:
"""Handle initial_flip event."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
positions = event.data["positions"]
cards = event.data["cards"]
for pos, card_data in zip(positions, cards):
if 0 <= pos < len(player.cards):
player.cards[pos] = CardState.from_dict(card_data)
player.cards[pos].face_up = True
self.initial_flips_done.add(player_id)
# Check if all players have flipped
if len(self.initial_flips_done) == len(self.players):
self.phase = GamePhase.PLAYING
def _apply_card_drawn(self, event: GameEvent) -> None:
"""Handle card_drawn event."""
card = CardState.from_dict(event.data["card"])
card.face_up = True
self.drawn_card = card
self.drawn_from_discard = event.data["source"] == "discard"
if self.drawn_from_discard and self.discard_pile:
self.discard_pile.pop()
else:
self.deck_remaining = max(0, self.deck_remaining - 1)
def _apply_card_swapped(self, event: GameEvent) -> None:
"""Handle card_swapped event."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
new_card = CardState.from_dict(event.data["new_card"])
old_card = CardState.from_dict(event.data["old_card"])
# Place new card in hand
new_card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = new_card
# Add old card to discard
old_card.face_up = True
self.discard_pile.append(old_card)
# Clear drawn card
self.drawn_card = None
self.drawn_from_discard = False
# Advance turn
self._end_turn(player)
def _apply_card_discarded(self, event: GameEvent) -> None:
"""Handle card_discarded event."""
player_id = event.player_id
player = self.players.get(player_id)
if self.drawn_card:
self.drawn_card.face_up = True
self.discard_pile.append(self.drawn_card)
self.drawn_card = None
self.drawn_from_discard = False
# Check if flip_on_discard mode requires a flip
# If not, end turn now
flip_mode = self.options.get("flip_mode", "never")
if flip_mode == "never":
if player:
self._end_turn(player)
# For "always" or "endgame", wait for flip_card or flip_skipped event
def _apply_card_flipped(self, event: GameEvent) -> None:
"""Handle card_flipped event (after discard in flip mode)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
card = CardState.from_dict(event.data["card"])
card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = card
self._end_turn(player)
def _apply_flip_skipped(self, event: GameEvent) -> None:
"""Handle flip_skipped event (endgame mode optional flip)."""
player_id = event.player_id
player = self.players.get(player_id)
if player:
self._end_turn(player)
def _apply_flip_as_action(self, event: GameEvent) -> None:
"""Handle flip_as_action event (house rule)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
card = CardState.from_dict(event.data["card"])
card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = card
self._end_turn(player)
def _apply_knock_early(self, event: GameEvent) -> None:
"""Handle knock_early event (house rule)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
positions = event.data["positions"]
cards = event.data["cards"]
for pos, card_data in zip(positions, cards):
if 0 <= pos < len(player.cards):
card = CardState.from_dict(card_data)
card.face_up = True
player.cards[pos] = card
self._end_turn(player)
# -------------------------------------------------------------------------
# Turn Management
# -------------------------------------------------------------------------
def _end_turn(self, player: PlayerState) -> None:
"""
Handle end of player's turn.
Checks for going out and advances to next player.
"""
# Check if player went out
if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN
self.players_with_final_turn.add(player.id)
elif self.phase == GamePhase.FINAL_TURN:
# In final turn, reveal all cards after turn ends
for card in player.cards:
card.face_up = True
self.players_with_final_turn.add(player.id)
# Advance to next player
self._next_turn()
def _next_turn(self) -> None:
"""Advance to the next player's turn."""
if not self.player_order:
return
if self.phase == GamePhase.FINAL_TURN:
# Check if all players have had their final turn
all_done = all(
pid in self.players_with_final_turn
for pid in self.player_order
)
if all_done:
# Round will end (round_ended event will set phase)
return
# Move to next player
self.current_player_idx = (self.current_player_idx + 1) % len(self.player_order)
# -------------------------------------------------------------------------
# Query Methods
# -------------------------------------------------------------------------
@property
def current_player_id(self) -> Optional[str]:
"""Get the current player's ID."""
if self.player_order and 0 <= self.current_player_idx < len(self.player_order):
return self.player_order[self.current_player_idx]
return None
@property
def current_player(self) -> Optional[PlayerState]:
"""Get the current player's state."""
player_id = self.current_player_id
return self.players.get(player_id) if player_id else None
def discard_top(self) -> Optional[CardState]:
"""Get the top card of the discard pile."""
return self.discard_pile[-1] if self.discard_pile else None
def get_player(self, player_id: str) -> Optional[PlayerState]:
"""Get a player's state by ID."""
return self.players.get(player_id)
def rebuild_state(events: list[GameEvent]) -> RebuiltGameState:
"""
Rebuild game state from a list of events.
Args:
events: List of events in sequence order.
Returns:
Reconstructed game state.
Raises:
ValueError: If events list is empty or has invalid sequence.
"""
if not events:
raise ValueError("Cannot rebuild state from empty event list")
state = RebuiltGameState(game_id=events[0].game_id)
for event in events:
state.apply(event)
return state
async def rebuild_state_from_store(
event_store,
game_id: str,
to_sequence: Optional[int] = None,
) -> RebuiltGameState:
"""
Rebuild game state by loading events from the store.
Args:
event_store: EventStore instance.
game_id: Game UUID.
to_sequence: Optional sequence to rebuild up to.
Returns:
Reconstructed game state.
"""
events = await event_store.get_events(game_id, to_sequence=to_sequence)
return rebuild_state(events)

287
server/models/user.py Normal file
View File

@@ -0,0 +1,287 @@
"""
User-related models for Golf game authentication.
Defines user accounts, sessions, and guest tracking for the V2 auth system.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any
import json
class UserRole(str, Enum):
"""User role levels."""
GUEST = "guest"
USER = "user"
ADMIN = "admin"
@dataclass
class User:
"""
A registered user account.
Attributes:
id: UUID primary key.
username: Unique display name.
email: Optional email address.
password_hash: bcrypt hash of password.
role: User role (guest, user, admin).
email_verified: Whether email has been verified.
verification_token: Token for email verification.
verification_expires: When verification token expires.
reset_token: Token for password reset.
reset_expires: When reset token expires.
guest_id: Guest session ID if converted from guest.
deleted_at: Soft delete timestamp.
preferences: User preferences as JSON.
created_at: When account was created.
last_login: Last login timestamp.
last_seen_at: Last activity timestamp.
is_active: Whether account is active.
is_banned: Whether user is banned.
ban_reason: Reason for ban (if banned).
force_password_reset: Whether user must reset password on next login.
"""
id: str
username: str
password_hash: str
email: Optional[str] = None
role: UserRole = UserRole.USER
email_verified: bool = False
verification_token: Optional[str] = None
verification_expires: Optional[datetime] = None
reset_token: Optional[str] = None
reset_expires: Optional[datetime] = None
guest_id: Optional[str] = None
deleted_at: Optional[datetime] = None
preferences: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_login: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
is_active: bool = True
is_banned: bool = False
ban_reason: Optional[str] = None
force_password_reset: bool = False
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.role == UserRole.ADMIN
def is_guest(self) -> bool:
"""Check if user has guest role."""
return self.role == UserRole.GUEST
def can_login(self) -> bool:
"""Check if user can log in."""
return self.is_active and self.deleted_at is None and not self.is_banned
def to_dict(self, include_sensitive: bool = False) -> dict:
"""
Serialize user to dictionary.
Args:
include_sensitive: Include password hash and tokens.
"""
d = {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role.value,
"email_verified": self.email_verified,
"preferences": self.preferences,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"is_active": self.is_active,
"is_banned": self.is_banned,
"ban_reason": self.ban_reason,
"force_password_reset": self.force_password_reset,
}
if include_sensitive:
d["password_hash"] = self.password_hash
d["verification_token"] = self.verification_token
d["verification_expires"] = (
self.verification_expires.isoformat() if self.verification_expires else None
)
d["reset_token"] = self.reset_token
d["reset_expires"] = (
self.reset_expires.isoformat() if self.reset_expires else None
)
d["guest_id"] = self.guest_id
d["deleted_at"] = self.deleted_at.isoformat() if self.deleted_at else None
return d
@classmethod
def from_dict(cls, d: dict) -> "User":
"""Deserialize user from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
username=d["username"],
password_hash=d.get("password_hash", ""),
email=d.get("email"),
role=UserRole(d.get("role", "user")),
email_verified=d.get("email_verified", False),
verification_token=d.get("verification_token"),
verification_expires=parse_dt(d.get("verification_expires")),
reset_token=d.get("reset_token"),
reset_expires=parse_dt(d.get("reset_expires")),
guest_id=d.get("guest_id"),
deleted_at=parse_dt(d.get("deleted_at")),
preferences=d.get("preferences", {}),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_login=parse_dt(d.get("last_login")),
last_seen_at=parse_dt(d.get("last_seen_at")),
is_active=d.get("is_active", True),
is_banned=d.get("is_banned", False),
ban_reason=d.get("ban_reason"),
force_password_reset=d.get("force_password_reset", False),
)
@dataclass
class UserSession:
"""
An active user session.
Session tokens are hashed before storage for security.
Attributes:
id: UUID primary key.
user_id: Reference to user.
token_hash: SHA256 hash of session token.
device_info: Device/browser information.
ip_address: Client IP address.
created_at: When session was created.
expires_at: When session expires.
last_used_at: Last activity timestamp.
revoked_at: When session was revoked (if any).
"""
id: str
user_id: str
token_hash: str
device_info: dict = field(default_factory=dict)
ip_address: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
revoked_at: Optional[datetime] = None
def is_valid(self) -> bool:
"""Check if session is still valid."""
now = datetime.now(timezone.utc)
return (
self.revoked_at is None
and self.expires_at > now
)
def to_dict(self) -> dict:
"""Serialize session to dictionary."""
return {
"id": self.id,
"user_id": self.user_id,
"token_hash": self.token_hash,
"device_info": self.device_info,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "UserSession":
"""Deserialize session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
user_id=d["user_id"],
token_hash=d["token_hash"],
device_info=d.get("device_info", {}),
ip_address=d.get("ip_address"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
last_used_at=parse_dt(d.get("last_used_at")) or datetime.now(timezone.utc),
revoked_at=parse_dt(d.get("revoked_at")),
)
@dataclass
class GuestSession:
"""
A guest session for tracking anonymous users.
Guests can play games without registering. Their session
can later be converted to a full user account.
Attributes:
id: Guest session ID (stored in client).
display_name: Display name for the guest.
created_at: When session was created.
last_seen_at: Last activity timestamp.
games_played: Number of games played as guest.
converted_to_user_id: User ID if converted to account.
expires_at: When guest session expires.
"""
id: str
display_name: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_seen_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
games_played: int = 0
converted_to_user_id: Optional[str] = None
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def is_converted(self) -> bool:
"""Check if guest has been converted to user."""
return self.converted_to_user_id is not None
def is_expired(self) -> bool:
"""Check if guest session has expired."""
return datetime.now(timezone.utc) > self.expires_at
def to_dict(self) -> dict:
"""Serialize guest session to dictionary."""
return {
"id": self.id,
"display_name": self.display_name,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"games_played": self.games_played,
"converted_to_user_id": self.converted_to_user_id,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "GuestSession":
"""Deserialize guest session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
display_name=d.get("display_name"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_seen_at=parse_dt(d.get("last_seen_at")) or datetime.now(timezone.utc),
games_played=d.get("games_played", 0),
converted_to_user_id=d.get("converted_to_user_id"),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
)