From e7a1e7586333c7127e64499fe1d3f1c99deaed2d Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Wed, 9 Sep 2026 19:32:22 -0500 Subject: [PATCH] version shown on page, health, different award choices for battling wizard --- AGENTS.md | 25 +- Dockerfile | 7 + GAME_RULES.md | 21 +- backend/app/api/routes.py | 12 +- backend/app/game.py | 328 ++++++++++++++++-- backend/app/models.py | 13 + backend/tests/test_api.py | 201 ++++++++++- botagent/bot_agent.py | 54 ++- botagent_ai/bot.py | 75 +++- botagent_gear/bot.py | 72 +++- frontend/src/App.tsx | 48 ++- frontend/src/components/BoardCanvas.tsx | 149 ++++---- frontend/src/components/Header.tsx | 2 +- frontend/src/components/MovementControls.tsx | 11 +- frontend/src/components/PlayerList.tsx | 68 ++-- frontend/src/components/ScoreboardModal.tsx | 196 ++++++----- .../src/components/WizardChallengeModal.tsx | 12 +- frontend/src/components/WizardPromptModal.tsx | 196 +++++++++++ frontend/src/hooks/useGameSocket.ts | 18 +- frontend/src/types.ts | 13 + frontend/src/utils/pixelAvatars.tsx | 81 +++-- frontend/vite.config.ts | 37 ++ version.ini | 2 +- 23 files changed, 1313 insertions(+), 328 deletions(-) create mode 100644 frontend/src/components/WizardPromptModal.tsx diff --git a/AGENTS.md b/AGENTS.md index ebb7a98..91aadcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,15 +117,28 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to - 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**. +- **Victory Rewards & Defeat Penalties**: + - **Victory**: The player (or party leader) decides and chooses which reward to claim: + - **+2 Score Points**: Adds +2 score points. + - **+2 Strength**: Adds +2.0 strength to the bot (recalculating squad total strength if in a party). + - **+2 Health**: Adds +2 health points (expanding max health if current health exceeds initial maximum). - **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose). - **Post-Challenge**: - Following the challenge, 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**. -- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker). +### 7. Player Health, Damage & Death +- All bots register with default **10 HP** (configurable). +- Health damage is sustained by losing battles (-1 to -3 HP) or losing Wizard challenges (-2 HP). +- When a bot's health drops to **0 HP**, they are **dead**. +- **Death Consequences**: + - **Party Disconnection**: The dead bot is immediately detached from any party. If the dead bot was the leader, squad leadership transfers to the strongest surviving squad member (or the party dissolves if empty). Defeated dead followers are not absorbed. + - **Gravestone Marker**: A **gravestone replaces their icon on the board** at their final coordinate. Deceased leaders do not respawn elsewhere. + - **No Turns or Actions**: Dead bots are omitted from turn order rotation and can **no longer move, duel, or take any actions**. + - **Scoreboard Preservation**: Dead bots **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and visited locations. + +### 8. Game Conclusion +- The game concludes when all surviving bots on the board are united into a **single remaining party** (or if only 1 survivor remains). +- Final rankings and trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker), with all bots (surviving and deceased) included on the scoreboard. --- @@ -142,7 +155,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream | `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) | | `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard | | `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) | +| `POST` | `/api/wizard/challenge` | Challenge the Wizard NPC: `{"player_id": str, "reward_choice": "score"|"strength"|"health"}` (3-bout D20 duel) | | `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots and Wizard NPC | | `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) | diff --git a/Dockerfile b/Dockerfile index 58d4cde..6399578 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,17 @@ FROM node:22-alpine AS frontend-builder WORKDIR /build/frontend +# Optional build argument to override version +ARG APP_VERSION +ENV VITE_APP_VERSION=$APP_VERSION + # Install dependencies COPY frontend/package*.json ./ RUN npm ci || npm install +# Copy version.ini for compile-time version resolution +COPY version.ini /build/version.ini + # Build frontend production bundle COPY frontend/ ./ RUN npm run build diff --git a/GAME_RULES.md b/GAME_RULES.md index 676d9f8..41b6f42 100644 --- a/GAME_RULES.md +++ b/GAME_RULES.md @@ -34,14 +34,27 @@ 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**. + 4. **Victory Rewards & Defeat Penalties**: + - **Victory**: The player (or party leader) must decide and choose which reward to receive: + - **+2 Score Points**: Adds +2 points to their tournament score. + - **+2 Strength**: Adds +2.0 strength to the bot (recalculating squad total strength if in a party). + - **+2 Health**: Adds +2 health points (HP) to the bot (expanding max health if current health exceeds initial maximum). - **Defeat**: The player (or party leader) loses **2 health** (or **2 score points** if they do not have health to lose). 5. **Wizard Relocation**: - Following any challenge, the wizard teleports to a new random open coordinate on the map. +7. **Player Health, Damage & Death**: + 1. All bots register with default **10 HP** (configurable via `--health` / `-H`). + 2. Health damage is sustained by losing battles (-1 to -3 HP) or losing Wizard challenges (-2 HP). + 3. When a player's health points reach **0 HP**, they are **dead**. + 4. **Death Consequences**: + - **Party Disconnection**: The dead player is immediately disconnected from any existing party. If the dead player was the party leader, squad leadership transfers to the strongest surviving member (or the party dissolves if no living members remain). Dead followers are not absorbed into opposing parties. + - **Gravestone Marker**: A **gravestone replaces their icon on the board** at their final coordinate. Deceased leaders do not respawn at a random coordinate. + - **No Turns or Actions**: Dead players are removed from the turn rotation and can **no longer move, duel, or perform any actions**. + - **Scoreboard Preservation**: Dead players **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and stats. + # Game Conclusion -The game ends when all bots are united into a single remaining party. +The game ends when all surviving bots on the board are united into a single remaining party (or if only one surviving bot remains). -Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). +All players (both living and deceased) are listed on the final scoreboard rankings. Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 8489715..55a871c 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -404,12 +404,15 @@ async def get_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)", + summary="Challenge the Wizard NPC to a 3-bout D20 duel (awards choice of +2 score, +2 strength, or +2 health on win; -2 health/points on loss)", tags=["Wizard NPC"], ) async def challenge_wizard(challenge_req: WizardChallengeRequest): try: - result = await game_engine.challenge_wizard(challenge_req.player_id) + result = await game_engine.challenge_wizard( + challenge_req.player_id, + reward_choice=challenge_req.reward_choice or "score", + ) board_state = await game_engine.get_board_state() await manager.broadcast({ @@ -668,6 +671,11 @@ async def pass_turn(player_id: str): 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), + ) @router.get( diff --git a/backend/app/game.py b/backend/app/game.py index eb4b61b..71e65c2 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -268,13 +268,62 @@ class GameEngine: random.randint(self.config.min_y, self.config.max_y), ) + def _check_and_apply_death(self, player: Player) -> bool: + """If player's health <= 0, mark dead, disconnect from party, and remove from turn rotation.""" + if player.health <= 0: + player.health = 0 + player.is_alive = False + + # Disconnect from any existing party + if player.party_id and player.party_id in self.parties: + party = self.parties[player.party_id] + if player.id in party.member_ids: + party.member_ids.remove(player.id) + + if party.leader_id == player.id: + surviving = [ + self.players[mid] + for mid in party.member_ids + if mid in self.players and self.players[mid].is_alive + ] + if surviving: + surviving.sort(key=lambda p: (p.strength, p.score), reverse=True) + new_leader = surviving[0] + party.leader_id = new_leader.id + party.leader_name = new_leader.name + new_leader.is_party_leader = True + self._update_party_strength(party) + else: + if party.id in self.parties: + del self.parties[party.id] + elif party.member_ids: + self._update_party_strength(party) + else: + if party.id in self.parties: + del self.parties[party.id] + + player.party_id = None + player.is_party_leader = False + + if player.id in self.turn_order: + self.turn_order.remove(player.id) + + actors = self._get_active_turn_actors() + if actors: + self.current_turn_index %= len(actors) + else: + self.current_turn_index = 0 + + return True + return False + def _get_active_turn_actors(self) -> List[str]: if not self.game_started: return [] actors = [] for pid in self.turn_order: p = self.players.get(pid) - if not p: + if not p or not p.is_alive: continue if not p.party_id or p.is_party_leader: actors.append(pid) @@ -506,7 +555,7 @@ class GameEngine: targets: List[RadarTarget] = [] for other in self.players.values(): - if other.id == player.id: + if other.id == player.id or not other.is_alive or other.health <= 0: continue dist = max(abs(player.x - other.x), abs(player.y - other.y)) is_ally = bool(player.party_id and player.party_id == other.party_id) @@ -669,6 +718,8 @@ class GameEngine: p = self.players.get(mid) if not p: raise KeyError(f"Player '{mid}' not found") + if not p.is_alive or p.health <= 0: + raise ValueError(f"Player '{p.name}' is dead and cannot join a party.") member_players.append(p) if not self._verify_party_connectivity(member_players): @@ -716,6 +767,10 @@ class GameEngine: invitee = self.players.get(invitee_id) if not inviter or not invitee: raise KeyError("Inviter or invitee not found") + if not inviter.is_alive or inviter.health <= 0: + raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.") + if not invitee.is_alive or invitee.health <= 0: + raise ValueError(f"Player '{invitee.name}' is dead and cannot be invited.") eligible_hosts = [inviter] if inviter.party_id and inviter.party_id in self.parties: @@ -756,6 +811,8 @@ class GameEngine: invitee = self.players.get(invite.invitee_id) if not inviter or not invitee: raise KeyError("Inviter or invitee no longer active") + if not inviter.is_alive or not invitee.is_alive: + raise ValueError("Cannot join party with deceased player.") if inviter.party_id and inviter.party_id in self.parties: party = self.parties[inviter.party_id] @@ -927,6 +984,17 @@ class GameEngine: direction_name: str, occupied_map: Dict[Tuple[int, int], Player], ) -> MoveCheckResult: + if not player.is_alive or player.health <= 0: + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=player.x + dx, + target_y=player.y + dy, + available=False, + reason="Player is dead (0 HP)", + ) + if player.party_id and player.party_id in self.parties and player.is_party_leader: party = self.parties[player.party_id] new_positions, failure_reason, strength_penalty = self._compute_party_move( @@ -1033,6 +1101,32 @@ class GameEngine: if not player: raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + moves = { + name: MoveCheckResult( + direction=name, + dx=dx, + dy=dy, + target_x=player.x + dx, + target_y=player.y + dy, + available=False, + reason="Player is dead (0 HP)", + ) + for name, dx, dy in STANDARD_DIRECTIONS + } + return AvailableMovesResponse( + player_id=player.id, + player_name=player.name, + current_x=player.x, + current_y=player.y, + is_turn=False, + is_party_leader=False, + party_id=None, + party_member_count=0, + current_turn_player_id=None, + moves=moves, + ) + occupied = self._get_occupied_coordinates() current_turn = self._get_current_player() is_turn = bool(current_turn and current_turn.id == player_id) @@ -1149,24 +1243,7 @@ class GameEngine: killed_leader = self.players.get(defeated_party.leader_id) absorbed_members: List[str] = [] - - if killed_leader: - killed_leader.score -= 1 - - if len(defeated_party.member_ids) == 1: - killed_leader.party_id = winner_party.id - killed_leader.is_party_leader = False - if killed_leader.id not in winner_party.member_ids: - winner_party.member_ids.append(killed_leader.id) - absorbed_members.append(killed_leader.id) - respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} - else: - respawn_x, respawn_y = self._find_random_free_position() - killed_leader.x = respawn_x - killed_leader.y = respawn_y - killed_leader.party_id = None - killed_leader.is_party_leader = False - respawn_pos = {"x": respawn_x, "y": respawn_y} + dead_players: List[str] = [] # Defeated party members (including leader) all lose 1 to 3 health points (randomized) health_losses: Dict[str, int] = {} @@ -1180,11 +1257,37 @@ class GameEngine: hp_loss = random.randint(1, 3) p.health = max(0, p.health - hp_loss) health_losses[mid] = hp_loss + if p.health == 0 and p.is_alive: + self._check_and_apply_death(p) + dead_players.append(mid) + if killed_leader: + killed_leader.score -= 1 + + if killed_leader.is_alive: + if len(defeated_party.member_ids) == 1: + killed_leader.party_id = winner_party.id + killed_leader.is_party_leader = False + if killed_leader.id not in winner_party.member_ids: + winner_party.member_ids.append(killed_leader.id) + absorbed_members.append(killed_leader.id) + respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} + else: + respawn_x, respawn_y = self._find_random_free_position() + killed_leader.x = respawn_x + killed_leader.y = respawn_y + killed_leader.party_id = None + killed_leader.is_party_leader = False + respawn_pos = {"x": respawn_x, "y": respawn_y} + else: + # Leader is dead: remains at final coordinates as gravestone, disconnected from party + respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} + + # Only surviving defeated party followers are absorbed into winning party for mid in list(defeated_party.member_ids): if mid != defeated_party.leader_id: m = self.players.get(mid) - if m: + if m and m.is_alive: m.party_id = winner_party.id m.is_party_leader = False if m.id not in winner_party.member_ids: @@ -1218,6 +1321,7 @@ class GameEngine: new_party_size=len(winner_party.member_ids), new_party_strength=winner_party.total_strength, health_losses=health_losses, + dead_players=dead_players, ) async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: @@ -1228,6 +1332,10 @@ class GameEngine: p2 = self.players.get(defender_id) if not p1 or not p2: raise KeyError("Challenger or defender not found") + if not p1.is_alive or p1.health <= 0: + raise ValueError(f"Challenger '{p1.name}' is dead and cannot battle.") + if not p2.is_alive or p2.health <= 0: + raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.") if p1.party_id and p1.party_id == p2.party_id: raise ValueError("Cannot battle members of your own party") @@ -1273,7 +1381,9 @@ class GameEngine: async def battle(self, challenger_id: str, defender_id: str) -> BattleResult: return await self.fight_battle(challenger_id, defender_id) - def _resolve_wizard_challenge_internal(self, player: Player) -> WizardChallengeResult: + def _resolve_wizard_challenge_internal( + self, player: Player, reward_choice: str = "score" + ) -> WizardChallengeResult: """Resolve a 3-bout D20 challenge between player/party and the Wizard NPC.""" effective_strength = player.strength party_id = player.party_id @@ -1321,14 +1431,31 @@ class GameEngine: player_won = player_bouts_won >= 2 score_change = 0 + strength_change = 0.0 health_change = 0 + choice = (reward_choice or "score").lower().strip() + if choice not in ("score", "strength", "health"): + choice = "score" + if player_won: - # Player wins challenge: +2 score - player.score += 2 - score_change = 2 - health_change = 0 - else: + # Player wins challenge: choice of +2 score, +2 strength, or +2 health + if choice == "strength": + player.strength = round(player.strength + 2.0, 1) + strength_change = 2.0 + if player.party_id and player.party_id in self.parties: + self._update_party_strength(self.parties[player.party_id]) + elif choice == "health": + player.health += 2 + if player.health > player.max_health: + player.max_health = player.health + health_change = 2 + else: # "score" + player.score += 2 + score_change = 2 + + player_died = False + if not player_won: # Player loses challenge: lose 2 health (or points if they do not have health to lose) if player.health >= 2: player.health -= 2 @@ -1345,6 +1472,9 @@ class GameEngine: player.score -= 2 score_change = -2 + if player.health == 0 and player.is_alive: + player_died = self._check_and_apply_death(player) + # Wizard teleports to a new random open coordinate on the map new_wx, new_wy = self._find_random_free_position() self.wizard.x = new_wx @@ -1356,19 +1486,24 @@ class GameEngine: return WizardChallengeResult( challenger_id=player.id, challenger_name=player.name, + wizard_name="Grand Wizard", party_id=party_id, bouts=bouts, player_bouts_won=player_bouts_won, wizard_bouts_won=wizard_bouts_won, player_won=player_won, + reward_chosen=choice if player_won else None, score_change=score_change, + strength_change=strength_change, health_change=health_change, new_score=player.score, + new_strength=player.strength, new_health=player.health, + player_died=player_died, wizard_respawn_position=respawn_pos, ) - async def challenge_wizard(self, player_id: str) -> WizardChallengeResult: + async def challenge_wizard(self, player_id: str, reward_choice: str = "score") -> WizardChallengeResult: async with self._lock: if not self.game_started: raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") @@ -1377,6 +1512,9 @@ class GameEngine: if not player: raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.") + current_turn_player = self._get_current_player() if not current_turn_player or current_turn_player.id != player_id: curr_name = current_turn_player.name if current_turn_player else "Nobody" @@ -1398,7 +1536,7 @@ class GameEngine: 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) + return self._resolve_wizard_challenge_internal(player, reward_choice=reward_choice) async def get_game_conclusion(self) -> GameConclusion: async with self._lock: @@ -1408,21 +1546,52 @@ class GameEngine: if len(self.players) < 2: return GameConclusion(concluded=False) + living_players = [p for p in self.players.values() if p.is_alive] + total_bots = len(self.players) + rankings = sorted( + self.players.values(), + key=lambda p: (p.score, p.strength, p.name), + reverse=True, + ) + + # Case 1: All bots died + if len(living_players) == 0: + return GameConclusion( + concluded=True, + total_bots=total_bots, + rankings=rankings, + ) + + # Case 2: Exactly 1 living bot remains (sole survivor) + if len(living_players) == 1: + survivor = living_players[0] + party = self.parties.get(survivor.party_id) if survivor.party_id else None + return GameConclusion( + concluded=True, + winning_party_id=party.id if party else None, + winning_party_name=party.name if party else f"Squad {survivor.name}", + winning_leader_id=party.leader_id if party else survivor.id, + winning_leader_name=party.leader_name if party else survivor.name, + total_bots=total_bots, + rankings=rankings, + ) + + # Case 3: All living bots are united into a single remaining party if len(self.parties) == 1: only_party = next(iter(self.parties.values())) - if len(only_party.member_ids) == len(self.players): - rankings = sorted( - self.players.values(), - key=lambda p: (p.score, p.strength, p.name), - reverse=True, - ) + living_member_ids = { + mid for mid in only_party.member_ids + if self.players.get(mid) and self.players[mid].is_alive + } + living_player_ids = {p.id for p in living_players} + if living_member_ids == living_player_ids and len(living_player_ids) >= 1: return GameConclusion( concluded=True, winning_party_id=only_party.id, winning_party_name=only_party.name, winning_leader_id=only_party.leader_id, winning_leader_name=only_party.leader_name, - total_bots=len(self.players), + total_bots=total_bots, rankings=rankings, ) @@ -1431,9 +1600,13 @@ class GameEngine: def _check_adjacent_encounter( self, player: Player ) -> Tuple[Optional[Party], Optional[BattleResult]]: + if not player.is_alive or player.health <= 0: + return None, None for other in self.players.values(): if other.id == player.id: continue + if not other.is_alive or other.health <= 0: + continue if player.party_id and player.party_id == other.party_id: continue @@ -1544,6 +1717,8 @@ class GameEngine: player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + raise ValueError(f"Player '{player.name}' is dead and cannot move.") if player.party_id and not player.is_party_leader: party = self.parties.get(player.party_id) @@ -1636,6 +1811,8 @@ class GameEngine: player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + raise ValueError(f"Player '{player.name}' is dead and has no actions.") current_turn_player = self._get_current_player() if not current_turn_player or current_turn_player.id != player_id: @@ -1655,6 +1832,8 @@ class GameEngine: player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + raise ValueError(f"Player '{player.name}' is dead and cannot take actions.") current_turn_player = self._get_current_player() if not current_turn_player or current_turn_player.id != player_id: @@ -1708,7 +1887,13 @@ class GameEngine: 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) + if player.health <= 5: + bot_reward = "health" + elif player.strength < 4.0: + bot_reward = "strength" + else: + bot_reward = "score" + challenge_res = self._resolve_wizard_challenge_internal(player, reward_choice=bot_reward) conclusion = self._check_game_concluded() return AiStepResponse( action_taken="challenged_wizard", @@ -1750,7 +1935,7 @@ class GameEngine: # Find nearest target according to goal targets = [] for other in self.players.values(): - if other.id == player.id: + if other.id == player.id or not other.is_alive or other.health <= 0: continue if player.party_id and player.party_id == other.party_id: continue @@ -1862,6 +2047,73 @@ class GameEngine: turn=turn_info, ) + async def defeat_party(self, party_id: str) -> PartyDefeatResult: + async with self._lock: + if party_id not in self.parties: + raise KeyError(f"Party '{party_id}' not found") + party = self.parties[party_id] + leader = self.players.get(party.leader_id) + leader_id = party.leader_id + leader_name = party.leader_name + + if leader: + leader.score -= 1 + hp_loss = random.randint(1, 3) + leader.health = max(0, leader.health - hp_loss) + if leader.health == 0 and leader.is_alive: + self._check_and_apply_death(leader) + respawn_pos = {"x": leader.x, "y": leader.y} + else: + rx, ry = self._find_random_free_position() + leader.x = rx + leader.y = ry + leader.party_id = None + leader.is_party_leader = False + respawn_pos = {"x": rx, "y": ry} + else: + respawn_pos = {"x": 0, "y": 0} + + if leader_id in party.member_ids: + party.member_ids.remove(leader_id) + + remaining_alive = [ + mid for mid in party.member_ids + if mid in self.players and self.players[mid].is_alive + ] + + new_leader_id = None + new_leader_name = None + party_dissolved = False + + if remaining_alive: + candidates = [self.players[mid] for mid in remaining_alive] + candidates.sort(key=lambda p: (p.strength, p.score), reverse=True) + new_lead = candidates[0] + party.leader_id = new_lead.id + party.leader_name = new_lead.name + new_lead.is_party_leader = True + new_leader_id = new_lead.id + new_leader_name = new_lead.name + self._update_party_strength(party) + else: + party_dissolved = True + if party.id in self.parties: + del self.parties[party.id] + + self._advance_turn() + + return PartyDefeatResult( + party_id=party_id, + killed_leader_id=leader_id, + killed_leader_name=leader_name, + killed_leader_new_score=leader.score if leader else 0, + killed_leader_respawn_position=respawn_pos, + new_leader_id=new_leader_id, + new_leader_name=new_leader_name, + remaining_members=remaining_alive, + party_dissolved=party_dissolved, + ) + # Global game engine instance game_engine = GameEngine() diff --git a/backend/app/models.py b/backend/app/models.py index 7d282b0..b670569 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -154,6 +154,7 @@ class Player(BaseModel): piece_type: Optional[str] = "knight" party_id: Optional[str] = None is_party_leader: bool = False + is_alive: bool = True visited_locations: List[Dict[str, int]] = Field(default_factory=list) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -250,6 +251,10 @@ class BattleResult(BaseModel): default_factory=dict, description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}", ) + dead_players: List[str] = Field( + default_factory=list, + description="IDs of players who died (HP reached 0) in this battle", + ) class BattleRequest(BaseModel): @@ -291,15 +296,23 @@ class WizardChallengeResult(BaseModel): player_bouts_won: int wizard_bouts_won: int player_won: bool + reward_chosen: Optional[str] = "score" score_change: int = 0 + strength_change: float = 0.0 health_change: int = 0 new_score: int + new_strength: float = 1.0 new_health: int + player_died: bool = False wizard_respawn_position: Dict[str, int] class WizardChallengeRequest(BaseModel): player_id: str + reward_choice: Optional[str] = Field( + default="score", + description="Chosen victory reward: 'score' (+2 score), 'strength' (+2 strength), or 'health' (+2 health)" + ) class WizardRadarTarget(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0e7a765..96b1d22 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -531,19 +531,63 @@ def test_wizard_challenge_mechanics_victory_and_defeat(): 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"]}) + # 1a. Challenge the wizard with default/score reward (strength 50 guaranteed win) + chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "score"}) assert chal_res.status_code == 200 res_data = chal_res.json() assert len(res_data["bouts"]) == 3 assert res_data["player_won"] is True + assert res_data["reward_chosen"] == "score" assert res_data["score_change"] == 2 + assert res_data["strength_change"] == 0.0 + assert res_data["health_change"] == 0 assert res_data["new_score"] == 2 assert res_data["new_health"] == 10 + # 1b. Challenge with "strength" reward + wiz_loc = res_data["wizard_respawn_position"] + async def place_p1_for_strength_win(): + b = await game_engine.get_player(p1["id"]) + b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1 + b.y = wiz_loc["y"] + asyncio.run(place_p1_for_strength_win()) + game_engine.turn_order = [p1["id"], p2["id"]] + game_engine.current_turn_index = 0 + + str_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "strength"}) + assert str_res.status_code == 200 + str_data = str_res.json() + assert str_data["player_won"] is True + assert str_data["reward_chosen"] == "strength" + assert str_data["strength_change"] == 2.0 + assert str_data["new_strength"] == 52.0 + assert str_data["score_change"] == 0 + assert str_data["health_change"] == 0 + + # 1c. Challenge with "health" reward + wiz_loc = str_data["wizard_respawn_position"] + async def place_p1_for_health_win(): + b = await game_engine.get_player(p1["id"]) + b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1 + b.y = wiz_loc["y"] + b.health = 8 + asyncio.run(place_p1_for_health_win()) + game_engine.turn_order = [p1["id"], p2["id"]] + game_engine.current_turn_index = 0 + + hp_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "health"}) + assert hp_res.status_code == 200 + hp_data = hp_res.json() + assert hp_data["player_won"] is True + assert hp_data["reward_chosen"] == "health" + assert hp_data["health_change"] == 2 + assert hp_data["new_health"] == 10 + assert hp_data["score_change"] == 0 + assert hp_data["strength_change"] == 0.0 + # Wizard teleports to a new location wiz_after = client.get("/api/wizard").json() - assert (wiz_after["x"], wiz_after["y"]) == (res_data["wizard_respawn_position"]["x"], res_data["wizard_respawn_position"]["y"]) + assert (wiz_after["x"], wiz_after["y"]) == (hp_data["wizard_respawn_position"]["x"], hp_data["wizard_respawn_position"]["y"]) # 2. Test defeat case: bot loses 2 health # Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss) @@ -568,8 +612,8 @@ def test_wizard_challenge_mechanics_victory_and_defeat(): 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(): + # 3. Test defeat when health reaches 0: bot dies, health becomes 0, player_died is True + async def setup_low_health(): cur_wiz = client.get("/api/wizard").json() adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1 adj_y = cur_wiz["y"] @@ -577,20 +621,147 @@ def test_wizard_challenge_mechanics_victory_and_defeat(): bot2.x = adj_x bot2.y = adj_y bot2.strength = 0.001 - bot2.health = 0 # no health to lose + bot2.health = 1 # 1 HP left, losing 2 HP will reduce HP to 0 and score by 1 bot2.score = 5 - asyncio.run(setup_zero_health()) + bot2.is_alive = True + asyncio.run(setup_low_health()) # Set turn back to p2 game_engine.current_turn_index = 0 game_engine.turn_order = [p2["id"]] - 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 + death_chal_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) + assert death_chal_res.status_code == 200 + death_data = death_chal_res.json() + assert death_data["player_won"] is False + assert death_data["health_change"] == -1 + assert death_data["score_change"] == -1 + assert death_data["new_health"] == 0 + assert death_data["new_score"] == 4 + assert death_data["player_died"] is True + + # Verify dead bot state: health is 0, is_alive is False + bot2_dead = client.get(f"/api/players/{p2['id']}").json() + assert bot2_dead["health"] == 0 + assert bot2_dead["is_alive"] is False + + # Dead bot cannot challenge wizard or move (no actions) + dead_action_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) + assert dead_action_res.status_code == 400 + assert "dead" in dead_action_res.json()["detail"].lower() + + +def test_player_death_rule_and_scoreboard_preservation(): + """Test the rule: + When a player loses enough health points to reach 0, they are dead. + They are disconnected from any existing party and a gravestone replaces their icon on the board. + They still are listed on the scores, but they can no longer move and they no longer have a turn. + """ + client = TestClient(app) + client.post("/api/reset") + + # Register 3 bots: Winner (party of 1), LoserLead (party of 2), LoserFollower (party of 2) + b1 = client.post("/api/players", json={"name": "IronChampion", "color": "#10b981", "strength": 20, "health": 10}).json() + b2 = client.post("/api/players", json={"name": "FragileLeader", "color": "#ef4444", "strength": 1, "health": 2}).json() + b3 = client.post("/api/players", json={"name": "DoomedFollower", "color": "#f59e0b", "strength": 1, "health": 1}).json() + + # Place b2 and b3 in a party + async def setup_party_and_combat(): + p1 = await game_engine.get_player(b1["id"]) + p2 = await game_engine.get_player(b2["id"]) + p3 = await game_engine.get_player(b3["id"]) + p1.x, p1.y = 20, 20 + p2.x, p2.y = 20, 21 + p3.x, p3.y = 21, 21 + asyncio.run(setup_party_and_combat()) + + client.post("/api/game/start") + + # Form losing party with b2 (leader) and b3 (follower) + party_loser = client.post("/api/parties", json={ + "member_ids": [b2["id"], b3["id"]], + "leader_id": b2["id"], + "name": "FragileSquad", + }).json() + + # Form winning party with b1 + party_winner = client.post("/api/parties", json={ + "member_ids": [b1["id"]], + "leader_id": b1["id"], + "name": "IronSquad", + }) + # If parties requires min 2 members via API, b1 battles as solo vs party directly + # Trigger battle between b1 and b2 + battle_res = client.post("/api/battles/fight", json={ + "challenger_id": b1["id"], + "defender_id": b2["id"], + }) + assert battle_res.status_code == 200 + battle_data = battle_res.json() + + # Verify health loss caused DoomedFollower (starting HP 1, losing 1-3) to DIE + p3_after = client.get(f"/api/players/{b3['id']}").json() + assert p3_after["health"] == 0 + assert p3_after["is_alive"] is False + assert p3_after["party_id"] is None + assert p3_after["is_party_leader"] is False + # Dead follower must NOT be absorbed into winning party + assert b3["id"] not in battle_data["absorbed_members"] + + # If FragileLeader also died (starting HP 2, lost >= 2): + p2_after = client.get(f"/api/players/{b2['id']}").json() + if p2_after["health"] == 0: + assert p2_after["is_alive"] is False + assert p2_after["party_id"] is None + assert p2_after["is_party_leader"] is False + else: + # If leader survived with 1 HP, manually drop them to 0 to verify death + async def kill_leader(): + lead = await game_engine.get_player(b2["id"]) + lead.health = 0 + game_engine._check_and_apply_death(lead) + asyncio.run(kill_leader()) + p2_after = client.get(f"/api/players/{b2['id']}").json() + assert p2_after["health"] == 0 + assert p2_after["is_alive"] is False + + # Verify dead bots CANNOT move + move_attempt = client.post(f"/api/players/{b3['id']}/move", json={"direction": "UP"}) + assert move_attempt.status_code == 400 + assert "dead" in move_attempt.json()["detail"].lower() + + # Verify dead bots CANNOT pass + pass_attempt = client.post(f"/api/players/{b3['id']}/pass") + assert pass_attempt.status_code == 400 + assert "dead" in pass_attempt.json()["detail"].lower() + + # Verify available moves returns all unavailable + moves_res = client.get(f"/api/players/{b3['id']}/available-moves").json() + for move in moves_res["moves"].values(): + assert move["available"] is False + assert "dead" in move["reason"].lower() + + # Verify dead bots do NOT have a turn in turn rotation + turn_data = client.get("/api/turn").json() + assert b3["id"] not in turn_data["turn_order"] + assert b2["id"] not in turn_data["turn_order"] + assert turn_data["current_player_id"] == b1["id"] + + # Verify dead bots STILL ARE LISTED on the players/scores list + all_players = client.get("/api/players").json() + all_player_ids = [p["id"] for p in all_players] + assert b1["id"] in all_player_ids + assert b2["id"] in all_player_ids + assert b3["id"] in all_player_ids + + # Verify game conclusion: only b1 is alive, so game is concluded! + board = client.get("/api/board").json() + assert board["conclusion"]["concluded"] is True + # All 3 bots (including dead ones) are listed in rankings with their scores! + ranking_ids = [r["id"] for r in board["conclusion"]["rankings"]] + assert len(ranking_ids) == 3 + assert b1["id"] in ranking_ids + assert b2["id"] in ranking_ids + assert b3["id"] in ranking_ids + diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py index 269a283..b897547 100644 --- a/botagent/bot_agent.py +++ b/botagent/bot_agent.py @@ -104,6 +104,11 @@ class SmartBotAgent: if not my_info: return + # Death rule: 0 HP means fallen, gravestone on board, no turns or actions + if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0: + print(f"๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.") + return + print(f"\n๐ŸŽฎ --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") # 1. Consult Radar Sensor @@ -130,7 +135,7 @@ class SmartBotAgent: # 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() + self._challenge_wizard(my_health=my_health) return # 4. If no immediate adjacent enemy/recruit, move towards target @@ -229,24 +234,37 @@ class SmartBotAgent: print(f"๐Ÿ’€ Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") if battle.get("absorbed_members"): print(f"๐Ÿงฒ Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") + if battle.get("dead_players"): + print(f"๐Ÿชฆ Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!") + if self.bot_id in battle["dead_players"]: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") else: 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...") + def _challenge_wizard(self, my_health: int = 10): + """Voluntarily challenge the Wizard NPC to a 3-bout D20 duel with chosen victory reward.""" + if my_health <= 5: + reward_choice = "health" + elif self.strength < 4.0: + reward_choice = "strength" + else: + reward_choice = "score" + + print(f"๐Ÿง™ [WIZARD CHALLENGE] Challenging Grand Wizard to a 3-bout D20 duel (reward if won: +2 {reward_choice})...") try: res = requests.post( f"{self.base_url}/wizard/challenge", - json={"player_id": self.bot_id}, + json={"player_id": self.bot_id, "reward_choice": reward_choice}, ) if res.status_code == 200: result = res.json() - outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)" print(f"๐Ÿง™ [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") for b in result.get("bouts", []): print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})ร—Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})ร—Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") - print(f" Score Change: {result.get('score_change')} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Score: {result.get('new_score')}") + print(f" Score Change: {result.get('score_change')} | Strength Change: {result.get('strength_change', 0.0)} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Strength: {result.get('new_strength')} | New Score: {result.get('new_score')}") + if result.get("player_died") or result.get("new_health", 10) <= 0: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") pos = result.get("wizard_respawn_position") if pos: print(f" ๐Ÿ”ฎ Wizard teleported to ({pos.get('x')}, {pos.get('y')})") @@ -322,6 +340,21 @@ class SmartBotAgent: self.register() try: while True: + # Check life status: dead players cannot act but remain on board/scores + my_status = self.refresh_status() + if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0): + print(f"\n๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.") + print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n๐ŸŽ‰ [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + turn_info = requests.get(f"{self.base_url}/turn").json() if not turn_info.get("game_started", False): # Check if bot was removed (e.g., board was reset) @@ -350,7 +383,12 @@ class SmartBotAgent: except KeyboardInterrupt: print(f"\nDisconnecting {self.name}...") - requests.delete(f"{self.base_url}/players/{self.bot_id}") + # If still alive, remove from board; if deceased, preserve on board and scoreboard + my_status = self.refresh_status() + if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0: + requests.delete(f"{self.base_url}/players/{self.bot_id}") + else: + print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.") def main(): diff --git a/botagent_ai/bot.py b/botagent_ai/bot.py index d6dec73..5ba4569 100644 --- a/botagent_ai/bot.py +++ b/botagent_ai/bot.py @@ -17,7 +17,7 @@ import os import re import time import argparse -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import requests @@ -38,10 +38,14 @@ Rules you must respect when choosing among the OPTIONS given to you: - 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). Defeated party members (including leader) lose 1-3 health points (randomized). +- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone + replaces its icon on the board, and it can no longer move or take turns. Its final score remains + preserved on the scoreboard. - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - 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. + is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score, + +2 strength, or +2 health; losing costs 2 health (or score if no health). +- The game ends when all surviving bots are united into a single remaining party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. """ @@ -175,6 +179,11 @@ class AIBotAgent: if not my_info: return + # Death rule: 0 HP means fallen, gravestone on board, no turns or actions + if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0: + print(f"๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.") + return + print(f"\n๐Ÿค– --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() @@ -190,47 +199,53 @@ class AIBotAgent: if adjacent_target: 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) + should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info) + if should_challenge: + self._challenge_wizard(wizard, reward_choice=reward_choice) else: self._navigate_towards_goal(radar_res, my_info) else: 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.""" + def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> Tuple[bool, str]: + """Ask the LLM whether to challenge the adjacent Wizard NPC and which reward to choose on win.""" prompt = f"""{GAME_RULES_SUMMARY} You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) at position ({my_info['x']}, {my_info['y']}). You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). Challenging the wizard initiates a 3-bout D20 duel (strength * roll). -- If you win: +2 score points! +- If you win: You choose one reward: +2 score, +2 strength, or +2 health! - If you lose: -2 health points (or -2 score if no health)! -Do you want to challenge the wizard to a duel? +Do you want to challenge the wizard to a duel, and if you win, which reward do you want ("score", "strength", or "health")? -Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}} +Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}} """ decision = self.llm.ask_json(prompt) or {} challenge = decision.get("challenge_wizard", False) + reward_choice = str(decision.get("reward_choice", "score")).lower().strip() + if reward_choice not in ("score", "strength", "health"): + reward_choice = "score" reasoning = decision.get("reasoning", "") - print(f"๐Ÿง™ [LLM DECISION] Challenge Wizard: {challenge}. {reasoning}") - return bool(challenge) + print(f"๐Ÿง™ [LLM DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}") + return bool(challenge), reward_choice - def _challenge_wizard(self, wizard: Dict[str, Any]): + def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"): """Execute the challenge against the Wizard NPC.""" - print(f"๐Ÿง™ [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...") + print(f"๐Ÿง™ [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...") try: res = requests.post( f"{self.base_url}/wizard/challenge", - json={"player_id": self.bot_id}, + json={"player_id": self.bot_id, "reward_choice": reward_choice}, ) if res.status_code == 200: result = res.json() - outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)" print(f"๐Ÿง™ [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") for b in result.get("bouts", []): print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})ร—Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})ร—Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") - print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}") + print(f" Score: {result.get('new_score')} | Strength: {result.get('new_strength')} | HP: {result.get('new_health')}") + if result.get("player_died") or result.get("new_health", 10) <= 0: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") pos = result.get("wizard_respawn_position") if pos: print(f" ๐Ÿ”ฎ Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") @@ -343,6 +358,10 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso print(f"๐Ÿ’€ Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") if battle.get("absorbed_members"): print(f"๐Ÿงฒ Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") + if battle.get("dead_players"): + print(f"๐Ÿชฆ Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!") + if self.bot_id in battle["dead_players"]: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") else: print(f"Battle failed ({res.status_code}): {res.text}") @@ -529,6 +548,21 @@ Respond ONLY with JSON: {{"direction": "", "reasoning": "sho self.register() try: while True: + # Check life status: dead players cannot act but remain on board/scores + my_status = self.refresh_status() + if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0): + print(f"\n๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.") + print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n๐ŸŽ‰ [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + turn_info = requests.get(f"{self.base_url}/turn").json() if not turn_info.get("game_started", False): if self.bot_id: @@ -555,7 +589,12 @@ Respond ONLY with JSON: {{"direction": "", "reasoning": "sho except KeyboardInterrupt: print(f"\nDisconnecting {self.name}...") - requests.delete(f"{self.base_url}/players/{self.bot_id}") + # If still alive, remove from board; if deceased, preserve on board and scoreboard + my_status = self.refresh_status() + if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0: + requests.delete(f"{self.base_url}/players/{self.bot_id}") + else: + print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.") def main(): diff --git a/botagent_gear/bot.py b/botagent_gear/bot.py index 3b2745a..b306af2 100644 --- a/botagent_gear/bot.py +++ b/botagent_gear/bot.py @@ -48,10 +48,14 @@ Rules you must respect when choosing among the OPTIONS given to you: - 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). Defeated party members (including leader) lose 1-3 health points (randomized). +- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone + replaces its icon on the board, and it can no longer move or take turns. Its final score remains + preserved on the scoreboard. - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - 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. + is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score, + +2 strength, or +2 health; losing costs 2 health (or score if no health). +- The game ends when all surviving bots are united into a single remaining party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. """ @@ -361,6 +365,11 @@ class VertexAIBotAgent: if not my_info: return + # Death rule: 0 HP means fallen, gravestone on board, no turns or actions + if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0: + print(f"๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.") + return + print(f"\n๐Ÿค– --- Turn for {self.name} | Score: {my_info['score']} | Str: {my_info['strength']} | Party: {self.party_id or 'Solo'} ---") radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() @@ -376,47 +385,53 @@ class VertexAIBotAgent: if adjacent_target: 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) + should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info) + if should_challenge: + self._challenge_wizard(wizard, reward_choice=reward_choice) else: self._navigate_towards_goal(radar_res, my_info) else: 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.""" + def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> Tuple[bool, str]: + """Ask Gemini whether to challenge the adjacent Wizard NPC and which reward to choose on win.""" prompt = f"""{GAME_RULES_SUMMARY} You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) at position ({my_info['x']}, {my_info['y']}). You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). Challenging the wizard initiates a 3-bout D20 duel (strength * roll). -- If you win: +2 score points! +- If you win: You choose one reward: +2 score, +2 strength, or +2 health! - If you lose: -2 health points (or -2 score if no health)! -Do you want to challenge the wizard to a duel? +Do you want to challenge the wizard to a duel, and if you win, which reward do you want ("score", "strength", or "health")? -Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}} +Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}} """ decision = self.llm.ask_json(prompt) or {} challenge = decision.get("challenge_wizard", False) + reward_choice = str(decision.get("reward_choice", "score")).lower().strip() + if reward_choice not in ("score", "strength", "health"): + reward_choice = "score" reasoning = decision.get("reasoning", "") - print(f"๐Ÿง™ [GEMINI DECISION] Challenge Wizard: {challenge}. {reasoning}") - return bool(challenge) + print(f"๐Ÿง™ [GEMINI DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}") + return bool(challenge), reward_choice - def _challenge_wizard(self, wizard: Dict[str, Any]): + def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"): """Execute the challenge against the Wizard NPC.""" - print(f"๐Ÿง™ [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...") + print(f"๐Ÿง™ [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...") try: res = requests.post( f"{self.base_url}/wizard/challenge", - json={"player_id": self.bot_id}, + json={"player_id": self.bot_id, "reward_choice": reward_choice}, ) if res.status_code == 200: result = res.json() - outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)" print(f"๐Ÿง™ [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") for b in result.get("bouts", []): print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})ร—Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})ร—Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") - print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}") + print(f" Score: {result.get('new_score')} | Strength: {result.get('new_strength')} | HP: {result.get('new_health')}") + if result.get("player_died") or result.get("new_health", 10) <= 0: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") pos = result.get("wizard_respawn_position") if pos: print(f" ๐Ÿ”ฎ Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") @@ -529,6 +544,10 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso print(f"๐Ÿ’€ Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") if battle.get("absorbed_members"): print(f"๐Ÿงฒ Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") + if battle.get("dead_players"): + print(f"๐Ÿชฆ Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!") + if self.bot_id in battle["dead_players"]: + print(f"๐Ÿ’€ [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") else: print(f"Battle failed ({res.status_code}): {res.text}") @@ -723,6 +742,21 @@ Respond ONLY with JSON: {{"direction": "", "reasoning": "sho self.register() try: while True: + # Check life status: dead players cannot act but remain on board/scores + my_status = self.refresh_status() + if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0): + print(f"\n๐Ÿชฆ [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.") + print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n๐ŸŽ‰ [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + turn_info = requests.get(f"{self.base_url}/turn").json() if not turn_info.get("game_started", False): if self.bot_id: @@ -749,8 +783,12 @@ Respond ONLY with JSON: {{"direction": "", "reasoning": "sho except KeyboardInterrupt: print(f"\nDisconnecting {self.name}...") - if self.bot_id: + # If still alive, remove from board; if deceased, preserve on board and scoreboard + my_status = self.refresh_status() + if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0: requests.delete(f"{self.base_url}/players/{self.bot_id}") + else: + print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.") def main(): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4e4005..d390ae3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,9 @@ import { RegisterModal } from './components/RegisterModal'; import { PartyModal } from './components/PartyModal'; import { BattleModal } from './components/BattleModal'; import { WizardChallengeModal } from './components/WizardChallengeModal'; +import { WizardPromptModal } from './components/WizardPromptModal'; import { ScoreboardModal } from './components/ScoreboardModal'; +import type { WizardRewardChoice } from './types'; const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' }, @@ -52,8 +54,13 @@ export function App() { const [isRegisterOpen, setIsRegisterOpen] = useState(false); const [isPartyModalOpen, setIsPartyModalOpen] = useState(false); + const [promptChallengerId, setPromptChallengerId] = useState(null); const [notification, setNotification] = useState(null); + const pendingWizardChallenger = promptChallengerId + ? boardState.players.find((p) => p.id === promptChallengerId) || null + : null; + const handleCloseBattle = useCallback(() => { setActiveBattle(null); }, [setActiveBattle]); @@ -62,6 +69,26 @@ export function App() { setActiveWizardChallenge(null); }, [setActiveWizardChallenge]); + const handleOpenWizardPrompt = useCallback(async (playerId: string) => { + setPromptChallengerId(playerId); + }, []); + + const handleConfirmWizardChallenge = useCallback( + async (rewardChoice: WizardRewardChoice) => { + if (!promptChallengerId) return; + const id = promptChallengerId; + setPromptChallengerId(null); + try { + await challengeWizard(id, rewardChoice); + } catch (err: unknown) { + if (err instanceof Error) { + alert(err.message); + } + } + }, + [promptChallengerId, challengeWizard] + ); + const showNotification = (msg: string) => { setNotification(msg); setTimeout(() => { @@ -167,9 +194,7 @@ export function App() { selectedPlayer={selectedPlayer} availableMoves={availableMoves} onSelectPlayer={setSelectedPlayer} - onChallengeWizard={async (id) => { - await challengeWizard(id); - }} + onChallengeWizard={handleOpenWizardPrompt} /> {/* 8-Directional Movement D-Pad & Simulation Controls */} @@ -183,9 +208,7 @@ export function App() { onPass={async (id) => { await passTurn(id); }} - onChallengeWizard={async (id) => { - await challengeWizard(id); - }} + onChallengeWizard={handleOpenWizardPrompt} onStepBot={stepActiveBotTurn} isAutoPlaying={isAutoPlaying} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} @@ -200,9 +223,7 @@ export function App() { onOpenPartyModal={() => setIsPartyModalOpen(true)} onDefeatParty={handleDefeatParty} onFightBattle={handleFightBattle} - onChallengeWizard={async (id) => { - await challengeWizard(id); - }} + onChallengeWizard={handleOpenWizardPrompt} /> {/* Live Event Feed Notification */} @@ -241,6 +262,15 @@ export function App() { onClose={handleCloseBattle} /> + {/* Wizard Challenge Reward Selector Prompt Modal */} + setPromptChallengerId(null)} + /> + {/* 3-Bout D20 Wizard Challenge Modal */} = ({ {/* Selected Player Overlay card */} - {liveSelectedPlayer && ( -
-
- -
-
-
- {liveSelectedPlayer.name} - {liveSelectedPlayer.is_party_leader && ( - ๐Ÿ‘‘ Leader - )} - {liveSelectedPlayer.id === currentTurnId && ( - โ€ข Turn - )} + {liveSelectedPlayer && (() => { + const isDead = isPlayerDead(liveSelectedPlayer); + return ( +
+
+
-
- Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) โ€ข Score:{' '} - - {liveSelectedPlayer.score} - - {' '}โ€ข HP: โค๏ธ{liveSelectedPlayer.health ?? 10} +
+
+ + {liveSelectedPlayer.name} + + {isDead && ( + + ๐Ÿ’€ DECEASED + + )} + {!isDead && liveSelectedPlayer.is_party_leader && ( + ๐Ÿ‘‘ Leader + )} + {!isDead && liveSelectedPlayer.id === currentTurnId && ( + โ€ข Turn + )} +
+
+ Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) โ€ข Score:{' '} + + {liveSelectedPlayer.score} + + {' '}โ€ข HP: {isDead ? '๐Ÿ’€ 0' : `โค๏ธ${liveSelectedPlayer.health ?? 10}`} +
-
- {/* Challenge Wizard button on Selected Player Card */} - {isSelectedAdjacentToWizard && onChallengeWizard && ( + {/* Challenge Wizard button on Selected Player Card (alive only) */} + {!isDead && isSelectedAdjacentToWizard && onChallengeWizard && ( + + )} - )} - - -
- )} +
+ ); + })()}
); }; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f4d9869..902e404 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -35,7 +35,7 @@ export const Header: React.FC = ({

botWebWars - v1.0 + v{import.meta.env.VITE_APP_VERSION || '1.3'}

diff --git a/frontend/src/components/MovementControls.tsx b/frontend/src/components/MovementControls.tsx index f1011ae..7dcb773 100644 --- a/frontend/src/components/MovementControls.tsx +++ b/frontend/src/components/MovementControls.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback } from 'react'; import type { AvailableMovesResponse, BoardState, Player } from '../types'; +import { isPlayerDead } from '../utils/pixelAvatars'; interface MovementControlsProps { boardState: BoardState; @@ -32,10 +33,12 @@ export const MovementControls: React.FC = ({ ? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer : null; const controlledPlayer = liveSelectedPlayer || activePlayer; - const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); + const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer)); + const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); const isAdjacentToWizard = Boolean( - controlledPlayer && + !isDead && + controlledPlayer && boardState.wizard && Math.max( Math.abs(controlledPlayer.x - boardState.wizard.x), @@ -226,6 +229,10 @@ export const MovementControls: React.FC = ({ NOT STARTED + ) : isDead ? ( + + ๐Ÿ’€ DECEASED + ) : isMyTurn ? ( YOUR TURN diff --git a/frontend/src/components/PlayerList.tsx b/frontend/src/components/PlayerList.tsx index 4139537..c1991d7 100644 --- a/frontend/src/components/PlayerList.tsx +++ b/frontend/src/components/PlayerList.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { BoardState, Player } from '../types'; -import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars'; +import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars'; interface PlayerListProps { boardState: BoardState; @@ -197,17 +197,22 @@ export const PlayerList: React.FC = ({

) : ( players.map((player) => { + const isDead = isPlayerDead(player); const isSelected = selectedPlayer?.id === player.id; - const isCurrentTurn = currentTurnId === player.id; - const isLeader = player.is_party_leader; - const partyName = partyMap.get(player.id); + const isCurrentTurn = !isDead && currentTurnId === player.id; + const isLeader = !isDead && player.is_party_leader; + const partyName = !isDead ? partyMap.get(player.id) : null; return (
onSelectPlayer(player)} className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${ - isCurrentTurn + isDead + ? isSelected + ? 'bg-slate-900/80 border-slate-600 shadow-md shadow-slate-900/50' + : 'bg-slate-950/40 border-slate-800/80 hover:border-slate-700 opacity-75' + : isCurrentTurn ? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10' : isSelected ? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10' @@ -219,12 +224,16 @@ export const PlayerList: React.FC = ({
= ({
- + {player.name} - 0 - ? 'bg-emerald-950 text-emerald-300 border border-emerald-800' - : 'bg-slate-800 text-slate-400' - }`} - > - {player.score >= 0 ? `+${player.score}` : player.score} pts - + {isDead ? ( + + ๐Ÿ’€ DEAD + + ) : ( + 0 + ? 'bg-emerald-950 text-emerald-300 border border-emerald-800' + : 'bg-slate-800 text-slate-400' + }`} + > + {player.score >= 0 ? `+${player.score}` : player.score} pts + + )}
- pos: ({player.x}, {player.y}) โ€ข Str: โšก{player.strength || 1} โ€ข HP: โค๏ธ{player.health ?? 10} - {partyName && ( + pos: ({player.x}, {player.y}) โ€ข Str: โšก{player.strength || 1} โ€ข HP:{' '} + + {isDead ? '๐Ÿ’€ 0' : `โค๏ธ${player.health ?? 10}`} + + {isDead && ( + + โ€ข Score: {player.score} + + )} + {partyName && !isDead && ( โ€ข {isLeader ? 'Leader' : 'Squad'} @@ -274,7 +297,8 @@ export const PlayerList: React.FC = ({
- {boardState.wizard && + {!isDead && + boardState.wizard && Math.max( Math.abs(player.x - boardState.wizard.x), Math.abs(player.y - boardState.wizard.y) diff --git a/frontend/src/components/ScoreboardModal.tsx b/frontend/src/components/ScoreboardModal.tsx index dceb582..ee739ba 100644 --- a/frontend/src/components/ScoreboardModal.tsx +++ b/frontend/src/components/ScoreboardModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import type { GameConclusion, Player } from '../types'; -import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars'; +import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars'; interface ScoreboardModalProps { conclusion: GameConclusion | null; @@ -204,97 +204,109 @@ export const ScoreboardModal: React.FC = ({ {/* Podium for Top 3 */}
{/* 2nd Place */} - {rankedPlayers[1] && ( -
-
๐Ÿฅˆ
-
2nd Place
-
- + {rankedPlayers[1] && (() => { + const isDead = isPlayerDead(rankedPlayers[1]); + return ( +
+
๐Ÿฅˆ
+
2nd Place
+
+ +
+
{rankedPlayers[1].name}
+ {isDead &&
๐Ÿ’€ Fallen (0 HP)
} +
{rankedPlayers[1].score} pts
+
โšก {rankedPlayers[1].strength} STR
-
{rankedPlayers[1].name}
-
{rankedPlayers[1].score} pts
-
โšก {rankedPlayers[1].strength} STR
-
- )} + ); + })()} {/* 1st Place (Center / Tallest) */} - {rankedPlayers[0] && ( -
-
๐Ÿ‘‘
-
๐Ÿฅ‡
-
- 1st Place Champion + {rankedPlayers[0] && (() => { + const isDead = isPlayerDead(rankedPlayers[0]); + return ( +
+
{isDead ? '๐Ÿชฆ' : '๐Ÿ‘‘'}
+
๐Ÿฅ‡
+
+ 1st Place Champion +
+
+ +
+
{rankedPlayers[0].name}
+ {isDead &&
๐Ÿ’€ Fallen (0 HP)
} +
+ {rankedPlayers[0].score} pts +
+
โšก {rankedPlayers[0].strength} STR
+ {!isDead && rankedPlayers[0].id === conclusion.winning_leader_id && ( +
Supreme Leader
+ )}
-
- -
-
{rankedPlayers[0].name}
-
- {rankedPlayers[0].score} pts -
-
โšก {rankedPlayers[0].strength} STR
- {rankedPlayers[0].id === conclusion.winning_leader_id && ( -
Supreme Leader
- )} -
- )} + ); + })()} {/* 3rd Place */} - {rankedPlayers[2] && ( -
-
๐Ÿฅ‰
-
3rd Place
-
- + {rankedPlayers[2] && (() => { + const isDead = isPlayerDead(rankedPlayers[2]); + return ( +
+
๐Ÿฅ‰
+
3rd Place
+
+ +
+
{rankedPlayers[2].name}
+ {isDead &&
๐Ÿ’€ Fallen (0 HP)
} +
{rankedPlayers[2].score} pts
+
โšก {rankedPlayers[2].strength} STR
-
{rankedPlayers[2].name}
-
{rankedPlayers[2].score} pts
-
โšก {rankedPlayers[2].strength} STR
-
- )} + ); + })()}
{/* Complete Scoreboard Rankings Table */} @@ -315,6 +327,7 @@ export const ScoreboardModal: React.FC = ({ {rankedPlayers.map((player, idx) => { const rank = idx + 1; + const isDead = isPlayerDead(player); let rankBadge = `#${rank}`; let rowStyle = styles.tr; @@ -329,10 +342,10 @@ export const ScoreboardModal: React.FC = ({ rowStyle = { ...styles.tr, ...styles.bronzeRow }; } - const isLeader = player.id === conclusion.winning_leader_id; + const isLeader = !isDead && player.id === conclusion.winning_leader_id; return ( - + {rankBadge} = ({ className="mr-2" /> {player.name} + {isDead && ๐Ÿ’€ Fallen} {isLeader && ๐Ÿ‘‘ Leader} - {isLeader ? 'Supreme Leader' : 'Squad Member'} + {isDead ? ( + Fallen (Deceased) + ) : isLeader ? ( + 'Supreme Leader' + ) : ( + 'Squad Member' + )} โšก {player.strength} diff --git a/frontend/src/components/WizardChallengeModal.tsx b/frontend/src/components/WizardChallengeModal.tsx index 3352b75..8a9547a 100644 --- a/frontend/src/components/WizardChallengeModal.tsx +++ b/frontend/src/components/WizardChallengeModal.tsx @@ -19,7 +19,7 @@ export const WizardChallengeModal: React.FC = ({ }); 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}` + ? `${challenge.challenger_id}_${challenge.player_bouts_won}_${challenge.wizard_bouts_won}_${challenge.reward_chosen}_${challenge.score_change}_${challenge.strength_change}_${challenge.health_change}_${challenge.wizard_respawn_position?.x}_${challenge.wizard_respawn_position?.y}` : null; useEffect(() => { @@ -186,8 +186,14 @@ export const WizardChallengeModal: React.FC = ({
{challenge.player_won ? ( -
- ๐ŸŽ‰ +{challenge.score_change} Score Points awarded! (Total: {challenge.new_score}) +
+ {challenge.reward_chosen === 'strength' ? ( +
โšก Victory Reward: +{challenge.strength_change || 2} Strength! (Total Strength: {challenge.new_strength ?? '?'})
+ ) : challenge.reward_chosen === 'health' ? ( +
โค๏ธ Victory Reward: +{challenge.health_change || 2} Health! (Total HP: {challenge.new_health})
+ ) : ( +
๐Ÿ† Victory Reward: +{challenge.score_change || 2} Score Points! (Total Score: {challenge.new_score})
+ )}
) : (
diff --git a/frontend/src/components/WizardPromptModal.tsx b/frontend/src/components/WizardPromptModal.tsx new file mode 100644 index 0000000..f8d13bf --- /dev/null +++ b/frontend/src/components/WizardPromptModal.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import type { Player, WizardNPC, WizardRewardChoice } from '../types'; + +interface WizardPromptModalProps { + isOpen: boolean; + challenger: Player | null; + wizard?: WizardNPC | null; + onConfirm: (rewardChoice: WizardRewardChoice) => void; + onClose: () => void; +} + +export const WizardPromptModal: React.FC = ({ + isOpen, + challenger, + wizard, + onConfirm, + onClose, +}) => { + const [selectedReward, setSelectedReward] = useState('score'); + + if (!isOpen || !challenger) return null; + + const wizardName = wizard?.name || 'Grand Wizard'; + const wizardStr = wizard?.strength ?? 3.0; + + const options: Array<{ + id: WizardRewardChoice; + title: string; + icon: string; + description: string; + badge: string; + activeBorder: string; + activeBg: string; + }> = [ + { + id: 'score', + title: '+2 Score Points', + icon: '๐Ÿ†', + description: 'Climb the scoreboard and advance toward tournament victory.', + badge: 'Current Score: ' + challenger.score, + activeBorder: 'border-amber-400', + activeBg: 'bg-amber-950/40', + }, + { + id: 'strength', + title: '+2 Strength', + icon: 'โšก', + description: 'Permanently boost combat power and roll multiplier for battles.', + badge: 'Current Str: ' + challenger.strength, + activeBorder: 'border-sky-400', + activeBg: 'bg-sky-950/40', + }, + { + id: 'health', + title: '+2 Health (HP)', + icon: 'โค๏ธ', + description: 'Heal and reinforce bot survivability against lethal battle damage.', + badge: `Current HP: ${challenger.health}/${challenger.max_health}`, + activeBorder: 'border-rose-400', + activeBg: 'bg-rose-950/40', + }, + ]; + + return ( +
+
+ {/* Top accent bar */} +
+ + {/* Modal Header */} +
+
+ ๐Ÿง™โ€โ™‚๏ธ +
+

+ CHALLENGE THE GRAND WIZARD +

+

+ Select your victory reward before entering the 3-bout D20 duel +

+
+
+ +
+ + {/* Matchup Banner */} +
+
+ +
+ {challenger.name} +
+ โšกStr: {challenger.strength} โ€ข โค๏ธHP: {challenger.health} โ€ข ๐Ÿ†Score: {challenger.score} +
+
+
+
VS
+
+ {wizardName} +
+ โšกStr: {wizardStr} โ€ข Wandering NPC +
+
+
+ + {/* Reward Choice Selector */} +
+ +
+ {options.map((opt) => { + const isSelected = selectedReward === opt.id; + return ( + + ); + })} +
+
+ + {/* Defeat Risk Warning */} +
+ โš ๏ธ + + Defeat Risk: If defeated, {challenger.name} will lose 2 HP (or 2 score points if no HP remains). + +
+ + {/* Modal Actions */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index 0c90c28..3e12970 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -11,6 +11,7 @@ import type { Player, TurnInfo, WizardChallengeResult, + WizardRewardChoice, } from '../types'; const INITIAL_BOARD: BoardState = { @@ -212,12 +213,18 @@ export function useGameSocket() { wizard: data.wizard ?? prev.wizard, turn: data.turn ?? prev.turn, })); - const c: WizardChallengeResult = data.challenge; + const c: WizardChallengeResult = data.challenge_result || data.challenge; setActiveWizardChallenge(c); const wizName = c.wizard_name || 'Grand Wizard'; + let rewardLabel = `+${c.score_change} score`; + if (c.reward_chosen === 'strength') { + rewardLabel = `+${c.strength_change ?? 2} strength`; + } else if (c.reward_chosen === 'health') { + rewardLabel = `+${c.health_change} health`; + } setLastEventMessage( c.player_won - ? `๐Ÿง™ ${c.challenger_name} defeated ${wizName}! (+${c.score_change} score)` + ? `๐Ÿง™ ${c.challenger_name} defeated ${wizName}! (${rewardLabel})` : `๐Ÿง™ ${c.challenger_name} lost to ${wizName}! (${c.health_change < 0 ? `${c.health_change} HP` : `${c.score_change} pts`})` ); } else if (data.event === 'game_concluded') { @@ -444,11 +451,14 @@ export function useGameSocket() { return data; }; - const challengeWizard = async (playerId: string): Promise => { + const challengeWizard = async ( + playerId: string, + rewardChoice: WizardRewardChoice = 'score' + ): Promise => { const res = await fetch('/api/wizard/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ player_id: playerId }), + body: JSON.stringify({ player_id: playerId, reward_choice: rewardChoice }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6c30e55..9591949 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -23,6 +23,7 @@ export interface Player { score: number; health?: number; max_health?: number; + is_alive?: boolean; piece_type?: 'knight' | 'warrior'; party_id?: string | null; is_party_leader: boolean; @@ -185,6 +186,7 @@ export interface BattleResult { new_party_size: number; new_party_strength: number; health_losses?: Record; + dead_players?: string[]; } export interface WizardChallengeBout { @@ -198,6 +200,13 @@ export interface WizardChallengeBout { winner: string; } +export type WizardRewardChoice = 'score' | 'strength' | 'health'; + +export interface WizardChallengeRequest { + player_id: string; + reward_choice?: WizardRewardChoice; +} + export interface WizardChallengeResult { challenger_id: string; challenger_name: string; @@ -207,10 +216,14 @@ export interface WizardChallengeResult { player_bouts_won: number; wizard_bouts_won: number; player_won: boolean; + reward_chosen?: WizardRewardChoice | null; score_change: number; + strength_change?: number; health_change: number; new_score: number; + new_strength?: number; new_health: number; + player_died?: boolean; wizard_respawn_position: { x: number; y: number }; } diff --git a/frontend/src/utils/pixelAvatars.tsx b/frontend/src/utils/pixelAvatars.tsx index 84ec336..f3e0302 100644 --- a/frontend/src/utils/pixelAvatars.tsx +++ b/frontend/src/utils/pixelAvatars.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Player } from '../types'; -export type PieceType = 'knight' | 'warrior' | 'wizard'; +export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone'; // Hex color parser and manipulator function parseHex(hex: string): [number, number, number] { @@ -150,6 +150,32 @@ const WIZARD_SPRITE: string[] = [ '..____________..', // Row 15: Miniature drop shadow ]; +const GRAVESTONE_SPRITE: string[] = [ + '.....KKKKKK.....', // Row 0: Arched stone tombstone top outline + '...KKmmmmMMKK...', // Row 1: Stone bevel + '..KmmmMMMMMSSK..', // Row 2: Stone face with highlight and shadow + '..KmMMMMMMMSSK..', // Row 3: Stone face + '..KmMMMKSMMMSSK.', // Row 4: Carved cross top + '..KmMKKKKKKMSSK.', // Row 5: Carved cross bar + '..KmMMMKSMMMSSK.', // Row 6: Carved cross vertical stem + '..KmMMMKSMMMSSK.', // Row 7: Carved cross vertical stem + '..KmMMMMMMMSSK..', // Row 8: Stone slab + '..KmK.K.K.KMSSK.', // Row 9: Carved epitaph "R I P" + '..KmMMMMMMMSSK..', // Row 10: Lower stone + '.KKKKKKKKKKKKKK.', // Row 11: Base stone top + '.KBBBBBBBBBBBBK.', // Row 12: Stone pedestal base + 'KHHHHHHKLDKKHHHK', // Row 13: Dirt mound with faction flower + 'KhhhhhhhhhhhhhhK', // Row 14: Dark earth base + '..____________..', // Row 15: Drop shadow +]; + +// Death status helper +export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean { + if (player.is_alive === false) return true; + if (player.health !== undefined && player.health <= 0) return true; + return false; +} + // Palette generation function getPalette(color: string): Record { const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`; @@ -188,7 +214,13 @@ export function getPlayerPieceType(player: { piece_type?: string; is_party_leader?: boolean; party_id?: string | null; + is_alive?: boolean; + health?: number; }): PieceType { + // Fallen / deceased players are represented by gravestones + if (isPlayerDead(player)) { + return 'gravestone'; + } // Party leaders are always Knights commanding the squad if (player.is_party_leader) { return 'knight'; @@ -259,6 +291,8 @@ export function getSpriteCanvas( spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE; } else if (pieceType === 'wizard') { spriteMatrix = WIZARD_SPRITE; + } else if (pieceType === 'gravestone') { + spriteMatrix = GRAVESTONE_SPRITE; } else { spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; } @@ -311,8 +345,9 @@ export function drawPlayerPiece( isSelected: boolean, isCurrentTurn: boolean ): void { - const pieceType = getPlayerPieceType(player); - const isLeader = player.is_party_leader; + const dead = isPlayerDead(player); + const pieceType = dead ? 'gravestone' : getPlayerPieceType(player); + const isLeader = !dead && player.is_party_leader; const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader); // Scaled miniature size: board game miniature looks best at ~1.35x cellSize @@ -323,8 +358,8 @@ export function drawPlayerPiece( ctx.save(); - // Active turn indicator glowing ring around the pedestal - if (isCurrentTurn) { + // Active turn indicator glowing ring around the pedestal (only if alive) + if (isCurrentTurn && !dead) { ctx.save(); ctx.beginPath(); ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.55, spriteSize * 0.28, 0, 0, Math.PI * 2); @@ -343,20 +378,23 @@ export function drawPlayerPiece( ctx.save(); ctx.beginPath(); ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.62, spriteSize * 0.32, 0, 0, Math.PI * 2); - ctx.strokeStyle = '#38bdf8'; + ctx.strokeStyle = dead ? '#94a3b8' : '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([3, 3]); - ctx.shadowColor = '#38bdf8'; + ctx.shadowColor = dead ? '#64748b' : '#38bdf8'; ctx.shadowBlur = 6; ctx.stroke(); ctx.restore(); } - // Draw the crisp pixelated Knight or Warrior figurine + // Draw the crisp pixelated figurine or gravestone ctx.imageSmoothingEnabled = false; + if (dead) { + ctx.globalAlpha = 0.85; + } ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize); - // Crown symbol above party leader + // Crown symbol above party leader (only alive) if (isLeader) { ctx.save(); ctx.fillStyle = '#fbbf24'; @@ -369,30 +407,31 @@ export function drawPlayerPiece( // Player Name and Strength Badge (Title Bar) ctx.save(); - const isHighlighted = isCurrentTurn || isSelected; - ctx.globalAlpha = isHighlighted ? 1.0 : 0.5; + const isHighlighted = (isCurrentTurn && !dead) || isSelected; + ctx.globalAlpha = dead ? (isSelected ? 0.9 : 0.65) : (isHighlighted ? 1.0 : 0.5); ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const roleIcon = isLeader ? '๐Ÿ‘‘' : pieceType === 'knight' ? 'โš”๏ธ' : '๐Ÿช“'; - const hpBadge = player.health !== undefined ? ` โค๏ธ${player.health}` : ''; - const text = `${roleIcon} ${player.name} [โšก${player.strength.toFixed(1)}${hpBadge}]`; + const roleIcon = dead ? '๐Ÿชฆ' : isLeader ? '๐Ÿ‘‘' : pieceType === 'knight' ? 'โš”๏ธ' : '๐Ÿช“'; + const text = dead + ? `๐Ÿชฆ ${player.name} [DEAD]` + : `${roleIcon} ${player.name} [โšก${player.strength.toFixed(1)}${player.health !== undefined ? ` โค๏ธ${player.health}` : ''}]`; const textMetrics = ctx.measureText(text); const bgWidth = textMetrics.width + 12; const bgHeight = 16; const labelY = isLeader ? destY - 14 : destY - 8; - ctx.fillStyle = 'rgba(15, 23, 42, 0.92)'; - ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; + ctx.fillStyle = dead ? 'rgba(30, 41, 59, 0.92)' : 'rgba(15, 23, 42, 0.92)'; + ctx.strokeStyle = dead ? '#64748b' : isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.fill(); ctx.stroke(); - ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc'; + ctx.fillStyle = dead ? '#94a3b8' : isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc'; ctx.fillText(text, px, labelY - 4); ctx.restore(); @@ -489,13 +528,15 @@ export const PixelAvatar: React.FC = ({ className = '', title, }) => { - const dataUrl = getSpriteDataUrl(pieceType, color, isLeader); + const actualLeader = pieceType === 'gravestone' ? false : isLeader; + const dataUrl = getSpriteDataUrl(pieceType, color, actualLeader); + const label = pieceType === 'gravestone' ? 'Gravestone' : `${actualLeader ? 'Leader ' : ''}${pieceType}`; return (