avatars instead of dots

This commit is contained in:
Isaac Johnson 2026-09-09 15:41:06 -05:00
parent 7a233afe3a
commit 9f2597d7f7
16 changed files with 1052 additions and 177 deletions

View File

@ -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}],

View File

@ -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)

View File

@ -1,5 +1,24 @@
# Heuristic Bot Agent (`botagent`)
Example invokation
```
$ python3 bot_agent.py --name Bill --color "#4455FF" -s 2
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
```
## 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` |

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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<br/>(BOT_SERVER_URL, VERTEX_MODEL, etc.)"]
AUTH_SRC["Auth Sources<br/>(ADC / gcloud / Service Account / API Key)"]
end
subgraph BotAgentGear["botagent_gear (bot.py)"]
subgraph ClientLayer["LLM & Authentication Subsystem"]
VGC["VertexGeminiClient<br/>Token cache, auth check, endpoint routing"]
AUTH_RESOLV["Auth Resolver<br/>google.auth / gcloud CLI / API Key"]
PROMPT_ENG["Prompt & Payload Builder<br/>System Instructions + JSON Schema Mode"]
JSON_PARSE["extract_json<br/>Markdown stripping & JSON validation"]
end
subgraph CoreAgent["VertexAIBotAgent Subsystem"]
RUN_LOOP["Turn Polling Loop<br/>GET /api/turn"]
PERCEPTION["Perception Engine<br/>• Radar targets (distance, party, strength)<br/>• Available moves & diagonal squeeze cost<br/>• _build_local_map (17x17 ASCII minimap)"]
subgraph StrategyEngine["Decision & Rules Engine"]
ENCOUNTER["Encounter Evaluator<br/>_handle_adjacent_encounter"]
RULES_GUARD["Mandatory Rules Enforcer<br/>(GAME_RULES.md: forced joins & battles)"]
LLM_ALLIANCE["Voluntary Alliance Reasoner<br/>_decide_voluntary_alliance (Gemini)"]
LLM_NAV["Tactical Navigation Reasoner<br/>_ask_llm_for_direction (Gemini)"]
FALLBACK["Guardrail / Fallback Pathing<br/>Server recommended or nearest distance"]
end
ACTIONS["Action Dispatcher<br/>• Move bot (/move)<br/>• Form party (/parties)<br/>• Initiate battle (/battles/fight)<br/>• Pass turn (/pass)"]
end
end
subgraph ExternalBackends["External Services"]
SERVER["botWebWars Backend (FastAPI)<br/>Port 8000 REST API"]
GEMINI["Google Vertex AI / AI Studio<br/>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<br/>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<br/>(Voluntary Alliance)"]
GeminiAlliance --> AllianceChoice{"Form Alliance?"}
AllianceChoice -- Yes --> FormParty["POST /api/parties<br/>(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<br/>(Voluntary absorption)"]
CompareLeaderStr -- No --> FightParty["POST /api/battles/fight<br/>(Mandatory battle: refused weak leader)"]
SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"}
LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass<br/>(Follow leader command)"]
LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"}
PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight<br/>(Mandatory squad battle)"]
PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"}
SoloTargetStr -- Yes --> AbsorbSolo["Step toward target<br/>(Absorb solo follower)"]
SoloTargetStr -- No --> FightSolo["POST /api/battles/fight<br/>(Mandatory battle: solo refused)"]
%% Navigation Path
CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"]
FetchMoves --> GenMap["_build_local_map<br/>(Generate 17x17 ASCII minimap)"]
GenMap --> AskNav["Query Gemini via ask_json<br/>(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 |

169
diagram_agent_gear.md Normal file
View File

@ -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<br/>(BOT_SERVER_URL, VERTEX_MODEL, etc.)"]
AUTH_SRC["Auth Sources<br/>(ADC / gcloud / Service Account / API Key)"]
end
subgraph BotAgentGear["botagent_gear (bot.py)"]
subgraph ClientLayer["LLM & Authentication Subsystem"]
VGC["VertexGeminiClient<br/>Token cache, auth check, endpoint routing"]
AUTH_RESOLV["Auth Resolver<br/>google.auth / gcloud CLI / API Key"]
PROMPT_ENG["Prompt & Payload Builder<br/>System Instructions + JSON Schema Mode"]
JSON_PARSE["extract_json<br/>Markdown stripping & JSON validation"]
end
subgraph CoreAgent["VertexAIBotAgent Subsystem"]
RUN_LOOP["Turn Polling Loop<br/>GET /api/turn"]
PERCEPTION["Perception Engine<br/>• Radar targets (distance, party, strength)<br/>• Available moves & diagonal squeeze cost<br/>• _build_local_map (17x17 ASCII minimap)"]
subgraph StrategyEngine["Decision & Rules Engine"]
ENCOUNTER["Encounter Evaluator<br/>_handle_adjacent_encounter"]
RULES_GUARD["Mandatory Rules Enforcer<br/>(GAME_RULES.md: forced joins & battles)"]
LLM_ALLIANCE["Voluntary Alliance Reasoner<br/>_decide_voluntary_alliance (Gemini)"]
LLM_NAV["Tactical Navigation Reasoner<br/>_ask_llm_for_direction (Gemini)"]
FALLBACK["Guardrail / Fallback Pathing<br/>Server recommended or nearest distance"]
end
ACTIONS["Action Dispatcher<br/>• Move bot (/move)<br/>• Form party (/parties)<br/>• Initiate battle (/battles/fight)<br/>• Pass turn (/pass)"]
end
end
subgraph ExternalBackends["External Services"]
SERVER["botWebWars Backend (FastAPI)<br/>Port 8000 REST API"]
GEMINI["Google Vertex AI / AI Studio<br/>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<br/>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<br/>(Voluntary Alliance)"]
GeminiAlliance --> AllianceChoice{"Form Alliance?"}
AllianceChoice -- Yes --> FormParty["POST /api/parties<br/>(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<br/>(Voluntary absorption)"]
CompareLeaderStr -- No --> FightParty["POST /api/battles/fight<br/>(Mandatory battle: refused weak leader)"]
SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"}
LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass<br/>(Follow leader command)"]
LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"}
PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight<br/>(Mandatory squad battle)"]
PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"}
SoloTargetStr -- Yes --> AbsorbSolo["Step toward target<br/>(Absorb solo follower)"]
SoloTargetStr -- No --> FightSolo["POST /api/battles/fight<br/>(Mandatory battle: solo refused)"]
%% Navigation Path
CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"]
FetchMoves --> GenMap["_build_local_map<br/>(Generate 17x17 ASCII minimap)"]
GenMap --> AskNav["Query Gemini via ask_json<br/>(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 |

View File

@ -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() {
<RegisterModal
isOpen={isRegisterOpen}
onClose={() => 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})!`);
}}
/>

View File

@ -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<BoardCanvasProps> = ({
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<BoardCanvasProps> = ({
{/* Selected Player Overlay card */}
{selectedPlayer && (
<div className="absolute bottom-4 left-4 bg-slate-900/90 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-xl flex items-center gap-3 text-xs max-w-sm">
<div
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white shadow-md border-2 border-white/30"
style={{ backgroundColor: selectedPlayer.color }}
>
{selectedPlayer.name.slice(0, 2).toUpperCase()}
<div className="w-10 h-10 rounded-xl bg-slate-950/80 flex items-center justify-center shadow-md border border-slate-700/70 p-0.5">
<PixelAvatar
pieceType={getPlayerPieceType(selectedPlayer)}
color={selectedPlayer.color}
isLeader={selectedPlayer.is_party_leader}
size={36}
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">

View File

@ -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<PlayerListProps> = ({
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<div className="relative flex-shrink-0">
<div
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-950/80 border p-0.5 shadow transition-transform"
style={{
backgroundColor: player.color,
boxShadow: isLeader
? '0 0 14px #fbbf24'
borderColor: isLeader
? '#fbbf24'
: isCurrentTurn
? '0 0 14px #f59e0b'
: `0 0 10px ${player.color}55`,
? '#f59e0b'
: `${player.color}66`,
boxShadow: isLeader
? '0 0 12px #fbbf2455'
: isCurrentTurn
? '0 0 12px #f59e0b55'
: `0 0 8px ${player.color}33`,
}}
>
{player.name.slice(0, 2).toUpperCase()}
<PixelAvatar
pieceType={getPlayerPieceType(player)}
color={player.color}
isLeader={isLeader}
size={32}
/>
</div>
{isLeader && (
<span className="absolute -top-2 -right-1 text-[12px] leading-none">

View File

@ -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<unknown>;
onRegister: (name: string, color: string, pieceType?: PieceType) => Promise<unknown>;
}
export const RegisterModal: React.FC<RegisterModalProps> = ({
@ -39,16 +42,26 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
}) => {
const [name, setName] = useState('');
const [color, setColor] = useState('#38BDF8');
const [pieceType, setPieceType] = useState<PieceType>('knight');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<RegisterModalProps> = ({
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<RegisterModalProps> = ({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-md p-6 shadow-2xl animate-in fade-in zoom-in duration-150">
<div className="flex justify-between items-center mb-5 pb-3 border-b border-slate-800">
<div>
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-emerald-400 inline-block animate-pulse" />
Register Player Avatar
Register Board Game Piece
</h2>
<p className="text-xs text-slate-400 mt-0.5">
Enter arena coordinates between (0,0) and (64,64)
Choose your Knight or Warrior miniature & faction colors
</p>
</div>
<button
@ -102,31 +115,86 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Avatar Preview */}
<div className="flex items-center justify-center py-4 bg-slate-950/60 rounded-xl border border-slate-800">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Miniature Preview */}
<div className="flex items-center justify-center py-4 bg-slate-950/70 rounded-xl border border-slate-800/80">
<div className="flex flex-col items-center gap-2">
<div
className="w-16 h-16 rounded-full flex items-center justify-center shadow-lg transition-transform duration-300 transform hover:scale-105"
className="w-20 h-20 rounded-xl flex items-center justify-center bg-slate-900 border transition-all duration-300 transform hover:scale-105"
style={{
backgroundColor: color,
boxShadow: `0 0 20px ${color}66`,
borderColor: `${color}88`,
boxShadow: `0 0 25px ${color}44`,
}}
>
<div className="w-6 h-6 rounded-full bg-white/90 shadow-inner flex items-center justify-center">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<PixelAvatar
pieceType={pieceType}
color={color}
size={64}
/>
</div>
<div className="text-center">
<div className="text-xs font-mono font-semibold text-slate-200">
{name.trim() ? name : 'Miniature Preview'}
</div>
<span className="text-xs font-mono font-medium text-slate-300">
{name.trim() ? name : 'Avatar Preview'}
<div className="text-[11px] text-slate-400 flex items-center justify-center gap-1.5 mt-0.5">
<span className="text-amber-400 font-mono">
{pieceType === 'knight' ? '⚔️ Knight Piece' : '🪓 Warrior Piece'}
</span>
{currentFaction && (
<>
<span></span>
<span style={{ color }}>{currentFaction.name}</span>
</>
)}
</div>
</div>
</div>
</div>
{/* Piece Class Selector (Knight vs Warrior) */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Board Game Piece Class
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setPieceType('knight')}
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${
pieceType === 'knight'
? 'bg-sky-950/60 border-sky-500 text-sky-200 shadow-md shadow-sky-500/20 ring-1 ring-sky-400'
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
<PixelAvatar pieceType="knight" color={color} size={28} />
<div className="text-left">
<div className="font-bold">Knight</div>
<div className="text-[10px] text-slate-400">Sword & Shield</div>
</div>
</button>
<button
type="button"
onClick={() => setPieceType('warrior')}
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${
pieceType === 'warrior'
? 'bg-amber-950/60 border-amber-500 text-amber-200 shadow-md shadow-amber-500/20 ring-1 ring-amber-400'
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
<PixelAvatar pieceType="warrior" color={color} size={28} />
<div className="text-left">
<div className="font-bold">Warrior</div>
<div className="text-[10px] text-slate-400">Battle Axe</div>
</div>
</button>
</div>
</div>
{/* Player Name */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-slate-300">Player Name</label>
<label className="text-xs font-semibold text-slate-300">Piece Name</label>
<button
type="button"
onClick={handleRandomize}
@ -139,31 +207,41 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. CyberKnight"
placeholder="e.g. AzureKnight"
maxLength={32}
className="w-full bg-slate-950 border border-slate-700 focus:border-sky-500 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors"
required
/>
</div>
{/* Color Chooser */}
{/* Color & Faction Chooser */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-2">
Avatar Color
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-slate-300">
Offered Faction Colors
</label>
{currentFaction && (
<span className="text-[11px] font-mono font-medium" style={{ color }}>
{currentFaction.name}
</span>
)}
</div>
<div className="grid grid-cols-5 gap-2 mb-3">
{PRESET_COLORS.map((preset) => (
{PRESET_FACTIONS.map((preset) => (
<button
key={preset}
key={preset.color}
type="button"
onClick={() => setColor(preset)}
className={`h-8 rounded-lg transition-transform ${
color.toUpperCase() === preset.toUpperCase()
? 'ring-2 ring-white scale-105'
: 'hover:scale-102 opacity-80 hover:opacity-100'
onClick={() => setColor(preset.color)}
title={preset.name}
className={`h-9 rounded-lg flex items-center justify-center transition-all ${
color.toUpperCase() === preset.color.toUpperCase()
? 'ring-2 ring-white scale-105 shadow-md shadow-white/20'
: 'hover:scale-102 opacity-85 hover:opacity-100'
}`}
style={{ backgroundColor: preset }}
/>
style={{ backgroundColor: preset.color }}
>
<PixelAvatar pieceType={pieceType} color={preset.color} size={20} />
</button>
))}
</div>
<div className="flex items-center gap-3">

View File

@ -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<ScoreboardModalProps> = ({
<div
style={{
...styles.avatarCircle,
backgroundColor: rankedPlayers[1].color,
backgroundColor: 'rgba(15, 23, 42, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderColor: '#94a3b8',
}}
>
{rankedPlayers[1].name.substring(0, 2).toUpperCase()}
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[1])}
color={rankedPlayers[1].color}
isLeader={rankedPlayers[1].id === conclusion.winning_leader_id}
size={52}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[1].name}</div>
<div style={styles.podiumScore}>{rankedPlayers[1].score} pts</div>
@ -232,12 +242,20 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
<div
style={{
...styles.avatarCircle,
backgroundColor: rankedPlayers[0].color,
backgroundColor: 'rgba(15, 23, 42, 0.85)',
border: '3px solid #FFD700',
boxShadow: '0 0 15px rgba(255, 215, 0, 0.6)',
boxShadow: '0 0 20px rgba(255, 215, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{rankedPlayers[0].name.substring(0, 2).toUpperCase()}
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[0])}
color={rankedPlayers[0].color}
isLeader={true}
size={60}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[0].name}</div>
<div style={{ ...styles.podiumScore, color: '#FFD700' }}>
@ -258,10 +276,19 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
<div
style={{
...styles.avatarCircle,
backgroundColor: rankedPlayers[2].color,
backgroundColor: 'rgba(15, 23, 42, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderColor: '#cd7f32',
}}
>
{rankedPlayers[2].name.substring(0, 2).toUpperCase()}
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[2])}
color={rankedPlayers[2].color}
isLeader={rankedPlayers[2].id === conclusion.winning_leader_id}
size={52}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[2].name}</div>
<div style={styles.podiumScore}>{rankedPlayers[2].score} pts</div>
@ -308,11 +335,12 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
<tr key={player.id} style={rowStyle}>
<td style={styles.tdRank}>{rankBadge}</td>
<td style={styles.tdBot}>
<span
style={{
...styles.botColorIndicator,
backgroundColor: player.color,
}}
<PixelAvatar
pieceType={getPlayerPieceType(player)}
color={player.color}
isLeader={isLeader}
size={24}
className="mr-2"
/>
<span style={styles.botNameText}>{player.name}</span>
{isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>}

View File

@ -269,11 +269,16 @@ export function useGameSocket() {
}
}, [selectedPlayer, fetchAvailableMoves]);
const registerPlayer = async (name: string, color: string, strength: number = 1): Promise<Player> => {
const registerPlayer = async (
name: string,
color: string,
strength: number = 1,
piece_type?: 'knight' | 'warrior'
): Promise<Player> => {
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(() => ({}));

View File

@ -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 }[];

View File

@ -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<string, string> {
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<string, HTMLCanvasElement>();
const spriteDataUrlCache = new Map<string, string>();
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<PixelAvatarProps> = ({
pieceType,
color,
isLeader = false,
size = 32,
className = '',
title,
}) => {
const dataUrl = getSpriteDataUrl(pieceType, color, isLeader);
return (
<img
src={dataUrl}
alt={`${isLeader ? 'Leader ' : ''}${pieceType}`}
title={title || `${isLeader ? 'Leader ' : ''}${pieceType} (${color})`}
className={`inline-block ${className}`}
style={{
width: size,
height: size,
imageRendering: 'pixelated',
}}
/>
);
};