botWebWars/backend/app/models.py

365 lines
10 KiB
Python

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
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: int = Field(default=1, ge=1, description="Bot strength (default is 1)")
@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: int = 1
score: int = 0
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: int = 1
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: int
party1_score: int = 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: int
party2_score: int = 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: int
party2_total_score: int
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: int
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: int = 1
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):
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
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