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:
19
server/models/__init__.py
Normal file
19
server/models/__init__.py
Normal 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
574
server/models/events.py
Normal 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
535
server/models/game_state.py
Normal 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
287
server/models/user.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user