2026-09-05 21:10:18 +00:00
|
|
|
import re
|
|
|
|
|
from datetime import datetime, timezone
|
2026-09-05 21:20:39 +00:00
|
|
|
from enum import Enum
|
|
|
|
|
from typing import Dict, List, Optional
|
2026-09-05 21:10:18 +00:00
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
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] = []
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
class BoardState(BaseModel):
|
|
|
|
|
config: BoardConfig
|
|
|
|
|
player_count: int
|
|
|
|
|
players: List[Player]
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: TurnInfo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MoveResponse(BaseModel):
|
|
|
|
|
success: bool
|
|
|
|
|
player: Player
|
|
|
|
|
direction: str
|
|
|
|
|
previous_position: Dict[str, int]
|
|
|
|
|
new_position: Dict[str, int]
|
|
|
|
|
turn: TurnInfo
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ApiResponse(BaseModel):
|
|
|
|
|
success: bool
|
|
|
|
|
message: str
|
|
|
|
|
data: Optional[dict] = None
|