368 lines
10 KiB
Python
368 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
|
|
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)")
|
|
|
|
@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
|
|
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
|