botWebWars/backend/app/models.py

168 lines
4.8 KiB
Python

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.")
# Find matching direction name
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
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
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
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]
turn: TurnInfo
class MoveResponse(BaseModel):
success: bool
player: Player
direction: str
previous_position: Dict[str, int]
new_position: Dict[str, int]
turn: TurnInfo
class ApiResponse(BaseModel):
success: bool
message: str
data: Optional[dict] = None