Compare commits

...

4 Commits

Author SHA1 Message Date
Isaac Johnson 5ed7bb927e Gary the Wizard
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m3s Details
2026-09-09 19:46:30 -05:00
Isaac Johnson e7a1e75863 version shown on page, health, different award choices for battling wizard 2026-09-09 19:32:22 -05:00
Isaac Johnson 03e792e821 wizard and health updates 2026-09-09 17:11:54 -05:00
Isaac Johnson 9f2597d7f7 avatars instead of dots 2026-09-09 15:41:06 -05:00
31 changed files with 3726 additions and 349 deletions

1
.gitignore vendored
View File

@ -326,3 +326,4 @@ poetry.toml
pyrightconfig.json pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,node,linux # End of https://www.toptal.com/developers/gitignore/api/python,node,linux
helm.values.yml

View File

@ -97,6 +97,7 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
- **Winning Party Members**: +1 score point each. - **Winning Party Members**: +1 score point each.
- **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate. - **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate.
- **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party. - **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party.
- **Health Damage**: All defeated party members (including the leader) lose **1 to 3 health points** (randomized). Default health is **10 HP** for all bots upon registration.
### 4. Party Squad Movement ### 4. Party Squad Movement
- The party leader chooses movement direction. - The party leader chooses movement direction.
@ -109,9 +110,35 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
- **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength. - **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength.
- Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1. - Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1.
### 6. Game Conclusion ### 6. Gary the Wizard (NPC Encounter & Challenge)
- The game concludes when all bots on the board are united into a **single remaining party**. - Gary the Wizard is a wandering NPC who roams the map at a random passable coordinate.
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker). - Players (or party leaders) who locate Gary the Wizard (adjacent or on same tile, distance <= 1) may voluntarily **choose to challenge** him.
- **Challenge Resolution (3-Bout D20)**:
- Exactly 3 bouts are conducted.
- In each bout, each side rolls a D20 die, multiplied by their strength (Gary the Wizard has 3.0 strength; parties use squad total strength).
- Highest bout score wins the bout. Best 2-out-of-3 bouts wins the challenge.
- **Victory Rewards & Defeat Penalties**:
- **Victory**: The player (or party leader) decides and chooses which reward to claim:
- **+2 Score Points**: Adds +2 score points.
- **+2 Strength**: Adds +2.0 strength to the bot (recalculating squad total strength if in a party).
- **+2 Health**: Adds +2 health points (expanding max health if current health exceeds initial maximum).
- **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose).
- **Post-Challenge**:
- Following the challenge, Gary the Wizard teleports to a new random open coordinate on the map.
### 7. Player Health, Damage & Death
- All bots register with default **10 HP** (configurable).
- Health damage is sustained by losing battles (-1 to -3 HP) or losing Gary the Wizard challenges (-2 HP).
- When a bot's health drops to **0 HP**, they are **dead**.
- **Death Consequences**:
- **Party Disconnection**: The dead bot is immediately detached from any party. If the dead bot was the leader, squad leadership transfers to the strongest surviving squad member (or the party dissolves if empty). Defeated dead followers are not absorbed.
- **Gravestone Marker**: A **gravestone replaces their icon on the board** at their final coordinate. Deceased leaders do not respawn elsewhere.
- **No Turns or Actions**: Dead bots are omitted from turn order rotation and can **no longer move, duel, or take any actions**.
- **Scoreboard Preservation**: Dead bots **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and visited locations.
### 8. Game Conclusion
- The game concludes when all surviving bots on the board are united into a **single remaining party** (or if only 1 survivor remains).
- Final rankings and trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker), with all bots (surviving and deceased) included on the scoreboard.
--- ---
@ -123,11 +150,13 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/health` | Container healthcheck endpoint | | `GET` | `/api/health` | Container healthcheck endpoint |
| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int}` | | `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int}` |
| `GET` | `/api/players` | List all active players, scores, and positions | | `GET` | `/api/players` | List all active players, scores, health, and positions |
| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, current turn) | | `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) |
| `POST` | `/api/board/reset` | Clear board, reset parties, and reset players | | `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard |
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots, identifies allies/opponents | | `GET` | `/api/wizard` | Get current Gary the Wizard NPC coordinates and attributes |
| `POST` | `/api/wizard/challenge` | Challenge Gary the Wizard NPC: `{"player_id": str, "reward_choice": "score"|"strength"|"health"}` (3-bout D20 duel) |
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots and Gary the Wizard NPC |
| `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations | | `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations |
| `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) | | `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) |
| `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) | | `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) |
@ -139,7 +168,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
| `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties | | `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties |
### Real-Time WebSocket (`/ws`) ### Real-Time WebSocket (`/ws`)
- Automatically broadcasts state changes (`init`, `move`, `battle`, `party_formed`, `turn_change`, `game_over`). - Automatically broadcasts state changes (`init`, `move`, `battle`, `wizard_challenge_resolved`, `party_formed`, `turn_change`, `game_over`).
- Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`. - Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`.
--- ---
@ -182,13 +211,14 @@ The application is containerized into a single unified image via [Dockerfile](Do
- **Workflow**: - **Workflow**:
1. Registers bot via `POST /api/players`. 1. Registers bot via `POST /api/players`.
2. Polls `GET /api/turn` to wait for its turn. 2. Polls `GET /api/turn` to wait for its turn.
3. Uses `GET /api/players/{id}/radar` to find nearest target. 3. Uses `GET /api/players/{id}/radar` to find nearest target and detect Gary the Wizard NPC proximity.
4. Avoids looping using internal coordinate history. 4. Avoids looping using internal coordinate history.
5. Computes vector direction, evaluates diagonal obstacles, and moves. 5. Computes vector direction, evaluates diagonal obstacles, and moves.
6. Evaluates alliances vs. fights strictly according to strength hierarchy. 6. Evaluates alliances vs. fights strictly according to strength hierarchy.
7. Decides whether to challenge Gary the Wizard NPC based on relative strength and health advantage.
- **Run Command**: - **Run Command**:
```bash ```bash
python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 --url http://localhost:8000/api python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 -H 10 --url http://localhost:8000/api
``` ```
### B. LLM-Assisted Bot: `botagent_ai/` ### B. LLM-Assisted Bot: `botagent_ai/`
@ -198,6 +228,7 @@ The application is containerized into a single unified image via [Dockerfile](Do
- Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints). - Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints).
- Delegates discretionary decisions to the model: - Delegates discretionary decisions to the model:
- Voluntary alliances (whether to ally or keep hunting when solo meets solo). - Voluntary alliances (whether to ally or keep hunting when solo meets solo).
- Voluntary Wizard challenges (whether to challenge Gary the Wizard NPC when adjacent for +2 score vs. -2 health risk).
- Navigation direction toward radar targets while balancing obstacle squeeze trade-offs. - Navigation direction toward radar targets while balancing obstacle squeeze trade-offs.
- Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference. - Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference.
- **Configuration & Environment Variables**: - **Configuration & Environment Variables**:
@ -207,30 +238,33 @@ The application is containerized into a single unified image via [Dockerfile](Do
- `BOT_NAME` / `-n`: Bot name. - `BOT_NAME` / `-n`: Bot name.
- `BOT_COLOR` / `-c`: Bot hex color. - `BOT_COLOR` / `-c`: Bot hex color.
- `BOT_STRENGTH` / `-s`: Starting strength (1-10). - `BOT_STRENGTH` / `-s`: Starting strength (1-10).
- `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10).
- **Run Command**: - **Run Command**:
```bash ```bash
export OLLAMA_BASE_URL="http://localhost:11434" export OLLAMA_BASE_URL="http://localhost:11434"
export OLLAMA_MODEL="gemma4:12b" export OLLAMA_MODEL="gemma4:12b"
python3 botagent_ai/bot.py -n MyAIBot -s 4 -c "#8b5cf6" python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6"
``` ```
### C. Vertex AI (Gemini) Bot: `botagent_gear/` ### C. Vertex AI (Gemini) Bot: `botagent_gear/`
- **Entry File**: `botagent_gear/bot.py` - **Entry File**: `botagent_gear/bot.py`
- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances. - **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances/wizard duels.
- **LLM Integration**: - **LLM Integration**:
- Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API. - Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API.
- Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement. - Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement.
- Delegates discretionary choices to Gemini: voluntary alliances, choosing whether to challenge Gary the Wizard NPC, and spatial pathing.
- Supports multiple authentication methods: - Supports multiple authentication methods:
- Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`). - Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`).
- Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`). - Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`).
- Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`). - Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`).
- Configurable via `--health` / `-H` (or `BOT_HEALTH`, default: 10).
- **Documentation**: - **Documentation**:
- [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup. - [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup.
- [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions. - [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions.
- [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples. - [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples.
- **Run Command**: - **Run Command**:
```bash ```bash
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10
``` ```
--- ---

View File

@ -5,10 +5,17 @@ FROM node:22-alpine AS frontend-builder
WORKDIR /build/frontend WORKDIR /build/frontend
# Optional build argument to override version
ARG APP_VERSION
ENV VITE_APP_VERSION=$APP_VERSION
# Install dependencies # Install dependencies
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci || npm install RUN npm ci || npm install
# Copy version.ini for compile-time version resolution
COPY version.ini /build/version.ini
# Build frontend production bundle # Build frontend production bundle
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build

View File

@ -10,6 +10,7 @@
2. Battles consist of 3 bouts: each party rolls a D20 die, multiplied by their squad's total strength. The highest score wins the bout. Best 2 out of 3 bouts wins the battle. 2. Battles consist of 3 bouts: each party rolls a D20 die, multiplied by their squad's total strength. The highest score wins the bout. Best 2 out of 3 bouts wins the battle.
3. The winning party leader receives **+2 score points**, and each original winning member receives **+1 score point**. 3. The winning party leader receives **+2 score points**, and each original winning member receives **+1 score point**.
4. The losing party leader receives **-1 score point** and respawns at a random free position. All other members of the losing party are absorbed into the winning party. 4. The losing party leader receives **-1 score point** and respawns at a random free position. All other members of the losing party are absorbed into the winning party.
5. All members of the losing party (including the leader) lose **1 to 3 health points** (randomized). The health setting defaults to **10 HP** for all bots upon registration.
3. **Solo Bot vs Party Encounters**: 3. **Solo Bot vs Party Encounters**:
1. A solo bot will willingly join a party if the party leader's strength is greater than or equal to its own. 1. A solo bot will willingly join a party if the party leader's strength is greater than or equal to its own.
@ -27,8 +28,33 @@
- In a party, the leader loses **0.2 strength**, and follower members each lose **0.1 strength**. - In a party, the leader loses **0.2 strength**, and follower members each lose **0.1 strength**.
4. Moving along the open outer edge of an obstacle incurs no penalty. Minimum bot strength cannot fall below 0.1. 4. Moving along the open outer edge of an obstacle incurs no penalty. Minimum bot strength cannot fall below 0.1.
6. **Gary the Wizard (NPC Encounter & Challenge)**:
1. Gary the Wizard is a special Non-Player Character (NPC) roaming the map at a random passable location.
2. Players (or party leaders) who find Gary the Wizard (adjacent or on the same coordinate, distance <= 1) may voluntarily **choose to challenge** him.
3. **Challenge Resolution (3-Bout D20)**:
- Challenges consist of 3 bouts: each side rolls a D20 die, multiplied by their strength (Gary the Wizard has a strength of 3.0; a party uses its squad total strength).
- Highest bout score wins the bout. Best 2 out of 3 bouts wins the challenge.
4. **Victory Rewards & Defeat Penalties**:
- **Victory**: The player (or party leader) must decide and choose which reward to receive:
- **+2 Score Points**: Adds +2 points to their tournament score.
- **+2 Strength**: Adds +2.0 strength to the bot (recalculating squad total strength if in a party).
- **+2 Health**: Adds +2 health points (HP) to the bot (expanding max health if current health exceeds initial maximum).
- **Defeat**: The player (or party leader) loses **2 health** (or **2 score points** if they do not have health to lose).
5. **Wizard Relocation**:
- Following any challenge, Gary the Wizard teleports to a new random open coordinate on the map.
7. **Player Health, Damage & Death**:
1. All bots register with default **10 HP** (configurable via `--health` / `-H`).
2. Health damage is sustained by losing battles (-1 to -3 HP) or losing Gary the Wizard challenges (-2 HP).
3. When a player's health points reach **0 HP**, they are **dead**.
4. **Death Consequences**:
- **Party Disconnection**: The dead player is immediately disconnected from any existing party. If the dead player was the party leader, squad leadership transfers to the strongest surviving member (or the party dissolves if no living members remain). Dead followers are not absorbed into opposing parties.
- **Gravestone Marker**: A **gravestone replaces their icon on the board** at their final coordinate. Deceased leaders do not respawn at a random coordinate.
- **No Turns or Actions**: Dead players are removed from the turn rotation and can **no longer move, duel, or perform any actions**.
- **Scoreboard Preservation**: Dead players **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and stats.
# Game Conclusion # Game Conclusion
The game ends when all bots are united into a single remaining party. The game ends when all surviving bots on the board are united into a single remaining party (or if only one surviving bot remains).
Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). All players (both living and deceased) are listed on the final scoreboard rankings. Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker).

View File

@ -24,6 +24,9 @@ from app.models import (
Player, Player,
PlayerCreate, PlayerCreate,
TurnInfo, TurnInfo,
WizardChallengeRequest,
WizardChallengeResult,
WizardNPC,
) )
router = APIRouter() router = APIRouter()
@ -384,6 +387,52 @@ async def fight_battle(battle_req: BattleRequest):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ==========================================
# Wizard NPC Endpoints
# ==========================================
@router.get(
"/wizard",
response_model=WizardNPC,
summary="Get current position and details of the wandering Wizard NPC",
tags=["Wizard NPC"],
)
async def get_wizard():
return game_engine.wizard
@router.post(
"/wizard/challenge",
response_model=WizardChallengeResult,
summary="Challenge the Wizard NPC to a 3-bout D20 duel (awards choice of +2 score, +2 strength, or +2 health on win; -2 health/points on loss)",
tags=["Wizard NPC"],
)
async def challenge_wizard(challenge_req: WizardChallengeRequest):
try:
result = await game_engine.challenge_wizard(
challenge_req.player_id,
reward_choice=challenge_req.reward_choice or "score",
)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "wizard_challenge_resolved",
"challenge_result": result.model_dump(),
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return result
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ========================================== # ==========================================
# Movement & Turn Endpoints # Movement & Turn Endpoints
# ========================================== # ==========================================
@ -526,6 +575,15 @@ async def step_bot_ai(player_id: str):
"parties": [p.model_dump() for p in board_state.parties], "parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(), "turn": board_state.turn.model_dump(),
}) })
elif result.wizard_challenge_result:
await manager.broadcast({
"event": "wizard_challenge_resolved",
"challenge_result": result.wizard_challenge_result.model_dump(),
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
elif result.move_result: elif result.move_result:
await manager.broadcast({ await manager.broadcast({
"event": "player_moved", "event": "player_moved",
@ -613,6 +671,11 @@ async def pass_turn(player_id: str):
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=str(e), detail=str(e),
) )
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get( @router.get(

View File

@ -24,6 +24,10 @@ from app.models import (
PlayerCreate, PlayerCreate,
RadarTarget, RadarTarget,
TurnInfo, TurnInfo,
WizardChallengeBout,
WizardChallengeResult,
WizardNPC,
WizardRadarTarget,
) )
STANDARD_DIRECTIONS = [ STANDARD_DIRECTIONS = [
@ -62,6 +66,20 @@ class GameEngine:
# Procedural Impassable Obstacles (Mountains & Valleys) # Procedural Impassable Obstacles (Mountains & Valleys)
# Guaranteed: <= 50% impassable, and all passable tiles form a single connected component # Guaranteed: <= 50% impassable, and all passable tiles form a single connected component
self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain() self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain()
self.wizard: WizardNPC = self._spawn_wizard()
def _spawn_wizard(self) -> WizardNPC:
"""Spawn the wandering Wizard NPC at a random free passable coordinate."""
wx, wy = self._find_random_free_position()
return WizardNPC(
id="wizard_npc",
name="Gary the Wizard",
x=wx,
y=wy,
strength=3.0,
color="#A855F7",
dialogue="Greetings, traveler! Do you dare challenge my arcane arts?",
)
def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]: def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]:
"""Generates procedural mountain ranges and valley chasms subject to: """Generates procedural mountain ranges and valley chasms subject to:
@ -250,13 +268,62 @@ class GameEngine:
random.randint(self.config.min_y, self.config.max_y), random.randint(self.config.min_y, self.config.max_y),
) )
def _check_and_apply_death(self, player: Player) -> bool:
"""If player's health <= 0, mark dead, disconnect from party, and remove from turn rotation."""
if player.health <= 0:
player.health = 0
player.is_alive = False
# Disconnect from any existing party
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
if player.id in party.member_ids:
party.member_ids.remove(player.id)
if party.leader_id == player.id:
surviving = [
self.players[mid]
for mid in party.member_ids
if mid in self.players and self.players[mid].is_alive
]
if surviving:
surviving.sort(key=lambda p: (p.strength, p.score), reverse=True)
new_leader = surviving[0]
party.leader_id = new_leader.id
party.leader_name = new_leader.name
new_leader.is_party_leader = True
self._update_party_strength(party)
else:
if party.id in self.parties:
del self.parties[party.id]
elif party.member_ids:
self._update_party_strength(party)
else:
if party.id in self.parties:
del self.parties[party.id]
player.party_id = None
player.is_party_leader = False
if player.id in self.turn_order:
self.turn_order.remove(player.id)
actors = self._get_active_turn_actors()
if actors:
self.current_turn_index %= len(actors)
else:
self.current_turn_index = 0
return True
return False
def _get_active_turn_actors(self) -> List[str]: def _get_active_turn_actors(self) -> List[str]:
if not self.game_started: if not self.game_started:
return [] return []
actors = [] actors = []
for pid in self.turn_order: for pid in self.turn_order:
p = self.players.get(pid) p = self.players.get(pid)
if not p: if not p or not p.is_alive:
continue continue
if not p.party_id or p.is_party_leader: if not p.party_id or p.is_party_leader:
actors.append(pid) actors.append(pid)
@ -317,14 +384,32 @@ class GameEngine:
player_id = f"player_{uuid.uuid4().hex[:8]}" player_id = f"player_{uuid.uuid4().hex[:8]}"
spawn_x, spawn_y = self._find_random_free_position() 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"
health = getattr(player_in, "health", 10)
if health is None:
health = 10
player = Player( player = Player(
id=player_id, id=player_id,
name=player_in.name, name=player_in.name,
color=player_in.color, color=player_in.color,
strength=player_in.strength, strength=player_in.strength,
score=0, score=0,
health=health,
max_health=health,
x=spawn_x, x=spawn_x,
y=spawn_y, y=spawn_y,
piece_type=piece_type,
party_id=None, party_id=None,
is_party_leader=False, is_party_leader=False,
visited_locations=[{"x": spawn_x, "y": spawn_y}], visited_locations=[{"x": spawn_x, "y": spawn_y}],
@ -415,6 +500,7 @@ class GameEngine:
self.game_started = False self.game_started = False
# Regenerate fresh procedural mountain ranges and valley trenches on reset # Regenerate fresh procedural mountain ranges and valley trenches on reset
self.obstacles = self._generate_terrain() self.obstacles = self._generate_terrain()
self.wizard = self._spawn_wizard()
async def get_board_state(self) -> BoardState: async def get_board_state(self) -> BoardState:
async with self._lock: async with self._lock:
@ -427,6 +513,7 @@ class GameEngine:
players=players_list, players=players_list,
parties=parties_list, parties=parties_list,
obstacles=obstacles_list, obstacles=obstacles_list,
wizard=self.wizard,
turn=self._get_turn_info(), turn=self._get_turn_info(),
game_started=self.game_started, game_started=self.game_started,
conclusion=self._check_game_concluded(), conclusion=self._check_game_concluded(),
@ -468,7 +555,7 @@ class GameEngine:
targets: List[RadarTarget] = [] targets: List[RadarTarget] = []
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id or not other.is_alive or other.health <= 0:
continue continue
dist = max(abs(player.x - other.x), abs(player.y - other.y)) dist = max(abs(player.x - other.x), abs(player.y - other.y))
is_ally = bool(player.party_id and player.party_id == other.party_id) is_ally = bool(player.party_id and player.party_id == other.party_id)
@ -555,6 +642,17 @@ class GameEngine:
else: else:
rec_act = "hunt_party" rec_act = "hunt_party"
wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
wiz_radar = WizardRadarTarget(
id=self.wizard.id,
name=self.wizard.name,
x=self.wizard.x,
y=self.wizard.y,
distance=wiz_dist,
strength=self.wizard.strength,
can_challenge=(wiz_dist <= 1),
)
return BotRadarResponse( return BotRadarResponse(
player_id=player.id, player_id=player.id,
current_x=player.x, current_x=player.x,
@ -562,6 +660,7 @@ class GameEngine:
bot_goal=bot_goal, bot_goal=bot_goal,
targets=targets, targets=targets,
nearest_target=nearest, nearest_target=nearest,
wizard=wiz_radar,
recommended_direction=rec_dir, recommended_direction=rec_dir,
recommended_action=rec_act, recommended_action=rec_act,
) )
@ -619,6 +718,8 @@ class GameEngine:
p = self.players.get(mid) p = self.players.get(mid)
if not p: if not p:
raise KeyError(f"Player '{mid}' not found") raise KeyError(f"Player '{mid}' not found")
if not p.is_alive or p.health <= 0:
raise ValueError(f"Player '{p.name}' is dead and cannot join a party.")
member_players.append(p) member_players.append(p)
if not self._verify_party_connectivity(member_players): if not self._verify_party_connectivity(member_players):
@ -666,6 +767,10 @@ class GameEngine:
invitee = self.players.get(invitee_id) invitee = self.players.get(invitee_id)
if not inviter or not invitee: if not inviter or not invitee:
raise KeyError("Inviter or invitee not found") raise KeyError("Inviter or invitee not found")
if not inviter.is_alive or inviter.health <= 0:
raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.")
if not invitee.is_alive or invitee.health <= 0:
raise ValueError(f"Player '{invitee.name}' is dead and cannot be invited.")
eligible_hosts = [inviter] eligible_hosts = [inviter]
if inviter.party_id and inviter.party_id in self.parties: if inviter.party_id and inviter.party_id in self.parties:
@ -706,6 +811,8 @@ class GameEngine:
invitee = self.players.get(invite.invitee_id) invitee = self.players.get(invite.invitee_id)
if not inviter or not invitee: if not inviter or not invitee:
raise KeyError("Inviter or invitee no longer active") raise KeyError("Inviter or invitee no longer active")
if not inviter.is_alive or not invitee.is_alive:
raise ValueError("Cannot join party with deceased player.")
if inviter.party_id and inviter.party_id in self.parties: if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id] party = self.parties[inviter.party_id]
@ -877,6 +984,17 @@ class GameEngine:
direction_name: str, direction_name: str,
occupied_map: Dict[Tuple[int, int], Player], occupied_map: Dict[Tuple[int, int], Player],
) -> MoveCheckResult: ) -> MoveCheckResult:
if not player.is_alive or player.health <= 0:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason="Player is dead (0 HP)",
)
if player.party_id and player.party_id in self.parties and player.is_party_leader: if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id] party = self.parties[player.party_id]
new_positions, failure_reason, strength_penalty = self._compute_party_move( new_positions, failure_reason, strength_penalty = self._compute_party_move(
@ -983,6 +1101,32 @@ class GameEngine:
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
moves = {
name: MoveCheckResult(
direction=name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason="Player is dead (0 HP)",
)
for name, dx, dy in STANDARD_DIRECTIONS
}
return AvailableMovesResponse(
player_id=player.id,
player_name=player.name,
current_x=player.x,
current_y=player.y,
is_turn=False,
is_party_leader=False,
party_id=None,
party_member_count=0,
current_turn_player_id=None,
moves=moves,
)
occupied = self._get_occupied_coordinates() occupied = self._get_occupied_coordinates()
current_turn = self._get_current_player() current_turn = self._get_current_player()
is_turn = bool(current_turn and current_turn.id == player_id) is_turn = bool(current_turn and current_turn.id == player_id)
@ -1099,29 +1243,51 @@ class GameEngine:
killed_leader = self.players.get(defeated_party.leader_id) killed_leader = self.players.get(defeated_party.leader_id)
absorbed_members: List[str] = [] absorbed_members: List[str] = []
dead_players: List[str] = []
# Defeated party members (including leader) all lose 1 to 3 health points (randomized)
health_losses: Dict[str, int] = {}
defeated_all_ids = list(defeated_party.member_ids)
if defeated_party.leader_id and defeated_party.leader_id not in defeated_all_ids:
defeated_all_ids.append(defeated_party.leader_id)
for mid in defeated_all_ids:
p = self.players.get(mid)
if p:
hp_loss = random.randint(1, 3)
p.health = max(0, p.health - hp_loss)
health_losses[mid] = hp_loss
if p.health == 0 and p.is_alive:
self._check_and_apply_death(p)
dead_players.append(mid)
if killed_leader: if killed_leader:
killed_leader.score -= 1 killed_leader.score -= 1
if len(defeated_party.member_ids) == 1: if killed_leader.is_alive:
killed_leader.party_id = winner_party.id if len(defeated_party.member_ids) == 1:
killed_leader.is_party_leader = False killed_leader.party_id = winner_party.id
if killed_leader.id not in winner_party.member_ids: killed_leader.is_party_leader = False
winner_party.member_ids.append(killed_leader.id) if killed_leader.id not in winner_party.member_ids:
absorbed_members.append(killed_leader.id) winner_party.member_ids.append(killed_leader.id)
respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} absorbed_members.append(killed_leader.id)
respawn_pos = {"x": killed_leader.x, "y": killed_leader.y}
else:
respawn_x, respawn_y = self._find_random_free_position()
killed_leader.x = respawn_x
killed_leader.y = respawn_y
killed_leader.party_id = None
killed_leader.is_party_leader = False
respawn_pos = {"x": respawn_x, "y": respawn_y}
else: else:
respawn_x, respawn_y = self._find_random_free_position() # Leader is dead: remains at final coordinates as gravestone, disconnected from party
killed_leader.x = respawn_x respawn_pos = {"x": killed_leader.x, "y": killed_leader.y}
killed_leader.y = respawn_y
killed_leader.party_id = None
killed_leader.is_party_leader = False
respawn_pos = {"x": respawn_x, "y": respawn_y}
# Only surviving defeated party followers are absorbed into winning party
for mid in list(defeated_party.member_ids): for mid in list(defeated_party.member_ids):
if mid != defeated_party.leader_id: if mid != defeated_party.leader_id:
m = self.players.get(mid) m = self.players.get(mid)
if m: if m and m.is_alive:
m.party_id = winner_party.id m.party_id = winner_party.id
m.is_party_leader = False m.is_party_leader = False
if m.id not in winner_party.member_ids: if m.id not in winner_party.member_ids:
@ -1154,6 +1320,8 @@ class GameEngine:
absorbed_members=absorbed_members, absorbed_members=absorbed_members,
new_party_size=len(winner_party.member_ids), new_party_size=len(winner_party.member_ids),
new_party_strength=winner_party.total_strength, new_party_strength=winner_party.total_strength,
health_losses=health_losses,
dead_players=dead_players,
) )
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
@ -1164,6 +1332,10 @@ class GameEngine:
p2 = self.players.get(defender_id) p2 = self.players.get(defender_id)
if not p1 or not p2: if not p1 or not p2:
raise KeyError("Challenger or defender not found") raise KeyError("Challenger or defender not found")
if not p1.is_alive or p1.health <= 0:
raise ValueError(f"Challenger '{p1.name}' is dead and cannot battle.")
if not p2.is_alive or p2.health <= 0:
raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.")
if p1.party_id and p1.party_id == p2.party_id: if p1.party_id and p1.party_id == p2.party_id:
raise ValueError("Cannot battle members of your own party") raise ValueError("Cannot battle members of your own party")
@ -1209,6 +1381,164 @@ class GameEngine:
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult: async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
return await self.fight_battle(challenger_id, defender_id) return await self.fight_battle(challenger_id, defender_id)
def _resolve_wizard_challenge_internal(
self, player: Player, reward_choice: str = "score"
) -> WizardChallengeResult:
"""Resolve a 3-bout D20 challenge between player/party and the Wizard NPC."""
effective_strength = player.strength
party_id = player.party_id
if party_id and party_id in self.parties:
party = self.parties[party_id]
self._update_party_strength(party)
effective_strength = party.total_strength
bouts: List[WizardChallengeBout] = []
player_bouts_won = 0
wizard_bouts_won = 0
for bout_idx in range(1, 4):
r_player = random.randint(1, 20)
r_wiz = random.randint(1, 20)
score_player = round(effective_strength * r_player, 1)
score_wiz = round(self.wizard.strength * r_wiz, 1)
if score_player > score_wiz:
winner = "player"
player_bouts_won += 1
elif score_wiz > score_player:
winner = "wizard"
wizard_bouts_won += 1
else:
if effective_strength >= self.wizard.strength:
winner = "player"
player_bouts_won += 1
else:
winner = "wizard"
wizard_bouts_won += 1
bouts.append(
WizardChallengeBout(
bout_number=bout_idx,
player_roll=r_player,
player_strength=effective_strength,
player_score=score_player,
wizard_roll=r_wiz,
wizard_strength=self.wizard.strength,
wizard_score=score_wiz,
winner=winner,
)
)
player_won = player_bouts_won >= 2
score_change = 0
strength_change = 0.0
health_change = 0
choice = (reward_choice or "score").lower().strip()
if choice not in ("score", "strength", "health"):
choice = "score"
if player_won:
# Player wins challenge: choice of +2 score, +2 strength, or +2 health
if choice == "strength":
player.strength = round(player.strength + 2.0, 1)
strength_change = 2.0
if player.party_id and player.party_id in self.parties:
self._update_party_strength(self.parties[player.party_id])
elif choice == "health":
player.health += 2
if player.health > player.max_health:
player.max_health = player.health
health_change = 2
else: # "score"
player.score += 2
score_change = 2
player_died = False
if not player_won:
# Player loses challenge: lose 2 health (or points if they do not have health to lose)
if player.health >= 2:
player.health -= 2
health_change = -2
score_change = 0
elif player.health > 0:
pts_lost = 2 - player.health
health_change = -player.health
player.health = 0
player.score -= pts_lost
score_change = -pts_lost
else:
health_change = 0
player.score -= 2
score_change = -2
if player.health == 0 and player.is_alive:
player_died = self._check_and_apply_death(player)
# Wizard teleports to a new random open coordinate on the map
new_wx, new_wy = self._find_random_free_position()
self.wizard.x = new_wx
self.wizard.y = new_wy
respawn_pos = {"x": new_wx, "y": new_wy}
self._advance_turn()
return WizardChallengeResult(
challenger_id=player.id,
challenger_name=player.name,
wizard_name=self.wizard.name if self.wizard else "Gary the Wizard",
party_id=party_id,
bouts=bouts,
player_bouts_won=player_bouts_won,
wizard_bouts_won=wizard_bouts_won,
player_won=player_won,
reward_chosen=choice if player_won else None,
score_change=score_change,
strength_change=strength_change,
health_change=health_change,
new_score=player.score,
new_strength=player.strength,
new_health=player.health,
player_died=player_died,
wizard_respawn_position=respawn_pos,
)
async def challenge_wizard(self, player_id: str, reward_choice: str = "score") -> WizardChallengeResult:
async with self._lock:
if not self.game_started:
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.")
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody"
curr_id = current_turn_player.id if current_turn_player else "None"
raise PermissionError(
f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})."
)
if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id)
leader_name = party.leader_name if party else "Leader"
raise PermissionError(
f"Party member '{player.name}' cannot challenge the wizard individually. Only party leader '{leader_name}' can initiate challenges."
)
dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
if dist > 1:
wiz_name = self.wizard.name if self.wizard else "Gary the Wizard"
raise ValueError(
f"Player '{player.name}' is not adjacent to {wiz_name} (distance {dist}). Must be within 1 distance."
)
return self._resolve_wizard_challenge_internal(player, reward_choice=reward_choice)
async def get_game_conclusion(self) -> GameConclusion: async def get_game_conclusion(self) -> GameConclusion:
async with self._lock: async with self._lock:
return self._check_game_concluded() return self._check_game_concluded()
@ -1217,21 +1547,52 @@ class GameEngine:
if len(self.players) < 2: if len(self.players) < 2:
return GameConclusion(concluded=False) return GameConclusion(concluded=False)
living_players = [p for p in self.players.values() if p.is_alive]
total_bots = len(self.players)
rankings = sorted(
self.players.values(),
key=lambda p: (p.score, p.strength, p.name),
reverse=True,
)
# Case 1: All bots died
if len(living_players) == 0:
return GameConclusion(
concluded=True,
total_bots=total_bots,
rankings=rankings,
)
# Case 2: Exactly 1 living bot remains (sole survivor)
if len(living_players) == 1:
survivor = living_players[0]
party = self.parties.get(survivor.party_id) if survivor.party_id else None
return GameConclusion(
concluded=True,
winning_party_id=party.id if party else None,
winning_party_name=party.name if party else f"Squad {survivor.name}",
winning_leader_id=party.leader_id if party else survivor.id,
winning_leader_name=party.leader_name if party else survivor.name,
total_bots=total_bots,
rankings=rankings,
)
# Case 3: All living bots are united into a single remaining party
if len(self.parties) == 1: if len(self.parties) == 1:
only_party = next(iter(self.parties.values())) only_party = next(iter(self.parties.values()))
if len(only_party.member_ids) == len(self.players): living_member_ids = {
rankings = sorted( mid for mid in only_party.member_ids
self.players.values(), if self.players.get(mid) and self.players[mid].is_alive
key=lambda p: (p.score, p.strength, p.name), }
reverse=True, living_player_ids = {p.id for p in living_players}
) if living_member_ids == living_player_ids and len(living_player_ids) >= 1:
return GameConclusion( return GameConclusion(
concluded=True, concluded=True,
winning_party_id=only_party.id, winning_party_id=only_party.id,
winning_party_name=only_party.name, winning_party_name=only_party.name,
winning_leader_id=only_party.leader_id, winning_leader_id=only_party.leader_id,
winning_leader_name=only_party.leader_name, winning_leader_name=only_party.leader_name,
total_bots=len(self.players), total_bots=total_bots,
rankings=rankings, rankings=rankings,
) )
@ -1240,9 +1601,13 @@ class GameEngine:
def _check_adjacent_encounter( def _check_adjacent_encounter(
self, player: Player self, player: Player
) -> Tuple[Optional[Party], Optional[BattleResult]]: ) -> Tuple[Optional[Party], Optional[BattleResult]]:
if not player.is_alive or player.health <= 0:
return None, None
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id:
continue continue
if not other.is_alive or other.health <= 0:
continue
if player.party_id and player.party_id == other.party_id: if player.party_id and player.party_id == other.party_id:
continue continue
@ -1353,6 +1718,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot move.")
if player.party_id and not player.is_party_leader: if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id) party = self.parties.get(player.party_id)
@ -1445,6 +1812,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and has no actions.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
@ -1464,6 +1833,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot take actions.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
@ -1505,10 +1876,40 @@ class GameEngine:
move_result=None, move_result=None,
formed_party=formed_party, formed_party=formed_party,
battle_result=None, battle_result=None,
wizard_challenge_result=None,
game_concluded=conclusion if conclusion.concluded else None, game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(), turn=self._get_turn_info(),
) )
# 1b. Check if adjacent to Gary the Wizard NPC and choose to challenge
wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y))
if wiz_dist <= 1 and (not player.party_id or player.is_party_leader):
effective_str = player.strength
if player.party_id and player.party_id in self.parties:
effective_str = self.parties[player.party_id].total_strength
if effective_str >= self.wizard.strength or player.health >= 4:
if player.health <= 5:
bot_reward = "health"
elif player.strength < 4.0:
bot_reward = "strength"
else:
bot_reward = "score"
challenge_res = self._resolve_wizard_challenge_internal(player, reward_choice=bot_reward)
conclusion = self._check_game_concluded()
return AiStepResponse(
action_taken="challenged_wizard",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=None,
battle_result=None,
wizard_challenge_result=challenge_res,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(),
)
# 2. Navigate towards target using BFS pathfinder & memory # 2. Navigate towards target using BFS pathfinder & memory
occupied = self._get_occupied_coordinates() occupied = self._get_occupied_coordinates()
moves_map: Dict[str, MoveCheckResult] = {} moves_map: Dict[str, MoveCheckResult] = {}
@ -1535,7 +1936,7 @@ class GameEngine:
# Find nearest target according to goal # Find nearest target according to goal
targets = [] targets = []
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id or not other.is_alive or other.health <= 0:
continue continue
if player.party_id and player.party_id == other.party_id: if player.party_id and player.party_id == other.party_id:
continue continue
@ -1647,6 +2048,73 @@ class GameEngine:
turn=turn_info, turn=turn_info,
) )
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
async with self._lock:
if party_id not in self.parties:
raise KeyError(f"Party '{party_id}' not found")
party = self.parties[party_id]
leader = self.players.get(party.leader_id)
leader_id = party.leader_id
leader_name = party.leader_name
if leader:
leader.score -= 1
hp_loss = random.randint(1, 3)
leader.health = max(0, leader.health - hp_loss)
if leader.health == 0 and leader.is_alive:
self._check_and_apply_death(leader)
respawn_pos = {"x": leader.x, "y": leader.y}
else:
rx, ry = self._find_random_free_position()
leader.x = rx
leader.y = ry
leader.party_id = None
leader.is_party_leader = False
respawn_pos = {"x": rx, "y": ry}
else:
respawn_pos = {"x": 0, "y": 0}
if leader_id in party.member_ids:
party.member_ids.remove(leader_id)
remaining_alive = [
mid for mid in party.member_ids
if mid in self.players and self.players[mid].is_alive
]
new_leader_id = None
new_leader_name = None
party_dissolved = False
if remaining_alive:
candidates = [self.players[mid] for mid in remaining_alive]
candidates.sort(key=lambda p: (p.strength, p.score), reverse=True)
new_lead = candidates[0]
party.leader_id = new_lead.id
party.leader_name = new_lead.name
new_lead.is_party_leader = True
new_leader_id = new_lead.id
new_leader_name = new_lead.name
self._update_party_strength(party)
else:
party_dissolved = True
if party.id in self.parties:
del self.parties[party.id]
self._advance_turn()
return PartyDefeatResult(
party_id=party_id,
killed_leader_id=leader_id,
killed_leader_name=leader_name,
killed_leader_new_score=leader.score if leader else 0,
killed_leader_respawn_position=respawn_pos,
new_leader_id=new_leader_id,
new_leader_name=new_leader_name,
remaining_members=remaining_alive,
party_dissolved=party_dissolved,
)
# Global game engine instance # Global game engine instance
game_engine = GameEngine() game_engine = GameEngine()

View File

@ -119,6 +119,8 @@ class PlayerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") 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") 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)") strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)")
health: Optional[int] = Field(default=10, ge=1, description="Starting health points (default is 10)")
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@ -147,8 +149,12 @@ class Player(BaseModel):
y: int y: int
strength: float = 1.0 strength: float = 1.0
score: int = 0 score: int = 0
health: int = 10
max_health: int = 10
piece_type: Optional[str] = "knight"
party_id: Optional[str] = None party_id: Optional[str] = None
is_party_leader: bool = False is_party_leader: bool = False
is_alive: bool = True
visited_locations: List[Dict[str, int]] = Field(default_factory=list) visited_locations: List[Dict[str, int]] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@ -241,6 +247,14 @@ class BattleResult(BaseModel):
) )
new_party_size: int new_party_size: int
new_party_strength: float new_party_strength: float
health_losses: Dict[str, int] = Field(
default_factory=dict,
description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}",
)
dead_players: List[str] = Field(
default_factory=list,
description="IDs of players who died (HP reached 0) in this battle",
)
class BattleRequest(BaseModel): class BattleRequest(BaseModel):
@ -248,6 +262,69 @@ class BattleRequest(BaseModel):
defender_id: str defender_id: str
# ==========================================
# Wizard NPC & Challenge Models
# ==========================================
class WizardNPC(BaseModel):
id: str = "wizard_npc"
name: str = "Gary the Wizard"
x: int
y: int
strength: float = 3.0
color: str = "#A855F7"
dialogue: Optional[str] = "Greetings, traveler! Do you dare challenge my arcane arts?"
class WizardChallengeBout(BaseModel):
bout_number: int
player_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for player")
player_strength: float
player_score: float = Field(..., description="player_strength * player_roll")
wizard_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for wizard")
wizard_strength: float
wizard_score: float = Field(..., description="wizard_strength * wizard_roll")
winner: str = Field(..., description="'player' or 'wizard'")
class WizardChallengeResult(BaseModel):
challenger_id: str
challenger_name: str
wizard_name: str = "Gary the Wizard"
party_id: Optional[str] = None
bouts: List[WizardChallengeBout]
player_bouts_won: int
wizard_bouts_won: int
player_won: bool
reward_chosen: Optional[str] = "score"
score_change: int = 0
strength_change: float = 0.0
health_change: int = 0
new_score: int
new_strength: float = 1.0
new_health: int
player_died: bool = False
wizard_respawn_position: Dict[str, int]
class WizardChallengeRequest(BaseModel):
player_id: str
reward_choice: Optional[str] = Field(
default="score",
description="Chosen victory reward: 'score' (+2 score), 'strength' (+2 strength), or 'health' (+2 health)"
)
class WizardRadarTarget(BaseModel):
id: str = "wizard_npc"
name: str = "Gary the Wizard"
x: int
y: int
distance: int
strength: float = 3.0
can_challenge: bool = False
# ========================================== # ==========================================
# Memory & Radar Models # Memory & Radar Models
# ========================================== # ==========================================
@ -285,8 +362,9 @@ class BotRadarResponse(BaseModel):
bot_goal: str # "form_party" or "find_and_defeat_all_parties" bot_goal: str # "form_party" or "find_and_defeat_all_parties"
targets: List[RadarTarget] targets: List[RadarTarget]
nearest_target: Optional[RadarTarget] = None nearest_target: Optional[RadarTarget] = None
wizard: Optional[WizardRadarTarget] = None
recommended_direction: Optional[str] = None recommended_direction: Optional[str] = None
recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited" recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited", "challenge_wizard"
class BoardConfig(BaseModel): class BoardConfig(BaseModel):
@ -327,6 +405,7 @@ class BoardState(BaseModel):
players: List[Player] players: List[Player]
parties: List[Party] = [] parties: List[Party] = []
obstacles: List[Obstacle] = Field(default_factory=list) obstacles: List[Obstacle] = Field(default_factory=list)
wizard: Optional[WizardNPC] = None
turn: TurnInfo turn: TurnInfo
game_started: bool = False game_started: bool = False
conclusion: Optional[GameConclusion] = None conclusion: Optional[GameConclusion] = None
@ -349,7 +428,7 @@ class MoveResponse(BaseModel):
class AiStepResponse(BaseModel): class AiStepResponse(BaseModel):
action_taken: str # "formed_party", "battled", "moved", "passed" action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed"
player_id: str player_id: str
player_name: str player_name: str
bot_goal: str # "form_party" or "find_and_defeat_all_parties" bot_goal: str # "form_party" or "find_and_defeat_all_parties"
@ -357,6 +436,7 @@ class AiStepResponse(BaseModel):
move_result: Optional[MoveResponse] = None move_result: Optional[MoveResponse] = None
formed_party: Optional[Party] = None formed_party: Optional[Party] = None
battle_result: Optional[BattleResult] = None battle_result: Optional[BattleResult] = None
wizard_challenge_result: Optional[WizardChallengeResult] = None
game_concluded: Optional[GameConclusion] = None game_concluded: Optional[GameConclusion] = None
turn: TurnInfo turn: TurnInfo

View File

@ -196,6 +196,34 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
assert battle_data["bouts"][0]["party1_strength"] == 10 assert battle_data["bouts"][0]["party1_strength"] == 10
assert battle_data["bouts"][0]["party2_strength"] == 2 assert battle_data["bouts"][0]["party2_strength"] == 2
# Verify losing party members (leader BetaLead + follower BetaWing) each lost 1-3 health points
assert "health_losses" in battle_data
assert p3["id"] in battle_data["health_losses"]
assert p4["id"] in battle_data["health_losses"]
assert 1 <= battle_data["health_losses"][p3["id"]] <= 3
assert 1 <= battle_data["health_losses"][p4["id"]] <= 3
# Check updated health of defeated members
b3_after = client.get(f"/api/players/{p3['id']}").json()
b4_after = client.get(f"/api/players/{p4['id']}").json()
assert b3_after["health"] == 10 - battle_data["health_losses"][p3["id"]]
assert b4_after["health"] == 10 - battle_data["health_losses"][p4["id"]]
def test_player_health_default_and_custom_registration():
client = TestClient(app)
client.post("/api/reset")
# Default health should be 10
p_default = client.post("/api/players", json={"name": "DefaultHPBot", "color": "#123456", "strength": 3}).json()
assert p_default["health"] == 10
assert p_default["max_health"] == 10
# Custom health (e.g. 18)
p_custom = client.post("/api/players", json={"name": "TankHPBot", "color": "#654321", "strength": 4, "health": 18}).json()
assert p_custom["health"] == 18
assert p_custom["max_health"] == 18
def test_game_conclusion_and_scoreboard(): def test_game_conclusion_and_scoreboard():
client = TestClient(app) client = TestClient(app)
@ -445,3 +473,295 @@ def test_party_movement_forms_line():
# Distances are all <= 1 # Distances are all <= 1
assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1 assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1
assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1 assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1
def test_wizard_npc_existence_and_radar_detection():
client = TestClient(app)
client.post("/api/reset")
# 1. Check GET /api/wizard
wiz_res = client.get("/api/wizard")
assert wiz_res.status_code == 200
wiz_data = wiz_res.json()
assert wiz_data["id"] == "wizard_npc"
assert wiz_data["name"] == "Gary the Wizard"
assert wiz_data["strength"] == 3.0
assert 0 <= wiz_data["x"] <= 64
assert 0 <= wiz_data["y"] <= 64
# 2. Check BoardState includes wizard
board_res = client.get("/api/board").json()
assert board_res["wizard"] is not None
assert board_res["wizard"]["id"] == "wizard_npc"
# 3. Register a bot and check radar includes wizard target
p = client.post("/api/players", json={"name": "RadarExplorer", "color": "#123456", "strength": 4}).json()
radar_res = client.get(f"/api/players/{p['id']}/radar").json()
assert "wizard" in radar_res
assert radar_res["wizard"] is not None
assert radar_res["wizard"]["id"] == "wizard_npc"
assert radar_res["wizard"]["strength"] == 3.0
def test_wizard_challenge_mechanics_victory_and_defeat():
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "ChallengerBot", "color": "#38bdf8", "strength": 50}).json()
p2 = client.post("/api/players", json={"name": "WeakChallenger", "color": "#f43f5e", "strength": 1}).json()
# Move p1 adjacent to wizard
wiz = client.get("/api/wizard").json()
wx, wy = wiz["x"], wiz["y"]
async def setup_wizard_adjacent():
# Place p1 adjacent to wizard (e.g. wx+1, wy if within bounds)
adj_x = wx + 1 if wx < 64 else wx - 1
adj_y = wy
bot1 = await game_engine.get_player(p1["id"])
bot1.x = adj_x
bot1.y = adj_y
bot1.health = 10
bot1.score = 0
asyncio.run(setup_wizard_adjacent())
client.post("/api/game/start")
# Set turn to ChallengerBot
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
# 1a. Challenge the wizard with default/score reward (strength 50 guaranteed win)
chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "score"})
assert chal_res.status_code == 200
res_data = chal_res.json()
assert len(res_data["bouts"]) == 3
assert res_data["player_won"] is True
assert res_data["reward_chosen"] == "score"
assert res_data["score_change"] == 2
assert res_data["strength_change"] == 0.0
assert res_data["health_change"] == 0
assert res_data["new_score"] == 2
assert res_data["new_health"] == 10
# 1b. Challenge with "strength" reward
wiz_loc = res_data["wizard_respawn_position"]
async def place_p1_for_strength_win():
b = await game_engine.get_player(p1["id"])
b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1
b.y = wiz_loc["y"]
asyncio.run(place_p1_for_strength_win())
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
str_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "strength"})
assert str_res.status_code == 200
str_data = str_res.json()
assert str_data["player_won"] is True
assert str_data["reward_chosen"] == "strength"
assert str_data["strength_change"] == 2.0
assert str_data["new_strength"] == 52.0
assert str_data["score_change"] == 0
assert str_data["health_change"] == 0
# 1c. Challenge with "health" reward
wiz_loc = str_data["wizard_respawn_position"]
async def place_p1_for_health_win():
b = await game_engine.get_player(p1["id"])
b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1
b.y = wiz_loc["y"]
b.health = 8
asyncio.run(place_p1_for_health_win())
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
hp_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "health"})
assert hp_res.status_code == 200
hp_data = hp_res.json()
assert hp_data["player_won"] is True
assert hp_data["reward_chosen"] == "health"
assert hp_data["health_change"] == 2
assert hp_data["new_health"] == 10
assert hp_data["score_change"] == 0
assert hp_data["strength_change"] == 0.0
# Wizard teleports to a new location
wiz_after = client.get("/api/wizard").json()
assert (wiz_after["x"], wiz_after["y"]) == (hp_data["wizard_respawn_position"]["x"], hp_data["wizard_respawn_position"]["y"])
# 2. Test defeat case: bot loses 2 health
# Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss)
async def setup_weak_loss():
new_wx, new_wy = wiz_after["x"], wiz_after["y"]
adj_x = new_wx + 1 if new_wx < 64 else new_wx - 1
adj_y = new_wy
bot2 = await game_engine.get_player(p2["id"])
bot2.x = adj_x
bot2.y = adj_y
bot2.strength = 0.001
bot2.health = 10
bot2.score = 5
asyncio.run(setup_weak_loss())
game_engine.current_turn_index = 1 # p2's turn
loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert loss_res.status_code == 200
loss_data = loss_res.json()
assert loss_data["player_won"] is False
assert loss_data["health_change"] == -2
assert loss_data["new_health"] == 8
assert loss_data["new_score"] == 5
# 3. Test defeat when health reaches 0: bot dies, health becomes 0, player_died is True
async def setup_low_health():
cur_wiz = client.get("/api/wizard").json()
adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1
adj_y = cur_wiz["y"]
bot2 = await game_engine.get_player(p2["id"])
bot2.x = adj_x
bot2.y = adj_y
bot2.strength = 0.001
bot2.health = 1 # 1 HP left, losing 2 HP will reduce HP to 0 and score by 1
bot2.score = 5
bot2.is_alive = True
asyncio.run(setup_low_health())
# Set turn back to p2
game_engine.current_turn_index = 0
game_engine.turn_order = [p2["id"]]
death_chal_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert death_chal_res.status_code == 200
death_data = death_chal_res.json()
assert death_data["player_won"] is False
assert death_data["health_change"] == -1
assert death_data["score_change"] == -1
assert death_data["new_health"] == 0
assert death_data["new_score"] == 4
assert death_data["player_died"] is True
# Verify dead bot state: health is 0, is_alive is False
bot2_dead = client.get(f"/api/players/{p2['id']}").json()
assert bot2_dead["health"] == 0
assert bot2_dead["is_alive"] is False
# Dead bot cannot challenge wizard or move (no actions)
dead_action_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert dead_action_res.status_code == 400
assert "dead" in dead_action_res.json()["detail"].lower()
def test_player_death_rule_and_scoreboard_preservation():
"""Test the rule:
When a player loses enough health points to reach 0, they are dead.
They are disconnected from any existing party and a gravestone replaces their icon on the board.
They still are listed on the scores, but they can no longer move and they no longer have a turn.
"""
client = TestClient(app)
client.post("/api/reset")
# Register 3 bots: Winner (party of 1), LoserLead (party of 2), LoserFollower (party of 2)
b1 = client.post("/api/players", json={"name": "IronChampion", "color": "#10b981", "strength": 20, "health": 10}).json()
b2 = client.post("/api/players", json={"name": "FragileLeader", "color": "#ef4444", "strength": 1, "health": 2}).json()
b3 = client.post("/api/players", json={"name": "DoomedFollower", "color": "#f59e0b", "strength": 1, "health": 1}).json()
# Place b2 and b3 in a party
async def setup_party_and_combat():
p1 = await game_engine.get_player(b1["id"])
p2 = await game_engine.get_player(b2["id"])
p3 = await game_engine.get_player(b3["id"])
p1.x, p1.y = 20, 20
p2.x, p2.y = 20, 21
p3.x, p3.y = 21, 21
asyncio.run(setup_party_and_combat())
client.post("/api/game/start")
# Form losing party with b2 (leader) and b3 (follower)
party_loser = client.post("/api/parties", json={
"member_ids": [b2["id"], b3["id"]],
"leader_id": b2["id"],
"name": "FragileSquad",
}).json()
# Form winning party with b1
party_winner = client.post("/api/parties", json={
"member_ids": [b1["id"]],
"leader_id": b1["id"],
"name": "IronSquad",
})
# If parties requires min 2 members via API, b1 battles as solo vs party directly
# Trigger battle between b1 and b2
battle_res = client.post("/api/battles/fight", json={
"challenger_id": b1["id"],
"defender_id": b2["id"],
})
assert battle_res.status_code == 200
battle_data = battle_res.json()
# Verify health loss caused DoomedFollower (starting HP 1, losing 1-3) to DIE
p3_after = client.get(f"/api/players/{b3['id']}").json()
assert p3_after["health"] == 0
assert p3_after["is_alive"] is False
assert p3_after["party_id"] is None
assert p3_after["is_party_leader"] is False
# Dead follower must NOT be absorbed into winning party
assert b3["id"] not in battle_data["absorbed_members"]
# If FragileLeader also died (starting HP 2, lost >= 2):
p2_after = client.get(f"/api/players/{b2['id']}").json()
if p2_after["health"] == 0:
assert p2_after["is_alive"] is False
assert p2_after["party_id"] is None
assert p2_after["is_party_leader"] is False
else:
# If leader survived with 1 HP, manually drop them to 0 to verify death
async def kill_leader():
lead = await game_engine.get_player(b2["id"])
lead.health = 0
game_engine._check_and_apply_death(lead)
asyncio.run(kill_leader())
p2_after = client.get(f"/api/players/{b2['id']}").json()
assert p2_after["health"] == 0
assert p2_after["is_alive"] is False
# Verify dead bots CANNOT move
move_attempt = client.post(f"/api/players/{b3['id']}/move", json={"direction": "UP"})
assert move_attempt.status_code == 400
assert "dead" in move_attempt.json()["detail"].lower()
# Verify dead bots CANNOT pass
pass_attempt = client.post(f"/api/players/{b3['id']}/pass")
assert pass_attempt.status_code == 400
assert "dead" in pass_attempt.json()["detail"].lower()
# Verify available moves returns all unavailable
moves_res = client.get(f"/api/players/{b3['id']}/available-moves").json()
for move in moves_res["moves"].values():
assert move["available"] is False
assert "dead" in move["reason"].lower()
# Verify dead bots do NOT have a turn in turn rotation
turn_data = client.get("/api/turn").json()
assert b3["id"] not in turn_data["turn_order"]
assert b2["id"] not in turn_data["turn_order"]
assert turn_data["current_player_id"] == b1["id"]
# Verify dead bots STILL ARE LISTED on the players/scores list
all_players = client.get("/api/players").json()
all_player_ids = [p["id"] for p in all_players]
assert b1["id"] in all_player_ids
assert b2["id"] in all_player_ids
assert b3["id"] in all_player_ids
# Verify game conclusion: only b1 is alive, so game is concluded!
board = client.get("/api/board").json()
assert board["conclusion"]["concluded"] is True
# All 3 bots (including dead ones) are listed in rankings with their scores!
ranking_ids = [r["id"] for r in board["conclusion"]["rankings"]]
assert len(ranking_ids) == 3
assert b1["id"] in ranking_ids
assert b2["id"] in ranking_ids
assert b3["id"] in ranking_ids

View File

@ -1,5 +1,25 @@
# 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
``` ## 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) |
| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) |
| | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` |

View File

@ -18,6 +18,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
DEFAULT_BOT_NAME = "ExternalCyberBot" DEFAULT_BOT_NAME = "ExternalCyberBot"
DEFAULT_BOT_COLOR = "#10b981" DEFAULT_BOT_COLOR = "#10b981"
DEFAULT_BOT_STRENGTH = 4 DEFAULT_BOT_STRENGTH = 4
DEFAULT_BOT_HEALTH = 10
def normalize_url(url: str) -> str: def normalize_url(url: str) -> str:
@ -34,11 +35,16 @@ class SmartBotAgent:
name: str = DEFAULT_BOT_NAME, name: str = DEFAULT_BOT_NAME,
color: str = DEFAULT_BOT_COLOR, color: str = DEFAULT_BOT_COLOR,
strength: int = DEFAULT_BOT_STRENGTH, strength: int = DEFAULT_BOT_STRENGTH,
health: int = DEFAULT_BOT_HEALTH,
server_url: str = DEFAULT_SERVER_URL, server_url: str = DEFAULT_SERVER_URL,
piece_type: Optional[str] = None,
): ):
self.name = name self.name = name
self.color = color self.color = color
self.strength = strength self.strength = strength
self.health = health
self.server_url = server_url
self.piece_type = piece_type
self.base_url = normalize_url(server_url) self.base_url = normalize_url(server_url)
self.bot_id: Optional[str] = None self.bot_id: Optional[str] = None
self.party_id: Optional[str] = None self.party_id: Optional[str] = None
@ -51,27 +57,36 @@ class SmartBotAgent:
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
except Exception: except Exception:
pass pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type:
payload["piece_type"] = self.piece_type
res = requests.post( res = requests.post(
f"{self.base_url}/players", 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: if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json() players = requests.get(f"{self.base_url}/players").json()
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
self.bot_id = data["id"] self.bot_id = data["id"]
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
def refresh_status(self): def refresh_status(self):
"""Update bot state (party membership, leader status, score).""" """Update bot state (party membership, leader status, score)."""
@ -89,12 +104,18 @@ class SmartBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---")
# 1. Consult Radar Sensor # 1. Consult Radar Sensor
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
targets = radar_res.get("targets", []) targets = radar_res.get("targets", [])
nearest = radar_res.get("nearest_target") nearest = radar_res.get("nearest_target")
wizard = radar_res.get("wizard")
# 2. Check for immediate adjacent interaction (distance <= 1) # 2. Check for immediate adjacent interaction (distance <= 1)
adjacent_target = None adjacent_target = None
@ -107,7 +128,17 @@ class SmartBotAgent:
self._handle_adjacent_encounter(adjacent_target) self._handle_adjacent_encounter(adjacent_target)
return return
# 3. If no immediate adjacent enemy/recruit, move towards target # 3. Check for Wizard NPC encounter (voluntary challenge)
if wizard and wizard.get("can_challenge"):
my_health = my_info.get("health", 10)
wiz_str = wizard.get("strength", 3.0)
# Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4)
if self.strength >= wiz_str or my_health >= 4:
print(f"🧙 [WIZARD NEARBY] Adjacent to {wizard.get('name', 'Gary the Wizard')} (Str: {wiz_str})! HP: {my_health}, Bot Str: {self.strength}. Choosing to challenge!")
self._challenge_wizard(my_health=my_health)
return
# 4. If no immediate adjacent enemy/recruit, move towards target
self._navigate_towards_goal(radar_res) self._navigate_towards_goal(radar_res)
def _handle_adjacent_encounter(self, target: Dict[str, Any]): def _handle_adjacent_encounter(self, target: Dict[str, Any]):
@ -203,9 +234,47 @@ class SmartBotAgent:
print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
def _challenge_wizard(self, my_health: int = 10):
"""Voluntarily challenge the Wizard NPC to a 3-bout D20 duel with chosen victory reward."""
if my_health <= 5:
reward_choice = "health"
elif self.strength < 4.0:
reward_choice = "strength"
else:
reward_choice = "score"
print(f"🧙 [WIZARD CHALLENGE] Challenging Gary the Wizard to a 3-bout D20 duel (reward if won: +2 {reward_choice})...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id, "reward_choice": reward_choice},
)
if res.status_code == 200:
result = res.json()
outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []):
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
print(f" Score Change: {result.get('score_change')} | Strength Change: {result.get('strength_change', 0.0)} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Strength: {result.get('new_strength')} | New Score: {result.get('new_score')}")
if result.get("player_died") or result.get("new_health", 10) <= 0:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
pos = result.get("wizard_respawn_position")
if pos:
print(f" 🔮 Wizard teleported to ({pos.get('x')}, {pos.get('y')})")
else:
print(f"Wizard challenge failed ({res.status_code}): {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
except Exception as e:
print(f"Error challenging wizard: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]: def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]:
"""Pick the available direction that minimizes Chebyshev distance to (target_x, target_y), strictly avoiding obstacles.""" """Pick the available direction that minimizes Chebyshev distance to (target_x, target_y), strictly avoiding obstacles."""
valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")} valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")}
@ -271,6 +340,21 @@ class SmartBotAgent:
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
# Check if bot was removed (e.g., board was reset) # Check if bot was removed (e.g., board was reset)
@ -299,7 +383,12 @@ class SmartBotAgent:
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
requests.delete(f"{self.base_url}/players/{self.bot_id}") # If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():
@ -308,6 +397,8 @@ def main():
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Autonomous External Bot Agent for botWebWars", description="Autonomous External Bot Agent for botWebWars",
@ -338,6 +429,20 @@ def main():
default=env_strength, default=env_strength,
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)", help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)",
) )
parser.add_argument(
"-H", "--health",
dest="health",
type=int,
default=env_health,
help="Health points attribute (default 10) for the bot (env: BOT_HEALTH)",
)
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() args = parser.parse_args()
@ -345,7 +450,9 @@ def main():
name=args.name, name=args.name,
color=args.color, color=args.color,
strength=args.strength, strength=args.strength,
health=args.health,
server_url=args.server_url, server_url=args.server_url,
piece_type=args.piece_type,
) )
agent.run() agent.run()

View File

@ -31,12 +31,13 @@ Configure it via environment variables or CLI flags:
- `BOT_NAME` / `-n`: Bot display name - `BOT_NAME` / `-n`: Bot display name
- `BOT_COLOR` / `-c`: Hex color for the bot avatar - `BOT_COLOR` / `-c`: Hex color for the bot avatar
- `BOT_STRENGTH` / `-s`: Starting strength (1-10) - `BOT_STRENGTH` / `-s`: Starting strength (1-10)
- `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10)
Example: Example:
```bash ```bash
export OLLAMA_BASE_URL="http://192.168.1.220:11434" export OLLAMA_BASE_URL="http://192.168.1.220:11434"
export OLLAMA_MODEL="gemma4:12b" export OLLAMA_MODEL="gemma4:12b"
python bot.py -n MyAIBot -s 5 python bot.py -n MyAIBot -s 5 -H 10
``` ```
## Running the Bot ## Running the Bot

View File

@ -17,7 +17,7 @@ import os
import re import re
import time import time
import argparse import argparse
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import requests import requests
@ -25,6 +25,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
DEFAULT_BOT_NAME = "OllamaBot" DEFAULT_BOT_NAME = "OllamaBot"
DEFAULT_BOT_COLOR = "#8b5cf6" DEFAULT_BOT_COLOR = "#8b5cf6"
DEFAULT_BOT_STRENGTH = 4 DEFAULT_BOT_STRENGTH = 4
DEFAULT_BOT_HEALTH = 10
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b")
@ -35,9 +36,16 @@ Rules you must respect when choosing among the OPTIONS given to you:
score if tied) leads. Larger parties have an advantage in battle. score if tied) leads. Larger parties have an advantage in battle.
- A solo bot always joins a party if the party leader's strength >= its own (no choice). - A solo bot always joins a party if the party leader's strength >= its own (no choice).
- A solo bot always refuses and fights if the party leader is weaker (no choice). - A solo bot always refuses and fights if the party leader is weaker (no choice).
- Two opposing parties that meet must always battle (no choice). - Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
lose 1-3 health points (randomized).
- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
replaces its icon on the board, and it can no longer move or take turns. Its final score remains
preserved on the scoreboard.
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The game ends when all bots are united into a single party. - The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score,
+2 strength, or +2 health; losing costs 2 health (or score if no health).
- The game ends when all surviving bots are united into a single remaining party.
You will only ever be asked to choose between options that are legal - always answer with the You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else. requested JSON object and nothing else.
""" """
@ -95,13 +103,17 @@ class AIBotAgent:
name: str = DEFAULT_BOT_NAME, name: str = DEFAULT_BOT_NAME,
color: str = DEFAULT_BOT_COLOR, color: str = DEFAULT_BOT_COLOR,
strength: int = DEFAULT_BOT_STRENGTH, strength: int = DEFAULT_BOT_STRENGTH,
health: int = DEFAULT_BOT_HEALTH,
server_url: str = DEFAULT_SERVER_URL, server_url: str = DEFAULT_SERVER_URL,
ollama_url: str = OLLAMA_BASE_URL, ollama_url: str = OLLAMA_BASE_URL,
ollama_model: str = OLLAMA_MODEL, ollama_model: str = OLLAMA_MODEL,
piece_type: Optional[str] = None,
): ):
self.name = name self.name = name
self.color = color self.color = color
self.strength = strength self.strength = strength
self.health = health
self.piece_type = piece_type
self.base_url = normalize_url(server_url) self.base_url = normalize_url(server_url)
self.llm = OllamaClient(ollama_url, ollama_model) self.llm = OllamaClient(ollama_url, ollama_model)
self.bot_id: Optional[str] = None self.bot_id: Optional[str] = None
@ -118,27 +130,36 @@ class AIBotAgent:
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
except Exception: except Exception:
pass pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type:
payload["piece_type"] = self.piece_type
res = requests.post( res = requests.post(
f"{self.base_url}/players", 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: if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json() players = requests.get(f"{self.base_url}/players").json()
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
self.bot_id = data["id"] self.bot_id = data["id"]
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
def refresh_status(self): def refresh_status(self):
"""Update bot state (party membership, leader status, score).""" """Update bot state (party membership, leader status, score)."""
@ -158,10 +179,16 @@ class AIBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---")
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
targets = radar_res.get("targets", []) targets = radar_res.get("targets", [])
wizard = radar_res.get("wizard")
adjacent_target = None adjacent_target = None
for t in targets: for t in targets:
@ -171,9 +198,64 @@ class AIBotAgent:
if adjacent_target: if adjacent_target:
self._handle_adjacent_encounter(adjacent_target, my_info) self._handle_adjacent_encounter(adjacent_target, my_info)
elif wizard and wizard.get("can_challenge"):
should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info)
if should_challenge:
self._challenge_wizard(wizard, reward_choice=reward_choice)
else:
self._navigate_towards_goal(radar_res, my_info)
else: else:
self._navigate_towards_goal(radar_res, my_info) self._navigate_towards_goal(radar_res, my_info)
def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> Tuple[bool, str]:
"""Ask the LLM whether to challenge the adjacent Wizard NPC and which reward to choose on win."""
prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)})
at position ({my_info['x']}, {my_info['y']}).
You are adjacent to the NPC Gary the Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
- If you win: You choose one reward: +2 score, +2 strength, or +2 health!
- If you lose: -2 health points (or -2 score if no health)!
Do you want to challenge the wizard to a duel, and if you win, which reward do you want ("score", "strength", or "health")?
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}}
"""
decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False)
reward_choice = str(decision.get("reward_choice", "score")).lower().strip()
if reward_choice not in ("score", "strength", "health"):
reward_choice = "score"
reasoning = decision.get("reasoning", "")
print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}")
return bool(challenge), reward_choice
def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"):
"""Execute the challenge against the Wizard NPC."""
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Gary the Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id, "reward_choice": reward_choice},
)
if res.status_code == 200:
result = res.json()
outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []):
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
print(f" Score: {result.get('new_score')} | Strength: {result.get('new_strength')} | HP: {result.get('new_health')}")
if result.get("player_died") or result.get("new_health", 10) <= 0:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
pos = result.get("wizard_respawn_position")
if pos:
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})")
else:
print(f"Wizard challenge failed: {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
except Exception as e:
print(f"Error challenging wizard: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]): def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
"""Resolve the encounter; the LLM only gets a say when the rules allow a choice.""" """Resolve the encounter; the LLM only gets a say when the rules allow a choice."""
target_name = target["name"] target_name = target["name"]
@ -276,6 +358,10 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
@ -367,6 +453,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
continue continue
player_at.setdefault((p["x"], p["y"]), []).append(p) player_at.setdefault((p["x"], p["y"]), []).append(p)
wizard = board.get("wizard")
wizard_pos = (wizard["x"], wizard["y"]) if wizard else None
rows = [] rows = []
for y in range(center_y - radius, center_y + radius + 1): for y in range(center_y - radius, center_y + radius + 1):
row_chars = [] row_chars = []
@ -375,6 +464,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
row_chars.append("@") row_chars.append("@")
elif x < 0 or y < 0 or x >= max_x or y >= max_y: elif x < 0 or y < 0 or x >= max_x or y >= max_y:
row_chars.append("#") row_chars.append("#")
elif wizard_pos and (x, y) == wizard_pos:
row_chars.append("W")
elif (x, y) in obstacle_at: elif (x, y) in obstacle_at:
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M")) row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
elif (x, y) in player_at: elif (x, y) in player_at:
@ -422,7 +513,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
map_section = f""" map_section = f"""
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot, increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. W = Gary the Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
{chr(10).join(local_map)} {chr(10).join(local_map)}
""" """
@ -457,6 +548,21 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
if self.bot_id: if self.bot_id:
@ -483,7 +589,12 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
requests.delete(f"{self.base_url}/players/{self.bot_id}") # If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():
@ -491,6 +602,8 @@ def main():
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="LLM-driven Bot Agent for botWebWars (uses a local Ollama model for strategy)", description="LLM-driven Bot Agent for botWebWars (uses a local Ollama model for strategy)",
@ -504,6 +617,10 @@ def main():
help="Hex color code for the bot avatar (env: BOT_COLOR)") help="Hex color code for the bot avatar (env: BOT_COLOR)")
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
parser.add_argument("-H", "--health", dest="health", type=int, default=env_health,
help="Starting health points (default 10) (env: BOT_HEALTH)")
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, parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL,
help="Ollama base URL (env: OLLAMA_BASE_URL)") help="Ollama base URL (env: OLLAMA_BASE_URL)")
parser.add_argument("--ollama-model", dest="ollama_model", default=OLLAMA_MODEL, parser.add_argument("--ollama-model", dest="ollama_model", default=OLLAMA_MODEL,
@ -517,9 +634,11 @@ def main():
name=args.name, name=args.name,
color=args.color, color=args.color,
strength=args.strength, strength=args.strength,
health=args.health,
server_url=args.server_url, server_url=args.server_url,
ollama_url=args.ollama_url, ollama_url=args.ollama_url,
ollama_model=args.ollama_model, ollama_model=args.ollama_model,
piece_type=args.piece_type,
) )
agent.run() agent.run()

View File

@ -87,6 +87,7 @@ python3 bot.py --url "http://192.168.1.100:8000/api" --name "RemoteGear"
| `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid | | `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid |
| `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar | | `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar |
| `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) | | `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) |
| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) |
| `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID | | `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID |
| `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI | | `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI |
| `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name | | `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name |

View File

@ -36,6 +36,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api"
DEFAULT_BOT_NAME = "GeminiGearBot" DEFAULT_BOT_NAME = "GeminiGearBot"
DEFAULT_BOT_COLOR = "#4285f4" DEFAULT_BOT_COLOR = "#4285f4"
DEFAULT_BOT_STRENGTH = 5 DEFAULT_BOT_STRENGTH = 5
DEFAULT_BOT_HEALTH = 10
DEFAULT_MODEL = "gemini-3.8-flash" DEFAULT_MODEL = "gemini-3.8-flash"
DEFAULT_LOCATION = "us-central1" DEFAULT_LOCATION = "us-central1"
@ -45,9 +46,16 @@ Rules you must respect when choosing among the OPTIONS given to you:
score if tied) leads. Larger parties have an advantage in battle. score if tied) leads. Larger parties have an advantage in battle.
- A solo bot always joins a party if the party leader's strength >= its own (no choice). - A solo bot always joins a party if the party leader's strength >= its own (no choice).
- A solo bot always refuses and fights if the party leader is weaker (no choice). - A solo bot always refuses and fights if the party leader is weaker (no choice).
- Two opposing parties that meet must always battle (no choice). - Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
lose 1-3 health points (randomized).
- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
replaces its icon on the board, and it can no longer move or take turns. Its final score remains
preserved on the scoreboard.
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The game ends when all bots are united into a single party. - The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score,
+2 strength, or +2 health; losing costs 2 health (or score if no health).
- The game ends when all surviving bots are united into a single remaining party.
You will only ever be asked to choose between options that are legal - always answer with the You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else. requested JSON object and nothing else.
""" """
@ -274,15 +282,19 @@ class VertexAIBotAgent:
name: str = DEFAULT_BOT_NAME, name: str = DEFAULT_BOT_NAME,
color: str = DEFAULT_BOT_COLOR, color: str = DEFAULT_BOT_COLOR,
strength: int = DEFAULT_BOT_STRENGTH, strength: int = DEFAULT_BOT_STRENGTH,
health: int = DEFAULT_BOT_HEALTH,
server_url: str = DEFAULT_SERVER_URL, server_url: str = DEFAULT_SERVER_URL,
project_id: Optional[str] = None, project_id: Optional[str] = None,
location: str = DEFAULT_LOCATION, location: str = DEFAULT_LOCATION,
model: str = DEFAULT_MODEL, model: str = DEFAULT_MODEL,
api_key: Optional[str] = None, api_key: Optional[str] = None,
piece_type: Optional[str] = None,
): ):
self.name = name self.name = name
self.color = color self.color = color
self.strength = strength self.strength = strength
self.health = health
self.piece_type = piece_type
self.base_url = normalize_url(server_url) self.base_url = normalize_url(server_url)
self.llm = VertexGeminiClient( self.llm = VertexGeminiClient(
project_id=project_id, project_id=project_id,
@ -304,27 +316,36 @@ class VertexAIBotAgent:
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
except Exception: except Exception:
pass pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type:
payload["piece_type"] = self.piece_type
res = requests.post( res = requests.post(
f"{self.base_url}/players", 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: if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json() players = requests.get(f"{self.base_url}/players").json()
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})")
return return
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
self.bot_id = data["id"] self.bot_id = data["id"]
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
def refresh_status(self): def refresh_status(self):
"""Update bot state (party membership, leader status, score).""" """Update bot state (party membership, leader status, score)."""
@ -344,10 +365,16 @@ class VertexAIBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Str: {my_info['strength']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Str: {my_info['strength']} | Party: {self.party_id or 'Solo'} ---")
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
targets = radar_res.get("targets", []) targets = radar_res.get("targets", [])
wizard = radar_res.get("wizard")
adjacent_target = None adjacent_target = None
for t in targets: for t in targets:
@ -357,9 +384,64 @@ class VertexAIBotAgent:
if adjacent_target: if adjacent_target:
self._handle_adjacent_encounter(adjacent_target, my_info) self._handle_adjacent_encounter(adjacent_target, my_info)
elif wizard and wizard.get("can_challenge"):
should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info)
if should_challenge:
self._challenge_wizard(wizard, reward_choice=reward_choice)
else:
self._navigate_towards_goal(radar_res, my_info)
else: else:
self._navigate_towards_goal(radar_res, my_info) self._navigate_towards_goal(radar_res, my_info)
def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> Tuple[bool, str]:
"""Ask Gemini whether to challenge the adjacent Wizard NPC and which reward to choose on win."""
prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)})
at position ({my_info['x']}, {my_info['y']}).
You are adjacent to the NPC Gary the Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
- If you win: You choose one reward: +2 score, +2 strength, or +2 health!
- If you lose: -2 health points (or -2 score if no health)!
Do you want to challenge the wizard to a duel, and if you win, which reward do you want ("score", "strength", or "health")?
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}}
"""
decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False)
reward_choice = str(decision.get("reward_choice", "score")).lower().strip()
if reward_choice not in ("score", "strength", "health"):
reward_choice = "score"
reasoning = decision.get("reasoning", "")
print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}")
return bool(challenge), reward_choice
def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"):
"""Execute the challenge against the Wizard NPC."""
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Gary the Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id, "reward_choice": reward_choice},
)
if res.status_code == 200:
result = res.json()
outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)"
print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []):
print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}")
print(f" Score: {result.get('new_score')} | Strength: {result.get('new_strength')} | HP: {result.get('new_health')}")
if result.get("player_died") or result.get("new_health", 10) <= 0:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
pos = result.get("wizard_respawn_position")
if pos:
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})")
else:
print(f"Wizard challenge failed: {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
except Exception as e:
print(f"Error challenging wizard: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]): def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]):
"""Resolve the encounter; Gemini only gets a say when the rules allow a choice.""" """Resolve the encounter; Gemini only gets a say when the rules allow a choice."""
target_name = target["name"] target_name = target["name"]
@ -462,6 +544,10 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
@ -553,6 +639,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
continue continue
player_at.setdefault((p["x"], p["y"]), []).append(p) player_at.setdefault((p["x"], p["y"]), []).append(p)
wizard = board.get("wizard")
wizard_pos = (wizard["x"], wizard["y"]) if wizard else None
rows = [] rows = []
for y in range(center_y - radius, center_y + radius + 1): for y in range(center_y - radius, center_y + radius + 1):
row_chars = [] row_chars = []
@ -561,6 +650,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
row_chars.append("@") row_chars.append("@")
elif x < 0 or y < 0 or x >= max_x or y >= max_y: elif x < 0 or y < 0 or x >= max_x or y >= max_y:
row_chars.append("#") row_chars.append("#")
elif wizard_pos and (x, y) == wizard_pos:
row_chars.append("W")
elif (x, y) in obstacle_at: elif (x, y) in obstacle_at:
row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M")) row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M"))
elif (x, y) in player_at: elif (x, y) in player_at:
@ -608,7 +699,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso
map_section = f""" map_section = f"""
Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is
increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot, increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot,
M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. W = Gary the Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
{chr(10).join(local_map)} {chr(10).join(local_map)}
""" """
@ -651,6 +742,21 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
if self.bot_id: if self.bot_id:
@ -677,8 +783,12 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
if self.bot_id: # If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}") requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():
@ -686,10 +796,12 @@ def main():
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
env_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT") env_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT")
env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION 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_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_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( parser = argparse.ArgumentParser(
description="Vertex AI (Gemini) Bot Agent for botWebWars", description="Vertex AI (Gemini) Bot Agent for botWebWars",
@ -703,6 +815,8 @@ def main():
help="Hex color code for the bot avatar (env: BOT_COLOR)") help="Hex color code for the bot avatar (env: BOT_COLOR)")
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
parser.add_argument("-H", "--health", dest="health", type=int, default=env_health,
help="Starting health points (default 10) (env: BOT_HEALTH)")
parser.add_argument("-p", "--project", dest="project_id", default=env_project, parser.add_argument("-p", "--project", dest="project_id", default=env_project,
help="Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT)") help="Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT)")
parser.add_argument("-l", "--location", dest="location", default=env_location, parser.add_argument("-l", "--location", dest="location", default=env_location,
@ -711,6 +825,8 @@ def main():
help="Gemini model ID (env: VERTEX_MODEL)") help="Gemini model ID (env: VERTEX_MODEL)")
parser.add_argument("-k", "--api-key", dest="api_key", default=env_api_key, 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)") 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() args = parser.parse_args()
@ -725,11 +841,13 @@ def main():
name=args.name, name=args.name,
color=args.color, color=args.color,
strength=args.strength, strength=args.strength,
health=args.health,
server_url=args.server_url, server_url=args.server_url,
project_id=args.project_id, project_id=args.project_id,
location=args.location, location=args.location,
model=args.model, model=args.model,
api_key=args.api_key, api_key=args.api_key,
piece_type=args.piece_type,
) )
agent.run() 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

@ -7,15 +7,20 @@ import { PlayerList } from './components/PlayerList';
import { RegisterModal } from './components/RegisterModal'; import { RegisterModal } from './components/RegisterModal';
import { PartyModal } from './components/PartyModal'; import { PartyModal } from './components/PartyModal';
import { BattleModal } from './components/BattleModal'; import { BattleModal } from './components/BattleModal';
import { WizardChallengeModal } from './components/WizardChallengeModal';
import { WizardPromptModal } from './components/WizardPromptModal';
import { ScoreboardModal } from './components/ScoreboardModal'; import { ScoreboardModal } from './components/ScoreboardModal';
import type { WizardRewardChoice } from './types';
const BOT_PRESETS = [ const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
{ name: 'AlphaBot', color: '#38bdf8', strength: 1 }, { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' },
{ name: 'BetaTank', color: '#f43f5e', strength: 2 }, { name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' },
{ name: 'GammaStriker', color: '#a855f7', strength: 3 }, { name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' },
{ name: 'DeltaRanger', color: '#22c55e', strength: 1 }, { name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' },
{ name: 'OmegaTitan', color: '#eab308', strength: 4 }, { name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' },
{ name: 'SigmaScout', color: '#ec4899', strength: 1 }, { 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() { export function App() {
@ -30,6 +35,8 @@ export function App() {
lastEventMessage, lastEventMessage,
activeBattle, activeBattle,
setActiveBattle, setActiveBattle,
activeWizardChallenge,
setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
registerPlayer, registerPlayer,
@ -39,6 +46,7 @@ export function App() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,
resetBoard, resetBoard,
@ -46,12 +54,41 @@ export function App() {
const [isRegisterOpen, setIsRegisterOpen] = useState(false); const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false); const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
const [promptChallengerId, setPromptChallengerId] = useState<string | null>(null);
const [notification, setNotification] = useState<string | null>(null); const [notification, setNotification] = useState<string | null>(null);
const pendingWizardChallenger = promptChallengerId
? boardState.players.find((p) => p.id === promptChallengerId) || null
: null;
const handleCloseBattle = useCallback(() => { const handleCloseBattle = useCallback(() => {
setActiveBattle(null); setActiveBattle(null);
}, [setActiveBattle]); }, [setActiveBattle]);
const handleCloseWizardChallenge = useCallback(() => {
setActiveWizardChallenge(null);
}, [setActiveWizardChallenge]);
const handleOpenWizardPrompt = useCallback(async (playerId: string) => {
setPromptChallengerId(playerId);
}, []);
const handleConfirmWizardChallenge = useCallback(
async (rewardChoice: WizardRewardChoice) => {
if (!promptChallengerId) return;
const id = promptChallengerId;
setPromptChallengerId(null);
try {
await challengeWizard(id, rewardChoice);
} catch (err: unknown) {
if (err instanceof Error) {
alert(err.message);
}
}
},
[promptChallengerId, challengeWizard]
);
const showNotification = (msg: string) => { const showNotification = (msg: string) => {
setNotification(msg); setNotification(msg);
setTimeout(() => { setTimeout(() => {
@ -62,18 +99,20 @@ export function App() {
const handleQuickSpawn = async () => { const handleQuickSpawn = async () => {
const existingNames = new Set(boardState.players.map((p) => p.name)); const existingNames = new Set(boardState.players.map((p) => p.name));
const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name)); const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name));
const randomPiece: 'knight' | 'warrior' = Math.random() > 0.5 ? 'knight' : 'warrior';
const preset = const preset =
availablePresets.length > 0 availablePresets.length > 0
? availablePresets[Math.floor(Math.random() * availablePresets.length)] ? 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')}`, color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`,
strength: Math.floor(Math.random() * 3) + 1, strength: Math.floor(Math.random() * 3) + 1,
piece_type: randomPiece,
}; };
try { try {
const player = await registerPlayer(preset.name, preset.color, preset.strength); const player = await registerPlayer(preset.name, preset.color, preset.strength, preset.piece_type);
showNotification(`Spawned ${player.name} (Str: ${player.strength}) at (${player.x}, ${player.y})`); showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
showNotification(`Failed to spawn bot: ${err.message}`); showNotification(`Failed to spawn bot: ${err.message}`);
@ -155,6 +194,7 @@ export function App() {
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
availableMoves={availableMoves} availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
onChallengeWizard={handleOpenWizardPrompt}
/> />
{/* 8-Directional Movement D-Pad & Simulation Controls */} {/* 8-Directional Movement D-Pad & Simulation Controls */}
@ -168,6 +208,7 @@ export function App() {
onPass={async (id) => { onPass={async (id) => {
await passTurn(id); await passTurn(id);
}} }}
onChallengeWizard={handleOpenWizardPrompt}
onStepBot={stepActiveBotTurn} onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying} isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
@ -182,6 +223,7 @@ export function App() {
onOpenPartyModal={() => setIsPartyModalOpen(true)} onOpenPartyModal={() => setIsPartyModalOpen(true)}
onDefeatParty={handleDefeatParty} onDefeatParty={handleDefeatParty}
onFightBattle={handleFightBattle} onFightBattle={handleFightBattle}
onChallengeWizard={handleOpenWizardPrompt}
/> />
{/* Live Event Feed Notification */} {/* Live Event Feed Notification */}
@ -197,9 +239,9 @@ export function App() {
<RegisterModal <RegisterModal
isOpen={isRegisterOpen} isOpen={isRegisterOpen}
onClose={() => setIsRegisterOpen(false)} onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color) => { onRegister={async (name, color, pieceType, health) => {
const player = await registerPlayer(name, color); const player = await registerPlayer(name, color, 1, pieceType, health);
showNotification(`Deployed ${player.name} at (${player.x}, ${player.y})!`); showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`);
}} }}
/> />
@ -220,6 +262,21 @@ export function App() {
onClose={handleCloseBattle} onClose={handleCloseBattle}
/> />
{/* Wizard Challenge Reward Selector Prompt Modal */}
<WizardPromptModal
isOpen={Boolean(promptChallengerId && pendingWizardChallenger)}
challenger={pendingWizardChallenger}
wizard={boardState.wizard}
onConfirm={handleConfirmWizardChallenge}
onClose={() => setPromptChallengerId(null)}
/>
{/* 3-Bout D20 Wizard Challenge Modal */}
<WizardChallengeModal
challenge={activeWizardChallenge}
onClose={handleCloseWizardChallenge}
/>
{/* Game Conclusion Scoreboard Modal */} {/* Game Conclusion Scoreboard Modal */}
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && ( {showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (
<ScoreboardModal <ScoreboardModal

View File

@ -172,6 +172,28 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
</div> </div>
)} )}
{battle.health_losses && Object.keys(battle.health_losses).length > 0 && (
<div className="text-slate-300 leading-relaxed">
Defeated squad members suffered <span className="text-rose-400 font-bold">1-3 HP damage</span>:
<div className="mt-1 flex flex-wrap gap-1.5">
{Object.entries(battle.health_losses).map(([pid, loss]) => {
const isLeader = pid === battle.killed_leader_id;
const label = isLeader ? `${battle.killed_leader_name} (Leader)` : `Bot ${pid.slice(-4)}`;
return (
<span
key={pid}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-rose-950/70 border border-rose-800/80 text-rose-300 font-mono text-[11px]"
>
<span></span>
<span>{label}:</span>
<strong className="text-rose-200">-{loss} HP</strong>
</span>
);
})}
</div>
</div>
)}
<div className="text-slate-400 text-[11px] pt-1"> <div className="text-slate-400 text-[11px] pt-1">
New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> Total Strength:{' '} New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> Total Strength:{' '}
<strong className="text-amber-400">{battle.new_party_strength}</strong> <strong className="text-amber-400">{battle.new_party_strength}</strong>

View File

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { AvailableMovesResponse, BoardState, Player } from '../types'; import type { AvailableMovesResponse, BoardState, Player } from '../types';
import { drawPlayerPiece, drawWizardPiece, getPlayerPieceType, PixelAvatar, isPlayerDead } from '../utils/pixelAvatars';
interface BoardCanvasProps { interface BoardCanvasProps {
boardState: BoardState; boardState: BoardState;
@ -7,6 +8,7 @@ interface BoardCanvasProps {
availableMoves?: AvailableMovesResponse | null; availableMoves?: AvailableMovesResponse | null;
onSelectPlayer: (player: Player | null) => void; onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void; onHoverCoord?: (coord: { x: number; y: number } | null) => void;
onChallengeWizard?: (playerId: string) => void;
} }
export const BoardCanvas: React.FC<BoardCanvasProps> = ({ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
@ -15,6 +17,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
availableMoves, availableMoves,
onSelectPlayer, onSelectPlayer,
onHoverCoord, onHoverCoord,
onChallengeWizard,
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -421,88 +424,21 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.restore(); ctx.restore();
}); });
// Draw Players / Bots // Draw Wandering Wizard NPC
if (boardState.wizard) {
const wx = startX + (boardState.wizard.x - min_x) * cellSize;
const wy = startY + (boardState.wizard.y - min_y) * cellSize;
drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize);
}
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
boardState.players.forEach((player) => { boardState.players.forEach((player) => {
const px = startX + (player.x - min_x) * cellSize; const px = startX + (player.x - min_x) * cellSize;
const py = startY + (player.y - min_y) * cellSize; const py = startY + (player.y - min_y) * cellSize;
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === 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 drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn);
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();
}
}); });
ctx.restore(); // end clip ctx.restore(); // end clip
@ -621,6 +557,37 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y) ? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y)
: null; : null;
const isHoveredWizard = Boolean(
hoveredCoord &&
boardState.wizard &&
boardState.wizard.x === hoveredCoord.x &&
boardState.wizard.y === hoveredCoord.y
);
const liveSelectedPlayer = selectedPlayer
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const isSelectedAdjacentToWizard = Boolean(
liveSelectedPlayer &&
boardState.wizard &&
Math.max(
Math.abs(liveSelectedPlayer.x - boardState.wizard.x),
Math.abs(liveSelectedPlayer.y - boardState.wizard.y)
) <= 1
);
const isSelectedTurn = Boolean(
boardState.turn.game_started &&
liveSelectedPlayer &&
currentTurnId === liveSelectedPlayer.id
);
const canSelectedChallenge = Boolean(
liveSelectedPlayer &&
(!liveSelectedPlayer.party_id || liveSelectedPlayer.is_party_leader)
);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -651,6 +618,15 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'} {hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
</strong> </strong>
</span> </span>
{isHoveredWizard && (
<>
<span className="text-slate-500">|</span>
<span className="text-purple-300 font-bold flex items-center gap-1">
<span>🧙</span>
<span>Gary the Wizard NPC (Str: {boardState.wizard?.strength})</span>
</span>
</>
)}
{hoveredObstacle && ( {hoveredObstacle && (
<> <>
<span className="text-slate-500">|</span> <span className="text-slate-500">|</span>
@ -711,42 +687,93 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
</div> </div>
{/* Selected Player Overlay card */} {/* Selected Player Overlay card */}
{selectedPlayer && ( {liveSelectedPlayer && (() => {
<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"> const isDead = isPlayerDead(liveSelectedPlayer);
<div return (
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white shadow-md border-2 border-white/30" <div className="absolute bottom-4 left-4 bg-slate-900/95 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-2xl flex items-center gap-3 text-xs max-w-md">
style={{ backgroundColor: selectedPlayer.color }} <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
{selectedPlayer.name.slice(0, 2).toUpperCase()} pieceType={getPlayerPieceType(liveSelectedPlayer)}
</div> color={liveSelectedPlayer.color}
<div className="flex-1 min-w-0"> isLeader={!isDead && liveSelectedPlayer.is_party_leader}
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate"> size={36}
{selectedPlayer.name} />
{selectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)}
{selectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-emerald-400 font-mono"> Turn</span>
)}
</div> </div>
<div className="text-slate-400 font-mono text-[11px]"> <div className="flex-1 min-w-0">
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Score:{' '} <div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}> <span className={isDead ? 'text-slate-400 line-through' : 'text-slate-100'}>
{selectedPlayer.score} {liveSelectedPlayer.name}
</span> </span>
{isDead && (
<span className="text-[10px] text-rose-400 font-mono bg-rose-950/80 px-1.5 py-0.5 rounded border border-rose-800">
💀 DECEASED
</span>
)}
{!isDead && liveSelectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)}
{!isDead && liveSelectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-emerald-400 font-mono"> Turn</span>
)}
</div>
<div className="text-slate-400 font-mono text-[11px]">
Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) Score:{' '}
<span className={liveSelectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{liveSelectedPlayer.score}
</span>
{' '} HP: <span className="text-rose-400 font-bold">{isDead ? '💀 0' : `❤️${liveSelectedPlayer.health ?? 10}`}</span>
</div>
</div> </div>
{/* Challenge Wizard button on Selected Player Card (alive only) */}
{!isDead && isSelectedAdjacentToWizard && onChallengeWizard && (
<button
onClick={(e) => {
e.stopPropagation();
if (!boardState.turn.game_started) {
alert("Game has not started yet. Click 'Start Game' in the header first.");
return;
}
if (!isSelectedTurn) {
alert(`It is not ${liveSelectedPlayer.name}'s turn! Wait for their turn to challenge the wizard.`);
return;
}
if (!canSelectedChallenge) {
alert("Only party leader can challenge the wizard.");
return;
}
onChallengeWizard(liveSelectedPlayer.id);
}}
disabled={!isSelectedTurn || !canSelectedChallenge}
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold flex items-center gap-1.5 transition-all ${
isSelectedTurn && canSelectedChallenge
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
: 'bg-purple-950/50 text-purple-400/60 border border-purple-800/40 cursor-not-allowed'
}`}
title={
!isSelectedTurn
? `Wait for ${liveSelectedPlayer.name}'s turn`
: !canSelectedChallenge
? 'Only party leader can challenge'
: 'Challenge Wizard to a 3-bout D20 duel!'
}
>
<span>🧙</span>
<span>{isSelectedTurn && canSelectedChallenge ? 'Challenge Wizard' : 'Duel (Wait Turn)'}</span>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onSelectPlayer(null);
}}
className="text-slate-400 hover:text-slate-200 p-1"
>
</button>
</div> </div>
<button );
onClick={(e) => { })()}
e.stopPropagation();
onSelectPlayer(null);
}}
className="text-slate-400 hover:text-slate-200 p-1"
>
</button>
</div>
)}
</div> </div>
); );
}; };

View File

@ -35,7 +35,7 @@ export const Header: React.FC<HeaderProps> = ({
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2"> <h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
botWebWars botWebWars
<span className="text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-sky-950 text-sky-400 border border-sky-800"> <span className="text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-sky-950 text-sky-400 border border-sky-800">
v1.0 v{import.meta.env.VITE_APP_VERSION || '1.3'}
</span> </span>
</h1> </h1>
<p className="text-[11px] text-slate-400 font-mono"> <p className="text-[11px] text-slate-400 font-mono">

View File

@ -1,5 +1,6 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import type { AvailableMovesResponse, BoardState, Player } from '../types'; import type { AvailableMovesResponse, BoardState, Player } from '../types';
import { isPlayerDead } from '../utils/pixelAvatars';
interface MovementControlsProps { interface MovementControlsProps {
boardState: BoardState; boardState: BoardState;
@ -7,6 +8,7 @@ interface MovementControlsProps {
availableMoves: AvailableMovesResponse | null; availableMoves: AvailableMovesResponse | null;
onMove: (playerId: string, direction: string) => Promise<unknown>; onMove: (playerId: string, direction: string) => Promise<unknown>;
onPass: (playerId: string) => Promise<unknown>; onPass: (playerId: string) => Promise<unknown>;
onChallengeWizard?: (playerId: string) => Promise<unknown>;
onStepBot: () => void; onStepBot: () => void;
isAutoPlaying: boolean; isAutoPlaying: boolean;
onToggleAutoPlay: () => void; onToggleAutoPlay: () => void;
@ -18,15 +20,55 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
availableMoves, availableMoves,
onMove, onMove,
onPass, onPass,
onChallengeWizard,
onStepBot, onStepBot,
isAutoPlaying, isAutoPlaying,
onToggleAutoPlay, onToggleAutoPlay,
}) => { }) => {
const isStarted = Boolean(boardState.turn?.game_started); const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id; const currentTurnId = boardState.turn.current_player_id;
const activePlayer = boardState.players.find((p) => p.id === currentTurnId); const activePlayer = boardState.players.find((p) => p.id === currentTurnId) || null;
const controlledPlayer = selectedPlayer || activePlayer; // Always resolve the live coordinates from boardState.players to prevent stale player coordinates
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); const liveSelectedPlayer = selectedPlayer
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const controlledPlayer = liveSelectedPlayer || activePlayer;
const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer));
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const isAdjacentToWizard = Boolean(
!isDead &&
controlledPlayer &&
boardState.wizard &&
Math.max(
Math.abs(controlledPlayer.x - boardState.wizard.x),
Math.abs(controlledPlayer.y - boardState.wizard.y)
) <= 1
);
const canInitiateWizardChallenge = !controlledPlayer?.party_id || controlledPlayer?.is_party_leader;
const handleChallengeWizardClick = useCallback(() => {
if (!controlledPlayer) return;
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (!isMyTurn) {
alert(`It is not ${controlledPlayer.name}'s turn! Current turn belongs to ${activePlayer?.name || 'another bot'}.`);
return;
}
if (!canInitiateWizardChallenge) {
alert("Party member cannot challenge the wizard individually. Only party leader can initiate challenges.");
return;
}
if (!isAdjacentToWizard) {
alert("Must be within 1 space of the wizard to challenge.");
return;
}
onChallengeWizard?.(controlledPlayer.id).catch((err: unknown) => {
if (err instanceof Error) alert(err.message);
});
}, [controlledPlayer, isStarted, isMyTurn, canInitiateWizardChallenge, isAdjacentToWizard, activePlayer, onChallengeWizard]);
const handleDirectionClick = useCallback( const handleDirectionClick = useCallback(
(dir: string) => { (dir: string) => {
@ -187,6 +229,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700"> <span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700">
NOT STARTED NOT STARTED
</span> </span>
) : isDead ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-rose-950 text-rose-300 border border-rose-700 font-bold">
💀 DECEASED
</span>
) : isMyTurn ? ( ) : isMyTurn ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse"> <span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
YOUR TURN YOUR TURN
@ -233,6 +279,39 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
</div> </div>
</div> </div>
{/* Challenge Wizard Button if adjacent */}
{isAdjacentToWizard && onChallengeWizard && (
<button
onClick={handleChallengeWizardClick}
disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge}
className={`w-full py-2.5 px-3 rounded-xl font-bold font-mono text-xs flex items-center justify-center gap-2 border transition-all ${
!isStarted || !isMyTurn || !canInitiateWizardChallenge
? 'bg-purple-950/40 text-purple-400/60 border-purple-900/50 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-500 text-white border-purple-400 shadow-xl shadow-purple-950/70 animate-pulse active:scale-95 cursor-pointer'
}`}
title={
!isStarted
? 'Game has not started yet'
: !isMyTurn
? `Waiting for ${controlledPlayer?.name}'s turn`
: !canInitiateWizardChallenge
? 'Only party leader can challenge the wizard'
: 'Challenge the Wizard NPC to a 3-bout D20 duel! (+2 score on win, -2 health/pts on loss)'
}
>
<span className="text-base">🧙</span>
<span>
{!isStarted
? 'Start Game to Duel Wizard'
: !isMyTurn
? `Wait for Turn to Duel`
: !canInitiateWizardChallenge
? 'Leader Only'
: 'Challenge Wizard (D20 Duel)'}
</span>
</button>
)}
{/* Simulation / Bot Controls */} {/* Simulation / Bot Controls */}
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2"> <div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
<button <button

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import type { BoardState, Player } from '../types'; import type { BoardState, Player } from '../types';
import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars';
interface PlayerListProps { interface PlayerListProps {
boardState: BoardState; boardState: BoardState;
@ -9,6 +10,7 @@ interface PlayerListProps {
onOpenPartyModal?: () => void; onOpenPartyModal?: () => void;
onDefeatParty?: (partyId: string) => void; onDefeatParty?: (partyId: string) => void;
onFightBattle?: (challengerId: string, defenderId: string) => void; onFightBattle?: (challengerId: string, defenderId: string) => void;
onChallengeWizard?: (playerId: string) => void;
} }
export const PlayerList: React.FC<PlayerListProps> = ({ export const PlayerList: React.FC<PlayerListProps> = ({
@ -18,6 +20,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
onRemovePlayer, onRemovePlayer,
onOpenPartyModal, onOpenPartyModal,
onDefeatParty, onDefeatParty,
onChallengeWizard,
}) => { }) => {
const { players, parties = [], turn } = boardState; const { players, parties = [], turn } = boardState;
const currentTurnId = turn.current_player_id; const currentTurnId = turn.current_player_id;
@ -27,6 +30,27 @@ export const PlayerList: React.FC<PlayerListProps> = ({
p.member_ids.forEach((mid) => partyMap.set(mid, p.name)); p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
}); });
const isStarted = Boolean(turn.game_started);
const liveSelected = selectedPlayer
? players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const activePlayer = players.find((p) => p.id === currentTurnId) || null;
const candidate = liveSelected || activePlayer;
const candidateAdjacent = Boolean(
candidate &&
boardState.wizard &&
Math.max(
Math.abs(candidate.x - boardState.wizard.x),
Math.abs(candidate.y - boardState.wizard.y)
) <= 1
);
const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId);
const candidateCanChallenge = Boolean(
candidateAdjacent &&
candidateIsTurn &&
(!candidate?.party_id || candidate?.is_party_leader)
);
return ( return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80"> <div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */} {/* Header */}
@ -88,6 +112,68 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </div>
)} )}
{/* Wizard NPC Section */}
{boardState.wizard && (
<div className="p-3 border-b border-slate-800 bg-purple-950/20">
<div className="text-[11px] font-mono text-purple-300 uppercase tracking-wider mb-1.5 flex items-center justify-between">
<span>🧙 Wandering Wizard (NPC)</span>
<span className="text-[9px] bg-purple-900/60 px-1.5 py-0.5 rounded text-purple-300 border border-purple-700 font-mono">
CHALLENGE NPC
</span>
</div>
<div className="bg-slate-900/90 border border-purple-500/40 rounded-lg p-2 flex items-center justify-between text-xs">
<div className="min-w-0 flex-1">
<div className="font-bold text-purple-300 flex items-center gap-1.5">
<span>🧙</span> {boardState.wizard.name}
</div>
<div className="text-[10px] text-slate-400 font-mono mt-0.5">
pos: <span className="text-emerald-400">({boardState.wizard.x}, {boardState.wizard.y})</span> Str: <span className="text-amber-400 font-bold">{boardState.wizard.strength}</span>
</div>
</div>
{candidateAdjacent && onChallengeWizard ? (
<button
onClick={() => {
if (!candidate) return;
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (!candidateIsTurn) {
alert(`It is not ${candidate.name}'s turn! Wait for their turn to challenge.`);
return;
}
if (candidate.party_id && !candidate.is_party_leader) {
alert("Only party leader can challenge the wizard.");
return;
}
onChallengeWizard(candidate.id);
}}
disabled={!candidateCanChallenge}
className={`ml-2 px-2.5 py-1 rounded text-xs font-mono font-bold flex items-center gap-1 transition-all ${
candidateCanChallenge
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
: 'bg-purple-950/60 text-purple-400/60 border border-purple-800/40 cursor-not-allowed text-[10px]'
}`}
title={
!candidateIsTurn
? `Wait for ${candidate?.name || 'bot'}'s turn`
: candidate?.party_id && !candidate.is_party_leader
? 'Only party leader can challenge'
: `Challenge Wizard with ${candidate?.name}!`
}
>
<span></span>
<span>{candidateCanChallenge ? 'Challenge' : 'Wait Turn'}</span>
</button>
) : (
<div className="text-[10px] font-mono text-purple-400 bg-purple-950/80 border border-purple-800 px-2 py-1 rounded text-center">
D20 Duel
</div>
)}
</div>
</div>
)}
{/* Action to form party */} {/* Action to form party */}
{players.length >= 2 && onOpenPartyModal && ( {players.length >= 2 && onOpenPartyModal && (
<div className="p-2 border-b border-slate-800 bg-slate-950/30"> <div className="p-2 border-b border-slate-800 bg-slate-950/30">
@ -111,17 +197,22 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </div>
) : ( ) : (
players.map((player) => { players.map((player) => {
const isDead = isPlayerDead(player);
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id; const isCurrentTurn = !isDead && currentTurnId === player.id;
const isLeader = player.is_party_leader; const isLeader = !isDead && player.is_party_leader;
const partyName = partyMap.get(player.id); const partyName = !isDead ? partyMap.get(player.id) : null;
return ( return (
<div <div
key={player.id} key={player.id}
onClick={() => onSelectPlayer(player)} onClick={() => onSelectPlayer(player)}
className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${ className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${
isCurrentTurn isDead
? isSelected
? 'bg-slate-900/80 border-slate-600 shadow-md shadow-slate-900/50'
: 'bg-slate-950/40 border-slate-800/80 hover:border-slate-700 opacity-75'
: isCurrentTurn
? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10' ? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10'
: isSelected : isSelected
? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10' ? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10'
@ -129,19 +220,32 @@ export const PlayerList: React.FC<PlayerListProps> = ({
}`} }`}
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="relative"> <div className="relative flex-shrink-0">
<div <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={{ style={{
backgroundColor: player.color, borderColor: isDead
boxShadow: isLeader ? '#64748b'
? '0 0 14px #fbbf24' : isLeader
? '#fbbf24'
: isCurrentTurn : isCurrentTurn
? '0 0 14px #f59e0b' ? '#f59e0b'
: `0 0 10px ${player.color}55`, : `${player.color}66`,
boxShadow: isDead
? 'none'
: 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> </div>
{isLeader && ( {isLeader && (
<span className="absolute -top-2 -right-1 text-[12px] leading-none"> <span className="absolute -top-2 -right-1 text-[12px] leading-none">
@ -152,24 +256,38 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-200 truncate"> <span className={`text-xs font-semibold truncate ${isDead ? 'text-slate-400 line-through' : 'text-slate-200'}`}>
{player.name} {player.name}
</span> </span>
<span {isDead ? (
className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${ <span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-rose-950/80 text-rose-400 border border-rose-800 font-bold">
player.score < 0 💀 DEAD
? 'bg-rose-950 text-rose-300 border border-rose-800' </span>
: player.score > 0 ) : (
? 'bg-emerald-950 text-emerald-300 border border-emerald-800' <span
: 'bg-slate-800 text-slate-400' className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
}`} player.score < 0
> ? 'bg-rose-950 text-rose-300 border border-rose-800'
{player.score >= 0 ? `+${player.score}` : player.score} pts : player.score > 0
</span> ? 'bg-emerald-950 text-emerald-300 border border-emerald-800'
: 'bg-slate-800 text-slate-400'
}`}
>
{player.score >= 0 ? `+${player.score}` : player.score} pts
</span>
)}
</div> </div>
<div className="text-[11px] font-mono text-slate-400"> <div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> Str: <span className="text-amber-400">{player.strength || 1}</span> pos: <span className="text-emerald-400">({player.x}, {player.y})</span> Str: <span className="text-amber-400">{player.strength || 1}</span> HP:{' '}
{partyName && ( <span className={isDead ? 'text-rose-500 font-bold' : 'text-rose-400 font-bold'}>
{isDead ? '💀 0' : `❤️${player.health ?? 10}`}
</span>
{isDead && (
<span className="ml-1 text-slate-500 font-sans italic">
Score: {player.score}
</span>
)}
{partyName && !isDead && (
<span className="ml-1 text-sky-400 truncate font-sans"> <span className="ml-1 text-sky-400 truncate font-sans">
{isLeader ? 'Leader' : 'Squad'} {isLeader ? 'Leader' : 'Squad'}
</span> </span>
@ -178,7 +296,53 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 opacity-60 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
{!isDead &&
boardState.wizard &&
Math.max(
Math.abs(player.x - boardState.wizard.x),
Math.abs(player.y - boardState.wizard.y)
) <= 1 &&
onChallengeWizard && (
<button
onClick={(e) => {
e.stopPropagation();
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (currentTurnId !== player.id) {
alert(`It is not ${player.name}'s turn! Wait for their turn to challenge.`);
return;
}
if (player.party_id && !player.is_party_leader) {
alert("Only party leader can challenge the wizard.");
return;
}
onChallengeWizard(player.id);
}}
disabled={
!isStarted ||
currentTurnId !== player.id ||
Boolean(player.party_id && !player.is_party_leader)
}
className={`px-2 py-0.5 rounded text-[10px] font-mono font-bold flex items-center gap-1 transition-all ${
isStarted &&
currentTurnId === player.id &&
(!player.party_id || player.is_party_leader)
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
: 'bg-purple-950/50 text-purple-400/50 border border-purple-800/40 cursor-not-allowed'
}`}
title={
currentTurnId === player.id
? `Challenge Wizard with ${player.name}!`
: `Wait for ${player.name}'s turn to challenge`
}
>
<span>🧙</span>
<span>Duel</span>
</button>
)}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -1,35 +1,38 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { PixelAvatar, type PieceType } from '../utils/pixelAvatars';
const PRESET_COLORS = [ export const PRESET_FACTIONS = [
'#38BDF8', // Sky Blue { color: '#38BDF8', name: 'Azure Order' },
'#F43F5E', // Rose / Red { color: '#F43F5E', name: 'Crimson Legion' },
'#10B981', // Emerald Green { color: '#10B981', name: 'Emerald Wardens' },
'#F59E0B', // Amber { color: '#F59E0B', name: 'Golden Templars' },
'#A855F7', // Purple { color: '#A855F7', name: 'Amethyst Guard' },
'#EC4899', // Pink { color: '#EC4899', name: 'Roseblade Order' },
'#06B6D4', // Cyan { color: '#06B6D4', name: 'Frostguard' },
'#EAB308', // Yellow { color: '#EAB308', name: 'Solar Vanguard' },
'#6366F1', // Indigo { color: '#6366F1', name: 'Twilight Sentinels' },
'#14B8A6', // Teal { color: '#14B8A6', name: 'Jade Protectors' },
]; ];
const RANDOM_NAMES = [ const RANDOM_NAMES = [
'CyberViper', 'AzureKnight',
'NexusBot', 'CrimsonWarrior',
'PulseGhost', 'StormPaladin',
'ApexVector', 'IronBerserker',
'IronShard', 'ShadowKnight',
'NovaMatrix', 'ApexGladiator',
'QuantumGlitch', 'FrostChampion',
'EchoZero', 'ThunderWarden',
'TitanByte', 'TitanKnight',
'ShadowCircuit', 'ViperWarrior',
'SolarTemplar',
'EmeraldWarden',
]; ];
interface RegisterModalProps { interface RegisterModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onRegister: (name: string, color: string) => Promise<unknown>; onRegister: (name: string, color: string, pieceType?: PieceType, health?: number) => Promise<unknown>;
} }
export const RegisterModal: React.FC<RegisterModalProps> = ({ export const RegisterModal: React.FC<RegisterModalProps> = ({
@ -39,16 +42,27 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
}) => { }) => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [color, setColor] = useState('#38BDF8'); const [color, setColor] = useState('#38BDF8');
const [pieceType, setPieceType] = useState<PieceType>('knight');
const [health, setHealth] = useState<number>(10);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
if (!isOpen) return null; if (!isOpen) return null;
const currentFaction = PRESET_FACTIONS.find(
(f) => f.color.toUpperCase() === color.toUpperCase()
);
const handleRandomize = () => { const handleRandomize = () => {
const randomName = RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100); const randomName =
const randomColor = PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]; 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); setName(randomName);
setColor(randomColor); setColor(randomFaction.color);
setPieceType(randomType);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -61,9 +75,10 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
try { try {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
await onRegister(name.trim(), color); await onRegister(name.trim(), color, pieceType, health);
onClose(); onClose();
setName(''); setName('');
setHealth(10);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message); setError(err.message);
@ -76,16 +91,16 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
}; };
return ( 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="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 className="flex justify-between items-center mb-5 pb-3 border-b border-slate-800">
<div> <div>
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2"> <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" /> <span className="w-3 h-3 rounded-full bg-emerald-400 inline-block animate-pulse" />
Register Player Avatar Register Board Game Piece
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5"> <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> </p>
</div> </div>
<button <button
@ -102,31 +117,86 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Avatar Preview */} {/* Miniature Preview */}
<div className="flex items-center justify-center py-4 bg-slate-950/60 rounded-xl border border-slate-800"> <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="flex flex-col items-center gap-2">
<div <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={{ style={{
backgroundColor: color, borderColor: `${color}88`,
boxShadow: `0 0 20px ${color}66`, boxShadow: `0 0 25px ${color}44`,
}} }}
> >
<div className="w-6 h-6 rounded-full bg-white/90 shadow-inner flex items-center justify-center"> <PixelAvatar
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} /> 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>
<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>
<span className="text-xs font-mono font-medium text-slate-300"> </div>
{name.trim() ? name : 'Avatar Preview'} </div>
</span>
{/* 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>
</div> </div>
{/* Player Name */} {/* Player Name */}
<div> <div>
<div className="flex justify-between items-center mb-1.5"> <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 <button
type="button" type="button"
onClick={handleRandomize} onClick={handleRandomize}
@ -139,31 +209,71 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. CyberKnight" placeholder="e.g. AzureKnight"
maxLength={32} 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" 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 required
/> />
</div> </div>
{/* Color Chooser */} {/* Starting Health */}
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-2"> <div className="flex justify-between items-center mb-1.5">
Avatar Color <label className="text-xs font-semibold text-slate-300">
</label> Starting Health (HP)
</label>
<span className="text-[11px] font-mono text-rose-400 font-semibold">
{health} HP {health === 10 ? '(Default)' : ''}
</span>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={30}
value={health}
onChange={(e) => setHealth(Number(e.target.value))}
className="flex-1 accent-rose-500 cursor-pointer"
/>
<input
type="number"
min={1}
max={100}
value={health}
onChange={(e) => setHealth(Math.max(1, Number(e.target.value)))}
className="w-16 bg-slate-950 border border-slate-700 focus:border-rose-500 rounded-lg px-2.5 py-1.5 text-xs font-mono text-rose-300 text-center focus:outline-none"
/>
</div>
</div>
{/* Color & Faction Chooser */}
<div>
<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"> <div className="grid grid-cols-5 gap-2 mb-3">
{PRESET_COLORS.map((preset) => ( {PRESET_FACTIONS.map((preset) => (
<button <button
key={preset} key={preset.color}
type="button" type="button"
onClick={() => setColor(preset)} onClick={() => setColor(preset.color)}
className={`h-8 rounded-lg transition-transform ${ title={preset.name}
color.toUpperCase() === preset.toUpperCase() className={`h-9 rounded-lg flex items-center justify-center transition-all ${
? 'ring-2 ring-white scale-105' color.toUpperCase() === preset.color.toUpperCase()
: 'hover:scale-102 opacity-80 hover:opacity-100' ? '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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import type { GameConclusion, Player } from '../types'; import type { GameConclusion, Player } from '../types';
import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars';
interface ScoreboardModalProps { interface ScoreboardModalProps {
conclusion: GameConclusion | null; conclusion: GameConclusion | null;
@ -203,71 +204,109 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
{/* Podium for Top 3 */} {/* Podium for Top 3 */}
<div style={styles.podiumContainer}> <div style={styles.podiumContainer}>
{/* 2nd Place */} {/* 2nd Place */}
{rankedPlayers[1] && ( {rankedPlayers[1] && (() => {
<div style={{ ...styles.podiumCard, ...styles.silverCard }}> const isDead = isPlayerDead(rankedPlayers[1]);
<div style={styles.medalBadge}>🥈</div> return (
<div style={styles.placeLabel}>2nd Place</div> <div style={{ ...styles.podiumCard, ...styles.silverCard }}>
<div <div style={styles.medalBadge}>🥈</div>
style={{ <div style={styles.placeLabel}>2nd Place</div>
...styles.avatarCircle, <div
backgroundColor: rankedPlayers[1].color, style={{
}} ...styles.avatarCircle,
> backgroundColor: 'rgba(15, 23, 42, 0.8)',
{rankedPlayers[1].name.substring(0, 2).toUpperCase()} display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderColor: isDead ? '#64748b' : '#94a3b8',
}}
>
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[1])}
color={rankedPlayers[1].color}
isLeader={!isDead && rankedPlayers[1].id === conclusion.winning_leader_id}
size={52}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[1].name}</div>
{isDead && <div style={{ fontSize: '11px', color: '#ef4444', fontWeight: 'bold' }}>💀 Fallen (0 HP)</div>}
<div style={styles.podiumScore}>{rankedPlayers[1].score} pts</div>
<div style={styles.podiumDetails}> {rankedPlayers[1].strength} STR</div>
</div> </div>
<div style={styles.podiumBotName}>{rankedPlayers[1].name}</div> );
<div style={styles.podiumScore}>{rankedPlayers[1].score} pts</div> })()}
<div style={styles.podiumDetails}> {rankedPlayers[1].strength} STR</div>
</div>
)}
{/* 1st Place (Center / Tallest) */} {/* 1st Place (Center / Tallest) */}
{rankedPlayers[0] && ( {rankedPlayers[0] && (() => {
<div style={{ ...styles.podiumCard, ...styles.goldCard }}> const isDead = isPlayerDead(rankedPlayers[0]);
<div style={styles.crown}>👑</div> return (
<div style={styles.medalBadge}>🥇</div> <div style={{ ...styles.podiumCard, ...styles.goldCard }}>
<div style={{ ...styles.placeLabel, color: '#FFD700', fontWeight: 'bold' }}> <div style={styles.crown}>{isDead ? '🪦' : '👑'}</div>
1st Place Champion <div style={styles.medalBadge}>🥇</div>
<div style={{ ...styles.placeLabel, color: '#FFD700', fontWeight: 'bold' }}>
1st Place Champion
</div>
<div
style={{
...styles.avatarCircle,
backgroundColor: 'rgba(15, 23, 42, 0.85)',
border: isDead ? '3px solid #64748b' : '3px solid #FFD700',
boxShadow: isDead ? 'none' : '0 0 20px rgba(255, 215, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[0])}
color={rankedPlayers[0].color}
isLeader={!isDead}
size={60}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[0].name}</div>
{isDead && <div style={{ fontSize: '11px', color: '#ef4444', fontWeight: 'bold' }}>💀 Fallen (0 HP)</div>}
<div style={{ ...styles.podiumScore, color: '#FFD700' }}>
{rankedPlayers[0].score} pts
</div>
<div style={styles.podiumDetails}> {rankedPlayers[0].strength} STR</div>
{!isDead && rankedPlayers[0].id === conclusion.winning_leader_id && (
<div style={styles.leaderBadge}>Supreme Leader</div>
)}
</div> </div>
<div );
style={{ })()}
...styles.avatarCircle,
backgroundColor: rankedPlayers[0].color,
border: '3px solid #FFD700',
boxShadow: '0 0 15px rgba(255, 215, 0, 0.6)',
}}
>
{rankedPlayers[0].name.substring(0, 2).toUpperCase()}
</div>
<div style={styles.podiumBotName}>{rankedPlayers[0].name}</div>
<div style={{ ...styles.podiumScore, color: '#FFD700' }}>
{rankedPlayers[0].score} pts
</div>
<div style={styles.podiumDetails}> {rankedPlayers[0].strength} STR</div>
{rankedPlayers[0].id === conclusion.winning_leader_id && (
<div style={styles.leaderBadge}>Supreme Leader</div>
)}
</div>
)}
{/* 3rd Place */} {/* 3rd Place */}
{rankedPlayers[2] && ( {rankedPlayers[2] && (() => {
<div style={{ ...styles.podiumCard, ...styles.bronzeCard }}> const isDead = isPlayerDead(rankedPlayers[2]);
<div style={styles.medalBadge}>🥉</div> return (
<div style={styles.placeLabel}>3rd Place</div> <div style={{ ...styles.podiumCard, ...styles.bronzeCard }}>
<div <div style={styles.medalBadge}>🥉</div>
style={{ <div style={styles.placeLabel}>3rd Place</div>
...styles.avatarCircle, <div
backgroundColor: rankedPlayers[2].color, style={{
}} ...styles.avatarCircle,
> backgroundColor: 'rgba(15, 23, 42, 0.8)',
{rankedPlayers[2].name.substring(0, 2).toUpperCase()} display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderColor: isDead ? '#64748b' : '#cd7f32',
}}
>
<PixelAvatar
pieceType={getPlayerPieceType(rankedPlayers[2])}
color={rankedPlayers[2].color}
isLeader={!isDead && rankedPlayers[2].id === conclusion.winning_leader_id}
size={52}
/>
</div>
<div style={styles.podiumBotName}>{rankedPlayers[2].name}</div>
{isDead && <div style={{ fontSize: '11px', color: '#ef4444', fontWeight: 'bold' }}>💀 Fallen (0 HP)</div>}
<div style={styles.podiumScore}>{rankedPlayers[2].score} pts</div>
<div style={styles.podiumDetails}> {rankedPlayers[2].strength} STR</div>
</div> </div>
<div style={styles.podiumBotName}>{rankedPlayers[2].name}</div> );
<div style={styles.podiumScore}>{rankedPlayers[2].score} pts</div> })()}
<div style={styles.podiumDetails}> {rankedPlayers[2].strength} STR</div>
</div>
)}
</div> </div>
{/* Complete Scoreboard Rankings Table */} {/* Complete Scoreboard Rankings Table */}
@ -288,6 +327,7 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
<tbody> <tbody>
{rankedPlayers.map((player, idx) => { {rankedPlayers.map((player, idx) => {
const rank = idx + 1; const rank = idx + 1;
const isDead = isPlayerDead(player);
let rankBadge = `#${rank}`; let rankBadge = `#${rank}`;
let rowStyle = styles.tr; let rowStyle = styles.tr;
@ -302,23 +342,31 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
rowStyle = { ...styles.tr, ...styles.bronzeRow }; rowStyle = { ...styles.tr, ...styles.bronzeRow };
} }
const isLeader = player.id === conclusion.winning_leader_id; const isLeader = !isDead && player.id === conclusion.winning_leader_id;
return ( return (
<tr key={player.id} style={rowStyle}> <tr key={player.id} style={isDead ? { ...rowStyle, opacity: 0.8 } : rowStyle}>
<td style={styles.tdRank}>{rankBadge}</td> <td style={styles.tdRank}>{rankBadge}</td>
<td style={styles.tdBot}> <td style={styles.tdBot}>
<span <PixelAvatar
style={{ pieceType={getPlayerPieceType(player)}
...styles.botColorIndicator, color={player.color}
backgroundColor: player.color, isLeader={isLeader}
}} size={24}
className="mr-2"
/> />
<span style={styles.botNameText}>{player.name}</span> <span style={styles.botNameText}>{player.name}</span>
{isDead && <span style={{ ...styles.inlineLeaderTag, backgroundColor: '#7f1d1d', color: '#fca5a5' }}>💀 Fallen</span>}
{isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>} {isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>}
</td> </td>
<td style={styles.tdRole}> <td style={styles.tdRole}>
{isLeader ? 'Supreme Leader' : 'Squad Member'} {isDead ? (
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>Fallen (Deceased)</span>
) : isLeader ? (
'Supreme Leader'
) : (
'Squad Member'
)}
</td> </td>
<td style={styles.tdStrength}> {player.strength}</td> <td style={styles.tdStrength}> {player.strength}</td>
<td style={styles.tdVisited}> <td style={styles.tdVisited}>

View File

@ -0,0 +1,230 @@
import React, { useEffect, useRef, useState } from 'react';
import type { WizardChallengeResult } from '../types';
interface WizardChallengeModalProps {
challenge: WizardChallengeResult | null;
onClose: () => void;
}
export const WizardChallengeModal: React.FC<WizardChallengeModalProps> = ({
challenge,
onClose,
}) => {
const [countdown, setCountdown] = useState(5);
const [progressPercent, setProgressPercent] = useState(100);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
});
const challengeKey = challenge
? `${challenge.challenger_id}_${challenge.player_bouts_won}_${challenge.wizard_bouts_won}_${challenge.reward_chosen}_${challenge.score_change}_${challenge.strength_change}_${challenge.health_change}_${challenge.wizard_respawn_position?.x}_${challenge.wizard_respawn_position?.y}`
: null;
useEffect(() => {
if (!challengeKey) {
setCountdown(5);
setProgressPercent(100);
return;
}
const DURATION_MS = 5000;
const targetEndTime = Date.now() + DURATION_MS;
setCountdown(5);
setProgressPercent(100);
const timer = window.setInterval(() => {
const now = Date.now();
const remainingMs = targetEndTime - now;
if (remainingMs <= 0) {
window.clearInterval(timer);
setCountdown(0);
setProgressPercent(0);
onCloseRef.current();
return;
}
const seconds = Math.ceil(remainingMs / 1000);
const percent = Math.min(100, Math.max(0, (remainingMs / DURATION_MS) * 100));
setCountdown(seconds);
setProgressPercent(percent);
}, 100);
return () => {
window.clearInterval(timer);
};
}, [challengeKey]);
if (!challenge) return null;
const playerName = challenge.challenger_name;
const wizardName = challenge.wizard_name || 'Gary the Wizard';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
<div className="bg-slate-900 border-2 border-purple-500/80 rounded-2xl w-full max-w-xl shadow-2xl p-6 relative overflow-hidden">
{/* Glowing cyber wizard header banner */}
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-purple-500 via-fuchsia-500 to-indigo-500" />
{/* 5-second automatic progress bar */}
<div className="absolute top-2 left-0 right-0 h-1 bg-slate-800">
<div
className="h-full bg-purple-400 transition-all duration-100 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-800 mt-1">
<div className="flex items-center gap-2">
<span className="text-2xl">🧙</span>
<div>
<h2 className="text-base font-bold text-slate-100 font-mono tracking-wide flex items-center gap-2">
<span>WIZARD NPC DUEL: 3-BOUT D20 CHALLENGE</span>
</h2>
<p className="text-xs text-slate-400">
<span className="text-sky-300 font-semibold">{playerName}</span> vs{' '}
<span className="text-purple-300 font-semibold">{wizardName}</span>
</p>
</div>
</div>
<button
onClick={() => onCloseRef.current()}
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
title="Dismiss now"
>
</button>
</div>
{/* 3 Bouts Breakdown */}
<div className="space-y-3 mb-5">
{challenge.bouts.map((bout) => (
<div
key={bout.bout_number}
className={`p-3 rounded-xl border flex items-center justify-between transition-colors ${
bout.winner === 'player'
? 'bg-sky-950/30 border-sky-500/50'
: bout.winner === 'wizard'
? 'bg-purple-950/30 border-purple-500/50'
: 'bg-slate-800/40 border-slate-700'
}`}
>
{/* Bout Index */}
<div className="w-16 font-mono text-xs font-bold text-slate-400">
Bout #{bout.bout_number}
</div>
{/* Player Bout Score */}
<div className="flex-1 text-left px-2">
<div className="text-xs font-bold text-slate-200 truncate">
{playerName}
</div>
<div className="text-[11px] font-mono text-slate-400 flex items-center gap-1">
<span>🎲 D20({bout.player_roll})</span>
<span>×</span>
<span>Str({bout.player_strength})</span>
<span>=</span>
<strong className="text-sky-400 text-xs">{bout.player_score}</strong>
</div>
</div>
{/* VS Divider */}
<div className="px-2 text-slate-600 font-mono text-xs font-black">VS</div>
{/* Wizard Bout Score */}
<div className="flex-1 text-right px-2">
<div className="text-xs font-bold text-purple-300 truncate">
{wizardName}
</div>
<div className="text-[11px] font-mono text-slate-400 flex items-center justify-end gap-1">
<strong className="text-purple-400 text-xs">{bout.wizard_score}</strong>
<span>=</span>
<span>Str({bout.wizard_strength})</span>
<span>×</span>
<span>🎲 D20({bout.wizard_roll})</span>
</div>
</div>
{/* Bout Outcome Badge */}
<div className="w-20 text-right">
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded font-bold uppercase ${
bout.winner === 'player'
? 'bg-sky-900/80 text-sky-200 border border-sky-600'
: bout.winner === 'wizard'
? 'bg-purple-900/80 text-purple-200 border border-purple-600'
: 'bg-slate-700 text-slate-300'
}`}
>
{bout.winner === 'player'
? 'Player'
: bout.winner === 'wizard'
? 'Wizard'
: 'Tie'}
</span>
</div>
</div>
))}
</div>
{/* Overall Match Outcome Banner */}
<div
className={`p-4 rounded-xl border mb-4 text-center ${
challenge.player_won
? 'bg-emerald-950/40 border-emerald-500/60 text-emerald-200'
: 'bg-rose-950/40 border-rose-500/60 text-rose-200'
}`}
>
<div className="text-xs font-mono uppercase tracking-wider mb-1">
Duel Outcome: {challenge.player_bouts_won} - {challenge.wizard_bouts_won}
</div>
<div className="text-base font-extrabold flex items-center justify-center gap-2">
<span>{challenge.player_won ? '✨ CHALLENGE VICTORY ✨' : '💀 CHALLENGE DEFEAT 💀'}</span>
</div>
<div className="mt-2 text-xs font-mono space-y-1">
{challenge.player_won ? (
<div className="text-emerald-300 font-bold space-y-0.5">
{challenge.reward_chosen === 'strength' ? (
<div> Victory Reward: +{challenge.strength_change || 2} Strength! (Total Strength: {challenge.new_strength ?? '?'})</div>
) : challenge.reward_chosen === 'health' ? (
<div> Victory Reward: +{challenge.health_change || 2} Health! (Total HP: {challenge.new_health})</div>
) : (
<div>🏆 Victory Reward: +{challenge.score_change || 2} Score Points! (Total Score: {challenge.new_score})</div>
)}
</div>
) : (
<div className="text-rose-300 font-bold">
{challenge.health_change < 0 ? (
<span>
💔 Lost {Math.abs(challenge.health_change)} Health! (Remaining HP: {challenge.new_health})
</span>
) : (
<span>
📉 Out of HP! Lost {Math.abs(challenge.score_change)} Score points! (Score: {challenge.new_score})
</span>
)}
</div>
)}
<div className="text-purple-300/80 text-[11px] pt-1">
🔮 The Wizard vanished in a puff of smoke and teleported to ({challenge.wizard_respawn_position?.x}, {challenge.wizard_respawn_position?.y}).
</div>
</div>
</div>
{/* Modal Controls / Auto-close countdown */}
<div className="flex items-center justify-between text-xs text-slate-400 font-mono">
<span>Auto-closing in {countdown}s...</span>
<button
onClick={() => onCloseRef.current()}
className="px-4 py-1.5 rounded-lg bg-slate-800 hover:bg-purple-700 text-slate-200 hover:text-white border border-slate-700 transition-colors font-semibold"
>
Acknowledge & Close
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,196 @@
import React, { useState } from 'react';
import type { Player, WizardNPC, WizardRewardChoice } from '../types';
interface WizardPromptModalProps {
isOpen: boolean;
challenger: Player | null;
wizard?: WizardNPC | null;
onConfirm: (rewardChoice: WizardRewardChoice) => void;
onClose: () => void;
}
export const WizardPromptModal: React.FC<WizardPromptModalProps> = ({
isOpen,
challenger,
wizard,
onConfirm,
onClose,
}) => {
const [selectedReward, setSelectedReward] = useState<WizardRewardChoice>('score');
if (!isOpen || !challenger) return null;
const wizardName = wizard?.name || 'Gary the Wizard';
const wizardStr = wizard?.strength ?? 3.0;
const options: Array<{
id: WizardRewardChoice;
title: string;
icon: string;
description: string;
badge: string;
activeBorder: string;
activeBg: string;
}> = [
{
id: 'score',
title: '+2 Score Points',
icon: '🏆',
description: 'Climb the scoreboard and advance toward tournament victory.',
badge: 'Current Score: ' + challenger.score,
activeBorder: 'border-amber-400',
activeBg: 'bg-amber-950/40',
},
{
id: 'strength',
title: '+2 Strength',
icon: '⚡',
description: 'Permanently boost combat power and roll multiplier for battles.',
badge: 'Current Str: ' + challenger.strength,
activeBorder: 'border-sky-400',
activeBg: 'bg-sky-950/40',
},
{
id: 'health',
title: '+2 Health (HP)',
icon: '❤️',
description: 'Heal and reinforce bot survivability against lethal battle damage.',
badge: `Current HP: ${challenger.health}/${challenger.max_health}`,
activeBorder: 'border-rose-400',
activeBg: 'bg-rose-950/40',
},
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
<div className="bg-slate-900 border-2 border-purple-500/80 rounded-2xl w-full max-w-lg shadow-2xl p-6 relative overflow-hidden">
{/* Top accent bar */}
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-purple-500 via-fuchsia-500 to-indigo-500" />
{/* Modal Header */}
<div className="flex items-center justify-between pb-3 border-b border-slate-800 mt-1">
<div className="flex items-center gap-2.5">
<span className="text-2xl">🧙</span>
<div>
<h2 className="text-base font-bold text-slate-100 font-mono tracking-wide">
CHALLENGE GARY THE WIZARD
</h2>
<p className="text-xs text-slate-400 font-mono">
Select your victory reward before entering the 3-bout D20 duel
</p>
</div>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono cursor-pointer"
title="Cancel"
>
</button>
</div>
{/* Matchup Banner */}
<div className="my-4 p-3 rounded-xl bg-slate-950/60 border border-slate-800 flex items-center justify-between font-mono text-xs">
<div className="flex items-center gap-2">
<span
className="w-3.5 h-3.5 rounded-full inline-block shrink-0"
style={{ backgroundColor: challenger.color }}
/>
<div>
<span className="font-bold text-slate-100">{challenger.name}</span>
<div className="text-[11px] text-slate-400">
Str: {challenger.strength} HP: {challenger.health} 🏆Score: {challenger.score}
</div>
</div>
</div>
<div className="font-bold text-purple-400 px-3">VS</div>
<div className="text-right">
<span className="font-bold text-purple-300">{wizardName}</span>
<div className="text-[11px] text-slate-400">
Str: {wizardStr} Wandering NPC
</div>
</div>
</div>
{/* Reward Choice Selector */}
<div className="space-y-2.5 mb-4">
<label className="block text-xs font-mono font-bold text-purple-300 uppercase tracking-wider">
Choose Victory Reward (if won):
</label>
<div className="grid grid-cols-1 gap-2.5">
{options.map((opt) => {
const isSelected = selectedReward === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => setSelectedReward(opt.id)}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex items-start justify-between ${
isSelected
? `${opt.activeBg} ${opt.activeBorder} ring-1 ring-purple-500/50 shadow-md shadow-purple-950/50`
: 'bg-slate-800/40 border-slate-700/80 hover:bg-slate-800 hover:border-slate-600'
}`}
>
<div className="flex items-start gap-3">
<span className="text-xl pt-0.5">{opt.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-bold text-slate-100">
{opt.title}
</span>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-slate-800 text-slate-400 border border-slate-700">
{opt.badge}
</span>
</div>
<p className="text-[11px] text-slate-400 mt-0.5 leading-tight">
{opt.description}
</p>
</div>
</div>
<div className="pt-1">
<div
className={`w-4 h-4 rounded-full border flex items-center justify-center ${
isSelected
? 'border-purple-400 bg-purple-600'
: 'border-slate-600 bg-slate-900'
}`}
>
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Defeat Risk Warning */}
<div className="p-2.5 rounded-lg bg-rose-950/30 border border-rose-800/40 text-rose-300 text-[11px] font-mono flex items-center gap-2 mb-5">
<span></span>
<span>
<strong>Defeat Risk:</strong> If defeated, {challenger.name} will lose 2 HP (or 2 score points if no HP remains).
</span>
</div>
{/* Modal Actions */}
<div className="flex items-center justify-end gap-3 font-mono text-xs">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm(selectedReward)}
className="px-5 py-2 rounded-xl bg-purple-600 hover:bg-purple-500 active:scale-95 text-white font-bold transition-all shadow-lg shadow-purple-900/60 flex items-center gap-1.5 cursor-pointer"
>
<span></span>
<span>Initiate Duel (+2 {selectedReward})</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -10,6 +10,8 @@ import type {
PartyDefeatResult, PartyDefeatResult,
Player, Player,
TurnInfo, TurnInfo,
WizardChallengeResult,
WizardRewardChoice,
} from '../types'; } from '../types';
const INITIAL_BOARD: BoardState = { const INITIAL_BOARD: BoardState = {
@ -25,6 +27,7 @@ const INITIAL_BOARD: BoardState = {
players: [], players: [],
parties: [], parties: [],
obstacles: [], obstacles: [],
wizard: null,
turn: { turn: {
game_started: false, game_started: false,
current_player_id: null, current_player_id: null,
@ -45,6 +48,7 @@ export function useGameSocket() {
const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [isAutoPlaying, setIsAutoPlaying] = useState(false);
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null); const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null); const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null);
const [showScoreboard, setShowScoreboard] = useState(false); const [showScoreboard, setShowScoreboard] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@ -52,6 +56,8 @@ export function useGameSocket() {
const autoPlayIntervalRef = useRef<number | null>(null); const autoPlayIntervalRef = useRef<number | null>(null);
const activeBattleRef = useRef<BattleResult | null>(null); const activeBattleRef = useRef<BattleResult | null>(null);
activeBattleRef.current = activeBattle; activeBattleRef.current = activeBattle;
const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null);
activeWizardChallengeRef.current = activeWizardChallenge;
const fetchBoard = useCallback(async () => { const fetchBoard = useCallback(async () => {
try { try {
@ -63,6 +69,10 @@ export function useGameSocket() {
parties: data.parties || prev.parties || [], parties: data.parties || prev.parties || [],
conclusion: data.conclusion || null, conclusion: data.conclusion || null,
})); }));
setSelectedPlayer((curr) => {
if (!curr) return null;
return data.players.find((p) => p.id === curr.id) || null;
});
if (data.conclusion && data.conclusion.concluded) { if (data.conclusion && data.conclusion.concluded) {
setShowScoreboard(true); setShowScoreboard(true);
} }
@ -112,6 +122,10 @@ export function useGameSocket() {
parties: data.state.parties || [], parties: data.state.parties || [],
conclusion: data.state.conclusion || null, conclusion: data.state.conclusion || null,
}); });
setSelectedPlayer((curr) => {
if (!curr || !data.state?.players) return null;
return data.state.players.find((p: Player) => p.id === curr.id) || null;
});
if (data.state.conclusion && data.state.conclusion.concluded) { if (data.state.conclusion && data.state.conclusion.concluded) {
setShowScoreboard(true); setShowScoreboard(true);
} }
@ -145,6 +159,19 @@ export function useGameSocket() {
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
}; };
}); });
setSelectedPlayer((curr) => {
if (!curr) return null;
if (data.affected_players) {
const match = data.affected_players.find((p: Player) => p.id === curr.id);
if (match) return match;
}
if (data.player && data.player.id === curr.id) return data.player;
if (data.players) {
const match = data.players.find((p: Player) => p.id === curr.id);
if (match) return match;
}
return curr;
});
} else if (data.event === 'party_formed' || data.event === 'party_updated') { } else if (data.event === 'party_formed' || data.event === 'party_updated') {
setBoardState((prev) => ({ setBoardState((prev) => ({
...prev, ...prev,
@ -179,6 +206,27 @@ export function useGameSocket() {
const b: BattleResult = data.battle; const b: BattleResult = data.battle;
setActiveBattle(b); setActiveBattle(b);
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
} else if (data.event === 'wizard_challenge_resolved') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
wizard: data.wizard ?? prev.wizard,
turn: data.turn ?? prev.turn,
}));
const c: WizardChallengeResult = data.challenge_result || data.challenge;
setActiveWizardChallenge(c);
const wizName = c.wizard_name || 'Gary the Wizard';
let rewardLabel = `+${c.score_change} score`;
if (c.reward_chosen === 'strength') {
rewardLabel = `+${c.strength_change ?? 2} strength`;
} else if (c.reward_chosen === 'health') {
rewardLabel = `+${c.health_change} health`;
}
setLastEventMessage(
c.player_won
? `🧙 ${c.challenger_name} defeated ${wizName}! (${rewardLabel})`
: `🧙 ${c.challenger_name} lost to ${wizName}! (${c.health_change < 0 ? `${c.health_change} HP` : `${c.score_change} pts`})`
);
} else if (data.event === 'game_concluded') { } else if (data.event === 'game_concluded') {
const conc: GameConclusion = data.conclusion; const conc: GameConclusion = data.conclusion;
setBoardState((prev) => ({ setBoardState((prev) => ({
@ -227,6 +275,7 @@ export function useGameSocket() {
setSelectedPlayer(null); setSelectedPlayer(null);
setAvailableMoves(null); setAvailableMoves(null);
setActiveBattle(null); setActiveBattle(null);
setActiveWizardChallenge(null);
setShowScoreboard(false); setShowScoreboard(false);
setLastEventMessage('🔄 Board has been reset.'); setLastEventMessage('🔄 Board has been reset.');
} }
@ -269,11 +318,17 @@ export function useGameSocket() {
} }
}, [selectedPlayer, fetchAvailableMoves]); }, [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?: string,
health: number = 10
): Promise<Player> => {
const res = await fetch('/api/players', { const res = await fetch('/api/players', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color, strength }), body: JSON.stringify({ name, color, strength, piece_type, health }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@ -348,6 +403,9 @@ export function useGameSocket() {
throw new Error(err.detail || 'Failed to move player'); throw new Error(err.detail || 'Failed to move player');
} }
const data: MoveResponse = await res.json(); const data: MoveResponse = await res.json();
if (data.player) {
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
}
if (data.battle_triggered && data.battle_result) { if (data.battle_triggered && data.battle_result) {
setActiveBattle(data.battle_result); setActiveBattle(data.battle_result);
} }
@ -355,6 +413,7 @@ export function useGameSocket() {
setIsAutoPlaying(false); setIsAutoPlaying(false);
setShowScoreboard(true); setShowScoreboard(true);
} }
fetchAvailableMoves(playerId);
return data; return data;
}; };
@ -392,14 +451,32 @@ export function useGameSocket() {
return data; return data;
}; };
const challengeWizard = async (
playerId: string,
rewardChoice: WizardRewardChoice = 'score'
): Promise<WizardChallengeResult> => {
const res = await fetch('/api/wizard/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId, reward_choice: rewardChoice }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to challenge wizard');
}
const data: WizardChallengeResult = await res.json();
setActiveWizardChallenge(data);
return data;
};
// Step active bot turn according to its explicit autonomous goal: // Step active bot turn according to its explicit autonomous goal:
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader) // - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
// - Party leader: seeks other parties to find and defeat all other parties // - Party leader: seeks other parties to find and defeat all other parties
const stepActiveBotTurn = useCallback(async () => { const stepActiveBotTurn = useCallback(async () => {
// Cannot step if game hasn't started yet // Cannot step if game hasn't started yet
if (!boardState.turn.game_started) return; if (!boardState.turn.game_started) return;
// If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes // If a battle or wizard challenge modal is currently open, pause turn stepping until modal acknowledges/closes
if (activeBattleRef.current) return; if (activeBattleRef.current || activeWizardChallengeRef.current) return;
const currentId = boardState.turn.current_player_id; const currentId = boardState.turn.current_player_id;
if (!currentId) return; if (!currentId) return;
@ -415,6 +492,15 @@ export function useGameSocket() {
} else if (data.action_taken === 'battled' && data.battle_result) { } else if (data.action_taken === 'battled' && data.battle_result) {
setActiveBattle(data.battle_result); setActiveBattle(data.battle_result);
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`); setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
} else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) {
const wcr = data.wizard_challenge_result;
setActiveWizardChallenge(wcr);
const wizName = wcr.wizard_name || 'Gary the Wizard';
setLastEventMessage(
wcr.player_won
? `🧙 ${wcr.challenger_name} defeated ${wizName}! (+${wcr.score_change} score)`
: `🧙 ${wcr.challenger_name} lost to ${wizName}! (${wcr.health_change < 0 ? `${wcr.health_change} HP` : `${wcr.score_change} pts`})`
);
} else if (data.move_result?.battle_result) { } else if (data.move_result?.battle_result) {
setActiveBattle(data.move_result.battle_result); setActiveBattle(data.move_result.battle_result);
} }
@ -427,7 +513,7 @@ export function useGameSocket() {
} catch (err) { } catch (err) {
console.warn('Bot AI step error:', err); console.warn('Bot AI step error:', err);
} }
}, [boardState.turn.current_player_id]); }, [boardState.turn.current_player_id, boardState.turn.game_started]);
// Autoplay interval loop // Autoplay interval loop
useEffect(() => { useEffect(() => {
@ -458,6 +544,8 @@ export function useGameSocket() {
lastEventMessage, lastEventMessage,
activeBattle, activeBattle,
setActiveBattle, setActiveBattle,
activeWizardChallenge,
setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
registerPlayer, registerPlayer,
@ -467,6 +555,7 @@ export function useGameSocket() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,
resetBoard, resetBoard,

View File

@ -21,6 +21,10 @@ export interface Player {
y: number; y: number;
strength: number; strength: number;
score: number; score: number;
health?: number;
max_health?: number;
is_alive?: boolean;
piece_type?: 'knight' | 'warrior';
party_id?: string | null; party_id?: string | null;
is_party_leader: boolean; is_party_leader: boolean;
visited_locations?: { x: number; y: number }[]; visited_locations?: { x: number; y: number }[];
@ -56,12 +60,23 @@ export interface GameConclusion {
rankings?: Player[]; rankings?: Player[];
} }
export interface WizardNPC {
id: string;
name: string;
x: number;
y: number;
strength: number;
color: string;
dialogue?: string;
}
export interface BoardState { export interface BoardState {
config: GridConfig; config: GridConfig;
player_count: number; player_count: number;
players: Player[]; players: Player[];
parties: Party[]; parties: Party[];
obstacles: Obstacle[]; obstacles: Obstacle[];
wizard?: WizardNPC | null;
turn: TurnInfo; turn: TurnInfo;
game_started?: boolean; game_started?: boolean;
conclusion?: GameConclusion | null; conclusion?: GameConclusion | null;
@ -116,6 +131,16 @@ export interface RadarTarget {
can_battle: boolean; can_battle: boolean;
} }
export interface WizardRadarTarget {
id: string;
name: string;
x: number;
y: number;
distance: number;
strength: number;
can_challenge: boolean;
}
export interface BotRadarResponse { export interface BotRadarResponse {
player_id: string; player_id: string;
current_x: number; current_x: number;
@ -123,6 +148,7 @@ export interface BotRadarResponse {
bot_goal: string; bot_goal: string;
targets: RadarTarget[]; targets: RadarTarget[];
nearest_target?: RadarTarget | null; nearest_target?: RadarTarget | null;
wizard?: WizardRadarTarget | null;
recommended_direction?: string | null; recommended_direction?: string | null;
recommended_action: string; recommended_action: string;
} }
@ -159,6 +185,46 @@ export interface BattleResult {
absorbed_members: string[]; absorbed_members: string[];
new_party_size: number; new_party_size: number;
new_party_strength: number; new_party_strength: number;
health_losses?: Record<string, number>;
dead_players?: string[];
}
export interface WizardChallengeBout {
bout_number: number;
player_roll: number;
player_strength: number;
player_score: number;
wizard_roll: number;
wizard_strength: number;
wizard_score: number;
winner: string;
}
export type WizardRewardChoice = 'score' | 'strength' | 'health';
export interface WizardChallengeRequest {
player_id: string;
reward_choice?: WizardRewardChoice;
}
export interface WizardChallengeResult {
challenger_id: string;
challenger_name: string;
wizard_name?: string;
party_id?: string | null;
bouts: WizardChallengeBout[];
player_bouts_won: number;
wizard_bouts_won: number;
player_won: boolean;
reward_chosen?: WizardRewardChoice | null;
score_change: number;
strength_change?: number;
health_change: number;
new_score: number;
new_strength?: number;
new_health: number;
player_died?: boolean;
wizard_respawn_position: { x: number; y: number };
} }
export interface PartyDefeatResult { export interface PartyDefeatResult {
@ -198,6 +264,7 @@ export interface AiStepResponse {
move_result?: MoveResponse | null; move_result?: MoveResponse | null;
formed_party?: Party | null; formed_party?: Party | null;
battle_result?: BattleResult | null; battle_result?: BattleResult | null;
wizard_challenge_result?: WizardChallengeResult | null;
game_concluded?: GameConclusion | null; game_concluded?: GameConclusion | null;
turn: TurnInfo; turn: TurnInfo;
} }

View File

@ -0,0 +1,548 @@
import React from 'react';
import type { Player } from '../types';
export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone';
// 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
];
const WIZARD_SPRITE: string[] = [
'......CC........', // Row 0: Pointed wizard hat tip
'.....LCCD.......', // Row 1: Hat cone
'....LLCCDD......', // Row 2: Hat cone body
'...GLLCCDDG.gW..', // Row 3: Golden hat buckle + Staff crystal shine
'..KCCCCCCCCK.Gg.', // Row 4: Wide hat brim + Staff orb
'...KWWWWWWK..KH.', // Row 5: Face & white flowing beard + wood staff
'...KWBRBWK...KH.', // Row 6: Glowing mystic eyes + beard + staff
'.LCKWWWWWWK..KH.', // Row 7: Robe shoulders + beard + staff
'LCCKLLCCDDG..KH.', // Row 8: Robe body with golden clasp + staff
'DCDKLLCCDDK..KH.', // Row 9: Flowing robe + staff
'.DDKLLCCDDK..KH.', // Row 10: Robe lower + staff
'..K.KBBBBK.K.KH.', // Row 11: Mystic boots + staff base
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KCCCCCCCCCCCCK.', // Row 14: Pedestal rim in robe color
'..____________..', // Row 15: Miniature drop shadow
];
const GRAVESTONE_SPRITE: string[] = [
'.....KKKKKK.....', // Row 0: Arched stone tombstone top outline
'...KKmmmmMMKK...', // Row 1: Stone bevel
'..KmmmMMMMMSSK..', // Row 2: Stone face with highlight and shadow
'..KmMMMMMMMSSK..', // Row 3: Stone face
'..KmMMMKSMMMSSK.', // Row 4: Carved cross top
'..KmMKKKKKKMSSK.', // Row 5: Carved cross bar
'..KmMMMKSMMMSSK.', // Row 6: Carved cross vertical stem
'..KmMMMKSMMMSSK.', // Row 7: Carved cross vertical stem
'..KmMMMMMMMSSK..', // Row 8: Stone slab
'..KmK.K.K.KMSSK.', // Row 9: Carved epitaph "R I P"
'..KmMMMMMMMSSK..', // Row 10: Lower stone
'.KKKKKKKKKKKKKK.', // Row 11: Base stone top
'.KBBBBBBBBBBBBK.', // Row 12: Stone pedestal base
'KHHHHHHKLDKKHHHK', // Row 13: Dirt mound with faction flower
'KhhhhhhhhhhhhhhK', // Row 14: Dark earth base
'..____________..', // Row 15: Drop shadow
];
// Death status helper
export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean {
if (player.is_alive === false) return true;
if (player.health !== undefined && player.health <= 0) return true;
return false;
}
// 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;
is_alive?: boolean;
health?: number;
}): PieceType {
// Fallen / deceased players are represented by gravestones
if (isPlayerDead(player)) {
return 'gravestone';
}
// 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 if (pieceType === 'wizard') {
spriteMatrix = WIZARD_SPRITE;
} else if (pieceType === 'gravestone') {
spriteMatrix = GRAVESTONE_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 dead = isPlayerDead(player);
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
const isLeader = !dead && 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 (only if alive)
if (isCurrentTurn && !dead) {
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 = dead ? '#94a3b8' : '#38bdf8';
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.shadowColor = dead ? '#64748b' : '#38bdf8';
ctx.shadowBlur = 6;
ctx.stroke();
ctx.restore();
}
// Draw the crisp pixelated figurine or gravestone
ctx.imageSmoothingEnabled = false;
if (dead) {
ctx.globalAlpha = 0.85;
}
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
// Crown symbol above party leader (only alive)
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 && !dead) || isSelected;
ctx.globalAlpha = dead ? (isSelected ? 0.9 : 0.65) : (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 = dead ? '🪦' : isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
const text = dead
? `🪦 ${player.name} [DEAD]`
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${player.health !== undefined ? ` ❤️${player.health}` : ''}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = isLeader ? destY - 14 : destY - 8;
ctx.fillStyle = dead ? 'rgba(30, 41, 59, 0.92)' : 'rgba(15, 23, 42, 0.92)';
ctx.strokeStyle = dead ? '#64748b' : 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 = dead ? '#94a3b8' : isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
ctx.fillText(text, px, labelY - 4);
ctx.restore();
ctx.restore();
}
// Canvas Drawing Helper for the Gary the Wizard NPC
export function drawWizardPiece(
ctx: CanvasRenderingContext2D,
wizard: { x: number; y: number; name: string; strength: number; color?: string },
px: number,
py: number,
cellSize: number
): void {
const color = wizard.color || '#A855F7';
const spriteCanvas = getSpriteCanvas('wizard', color, false);
const spriteSize = Math.max(cellSize * 1.45, 16);
const destX = Math.round(px - spriteSize / 2);
const destY = Math.round(py - spriteSize * 0.62);
ctx.save();
// Glowing mystic arcane aura
ctx.save();
ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.65, spriteSize * 0.32, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(168, 85, 247, 0.25)';
ctx.fill();
ctx.strokeStyle = '#c084fc';
ctx.lineWidth = 2;
ctx.shadowColor = '#c084fc';
ctx.shadowBlur = 12;
ctx.stroke();
ctx.restore();
// Draw the pixelated Wizard
ctx.imageSmoothingEnabled = false;
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
// Floating magic sparkle above wizard hat
ctx.save();
ctx.fillStyle = '#fef08a';
ctx.font = `${Math.max(spriteSize * 0.4, 10)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✨', px, destY - 6);
ctx.restore();
// Wizard Name & Strength Badge
ctx.save();
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = destY - 16;
ctx.fillStyle = 'rgba(15, 23, 42, 0.94)';
ctx.strokeStyle = '#c084fc';
ctx.lineWidth = 1.2;
ctx.shadowColor = '#c084fc';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#e9d5ff';
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 actualLeader = pieceType === 'gravestone' ? false : isLeader;
const dataUrl = getSpriteDataUrl(pieceType, color, actualLeader);
const label = pieceType === 'gravestone' ? 'Gravestone' : `${actualLeader ? 'Leader ' : ''}${pieceType}`;
return (
<img
src={dataUrl}
alt={label}
title={title || `${label} (${color})`}
className={`inline-block ${className}`}
style={{
width: size,
height: size,
imageRendering: 'pixelated',
}}
/>
);
};

View File

@ -1,6 +1,39 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function getAppVersion(): string {
if (process.env.VITE_APP_VERSION) {
return process.env.VITE_APP_VERSION
}
const candidatePaths = [
path.resolve(__dirname, '../version.ini'),
path.resolve(__dirname, 'version.ini'),
path.resolve(process.cwd(), '../version.ini'),
path.resolve(process.cwd(), 'version.ini'),
]
for (const p of candidatePaths) {
if (fs.existsSync(p)) {
try {
const content = fs.readFileSync(p, 'utf-8')
const match = content.match(/^version\s*=\s*([^\r\n]+)/m)
if (match) {
return match[1].trim().replace(/^["']|["']$/g, '')
}
} catch (err) {
console.warn(`Failed to read version from ${p}:`, err)
}
}
}
return '1.0'
}
const appVersion = getAppVersion()
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@ -8,6 +41,9 @@ export default defineConfig({
react(), react(),
tailwindcss(), tailwindcss(),
], ],
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
@ -22,3 +58,4 @@ export default defineConfig({
} }
} }
}) })

View File

@ -1,2 +1,2 @@
[metadata] [metadata]
version = 1.0 version = 1.3