import re from datetime import datetime, timezone from enum import Enum from typing import Dict, List, Optional from pydantic import BaseModel, Field, field_validator class Direction(str, Enum): UP = "UP" DOWN = "DOWN" LEFT = "LEFT" RIGHT = "RIGHT" UP_LEFT = "UP_LEFT" UP_RIGHT = "UP_RIGHT" DOWN_LEFT = "DOWN_LEFT" DOWN_RIGHT = "DOWN_RIGHT" # Map standard names, cardinal letters, and abbreviations DIRECTION_OFFSETS: Dict[str, tuple[int, int]] = { # Cardinal "UP": (0, -1), "DOWN": (0, 1), "LEFT": (-1, 0), "RIGHT": (1, 0), # Abbreviations "N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0), # Diagonals "UP_LEFT": (-1, -1), "UP_RIGHT": (1, -1), "DOWN_LEFT": (-1, 1), "DOWN_RIGHT": (1, 1), # Diagonal Abbreviations "NW": (-1, -1), "NE": (1, -1), "SW": (-1, 1), "SE": (1, 1), "UPLEFT": (-1, -1), "UPRIGHT": (1, -1), "DOWNLEFT": (-1, 1), "DOWNRIGHT": (1, 1), } class MoveRequest(BaseModel): direction: Optional[str] = Field( None, description="Direction to move: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, E, W, NW, NE, SW, SE)", ) dx: Optional[int] = Field(None, ge=-1, le=1, description="Delta X coordinate (-1, 0, or 1)") dy: Optional[int] = Field(None, ge=-1, le=1, description="Delta Y coordinate (-1, 0, or 1)") def get_delta(self) -> tuple[int, int, str]: if self.direction: normalized = self.direction.strip().upper().replace(" ", "_") if normalized in DIRECTION_OFFSETS: dx, dy = DIRECTION_OFFSETS[normalized] return dx, dy, normalized raise ValueError( f"Invalid direction '{self.direction}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, W, E, NW, NE, SW, SE)" ) if self.dx is not None and self.dy is not None: if self.dx == 0 and self.dy == 0: raise ValueError("Movement delta (dx=0, dy=0) is not a valid move. Use pass if you wish to skip.") for name, (ox, oy) in DIRECTION_OFFSETS.items(): if ox == self.dx and oy == self.dy and "_" in name: return self.dx, self.dy, name for name, (ox, oy) in DIRECTION_OFFSETS.items(): if ox == self.dx and oy == self.dy: return self.dx, self.dy, name return self.dx, self.dy, f"CUSTOM({self.dx},{self.dy})" raise ValueError("Must provide either 'direction' or both 'dx' and 'dy'") class MoveCheckResult(BaseModel): direction: str dx: int dy: int target_x: int target_y: int available: bool reason: Optional[str] = None class AvailableMovesResponse(BaseModel): player_id: str player_name: str current_x: int current_y: int is_turn: bool is_party_leader: bool = False party_id: Optional[str] = None party_member_count: int = 1 current_turn_player_id: Optional[str] = None moves: Dict[str, MoveCheckResult] class PlayerCreate(BaseModel): name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name") @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: cleaned = v.strip() if not cleaned: raise ValueError("Player name cannot be empty or only whitespace") return cleaned @field_validator("color") @classmethod def validate_color(cls, v: str) -> str: cleaned = v.strip() if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", cleaned): return cleaned.upper() if re.match(r"^[a-zA-Z]{3,20}$", cleaned): return cleaned.lower() raise ValueError("Color must be a valid hex code (e.g. #FF4444) or color name (e.g. crimson)") class Player(BaseModel): id: str name: str color: str x: int y: int score: int = 0 party_id: Optional[str] = None is_party_leader: bool = False created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class Party(BaseModel): id: str name: str leader_id: str leader_name: str member_ids: List[str] created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class PartyInviteCreate(BaseModel): inviter_id: str invitee_id: str proposed_leader_id: str party_name: Optional[str] = None class PartyInvite(BaseModel): id: str inviter_id: str invitee_id: str proposed_leader_id: str party_id: Optional[str] = None status: str = "pending" # pending, accepted, rejected created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class PartyInviteResponse(BaseModel): accept: bool class PartyDirectForm(BaseModel): member_ids: List[str] = Field(..., min_length=2, description="List of player IDs to form a party") leader_id: str = Field(..., description="Agreed leader ID (must be one of the member_ids)") name: Optional[str] = None class PartyDefeatResult(BaseModel): party_id: str killed_leader_id: str killed_leader_name: str killed_leader_new_score: int killed_leader_respawn_position: Dict[str, int] new_leader_id: Optional[str] = None new_leader_name: Optional[str] = None remaining_members: List[str] = [] party_dissolved: bool = False class BattleRequest(BaseModel): challenger_id: str defender_id: str class BattleResult(BaseModel): winner_id: str winner_name: str loser_id: str loser_name: str defeat_details: PartyDefeatResult class BoardConfig(BaseModel): min_x: int = 0 max_x: int = 64 min_y: int = 0 max_y: int = 64 grid_cells_x: int = 64 grid_cells_y: int = 64 class TurnInfo(BaseModel): current_player_id: Optional[str] = None current_player_name: Optional[str] = None round_number: int = 1 turn_number: int = 0 turn_order: List[str] = [] class BoardState(BaseModel): config: BoardConfig player_count: int players: List[Player] parties: List[Party] = [] turn: TurnInfo class MoveResponse(BaseModel): success: bool player: Player direction: str party_moved: bool = False affected_players: List[Player] = [] previous_position: Dict[str, int] new_position: Dict[str, int] turn: TurnInfo class ApiResponse(BaseModel): success: bool message: str data: Optional[dict] = None