botWebWars/backend/app/models.py

237 lines
6.5 KiB
Python
Raw Normal View History

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
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.")
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
2026-09-05 21:20:39 +00:00
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
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] = []
class BoardState(BaseModel):
config: BoardConfig
player_count: int
players: List[Player]
parties: List[Party] = []
2026-09-05 21:20:39 +00:00
turn: TurnInfo
class MoveResponse(BaseModel):
success: bool
player: Player
direction: str
party_moved: bool = False
affected_players: List[Player] = []
2026-09-05 21:20:39 +00:00
previous_position: Dict[str, int]
new_position: Dict[str, int]
turn: TurnInfo
class ApiResponse(BaseModel):
success: bool
message: str
data: Optional[dict] = None