diff --git a/.gitignore b/.gitignore index e1249d8..cae5ec0 100644 --- a/.gitignore +++ b/.gitignore @@ -326,3 +326,4 @@ poetry.toml pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,node,linux +helm.values.yml diff --git a/AGENTS.md b/AGENTS.md index 8add9be..ebb7a98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to - **Winning Party Members**: +1 score point each. - **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate. - **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party. + - **Health Damage**: All defeated party members (including the leader) lose **1 to 3 health points** (randomized). Default health is **10 HP** for all bots upon registration. ### 4. Party Squad Movement - The party leader chooses movement direction. @@ -109,7 +110,20 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to - **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength. - Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1. -### 6. Game Conclusion +### 6. The Wizard (NPC Encounter & Challenge) +- A wandering Wizard NPC roams the map at a random passable coordinate. +- Players (or party leaders) who locate the wizard (adjacent or on same tile, distance <= 1) may voluntarily **choose to challenge** the wizard. +- **Challenge Resolution (3-Bout D20)**: + - Exactly 3 bouts are conducted. + - In each bout, each side rolls a D20 die, multiplied by their strength (the wizard has 3.0 strength; parties use squad total strength). + - Highest bout score wins the bout. Best 2-out-of-3 bouts wins the challenge. +- **Scoring & Penalties**: + - **Victory**: Player/party leader receives **+2 score points**. + - **Defeat**: Player/party leader loses **2 health** (or **2 score points** if they do not have health to lose). +- **Post-Challenge**: + - Following the challenge, the wizard teleports to a new random open coordinate on the map. + +### 7. Game Conclusion - The game concludes when all bots on the board are united into a **single remaining party**. - Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker). @@ -123,11 +137,13 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream | Method | Path | Description | |---|---|---| | `GET` | `/api/health` | Container healthcheck endpoint | -| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int}` | -| `GET` | `/api/players` | List all active players, scores, and positions | -| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, current turn) | -| `POST` | `/api/board/reset` | Clear board, reset parties, and reset players | -| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots, identifies allies/opponents | +| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int}` | +| `GET` | `/api/players` | List all active players, scores, health, and positions | +| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) | +| `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard | +| `GET` | `/api/wizard` | Get current Wizard NPC coordinates and attributes | +| `POST` | `/api/wizard/challenge` | Challenge the Wizard NPC: `{"player_id": str}` (3-bout D20 duel) | +| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots and Wizard NPC | | `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations | | `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) | | `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) | @@ -139,7 +155,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream | `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties | ### Real-Time WebSocket (`/ws`) -- Automatically broadcasts state changes (`init`, `move`, `battle`, `party_formed`, `turn_change`, `game_over`). +- Automatically broadcasts state changes (`init`, `move`, `battle`, `wizard_challenge_resolved`, `party_formed`, `turn_change`, `game_over`). - Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`. --- @@ -182,13 +198,14 @@ The application is containerized into a single unified image via [Dockerfile](Do - **Workflow**: 1. Registers bot via `POST /api/players`. 2. Polls `GET /api/turn` to wait for its turn. - 3. Uses `GET /api/players/{id}/radar` to find nearest target. + 3. Uses `GET /api/players/{id}/radar` to find nearest target and detect Wizard NPC proximity. 4. Avoids looping using internal coordinate history. 5. Computes vector direction, evaluates diagonal obstacles, and moves. 6. Evaluates alliances vs. fights strictly according to strength hierarchy. + 7. Decides whether to challenge the Wizard NPC based on relative strength and health advantage. - **Run Command**: ```bash - python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 --url http://localhost:8000/api + python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 -H 10 --url http://localhost:8000/api ``` ### B. LLM-Assisted Bot: `botagent_ai/` @@ -198,6 +215,7 @@ The application is containerized into a single unified image via [Dockerfile](Do - Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints). - Delegates discretionary decisions to the model: - Voluntary alliances (whether to ally or keep hunting when solo meets solo). + - Voluntary Wizard challenges (whether to challenge the Wizard NPC when adjacent for +2 score vs. -2 health risk). - Navigation direction toward radar targets while balancing obstacle squeeze trade-offs. - Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference. - **Configuration & Environment Variables**: @@ -207,30 +225,33 @@ The application is containerized into a single unified image via [Dockerfile](Do - `BOT_NAME` / `-n`: Bot name. - `BOT_COLOR` / `-c`: Bot hex color. - `BOT_STRENGTH` / `-s`: Starting strength (1-10). + - `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10). - **Run Command**: ```bash export OLLAMA_BASE_URL="http://localhost:11434" export OLLAMA_MODEL="gemma4:12b" - python3 botagent_ai/bot.py -n MyAIBot -s 4 -c "#8b5cf6" + python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6" ``` ### C. Vertex AI (Gemini) Bot: `botagent_gear/` - **Entry File**: `botagent_gear/bot.py` -- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances. +- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances/wizard duels. - **LLM Integration**: - Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API. - Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement. + - Delegates discretionary choices to Gemini: voluntary alliances, choosing whether to challenge the Wizard NPC, and spatial pathing. - Supports multiple authentication methods: - Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`). - Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`). - Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`). + - Configurable via `--health` / `-H` (or `BOT_HEALTH`, default: 10). - **Documentation**: - [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup. - [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions. - [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples. - **Run Command**: ```bash - python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 + python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10 ``` --- diff --git a/GAME_RULES.md b/GAME_RULES.md index b25e6a6..676d9f8 100644 --- a/GAME_RULES.md +++ b/GAME_RULES.md @@ -10,6 +10,7 @@ 2. Battles consist of 3 bouts: each party rolls a D20 die, multiplied by their squad's total strength. The highest score wins the bout. Best 2 out of 3 bouts wins the battle. 3. The winning party leader receives **+2 score points**, and each original winning member receives **+1 score point**. 4. The losing party leader receives **-1 score point** and respawns at a random free position. All other members of the losing party are absorbed into the winning party. + 5. All members of the losing party (including the leader) lose **1 to 3 health points** (randomized). The health setting defaults to **10 HP** for all bots upon registration. 3. **Solo Bot vs Party Encounters**: 1. A solo bot will willingly join a party if the party leader's strength is greater than or equal to its own. @@ -27,6 +28,18 @@ - In a party, the leader loses **0.2 strength**, and follower members each lose **0.1 strength**. 4. Moving along the open outer edge of an obstacle incurs no penalty. Minimum bot strength cannot fall below 0.1. +6. **The Wizard (NPC Encounter & Challenge)**: + 1. The Wizard is a special Non-Player Character (NPC) roaming the map at a random passable location. + 2. Players (or party leaders) who find the wizard (adjacent or on the same coordinate, distance <= 1) may voluntarily **choose to challenge** the wizard. + 3. **Challenge Resolution (3-Bout D20)**: + - Challenges consist of 3 bouts: each side rolls a D20 die, multiplied by their strength (the wizard has a strength of 3.0; a party uses its squad total strength). + - Highest bout score wins the bout. Best 2 out of 3 bouts wins the challenge. + 4. **Scoring & Penalties**: + - **Victory**: The player (or party leader) receives **+2 score points**. + - **Defeat**: The player (or party leader) loses **2 health** (or **2 score points** if they do not have health to lose). + 5. **Wizard Relocation**: + - Following any challenge, the wizard teleports to a new random open coordinate on the map. + # Game Conclusion The game ends when all bots are united into a single remaining party. diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index e43b44b..8489715 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -24,6 +24,9 @@ from app.models import ( Player, PlayerCreate, TurnInfo, + WizardChallengeRequest, + WizardChallengeResult, + WizardNPC, ) router = APIRouter() @@ -384,6 +387,49 @@ async def fight_battle(battle_req: BattleRequest): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +# ========================================== +# Wizard NPC Endpoints +# ========================================== + +@router.get( + "/wizard", + response_model=WizardNPC, + summary="Get current position and details of the wandering Wizard NPC", + tags=["Wizard NPC"], +) +async def get_wizard(): + return game_engine.wizard + + +@router.post( + "/wizard/challenge", + response_model=WizardChallengeResult, + summary="Challenge the Wizard NPC to a 3-bout D20 duel (awards +2 score on win, -2 health/points on loss)", + tags=["Wizard NPC"], +) +async def challenge_wizard(challenge_req: WizardChallengeRequest): + try: + result = await game_engine.challenge_wizard(challenge_req.player_id) + board_state = await game_engine.get_board_state() + + await manager.broadcast({ + "event": "wizard_challenge_resolved", + "challenge_result": result.model_dump(), + "wizard": board_state.wizard.model_dump() if board_state.wizard else None, + "players": [p.model_dump() for p in board_state.players], + "parties": [p.model_dump() for p in board_state.parties], + "turn": board_state.turn.model_dump(), + }) + + return result + except KeyError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + # ========================================== # Movement & Turn Endpoints # ========================================== @@ -526,6 +572,15 @@ async def step_bot_ai(player_id: str): "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) + elif result.wizard_challenge_result: + await manager.broadcast({ + "event": "wizard_challenge_resolved", + "challenge_result": result.wizard_challenge_result.model_dump(), + "wizard": board_state.wizard.model_dump() if board_state.wizard else None, + "players": [p.model_dump() for p in board_state.players], + "parties": [p.model_dump() for p in board_state.parties], + "turn": board_state.turn.model_dump(), + }) elif result.move_result: await manager.broadcast({ "event": "player_moved", diff --git a/backend/app/game.py b/backend/app/game.py index 6ddff59..eb4b61b 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -24,6 +24,10 @@ from app.models import ( PlayerCreate, RadarTarget, TurnInfo, + WizardChallengeBout, + WizardChallengeResult, + WizardNPC, + WizardRadarTarget, ) STANDARD_DIRECTIONS = [ @@ -62,6 +66,20 @@ class GameEngine: # Procedural Impassable Obstacles (Mountains & Valleys) # Guaranteed: <= 50% impassable, and all passable tiles form a single connected component self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain() + self.wizard: WizardNPC = self._spawn_wizard() + + def _spawn_wizard(self) -> WizardNPC: + """Spawn the wandering Wizard NPC at a random free passable coordinate.""" + wx, wy = self._find_random_free_position() + return WizardNPC( + id="wizard_npc", + name="Grand Wizard", + x=wx, + y=wy, + strength=3.0, + color="#A855F7", + dialogue="Greetings, traveler! Do you dare challenge my arcane arts?", + ) def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]: """Generates procedural mountain ranges and valley chasms subject to: @@ -328,12 +346,18 @@ class GameEngine: else: piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior" + health = getattr(player_in, "health", 10) + if health is None: + health = 10 + player = Player( id=player_id, name=player_in.name, color=player_in.color, strength=player_in.strength, score=0, + health=health, + max_health=health, x=spawn_x, y=spawn_y, piece_type=piece_type, @@ -427,6 +451,7 @@ class GameEngine: self.game_started = False # Regenerate fresh procedural mountain ranges and valley trenches on reset self.obstacles = self._generate_terrain() + self.wizard = self._spawn_wizard() async def get_board_state(self) -> BoardState: async with self._lock: @@ -439,6 +464,7 @@ class GameEngine: players=players_list, parties=parties_list, obstacles=obstacles_list, + wizard=self.wizard, turn=self._get_turn_info(), game_started=self.game_started, conclusion=self._check_game_concluded(), @@ -567,6 +593,17 @@ class GameEngine: else: rec_act = "hunt_party" + wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) + wiz_radar = WizardRadarTarget( + id=self.wizard.id, + name=self.wizard.name, + x=self.wizard.x, + y=self.wizard.y, + distance=wiz_dist, + strength=self.wizard.strength, + can_challenge=(wiz_dist <= 1), + ) + return BotRadarResponse( player_id=player.id, current_x=player.x, @@ -574,6 +611,7 @@ class GameEngine: bot_goal=bot_goal, targets=targets, nearest_target=nearest, + wizard=wiz_radar, recommended_direction=rec_dir, recommended_action=rec_act, ) @@ -1130,6 +1168,19 @@ class GameEngine: killed_leader.is_party_leader = False respawn_pos = {"x": respawn_x, "y": respawn_y} + # Defeated party members (including leader) all lose 1 to 3 health points (randomized) + health_losses: Dict[str, int] = {} + defeated_all_ids = list(defeated_party.member_ids) + if defeated_party.leader_id and defeated_party.leader_id not in defeated_all_ids: + defeated_all_ids.append(defeated_party.leader_id) + + for mid in defeated_all_ids: + p = self.players.get(mid) + if p: + hp_loss = random.randint(1, 3) + p.health = max(0, p.health - hp_loss) + health_losses[mid] = hp_loss + for mid in list(defeated_party.member_ids): if mid != defeated_party.leader_id: m = self.players.get(mid) @@ -1166,6 +1217,7 @@ class GameEngine: absorbed_members=absorbed_members, new_party_size=len(winner_party.member_ids), new_party_strength=winner_party.total_strength, + health_losses=health_losses, ) async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: @@ -1221,6 +1273,133 @@ class GameEngine: async def battle(self, challenger_id: str, defender_id: str) -> BattleResult: return await self.fight_battle(challenger_id, defender_id) + def _resolve_wizard_challenge_internal(self, player: Player) -> WizardChallengeResult: + """Resolve a 3-bout D20 challenge between player/party and the Wizard NPC.""" + effective_strength = player.strength + party_id = player.party_id + if party_id and party_id in self.parties: + party = self.parties[party_id] + self._update_party_strength(party) + effective_strength = party.total_strength + + bouts: List[WizardChallengeBout] = [] + player_bouts_won = 0 + wizard_bouts_won = 0 + + for bout_idx in range(1, 4): + r_player = random.randint(1, 20) + r_wiz = random.randint(1, 20) + score_player = round(effective_strength * r_player, 1) + score_wiz = round(self.wizard.strength * r_wiz, 1) + + if score_player > score_wiz: + winner = "player" + player_bouts_won += 1 + elif score_wiz > score_player: + winner = "wizard" + wizard_bouts_won += 1 + else: + if effective_strength >= self.wizard.strength: + winner = "player" + player_bouts_won += 1 + else: + winner = "wizard" + wizard_bouts_won += 1 + + bouts.append( + WizardChallengeBout( + bout_number=bout_idx, + player_roll=r_player, + player_strength=effective_strength, + player_score=score_player, + wizard_roll=r_wiz, + wizard_strength=self.wizard.strength, + wizard_score=score_wiz, + winner=winner, + ) + ) + + player_won = player_bouts_won >= 2 + score_change = 0 + health_change = 0 + + if player_won: + # Player wins challenge: +2 score + player.score += 2 + score_change = 2 + health_change = 0 + else: + # Player loses challenge: lose 2 health (or points if they do not have health to lose) + if player.health >= 2: + player.health -= 2 + health_change = -2 + score_change = 0 + elif player.health > 0: + pts_lost = 2 - player.health + health_change = -player.health + player.health = 0 + player.score -= pts_lost + score_change = -pts_lost + else: + health_change = 0 + player.score -= 2 + score_change = -2 + + # Wizard teleports to a new random open coordinate on the map + new_wx, new_wy = self._find_random_free_position() + self.wizard.x = new_wx + self.wizard.y = new_wy + respawn_pos = {"x": new_wx, "y": new_wy} + + self._advance_turn() + + return WizardChallengeResult( + challenger_id=player.id, + challenger_name=player.name, + party_id=party_id, + bouts=bouts, + player_bouts_won=player_bouts_won, + wizard_bouts_won=wizard_bouts_won, + player_won=player_won, + score_change=score_change, + health_change=health_change, + new_score=player.score, + new_health=player.health, + wizard_respawn_position=respawn_pos, + ) + + async def challenge_wizard(self, player_id: str) -> WizardChallengeResult: + async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") + + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + current_turn_player = self._get_current_player() + if not current_turn_player or current_turn_player.id != player_id: + curr_name = current_turn_player.name if current_turn_player else "Nobody" + curr_id = current_turn_player.id if current_turn_player else "None" + raise PermissionError( + f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." + ) + + if player.party_id and not player.is_party_leader: + party = self.parties.get(player.party_id) + leader_name = party.leader_name if party else "Leader" + raise PermissionError( + f"Party member '{player.name}' cannot challenge the wizard individually. Only party leader '{leader_name}' can initiate challenges." + ) + + dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) + if dist > 1: + raise ValueError( + f"Player '{player.name}' is not adjacent to the Grand Wizard (distance {dist}). Must be within 1 distance." + ) + + return self._resolve_wizard_challenge_internal(player) + async def get_game_conclusion(self) -> GameConclusion: async with self._lock: return self._check_game_concluded() @@ -1517,10 +1696,34 @@ class GameEngine: move_result=None, formed_party=formed_party, battle_result=None, + wizard_challenge_result=None, game_concluded=conclusion if conclusion.concluded else None, turn=self._get_turn_info(), ) + # 1b. Check if adjacent to the Grand Wizard NPC and choose to challenge + wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) + if wiz_dist <= 1 and (not player.party_id or player.is_party_leader): + effective_str = player.strength + if player.party_id and player.party_id in self.parties: + effective_str = self.parties[player.party_id].total_strength + if effective_str >= self.wizard.strength or player.health >= 4: + challenge_res = self._resolve_wizard_challenge_internal(player) + conclusion = self._check_game_concluded() + return AiStepResponse( + action_taken="challenged_wizard", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=None, + wizard_challenge_result=challenge_res, + game_concluded=conclusion if conclusion.concluded else None, + turn=self._get_turn_info(), + ) + # 2. Navigate towards target using BFS pathfinder & memory occupied = self._get_occupied_coordinates() moves_map: Dict[str, MoveCheckResult] = {} diff --git a/backend/app/models.py b/backend/app/models.py index b03c720..7d282b0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,6 +119,7 @@ class PlayerCreate(BaseModel): name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name") strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)") + health: Optional[int] = Field(default=10, ge=1, description="Starting health points (default is 10)") piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'") @field_validator("name") @@ -148,6 +149,8 @@ class Player(BaseModel): y: int strength: float = 1.0 score: int = 0 + health: int = 10 + max_health: int = 10 piece_type: Optional[str] = "knight" party_id: Optional[str] = None is_party_leader: bool = False @@ -243,6 +246,10 @@ class BattleResult(BaseModel): ) new_party_size: int new_party_strength: float + health_losses: Dict[str, int] = Field( + default_factory=dict, + description="Health points lost (1-3 HP) by defeated party members: {player_id: hp_lost}", + ) class BattleRequest(BaseModel): @@ -250,6 +257,61 @@ class BattleRequest(BaseModel): defender_id: str +# ========================================== +# Wizard NPC & Challenge Models +# ========================================== + +class WizardNPC(BaseModel): + id: str = "wizard_npc" + name: str = "Grand Wizard" + x: int + y: int + strength: float = 3.0 + color: str = "#A855F7" + dialogue: Optional[str] = "Greetings, traveler! Do you dare challenge my arcane arts?" + + +class WizardChallengeBout(BaseModel): + bout_number: int + player_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for player") + player_strength: float + player_score: float = Field(..., description="player_strength * player_roll") + wizard_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for wizard") + wizard_strength: float + wizard_score: float = Field(..., description="wizard_strength * wizard_roll") + winner: str = Field(..., description="'player' or 'wizard'") + + +class WizardChallengeResult(BaseModel): + challenger_id: str + challenger_name: str + wizard_name: str = "Grand Wizard" + party_id: Optional[str] = None + bouts: List[WizardChallengeBout] + player_bouts_won: int + wizard_bouts_won: int + player_won: bool + score_change: int = 0 + health_change: int = 0 + new_score: int + new_health: int + wizard_respawn_position: Dict[str, int] + + +class WizardChallengeRequest(BaseModel): + player_id: str + + +class WizardRadarTarget(BaseModel): + id: str = "wizard_npc" + name: str = "Grand Wizard" + x: int + y: int + distance: int + strength: float = 3.0 + can_challenge: bool = False + + # ========================================== # Memory & Radar Models # ========================================== @@ -287,8 +349,9 @@ class BotRadarResponse(BaseModel): bot_goal: str # "form_party" or "find_and_defeat_all_parties" targets: List[RadarTarget] nearest_target: Optional[RadarTarget] = None + wizard: Optional[WizardRadarTarget] = None recommended_direction: Optional[str] = None - recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited" + recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited", "challenge_wizard" class BoardConfig(BaseModel): @@ -329,6 +392,7 @@ class BoardState(BaseModel): players: List[Player] parties: List[Party] = [] obstacles: List[Obstacle] = Field(default_factory=list) + wizard: Optional[WizardNPC] = None turn: TurnInfo game_started: bool = False conclusion: Optional[GameConclusion] = None @@ -351,7 +415,7 @@ class MoveResponse(BaseModel): class AiStepResponse(BaseModel): - action_taken: str # "formed_party", "battled", "moved", "passed" + action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed" player_id: str player_name: str bot_goal: str # "form_party" or "find_and_defeat_all_parties" @@ -359,6 +423,7 @@ class AiStepResponse(BaseModel): move_result: Optional[MoveResponse] = None formed_party: Optional[Party] = None battle_result: Optional[BattleResult] = None + wizard_challenge_result: Optional[WizardChallengeResult] = None game_concluded: Optional[GameConclusion] = None turn: TurnInfo diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1c5bc86..0e7a765 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -196,6 +196,34 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner(): assert battle_data["bouts"][0]["party1_strength"] == 10 assert battle_data["bouts"][0]["party2_strength"] == 2 + # Verify losing party members (leader BetaLead + follower BetaWing) each lost 1-3 health points + assert "health_losses" in battle_data + assert p3["id"] in battle_data["health_losses"] + assert p4["id"] in battle_data["health_losses"] + assert 1 <= battle_data["health_losses"][p3["id"]] <= 3 + assert 1 <= battle_data["health_losses"][p4["id"]] <= 3 + + # Check updated health of defeated members + b3_after = client.get(f"/api/players/{p3['id']}").json() + b4_after = client.get(f"/api/players/{p4['id']}").json() + assert b3_after["health"] == 10 - battle_data["health_losses"][p3["id"]] + assert b4_after["health"] == 10 - battle_data["health_losses"][p4["id"]] + + +def test_player_health_default_and_custom_registration(): + client = TestClient(app) + client.post("/api/reset") + + # Default health should be 10 + p_default = client.post("/api/players", json={"name": "DefaultHPBot", "color": "#123456", "strength": 3}).json() + assert p_default["health"] == 10 + assert p_default["max_health"] == 10 + + # Custom health (e.g. 18) + p_custom = client.post("/api/players", json={"name": "TankHPBot", "color": "#654321", "strength": 4, "health": 18}).json() + assert p_custom["health"] == 18 + assert p_custom["max_health"] == 18 + def test_game_conclusion_and_scoreboard(): client = TestClient(app) @@ -445,3 +473,124 @@ def test_party_movement_forms_line(): # Distances are all <= 1 assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1 assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1 + + +def test_wizard_npc_existence_and_radar_detection(): + client = TestClient(app) + client.post("/api/reset") + + # 1. Check GET /api/wizard + wiz_res = client.get("/api/wizard") + assert wiz_res.status_code == 200 + wiz_data = wiz_res.json() + assert wiz_data["id"] == "wizard_npc" + assert wiz_data["name"] == "Grand Wizard" + assert wiz_data["strength"] == 3.0 + assert 0 <= wiz_data["x"] <= 64 + assert 0 <= wiz_data["y"] <= 64 + + # 2. Check BoardState includes wizard + board_res = client.get("/api/board").json() + assert board_res["wizard"] is not None + assert board_res["wizard"]["id"] == "wizard_npc" + + # 3. Register a bot and check radar includes wizard target + p = client.post("/api/players", json={"name": "RadarExplorer", "color": "#123456", "strength": 4}).json() + radar_res = client.get(f"/api/players/{p['id']}/radar").json() + assert "wizard" in radar_res + assert radar_res["wizard"] is not None + assert radar_res["wizard"]["id"] == "wizard_npc" + assert radar_res["wizard"]["strength"] == 3.0 + + +def test_wizard_challenge_mechanics_victory_and_defeat(): + client = TestClient(app) + client.post("/api/reset") + + p1 = client.post("/api/players", json={"name": "ChallengerBot", "color": "#38bdf8", "strength": 50}).json() + p2 = client.post("/api/players", json={"name": "WeakChallenger", "color": "#f43f5e", "strength": 1}).json() + + # Move p1 adjacent to wizard + wiz = client.get("/api/wizard").json() + wx, wy = wiz["x"], wiz["y"] + + async def setup_wizard_adjacent(): + # Place p1 adjacent to wizard (e.g. wx+1, wy if within bounds) + adj_x = wx + 1 if wx < 64 else wx - 1 + adj_y = wy + bot1 = await game_engine.get_player(p1["id"]) + bot1.x = adj_x + bot1.y = adj_y + bot1.health = 10 + bot1.score = 0 + asyncio.run(setup_wizard_adjacent()) + + client.post("/api/game/start") + + # Set turn to ChallengerBot + game_engine.turn_order = [p1["id"], p2["id"]] + game_engine.current_turn_index = 0 + + # 1. Challenge the wizard with super high strength (strength 50 guaranteed win) + chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"]}) + assert chal_res.status_code == 200 + res_data = chal_res.json() + assert len(res_data["bouts"]) == 3 + assert res_data["player_won"] is True + assert res_data["score_change"] == 2 + assert res_data["new_score"] == 2 + assert res_data["new_health"] == 10 + + # Wizard teleports to a new location + wiz_after = client.get("/api/wizard").json() + assert (wiz_after["x"], wiz_after["y"]) == (res_data["wizard_respawn_position"]["x"], res_data["wizard_respawn_position"]["y"]) + + # 2. Test defeat case: bot loses 2 health + # Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss) + async def setup_weak_loss(): + new_wx, new_wy = wiz_after["x"], wiz_after["y"] + adj_x = new_wx + 1 if new_wx < 64 else new_wx - 1 + adj_y = new_wy + bot2 = await game_engine.get_player(p2["id"]) + bot2.x = adj_x + bot2.y = adj_y + bot2.strength = 0.001 + bot2.health = 10 + bot2.score = 5 + asyncio.run(setup_weak_loss()) + + game_engine.current_turn_index = 1 # p2's turn + loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) + assert loss_res.status_code == 200 + loss_data = loss_res.json() + assert loss_data["player_won"] is False + assert loss_data["health_change"] == -2 + assert loss_data["new_health"] == 8 + assert loss_data["new_score"] == 5 + + # 3. Test defeat when health is 0: bot loses 2 points (score) + async def setup_zero_health(): + cur_wiz = client.get("/api/wizard").json() + adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1 + adj_y = cur_wiz["y"] + bot2 = await game_engine.get_player(p2["id"]) + bot2.x = adj_x + bot2.y = adj_y + bot2.strength = 0.001 + bot2.health = 0 # no health to lose + bot2.score = 5 + asyncio.run(setup_zero_health()) + + # Set turn back to p2 + game_engine.current_turn_index = 0 + game_engine.turn_order = [p2["id"]] + + score_loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) + assert score_loss_res.status_code == 200 + score_loss_data = score_loss_res.json() + assert score_loss_data["player_won"] is False + assert score_loss_data["health_change"] == 0 + assert score_loss_data["score_change"] == -2 + assert score_loss_data["new_health"] == 0 + assert score_loss_data["new_score"] == 3 + diff --git a/botagent/README.md b/botagent/README.md index fe8d829..38c87ba 100644 --- a/botagent/README.md +++ b/botagent/README.md @@ -21,4 +21,5 @@ python3 bot_agent.py --name BloodAxe --color "#f43f5e" -s 3 --piece-type warrior | `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot | | `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar | | `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) | +| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) | | | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` | \ No newline at end of file diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py index 192569b..269a283 100644 --- a/botagent/bot_agent.py +++ b/botagent/bot_agent.py @@ -18,6 +18,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api" DEFAULT_BOT_NAME = "ExternalCyberBot" DEFAULT_BOT_COLOR = "#10b981" DEFAULT_BOT_STRENGTH = 4 +DEFAULT_BOT_HEALTH = 10 def normalize_url(url: str) -> str: @@ -34,12 +35,14 @@ class SmartBotAgent: name: str = DEFAULT_BOT_NAME, color: str = DEFAULT_BOT_COLOR, strength: int = DEFAULT_BOT_STRENGTH, + health: int = DEFAULT_BOT_HEALTH, server_url: str = DEFAULT_SERVER_URL, piece_type: Optional[str] = None, ): self.name = name self.color = color self.strength = strength + self.health = health self.server_url = server_url self.piece_type = piece_type self.base_url = normalize_url(server_url) @@ -54,12 +57,17 @@ class SmartBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return except Exception: pass - payload = {"name": self.name, "color": self.color, "strength": self.strength} + payload = { + "name": self.name, + "color": self.color, + "strength": self.strength, + "health": self.health, + } if self.piece_type: payload["piece_type"] = self.piece_type @@ -72,13 +80,13 @@ class SmartBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return res.raise_for_status() data = res.json() self.bot_id = data["id"] - print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") + print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})") def refresh_status(self): """Update bot state (party membership, leader status, score).""" @@ -102,6 +110,7 @@ class SmartBotAgent: radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() targets = radar_res.get("targets", []) nearest = radar_res.get("nearest_target") + wizard = radar_res.get("wizard") # 2. Check for immediate adjacent interaction (distance <= 1) adjacent_target = None @@ -114,7 +123,17 @@ class SmartBotAgent: self._handle_adjacent_encounter(adjacent_target) return - # 3. If no immediate adjacent enemy/recruit, move towards target + # 3. Check for Wizard NPC encounter (voluntary challenge) + if wizard and wizard.get("can_challenge"): + my_health = my_info.get("health", 10) + wiz_str = wizard.get("strength", 3.0) + # Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4) + if self.strength >= wiz_str or my_health >= 4: + print(f"🧙 [WIZARD NEARBY] Adjacent to {wizard.get('name', 'Grand Wizard')} (Str: {wiz_str})! HP: {my_health}, Bot Str: {self.strength}. Choosing to challenge!") + self._challenge_wizard() + return + + # 4. If no immediate adjacent enemy/recruit, move towards target self._navigate_towards_goal(radar_res) def _handle_adjacent_encounter(self, target: Dict[str, Any]): @@ -213,6 +232,31 @@ class SmartBotAgent: else: print(f"Battle failed ({res.status_code}): {res.text}") + def _challenge_wizard(self): + """Voluntarily challenge the Wizard NPC to a 3-bout D20 duel.""" + print(f"🧙 [WIZARD CHALLENGE] Challenging Grand Wizard to a 3-bout D20 duel...") + try: + res = requests.post( + f"{self.base_url}/wizard/challenge", + json={"player_id": self.bot_id}, + ) + if res.status_code == 200: + result = res.json() + outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") + for b in result.get("bouts", []): + print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") + print(f" Score Change: {result.get('score_change')} | HP Change: {result.get('health_change')} | New HP: {result.get('new_health')} | New Score: {result.get('new_score')}") + pos = result.get("wizard_respawn_position") + if pos: + print(f" 🔮 Wizard teleported to ({pos.get('x')}, {pos.get('y')})") + else: + print(f"Wizard challenge failed ({res.status_code}): {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + except Exception as e: + print(f"Error challenging wizard: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]: """Pick the available direction that minimizes Chebyshev distance to (target_x, target_y), strictly avoiding obstacles.""" valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")} @@ -315,6 +359,7 @@ def main(): env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) + env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH))) env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( @@ -346,6 +391,13 @@ def main(): default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)", ) + parser.add_argument( + "-H", "--health", + dest="health", + type=int, + default=env_health, + help="Health points attribute (default 10) for the bot (env: BOT_HEALTH)", + ) parser.add_argument( "--piece-type", dest="piece_type", @@ -360,6 +412,7 @@ def main(): name=args.name, color=args.color, strength=args.strength, + health=args.health, server_url=args.server_url, piece_type=args.piece_type, ) diff --git a/botagent_ai/INSTALL.md b/botagent_ai/INSTALL.md index 862e4b0..0340a56 100644 --- a/botagent_ai/INSTALL.md +++ b/botagent_ai/INSTALL.md @@ -31,12 +31,13 @@ Configure it via environment variables or CLI flags: - `BOT_NAME` / `-n`: Bot display name - `BOT_COLOR` / `-c`: Hex color for the bot avatar - `BOT_STRENGTH` / `-s`: Starting strength (1-10) +- `BOT_HEALTH` / `-H` / `--health`: Starting health points (default: 10) Example: ```bash export OLLAMA_BASE_URL="http://192.168.1.220:11434" export OLLAMA_MODEL="gemma4:12b" -python bot.py -n MyAIBot -s 5 +python bot.py -n MyAIBot -s 5 -H 10 ``` ## Running the Bot diff --git a/botagent_ai/bot.py b/botagent_ai/bot.py index 9e9d6a0..d6dec73 100644 --- a/botagent_ai/bot.py +++ b/botagent_ai/bot.py @@ -25,6 +25,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api" DEFAULT_BOT_NAME = "OllamaBot" DEFAULT_BOT_COLOR = "#8b5cf6" DEFAULT_BOT_STRENGTH = 4 +DEFAULT_BOT_HEALTH = 10 OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b") @@ -35,8 +36,11 @@ Rules you must respect when choosing among the OPTIONS given to you: score if tied) leads. Larger parties have an advantage in battle. - A solo bot always joins a party if the party leader's strength >= its own (no choice). - A solo bot always refuses and fights if the party leader is weaker (no choice). -- Two opposing parties that meet must always battle (no choice). +- Two opposing parties that meet must always battle (no choice). Defeated party members (including leader) + lose 1-3 health points (randomized). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). +- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge + is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health). - The game ends when all bots are united into a single party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. @@ -95,6 +99,7 @@ class AIBotAgent: name: str = DEFAULT_BOT_NAME, color: str = DEFAULT_BOT_COLOR, strength: int = DEFAULT_BOT_STRENGTH, + health: int = DEFAULT_BOT_HEALTH, server_url: str = DEFAULT_SERVER_URL, ollama_url: str = OLLAMA_BASE_URL, ollama_model: str = OLLAMA_MODEL, @@ -103,6 +108,7 @@ class AIBotAgent: self.name = name self.color = color self.strength = strength + self.health = health self.piece_type = piece_type self.base_url = normalize_url(server_url) self.llm = OllamaClient(ollama_url, ollama_model) @@ -120,12 +126,17 @@ class AIBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return except Exception: pass - payload = {"name": self.name, "color": self.color, "strength": self.strength} + payload = { + "name": self.name, + "color": self.color, + "strength": self.strength, + "health": self.health, + } if self.piece_type: payload["piece_type"] = self.piece_type @@ -138,13 +149,13 @@ class AIBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return res.raise_for_status() data = res.json() self.bot_id = data["id"] - print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") + print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})") def refresh_status(self): """Update bot state (party membership, leader status, score).""" @@ -168,6 +179,7 @@ class AIBotAgent: radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() targets = radar_res.get("targets", []) + wizard = radar_res.get("wizard") adjacent_target = None for t in targets: @@ -177,9 +189,58 @@ class AIBotAgent: if adjacent_target: self._handle_adjacent_encounter(adjacent_target, my_info) + elif wizard and wizard.get("can_challenge"): + if self._decide_wizard_challenge(wizard, my_info): + self._challenge_wizard(wizard) + else: + self._navigate_towards_goal(radar_res, my_info) else: self._navigate_towards_goal(radar_res, my_info) + def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> bool: + """Ask the LLM whether to challenge the adjacent Wizard NPC.""" + prompt = f"""{GAME_RULES_SUMMARY} +You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) +at position ({my_info['x']}, {my_info['y']}). +You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). +Challenging the wizard initiates a 3-bout D20 duel (strength * roll). +- If you win: +2 score points! +- If you lose: -2 health points (or -2 score if no health)! +Do you want to challenge the wizard to a duel? + +Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}} +""" + decision = self.llm.ask_json(prompt) or {} + challenge = decision.get("challenge_wizard", False) + reasoning = decision.get("reasoning", "") + print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge}. {reasoning}") + return bool(challenge) + + def _challenge_wizard(self, wizard: Dict[str, Any]): + """Execute the challenge against the Wizard NPC.""" + print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...") + try: + res = requests.post( + f"{self.base_url}/wizard/challenge", + json={"player_id": self.bot_id}, + ) + if res.status_code == 200: + result = res.json() + outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") + for b in result.get("bouts", []): + print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") + print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}") + pos = result.get("wizard_respawn_position") + if pos: + print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") + else: + print(f"Wizard challenge failed: {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + except Exception as e: + print(f"Error challenging wizard: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]): """Resolve the encounter; the LLM only gets a say when the rules allow a choice.""" target_name = target["name"] @@ -373,6 +434,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso continue player_at.setdefault((p["x"], p["y"]), []).append(p) + wizard = board.get("wizard") + wizard_pos = (wizard["x"], wizard["y"]) if wizard else None + rows = [] for y in range(center_y - radius, center_y + radius + 1): row_chars = [] @@ -381,6 +445,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso row_chars.append("@") elif x < 0 or y < 0 or x >= max_x or y >= max_y: row_chars.append("#") + elif wizard_pos and (x, y) == wizard_pos: + row_chars.append("W") elif (x, y) in obstacle_at: row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M")) elif (x, y) in player_at: @@ -428,7 +494,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso map_section = f""" Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot, -M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. +W = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. {chr(10).join(local_map)} """ @@ -497,6 +563,7 @@ def main(): env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) + env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH))) env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( @@ -511,6 +578,8 @@ def main(): help="Hex color code for the bot avatar (env: BOT_COLOR)") parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") + parser.add_argument("-H", "--health", dest="health", type=int, default=env_health, + help="Starting health points (default 10) (env: BOT_HEALTH)") parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type, help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)") parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL, @@ -526,6 +595,7 @@ def main(): name=args.name, color=args.color, strength=args.strength, + health=args.health, server_url=args.server_url, ollama_url=args.ollama_url, ollama_model=args.ollama_model, diff --git a/botagent_gear/README.md b/botagent_gear/README.md index 23bfe33..e8824ee 100644 --- a/botagent_gear/README.md +++ b/botagent_gear/README.md @@ -87,6 +87,7 @@ python3 bot.py --url "http://192.168.1.100:8000/api" --name "RemoteGear" | `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid | | `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar | | `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) | +| `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) | | `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID | | `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI | | `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name | diff --git a/botagent_gear/bot.py b/botagent_gear/bot.py index bf55be7..3b2745a 100644 --- a/botagent_gear/bot.py +++ b/botagent_gear/bot.py @@ -36,6 +36,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000/api" DEFAULT_BOT_NAME = "GeminiGearBot" DEFAULT_BOT_COLOR = "#4285f4" DEFAULT_BOT_STRENGTH = 5 +DEFAULT_BOT_HEALTH = 10 DEFAULT_MODEL = "gemini-3.8-flash" DEFAULT_LOCATION = "us-central1" @@ -45,8 +46,11 @@ Rules you must respect when choosing among the OPTIONS given to you: score if tied) leads. Larger parties have an advantage in battle. - A solo bot always joins a party if the party leader's strength >= its own (no choice). - A solo bot always refuses and fights if the party leader is weaker (no choice). -- Two opposing parties that meet must always battle (no choice). +- Two opposing parties that meet must always battle (no choice). Defeated party members (including leader) + lose 1-3 health points (randomized). - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). +- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge + is a 3-bout D20 duel (strength * D20). Winning gives +2 score; losing costs 2 health (or score if no health). - The game ends when all bots are united into a single party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. @@ -274,6 +278,7 @@ class VertexAIBotAgent: name: str = DEFAULT_BOT_NAME, color: str = DEFAULT_BOT_COLOR, strength: int = DEFAULT_BOT_STRENGTH, + health: int = DEFAULT_BOT_HEALTH, server_url: str = DEFAULT_SERVER_URL, project_id: Optional[str] = None, location: str = DEFAULT_LOCATION, @@ -284,6 +289,7 @@ class VertexAIBotAgent: self.name = name self.color = color self.strength = strength + self.health = health self.piece_type = piece_type self.base_url = normalize_url(server_url) self.llm = VertexGeminiClient( @@ -306,12 +312,17 @@ class VertexAIBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return except Exception: pass - payload = {"name": self.name, "color": self.color, "strength": self.strength} + payload = { + "name": self.name, + "color": self.color, + "strength": self.strength, + "health": self.health, + } if self.piece_type: payload["piece_type"] = self.piece_type @@ -324,13 +335,13 @@ class VertexAIBotAgent: for p in players: if p.get("name") == self.name: self.bot_id = p["id"] - print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})") + print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return res.raise_for_status() data = res.json() self.bot_id = data["id"] - print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") + print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})") def refresh_status(self): """Update bot state (party membership, leader status, score).""" @@ -354,6 +365,7 @@ class VertexAIBotAgent: radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() targets = radar_res.get("targets", []) + wizard = radar_res.get("wizard") adjacent_target = None for t in targets: @@ -363,9 +375,58 @@ class VertexAIBotAgent: if adjacent_target: self._handle_adjacent_encounter(adjacent_target, my_info) + elif wizard and wizard.get("can_challenge"): + if self._decide_wizard_challenge(wizard, my_info): + self._challenge_wizard(wizard) + else: + self._navigate_towards_goal(radar_res, my_info) else: self._navigate_towards_goal(radar_res, my_info) + def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> bool: + """Ask Gemini whether to challenge the adjacent Wizard NPC.""" + prompt = f"""{GAME_RULES_SUMMARY} +You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) +at position ({my_info['x']}, {my_info['y']}). +You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). +Challenging the wizard initiates a 3-bout D20 duel (strength * roll). +- If you win: +2 score points! +- If you lose: -2 health points (or -2 score if no health)! +Do you want to challenge the wizard to a duel? + +Respond ONLY with JSON: {{"challenge_wizard": true|false, "reasoning": "short reason"}} +""" + decision = self.llm.ask_json(prompt) or {} + challenge = decision.get("challenge_wizard", False) + reasoning = decision.get("reasoning", "") + print(f"🧙 [GEMINI DECISION] Challenge Wizard: {challenge}. {reasoning}") + return bool(challenge) + + def _challenge_wizard(self, wizard: Dict[str, Any]): + """Execute the challenge against the Wizard NPC.""" + print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel...") + try: + res = requests.post( + f"{self.base_url}/wizard/challenge", + json={"player_id": self.bot_id}, + ) + if res.status_code == 200: + result = res.json() + outcome = "VICTORY (+2 pts)" if result.get("player_won") else "DEFEAT (-2 HP/pts)" + print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") + for b in result.get("bouts", []): + print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") + print(f" Score: {result.get('new_score')} | HP: {result.get('new_health')}") + pos = result.get("wizard_respawn_position") + if pos: + print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") + else: + print(f"Wizard challenge failed: {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + except Exception as e: + print(f"Error challenging wizard: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]): """Resolve the encounter; Gemini only gets a say when the rules allow a choice.""" target_name = target["name"] @@ -559,6 +620,9 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso continue player_at.setdefault((p["x"], p["y"]), []).append(p) + wizard = board.get("wizard") + wizard_pos = (wizard["x"], wizard["y"]) if wizard else None + rows = [] for y in range(center_y - radius, center_y + radius + 1): row_chars = [] @@ -567,6 +631,8 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso row_chars.append("@") elif x < 0 or y < 0 or x >= max_x or y >= max_y: row_chars.append("#") + elif wizard_pos and (x, y) == wizard_pos: + row_chars.append("W") elif (x, y) in obstacle_at: row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M")) elif (x, y) in player_at: @@ -614,7 +680,7 @@ Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reaso map_section = f""" Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot, -M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. +W = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. {chr(10).join(local_map)} """ @@ -692,6 +758,7 @@ def main(): env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) + env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH))) env_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT") env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION env_model = os.environ.get("VERTEX_MODEL", DEFAULT_MODEL) @@ -710,6 +777,8 @@ def main(): help="Hex color code for the bot avatar (env: BOT_COLOR)") parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") + parser.add_argument("-H", "--health", dest="health", type=int, default=env_health, + help="Starting health points (default 10) (env: BOT_HEALTH)") parser.add_argument("-p", "--project", dest="project_id", default=env_project, help="Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT)") parser.add_argument("-l", "--location", dest="location", default=env_location, @@ -734,6 +803,7 @@ def main(): name=args.name, color=args.color, strength=args.strength, + health=args.health, server_url=args.server_url, project_id=args.project_id, location=args.location, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5c6971..d4e4005 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { PlayerList } from './components/PlayerList'; import { RegisterModal } from './components/RegisterModal'; import { PartyModal } from './components/PartyModal'; import { BattleModal } from './components/BattleModal'; +import { WizardChallengeModal } from './components/WizardChallengeModal'; import { ScoreboardModal } from './components/ScoreboardModal'; const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ @@ -32,6 +33,8 @@ export function App() { lastEventMessage, activeBattle, setActiveBattle, + activeWizardChallenge, + setActiveWizardChallenge, showScoreboard, setShowScoreboard, registerPlayer, @@ -41,6 +44,7 @@ export function App() { fightBattle, movePlayer, passTurn, + challengeWizard, stepActiveBotTurn, startGame, resetBoard, @@ -54,6 +58,10 @@ export function App() { setActiveBattle(null); }, [setActiveBattle]); + const handleCloseWizardChallenge = useCallback(() => { + setActiveWizardChallenge(null); + }, [setActiveWizardChallenge]); + const showNotification = (msg: string) => { setNotification(msg); setTimeout(() => { @@ -159,6 +167,9 @@ export function App() { selectedPlayer={selectedPlayer} availableMoves={availableMoves} onSelectPlayer={setSelectedPlayer} + onChallengeWizard={async (id) => { + await challengeWizard(id); + }} /> {/* 8-Directional Movement D-Pad & Simulation Controls */} @@ -172,6 +183,9 @@ export function App() { onPass={async (id) => { await passTurn(id); }} + onChallengeWizard={async (id) => { + await challengeWizard(id); + }} onStepBot={stepActiveBotTurn} isAutoPlaying={isAutoPlaying} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} @@ -186,6 +200,9 @@ export function App() { onOpenPartyModal={() => setIsPartyModalOpen(true)} onDefeatParty={handleDefeatParty} onFightBattle={handleFightBattle} + onChallengeWizard={async (id) => { + await challengeWizard(id); + }} /> {/* Live Event Feed Notification */} @@ -201,9 +218,9 @@ export function App() { setIsRegisterOpen(false)} - onRegister={async (name, color, pieceType) => { - const player = await registerPlayer(name, color, 1, pieceType); - showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) at (${player.x}, ${player.y})!`); + onRegister={async (name, color, pieceType, health) => { + const player = await registerPlayer(name, color, 1, pieceType, health); + showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`); }} /> @@ -224,6 +241,12 @@ export function App() { onClose={handleCloseBattle} /> + {/* 3-Bout D20 Wizard Challenge Modal */} + + {/* Game Conclusion Scoreboard Modal */} {showScoreboard && boardState.conclusion && boardState.conclusion.concluded && ( = ({ battle, onClose }) => )} + {battle.health_losses && Object.keys(battle.health_losses).length > 0 && ( +
+ • Defeated squad members suffered 1-3 HP damage: +
+ {Object.entries(battle.health_losses).map(([pid, loss]) => { + const isLeader = pid === battle.killed_leader_id; + const label = isLeader ? `${battle.killed_leader_name} (Leader)` : `Bot ${pid.slice(-4)}`; + return ( + + ❤️ + {label}: + -{loss} HP + + ); + })} +
+
+ )} +
New Squad Size: {battle.new_party_size} bots • Total Strength:{' '} {battle.new_party_strength} diff --git a/frontend/src/components/BoardCanvas.tsx b/frontend/src/components/BoardCanvas.tsx index d236133..ae0a9ab 100644 --- a/frontend/src/components/BoardCanvas.tsx +++ b/frontend/src/components/BoardCanvas.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import type { AvailableMovesResponse, BoardState, Player } from '../types'; -import { drawPlayerPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars'; +import { drawPlayerPiece, drawWizardPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars'; interface BoardCanvasProps { boardState: BoardState; @@ -8,6 +8,7 @@ interface BoardCanvasProps { availableMoves?: AvailableMovesResponse | null; onSelectPlayer: (player: Player | null) => void; onHoverCoord?: (coord: { x: number; y: number } | null) => void; + onChallengeWizard?: (playerId: string) => void; } export const BoardCanvas: React.FC = ({ @@ -16,6 +17,7 @@ export const BoardCanvas: React.FC = ({ availableMoves, onSelectPlayer, onHoverCoord, + onChallengeWizard, }) => { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -422,6 +424,13 @@ export const BoardCanvas: React.FC = ({ ctx.restore(); }); + // Draw Wandering Wizard NPC + if (boardState.wizard) { + const wx = startX + (boardState.wizard.x - min_x) * cellSize; + const wy = startY + (boardState.wizard.y - min_y) * cellSize; + drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize); + } + // Draw Players / Bots (Pixelated Board Game Knights and Warriors) boardState.players.forEach((player) => { const px = startX + (player.x - min_x) * cellSize; @@ -548,6 +557,37 @@ export const BoardCanvas: React.FC = ({ ? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y) : null; + const isHoveredWizard = Boolean( + hoveredCoord && + boardState.wizard && + boardState.wizard.x === hoveredCoord.x && + boardState.wizard.y === hoveredCoord.y + ); + + const liveSelectedPlayer = selectedPlayer + ? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer + : null; + + const isSelectedAdjacentToWizard = Boolean( + liveSelectedPlayer && + boardState.wizard && + Math.max( + Math.abs(liveSelectedPlayer.x - boardState.wizard.x), + Math.abs(liveSelectedPlayer.y - boardState.wizard.y) + ) <= 1 + ); + + const isSelectedTurn = Boolean( + boardState.turn.game_started && + liveSelectedPlayer && + currentTurnId === liveSelectedPlayer.id + ); + + const canSelectedChallenge = Boolean( + liveSelectedPlayer && + (!liveSelectedPlayer.party_id || liveSelectedPlayer.is_party_leader) + ); + return (
= ({ {hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'} + {isHoveredWizard && ( + <> + | + + 🧙‍♂️ + Grand Wizard NPC (Str: {boardState.wizard?.strength}) + + + )} {hoveredObstacle && ( <> | @@ -638,33 +687,73 @@ export const BoardCanvas: React.FC = ({
{/* Selected Player Overlay card */} - {selectedPlayer && ( -
+ {liveSelectedPlayer && ( +
- {selectedPlayer.name} - {selectedPlayer.is_party_leader && ( + {liveSelectedPlayer.name} + {liveSelectedPlayer.is_party_leader && ( 👑 Leader )} - {selectedPlayer.id === currentTurnId && ( + {liveSelectedPlayer.id === currentTurnId && ( • Turn )}
- Pos: ({selectedPlayer.x}, {selectedPlayer.y}) • Score:{' '} - - {selectedPlayer.score} + Pos: ({liveSelectedPlayer.x}, {liveSelectedPlayer.y}) • Score:{' '} + + {liveSelectedPlayer.score} + {' '}• HP: ❤️{liveSelectedPlayer.health ?? 10}
+ + {/* Challenge Wizard button on Selected Player Card */} + {isSelectedAdjacentToWizard && onChallengeWizard && ( + + )} +
+ {/* Challenge Wizard Button if adjacent */} + {isAdjacentToWizard && onChallengeWizard && ( + + )} + {/* Simulation / Bot Controls */}
+ ) : ( +
+ D20 Duel +
+ )} +
+
+ )} + {/* Action to form party */} {players.length >= 2 && onOpenPartyModal && (
@@ -178,7 +263,7 @@ export const PlayerList: React.FC = ({
- pos: ({player.x}, {player.y}) • Str: ⚡{player.strength || 1} + pos: ({player.x}, {player.y}) • Str: ⚡{player.strength || 1} • HP: ❤️{player.health ?? 10} {partyName && ( • {isLeader ? 'Leader' : 'Squad'} @@ -188,7 +273,52 @@ export const PlayerList: React.FC = ({
-
+
+ {boardState.wizard && + Math.max( + Math.abs(player.x - boardState.wizard.x), + Math.abs(player.y - boardState.wizard.y) + ) <= 1 && + onChallengeWizard && ( + + )}
+ {/* Starting Health */} +
+
+ + + ❤️ {health} HP {health === 10 ? '(Default)' : ''} + +
+
+ setHealth(Number(e.target.value))} + className="flex-1 accent-rose-500 cursor-pointer" + /> + setHealth(Math.max(1, Number(e.target.value)))} + className="w-16 bg-slate-950 border border-slate-700 focus:border-rose-500 rounded-lg px-2.5 py-1.5 text-xs font-mono text-rose-300 text-center focus:outline-none" + /> +
+
+ {/* Color & Faction Chooser */}
diff --git a/frontend/src/components/WizardChallengeModal.tsx b/frontend/src/components/WizardChallengeModal.tsx new file mode 100644 index 0000000..3352b75 --- /dev/null +++ b/frontend/src/components/WizardChallengeModal.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { WizardChallengeResult } from '../types'; + +interface WizardChallengeModalProps { + challenge: WizardChallengeResult | null; + onClose: () => void; +} + +export const WizardChallengeModal: React.FC = ({ + challenge, + onClose, +}) => { + const [countdown, setCountdown] = useState(5); + const [progressPercent, setProgressPercent] = useState(100); + + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }); + + const challengeKey = challenge + ? `${challenge.challenger_id}_${challenge.player_bouts_won}_${challenge.wizard_bouts_won}_${challenge.score_change}_${challenge.health_change}_${challenge.wizard_respawn_position?.x}_${challenge.wizard_respawn_position?.y}` + : null; + + useEffect(() => { + if (!challengeKey) { + setCountdown(5); + setProgressPercent(100); + return; + } + + const DURATION_MS = 5000; + const targetEndTime = Date.now() + DURATION_MS; + setCountdown(5); + setProgressPercent(100); + + const timer = window.setInterval(() => { + const now = Date.now(); + const remainingMs = targetEndTime - now; + + if (remainingMs <= 0) { + window.clearInterval(timer); + setCountdown(0); + setProgressPercent(0); + onCloseRef.current(); + return; + } + + const seconds = Math.ceil(remainingMs / 1000); + const percent = Math.min(100, Math.max(0, (remainingMs / DURATION_MS) * 100)); + setCountdown(seconds); + setProgressPercent(percent); + }, 100); + + return () => { + window.clearInterval(timer); + }; + }, [challengeKey]); + + if (!challenge) return null; + + const playerName = challenge.challenger_name; + const wizardName = challenge.wizard_name || 'Grand Wizard'; + + return ( +
+
+ {/* Glowing cyber wizard header banner */} +
+ + {/* 5-second automatic progress bar */} +
+
+
+ +
+
+ 🧙‍♂️ +
+

+ WIZARD NPC DUEL: 3-BOUT D20 CHALLENGE +

+

+ {playerName} vs{' '} + {wizardName} +

+
+
+ +
+ + {/* 3 Bouts Breakdown */} +
+ {challenge.bouts.map((bout) => ( +
+ {/* Bout Index */} +
+ Bout #{bout.bout_number} +
+ + {/* Player Bout Score */} +
+
+ {playerName} +
+
+ 🎲 D20({bout.player_roll}) + × + ⚡Str({bout.player_strength}) + = + {bout.player_score} +
+
+ + {/* VS Divider */} +
VS
+ + {/* Wizard Bout Score */} +
+
+ {wizardName} +
+
+ {bout.wizard_score} + = + ⚡Str({bout.wizard_strength}) + × + 🎲 D20({bout.wizard_roll}) +
+
+ + {/* Bout Outcome Badge */} +
+ + {bout.winner === 'player' + ? 'Player' + : bout.winner === 'wizard' + ? 'Wizard' + : 'Tie'} + +
+
+ ))} +
+ + {/* Overall Match Outcome Banner */} +
+
+ Duel Outcome: {challenge.player_bouts_won} - {challenge.wizard_bouts_won} +
+
+ {challenge.player_won ? '✨ CHALLENGE VICTORY ✨' : '💀 CHALLENGE DEFEAT 💀'} +
+ +
+ {challenge.player_won ? ( +
+ 🎉 +{challenge.score_change} Score Points awarded! (Total: {challenge.new_score}) +
+ ) : ( +
+ {challenge.health_change < 0 ? ( + + 💔 Lost {Math.abs(challenge.health_change)} Health! (Remaining HP: {challenge.new_health}) + + ) : ( + + 📉 Out of HP! Lost {Math.abs(challenge.score_change)} Score points! (Score: {challenge.new_score}) + + )} +
+ )} +
+ 🔮 The Wizard vanished in a puff of smoke and teleported to ({challenge.wizard_respawn_position?.x}, {challenge.wizard_respawn_position?.y}). +
+
+
+ + {/* Modal Controls / Auto-close countdown */} +
+ Auto-closing in {countdown}s... + +
+
+
+ ); +}; diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index cd57576..0c90c28 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -10,6 +10,7 @@ import type { PartyDefeatResult, Player, TurnInfo, + WizardChallengeResult, } from '../types'; const INITIAL_BOARD: BoardState = { @@ -25,6 +26,7 @@ const INITIAL_BOARD: BoardState = { players: [], parties: [], obstacles: [], + wizard: null, turn: { game_started: false, current_player_id: null, @@ -45,6 +47,7 @@ export function useGameSocket() { const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [lastEventMessage, setLastEventMessage] = useState(null); const [activeBattle, setActiveBattle] = useState(null); + const [activeWizardChallenge, setActiveWizardChallenge] = useState(null); const [showScoreboard, setShowScoreboard] = useState(false); const wsRef = useRef(null); @@ -52,6 +55,8 @@ export function useGameSocket() { const autoPlayIntervalRef = useRef(null); const activeBattleRef = useRef(null); activeBattleRef.current = activeBattle; + const activeWizardChallengeRef = useRef(null); + activeWizardChallengeRef.current = activeWizardChallenge; const fetchBoard = useCallback(async () => { try { @@ -63,6 +68,10 @@ export function useGameSocket() { parties: data.parties || prev.parties || [], conclusion: data.conclusion || null, })); + setSelectedPlayer((curr) => { + if (!curr) return null; + return data.players.find((p) => p.id === curr.id) || null; + }); if (data.conclusion && data.conclusion.concluded) { setShowScoreboard(true); } @@ -112,6 +121,10 @@ export function useGameSocket() { parties: data.state.parties || [], conclusion: data.state.conclusion || null, }); + setSelectedPlayer((curr) => { + if (!curr || !data.state?.players) return null; + return data.state.players.find((p: Player) => p.id === curr.id) || null; + }); if (data.state.conclusion && data.state.conclusion.concluded) { setShowScoreboard(true); } @@ -145,6 +158,19 @@ export function useGameSocket() { turn: data.turn ?? prev.turn, }; }); + setSelectedPlayer((curr) => { + if (!curr) return null; + if (data.affected_players) { + const match = data.affected_players.find((p: Player) => p.id === curr.id); + if (match) return match; + } + if (data.player && data.player.id === curr.id) return data.player; + if (data.players) { + const match = data.players.find((p: Player) => p.id === curr.id); + if (match) return match; + } + return curr; + }); } else if (data.event === 'party_formed' || data.event === 'party_updated') { setBoardState((prev) => ({ ...prev, @@ -179,6 +205,21 @@ export function useGameSocket() { const b: BattleResult = data.battle; setActiveBattle(b); setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); + } else if (data.event === 'wizard_challenge_resolved') { + setBoardState((prev) => ({ + ...prev, + players: data.players ?? prev.players, + wizard: data.wizard ?? prev.wizard, + turn: data.turn ?? prev.turn, + })); + const c: WizardChallengeResult = data.challenge; + setActiveWizardChallenge(c); + const wizName = c.wizard_name || 'Grand Wizard'; + setLastEventMessage( + c.player_won + ? `🧙 ${c.challenger_name} defeated ${wizName}! (+${c.score_change} score)` + : `🧙 ${c.challenger_name} lost to ${wizName}! (${c.health_change < 0 ? `${c.health_change} HP` : `${c.score_change} pts`})` + ); } else if (data.event === 'game_concluded') { const conc: GameConclusion = data.conclusion; setBoardState((prev) => ({ @@ -227,6 +268,7 @@ export function useGameSocket() { setSelectedPlayer(null); setAvailableMoves(null); setActiveBattle(null); + setActiveWizardChallenge(null); setShowScoreboard(false); setLastEventMessage('🔄 Board has been reset.'); } @@ -273,12 +315,13 @@ export function useGameSocket() { name: string, color: string, strength: number = 1, - piece_type?: 'knight' | 'warrior' + piece_type?: string, + health: number = 10 ): Promise => { const res = await fetch('/api/players', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, color, strength, piece_type }), + body: JSON.stringify({ name, color, strength, piece_type, health }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -353,6 +396,9 @@ export function useGameSocket() { throw new Error(err.detail || 'Failed to move player'); } const data: MoveResponse = await res.json(); + if (data.player) { + setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr)); + } if (data.battle_triggered && data.battle_result) { setActiveBattle(data.battle_result); } @@ -360,6 +406,7 @@ export function useGameSocket() { setIsAutoPlaying(false); setShowScoreboard(true); } + fetchAvailableMoves(playerId); return data; }; @@ -397,14 +444,29 @@ export function useGameSocket() { return data; }; + const challengeWizard = async (playerId: string): Promise => { + const res = await fetch('/api/wizard/challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id: playerId }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Failed to challenge wizard'); + } + const data: WizardChallengeResult = await res.json(); + setActiveWizardChallenge(data); + return data; + }; + // Step active bot turn according to its explicit autonomous goal: // - Bot without party: seeks other bots to form a party (stronger bot insists on being leader) // - Party leader: seeks other parties to find and defeat all other parties const stepActiveBotTurn = useCallback(async () => { // Cannot step if game hasn't started yet if (!boardState.turn.game_started) return; - // If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes - if (activeBattleRef.current) return; + // If a battle or wizard challenge modal is currently open, pause turn stepping until modal acknowledges/closes + if (activeBattleRef.current || activeWizardChallengeRef.current) return; const currentId = boardState.turn.current_player_id; if (!currentId) return; @@ -420,6 +482,15 @@ export function useGameSocket() { } else if (data.action_taken === 'battled' && data.battle_result) { setActiveBattle(data.battle_result); setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`); + } else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) { + const wcr = data.wizard_challenge_result; + setActiveWizardChallenge(wcr); + const wizName = wcr.wizard_name || 'Grand Wizard'; + setLastEventMessage( + wcr.player_won + ? `🧙 ${wcr.challenger_name} defeated ${wizName}! (+${wcr.score_change} score)` + : `🧙 ${wcr.challenger_name} lost to ${wizName}! (${wcr.health_change < 0 ? `${wcr.health_change} HP` : `${wcr.score_change} pts`})` + ); } else if (data.move_result?.battle_result) { setActiveBattle(data.move_result.battle_result); } @@ -432,7 +503,7 @@ export function useGameSocket() { } catch (err) { console.warn('Bot AI step error:', err); } - }, [boardState.turn.current_player_id]); + }, [boardState.turn.current_player_id, boardState.turn.game_started]); // Autoplay interval loop useEffect(() => { @@ -463,6 +534,8 @@ export function useGameSocket() { lastEventMessage, activeBattle, setActiveBattle, + activeWizardChallenge, + setActiveWizardChallenge, showScoreboard, setShowScoreboard, registerPlayer, @@ -472,6 +545,7 @@ export function useGameSocket() { fightBattle, movePlayer, passTurn, + challengeWizard, stepActiveBotTurn, startGame, resetBoard, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 00afbc2..6c30e55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,8 @@ export interface Player { y: number; strength: number; score: number; + health?: number; + max_health?: number; piece_type?: 'knight' | 'warrior'; party_id?: string | null; is_party_leader: boolean; @@ -57,12 +59,23 @@ export interface GameConclusion { rankings?: Player[]; } +export interface WizardNPC { + id: string; + name: string; + x: number; + y: number; + strength: number; + color: string; + dialogue?: string; +} + export interface BoardState { config: GridConfig; player_count: number; players: Player[]; parties: Party[]; obstacles: Obstacle[]; + wizard?: WizardNPC | null; turn: TurnInfo; game_started?: boolean; conclusion?: GameConclusion | null; @@ -117,6 +130,16 @@ export interface RadarTarget { can_battle: boolean; } +export interface WizardRadarTarget { + id: string; + name: string; + x: number; + y: number; + distance: number; + strength: number; + can_challenge: boolean; +} + export interface BotRadarResponse { player_id: string; current_x: number; @@ -124,6 +147,7 @@ export interface BotRadarResponse { bot_goal: string; targets: RadarTarget[]; nearest_target?: RadarTarget | null; + wizard?: WizardRadarTarget | null; recommended_direction?: string | null; recommended_action: string; } @@ -160,6 +184,34 @@ export interface BattleResult { absorbed_members: string[]; new_party_size: number; new_party_strength: number; + health_losses?: Record; +} + +export interface WizardChallengeBout { + bout_number: number; + player_roll: number; + player_strength: number; + player_score: number; + wizard_roll: number; + wizard_strength: number; + wizard_score: number; + winner: string; +} + +export interface WizardChallengeResult { + challenger_id: string; + challenger_name: string; + wizard_name?: string; + party_id?: string | null; + bouts: WizardChallengeBout[]; + player_bouts_won: number; + wizard_bouts_won: number; + player_won: boolean; + score_change: number; + health_change: number; + new_score: number; + new_health: number; + wizard_respawn_position: { x: number; y: number }; } export interface PartyDefeatResult { @@ -199,6 +251,7 @@ export interface AiStepResponse { move_result?: MoveResponse | null; formed_party?: Party | null; battle_result?: BattleResult | null; + wizard_challenge_result?: WizardChallengeResult | null; game_concluded?: GameConclusion | null; turn: TurnInfo; } diff --git a/frontend/src/utils/pixelAvatars.tsx b/frontend/src/utils/pixelAvatars.tsx index 408506a..84ec336 100644 --- a/frontend/src/utils/pixelAvatars.tsx +++ b/frontend/src/utils/pixelAvatars.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Player } from '../types'; -export type PieceType = 'knight' | 'warrior'; +export type PieceType = 'knight' | 'warrior' | 'wizard'; // Hex color parser and manipulator function parseHex(hex: string): [number, number, number] { @@ -131,6 +131,25 @@ const LEADER_WARRIOR_SPRITE: string[] = [ '..____________..', // Row 15: Miniature drop shadow ]; +const WIZARD_SPRITE: string[] = [ + '......CC........', // Row 0: Pointed wizard hat tip + '.....LCCD.......', // Row 1: Hat cone + '....LLCCDD......', // Row 2: Hat cone body + '...GLLCCDDG.gW..', // Row 3: Golden hat buckle + Staff crystal shine + '..KCCCCCCCCK.Gg.', // Row 4: Wide hat brim + Staff orb + '...KWWWWWWK..KH.', // Row 5: Face & white flowing beard + wood staff + '...KWBRBWK...KH.', // Row 6: Glowing mystic eyes + beard + staff + '.LCKWWWWWWK..KH.', // Row 7: Robe shoulders + beard + staff + 'LCCKLLCCDDG..KH.', // Row 8: Robe body with golden clasp + staff + 'DCDKLLCCDDK..KH.', // Row 9: Flowing robe + staff + '.DDKLLCCDDK..KH.', // Row 10: Robe lower + staff + '..K.KBBBBK.K.KH.', // Row 11: Mystic boots + staff base + '...KKKKKKKKK....', // Row 12: Pedestal top bevel + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KCCCCCCCCCCCCK.', // Row 14: Pedestal rim in robe color + '..____________..', // Row 15: Miniature drop shadow +]; + // Palette generation function getPalette(color: string): Record { const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`; @@ -238,6 +257,8 @@ export function getSpriteCanvas( let spriteMatrix: string[]; if (pieceType === 'knight') { spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE; + } else if (pieceType === 'wizard') { + spriteMatrix = WIZARD_SPRITE; } else { spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; } @@ -356,7 +377,8 @@ export function drawPlayerPiece( ctx.textBaseline = 'middle'; const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; - const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`; + const hpBadge = player.health !== undefined ? ` ❤️${player.health}` : ''; + const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpBadge}]`; const textMetrics = ctx.measureText(text); const bgWidth = textMetrics.width + 12; const bgHeight = 16; @@ -377,6 +399,78 @@ export function drawPlayerPiece( ctx.restore(); } +// Canvas Drawing Helper for the Grand Wizard NPC +export function drawWizardPiece( + ctx: CanvasRenderingContext2D, + wizard: { x: number; y: number; name: string; strength: number; color?: string }, + px: number, + py: number, + cellSize: number +): void { + const color = wizard.color || '#A855F7'; + const spriteCanvas = getSpriteCanvas('wizard', color, false); + + const spriteSize = Math.max(cellSize * 1.45, 16); + const destX = Math.round(px - spriteSize / 2); + const destY = Math.round(py - spriteSize * 0.62); + + ctx.save(); + + // Glowing mystic arcane aura + ctx.save(); + ctx.beginPath(); + ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.65, spriteSize * 0.32, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(168, 85, 247, 0.25)'; + ctx.fill(); + ctx.strokeStyle = '#c084fc'; + ctx.lineWidth = 2; + ctx.shadowColor = '#c084fc'; + ctx.shadowBlur = 12; + ctx.stroke(); + ctx.restore(); + + // Draw the pixelated Wizard + ctx.imageSmoothingEnabled = false; + ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize); + + // Floating magic sparkle above wizard hat + ctx.save(); + ctx.fillStyle = '#fef08a'; + ctx.font = `${Math.max(spriteSize * 0.4, 10)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('✨', px, destY - 6); + ctx.restore(); + + // Wizard Name & Strength Badge + ctx.save(); + ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 12; + const bgHeight = 16; + const labelY = destY - 16; + + ctx.fillStyle = 'rgba(15, 23, 42, 0.94)'; + ctx.strokeStyle = '#c084fc'; + ctx.lineWidth = 1.2; + ctx.shadowColor = '#c084fc'; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = '#e9d5ff'; + ctx.fillText(text, px, labelY - 4); + ctx.restore(); + + ctx.restore(); +} + // React Component for displaying pixel avatar in UI interface PixelAvatarProps { pieceType: PieceType;