From 9f2597d7f78d2d1795390bacc2ca4a10c3d3e842 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Wed, 9 Sep 2026 15:41:06 -0500 Subject: [PATCH] avatars instead of dots --- backend/app/game.py | 12 + backend/app/models.py | 2 + botagent/README.md | 25 +- botagent/bot_agent.py | 18 +- botagent_ai/bot.py | 12 +- botagent_gear/bot.py | 12 +- botagent_gear/diagram_agent_gear.md | 169 ++++++++ diagram_agent_gear.md | 169 ++++++++ frontend/src/App.tsx | 30 +- frontend/src/components/BoardCanvas.tsx | 91 +---- frontend/src/components/PlayerList.tsx | 26 +- frontend/src/components/RegisterModal.tsx | 188 ++++++--- frontend/src/components/ScoreboardModal.tsx | 52 ++- frontend/src/hooks/useGameSocket.ts | 9 +- frontend/src/types.ts | 1 + frontend/src/utils/pixelAvatars.tsx | 413 ++++++++++++++++++++ 16 files changed, 1052 insertions(+), 177 deletions(-) create mode 100644 botagent_gear/diagram_agent_gear.md create mode 100644 diagram_agent_gear.md create mode 100644 frontend/src/utils/pixelAvatars.tsx diff --git a/backend/app/game.py b/backend/app/game.py index db4b655..6ddff59 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -317,6 +317,17 @@ class GameEngine: player_id = f"player_{uuid.uuid4().hex[:8]}" spawn_x, spawn_y = self._find_random_free_position() + # Determine piece class (knight or warrior) + piece_type = getattr(player_in, "piece_type", None) + if not piece_type: + name_lower = player_in.name.lower() + if "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower: + piece_type = "warrior" + elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower: + piece_type = "knight" + else: + piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior" + player = Player( id=player_id, name=player_in.name, @@ -325,6 +336,7 @@ class GameEngine: score=0, x=spawn_x, y=spawn_y, + piece_type=piece_type, party_id=None, is_party_leader=False, visited_locations=[{"x": spawn_x, "y": spawn_y}], diff --git a/backend/app/models.py b/backend/app/models.py index 7ebf712..b03c720 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,6 +119,7 @@ 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)") + piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'") @field_validator("name") @classmethod @@ -147,6 +148,7 @@ class Player(BaseModel): y: int strength: float = 1.0 score: int = 0 + piece_type: Optional[str] = "knight" party_id: Optional[str] = None is_party_leader: bool = False visited_locations: List[Dict[str, int]] = Field(default_factory=list) diff --git a/botagent/README.md b/botagent/README.md index 499e334..fe8d829 100644 --- a/botagent/README.md +++ b/botagent/README.md @@ -1,5 +1,24 @@ +# Heuristic Bot Agent (`botagent`) -Example invokation +Autonomous rule-based bot agent for **botWebWars**. + +## Example Invocation + +```bash +# Default invocation +python3 bot_agent.py --name CyberKnight --color "#10b981" -s 3 + +# Explicitly choosing a Knight or Warrior board game piece class +python3 bot_agent.py --name IronPaladin --color "#38bdf8" -s 4 --piece-type knight +python3 bot_agent.py --name BloodAxe --color "#f43f5e" -s 3 --piece-type warrior ``` -$ python3 bot_agent.py --name Bill --color "#4455FF" -s 2 -``` \ No newline at end of file + +## Command-Line Arguments + +| Flag | Long Flag | Environment Variable | Default | Description | +|---|---|---|---|---| +| `-u` | `--url` | `BOT_SERVER_URL` | `http://localhost:8000/api` | Backend REST API base URL | +| `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot | +| `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar | +| `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) | +| | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` | \ No newline at end of file diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py index 7f887a5..192569b 100644 --- a/botagent/bot_agent.py +++ b/botagent/bot_agent.py @@ -35,10 +35,13 @@ class SmartBotAgent: color: str = DEFAULT_BOT_COLOR, strength: int = DEFAULT_BOT_STRENGTH, server_url: str = DEFAULT_SERVER_URL, + piece_type: Optional[str] = None, ): self.name = name self.color = color self.strength = strength + self.server_url = server_url + self.piece_type = piece_type self.base_url = normalize_url(server_url) self.bot_id: Optional[str] = None self.party_id: Optional[str] = None @@ -56,9 +59,13 @@ class SmartBotAgent: except Exception: pass + payload = {"name": self.name, "color": self.color, "strength": self.strength} + if self.piece_type: + payload["piece_type"] = self.piece_type + res = requests.post( f"{self.base_url}/players", - json={"name": self.name, "color": self.color, "strength": self.strength}, + json=payload, ) if res.status_code == 400 and "already registered" in res.text: players = requests.get(f"{self.base_url}/players").json() @@ -308,6 +315,7 @@ def main(): env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) + env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( description="Autonomous External Bot Agent for botWebWars", @@ -338,6 +346,13 @@ def main(): default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)", ) + parser.add_argument( + "--piece-type", + dest="piece_type", + choices=["knight", "warrior"], + default=env_piece_type, + help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)", + ) args = parser.parse_args() @@ -346,6 +361,7 @@ def main(): color=args.color, strength=args.strength, server_url=args.server_url, + piece_type=args.piece_type, ) agent.run() diff --git a/botagent_ai/bot.py b/botagent_ai/bot.py index ae16747..9e9d6a0 100644 --- a/botagent_ai/bot.py +++ b/botagent_ai/bot.py @@ -98,10 +98,12 @@ class AIBotAgent: server_url: str = DEFAULT_SERVER_URL, ollama_url: str = OLLAMA_BASE_URL, ollama_model: str = OLLAMA_MODEL, + piece_type: Optional[str] = None, ): self.name = name self.color = color self.strength = strength + self.piece_type = piece_type self.base_url = normalize_url(server_url) self.llm = OllamaClient(ollama_url, ollama_model) self.bot_id: Optional[str] = None @@ -123,9 +125,13 @@ class AIBotAgent: except Exception: pass + payload = {"name": self.name, "color": self.color, "strength": self.strength} + if self.piece_type: + payload["piece_type"] = self.piece_type + res = requests.post( f"{self.base_url}/players", - json={"name": self.name, "color": self.color, "strength": self.strength}, + json=payload, ) if res.status_code == 400 and "already registered" in res.text: players = requests.get(f"{self.base_url}/players").json() @@ -491,6 +497,7 @@ def main(): env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) + env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( description="LLM-driven Bot Agent for botWebWars (uses a local Ollama model for strategy)", @@ -504,6 +511,8 @@ def main(): help="Hex color code for the bot avatar (env: BOT_COLOR)") parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") + parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type, + help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)") parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL, help="Ollama base URL (env: OLLAMA_BASE_URL)") parser.add_argument("--ollama-model", dest="ollama_model", default=OLLAMA_MODEL, @@ -520,6 +529,7 @@ def main(): server_url=args.server_url, ollama_url=args.ollama_url, ollama_model=args.ollama_model, + piece_type=args.piece_type, ) agent.run() diff --git a/botagent_gear/bot.py b/botagent_gear/bot.py index f177849..bf55be7 100644 --- a/botagent_gear/bot.py +++ b/botagent_gear/bot.py @@ -279,10 +279,12 @@ class VertexAIBotAgent: location: str = DEFAULT_LOCATION, model: str = DEFAULT_MODEL, api_key: Optional[str] = None, + piece_type: Optional[str] = None, ): self.name = name self.color = color self.strength = strength + self.piece_type = piece_type self.base_url = normalize_url(server_url) self.llm = VertexGeminiClient( project_id=project_id, @@ -309,9 +311,13 @@ class VertexAIBotAgent: except Exception: pass + payload = {"name": self.name, "color": self.color, "strength": self.strength} + if self.piece_type: + payload["piece_type"] = self.piece_type + res = requests.post( f"{self.base_url}/players", - json={"name": self.name, "color": self.color, "strength": self.strength}, + json=payload, ) if res.status_code == 400 and "already registered" in res.text: players = requests.get(f"{self.base_url}/players").json() @@ -690,6 +696,7 @@ def main(): env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION env_model = os.environ.get("VERTEX_MODEL", DEFAULT_MODEL) env_api_key = os.environ.get("VERTEX_API_KEY") or os.environ.get("GEMINI_API_KEY") + env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( description="Vertex AI (Gemini) Bot Agent for botWebWars", @@ -711,6 +718,8 @@ def main(): help="Gemini model ID (env: VERTEX_MODEL)") parser.add_argument("-k", "--api-key", dest="api_key", default=env_api_key, help="Gemini API Key or Vertex AI express mode key (env: VERTEX_API_KEY or GEMINI_API_KEY)") + parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type, + help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)") args = parser.parse_args() @@ -730,6 +739,7 @@ def main(): location=args.location, model=args.model, api_key=args.api_key, + piece_type=args.piece_type, ) agent.run() diff --git a/botagent_gear/diagram_agent_gear.md b/botagent_gear/diagram_agent_gear.md new file mode 100644 index 0000000..7e72e20 --- /dev/null +++ b/botagent_gear/diagram_agent_gear.md @@ -0,0 +1,169 @@ +# botagent_gear Architecture & Workflow Diagrams + +This document outlines the system architecture, component relationships, and decision workflows of **`botagent_gear`**, the Google Cloud Vertex AI (Gemini) autonomous agent for **botWebWars**. + +--- + +## 1. System Architecture Diagram + +```mermaid +graph TB + subgraph HostEnv["Environment & Configuration"] + ENV["CLI Flags & Environment Variables
(BOT_SERVER_URL, VERTEX_MODEL, etc.)"] + AUTH_SRC["Auth Sources
(ADC / gcloud / Service Account / API Key)"] + end + + subgraph BotAgentGear["botagent_gear (bot.py)"] + subgraph ClientLayer["LLM & Authentication Subsystem"] + VGC["VertexGeminiClient
Token cache, auth check, endpoint routing"] + AUTH_RESOLV["Auth Resolver
google.auth / gcloud CLI / API Key"] + PROMPT_ENG["Prompt & Payload Builder
System Instructions + JSON Schema Mode"] + JSON_PARSE["extract_json
Markdown stripping & JSON validation"] + end + + subgraph CoreAgent["VertexAIBotAgent Subsystem"] + RUN_LOOP["Turn Polling Loop
GET /api/turn"] + PERCEPTION["Perception Engine
• Radar targets (distance, party, strength)
• Available moves & diagonal squeeze cost
• _build_local_map (17x17 ASCII minimap)"] + + subgraph StrategyEngine["Decision & Rules Engine"] + ENCOUNTER["Encounter Evaluator
_handle_adjacent_encounter"] + RULES_GUARD["Mandatory Rules Enforcer
(GAME_RULES.md: forced joins & battles)"] + LLM_ALLIANCE["Voluntary Alliance Reasoner
_decide_voluntary_alliance (Gemini)"] + LLM_NAV["Tactical Navigation Reasoner
_ask_llm_for_direction (Gemini)"] + FALLBACK["Guardrail / Fallback Pathing
Server recommended or nearest distance"] + end + + ACTIONS["Action Dispatcher
• Move bot (/move)
• Form party (/parties)
• Initiate battle (/battles/fight)
• Pass turn (/pass)"] + end + end + + subgraph ExternalBackends["External Services"] + SERVER["botWebWars Backend (FastAPI)
Port 8000 REST API"] + GEMINI["Google Vertex AI / AI Studio
gemini-2.5-flash / gemini-3.8-flash"] + end + + %% Wiring + ENV --> CoreAgent + AUTH_SRC --> AUTH_RESOLV + AUTH_RESOLV --> VGC + VGC --> PROMPT_ENG + PROMPT_ENG --> GEMINI + GEMINI --> JSON_PARSE + JSON_PARSE --> VGC + + RUN_LOOP --> SERVER + RUN_LOOP --> PERCEPTION + PERCEPTION --> SERVER + PERCEPTION --> StrategyEngine + + StrategyEngine --> VGC + LLM_ALLIANCE -.-> VGC + LLM_NAV -.-> VGC + + StrategyEngine --> ACTIONS + RULES_GUARD --> ACTIONS + FALLBACK --> ACTIONS + ACTIONS --> SERVER +``` + +--- + +## 2. Turn Execution & Tactical Decision Flow + +```mermaid +flowchart TD + Start(["Turn Polled (bot's turn)"]) --> Refresh["refresh_status & fetch Radar Data"] + Refresh --> CheckAdj{"Adjacent hostile / neutral
target within distance <= 1?"} + + %% Adjacent Target Path + CheckAdj -- Yes --> SoloCheck{"Is current bot Solo?"} + + SoloCheck -- "Yes (Solo)" --> TargetPartyCheck{"Is target in a Party?"} + TargetPartyCheck -- "No (Target is Solo)" --> GeminiAlliance["Query Gemini via ask_json
(Voluntary Alliance)"] + GeminiAlliance --> AllianceChoice{"Form Alliance?"} + AllianceChoice -- Yes --> FormParty["POST /api/parties
(Higher strength leads)"] + AllianceChoice -- No --> Pass1["POST /api/players/{id}/pass"] + + TargetPartyCheck -- "Yes (Target in Party)" --> CompareLeaderStr{"Bot Strength <= Target Leader Strength?"} + CompareLeaderStr -- Yes --> JoinSquad["Move into squad
(Voluntary absorption)"] + CompareLeaderStr -- No --> FightParty["POST /api/battles/fight
(Mandatory battle: refused weak leader)"] + + SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"} + LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass
(Follow leader command)"] + LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"} + PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight
(Mandatory squad battle)"] + PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"} + SoloTargetStr -- Yes --> AbsorbSolo["Step toward target
(Absorb solo follower)"] + SoloTargetStr -- No --> FightSolo["POST /api/battles/fight
(Mandatory battle: solo refused)"] + + %% Navigation Path + CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"] + FetchMoves --> GenMap["_build_local_map
(Generate 17x17 ASCII minimap)"] + GenMap --> AskNav["Query Gemini via ask_json
(Direction & Reasoning)"] + AskNav --> ValidChoice{"Chosen direction in legal moves?"} + ValidChoice -- Yes --> ExecMove["POST /api/players/{id}/move"] + ValidChoice -- No --> FallbackMove["Fallback: Server recommended or nearest distance"] + FallbackMove --> ExecMove + + %% Outcome + FormParty --> CheckEnd["Check Game Conclusion"] + Pass1 --> CheckEnd + JoinSquad --> CheckEnd + FightParty --> CheckEnd + PassFollower --> CheckEnd + FightHostile --> CheckEnd + AbsorbSolo --> CheckEnd + FightSolo --> CheckEnd + ExecMove --> CheckEnd + CheckEnd --> Done(["End of Turn"]) +``` + +--- + +## 3. Component Deep Dive + +### A. Authentication & LLM Client (`VertexGeminiClient`) +- **Module**: [`bot.py`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L82-L269) +- **Multi-Auth Strategy**: + 1. `google-auth` Python library using Application Default Credentials (ADC). + 2. `gcloud CLI` fallback via `gcloud auth print-access-token`. + 3. Direct OAuth token (`VERTEX_ACCESS_TOKEN` or `GOOGLE_OAUTH_ACCESS_TOKEN`). + 4. Service Account JSON key (`GOOGLE_APPLICATION_CREDENTIALS`). + 5. Direct Gemini API Key (`GEMINI_API_KEY` or `VERTEX_API_KEY`) routing to Google AI Studio. +- **Token Caching**: Access tokens are cached for up to 50 minutes with automatic invalidation and single-retry on HTTP 401. +- **Structured JSON Mode**: Uses `generationConfig.responseMimeType = "application/json"` with low temperature (`0.3`) for deterministic schema adherence. + +### B. Perception Engine & Spatial Representation +- **Minimap Generator** ([`_build_local_map`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L542-L574)): + - Generates a **17 × 17 ASCII grid** centered on the bot (`radius = 8`). + - Legend: `@` = self, `A` = ally, `E` = enemy/neutral, `M` = mountain, `F` = forest, `V` = valley, `#` = boundary, `.` = open terrain. +- **Radar & Move Analysis**: + - Consumes `/api/players/{id}/radar` for target distance and party alignment. + - Consumes `/api/players/{id}/available-moves` to account for diagonal obstacle squeeze penalties (`-0.1` solo, `-0.2` leader / `-0.1` follower). + +### C. Hybrid Decision & Rules Engine +- **Deterministic Rules Enforcer**: + - Implements canonical rules from [GAME_RULES.md](file:///home/isaac/Workspaces/botWebWars/GAME_RULES.md). + - Mandatory battles and joins bypass LLM invocation to guarantee engine compliance. +- **LLM Discretionary Invocations**: + - **Voluntary Alliances**: Solo bot meetings prompt Gemini to weigh party leadership vs. squad safety. + - **Tactical Navigation**: Coordinates obstacle avoidance, frontier exploration, and hostile avoidance using the local minimap and radar summary. + +--- + +## 4. Backend REST API Interactions + +| Method | Endpoint | Usage in `botagent_gear` | +|---|---|---| +| `POST` | `/api/players` | Register bot avatar or reconnect existing avatar | +| `GET` | `/api/players/{id}` | Refresh player health, score, and party leader status | +| `GET` | `/api/turn` | Turn polling loop to detect active player and round | +| `GET` | `/api/players/{id}/radar` | Scans surrounding bots and nearest target | +| `GET` | `/api/players/{id}/available-moves` | Evaluates passable directions & diagonal squeeze penalties | +| `GET` | `/api/board` | Full board snapshot for ASCII minimap generation | +| `POST` | `/api/players/{id}/move` | Execute cardinal / diagonal movement | +| `POST` | `/api/parties` | Establish voluntary alliance party | +| `POST` | `/api/battles/fight` | Initiate tactical 3-bout D20 confrontation | +| `POST` | `/api/players/{id}/pass` | Yield turn (follower wait or skipped turn) | +| `GET` | `/api/game/conclusion` | Check if single party remains (victory condition) | +| `DELETE` | `/api/players/{id}` | Clean disconnection on `SIGINT` / Ctrl+C | diff --git a/diagram_agent_gear.md b/diagram_agent_gear.md new file mode 100644 index 0000000..7e72e20 --- /dev/null +++ b/diagram_agent_gear.md @@ -0,0 +1,169 @@ +# botagent_gear Architecture & Workflow Diagrams + +This document outlines the system architecture, component relationships, and decision workflows of **`botagent_gear`**, the Google Cloud Vertex AI (Gemini) autonomous agent for **botWebWars**. + +--- + +## 1. System Architecture Diagram + +```mermaid +graph TB + subgraph HostEnv["Environment & Configuration"] + ENV["CLI Flags & Environment Variables
(BOT_SERVER_URL, VERTEX_MODEL, etc.)"] + AUTH_SRC["Auth Sources
(ADC / gcloud / Service Account / API Key)"] + end + + subgraph BotAgentGear["botagent_gear (bot.py)"] + subgraph ClientLayer["LLM & Authentication Subsystem"] + VGC["VertexGeminiClient
Token cache, auth check, endpoint routing"] + AUTH_RESOLV["Auth Resolver
google.auth / gcloud CLI / API Key"] + PROMPT_ENG["Prompt & Payload Builder
System Instructions + JSON Schema Mode"] + JSON_PARSE["extract_json
Markdown stripping & JSON validation"] + end + + subgraph CoreAgent["VertexAIBotAgent Subsystem"] + RUN_LOOP["Turn Polling Loop
GET /api/turn"] + PERCEPTION["Perception Engine
• Radar targets (distance, party, strength)
• Available moves & diagonal squeeze cost
• _build_local_map (17x17 ASCII minimap)"] + + subgraph StrategyEngine["Decision & Rules Engine"] + ENCOUNTER["Encounter Evaluator
_handle_adjacent_encounter"] + RULES_GUARD["Mandatory Rules Enforcer
(GAME_RULES.md: forced joins & battles)"] + LLM_ALLIANCE["Voluntary Alliance Reasoner
_decide_voluntary_alliance (Gemini)"] + LLM_NAV["Tactical Navigation Reasoner
_ask_llm_for_direction (Gemini)"] + FALLBACK["Guardrail / Fallback Pathing
Server recommended or nearest distance"] + end + + ACTIONS["Action Dispatcher
• Move bot (/move)
• Form party (/parties)
• Initiate battle (/battles/fight)
• Pass turn (/pass)"] + end + end + + subgraph ExternalBackends["External Services"] + SERVER["botWebWars Backend (FastAPI)
Port 8000 REST API"] + GEMINI["Google Vertex AI / AI Studio
gemini-2.5-flash / gemini-3.8-flash"] + end + + %% Wiring + ENV --> CoreAgent + AUTH_SRC --> AUTH_RESOLV + AUTH_RESOLV --> VGC + VGC --> PROMPT_ENG + PROMPT_ENG --> GEMINI + GEMINI --> JSON_PARSE + JSON_PARSE --> VGC + + RUN_LOOP --> SERVER + RUN_LOOP --> PERCEPTION + PERCEPTION --> SERVER + PERCEPTION --> StrategyEngine + + StrategyEngine --> VGC + LLM_ALLIANCE -.-> VGC + LLM_NAV -.-> VGC + + StrategyEngine --> ACTIONS + RULES_GUARD --> ACTIONS + FALLBACK --> ACTIONS + ACTIONS --> SERVER +``` + +--- + +## 2. Turn Execution & Tactical Decision Flow + +```mermaid +flowchart TD + Start(["Turn Polled (bot's turn)"]) --> Refresh["refresh_status & fetch Radar Data"] + Refresh --> CheckAdj{"Adjacent hostile / neutral
target within distance <= 1?"} + + %% Adjacent Target Path + CheckAdj -- Yes --> SoloCheck{"Is current bot Solo?"} + + SoloCheck -- "Yes (Solo)" --> TargetPartyCheck{"Is target in a Party?"} + TargetPartyCheck -- "No (Target is Solo)" --> GeminiAlliance["Query Gemini via ask_json
(Voluntary Alliance)"] + GeminiAlliance --> AllianceChoice{"Form Alliance?"} + AllianceChoice -- Yes --> FormParty["POST /api/parties
(Higher strength leads)"] + AllianceChoice -- No --> Pass1["POST /api/players/{id}/pass"] + + TargetPartyCheck -- "Yes (Target in Party)" --> CompareLeaderStr{"Bot Strength <= Target Leader Strength?"} + CompareLeaderStr -- Yes --> JoinSquad["Move into squad
(Voluntary absorption)"] + CompareLeaderStr -- No --> FightParty["POST /api/battles/fight
(Mandatory battle: refused weak leader)"] + + SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"} + LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass
(Follow leader command)"] + LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"} + PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight
(Mandatory squad battle)"] + PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"} + SoloTargetStr -- Yes --> AbsorbSolo["Step toward target
(Absorb solo follower)"] + SoloTargetStr -- No --> FightSolo["POST /api/battles/fight
(Mandatory battle: solo refused)"] + + %% Navigation Path + CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"] + FetchMoves --> GenMap["_build_local_map
(Generate 17x17 ASCII minimap)"] + GenMap --> AskNav["Query Gemini via ask_json
(Direction & Reasoning)"] + AskNav --> ValidChoice{"Chosen direction in legal moves?"} + ValidChoice -- Yes --> ExecMove["POST /api/players/{id}/move"] + ValidChoice -- No --> FallbackMove["Fallback: Server recommended or nearest distance"] + FallbackMove --> ExecMove + + %% Outcome + FormParty --> CheckEnd["Check Game Conclusion"] + Pass1 --> CheckEnd + JoinSquad --> CheckEnd + FightParty --> CheckEnd + PassFollower --> CheckEnd + FightHostile --> CheckEnd + AbsorbSolo --> CheckEnd + FightSolo --> CheckEnd + ExecMove --> CheckEnd + CheckEnd --> Done(["End of Turn"]) +``` + +--- + +## 3. Component Deep Dive + +### A. Authentication & LLM Client (`VertexGeminiClient`) +- **Module**: [`bot.py`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L82-L269) +- **Multi-Auth Strategy**: + 1. `google-auth` Python library using Application Default Credentials (ADC). + 2. `gcloud CLI` fallback via `gcloud auth print-access-token`. + 3. Direct OAuth token (`VERTEX_ACCESS_TOKEN` or `GOOGLE_OAUTH_ACCESS_TOKEN`). + 4. Service Account JSON key (`GOOGLE_APPLICATION_CREDENTIALS`). + 5. Direct Gemini API Key (`GEMINI_API_KEY` or `VERTEX_API_KEY`) routing to Google AI Studio. +- **Token Caching**: Access tokens are cached for up to 50 minutes with automatic invalidation and single-retry on HTTP 401. +- **Structured JSON Mode**: Uses `generationConfig.responseMimeType = "application/json"` with low temperature (`0.3`) for deterministic schema adherence. + +### B. Perception Engine & Spatial Representation +- **Minimap Generator** ([`_build_local_map`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L542-L574)): + - Generates a **17 × 17 ASCII grid** centered on the bot (`radius = 8`). + - Legend: `@` = self, `A` = ally, `E` = enemy/neutral, `M` = mountain, `F` = forest, `V` = valley, `#` = boundary, `.` = open terrain. +- **Radar & Move Analysis**: + - Consumes `/api/players/{id}/radar` for target distance and party alignment. + - Consumes `/api/players/{id}/available-moves` to account for diagonal obstacle squeeze penalties (`-0.1` solo, `-0.2` leader / `-0.1` follower). + +### C. Hybrid Decision & Rules Engine +- **Deterministic Rules Enforcer**: + - Implements canonical rules from [GAME_RULES.md](file:///home/isaac/Workspaces/botWebWars/GAME_RULES.md). + - Mandatory battles and joins bypass LLM invocation to guarantee engine compliance. +- **LLM Discretionary Invocations**: + - **Voluntary Alliances**: Solo bot meetings prompt Gemini to weigh party leadership vs. squad safety. + - **Tactical Navigation**: Coordinates obstacle avoidance, frontier exploration, and hostile avoidance using the local minimap and radar summary. + +--- + +## 4. Backend REST API Interactions + +| Method | Endpoint | Usage in `botagent_gear` | +|---|---|---| +| `POST` | `/api/players` | Register bot avatar or reconnect existing avatar | +| `GET` | `/api/players/{id}` | Refresh player health, score, and party leader status | +| `GET` | `/api/turn` | Turn polling loop to detect active player and round | +| `GET` | `/api/players/{id}/radar` | Scans surrounding bots and nearest target | +| `GET` | `/api/players/{id}/available-moves` | Evaluates passable directions & diagonal squeeze penalties | +| `GET` | `/api/board` | Full board snapshot for ASCII minimap generation | +| `POST` | `/api/players/{id}/move` | Execute cardinal / diagonal movement | +| `POST` | `/api/parties` | Establish voluntary alliance party | +| `POST` | `/api/battles/fight` | Initiate tactical 3-bout D20 confrontation | +| `POST` | `/api/players/{id}/pass` | Yield turn (follower wait or skipped turn) | +| `GET` | `/api/game/conclusion` | Check if single party remains (victory condition) | +| `DELETE` | `/api/players/{id}` | Clean disconnection on `SIGINT` / Ctrl+C | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25d4177..a5c6971 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,13 +9,15 @@ import { PartyModal } from './components/PartyModal'; import { BattleModal } from './components/BattleModal'; import { ScoreboardModal } from './components/ScoreboardModal'; -const BOT_PRESETS = [ - { name: 'AlphaBot', color: '#38bdf8', strength: 1 }, - { name: 'BetaTank', color: '#f43f5e', strength: 2 }, - { name: 'GammaStriker', color: '#a855f7', strength: 3 }, - { name: 'DeltaRanger', color: '#22c55e', strength: 1 }, - { name: 'OmegaTitan', color: '#eab308', strength: 4 }, - { name: 'SigmaScout', color: '#ec4899', strength: 1 }, +const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ + { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' }, + { name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' }, + { name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' }, + { name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' }, + { name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' }, + { name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior' }, + { name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight' }, + { name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior' }, ]; export function App() { @@ -62,18 +64,20 @@ export function App() { const handleQuickSpawn = async () => { const existingNames = new Set(boardState.players.map((p) => p.name)); const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name)); + const randomPiece: 'knight' | 'warrior' = Math.random() > 0.5 ? 'knight' : 'warrior'; const preset = availablePresets.length > 0 ? availablePresets[Math.floor(Math.random() * availablePresets.length)] : { - name: `Bot_${Math.floor(Math.random() * 1000)}`, + name: `${randomPiece === 'knight' ? 'Knight' : 'Warrior'}_${Math.floor(Math.random() * 1000)}`, color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, strength: Math.floor(Math.random() * 3) + 1, + piece_type: randomPiece, }; try { - const player = await registerPlayer(preset.name, preset.color, preset.strength); - showNotification(`Spawned ${player.name} (Str: ${player.strength}) at (${player.x}, ${player.y})`); + const player = await registerPlayer(preset.name, preset.color, preset.strength, preset.piece_type); + showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`); } catch (err: unknown) { if (err instanceof Error) { showNotification(`Failed to spawn bot: ${err.message}`); @@ -197,9 +201,9 @@ export function App() { setIsRegisterOpen(false)} - onRegister={async (name, color) => { - const player = await registerPlayer(name, color); - showNotification(`Deployed ${player.name} at (${player.x}, ${player.y})!`); + onRegister={async (name, color, pieceType) => { + const player = await registerPlayer(name, color, 1, pieceType); + showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) at (${player.x}, ${player.y})!`); }} /> diff --git a/frontend/src/components/BoardCanvas.tsx b/frontend/src/components/BoardCanvas.tsx index 4cb1045..d236133 100644 --- a/frontend/src/components/BoardCanvas.tsx +++ b/frontend/src/components/BoardCanvas.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import type { AvailableMovesResponse, BoardState, Player } from '../types'; +import { drawPlayerPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars'; interface BoardCanvasProps { boardState: BoardState; @@ -421,88 +422,14 @@ export const BoardCanvas: React.FC = ({ ctx.restore(); }); - // Draw Players / Bots + // Draw Players / Bots (Pixelated Board Game Knights and Warriors) boardState.players.forEach((player) => { const px = startX + (player.x - min_x) * cellSize; const py = startY + (player.y - min_y) * cellSize; const isSelected = selectedPlayer?.id === player.id; const isCurrentTurn = currentTurnId === player.id; - const isLeader = player.is_party_leader; - const radius = Math.max(cellSize * 0.42, 6); - // Turn indicator pulsating halo - if (isCurrentTurn) { - ctx.save(); - ctx.beginPath(); - ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(245, 158, 11, 0.25)'; - ctx.fill(); - ctx.restore(); - } - - // Selection ring - if (isSelected) { - ctx.save(); - ctx.beginPath(); - ctx.arc(px, py, radius * 1.8, 0, Math.PI * 2); - ctx.strokeStyle = '#38bdf8'; - ctx.lineWidth = 2; - ctx.setLineDash([3, 3]); - ctx.stroke(); - ctx.restore(); - } - - // Bot core body - ctx.save(); - ctx.beginPath(); - ctx.arc(px, py, radius, 0, Math.PI * 2); - ctx.fillStyle = player.color; - ctx.shadowColor = player.color; - ctx.shadowBlur = 10; - ctx.fill(); - - // Bot border - ctx.lineWidth = isLeader ? 2.5 : 1.5; - ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff'; - ctx.stroke(); - ctx.restore(); - - // Leader Crown / Star emblem - if (isLeader) { - ctx.save(); - ctx.fillStyle = '#fbbf24'; - ctx.font = `${Math.max(radius * 0.9, 10)}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('👑', px, py - radius - 5); - ctx.restore(); - } - - // Bot Name and Strength Label - if (cellSize >= 16 || isSelected || isCurrentTurn) { - ctx.save(); - ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.45))}px Inter, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const text = `${player.name} [⚡${player.strength.toFixed(1)}]`; - const textMetrics = ctx.measureText(text); - const bgWidth = textMetrics.width + 12; - const bgHeight = 16; - const labelY = py - radius - 8; - - ctx.fillStyle = 'rgba(15, 23, 42, 0.9)'; - ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc'; - ctx.fillText(text, px, labelY); - ctx.restore(); - } + drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn); }); ctx.restore(); // end clip @@ -713,11 +640,13 @@ export const BoardCanvas: React.FC = ({ {/* Selected Player Overlay card */} {selectedPlayer && (
-
- {selectedPlayer.name.slice(0, 2).toUpperCase()} +
+
diff --git a/frontend/src/components/PlayerList.tsx b/frontend/src/components/PlayerList.tsx index 7d9c755..e480bfb 100644 --- a/frontend/src/components/PlayerList.tsx +++ b/frontend/src/components/PlayerList.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { BoardState, Player } from '../types'; +import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars'; interface PlayerListProps { boardState: BoardState; @@ -129,19 +130,28 @@ export const PlayerList: React.FC = ({ }`} >
-
+
- {player.name.slice(0, 2).toUpperCase()} +
{isLeader && ( diff --git a/frontend/src/components/RegisterModal.tsx b/frontend/src/components/RegisterModal.tsx index a1edfd5..66f6d4f 100644 --- a/frontend/src/components/RegisterModal.tsx +++ b/frontend/src/components/RegisterModal.tsx @@ -1,35 +1,38 @@ import React, { useState } from 'react'; +import { PixelAvatar, type PieceType } from '../utils/pixelAvatars'; -const PRESET_COLORS = [ - '#38BDF8', // Sky Blue - '#F43F5E', // Rose / Red - '#10B981', // Emerald Green - '#F59E0B', // Amber - '#A855F7', // Purple - '#EC4899', // Pink - '#06B6D4', // Cyan - '#EAB308', // Yellow - '#6366F1', // Indigo - '#14B8A6', // Teal +export const PRESET_FACTIONS = [ + { color: '#38BDF8', name: 'Azure Order' }, + { color: '#F43F5E', name: 'Crimson Legion' }, + { color: '#10B981', name: 'Emerald Wardens' }, + { color: '#F59E0B', name: 'Golden Templars' }, + { color: '#A855F7', name: 'Amethyst Guard' }, + { color: '#EC4899', name: 'Roseblade Order' }, + { color: '#06B6D4', name: 'Frostguard' }, + { color: '#EAB308', name: 'Solar Vanguard' }, + { color: '#6366F1', name: 'Twilight Sentinels' }, + { color: '#14B8A6', name: 'Jade Protectors' }, ]; const RANDOM_NAMES = [ - 'CyberViper', - 'NexusBot', - 'PulseGhost', - 'ApexVector', - 'IronShard', - 'NovaMatrix', - 'QuantumGlitch', - 'EchoZero', - 'TitanByte', - 'ShadowCircuit', + 'AzureKnight', + 'CrimsonWarrior', + 'StormPaladin', + 'IronBerserker', + 'ShadowKnight', + 'ApexGladiator', + 'FrostChampion', + 'ThunderWarden', + 'TitanKnight', + 'ViperWarrior', + 'SolarTemplar', + 'EmeraldWarden', ]; interface RegisterModalProps { isOpen: boolean; onClose: () => void; - onRegister: (name: string, color: string) => Promise; + onRegister: (name: string, color: string, pieceType?: PieceType) => Promise; } export const RegisterModal: React.FC = ({ @@ -39,16 +42,26 @@ export const RegisterModal: React.FC = ({ }) => { const [name, setName] = useState(''); const [color, setColor] = useState('#38BDF8'); + const [pieceType, setPieceType] = useState('knight'); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); if (!isOpen) return null; + const currentFaction = PRESET_FACTIONS.find( + (f) => f.color.toUpperCase() === color.toUpperCase() + ); + const handleRandomize = () => { - const randomName = RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100); - const randomColor = PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]; + const randomName = + RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] + + '_' + + Math.floor(Math.random() * 900 + 100); + const randomFaction = PRESET_FACTIONS[Math.floor(Math.random() * PRESET_FACTIONS.length)]; + const randomType: PieceType = Math.random() > 0.5 ? 'knight' : 'warrior'; setName(randomName); - setColor(randomColor); + setColor(randomFaction.color); + setPieceType(randomType); }; const handleSubmit = async (e: React.FormEvent) => { @@ -61,7 +74,7 @@ export const RegisterModal: React.FC = ({ try { setIsSubmitting(true); setError(null); - await onRegister(name.trim(), color); + await onRegister(name.trim(), color, pieceType); onClose(); setName(''); } catch (err: unknown) { @@ -76,16 +89,16 @@ export const RegisterModal: React.FC = ({ }; return ( -
+

- Register Player Avatar + Register Board Game Piece

- Enter arena coordinates between (0,0) and (64,64) + Choose your Knight or Warrior miniature & faction colors

)} -
- {/* Avatar Preview */} -
+ + {/* Miniature Preview */} +
-
-
+ +
+
+
+ {name.trim() ? name : 'Miniature Preview'} +
+
+ + {pieceType === 'knight' ? '⚔️ Knight Piece' : '🪓 Warrior Piece'} + + {currentFaction && ( + <> + + {currentFaction.name} + + )}
- - {name.trim() ? name : 'Avatar Preview'} - +
+
+ + {/* Piece Class Selector (Knight vs Warrior) */} +
+ +
+ + +
{/* Player Name */}
- +
- {/* Color Chooser */} + {/* Color & Faction Chooser */}
- +
+ + {currentFaction && ( + + {currentFaction.name} + + )} +
- {PRESET_COLORS.map((preset) => ( + {PRESET_FACTIONS.map((preset) => ( ))}
diff --git a/frontend/src/components/ScoreboardModal.tsx b/frontend/src/components/ScoreboardModal.tsx index 1232745..dceb582 100644 --- a/frontend/src/components/ScoreboardModal.tsx +++ b/frontend/src/components/ScoreboardModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react'; import type { GameConclusion, Player } from '../types'; +import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars'; interface ScoreboardModalProps { conclusion: GameConclusion | null; @@ -210,10 +211,19 @@ export const ScoreboardModal: React.FC = ({
- {rankedPlayers[1].name.substring(0, 2).toUpperCase()} +
{rankedPlayers[1].name}
{rankedPlayers[1].score} pts
@@ -232,12 +242,20 @@ export const ScoreboardModal: React.FC = ({
- {rankedPlayers[0].name.substring(0, 2).toUpperCase()} +
{rankedPlayers[0].name}
@@ -258,10 +276,19 @@ export const ScoreboardModal: React.FC = ({
- {rankedPlayers[2].name.substring(0, 2).toUpperCase()} +
{rankedPlayers[2].name}
{rankedPlayers[2].score} pts
@@ -308,11 +335,12 @@ export const ScoreboardModal: React.FC = ({ {rankBadge} - {player.name} {isLeader && 👑 Leader} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index cd192b2..cd57576 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -269,11 +269,16 @@ export function useGameSocket() { } }, [selectedPlayer, fetchAvailableMoves]); - const registerPlayer = async (name: string, color: string, strength: number = 1): Promise => { + const registerPlayer = async ( + name: string, + color: string, + strength: number = 1, + piece_type?: 'knight' | 'warrior' + ): Promise => { const res = await fetch('/api/players', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, color, strength }), + body: JSON.stringify({ name, color, strength, piece_type }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2dc0b00..00afbc2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,7 @@ export interface Player { y: number; strength: number; score: number; + piece_type?: 'knight' | 'warrior'; party_id?: string | null; is_party_leader: boolean; visited_locations?: { x: number; y: number }[]; diff --git a/frontend/src/utils/pixelAvatars.tsx b/frontend/src/utils/pixelAvatars.tsx new file mode 100644 index 0000000..408506a --- /dev/null +++ b/frontend/src/utils/pixelAvatars.tsx @@ -0,0 +1,413 @@ +import React from 'react'; +import type { Player } from '../types'; + +export type PieceType = 'knight' | 'warrior'; + +// Hex color parser and manipulator +function parseHex(hex: string): [number, number, number] { + let c = hex.replace('#', '').trim(); + if (c.length === 3) { + c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2]; + } + const num = parseInt(c, 16); + if (isNaN(num)) { + return [56, 189, 248]; // default sky blue + } + return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; +} + +function rgbToHex(r: number, g: number, b: number): string { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + return '#' + [clamp(r), clamp(g), clamp(b)].map((x) => x.toString(16).padStart(2, '0')).join(''); +} + +function adjustColor(hex: string, percent: number): string { + const [r, g, b] = parseHex(hex); + if (percent > 0) { + return rgbToHex( + r + (255 - r) * percent, + g + (255 - g) * percent, + b + (255 - b) * percent + ); + } else { + const factor = 1 + percent; + return rgbToHex(r * factor, g * factor, b * factor); + } +} + +// 16x16 Pixel Sprite Matrices +// Characters: +// . : transparent +// _ : miniature drop shadow +// K : dark iron armor outline (#0f172a) +// C : faction color main +// L : faction color light (+35%) +// D : faction color dark (-30%) +// M : polished plate steel (#94a3b8) +// m : steel highlight (#e2e8f0) +// S : steel shadow (#475569) +// G : gold main (#eab308) +// g : gold highlight (#fef08a) +// d : gold shadow (#a16207) +// W : white shine/glint (#ffffff) +// H : brown wood/leather haft (#78350f) +// B : miniature pedestal top (#334155) +// b : miniature pedestal rim (#1e293b) +// R : ruby gem or glowing battle eye (#ef4444) + +const KNIGHT_SPRITE: string[] = [ + '......LCD.......', // Row 0: Plume crest tip in faction color + '.....LCCD.......', // Row 1: Plume feather body + '....KmMSK.......', // Row 2: Steel knight helm apex + '...KmMSSSK......', // Row 3: Helm brow + '...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip + '...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade + '.LCKMmMSKK...Mm.', // Row 6: Paired pauldron + breastplate + blade + 'LCCKmSSKGGGGGMm.', // Row 7: Shield top in color + sword crossguard + 'LCCKMSKK.KHK....', // Row 8: Shield face + sword hilt + 'DCDKMSKK.KGK....', // Row 9: Shield face + golden pommel + '.DDK.SS.K.......', // Row 10: Shield tip + armored legs + '..K..SS..K......', // Row 11: Iron sabatons + '...KKKKKKKKK....', // Row 12: Pedestal top bevel + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim + '..____________..', // Row 15: Miniature drop shadow +]; + +const LEADER_KNIGHT_SPRITE: string[] = [ + '....g.g.g.......', // Row 0: 3-point Golden Royal Crown + '....GgGgG.......', // Row 1: Crown band with jewels + '....KGRGK.......', // Row 2: Crown base with Ruby gem + '...KmMSSSK......', // Row 3: Knight visor brow + '...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip + '...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade + '.GGKMmMSKK...Mm.', // Row 6: Golden cape clasps + breastplate + blade + 'GLCKmSSKGGGGGMm.', // Row 7: Gold-trimmed Shield + sword crossguard + 'GCCKMSKK.KHK....', // Row 8: Shield in faction color + sword hilt + 'GDDKMSKK.KGK....', // Row 9: Shield in faction color + gold pommel + '.GGK.SS.K.......', // Row 10: Golden shield tip + armored legs + '..K..SS..K......', // Row 11: Iron sabatons + '...KGGGGGGGK....', // Row 12: Golden pedestal top + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim + '..____________..', // Row 15: Miniature drop shadow +]; + +const WARRIOR_SPRITE: string[] = [ + '.mW..........Wm.', // Row 0: Curved battle horns + '..Mm...KK...mM..', // Row 1: Horn bodies + helmet crest + '...MKKmMSKKM....', // Row 2: Horn bases on Spiked Iron Helm + '.Wm.KMMSSK.mW...', // Row 3: Dual battle-axe blades + brow + 'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes + 'WMMMKSSSKMMMW...', // Row 5: Full axe blades + lower iron plate + '.KMMKMmMSKMMK...', // Row 6: Axe blade curves + iron breastplate + '..KKKKHHKKKK....', // Row 7: Axe haft collar + spiked pauldrons + '...KLCCCCDK.....', // Row 8: Warrior war-tunic in faction color + '...KCCCCCCK.....', // Row 9: Faction color tunic + '...KGKKKKGK.....', // Row 10: Spiked warrior war belt with gold rivets + '...KHS..SHK.....', // Row 11: Leather greaves & combat boots + '...KKKKKKKKK....', // Row 12: Pedestal top bevel + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim + '..____________..', // Row 15: Miniature drop shadow +]; + +const LEADER_WARRIOR_SPRITE: string[] = [ + '.g.g.g....g.g.g.', // Row 0: Golden crown horn tips + '..GgG..KK..GgG..', // Row 1: Golden horn bodies + '...GKKmMSKKG....', // Row 2: Golden horn bases on helmet + '.Wm.KMMSSK.mW...', // Row 3: Battle-axe blades + brow + 'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes + 'WMMMKSSSKMMMW...', // Row 5: Axe blades + lower helm + '.KMMKMmMSKMMK...', // Row 6: Axe blade curves + breastplate + '..KKKKHHKKKK....', // Row 7: Pauldrons + haft collar + '...KLCCCCDK.....', // Row 8: Tunic in faction color + '...KCCCCCCK.....', // Row 9: Faction color tunic + '...GGGGGGGG.....', // Row 10: Golden warrior war belt + '...KHS..SHK.....', // Row 11: Leather boots + '...KGGGGGGGK....', // Row 12: Golden pedestal top + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim + '..____________..', // Row 15: Miniature drop shadow +]; + +// Palette generation +function getPalette(color: string): Record { + const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`; + const cLight = adjustColor(cMain, 0.40); + const cDark = adjustColor(cMain, -0.32); + + return { + '.': '', // transparent + '_': 'rgba(0, 0, 0, 0.45)', // drop shadow + 'K': '#0f172a', // dark outline + 'C': cMain, + 'L': cLight, + 'D': cDark, + 'M': '#94a3b8', // plate steel + 'm': '#e2e8f0', // steel highlight + 'S': '#475569', // steel shadow + 'G': '#eab308', // gold main + 'g': '#fef08a', // gold light + 'd': '#a16207', // gold dark + 'W': '#ffffff', // white glint + 'H': '#78350f', // wood / leather + 'h': '#451a03', // wood dark + 'B': '#334155', // pedestal stone + 'b': '#1e293b', // pedestal rim + 'R': '#ef4444', // ruby / battle eyes + }; +} + +// In-memory cache for pre-rendered 16x16 canvases and data URLs +const spriteCanvasCache = new Map(); +const spriteDataUrlCache = new Map(); + +export function getPlayerPieceType(player: { + name: string; + id?: string; + piece_type?: string; + is_party_leader?: boolean; + party_id?: string | null; +}): PieceType { + // Party leaders are always Knights commanding the squad + if (player.is_party_leader) { + return 'knight'; + } + // Party squad followers are loyal Warriors + if (player.party_id && !player.is_party_leader) { + return 'warrior'; + } + // Explicit piece type if present + if (player.piece_type === 'warrior' || player.piece_type === 'knight') { + return player.piece_type; + } + // Check name indicators + const lower = player.name.toLowerCase(); + if ( + lower.includes('knight') || + lower.includes('tank') || + lower.includes('titan') || + lower.includes('paladin') || + lower.includes('gear') || + lower.includes('iron') || + lower.includes('guard') + ) { + return 'knight'; + } + if ( + lower.includes('warrior') || + lower.includes('viper') || + lower.includes('striker') || + lower.includes('scout') || + lower.includes('ranger') || + lower.includes('axe') || + lower.includes('blade') + ) { + return 'warrior'; + } + // Deterministic stable hash for variety + let hash = 0; + const str = player.id || player.name; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash) % 2 === 0 ? 'knight' : 'warrior'; +} + +export function getSpriteCanvas( + pieceType: PieceType, + color: string, + isLeader: boolean = false +): HTMLCanvasElement { + const normColor = (color || '#38bdf8').toLowerCase(); + const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`; + + const cached = spriteCanvasCache.get(cacheKey); + if (cached) { + return cached; + } + + const canvas = document.createElement('canvas'); + canvas.width = 16; + canvas.height = 16; + const ctx = canvas.getContext('2d'); + + if (ctx) { + let spriteMatrix: string[]; + if (pieceType === 'knight') { + spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE; + } else { + spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; + } + + const palette = getPalette(normColor); + + for (let y = 0; y < 16; y++) { + const row = spriteMatrix[y] || '................'; + for (let x = 0; x < 16; x++) { + const char = row[x] || '.'; + const col = palette[char]; + if (col) { + ctx.fillStyle = col; + ctx.fillRect(x, y, 1, 1); + } + } + } + } + + spriteCanvasCache.set(cacheKey, canvas); + return canvas; +} + +export function getSpriteDataUrl( + pieceType: PieceType, + color: string, + isLeader: boolean = false +): string { + const normColor = (color || '#38bdf8').toLowerCase(); + const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`; + + const cached = spriteDataUrlCache.get(cacheKey); + if (cached) { + return cached; + } + + const canvas = getSpriteCanvas(pieceType, normColor, isLeader); + const dataUrl = canvas.toDataURL('image/png'); + spriteDataUrlCache.set(cacheKey, dataUrl); + return dataUrl; +} + +// Canvas Drawing Helper for BoardCanvas +export function drawPlayerPiece( + ctx: CanvasRenderingContext2D, + player: Player, + px: number, + py: number, + cellSize: number, + isSelected: boolean, + isCurrentTurn: boolean +): void { + const pieceType = getPlayerPieceType(player); + const isLeader = player.is_party_leader; + const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader); + + // Scaled miniature size: board game miniature looks best at ~1.35x cellSize + const spriteSize = Math.max(cellSize * 1.35, 14); + const destX = Math.round(px - spriteSize / 2); + // Center the pedestal base right on (px, py) + const destY = Math.round(py - spriteSize * 0.62); + + ctx.save(); + + // Active turn indicator glowing ring around the pedestal + if (isCurrentTurn) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.55, spriteSize * 0.28, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(245, 158, 11, 0.28)'; + ctx.fill(); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 1.8; + ctx.shadowColor = '#f59e0b'; + ctx.shadowBlur = 8; + ctx.stroke(); + ctx.restore(); + } + + // Selected player dashed ring around the pedestal + if (isSelected) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.62, spriteSize * 0.32, 0, 0, Math.PI * 2); + ctx.strokeStyle = '#38bdf8'; + ctx.lineWidth = 2; + ctx.setLineDash([3, 3]); + ctx.shadowColor = '#38bdf8'; + ctx.shadowBlur = 6; + ctx.stroke(); + ctx.restore(); + } + + // Draw the crisp pixelated Knight or Warrior figurine + ctx.imageSmoothingEnabled = false; + ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize); + + // Crown symbol above party leader + if (isLeader) { + ctx.save(); + ctx.fillStyle = '#fbbf24'; + ctx.font = `${Math.max(spriteSize * 0.45, 10)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('👑', px, destY - 4); + ctx.restore(); + } + + // Player Name and Strength Badge (Title Bar) + ctx.save(); + const isHighlighted = isCurrentTurn || isSelected; + ctx.globalAlpha = isHighlighted ? 1.0 : 0.5; + + ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; + const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 12; + const bgHeight = 16; + const labelY = isLeader ? destY - 14 : destY - 8; + + ctx.fillStyle = 'rgba(15, 23, 42, 0.92)'; + ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc'; + ctx.fillText(text, px, labelY - 4); + ctx.restore(); + + ctx.restore(); +} + +// React Component for displaying pixel avatar in UI +interface PixelAvatarProps { + pieceType: PieceType; + color: string; + isLeader?: boolean; + size?: number; + className?: string; + title?: string; +} + +export const PixelAvatar: React.FC = ({ + pieceType, + color, + isLeader = false, + size = 32, + className = '', + title, +}) => { + const dataUrl = getSpriteDataUrl(pieceType, color, isLeader); + + return ( + {`${isLeader + ); +};