wizard and health updates

This commit is contained in:
Isaac Johnson 2026-09-09 17:11:54 -05:00
parent 9f2597d7f7
commit 03e792e821
23 changed files with 1576 additions and 60 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,7 +110,20 @@ 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. The Wizard (NPC Encounter & Challenge)
- A wandering Wizard NPC roams the map at a random passable coordinate.
- Players (or party leaders) who locate the wizard (adjacent or on same tile, distance <= 1) may voluntarily **choose to challenge** the wizard.
- **Challenge Resolution (3-Bout D20)**:
- Exactly 3 bouts are conducted.
- In each bout, each side rolls a D20 die, multiplied by their strength (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.
- **Scoring & Penalties**:
- **Victory**: Player/party leader receives **+2 score points**.
- **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose).
- **Post-Challenge**:
- Following the challenge, the wizard teleports to a new random open coordinate on the map.
### 7. Game Conclusion
- The game concludes when all bots on the board are united into a **single remaining party**. - The game concludes when all bots on the board are united into a **single remaining party**.
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker). - Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker).
@ -123,11 +137,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 Wizard NPC coordinates and attributes |
| `POST` | `/api/wizard/challenge` | Challenge the Wizard NPC: `{"player_id": str}` (3-bout D20 duel) |
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots and 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 +155,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 +198,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 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 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 +215,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 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 +225,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 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

@ -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,6 +28,18 @@
- 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. **The Wizard (NPC Encounter & Challenge)**:
1. The Wizard is a special Non-Player Character (NPC) roaming the map at a random passable location.
2. Players (or party leaders) who find the wizard (adjacent or on the same coordinate, distance <= 1) may voluntarily **choose to challenge** the wizard.
3. **Challenge Resolution (3-Bout D20)**:
- Challenges consist of 3 bouts: each side rolls a D20 die, multiplied by their strength (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. **Scoring & Penalties**:
- **Victory**: The player (or party leader) receives **+2 score points**.
- **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, the wizard teleports to a new random open coordinate on the map.
# Game Conclusion # Game Conclusion
The game ends when all bots are united into a single remaining party. The game ends when all bots are united into a single remaining party.

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,49 @@ 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 +2 score 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)
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 +572,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",

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="Grand 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:
@ -328,12 +346,18 @@ class GameEngine:
else: else:
piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior" 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, piece_type=piece_type,
@ -427,6 +451,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:
@ -439,6 +464,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(),
@ -567,6 +593,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,
@ -574,6 +611,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,
) )
@ -1130,6 +1168,19 @@ class GameEngine:
killed_leader.is_party_leader = False killed_leader.is_party_leader = False
respawn_pos = {"x": respawn_x, "y": respawn_y} respawn_pos = {"x": respawn_x, "y": respawn_y}
# 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
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)
@ -1166,6 +1217,7 @@ 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,
) )
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
@ -1221,6 +1273,133 @@ 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) -> 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
health_change = 0
if player_won:
# Player wins challenge: +2 score
player.score += 2
score_change = 2
health_change = 0
else:
# 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
# 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,
party_id=party_id,
bouts=bouts,
player_bouts_won=player_bouts_won,
wizard_bouts_won=wizard_bouts_won,
player_won=player_won,
score_change=score_change,
health_change=health_change,
new_score=player.score,
new_health=player.health,
wizard_respawn_position=respawn_pos,
)
async def challenge_wizard(self, player_id: str) -> 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")
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:
raise ValueError(
f"Player '{player.name}' is not adjacent to the Grand Wizard (distance {dist}). Must be within 1 distance."
)
return self._resolve_wizard_challenge_internal(player)
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()
@ -1517,10 +1696,34 @@ 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 the Grand 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:
challenge_res = self._resolve_wizard_challenge_internal(player)
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] = {}

View File

@ -119,6 +119,7 @@ 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'") piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
@field_validator("name") @field_validator("name")
@ -148,6 +149,8 @@ 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" 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
@ -243,6 +246,10 @@ 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}",
)
class BattleRequest(BaseModel): class BattleRequest(BaseModel):
@ -250,6 +257,61 @@ class BattleRequest(BaseModel):
defender_id: str defender_id: str
# ==========================================
# Wizard NPC & Challenge Models
# ==========================================
class WizardNPC(BaseModel):
id: str = "wizard_npc"
name: str = "Grand 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 = "Grand Wizard"
party_id: Optional[str] = None
bouts: List[WizardChallengeBout]
player_bouts_won: int
wizard_bouts_won: int
player_won: bool
score_change: int = 0
health_change: int = 0
new_score: int
new_health: int
wizard_respawn_position: Dict[str, int]
class WizardChallengeRequest(BaseModel):
player_id: str
class WizardRadarTarget(BaseModel):
id: str = "wizard_npc"
name: str = "Grand Wizard"
x: int
y: int
distance: int
strength: float = 3.0
can_challenge: bool = False
# ========================================== # ==========================================
# Memory & Radar Models # Memory & Radar Models
# ========================================== # ==========================================
@ -287,8 +349,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):
@ -329,6 +392,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
@ -351,7 +415,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"
@ -359,6 +423,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,124 @@ 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"] == "Grand 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
# 1. Challenge the wizard with super high strength (strength 50 guaranteed win)
chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"]})
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["score_change"] == 2
assert res_data["new_score"] == 2
assert res_data["new_health"] == 10
# Wizard teleports to a new location
wiz_after = client.get("/api/wizard").json()
assert (wiz_after["x"], wiz_after["y"]) == (res_data["wizard_respawn_position"]["x"], res_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 is 0: bot loses 2 points (score)
async def setup_zero_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 = 0 # no health to lose
bot2.score = 5
asyncio.run(setup_zero_health())
# Set turn back to p2
game_engine.current_turn_index = 0
game_engine.turn_order = [p2["id"]]
score_loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert score_loss_res.status_code == 200
score_loss_data = score_loss_res.json()
assert score_loss_data["player_won"] is False
assert score_loss_data["health_change"] == 0
assert score_loss_data["score_change"] == -2
assert score_loss_data["new_health"] == 0
assert score_loss_data["new_score"] == 3

View File

@ -21,4 +21,5 @@ python3 bot_agent.py --name BloodAxe --color "#f43f5e" -s 3 --piece-type warrior
| `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot | | `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot |
| `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar | | `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar |
| `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) | | `-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` | | | `--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,12 +35,14 @@ 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, 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.server_url = server_url
self.piece_type = piece_type self.piece_type = piece_type
self.base_url = normalize_url(server_url) self.base_url = normalize_url(server_url)
@ -54,12 +57,17 @@ 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} payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type: if self.piece_type:
payload["piece_type"] = self.piece_type payload["piece_type"] = self.piece_type
@ -72,13 +80,13 @@ 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}) 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)."""
@ -102,6 +110,7 @@ class SmartBotAgent:
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
@ -114,7 +123,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', 'Grand Wizard')} (Str: {wiz_str})! HP: {my_health}, Bot Str: {self.strength}. Choosing to challenge!")
self._challenge_wizard()
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]):
@ -213,6 +232,31 @@ class SmartBotAgent:
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
def _challenge_wizard(self):
"""Voluntarily challenge the Wizard NPC to a 3-bout D20 duel."""
print(f"🧙 [WIZARD CHALLENGE] Challenging Grand Wizard to a 3-bout D20 duel...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id},
)
if res.status_code == 200:
result = res.json()
outcome = "VICTORY (+2 pts)" 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')} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Score: {result.get('new_score')}")
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")}
@ -315,6 +359,7 @@ 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") env_piece_type = os.environ.get("BOT_PIECE_TYPE")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -346,6 +391,13 @@ 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( parser.add_argument(
"--piece-type", "--piece-type",
dest="piece_type", dest="piece_type",
@ -360,6 +412,7 @@ 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, piece_type=args.piece_type,
) )

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

@ -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,8 +36,11 @@ 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).
- 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 Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health).
- The game ends when all bots are united into a single party. - The game ends when all bots are united into a single 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,6 +99,7 @@ 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,
@ -103,6 +108,7 @@ class AIBotAgent:
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.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)
@ -120,12 +126,17 @@ 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} payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type: if self.piece_type:
payload["piece_type"] = self.piece_type payload["piece_type"] = self.piece_type
@ -138,13 +149,13 @@ 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
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)."""
@ -168,6 +179,7 @@ class AIBotAgent:
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:
@ -177,9 +189,58 @@ 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"):
if self._decide_wizard_challenge(wizard, my_info):
self._challenge_wizard(wizard)
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]) -> bool:
"""Ask the LLM whether to challenge the adjacent Wizard NPC."""
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 Grand Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
- If you win: +2 score points!
- If you lose: -2 health points (or -2 score if no health)!
Do you want to challenge the wizard to a duel?
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}}
"""
decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False)
reasoning = decision.get("reasoning", "")
print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge}. {reasoning}")
return bool(challenge)
def _challenge_wizard(self, wizard: Dict[str, Any]):
"""Execute the challenge against the Wizard NPC."""
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id},
)
if res.status_code == 200:
result = res.json()
outcome = "VICTORY (+2 pts)" 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')} | HP: {result.get('new_health')}")
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"]
@ -373,6 +434,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 = []
@ -381,6 +445,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:
@ -428,7 +494,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 = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
{chr(10).join(local_map)} {chr(10).join(local_map)}
""" """
@ -497,6 +563,7 @@ 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") env_piece_type = os.environ.get("BOT_PIECE_TYPE")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -511,6 +578,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("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type, 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)") 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,
@ -526,6 +595,7 @@ 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,

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,8 +46,11 @@ 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).
- 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 Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health).
- The game ends when all bots are united into a single party. - The game ends when all bots are united into a single 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,6 +278,7 @@ 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,
@ -284,6 +289,7 @@ class VertexAIBotAgent:
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.piece_type = piece_type
self.base_url = normalize_url(server_url) self.base_url = normalize_url(server_url)
self.llm = VertexGeminiClient( self.llm = VertexGeminiClient(
@ -306,12 +312,17 @@ 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} payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
if self.piece_type: if self.piece_type:
payload["piece_type"] = self.piece_type payload["piece_type"] = self.piece_type
@ -324,13 +335,13 @@ 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
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)."""
@ -354,6 +365,7 @@ class VertexAIBotAgent:
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:
@ -363,9 +375,58 @@ 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"):
if self._decide_wizard_challenge(wizard, my_info):
self._challenge_wizard(wizard)
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]) -> bool:
"""Ask Gemini whether to challenge the adjacent Wizard NPC."""
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 Grand Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll).
- If you win: +2 score points!
- If you lose: -2 health points (or -2 score if no health)!
Do you want to challenge the wizard to a duel?
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}}
"""
decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False)
reasoning = decision.get("reasoning", "")
print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge}. {reasoning}")
return bool(challenge)
def _challenge_wizard(self, wizard: Dict[str, Any]):
"""Execute the challenge against the Wizard NPC."""
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...")
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id},
)
if res.status_code == 200:
result = res.json()
outcome = "VICTORY (+2 pts)" 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')} | HP: {result.get('new_health')}")
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"]
@ -559,6 +620,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 = []
@ -567,6 +631,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:
@ -614,7 +680,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 = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
{chr(10).join(local_map)} {chr(10).join(local_map)}
""" """
@ -692,6 +758,7 @@ 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)
@ -710,6 +777,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,
@ -734,6 +803,7 @@ 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,

View File

@ -7,6 +7,7 @@ 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 { ScoreboardModal } from './components/ScoreboardModal'; import { ScoreboardModal } from './components/ScoreboardModal';
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
@ -32,6 +33,8 @@ export function App() {
lastEventMessage, lastEventMessage,
activeBattle, activeBattle,
setActiveBattle, setActiveBattle,
activeWizardChallenge,
setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
registerPlayer, registerPlayer,
@ -41,6 +44,7 @@ export function App() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,
resetBoard, resetBoard,
@ -54,6 +58,10 @@ export function App() {
setActiveBattle(null); setActiveBattle(null);
}, [setActiveBattle]); }, [setActiveBattle]);
const handleCloseWizardChallenge = useCallback(() => {
setActiveWizardChallenge(null);
}, [setActiveWizardChallenge]);
const showNotification = (msg: string) => { const showNotification = (msg: string) => {
setNotification(msg); setNotification(msg);
setTimeout(() => { setTimeout(() => {
@ -159,6 +167,9 @@ export function App() {
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
availableMoves={availableMoves} availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
onChallengeWizard={async (id) => {
await challengeWizard(id);
}}
/> />
{/* 8-Directional Movement D-Pad & Simulation Controls */} {/* 8-Directional Movement D-Pad & Simulation Controls */}
@ -172,6 +183,9 @@ export function App() {
onPass={async (id) => { onPass={async (id) => {
await passTurn(id); await passTurn(id);
}} }}
onChallengeWizard={async (id) => {
await challengeWizard(id);
}}
onStepBot={stepActiveBotTurn} onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying} isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
@ -186,6 +200,9 @@ export function App() {
onOpenPartyModal={() => setIsPartyModalOpen(true)} onOpenPartyModal={() => setIsPartyModalOpen(true)}
onDefeatParty={handleDefeatParty} onDefeatParty={handleDefeatParty}
onFightBattle={handleFightBattle} onFightBattle={handleFightBattle}
onChallengeWizard={async (id) => {
await challengeWizard(id);
}}
/> />
{/* Live Event Feed Notification */} {/* Live Event Feed Notification */}
@ -201,9 +218,9 @@ export function App() {
<RegisterModal <RegisterModal
isOpen={isRegisterOpen} isOpen={isRegisterOpen}
onClose={() => setIsRegisterOpen(false)} onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color, pieceType) => { onRegister={async (name, color, pieceType, health) => {
const player = await registerPlayer(name, color, 1, pieceType); const player = await registerPlayer(name, color, 1, pieceType, health);
showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) at (${player.x}, ${player.y})!`); showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`);
}} }}
/> />
@ -224,6 +241,12 @@ export function App() {
onClose={handleCloseBattle} onClose={handleCloseBattle}
/> />
{/* 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,6 +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, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars'; import { drawPlayerPiece, drawWizardPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars';
interface BoardCanvasProps { interface BoardCanvasProps {
boardState: BoardState; boardState: BoardState;
@ -8,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> = ({
@ -16,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);
@ -422,6 +424,13 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.restore(); ctx.restore();
}); });
// 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) // 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;
@ -548,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}
@ -578,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>Grand Wizard NPC (Str: {boardState.wizard?.strength})</span>
</span>
</>
)}
{hoveredObstacle && ( {hoveredObstacle && (
<> <>
<span className="text-slate-500">|</span> <span className="text-slate-500">|</span>
@ -638,33 +687,73 @@ 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"> <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">
<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"> <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 <PixelAvatar
pieceType={getPlayerPieceType(selectedPlayer)} pieceType={getPlayerPieceType(liveSelectedPlayer)}
color={selectedPlayer.color} color={liveSelectedPlayer.color}
isLeader={selectedPlayer.is_party_leader} isLeader={liveSelectedPlayer.is_party_leader}
size={36} size={36}
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate"> <div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
{selectedPlayer.name} {liveSelectedPlayer.name}
{selectedPlayer.is_party_leader && ( {liveSelectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span> <span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)} )}
{selectedPlayer.id === currentTurnId && ( {liveSelectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-emerald-400 font-mono"> Turn</span> <span className="text-[10px] text-emerald-400 font-mono"> Turn</span>
)} )}
</div> </div>
<div className="text-slate-400 font-mono text-[11px]"> <div className="text-slate-400 font-mono text-[11px]">
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Score:{' '} Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) Score:{' '}
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}> <span className={liveSelectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{selectedPlayer.score} {liveSelectedPlayer.score}
</span> </span>
{' '} HP: <span className="text-rose-400 font-bold">{liveSelectedPlayer.health ?? 10}</span>
</div> </div>
</div> </div>
{/* Challenge Wizard button on Selected Player Card */}
{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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -7,6 +7,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,16 +19,54 @@ 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 liveSelectedPlayer = selectedPlayer
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const controlledPlayer = liveSelectedPlayer || activePlayer;
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const isAdjacentToWizard = Boolean(
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) => {
if (!isStarted || !controlledPlayer || !isMyTurn) return; if (!isStarted || !controlledPlayer || !isMyTurn) return;
@ -233,6 +272,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

@ -10,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> = ({
@ -19,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;
@ -28,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 */}
@ -89,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">
@ -178,7 +263,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</span> </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: <span className="text-rose-400 font-bold">{player.health ?? 10}</span>
{partyName && ( {partyName && (
<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'}
@ -188,7 +273,52 @@ 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">
{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

@ -32,7 +32,7 @@ const RANDOM_NAMES = [
interface RegisterModalProps { interface RegisterModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onRegister: (name: string, color: string, pieceType?: PieceType) => 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> = ({
@ -43,6 +43,7 @@ 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 [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);
@ -74,9 +75,10 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
try { try {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
await onRegister(name.trim(), color, pieceType); 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);
@ -214,6 +216,36 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
/> />
</div> </div>
{/* Starting Health */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-slate-300">
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 */} {/* Color & Faction Chooser */}
<div> <div>
<div className="flex justify-between items-center mb-1.5"> <div className="flex justify-between items-center mb-1.5">

View File

@ -0,0 +1,224 @@
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.score_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 || 'Grand 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">
🎉 +{challenge.score_change} Score Points awarded! (Total: {challenge.new_score})
</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

@ -10,6 +10,7 @@ import type {
PartyDefeatResult, PartyDefeatResult,
Player, Player,
TurnInfo, TurnInfo,
WizardChallengeResult,
} from '../types'; } from '../types';
const INITIAL_BOARD: BoardState = { const INITIAL_BOARD: BoardState = {
@ -25,6 +26,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 +47,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 +55,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 +68,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 +121,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 +158,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 +205,21 @@ 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;
setActiveWizardChallenge(c);
const wizName = c.wizard_name || 'Grand Wizard';
setLastEventMessage(
c.player_won
? `🧙 ${c.challenger_name} defeated ${wizName}! (+${c.score_change} score)`
: `🧙 ${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 +268,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.');
} }
@ -273,12 +315,13 @@ export function useGameSocket() {
name: string, name: string,
color: string, color: string,
strength: number = 1, strength: number = 1,
piece_type?: 'knight' | 'warrior' piece_type?: string,
health: number = 10
): Promise<Player> => { ): 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, piece_type }), 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(() => ({}));
@ -353,6 +396,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);
} }
@ -360,6 +406,7 @@ export function useGameSocket() {
setIsAutoPlaying(false); setIsAutoPlaying(false);
setShowScoreboard(true); setShowScoreboard(true);
} }
fetchAvailableMoves(playerId);
return data; return data;
}; };
@ -397,14 +444,29 @@ export function useGameSocket() {
return data; return data;
}; };
const challengeWizard = async (playerId: string): Promise<WizardChallengeResult> => {
const res = await fetch('/api/wizard/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId }),
});
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;
@ -420,6 +482,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 || 'Grand 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);
} }
@ -432,7 +503,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(() => {
@ -463,6 +534,8 @@ export function useGameSocket() {
lastEventMessage, lastEventMessage,
activeBattle, activeBattle,
setActiveBattle, setActiveBattle,
activeWizardChallenge,
setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
registerPlayer, registerPlayer,
@ -472,6 +545,7 @@ export function useGameSocket() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,
resetBoard, resetBoard,

View File

@ -21,6 +21,8 @@ export interface Player {
y: number; y: number;
strength: number; strength: number;
score: number; score: number;
health?: number;
max_health?: number;
piece_type?: 'knight' | 'warrior'; piece_type?: 'knight' | 'warrior';
party_id?: string | null; party_id?: string | null;
is_party_leader: boolean; is_party_leader: boolean;
@ -57,12 +59,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;
@ -117,6 +130,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;
@ -124,6 +147,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;
} }
@ -160,6 +184,34 @@ 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>;
}
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 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;
score_change: number;
health_change: number;
new_score: number;
new_health: number;
wizard_respawn_position: { x: number; y: number };
} }
export interface PartyDefeatResult { export interface PartyDefeatResult {
@ -199,6 +251,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

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import type { Player } from '../types'; import type { Player } from '../types';
export type PieceType = 'knight' | 'warrior'; export type PieceType = 'knight' | 'warrior' | 'wizard';
// Hex color parser and manipulator // Hex color parser and manipulator
function parseHex(hex: string): [number, number, number] { function parseHex(hex: string): [number, number, number] {
@ -131,6 +131,25 @@ const LEADER_WARRIOR_SPRITE: string[] = [
'..____________..', // Row 15: Miniature drop shadow '..____________..', // 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
];
// Palette generation // Palette generation
function getPalette(color: string): Record<string, string> { function getPalette(color: string): Record<string, string> {
const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`; const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
@ -238,6 +257,8 @@ export function getSpriteCanvas(
let spriteMatrix: string[]; let spriteMatrix: string[];
if (pieceType === 'knight') { if (pieceType === 'knight') {
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE; spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
} else if (pieceType === 'wizard') {
spriteMatrix = WIZARD_SPRITE;
} else { } else {
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
} }
@ -356,7 +377,8 @@ export function drawPlayerPiece(
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`; const hpBadge = player.health !== undefined ? ` ❤️${player.health}` : '';
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpBadge}]`;
const textMetrics = ctx.measureText(text); const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12; const bgWidth = textMetrics.width + 12;
const bgHeight = 16; const bgHeight = 16;
@ -377,6 +399,78 @@ export function drawPlayerPiece(
ctx.restore(); ctx.restore();
} }
// Canvas Drawing Helper for the Grand 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 // React Component for displaying pixel avatar in UI
interface PixelAvatarProps { interface PixelAvatarProps {
pieceType: PieceType; pieceType: PieceType;