version shown on page, health, different award choices for battling wizard

This commit is contained in:
Isaac Johnson 2026-09-09 19:32:22 -05:00
parent 03e792e821
commit e7a1e75863
23 changed files with 1313 additions and 328 deletions

View File

@ -117,15 +117,28 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
- Exactly 3 bouts are conducted. - 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). - 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. - Highest bout score wins the bout. Best 2-out-of-3 bouts wins the challenge.
- **Scoring & Penalties**: - **Victory Rewards & Defeat Penalties**:
- **Victory**: Player/party leader receives **+2 score points**. - **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). - **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose).
- **Post-Challenge**: - **Post-Challenge**:
- Following the challenge, the wizard teleports to a new random open coordinate on the map. - Following the challenge, the wizard teleports to a new random open coordinate on the map.
### 7. Game Conclusion ### 7. Player Health, Damage & Death
- The game concludes when all bots on the board are united into a **single remaining party**. - All bots register with default **10 HP** (configurable).
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker). - 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) | | `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 | | `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard |
| `GET` | `/api/wizard` | Get current Wizard NPC coordinates and attributes | | `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}/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) |

View File

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

View File

@ -34,14 +34,27 @@
3. **Challenge Resolution (3-Bout D20)**: 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). - 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. - Highest bout score wins the bout. Best 2 out of 3 bouts wins the challenge.
4. **Scoring & Penalties**: 4. **Victory Rewards & Defeat Penalties**:
- **Victory**: The player (or party leader) receives **+2 score points**. - **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). - **Defeat**: The player (or party leader) loses **2 health** (or **2 score points** if they do not have health to lose).
5. **Wizard Relocation**: 5. **Wizard Relocation**:
- Following any challenge, the wizard teleports to a new random open coordinate on the map. - 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 # Game Conclusion
The game ends when all bots are united into a single remaining party. The game ends when all surviving bots on the board are united into a single remaining party (or if only one surviving bot remains).
Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). All players (both living and deceased) are listed on the final scoreboard rankings. Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker).

View File

@ -404,12 +404,15 @@ async def get_wizard():
@router.post( @router.post(
"/wizard/challenge", "/wizard/challenge",
response_model=WizardChallengeResult, 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"], tags=["Wizard NPC"],
) )
async def challenge_wizard(challenge_req: WizardChallengeRequest): async def challenge_wizard(challenge_req: WizardChallengeRequest):
try: 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() board_state = await game_engine.get_board_state()
await manager.broadcast({ await manager.broadcast({
@ -668,6 +671,11 @@ async def pass_turn(player_id: str):
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=str(e), detail=str(e),
) )
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get( @router.get(

View File

@ -268,13 +268,62 @@ class GameEngine:
random.randint(self.config.min_y, self.config.max_y), random.randint(self.config.min_y, self.config.max_y),
) )
def _check_and_apply_death(self, player: Player) -> bool:
"""If player's health <= 0, mark dead, disconnect from party, and remove from turn rotation."""
if player.health <= 0:
player.health = 0
player.is_alive = False
# Disconnect from any existing party
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
if player.id in party.member_ids:
party.member_ids.remove(player.id)
if party.leader_id == player.id:
surviving = [
self.players[mid]
for mid in party.member_ids
if mid in self.players and self.players[mid].is_alive
]
if surviving:
surviving.sort(key=lambda p: (p.strength, p.score), reverse=True)
new_leader = surviving[0]
party.leader_id = new_leader.id
party.leader_name = new_leader.name
new_leader.is_party_leader = True
self._update_party_strength(party)
else:
if party.id in self.parties:
del self.parties[party.id]
elif party.member_ids:
self._update_party_strength(party)
else:
if party.id in self.parties:
del self.parties[party.id]
player.party_id = None
player.is_party_leader = False
if player.id in self.turn_order:
self.turn_order.remove(player.id)
actors = self._get_active_turn_actors()
if actors:
self.current_turn_index %= len(actors)
else:
self.current_turn_index = 0
return True
return False
def _get_active_turn_actors(self) -> List[str]: def _get_active_turn_actors(self) -> List[str]:
if not self.game_started: if not self.game_started:
return [] return []
actors = [] actors = []
for pid in self.turn_order: for pid in self.turn_order:
p = self.players.get(pid) p = self.players.get(pid)
if not p: if not p or not p.is_alive:
continue continue
if not p.party_id or p.is_party_leader: if not p.party_id or p.is_party_leader:
actors.append(pid) actors.append(pid)
@ -506,7 +555,7 @@ class GameEngine:
targets: List[RadarTarget] = [] targets: List[RadarTarget] = []
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id or not other.is_alive or other.health <= 0:
continue continue
dist = max(abs(player.x - other.x), abs(player.y - other.y)) dist = max(abs(player.x - other.x), abs(player.y - other.y))
is_ally = bool(player.party_id and player.party_id == other.party_id) is_ally = bool(player.party_id and player.party_id == other.party_id)
@ -669,6 +718,8 @@ class GameEngine:
p = self.players.get(mid) p = self.players.get(mid)
if not p: if not p:
raise KeyError(f"Player '{mid}' not found") raise KeyError(f"Player '{mid}' not found")
if not p.is_alive or p.health <= 0:
raise ValueError(f"Player '{p.name}' is dead and cannot join a party.")
member_players.append(p) member_players.append(p)
if not self._verify_party_connectivity(member_players): if not self._verify_party_connectivity(member_players):
@ -716,6 +767,10 @@ class GameEngine:
invitee = self.players.get(invitee_id) invitee = self.players.get(invitee_id)
if not inviter or not invitee: if not inviter or not invitee:
raise KeyError("Inviter or invitee not found") raise KeyError("Inviter or invitee not found")
if not inviter.is_alive or inviter.health <= 0:
raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.")
if not invitee.is_alive or invitee.health <= 0:
raise ValueError(f"Player '{invitee.name}' is dead and cannot be invited.")
eligible_hosts = [inviter] eligible_hosts = [inviter]
if inviter.party_id and inviter.party_id in self.parties: if inviter.party_id and inviter.party_id in self.parties:
@ -756,6 +811,8 @@ class GameEngine:
invitee = self.players.get(invite.invitee_id) invitee = self.players.get(invite.invitee_id)
if not inviter or not invitee: if not inviter or not invitee:
raise KeyError("Inviter or invitee no longer active") raise KeyError("Inviter or invitee no longer active")
if not inviter.is_alive or not invitee.is_alive:
raise ValueError("Cannot join party with deceased player.")
if inviter.party_id and inviter.party_id in self.parties: if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id] party = self.parties[inviter.party_id]
@ -927,6 +984,17 @@ class GameEngine:
direction_name: str, direction_name: str,
occupied_map: Dict[Tuple[int, int], Player], occupied_map: Dict[Tuple[int, int], Player],
) -> MoveCheckResult: ) -> MoveCheckResult:
if not player.is_alive or player.health <= 0:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason="Player is dead (0 HP)",
)
if player.party_id and player.party_id in self.parties and player.is_party_leader: if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id] party = self.parties[player.party_id]
new_positions, failure_reason, strength_penalty = self._compute_party_move( new_positions, failure_reason, strength_penalty = self._compute_party_move(
@ -1033,6 +1101,32 @@ class GameEngine:
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
moves = {
name: MoveCheckResult(
direction=name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason="Player is dead (0 HP)",
)
for name, dx, dy in STANDARD_DIRECTIONS
}
return AvailableMovesResponse(
player_id=player.id,
player_name=player.name,
current_x=player.x,
current_y=player.y,
is_turn=False,
is_party_leader=False,
party_id=None,
party_member_count=0,
current_turn_player_id=None,
moves=moves,
)
occupied = self._get_occupied_coordinates() occupied = self._get_occupied_coordinates()
current_turn = self._get_current_player() current_turn = self._get_current_player()
is_turn = bool(current_turn and current_turn.id == player_id) is_turn = bool(current_turn and current_turn.id == player_id)
@ -1149,10 +1243,28 @@ class GameEngine:
killed_leader = self.players.get(defeated_party.leader_id) killed_leader = self.players.get(defeated_party.leader_id)
absorbed_members: List[str] = [] absorbed_members: List[str] = []
dead_players: List[str] = []
# Defeated party members (including leader) all lose 1 to 3 health points (randomized)
health_losses: Dict[str, int] = {}
defeated_all_ids = list(defeated_party.member_ids)
if defeated_party.leader_id and defeated_party.leader_id not in defeated_all_ids:
defeated_all_ids.append(defeated_party.leader_id)
for mid in defeated_all_ids:
p = self.players.get(mid)
if p:
hp_loss = random.randint(1, 3)
p.health = max(0, p.health - hp_loss)
health_losses[mid] = hp_loss
if p.health == 0 and p.is_alive:
self._check_and_apply_death(p)
dead_players.append(mid)
if killed_leader: if killed_leader:
killed_leader.score -= 1 killed_leader.score -= 1
if killed_leader.is_alive:
if len(defeated_party.member_ids) == 1: if len(defeated_party.member_ids) == 1:
killed_leader.party_id = winner_party.id killed_leader.party_id = winner_party.id
killed_leader.is_party_leader = False killed_leader.is_party_leader = False
@ -1167,24 +1279,15 @@ class GameEngine:
killed_leader.party_id = None killed_leader.party_id = None
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}
else:
# Leader is dead: remains at final coordinates as gravestone, disconnected from party
respawn_pos = {"x": killed_leader.x, "y": killed_leader.y}
# Defeated party members (including leader) all lose 1 to 3 health points (randomized) # Only surviving defeated party followers are absorbed into winning party
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)
if m: if m and m.is_alive:
m.party_id = winner_party.id m.party_id = winner_party.id
m.is_party_leader = False m.is_party_leader = False
if m.id not in winner_party.member_ids: if m.id not in winner_party.member_ids:
@ -1218,6 +1321,7 @@ class GameEngine:
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, health_losses=health_losses,
dead_players=dead_players,
) )
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
@ -1228,6 +1332,10 @@ class GameEngine:
p2 = self.players.get(defender_id) p2 = self.players.get(defender_id)
if not p1 or not p2: if not p1 or not p2:
raise KeyError("Challenger or defender not found") raise KeyError("Challenger or defender not found")
if not p1.is_alive or p1.health <= 0:
raise ValueError(f"Challenger '{p1.name}' is dead and cannot battle.")
if not p2.is_alive or p2.health <= 0:
raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.")
if p1.party_id and p1.party_id == p2.party_id: if p1.party_id and p1.party_id == p2.party_id:
raise ValueError("Cannot battle members of your own party") raise ValueError("Cannot battle members of your own party")
@ -1273,7 +1381,9 @@ 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: 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.""" """Resolve a 3-bout D20 challenge between player/party and the Wizard NPC."""
effective_strength = player.strength effective_strength = player.strength
party_id = player.party_id party_id = player.party_id
@ -1321,14 +1431,31 @@ class GameEngine:
player_won = player_bouts_won >= 2 player_won = player_bouts_won >= 2
score_change = 0 score_change = 0
strength_change = 0.0
health_change = 0 health_change = 0
choice = (reward_choice or "score").lower().strip()
if choice not in ("score", "strength", "health"):
choice = "score"
if player_won: if player_won:
# Player wins challenge: +2 score # 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 player.score += 2
score_change = 2 score_change = 2
health_change = 0
else: player_died = False
if not player_won:
# Player loses challenge: lose 2 health (or points if they do not have health to lose) # Player loses challenge: lose 2 health (or points if they do not have health to lose)
if player.health >= 2: if player.health >= 2:
player.health -= 2 player.health -= 2
@ -1345,6 +1472,9 @@ class GameEngine:
player.score -= 2 player.score -= 2
score_change = -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 # Wizard teleports to a new random open coordinate on the map
new_wx, new_wy = self._find_random_free_position() new_wx, new_wy = self._find_random_free_position()
self.wizard.x = new_wx self.wizard.x = new_wx
@ -1356,19 +1486,24 @@ class GameEngine:
return WizardChallengeResult( return WizardChallengeResult(
challenger_id=player.id, challenger_id=player.id,
challenger_name=player.name, challenger_name=player.name,
wizard_name="Grand Wizard",
party_id=party_id, party_id=party_id,
bouts=bouts, bouts=bouts,
player_bouts_won=player_bouts_won, player_bouts_won=player_bouts_won,
wizard_bouts_won=wizard_bouts_won, wizard_bouts_won=wizard_bouts_won,
player_won=player_won, player_won=player_won,
reward_chosen=choice if player_won else None,
score_change=score_change, score_change=score_change,
strength_change=strength_change,
health_change=health_change, health_change=health_change,
new_score=player.score, new_score=player.score,
new_strength=player.strength,
new_health=player.health, new_health=player.health,
player_died=player_died,
wizard_respawn_position=respawn_pos, 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: async with self._lock:
if not self.game_started: if not self.game_started:
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
@ -1377,6 +1512,9 @@ class GameEngine:
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody" 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." 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 def get_game_conclusion(self) -> GameConclusion:
async with self._lock: async with self._lock:
@ -1408,21 +1546,52 @@ class GameEngine:
if len(self.players) < 2: if len(self.players) < 2:
return GameConclusion(concluded=False) return GameConclusion(concluded=False)
if len(self.parties) == 1: living_players = [p for p in self.players.values() if p.is_alive]
only_party = next(iter(self.parties.values())) total_bots = len(self.players)
if len(only_party.member_ids) == len(self.players):
rankings = sorted( rankings = sorted(
self.players.values(), self.players.values(),
key=lambda p: (p.score, p.strength, p.name), key=lambda p: (p.score, p.strength, p.name),
reverse=True, 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()))
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( return GameConclusion(
concluded=True, concluded=True,
winning_party_id=only_party.id, winning_party_id=only_party.id,
winning_party_name=only_party.name, winning_party_name=only_party.name,
winning_leader_id=only_party.leader_id, winning_leader_id=only_party.leader_id,
winning_leader_name=only_party.leader_name, winning_leader_name=only_party.leader_name,
total_bots=len(self.players), total_bots=total_bots,
rankings=rankings, rankings=rankings,
) )
@ -1431,9 +1600,13 @@ class GameEngine:
def _check_adjacent_encounter( def _check_adjacent_encounter(
self, player: Player self, player: Player
) -> Tuple[Optional[Party], Optional[BattleResult]]: ) -> Tuple[Optional[Party], Optional[BattleResult]]:
if not player.is_alive or player.health <= 0:
return None, None
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id:
continue continue
if not other.is_alive or other.health <= 0:
continue
if player.party_id and player.party_id == other.party_id: if player.party_id and player.party_id == other.party_id:
continue continue
@ -1544,6 +1717,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot move.")
if player.party_id and not player.is_party_leader: if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id) party = self.parties.get(player.party_id)
@ -1636,6 +1811,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and has no actions.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
@ -1655,6 +1832,8 @@ class GameEngine:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot take actions.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
@ -1708,7 +1887,13 @@ class GameEngine:
if player.party_id and player.party_id in self.parties: if player.party_id and player.party_id in self.parties:
effective_str = self.parties[player.party_id].total_strength effective_str = self.parties[player.party_id].total_strength
if effective_str >= self.wizard.strength or player.health >= 4: 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() conclusion = self._check_game_concluded()
return AiStepResponse( return AiStepResponse(
action_taken="challenged_wizard", action_taken="challenged_wizard",
@ -1750,7 +1935,7 @@ class GameEngine:
# Find nearest target according to goal # Find nearest target according to goal
targets = [] targets = []
for other in self.players.values(): for other in self.players.values():
if other.id == player.id: if other.id == player.id or not other.is_alive or other.health <= 0:
continue continue
if player.party_id and player.party_id == other.party_id: if player.party_id and player.party_id == other.party_id:
continue continue
@ -1862,6 +2047,73 @@ class GameEngine:
turn=turn_info, turn=turn_info,
) )
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
async with self._lock:
if party_id not in self.parties:
raise KeyError(f"Party '{party_id}' not found")
party = self.parties[party_id]
leader = self.players.get(party.leader_id)
leader_id = party.leader_id
leader_name = party.leader_name
if leader:
leader.score -= 1
hp_loss = random.randint(1, 3)
leader.health = max(0, leader.health - hp_loss)
if leader.health == 0 and leader.is_alive:
self._check_and_apply_death(leader)
respawn_pos = {"x": leader.x, "y": leader.y}
else:
rx, ry = self._find_random_free_position()
leader.x = rx
leader.y = ry
leader.party_id = None
leader.is_party_leader = False
respawn_pos = {"x": rx, "y": ry}
else:
respawn_pos = {"x": 0, "y": 0}
if leader_id in party.member_ids:
party.member_ids.remove(leader_id)
remaining_alive = [
mid for mid in party.member_ids
if mid in self.players and self.players[mid].is_alive
]
new_leader_id = None
new_leader_name = None
party_dissolved = False
if remaining_alive:
candidates = [self.players[mid] for mid in remaining_alive]
candidates.sort(key=lambda p: (p.strength, p.score), reverse=True)
new_lead = candidates[0]
party.leader_id = new_lead.id
party.leader_name = new_lead.name
new_lead.is_party_leader = True
new_leader_id = new_lead.id
new_leader_name = new_lead.name
self._update_party_strength(party)
else:
party_dissolved = True
if party.id in self.parties:
del self.parties[party.id]
self._advance_turn()
return PartyDefeatResult(
party_id=party_id,
killed_leader_id=leader_id,
killed_leader_name=leader_name,
killed_leader_new_score=leader.score if leader else 0,
killed_leader_respawn_position=respawn_pos,
new_leader_id=new_leader_id,
new_leader_name=new_leader_name,
remaining_members=remaining_alive,
party_dissolved=party_dissolved,
)
# Global game engine instance # Global game engine instance
game_engine = GameEngine() game_engine = GameEngine()

View File

@ -154,6 +154,7 @@ class Player(BaseModel):
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
is_alive: bool = True
visited_locations: List[Dict[str, int]] = Field(default_factory=list) visited_locations: List[Dict[str, int]] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@ -250,6 +251,10 @@ class BattleResult(BaseModel):
default_factory=dict, default_factory=dict,
description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}", description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}",
) )
dead_players: List[str] = Field(
default_factory=list,
description="IDs of players who died (HP reached 0) in this battle",
)
class BattleRequest(BaseModel): class BattleRequest(BaseModel):
@ -291,15 +296,23 @@ class WizardChallengeResult(BaseModel):
player_bouts_won: int player_bouts_won: int
wizard_bouts_won: int wizard_bouts_won: int
player_won: bool player_won: bool
reward_chosen: Optional[str] = "score"
score_change: int = 0 score_change: int = 0
strength_change: float = 0.0
health_change: int = 0 health_change: int = 0
new_score: int new_score: int
new_strength: float = 1.0
new_health: int new_health: int
player_died: bool = False
wizard_respawn_position: Dict[str, int] wizard_respawn_position: Dict[str, int]
class WizardChallengeRequest(BaseModel): class WizardChallengeRequest(BaseModel):
player_id: str 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): class WizardRadarTarget(BaseModel):

View File

@ -531,19 +531,63 @@ def test_wizard_challenge_mechanics_victory_and_defeat():
game_engine.turn_order = [p1["id"], p2["id"]] game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0 game_engine.current_turn_index = 0
# 1. Challenge the wizard with super high strength (strength 50 guaranteed win) # 1a. Challenge the wizard with default/score reward (strength 50 guaranteed win)
chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"]}) chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "score"})
assert chal_res.status_code == 200 assert chal_res.status_code == 200
res_data = chal_res.json() res_data = chal_res.json()
assert len(res_data["bouts"]) == 3 assert len(res_data["bouts"]) == 3
assert res_data["player_won"] is True assert res_data["player_won"] is True
assert res_data["reward_chosen"] == "score"
assert res_data["score_change"] == 2 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_score"] == 2
assert res_data["new_health"] == 10 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 # Wizard teleports to a new location
wiz_after = client.get("/api/wizard").json() 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 # 2. Test defeat case: bot loses 2 health
# Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss) # 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_health"] == 8
assert loss_data["new_score"] == 5 assert loss_data["new_score"] == 5
# 3. Test defeat when health is 0: bot loses 2 points (score) # 3. Test defeat when health reaches 0: bot dies, health becomes 0, player_died is True
async def setup_zero_health(): async def setup_low_health():
cur_wiz = client.get("/api/wizard").json() cur_wiz = client.get("/api/wizard").json()
adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1 adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1
adj_y = cur_wiz["y"] adj_y = cur_wiz["y"]
@ -577,20 +621,147 @@ def test_wizard_challenge_mechanics_victory_and_defeat():
bot2.x = adj_x bot2.x = adj_x
bot2.y = adj_y bot2.y = adj_y
bot2.strength = 0.001 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 bot2.score = 5
asyncio.run(setup_zero_health()) bot2.is_alive = True
asyncio.run(setup_low_health())
# Set turn back to p2 # Set turn back to p2
game_engine.current_turn_index = 0 game_engine.current_turn_index = 0
game_engine.turn_order = [p2["id"]] game_engine.turn_order = [p2["id"]]
score_loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) death_chal_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert score_loss_res.status_code == 200 assert death_chal_res.status_code == 200
score_loss_data = score_loss_res.json() death_data = death_chal_res.json()
assert score_loss_data["player_won"] is False assert death_data["player_won"] is False
assert score_loss_data["health_change"] == 0 assert death_data["health_change"] == -1
assert score_loss_data["score_change"] == -2 assert death_data["score_change"] == -1
assert score_loss_data["new_health"] == 0 assert death_data["new_health"] == 0
assert score_loss_data["new_score"] == 3 assert death_data["new_score"] == 4
assert death_data["player_died"] is True
# Verify dead bot state: health is 0, is_alive is False
bot2_dead = client.get(f"/api/players/{p2['id']}").json()
assert bot2_dead["health"] == 0
assert bot2_dead["is_alive"] is False
# Dead bot cannot challenge wizard or move (no actions)
dead_action_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert dead_action_res.status_code == 400
assert "dead" in dead_action_res.json()["detail"].lower()
def test_player_death_rule_and_scoreboard_preservation():
"""Test the rule:
When a player loses enough health points to reach 0, they are dead.
They are disconnected from any existing party and a gravestone replaces their icon on the board.
They still are listed on the scores, but they can no longer move and they no longer have a turn.
"""
client = TestClient(app)
client.post("/api/reset")
# Register 3 bots: Winner (party of 1), LoserLead (party of 2), LoserFollower (party of 2)
b1 = client.post("/api/players", json={"name": "IronChampion", "color": "#10b981", "strength": 20, "health": 10}).json()
b2 = client.post("/api/players", json={"name": "FragileLeader", "color": "#ef4444", "strength": 1, "health": 2}).json()
b3 = client.post("/api/players", json={"name": "DoomedFollower", "color": "#f59e0b", "strength": 1, "health": 1}).json()
# Place b2 and b3 in a party
async def setup_party_and_combat():
p1 = await game_engine.get_player(b1["id"])
p2 = await game_engine.get_player(b2["id"])
p3 = await game_engine.get_player(b3["id"])
p1.x, p1.y = 20, 20
p2.x, p2.y = 20, 21
p3.x, p3.y = 21, 21
asyncio.run(setup_party_and_combat())
client.post("/api/game/start")
# Form losing party with b2 (leader) and b3 (follower)
party_loser = client.post("/api/parties", json={
"member_ids": [b2["id"], b3["id"]],
"leader_id": b2["id"],
"name": "FragileSquad",
}).json()
# Form winning party with b1
party_winner = client.post("/api/parties", json={
"member_ids": [b1["id"]],
"leader_id": b1["id"],
"name": "IronSquad",
})
# If parties requires min 2 members via API, b1 battles as solo vs party directly
# Trigger battle between b1 and b2
battle_res = client.post("/api/battles/fight", json={
"challenger_id": b1["id"],
"defender_id": b2["id"],
})
assert battle_res.status_code == 200
battle_data = battle_res.json()
# Verify health loss caused DoomedFollower (starting HP 1, losing 1-3) to DIE
p3_after = client.get(f"/api/players/{b3['id']}").json()
assert p3_after["health"] == 0
assert p3_after["is_alive"] is False
assert p3_after["party_id"] is None
assert p3_after["is_party_leader"] is False
# Dead follower must NOT be absorbed into winning party
assert b3["id"] not in battle_data["absorbed_members"]
# If FragileLeader also died (starting HP 2, lost >= 2):
p2_after = client.get(f"/api/players/{b2['id']}").json()
if p2_after["health"] == 0:
assert p2_after["is_alive"] is False
assert p2_after["party_id"] is None
assert p2_after["is_party_leader"] is False
else:
# If leader survived with 1 HP, manually drop them to 0 to verify death
async def kill_leader():
lead = await game_engine.get_player(b2["id"])
lead.health = 0
game_engine._check_and_apply_death(lead)
asyncio.run(kill_leader())
p2_after = client.get(f"/api/players/{b2['id']}").json()
assert p2_after["health"] == 0
assert p2_after["is_alive"] is False
# Verify dead bots CANNOT move
move_attempt = client.post(f"/api/players/{b3['id']}/move", json={"direction": "UP"})
assert move_attempt.status_code == 400
assert "dead" in move_attempt.json()["detail"].lower()
# Verify dead bots CANNOT pass
pass_attempt = client.post(f"/api/players/{b3['id']}/pass")
assert pass_attempt.status_code == 400
assert "dead" in pass_attempt.json()["detail"].lower()
# Verify available moves returns all unavailable
moves_res = client.get(f"/api/players/{b3['id']}/available-moves").json()
for move in moves_res["moves"].values():
assert move["available"] is False
assert "dead" in move["reason"].lower()
# Verify dead bots do NOT have a turn in turn rotation
turn_data = client.get("/api/turn").json()
assert b3["id"] not in turn_data["turn_order"]
assert b2["id"] not in turn_data["turn_order"]
assert turn_data["current_player_id"] == b1["id"]
# Verify dead bots STILL ARE LISTED on the players/scores list
all_players = client.get("/api/players").json()
all_player_ids = [p["id"] for p in all_players]
assert b1["id"] in all_player_ids
assert b2["id"] in all_player_ids
assert b3["id"] in all_player_ids
# Verify game conclusion: only b1 is alive, so game is concluded!
board = client.get("/api/board").json()
assert board["conclusion"]["concluded"] is True
# All 3 bots (including dead ones) are listed in rankings with their scores!
ranking_ids = [r["id"] for r in board["conclusion"]["rankings"]]
assert len(ranking_ids) == 3
assert b1["id"] in ranking_ids
assert b2["id"] in ranking_ids
assert b3["id"] in ranking_ids

View File

@ -104,6 +104,11 @@ class SmartBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---")
# 1. Consult Radar Sensor # 1. Consult Radar Sensor
@ -130,7 +135,7 @@ class SmartBotAgent:
# Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4) # Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4)
if self.strength >= wiz_str or my_health >= 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!") 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 return
# 4. If no immediate adjacent enemy/recruit, move towards target # 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)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
def _challenge_wizard(self): def _challenge_wizard(self, my_health: int = 10):
"""Voluntarily challenge the Wizard NPC to a 3-bout D20 duel.""" """Voluntarily challenge the Wizard NPC to a 3-bout D20 duel with chosen victory reward."""
print(f"🧙 [WIZARD CHALLENGE] Challenging Grand Wizard to a 3-bout D20 duel...") 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: try:
res = requests.post( res = requests.post(
f"{self.base_url}/wizard/challenge", 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: if res.status_code == 200:
result = res.json() 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')}") print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []): 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" 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") pos = result.get("wizard_respawn_position")
if pos: if pos:
print(f" 🔮 Wizard teleported to ({pos.get('x')}, {pos.get('y')})") print(f" 🔮 Wizard teleported to ({pos.get('x')}, {pos.get('y')})")
@ -322,6 +340,21 @@ class SmartBotAgent:
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
# Check if bot was removed (e.g., board was reset) # Check if bot was removed (e.g., board was reset)
@ -350,7 +383,12 @@ class SmartBotAgent:
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
# If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}") requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():

View File

@ -17,7 +17,7 @@ import os
import re import re
import time import time
import argparse import argparse
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import requests import requests
@ -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). - 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) - Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
lose 1-3 health points (randomized). lose 1-3 health points (randomized).
- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
replaces its icon on the board, and it can no longer move or take turns. Its final score remains
preserved on the scoreboard.
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge - 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). is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score,
- The game ends when all bots are united into a single party. +2 strength, or +2 health; losing costs 2 health (or score if no health).
- The game ends when all surviving bots are united into a single remaining party.
You will only ever be asked to choose between options that are legal - always answer with the You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else. requested JSON object and nothing else.
""" """
@ -175,6 +179,11 @@ class AIBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---")
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
@ -190,47 +199,53 @@ 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"): elif wizard and wizard.get("can_challenge"):
if self._decide_wizard_challenge(wizard, my_info): should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info)
self._challenge_wizard(wizard) if should_challenge:
self._challenge_wizard(wizard, reward_choice=reward_choice)
else: else:
self._navigate_towards_goal(radar_res, my_info) 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: 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.""" """Ask the LLM whether to challenge the adjacent Wizard NPC and which reward to choose on win."""
prompt = f"""{GAME_RULES_SUMMARY} prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) 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']}). at position ({my_info['x']}, {my_info['y']}).
You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll). 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)! - 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 {} decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False) 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", "") reasoning = decision.get("reasoning", "")
print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge}. {reasoning}") print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}")
return bool(challenge) 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.""" """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: try:
res = requests.post( res = requests.post(
f"{self.base_url}/wizard/challenge", 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: if res.status_code == 200:
result = res.json() 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')}") print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []): 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" 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") pos = result.get("wizard_respawn_position")
if pos: if pos:
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") 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)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
@ -529,6 +548,21 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
if self.bot_id: if self.bot_id:
@ -555,7 +589,12 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
# If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}") requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():

View File

@ -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). - 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) - Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
lose 1-3 health points (randomized). lose 1-3 health points (randomized).
- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
replaces its icon on the board, and it can no longer move or take turns. Its final score remains
preserved on the scoreboard.
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge - 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). is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score,
- The game ends when all bots are united into a single party. +2 strength, or +2 health; losing costs 2 health (or score if no health).
- The game ends when all surviving bots are united into a single remaining party.
You will only ever be asked to choose between options that are legal - always answer with the You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else. requested JSON object and nothing else.
""" """
@ -361,6 +365,11 @@ class VertexAIBotAgent:
if not my_info: if not my_info:
return return
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0:
print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.")
return
print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Str: {my_info['strength']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Str: {my_info['strength']} | Party: {self.party_id or 'Solo'} ---")
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
@ -376,47 +385,53 @@ 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"): elif wizard and wizard.get("can_challenge"):
if self._decide_wizard_challenge(wizard, my_info): should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info)
self._challenge_wizard(wizard) if should_challenge:
self._challenge_wizard(wizard, reward_choice=reward_choice)
else: else:
self._navigate_towards_goal(radar_res, my_info) 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: 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.""" """Ask Gemini whether to challenge the adjacent Wizard NPC and which reward to choose on win."""
prompt = f"""{GAME_RULES_SUMMARY} prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) 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']}). at position ({my_info['x']}, {my_info['y']}).
You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}).
Challenging the wizard initiates a 3-bout D20 duel (strength * roll). 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)! - 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 {} decision = self.llm.ask_json(prompt) or {}
challenge = decision.get("challenge_wizard", False) 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", "") reasoning = decision.get("reasoning", "")
print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge}. {reasoning}") print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}")
return bool(challenge) 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.""" """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: try:
res = requests.post( res = requests.post(
f"{self.base_url}/wizard/challenge", 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: if res.status_code == 200:
result = res.json() 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')}") print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}")
for b in result.get("bouts", []): 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" 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") pos = result.get("wizard_respawn_position")
if pos: if pos:
print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") 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)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)")
if battle.get("absorbed_members"): if battle.get("absorbed_members"):
print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}")
if battle.get("dead_players"):
print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!")
if self.bot_id in battle["dead_players"]:
print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.")
else: else:
print(f"Battle failed ({res.status_code}): {res.text}") print(f"Battle failed ({res.status_code}): {res.text}")
@ -723,6 +742,21 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
self.register() self.register()
try: try:
while True: while True:
# Check life status: dead players cannot act but remain on board/scores
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
if self.bot_id: if self.bot_id:
@ -749,8 +783,12 @@ Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "sho
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
if self.bot_id: # If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self.refresh_status()
if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0:
requests.delete(f"{self.base_url}/players/{self.bot_id}") requests.delete(f"{self.base_url}/players/{self.bot_id}")
else:
print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.")
def main(): def main():

View File

@ -8,7 +8,9 @@ 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 { WizardChallengeModal } from './components/WizardChallengeModal';
import { WizardPromptModal } from './components/WizardPromptModal';
import { ScoreboardModal } from './components/ScoreboardModal'; import { ScoreboardModal } from './components/ScoreboardModal';
import type { WizardRewardChoice } from './types';
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
{ name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' }, { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' },
@ -52,8 +54,13 @@ export function App() {
const [isRegisterOpen, setIsRegisterOpen] = useState(false); const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false); const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
const [promptChallengerId, setPromptChallengerId] = useState<string | null>(null);
const [notification, setNotification] = useState<string | null>(null); const [notification, setNotification] = useState<string | null>(null);
const pendingWizardChallenger = promptChallengerId
? boardState.players.find((p) => p.id === promptChallengerId) || null
: null;
const handleCloseBattle = useCallback(() => { const handleCloseBattle = useCallback(() => {
setActiveBattle(null); setActiveBattle(null);
}, [setActiveBattle]); }, [setActiveBattle]);
@ -62,6 +69,26 @@ export function App() {
setActiveWizardChallenge(null); setActiveWizardChallenge(null);
}, [setActiveWizardChallenge]); }, [setActiveWizardChallenge]);
const handleOpenWizardPrompt = useCallback(async (playerId: string) => {
setPromptChallengerId(playerId);
}, []);
const handleConfirmWizardChallenge = useCallback(
async (rewardChoice: WizardRewardChoice) => {
if (!promptChallengerId) return;
const id = promptChallengerId;
setPromptChallengerId(null);
try {
await challengeWizard(id, rewardChoice);
} catch (err: unknown) {
if (err instanceof Error) {
alert(err.message);
}
}
},
[promptChallengerId, challengeWizard]
);
const showNotification = (msg: string) => { const showNotification = (msg: string) => {
setNotification(msg); setNotification(msg);
setTimeout(() => { setTimeout(() => {
@ -167,9 +194,7 @@ export function App() {
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
availableMoves={availableMoves} availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
onChallengeWizard={async (id) => { onChallengeWizard={handleOpenWizardPrompt}
await challengeWizard(id);
}}
/> />
{/* 8-Directional Movement D-Pad & Simulation Controls */} {/* 8-Directional Movement D-Pad & Simulation Controls */}
@ -183,9 +208,7 @@ export function App() {
onPass={async (id) => { onPass={async (id) => {
await passTurn(id); await passTurn(id);
}} }}
onChallengeWizard={async (id) => { onChallengeWizard={handleOpenWizardPrompt}
await challengeWizard(id);
}}
onStepBot={stepActiveBotTurn} onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying} isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
@ -200,9 +223,7 @@ export function App() {
onOpenPartyModal={() => setIsPartyModalOpen(true)} onOpenPartyModal={() => setIsPartyModalOpen(true)}
onDefeatParty={handleDefeatParty} onDefeatParty={handleDefeatParty}
onFightBattle={handleFightBattle} onFightBattle={handleFightBattle}
onChallengeWizard={async (id) => { onChallengeWizard={handleOpenWizardPrompt}
await challengeWizard(id);
}}
/> />
{/* Live Event Feed Notification */} {/* Live Event Feed Notification */}
@ -241,6 +262,15 @@ export function App() {
onClose={handleCloseBattle} onClose={handleCloseBattle}
/> />
{/* Wizard Challenge Reward Selector Prompt Modal */}
<WizardPromptModal
isOpen={Boolean(promptChallengerId && pendingWizardChallenger)}
challenger={pendingWizardChallenger}
wizard={boardState.wizard}
onConfirm={handleConfirmWizardChallenge}
onClose={() => setPromptChallengerId(null)}
/>
{/* 3-Bout D20 Wizard Challenge Modal */} {/* 3-Bout D20 Wizard Challenge Modal */}
<WizardChallengeModal <WizardChallengeModal
challenge={activeWizardChallenge} challenge={activeWizardChallenge}

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, drawWizardPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars'; import { drawPlayerPiece, drawWizardPiece, getPlayerPieceType, PixelAvatar, isPlayerDead } from '../utils/pixelAvatars';
interface BoardCanvasProps { interface BoardCanvasProps {
boardState: BoardState; boardState: BoardState;
@ -687,23 +687,32 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
</div> </div>
{/* Selected Player Overlay card */} {/* Selected Player Overlay card */}
{liveSelectedPlayer && ( {liveSelectedPlayer && (() => {
const isDead = isPlayerDead(liveSelectedPlayer);
return (
<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="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(liveSelectedPlayer)} pieceType={getPlayerPieceType(liveSelectedPlayer)}
color={liveSelectedPlayer.color} color={liveSelectedPlayer.color}
isLeader={liveSelectedPlayer.is_party_leader} isLeader={!isDead && 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">
<span className={isDead ? 'text-slate-400 line-through' : 'text-slate-100'}>
{liveSelectedPlayer.name} {liveSelectedPlayer.name}
{liveSelectedPlayer.is_party_leader && ( </span>
{isDead && (
<span className="text-[10px] text-rose-400 font-mono bg-rose-950/80 px-1.5 py-0.5 rounded border border-rose-800">
💀 DECEASED
</span>
)}
{!isDead && liveSelectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span> <span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)} )}
{liveSelectedPlayer.id === currentTurnId && ( {!isDead && 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>
@ -712,12 +721,12 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
<span className={liveSelectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}> <span className={liveSelectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{liveSelectedPlayer.score} {liveSelectedPlayer.score}
</span> </span>
{' '} HP: <span className="text-rose-400 font-bold">{liveSelectedPlayer.health ?? 10}</span> {' '} HP: <span className="text-rose-400 font-bold">{isDead ? '💀 0' : `❤️${liveSelectedPlayer.health ?? 10}`}</span>
</div> </div>
</div> </div>
{/* Challenge Wizard button on Selected Player Card */} {/* Challenge Wizard button on Selected Player Card (alive only) */}
{isSelectedAdjacentToWizard && onChallengeWizard && ( {!isDead && isSelectedAdjacentToWizard && onChallengeWizard && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -753,7 +762,6 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
<span>{isSelectedTurn && canSelectedChallenge ? 'Challenge Wizard' : 'Duel (Wait Turn)'}</span> <span>{isSelectedTurn && canSelectedChallenge ? 'Challenge Wizard' : 'Duel (Wait Turn)'}</span>
</button> </button>
)} )}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -764,7 +772,8 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
</button> </button>
</div> </div>
)} );
})()}
</div> </div>
); );
}; };

View File

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

View File

@ -1,5 +1,6 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import type { AvailableMovesResponse, BoardState, Player } from '../types'; import type { AvailableMovesResponse, BoardState, Player } from '../types';
import { isPlayerDead } from '../utils/pixelAvatars';
interface MovementControlsProps { interface MovementControlsProps {
boardState: BoardState; boardState: BoardState;
@ -32,9 +33,11 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer ? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null; : null;
const controlledPlayer = liveSelectedPlayer || activePlayer; 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( const isAdjacentToWizard = Boolean(
!isDead &&
controlledPlayer && controlledPlayer &&
boardState.wizard && boardState.wizard &&
Math.max( Math.max(
@ -226,6 +229,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700"> <span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700">
NOT STARTED NOT STARTED
</span> </span>
) : isDead ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-rose-950 text-rose-300 border border-rose-700 font-bold">
💀 DECEASED
</span>
) : isMyTurn ? ( ) : isMyTurn ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse"> <span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
YOUR TURN YOUR TURN

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import type { BoardState, Player } from '../types'; import type { BoardState, Player } from '../types';
import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars'; import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars';
interface PlayerListProps { interface PlayerListProps {
boardState: BoardState; boardState: BoardState;
@ -197,17 +197,22 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </div>
) : ( ) : (
players.map((player) => { players.map((player) => {
const isDead = isPlayerDead(player);
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id; const isCurrentTurn = !isDead && currentTurnId === player.id;
const isLeader = player.is_party_leader; const isLeader = !isDead && player.is_party_leader;
const partyName = partyMap.get(player.id); const partyName = !isDead ? partyMap.get(player.id) : null;
return ( return (
<div <div
key={player.id} key={player.id}
onClick={() => onSelectPlayer(player)} onClick={() => onSelectPlayer(player)}
className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${ className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${
isCurrentTurn isDead
? isSelected
? 'bg-slate-900/80 border-slate-600 shadow-md shadow-slate-900/50'
: 'bg-slate-950/40 border-slate-800/80 hover:border-slate-700 opacity-75'
: isCurrentTurn
? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10' ? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10'
: isSelected : isSelected
? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10' ? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10'
@ -219,12 +224,16 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<div <div
className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-950/80 border p-0.5 shadow transition-transform" className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-950/80 border p-0.5 shadow transition-transform"
style={{ style={{
borderColor: isLeader borderColor: isDead
? '#64748b'
: isLeader
? '#fbbf24' ? '#fbbf24'
: isCurrentTurn : isCurrentTurn
? '#f59e0b' ? '#f59e0b'
: `${player.color}66`, : `${player.color}66`,
boxShadow: isLeader boxShadow: isDead
? 'none'
: isLeader
? '0 0 12px #fbbf2455' ? '0 0 12px #fbbf2455'
: isCurrentTurn : isCurrentTurn
? '0 0 12px #f59e0b55' ? '0 0 12px #f59e0b55'
@ -247,9 +256,14 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-200 truncate"> <span className={`text-xs font-semibold truncate ${isDead ? 'text-slate-400 line-through' : 'text-slate-200'}`}>
{player.name} {player.name}
</span> </span>
{isDead ? (
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-rose-950/80 text-rose-400 border border-rose-800 font-bold">
💀 DEAD
</span>
) : (
<span <span
className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${ className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
player.score < 0 player.score < 0
@ -261,10 +275,19 @@ export const PlayerList: React.FC<PlayerListProps> = ({
> >
{player.score >= 0 ? `+${player.score}` : player.score} pts {player.score >= 0 ? `+${player.score}` : player.score} pts
</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> HP: <span className="text-rose-400 font-bold">{player.health ?? 10}</span> pos: <span className="text-emerald-400">({player.x}, {player.y})</span> Str: <span className="text-amber-400">{player.strength || 1}</span> HP:{' '}
{partyName && ( <span className={isDead ? 'text-rose-500 font-bold' : 'text-rose-400 font-bold'}>
{isDead ? '💀 0' : `❤️${player.health ?? 10}`}
</span>
{isDead && (
<span className="ml-1 text-slate-500 font-sans italic">
Score: {player.score}
</span>
)}
{partyName && !isDead && (
<span className="ml-1 text-sky-400 truncate font-sans"> <span className="ml-1 text-sky-400 truncate font-sans">
{isLeader ? 'Leader' : 'Squad'} {isLeader ? 'Leader' : 'Squad'}
</span> </span>
@ -274,7 +297,8 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </div>
<div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
{boardState.wizard && {!isDead &&
boardState.wizard &&
Math.max( Math.max(
Math.abs(player.x - boardState.wizard.x), Math.abs(player.x - boardState.wizard.x),
Math.abs(player.y - boardState.wizard.y) Math.abs(player.y - boardState.wizard.y)

View File

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

View File

@ -19,7 +19,7 @@ export const WizardChallengeModal: React.FC<WizardChallengeModalProps> = ({
}); });
const challengeKey = challenge 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; : null;
useEffect(() => { useEffect(() => {
@ -186,8 +186,14 @@ export const WizardChallengeModal: React.FC<WizardChallengeModalProps> = ({
<div className="mt-2 text-xs font-mono space-y-1"> <div className="mt-2 text-xs font-mono space-y-1">
{challenge.player_won ? ( {challenge.player_won ? (
<div className="text-emerald-300 font-bold"> <div className="text-emerald-300 font-bold space-y-0.5">
🎉 +{challenge.score_change} Score Points awarded! (Total: {challenge.new_score}) {challenge.reward_chosen === 'strength' ? (
<div> Victory Reward: +{challenge.strength_change || 2} Strength! (Total Strength: {challenge.new_strength ?? '?'})</div>
) : challenge.reward_chosen === 'health' ? (
<div> Victory Reward: +{challenge.health_change || 2} Health! (Total HP: {challenge.new_health})</div>
) : (
<div>🏆 Victory Reward: +{challenge.score_change || 2} Score Points! (Total Score: {challenge.new_score})</div>
)}
</div> </div>
) : ( ) : (
<div className="text-rose-300 font-bold"> <div className="text-rose-300 font-bold">

View File

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

View File

@ -11,6 +11,7 @@ import type {
Player, Player,
TurnInfo, TurnInfo,
WizardChallengeResult, WizardChallengeResult,
WizardRewardChoice,
} from '../types'; } from '../types';
const INITIAL_BOARD: BoardState = { const INITIAL_BOARD: BoardState = {
@ -212,12 +213,18 @@ export function useGameSocket() {
wizard: data.wizard ?? prev.wizard, wizard: data.wizard ?? prev.wizard,
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
})); }));
const c: WizardChallengeResult = data.challenge; const c: WizardChallengeResult = data.challenge_result || data.challenge;
setActiveWizardChallenge(c); setActiveWizardChallenge(c);
const wizName = c.wizard_name || 'Grand Wizard'; 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( setLastEventMessage(
c.player_won 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`})` : `🧙 ${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') {
@ -444,11 +451,14 @@ export function useGameSocket() {
return data; return data;
}; };
const challengeWizard = async (playerId: string): Promise<WizardChallengeResult> => { const challengeWizard = async (
playerId: string,
rewardChoice: WizardRewardChoice = 'score'
): Promise<WizardChallengeResult> => {
const res = await fetch('/api/wizard/challenge', { const res = await fetch('/api/wizard/challenge', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId }), body: JSON.stringify({ player_id: playerId, reward_choice: rewardChoice }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));

View File

@ -23,6 +23,7 @@ export interface Player {
score: number; score: number;
health?: number; health?: number;
max_health?: number; max_health?: number;
is_alive?: boolean;
piece_type?: 'knight' | 'warrior'; piece_type?: 'knight' | 'warrior';
party_id?: string | null; party_id?: string | null;
is_party_leader: boolean; is_party_leader: boolean;
@ -185,6 +186,7 @@ export interface BattleResult {
new_party_size: number; new_party_size: number;
new_party_strength: number; new_party_strength: number;
health_losses?: Record<string, number>; health_losses?: Record<string, number>;
dead_players?: string[];
} }
export interface WizardChallengeBout { export interface WizardChallengeBout {
@ -198,6 +200,13 @@ export interface WizardChallengeBout {
winner: string; winner: string;
} }
export type WizardRewardChoice = 'score' | 'strength' | 'health';
export interface WizardChallengeRequest {
player_id: string;
reward_choice?: WizardRewardChoice;
}
export interface WizardChallengeResult { export interface WizardChallengeResult {
challenger_id: string; challenger_id: string;
challenger_name: string; challenger_name: string;
@ -207,10 +216,14 @@ export interface WizardChallengeResult {
player_bouts_won: number; player_bouts_won: number;
wizard_bouts_won: number; wizard_bouts_won: number;
player_won: boolean; player_won: boolean;
reward_chosen?: WizardRewardChoice | null;
score_change: number; score_change: number;
strength_change?: number;
health_change: number; health_change: number;
new_score: number; new_score: number;
new_strength?: number;
new_health: number; new_health: number;
player_died?: boolean;
wizard_respawn_position: { x: number; y: number }; wizard_respawn_position: { x: number; y: number };
} }

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' | 'wizard'; export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone';
// Hex color parser and manipulator // Hex color parser and manipulator
function parseHex(hex: string): [number, number, number] { function parseHex(hex: string): [number, number, number] {
@ -150,6 +150,32 @@ const WIZARD_SPRITE: string[] = [
'..____________..', // Row 15: Miniature drop shadow '..____________..', // 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 // 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()}`;
@ -188,7 +214,13 @@ export function getPlayerPieceType(player: {
piece_type?: string; piece_type?: string;
is_party_leader?: boolean; is_party_leader?: boolean;
party_id?: string | null; party_id?: string | null;
is_alive?: boolean;
health?: number;
}): PieceType { }): PieceType {
// Fallen / deceased players are represented by gravestones
if (isPlayerDead(player)) {
return 'gravestone';
}
// Party leaders are always Knights commanding the squad // Party leaders are always Knights commanding the squad
if (player.is_party_leader) { if (player.is_party_leader) {
return 'knight'; return 'knight';
@ -259,6 +291,8 @@ export function getSpriteCanvas(
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE; spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
} else if (pieceType === 'wizard') { } else if (pieceType === 'wizard') {
spriteMatrix = WIZARD_SPRITE; spriteMatrix = WIZARD_SPRITE;
} else if (pieceType === 'gravestone') {
spriteMatrix = GRAVESTONE_SPRITE;
} else { } else {
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
} }
@ -311,8 +345,9 @@ export function drawPlayerPiece(
isSelected: boolean, isSelected: boolean,
isCurrentTurn: boolean isCurrentTurn: boolean
): void { ): void {
const pieceType = getPlayerPieceType(player); const dead = isPlayerDead(player);
const isLeader = player.is_party_leader; const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
const isLeader = !dead && player.is_party_leader;
const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader); const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader);
// Scaled miniature size: board game miniature looks best at ~1.35x cellSize // Scaled miniature size: board game miniature looks best at ~1.35x cellSize
@ -323,8 +358,8 @@ export function drawPlayerPiece(
ctx.save(); ctx.save();
// Active turn indicator glowing ring around the pedestal // Active turn indicator glowing ring around the pedestal (only if alive)
if (isCurrentTurn) { if (isCurrentTurn && !dead) {
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.55, spriteSize * 0.28, 0, 0, Math.PI * 2); 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.save();
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.62, spriteSize * 0.32, 0, 0, Math.PI * 2); 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.lineWidth = 2;
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
ctx.shadowColor = '#38bdf8'; ctx.shadowColor = dead ? '#64748b' : '#38bdf8';
ctx.shadowBlur = 6; ctx.shadowBlur = 6;
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
} }
// Draw the crisp pixelated Knight or Warrior figurine // Draw the crisp pixelated figurine or gravestone
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
if (dead) {
ctx.globalAlpha = 0.85;
}
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize); ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
// Crown symbol above party leader // Crown symbol above party leader (only alive)
if (isLeader) { if (isLeader) {
ctx.save(); ctx.save();
ctx.fillStyle = '#fbbf24'; ctx.fillStyle = '#fbbf24';
@ -369,30 +407,31 @@ export function drawPlayerPiece(
// Player Name and Strength Badge (Title Bar) // Player Name and Strength Badge (Title Bar)
ctx.save(); ctx.save();
const isHighlighted = isCurrentTurn || isSelected; const isHighlighted = (isCurrentTurn && !dead) || isSelected;
ctx.globalAlpha = isHighlighted ? 1.0 : 0.5; 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.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; const roleIcon = dead ? '🪦' : isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
const hpBadge = player.health !== undefined ? ` ❤️${player.health}` : ''; const text = dead
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpBadge}]`; ? `🪦 ${player.name} [DEAD]`
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${player.health !== undefined ? ` ❤️${player.health}` : ''}]`;
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;
const labelY = isLeader ? destY - 14 : destY - 8; const labelY = isLeader ? destY - 14 : destY - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.92)'; ctx.fillStyle = dead ? 'rgba(30, 41, 59, 0.92)' : 'rgba(15, 23, 42, 0.92)';
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; ctx.strokeStyle = dead ? '#64748b' : isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill(); ctx.fill();
ctx.stroke(); 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.fillText(text, px, labelY - 4);
ctx.restore(); ctx.restore();
@ -489,13 +528,15 @@ export const PixelAvatar: React.FC<PixelAvatarProps> = ({
className = '', className = '',
title, 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 ( return (
<img <img
src={dataUrl} src={dataUrl}
alt={`${isLeader ? 'Leader ' : ''}${pieceType}`} alt={label}
title={title || `${isLeader ? 'Leader ' : ''}${pieceType} (${color})`} title={title || `${label} (${color})`}
className={`inline-block ${className}`} className={`inline-block ${className}`}
style={{ style={{
width: size, width: size,

View File

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

View File

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