import re from datetime import datetime, timezone from enum import Enum from typing import Any, 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 strength_penalty: float = 0.0 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] # ========================================== # Impassable Obstacle Models (Mountains & Valleys) # ========================================== class ObstacleType(str, Enum): MOUNTAIN = "mountain" FOREST = "forest" VALLEY = "valley" class Obstacle(BaseModel): x: int y: int type: str = "mountain" # "mountain" or "valley" 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") strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)") piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'") @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 strength: float = 1.0 score: int = 0 piece_type: Optional[str] = "knight" party_id: Optional[str] = None is_party_leader: bool = False visited_locations: List[Dict[str, int]] = Field(default_factory=list) 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] total_strength: float = 1.0 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 # ========================================== # 3-Bout D20 Battle Models # ========================================== class BattleBout(BaseModel): bout_number: int party1_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 1") party1_strength: float party1_score: float = Field(..., description="party1_strength * party1_roll") party2_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 2") party2_strength: float party2_score: float = Field(..., description="party2_strength * party2_roll") winner_name: Optional[str] = None class BattleResult(BaseModel): bouts: List[BattleBout] party1_name: str party2_name: str party1_bouts_won: int party2_bouts_won: int party1_total_score: float party2_total_score: float winner_party_id: str winner_party_name: str winner_leader_id: str winner_leader_name: str defeated_party_id: str defeated_party_name: str killed_leader_id: str killed_leader_name: str killed_leader_new_score: int killed_leader_respawn_position: Dict[str, int] absorbed_members: List[str] = Field( default_factory=list, description="Remaining bots from the defeated party that merged into the winning party", ) new_party_size: int new_party_strength: float class BattleRequest(BaseModel): challenger_id: str defender_id: str # ========================================== # Memory & Radar Models # ========================================== class BotMemoryResponse(BaseModel): player_id: str player_name: str current_x: int current_y: int visited_count: int visited_history: List[Dict[str, int]] has_visited_current: bool = True class RadarTarget(BaseModel): id: str name: str color: str x: int y: int strength: float = 1.0 distance: int party_id: Optional[str] = None party_name: Optional[str] = None is_ally: bool is_enemy: bool can_recruit: bool = False can_battle: bool = False class BotRadarResponse(BaseModel): player_id: str current_x: int current_y: int bot_goal: str # "form_party" or "find_and_defeat_all_parties" targets: List[RadarTarget] nearest_target: Optional[RadarTarget] = None recommended_direction: Optional[str] = None recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited" 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): game_started: bool = False current_player_id: Optional[str] = None current_player_name: Optional[str] = None round_number: int = 1 turn_number: int = 0 turn_order: List[str] = [] # ========================================== # Game Conclusion & Scoreboard Models # ========================================== class GameConclusion(BaseModel): concluded: bool = False winning_party_id: Optional[str] = None winning_party_name: Optional[str] = None winning_leader_id: Optional[str] = None winning_leader_name: Optional[str] = None total_bots: int = 0 rankings: List[Player] = [] class BoardState(BaseModel): config: BoardConfig player_count: int players: List[Player] parties: List[Party] = [] obstacles: List[Obstacle] = Field(default_factory=list) turn: TurnInfo game_started: bool = False conclusion: Optional[GameConclusion] = None 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] party_formed_triggered: bool = False formed_party: Optional[Party] = None battle_triggered: bool = False battle_result: Optional[BattleResult] = None game_concluded: Optional[GameConclusion] = None turn: TurnInfo class AiStepResponse(BaseModel): action_taken: str # "formed_party", "battled", "moved", "passed" player_id: str player_name: str bot_goal: str # "form_party" or "find_and_defeat_all_parties" direction: Optional[str] = None move_result: Optional[MoveResponse] = None formed_party: Optional[Party] = None battle_result: Optional[BattleResult] = None game_concluded: Optional[GameConclusion] = None turn: TurnInfo class ApiResponse(BaseModel): success: bool message: str data: Optional[Dict[str, Any]] = None