diff --git a/AGENTS.md b/AGENTS.md index 423b5fc..10be7d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ Welcome to **botWebWars**! This document provides architectural context, compone - `botagent`: Python heuristic agent (radar scanning, frontier exploration, deterministic leader negotiation). - `botagent_ai`: LLM-assisted Python agent (Ollama / LLM-driven strategic negotiation and pathing). - `botagent_gear`: Vertex AI (Gemini) LLM-assisted agent (Google Cloud Vertex AI / Gemini 2.5 & 1.5 strategic reasoning). + - `trollagent`: Heuristic autonomous troll agent (solitary player hunter, sleep healing, ignores other trolls). + - `trollagent_ai`: LLM-assisted Python troll agent (Ollama / LLM-driven tactical hunting and sleep actions). + - `trollagent_gear`: Vertex AI (Gemini) LLM-assisted troll agent (Google Cloud Vertex AI / Gemini 2.5 & 1.5 strategic hunting). --- @@ -67,6 +70,23 @@ botWebWars/ │ ├── INSTALL.md # Installation, virtual environment, and dependency instructions │ └── README.md # Feature overview and invocation examples │ +├── trollagent/ # Heuristic autonomous troll agent +│ ├── troll_agent.py # Autonomous troll client (player hunting, sleep healing) +│ └── README.md # Execution command examples +│ +├── trollagent_ai/ # LLM-assisted autonomous troll agent (Ollama) +│ ├── bot.py # Troll delegating hunting/sleep strategy to Ollama LLM +│ ├── requirements.txt # Dependencies (requests) +│ ├── INSTALL.md # Setup & execution instructions +│ └── README.md # Feature overview +│ +├── trollagent_gear/ # LLM-assisted autonomous troll agent (Google Vertex AI / Gemini) +│ ├── bot.py # Troll delegating tactical hunting to Vertex AI Gemini +│ ├── requirements.txt # Dependencies (google-auth, requests) +│ ├── SETUP.md # Authentication guide +│ ├── INSTALL.md # Installation instructions +│ └── README.md # Feature overview +│ └── examples/ # Reference screenshots and board style designs ``` @@ -136,9 +156,21 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to - **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. +### 8. Trolls (Character Type & Unique Mechanics) +- **No Alliances**: Trolls never form or join parties. They remain lone hunters throughout the game. +- **Troll Truce**: Trolls do not battle each other. They ignore adjacent trolls and continue searching for players. +- **Player Hunting & Mandatory Battles**: Trolls actively hunt players and squads. Battles with adjacent players are mandatory. +- **Victory Scoring & No Strength Gain**: Winning a 3-bout D20 battle grants **+2 score points**. Trolls do not gain strength or absorb followers. +- **Health Damage & Death**: Losing battles inflicts **1 to 3 HP damage**. At 0 HP, trolls die and are marked with a gravestone. +- **No Gary the Wizard Duels**: Trolls cannot challenge or engage Gary the Wizard. +- **Rest & Sleep Action (+0.1 HP)**: A troll may spend its turn sleeping (`POST /api/players/{id}/sleep`), skipping movement to regenerate **+0.1 health points**. + +### 9. Game Conclusion +- The game concludes under any of the following conditions: + 1. **All players joined into one party**: When all surviving players have united into a single remaining party, even if trolls remain on the board (win condition for the united party). + 2. **1 entity remains**: When exactly 1 living entity (player or troll) remains as the sole survivor. + 3. **Only trolls remain**: When all players have been defeated and only trolls remain (due to troll mutual truce). +- Final rankings and trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker), with all bots (surviving and deceased, players and trolls) included on the scoreboard. Trolls can achieve 1st place champion status. --- @@ -150,7 +182,7 @@ 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, "health": int}` | +| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int, "character_type": "player"|"troll"}` | | `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 | @@ -160,6 +192,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream | `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"}`) | +| `POST` | `/api/players/{id}/sleep` | Troll rest action: forfeit movement to regenerate +0.1 health | | `POST` | `/api/players/{id}/ai-step` | Perform one autonomous decision step via internal game engine | | `POST` | `/api/players/{id}/pass` | Pass turn to next queued entity | | `GET` | `/api/turn` | Current turn owner, round number, and turn order | @@ -267,6 +300,48 @@ The application is containerized into a single unified image via [Dockerfile](Do python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10 ``` +### D. Heuristic Troll Bot: `trollagent/` +- **Entry File**: `trollagent/troll_agent.py` +- **Technique**: Autonomous rule-based troll hunter. +- **Workflow**: + 1. Registers with `character_type="troll"`, `piece_type="troll"`. + 2. Polls turn queue; checks life status. + 3. Uses radar sensor to scan for nearest player bots (ignores other trolls and Gary the Wizard). + 4. Attacks adjacent players in mandatory 3-bout D20 combat (+2 score on win, 1-3 damage on defeat). + 5. Evaluates health vs. `--sleep-threshold` (default 6.0 HP) or radar recommendation to execute rest turns (`POST /api/players/{id}/sleep`) for +0.1 HP regeneration. + 6. Navigates around obstacles using Chebyshev path minimization. +- **Run Command**: + ```bash + python3 trollagent/troll_agent.py --name GorgonTroll --color "#16a34a" -s 3 -H 10 --sleep-threshold 6.0 + ``` + +### E. LLM-Assisted Troll Bot (Ollama): `trollagent_ai/` +- **Entry File**: `trollagent_ai/bot.py` +- **Technique**: Ollama LLM-assisted tactical troll hunter with strict rule safeguards. +- **LLM Integration**: + - Prompts model with Troll rules of engagement (hunting players, no alliances, no wizard duels, sleep healing). + - Delegates discretionary tactical choices to the model: + - Deciding between resting to heal (`sleep`) vs. hunting (`move`). + - Selecting passable movement directions around obstacles. +- **Run Command**: + ```bash + export OLLAMA_BASE_URL="http://localhost:11434" + export OLLAMA_MODEL="gemma4:12b" + python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d" + ``` + +### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/` +- **Entry File**: `trollagent_gear/bot.py` +- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for tactical spatial pathing, ambush tracking, and strategic sleep turns. +- **LLM Integration**: + - Uses structured JSON generation (`responseMimeType: application/json`). + - Evaluates live health, nearest player distance, and obstacle bottlenecks to decide between healing (`sleep`) and hunting (`move`). + - Supports Google Cloud ADC (`gcloud auth application-default login`), service account keys, and direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`). +- **Run Command**: + ```bash + python3 trollagent_gear/bot.py --name GeminiBrute --color "#047857" -s 4 -H 10 + ``` + --- ## 8. Common Developer Workflows diff --git a/GAME_RULES.md b/GAME_RULES.md index 14b4ff3..fdd08e4 100644 --- a/GAME_RULES.md +++ b/GAME_RULES.md @@ -53,8 +53,26 @@ - **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. +8. **Trolls (Character Type & Unique Mechanics)**: + 1. **Registration**: Trolls register with `character_type: "troll"` and `piece_type: "troll"`. + 2. **No Alliances**: Trolls never form or join parties. They remain lone hunters throughout the game. + 3. **Troll Truce**: Trolls do not battle each other. When trolls encounter each other on adjacent coordinates, they ignore each other and continue their hunt. + 4. **Player Hunting & Mandatory Combat**: Trolls seek out player characters and player squads. When a troll is adjacent to a player or player party, battle is mandatory. + 5. **Battle Resolution & Scoring**: + - Battles follow the standard 3-bout D20 format. + - **Victory**: If a troll wins, it receives **+2 victory points (score)**. Trolls **do not gain strength** and **never absorb** defeated followers. + - **Defeat**: If a troll loses, it takes **1 to 3 health damage** (randomized). + 6. **Damage & Death**: Trolls can die when their health reaches **0 HP**, placing a gravestone marker on the board. Dead trolls cease taking turns but remain preserved on the scoreboard. + 7. **No Wizard Duels**: Trolls cannot engage or duel with Gary the Wizard NPC. + 8. **Rest & Sleep Action (+0.1 HP)**: + - A troll may choose to take a turn sleeping (`POST /api/players/{id}/sleep`). + - Choosing to sleep forfeits the troll's movement for that turn and regenerates **+0.1 health points**. + # Game Conclusion -The game ends when all surviving bots on the board are united into a single remaining party (or if only one surviving bot remains). +The game reaches its conclusion and triggers the final scoreboard when any of the following end conditions are met: +1. **All Players Joined into One Party**: When all surviving players on the board have joined into a single remaining party, even if trolls remain on the board. This is a win condition for the united party. +2. **Single Surviving Entity**: When exactly 1 living entity (player or troll) remains on the board (sole survivor). +3. **Only Trolls Remain**: When all players have been defeated and only trolls remain on the board (since trolls observe a mutual truce and do not battle or band together). -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). +All characters (both living and deceased, players and trolls) 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). Trolls can win the match and achieve 1st place champion status on the final scores like players. diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 55a871c..d9bec45 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -23,6 +23,7 @@ from app.models import ( PartyInviteResponse, Player, PlayerCreate, + SleepResponse, TurnInfo, WizardChallengeRequest, WizardChallengeResult, @@ -44,7 +45,7 @@ async def health_check(): @router.get( "/game/conclusion", response_model=GameConclusion, - summary="Get the game conclusion state and final rankings when 1 party remains with all bots", + summary="Get the game conclusion state and final rankings when an end condition is met", tags=["System"], ) async def get_game_conclusion(): @@ -584,6 +585,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.sleep_result: + await manager.broadcast({ + "event": "player_slept", + "player": result.sleep_result.player.model_dump(), + "health_gained": result.sleep_result.health_gained, + "new_health": result.sleep_result.new_health, + "players": [p.model_dump() for p in board_state.players], + "turn": result.turn.model_dump(), + }) elif result.move_result: await manager.broadcast({ "event": "player_moved", @@ -646,6 +656,52 @@ async def step_bot_ai(player_id: str): ) +@router.post( + "/players/{player_id}/sleep", + response_model=SleepResponse, + summary="Troll takes the sleep action: misses turn and gains 0.1 health", + tags=["Movement"], +) +async def sleep_player(player_id: str): + try: + result = await game_engine.sleep_player(player_id) + board_state = await game_engine.get_board_state() + await manager.broadcast({ + "event": "player_slept", + "player": result.player.model_dump(), + "health_gained": result.health_gained, + "new_health": result.new_health, + "players": [p.model_dump() for p in board_state.players], + "turn": result.turn.model_dump(), + }) + + if board_state.conclusion and board_state.conclusion.concluded: + await manager.broadcast({ + "event": "game_concluded", + "conclusion": board_state.conclusion.model_dump(), + "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: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + 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), + ) + + @router.post( "/players/{player_id}/pass", response_model=TurnInfo, diff --git a/backend/app/game.py b/backend/app/game.py index 915708c..20ee0f6 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -23,6 +23,7 @@ from app.models import ( Player, PlayerCreate, RadarTarget, + SleepResponse, TurnInfo, WizardChallengeBout, WizardChallengeResult, @@ -384,20 +385,35 @@ class GameEngine: player_id = f"player_{uuid.uuid4().hex[:8]}" spawn_x, spawn_y = self._find_random_free_position() - # Determine piece class (knight or warrior) + # Determine character type (player or troll) + char_type = (getattr(player_in, "character_type", None) or "player").lower().strip() + if char_type not in ["player", "troll"]: + char_type = "player" + + # Determine piece class (knight, warrior, or troll) piece_type = getattr(player_in, "piece_type", None) - if not piece_type: + if piece_type: + piece_type = piece_type.lower().strip() + if char_type == "troll" and (not piece_type or piece_type in ("knight", "warrior")): + piece_type = "troll" + elif not piece_type: name_lower = player_in.name.lower() - if "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower: + if "troll" in name_lower: + piece_type = "troll" + char_type = "troll" + elif "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower: piece_type = "warrior" elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower: piece_type = "knight" else: piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior" + elif piece_type == "troll": + char_type = "troll" - health = getattr(player_in, "health", 10) + health = getattr(player_in, "health", 10.0) if health is None: - health = 10 + health = 10.0 + health = float(health) player = Player( id=player_id, @@ -410,6 +426,7 @@ class GameEngine: x=spawn_x, y=spawn_y, piece_type=piece_type, + character_type=char_type, party_id=None, is_party_leader=False, visited_locations=[{"x": spawn_x, "y": spawn_y}], @@ -551,31 +568,54 @@ class GameEngine: if not player: raise KeyError(f"Player '{player_id}' not found") - bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" + is_troll = player.character_type == "troll" + if is_troll: + bot_goal = "hunt_players" + else: + bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" targets: List[RadarTarget] = [] for other in self.players.values(): if other.id == player.id or not other.is_alive or other.health <= 0: continue dist = max(abs(player.x - other.x), abs(player.y - other.y)) - is_ally = bool(player.party_id and player.party_id == other.party_id) - is_enemy = not is_ally party_obj = self.parties.get(other.party_id) if other.party_id else None - if player.party_id and player.is_party_leader and other.party_id is None: - can_recruit = other.strength <= player.strength - can_battle = other.strength > player.strength - elif player.party_id is None and other.party_id is None: - can_recruit = True - can_battle = False - elif player.party_id is None and other.party_id: - other_lead = self.players.get(party_obj.leader_id) if party_obj else None - lead_str = other_lead.strength if other_lead else 1 - can_recruit = player.strength <= lead_str - can_battle = player.strength > lead_str + if is_troll: + if other.character_type == "troll": + is_ally = False + is_enemy = False + can_recruit = False + can_battle = False + else: + is_ally = False + is_enemy = True + can_recruit = False + can_battle = True else: - can_recruit = False - can_battle = is_enemy and bool(player.party_id and other.party_id) + if other.character_type == "troll": + is_ally = False + is_enemy = True + can_recruit = False + can_battle = True + else: + is_ally = bool(player.party_id and player.party_id == other.party_id) + is_enemy = not is_ally + + if player.party_id and player.is_party_leader and other.party_id is None: + can_recruit = other.strength <= player.strength + can_battle = other.strength > player.strength + elif player.party_id is None and other.party_id is None: + can_recruit = True + can_battle = False + elif player.party_id is None and other.party_id: + other_lead = self.players.get(party_obj.leader_id) if party_obj else None + lead_str = other_lead.strength if other_lead else 1 + can_recruit = player.strength <= lead_str + can_battle = player.strength > lead_str + else: + can_recruit = False + can_battle = is_enemy and bool(player.party_id and other.party_id) targets.append( RadarTarget( @@ -598,8 +638,10 @@ class GameEngine: targets.sort(key=lambda t: t.distance) primary_targets = [] - if bot_goal == "form_party": - recruit_targets = [t for t in targets if t.can_recruit or not t.party_id] + if is_troll: + primary_targets = [t for t in targets if t.is_enemy] + elif bot_goal == "form_party": + recruit_targets = [t for t in targets if t.can_recruit or (not t.party_id and not t.is_enemy)] primary_targets = recruit_targets if recruit_targets else targets else: battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)] @@ -615,13 +657,13 @@ class GameEngine: y=self.wizard.y, distance=wiz_dist, strength=self.wizard.strength, - can_challenge=(wiz_dist <= 1), + can_challenge=(wiz_dist <= 1 and not is_troll), ) rec_dir = None rec_act = "explore_unvisited" - if player.health < 2 and self.wizard: + if not is_troll and player.health < 2 and self.wizard: rec_act = "seek_wizard" # Use BFS pathfinder to recommend direction navigating toward Gary the Wizard bfs_path = self._find_path_bfs((player.x, player.y), (self.wizard.x, self.wizard.y)) @@ -663,16 +705,24 @@ class GameEngine: rec_dir = name break - if nearest.distance <= 1: - if nearest.can_recruit: - rec_act = "form_party" - else: + if is_troll: + if nearest.distance <= 1: rec_act = "engage_battle" - else: - if bot_goal == "form_party": - rec_act = "seek_partner" + elif player.health < 4.0: + rec_act = "sleep" else: - rec_act = "hunt_party" + rec_act = "hunt_players" + else: + if nearest.distance <= 1: + if nearest.can_recruit: + rec_act = "form_party" + else: + rec_act = "engage_battle" + else: + if bot_goal == "form_party": + rec_act = "seek_partner" + else: + rec_act = "hunt_party" return BotRadarResponse( player_id=player.id, @@ -741,6 +791,8 @@ class GameEngine: 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.") + if p.character_type == "troll": + raise ValueError(f"Troll '{p.name}' cannot join or form a party. Trolls do not band together.") member_players.append(p) if not self._verify_party_connectivity(member_players): @@ -788,6 +840,8 @@ class GameEngine: invitee = self.players.get(invitee_id) if not inviter or not invitee: raise KeyError("Inviter or invitee not found") + if inviter.character_type == "troll" or invitee.character_type == "troll": + raise ValueError("Trolls do not band together into parties.") 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: @@ -832,6 +886,8 @@ class GameEngine: invitee = self.players.get(invite.invitee_id) if not inviter or not invitee: raise KeyError("Inviter or invitee no longer active") + if inviter.character_type == "troll" or invitee.character_type == "troll": + raise ValueError("Trolls do not band together into parties.") if not inviter.is_alive or not invitee.is_alive: raise ValueError("Cannot join party with deceased player.") @@ -1255,6 +1311,7 @@ class GameEngine: defeated_party = party1 winner_lead = self.players.get(winner_party.leader_id) + is_winner_troll = bool(winner_lead and winner_lead.character_type == "troll") if winner_lead: winner_lead.score += 2 @@ -1263,6 +1320,7 @@ class GameEngine: self.players[mid].score += 1 killed_leader = self.players.get(defeated_party.leader_id) + is_defeated_troll = bool(killed_leader and killed_leader.character_type == "troll") absorbed_members: List[str] = [] dead_players: List[str] = [] @@ -1276,9 +1334,9 @@ class GameEngine: p = self.players.get(mid) if p: hp_loss = random.randint(1, 3) - p.health = max(0, p.health - hp_loss) + p.health = round(max(0.0, p.health - hp_loss), 1) health_losses[mid] = hp_loss - if p.health == 0 and p.is_alive: + if p.health <= 0 and p.is_alive: self._check_and_apply_death(p) dead_players.append(mid) @@ -1286,7 +1344,15 @@ class GameEngine: killed_leader.score -= 1 if killed_leader.is_alive: - if len(defeated_party.member_ids) == 1: + if is_winner_troll or is_defeated_troll: + # Trolls do not absorb members and cannot be absorbed + respawn_x, respawn_y = self._find_random_free_position() + killed_leader.x = respawn_x + killed_leader.y = respawn_y + killed_leader.party_id = None + killed_leader.is_party_leader = False + respawn_pos = {"x": respawn_x, "y": respawn_y} + elif len(defeated_party.member_ids) == 1: killed_leader.party_id = winner_party.id killed_leader.is_party_leader = False if killed_leader.id not in winner_party.member_ids: @@ -1304,21 +1370,53 @@ class GameEngine: # Leader is dead: remains at final coordinates as gravestone, disconnected from party respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} - # Only surviving defeated party followers are absorbed into winning party - for mid in list(defeated_party.member_ids): - if mid != defeated_party.leader_id: - m = self.players.get(mid) - if m and m.is_alive: - m.party_id = winner_party.id - m.is_party_leader = False - if m.id not in winner_party.member_ids: - winner_party.member_ids.append(m.id) - absorbed_members.append(m.id) + if is_winner_troll: + # Winner is a troll: NO followers are absorbed, troll gains NO strength! + surviving_followers = [ + self.players[mid] for mid in defeated_party.member_ids + if mid != defeated_party.leader_id and self.players.get(mid) and self.players[mid].is_alive + ] + if surviving_followers: + new_lead = max(surviving_followers, key=lambda f: (f.strength, f.score)) + defeated_party.leader_id = new_lead.id + defeated_party.leader_name = new_lead.name + defeated_party.member_ids = [f.id for f in surviving_followers] + new_lead.is_party_leader = True + self._update_party_strength(defeated_party) + else: + if defeated_party.id in self.parties: + del self.parties[defeated_party.id] - if defeated_party.id in self.parties: - del self.parties[defeated_party.id] + # Clean up temporary troll party so troll stays completely solo + if winner_party.id in self.parties: + del self.parties[winner_party.id] + if winner_lead: + winner_lead.party_id = None + winner_lead.is_party_leader = False + elif is_defeated_troll: + # Defeated was a troll: No one absorbed from troll + if defeated_party.id in self.parties: + del self.parties[defeated_party.id] + if killed_leader: + killed_leader.party_id = None + killed_leader.is_party_leader = False + self._update_party_strength(winner_party) + else: + # Standard player vs player party defeat: Only surviving defeated followers are absorbed + for mid in list(defeated_party.member_ids): + if mid != defeated_party.leader_id: + m = self.players.get(mid) + if m and m.is_alive: + m.party_id = winner_party.id + m.is_party_leader = False + if m.id not in winner_party.member_ids: + winner_party.member_ids.append(m.id) + absorbed_members.append(m.id) - self._update_party_strength(winner_party) + if defeated_party.id in self.parties: + del self.parties[defeated_party.id] + + self._update_party_strength(winner_party) return BattleResult( bouts=bouts, @@ -1358,6 +1456,9 @@ class GameEngine: if not p2.is_alive or p2.health <= 0: raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.") + if p1.character_type == "troll" and p2.character_type == "troll": + raise ValueError("Trolls do not battle each other.") + if p1.party_id and p1.party_id == p2.party_id: raise ValueError("Cannot battle members of your own party") @@ -1369,9 +1470,10 @@ class GameEngine: if not party1: temp_p1_id = f"party_{uuid.uuid4().hex[:8]}" + p1_prefix = "Troll" if p1.character_type == "troll" else "Squad" party1 = Party( id=temp_p1_id, - name=f"Squad {p1.name}", + name=f"{p1_prefix} {p1.name}", leader_id=p1.id, leader_name=p1.name, member_ids=[p1.id], @@ -1383,9 +1485,10 @@ class GameEngine: if not party2: temp_p2_id = f"party_{uuid.uuid4().hex[:8]}" + p2_prefix = "Troll" if p2.character_type == "troll" else "Squad" party2 = Party( id=temp_p2_id, - name=f"Squad {p2.name}", + name=f"{p2_prefix} {p2.name}", leader_id=p2.id, leader_name=p2.name, member_ids=[p2.id], @@ -1536,6 +1639,9 @@ class GameEngine: if not player.is_alive or player.health <= 0: raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.") + if player.character_type == "troll": + raise ValueError("Trolls cannot engage with Gary the Wizard.") + current_turn_player = self._get_current_player() if not current_turn_player or current_turn_player.id != player_id: curr_name = current_turn_player.name if current_turn_player else "Nobody" @@ -1568,7 +1674,7 @@ class GameEngine: if len(self.players) < 2: return GameConclusion(concluded=False) - living_players = [p for p in self.players.values() if p.is_alive] + living_entities = [p for p in self.players.values() if p.is_alive] total_bots = len(self.players) rankings = sorted( self.players.values(), @@ -1576,46 +1682,70 @@ class GameEngine: reverse=True, ) - # Case 1: All bots died - if len(living_players) == 0: + # Case 0: All bots died + if len(living_entities) == 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] + # End Condition 1: Exactly 1 living entity remains (player or troll) + if len(living_entities) == 1: + survivor = living_entities[0] party = self.parties.get(survivor.party_id) if survivor.party_id else None + is_survivor_troll = survivor.character_type == "troll" + default_name = f"Troll {survivor.name}" if is_survivor_troll else f"Squad {survivor.name}" 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_party_name=party.name if party else default_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( - concluded=True, - winning_party_id=only_party.id, - winning_party_name=only_party.name, - winning_leader_id=only_party.leader_id, - winning_leader_name=only_party.leader_name, - total_bots=total_bots, - rankings=rankings, - ) + living_trolls = [p for p in living_entities if p.character_type == "troll"] + living_players = [p for p in living_entities if p.character_type != "troll"] + + # End Condition 2: Only trolls remain (all players defeated) + # Since trolls do not band together or battle each other, the game concludes. + if len(living_players) == 0 and len(living_trolls) > 0: + surviving_trolls = sorted( + living_trolls, + key=lambda p: (p.score, p.strength, p.name), + reverse=True, + ) + top_troll = surviving_trolls[0] + return GameConclusion( + concluded=True, + winning_party_id=None, + winning_party_name=f"Troll {top_troll.name}", + winning_leader_id=top_troll.id, + winning_leader_name=top_troll.name, + total_bots=total_bots, + rankings=rankings, + ) + + # End Condition 3: All players have joined into one party (even if trolls remain) + if len(living_players) >= 2: + first_party_id = living_players[0].party_id + if first_party_id and all(p.party_id == first_party_id for p in living_players): + party = self.parties.get(first_party_id) + if party: + leader = self.players.get(party.leader_id) + leader_id = leader.id if (leader and leader.is_alive) else living_players[0].id + leader_name = leader.name if (leader and leader.is_alive) else living_players[0].name + return GameConclusion( + concluded=True, + winning_party_id=party.id, + winning_party_name=party.name, + winning_leader_id=leader_id, + winning_leader_name=leader_name, + total_bots=total_bots, + rankings=rankings, + ) return GameConclusion(concluded=False) @@ -1636,6 +1766,48 @@ class GameEngine: if not self._are_adjacent(player, other): continue + # Case Trolls Encounter Logic: + # 1. Trolls do not band together or battle each other + if player.character_type == "troll" and other.character_type == "troll": + continue + + # 2. Troll vs Player (or Player Party): MANDATORY BATTLE! + if player.character_type == "troll" or other.character_type == "troll": + p1_party = self.parties.get(player.party_id) if player.party_id else None + if not p1_party: + temp_p1_id = f"party_{uuid.uuid4().hex[:8]}" + p1_prefix = "Troll" if player.character_type == "troll" else "Squad" + p1_party = Party( + id=temp_p1_id, + name=f"{p1_prefix} {player.name}", + leader_id=player.id, + leader_name=player.name, + member_ids=[player.id], + total_strength=player.strength, + ) + player.party_id = temp_p1_id + player.is_party_leader = True + self.parties[temp_p1_id] = p1_party + + p2_party = self.parties.get(other.party_id) if other.party_id else None + if not p2_party: + temp_p2_id = f"party_{uuid.uuid4().hex[:8]}" + p2_prefix = "Troll" if other.character_type == "troll" else "Squad" + p2_party = Party( + id=temp_p2_id, + name=f"{p2_prefix} {other.name}", + leader_id=other.id, + leader_name=other.name, + member_ids=[other.id], + total_strength=other.strength, + ) + other.party_id = temp_p2_id + other.is_party_leader = True + self.parties[temp_p2_id] = p2_party + + battle_res = self._resolve_3bout_battle_internal(p1_party, p2_party) + return None, battle_res + # Case A: Solo + Solo encounter if not player.party_id and not other.party_id: leader_id = self._negotiate_party_leader_id(player, other) @@ -1847,6 +2019,41 @@ class GameEngine: self._advance_turn() return self._get_turn_info() + async def sleep_player(self, player_id: str) -> SleepResponse: + async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + if not player.is_alive or player.health <= 0: + raise ValueError(f"Player '{player.name}' is dead and cannot act.") + if player.character_type != "troll": + raise ValueError("Only trolls can take the sleep action.") + + 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 to sleep. Current turn belongs to '{curr_name}' ({curr_id})." + ) + + # Troll misses a turn and gains 0.1 health + player.health = round(player.health + 0.1, 1) + player.max_health = max(player.max_health, player.health) + + self._advance_turn() + turn_info = self._get_turn_info() + + return SleepResponse( + success=True, + player=player, + health_gained=0.1, + new_health=player.health, + turn=turn_info, + ) + async def step_bot_ai(self, player_id: str) -> AiStepResponse: async with self._lock: if not self.game_started: @@ -1865,6 +2072,163 @@ class GameEngine: f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." ) + if player.character_type == "troll": + bot_goal = "hunt_players" + + # 1. Check if already adjacent to encounter before moving + formed_party, battle_res = self._check_adjacent_encounter(player) + if battle_res: + self._advance_turn() + conclusion = self._check_game_concluded() + return AiStepResponse( + action_taken="battled", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=battle_res, + game_concluded=conclusion if conclusion.concluded else None, + turn=self._get_turn_info(), + ) + + # 2. Check if troll chooses to sleep: + if player.health < 6.0: + player.health = round(player.health + 0.1, 1) + player.max_health = max(player.max_health, player.health) + self._advance_turn() + turn_info = self._get_turn_info() + conclusion = self._check_game_concluded() + sleep_res = SleepResponse( + success=True, + player=player, + health_gained=0.1, + new_health=player.health, + turn=turn_info, + ) + return AiStepResponse( + action_taken="slept", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=None, + sleep_result=sleep_res, + game_concluded=conclusion if conclusion.concluded else None, + turn=turn_info, + ) + + # 3. Troll navigates towards nearest player + occupied = self._get_occupied_coordinates() + moves_map: Dict[str, MoveCheckResult] = {} + for name, dx, dy in STANDARD_DIRECTIONS: + moves_map[name] = self._check_move_internal(player, dx, dy, name, occupied) + + available_dirs = [name for name, chk in moves_map.items() if chk.available] + if not available_dirs: + self._advance_turn() + conclusion = self._check_game_concluded() + return AiStepResponse( + action_taken="passed", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=None, + game_concluded=conclusion if conclusion.concluded else None, + turn=self._get_turn_info(), + ) + + living_players = [ + (max(abs(player.x - other.x), abs(player.y - other.y)), other) + for other in self.players.values() + if other.id != player.id and other.is_alive and other.health > 0 and other.character_type != "troll" + ] + living_players.sort(key=lambda t: t[0]) + target_bot = living_players[0][1] if living_players else None + + bfs_next_step: Optional[Tuple[int, int]] = None + if target_bot: + path = self._find_path_bfs((player.x, player.y), (target_bot.x, target_bot.y)) + if path and len(path) >= 2: + bfs_next_step = (path[1][0] - player.x, path[1][1] - player.y) + + visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations} + best_dir = available_dirs[0] + best_score = float("-inf") + + for dir_name in available_dirs: + dx, dy = DIRECTION_OFFSETS[dir_name] + tx = player.x + dx + ty = player.y + dy + score = 0.0 + + if bfs_next_step and (dx, dy) == bfs_next_step: + score += 50.0 + + if target_bot: + old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y)) + new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y)) + score += (old_dist - new_dist) * 10.0 + + if (tx, ty) not in visited_set: + score += 2.0 + + if score > best_score: + best_score = score + best_dir = dir_name + + dx, dy = DIRECTION_OFFSETS[best_dir] + prev_pos = {"x": player.x, "y": player.y} + player.x += dx + player.y += dy + player.visited_locations.append({"x": player.x, "y": player.y}) + affected_players = [player] + new_pos = {"x": player.x, "y": player.y} + + formed_party, battle_result = self._check_adjacent_encounter(player) + self._advance_turn() + turn_info = self._get_turn_info() + conclusion = self._check_game_concluded() + + move_res = MoveResponse( + success=True, + player=player, + direction=best_dir, + party_moved=False, + affected_players=affected_players, + previous_position=prev_pos, + new_position=new_pos, + party_formed_triggered=False, + formed_party=None, + battle_triggered=bool(battle_result), + battle_result=battle_result, + game_concluded=conclusion if conclusion.concluded else None, + turn=turn_info, + ) + + action_name = "moved" + if battle_result: + action_name = "battled" + + return AiStepResponse( + action_taken=action_name, + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=best_dir, + move_result=move_res, + formed_party=None, + battle_result=battle_result, + game_concluded=conclusion if conclusion.concluded else None, + turn=turn_info, + ) + seeking_wizard = bool(player.health < 2 and self.wizard) bot_goal = ( "seek_wizard" diff --git a/backend/app/models.py b/backend/app/models.py index 68728a6..0098f30 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,8 +119,9 @@ 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'") + health: Optional[float] = Field(default=10.0, ge=0.1, description="Starting health points (default is 10)") + piece_type: Optional[str] = Field(default=None, description="Board game piece class: 'knight', 'warrior', or 'troll'") + character_type: Optional[str] = Field(default="player", description="Character type: 'player' or 'troll'") @field_validator("name") @classmethod @@ -149,9 +150,10 @@ class Player(BaseModel): y: int strength: float = 1.0 score: int = 0 - health: int = 10 - max_health: int = 10 + health: float = 10.0 + max_health: float = 10.0 piece_type: Optional[str] = "knight" + character_type: str = "player" # "player" or "troll" party_id: Optional[str] = None is_party_leader: bool = False is_alive: bool = True @@ -427,16 +429,25 @@ class MoveResponse(BaseModel): turn: TurnInfo +class SleepResponse(BaseModel): + success: bool = True + player: Player + health_gained: float = 0.1 + new_health: float + turn: TurnInfo + + class AiStepResponse(BaseModel): - action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed" + action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed", "slept" player_id: str player_name: str - bot_goal: str # "form_party" or "find_and_defeat_all_parties" + bot_goal: str # "form_party", "find_and_defeat_all_parties", "hunt_players" direction: Optional[str] = None move_result: Optional[MoveResponse] = None formed_party: Optional[Party] = None battle_result: Optional[BattleResult] = None wizard_challenge_result: Optional[WizardChallengeResult] = None + sleep_result: Optional[SleepResponse] = None game_concluded: Optional[GameConclusion] = None turn: TurnInfo diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 20c6f55..ab9fffe 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -854,3 +854,288 @@ def test_step_bot_ai_seeks_and_challenges_wizard_when_low_health(): step2_data = step2_res.json() assert step2_data["action_taken"] == "challenged_wizard" assert step2_data["wizard_challenge_result"] is not None + + +def test_troll_registration_and_attributes(): + client = TestClient(app) + client.post("/api/reset") + + troll_res = client.post("/api/players", json={ + "name": "GorgTroll", + "color": "#16a34a", + "strength": 5, + "health": 12, + "character_type": "troll", + }) + assert troll_res.status_code == 201 + troll = troll_res.json() + assert troll["name"] == "GorgTroll" + assert troll["character_type"] == "troll" + assert troll["piece_type"] == "troll" + assert troll["health"] == 12.0 + assert troll["strength"] == 5.0 + assert troll["party_id"] is None + assert troll["is_party_leader"] is False + + +def test_trolls_do_not_band_together_or_battle_each_other(): + client = TestClient(app) + client.post("/api/reset") + + t1 = client.post("/api/players", json={"name": "Troll1", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json() + t2 = client.post("/api/players", json={"name": "Troll2", "color": "#15803d", "strength": 4, "character_type": "troll"}).json() + + # Place trolls adjacent + async def place_trolls(): + b1 = await game_engine.get_player(t1["id"]) + b2 = await game_engine.get_player(t2["id"]) + b1.x, b1.y = 10, 10 + b2.x, b2.y = 10, 11 + asyncio.run(place_trolls()) + + client.post("/api/game/start") + + # 1. Attempting direct party formation between trolls must fail + party_res = client.post("/api/parties", json={"member_ids": [t1["id"], t2["id"]], "leader_id": t1["id"]}) + assert party_res.status_code == 400 + assert "Trolls do not band together" in party_res.json()["detail"] + + # 2. Attempting invite between trolls must fail + inv_res = client.post("/api/parties/invite", json={ + "inviter_id": t1["id"], + "invitee_id": t2["id"], + "proposed_leader_id": t1["id"], + }) + assert inv_res.status_code == 400 + assert "Trolls do not band together" in inv_res.json()["detail"] + + # 3. Attempting battle between trolls must fail + fight_res = client.post("/api/battles/fight", json={"challenger_id": t1["id"], "defender_id": t2["id"]}) + assert fight_res.status_code == 400 + assert "Trolls do not battle each other" in fight_res.json()["detail"] + + # 4. Moving troll next to another troll should NOT trigger encounter or battle + game_engine.turn_order = [t1["id"], t2["id"]] + game_engine.current_turn_index = 0 + + move_res = client.post(f"/api/players/{t1['id']}/move", json={"direction": "RIGHT"}) + assert move_res.status_code == 200 + data = move_res.json() + assert data["party_formed_triggered"] is False + assert data["battle_triggered"] is False + + +def test_troll_vs_player_mandatory_battle_and_mechanics(): + client = TestClient(app) + client.post("/api/reset") + + troll = client.post("/api/players", json={"name": "BruteTroll", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json() + player = client.post("/api/players", json={"name": "BraveKnight", "color": "#38bdf8", "strength": 3, "character_type": "player"}).json() + + # Place them 1 tile apart: player at (10, 10), troll at (10, 12) + async def place_combatants(): + t = await game_engine.get_player(troll["id"]) + p = await game_engine.get_player(player["id"]) + t.x, t.y = 10, 12 + p.x, p.y = 10, 10 + asyncio.run(place_combatants()) + + client.post("/api/game/start") + game_engine.turn_order = [troll["id"], player["id"]] + game_engine.current_turn_index = 0 + + initial_troll_str = troll["strength"] + + # Troll moves UP to (10, 11) adjacent to player at (10, 10) -> MANDATORY BATTLE! + move_res = client.post(f"/api/players/{troll['id']}/move", json={"direction": "UP"}) + assert move_res.status_code == 200 + data = move_res.json() + assert data["battle_triggered"] is True + assert data["battle_result"] is not None + b_res = data["battle_result"] + + # Verify: Trolls do NOT absorb members + assert len(b_res["absorbed_members"]) == 0 + + # Verify: Troll does not remain in any party after battle + t_after = client.get(f"/api/players/{troll['id']}").json() + assert t_after["party_id"] is None + assert t_after["is_party_leader"] is False + # Troll did not gain strength + assert t_after["strength"] == initial_troll_str + + +def test_troll_cannot_challenge_wizard(): + client = TestClient(app) + client.post("/api/reset") + + troll = client.post("/api/players", json={"name": "CaveTroll", "color": "#16a34a", "strength": 6, "character_type": "troll"}).json() + wiz = client.get("/api/wizard").json() + + async def place_troll_at_wizard(): + t = await game_engine.get_player(troll["id"]) + t.x, t.y = wiz["x"] + 1, wiz["y"] + asyncio.run(place_troll_at_wizard()) + + client.post("/api/game/start") + game_engine.turn_order = [troll["id"]] + game_engine.current_turn_index = 0 + + # Radar can_challenge must be False for wizard + radar = client.get(f"/api/players/{troll['id']}/radar").json() + assert radar["wizard"]["can_challenge"] is False + assert radar["recommended_action"] != "challenge_wizard" + assert radar["recommended_action"] != "seek_wizard" + + # Attempting to challenge Gary directly must be rejected + chal_res = client.post("/api/wizard/challenge", json={"player_id": troll["id"], "reward_choice": "score"}) + assert chal_res.status_code == 400 + assert "Trolls cannot engage with Gary the Wizard" in chal_res.json()["detail"] + + +def test_troll_sleep_action_and_health_regen(): + client = TestClient(app) + client.post("/api/reset") + + troll = client.post("/api/players", json={"name": "SleepyTroll", "color": "#16a34a", "strength": 4, "health": 8, "character_type": "troll"}).json() + player = client.post("/api/players", json={"name": "AwakePlayer", "color": "#38bdf8", "strength": 2, "character_type": "player"}).json() + + client.post("/api/game/start") + game_engine.turn_order = [troll["id"], player["id"]] + game_engine.current_turn_index = 0 + + # 1. Troll sleeps: gains 0.1 health and advances turn + sleep_res = client.post(f"/api/players/{troll['id']}/sleep") + assert sleep_res.status_code == 200 + s_data = sleep_res.json() + assert s_data["success"] is True + assert s_data["health_gained"] == 0.1 + assert s_data["new_health"] == 8.1 + assert s_data["player"]["health"] == 8.1 + # Turn advanced to player + assert s_data["turn"]["current_player_id"] == player["id"] + + # 2. Player cannot sleep (it is player's turn now) + player_sleep = client.post(f"/api/players/{player['id']}/sleep") + assert player_sleep.status_code == 400 + assert "Only trolls can take the sleep action" in player_sleep.json()["detail"] + + +def test_troll_game_conclusion_and_rankings(): + client = TestClient(app) + client.post("/api/reset") + + troll1 = client.post("/api/players", json={"name": "TrollKing", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json() + troll2 = client.post("/api/players", json={"name": "TrollGuard", "color": "#15803d", "strength": 3, "character_type": "troll"}).json() + + # Give TrollKing higher score + async def set_troll_score(): + tk = await game_engine.get_player(troll1["id"]) + tk.score = 6 + asyncio.run(set_troll_score()) + + # Only trolls remain: game concludes and troll can win! + conc = client.get("/api/game/conclusion").json() + assert conc["concluded"] is True + assert conc["winning_leader_id"] == troll1["id"] + assert conc["winning_leader_name"] == "TrollKing" + assert conc["rankings"][0]["name"] == "TrollKing" + assert conc["rankings"][0]["score"] == 6 + + +def test_win_condition_all_players_joined_one_party_with_trolls_remaining(): + """When all players have joined one party, even if there are trolls remaining, that is a win condition.""" + client = TestClient(app) + client.post("/api/reset") + + p1 = client.post("/api/players", json={"name": "PlayerAlpha", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json() + p2 = client.post("/api/players", json={"name": "PlayerBeta", "color": "#8b5cf6", "strength": 3, "character_type": "player"}).json() + t1 = client.post("/api/players", json={"name": "TrollBrute", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json() + t2 = client.post("/api/players", json={"name": "TrollGorgon", "color": "#15803d", "strength": 4, "character_type": "troll"}).json() + + # Set up adjacent positions + async def setup_positions(): + b1 = await game_engine.get_player(p1["id"]) + b1.x, b1.y = 10, 10 + b2 = await game_engine.get_player(p2["id"]) + b2.x, b2.y = 10, 11 + bt1 = await game_engine.get_player(t1["id"]) + bt1.x, bt1.y = 25, 25 + bt2 = await game_engine.get_player(t2["id"]) + bt2.x, bt2.y = 30, 30 + asyncio.run(setup_positions()) + + client.post("/api/game/start") + + # Before players unite, game is not concluded + conc_before = client.get("/api/game/conclusion").json() + assert conc_before["concluded"] is False + + # All living players (p1, p2) join into one party, while trolls t1, t2 remain on the board + party_res = client.post("/api/parties", json={ + "member_ids": [p1["id"], p2["id"]], + "leader_id": p1["id"], + "name": "UnitedPlayers", + }) + assert party_res.status_code == 201 + + # Trolls remain alive, but all players joined 1 party -> WIN CONDITION! Game concludes! + conc_after = client.get("/api/game/conclusion").json() + assert conc_after["concluded"] is True + assert conc_after["winning_party_name"] == "UnitedPlayers" + assert conc_after["winning_leader_id"] == p1["id"] + assert conc_after["winning_leader_name"] == "PlayerAlpha" + assert conc_after["total_bots"] == 4 + + +def test_game_does_not_conclude_with_one_solo_player_and_multiple_trolls_until_elimination(): + """1 solo player and trolls alive should continue fighting until 1 entity remains or only trolls remain.""" + client = TestClient(app) + client.post("/api/reset") + + p1 = client.post("/api/players", json={"name": "SoloHero", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json() + t1 = client.post("/api/players", json={"name": "Troll1", "color": "#16a34a", "strength": 3, "character_type": "troll"}).json() + t2 = client.post("/api/players", json={"name": "Troll2", "color": "#15803d", "strength": 3, "character_type": "troll"}).json() + + client.post("/api/game/start") + + # 1 player and 2 trolls: total 3 entities, player is not in a party. Game must not be concluded yet! + conc = client.get("/api/game/conclusion").json() + assert conc["concluded"] is False + + # Troll defeats player: player dies + async def kill_player(): + hero = await game_engine.get_player(p1["id"]) + hero.health = 0 + game_engine._check_and_apply_death(hero) + asyncio.run(kill_player()) + + # Now only trolls remain (all players defeated) -> Game concludes! + conc_after_death = client.get("/api/game/conclusion").json() + assert conc_after_death["concluded"] is True + assert conc_after_death["winning_leader_name"] in ["Troll1", "Troll2"] + + +def test_game_concludes_when_single_entity_remains(): + """When only 1 entity (player or troll) remains, game concludes.""" + client = TestClient(app) + client.post("/api/reset") + + p1 = client.post("/api/players", json={"name": "LoneSurvivor", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json() + t1 = client.post("/api/players", json={"name": "FallenTroll", "color": "#16a34a", "strength": 3, "character_type": "troll"}).json() + + client.post("/api/game/start") + + # Kill troll + async def kill_troll(): + tr = await game_engine.get_player(t1["id"]) + tr.health = 0 + game_engine._check_and_apply_death(tr) + asyncio.run(kill_troll()) + + # Exactly 1 entity remains + conc = client.get("/api/game/conclusion").json() + assert conc["concluded"] is True + assert conc["winning_leader_id"] == p1["id"] + assert conc["winning_leader_name"] == "LoneSurvivor" + diff --git a/botagent/requirements.txt b/botagent/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/botagent/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d390ae3..822a720 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,15 +12,23 @@ import { WizardPromptModal } from './components/WizardPromptModal'; import { ScoreboardModal } from './components/ScoreboardModal'; import type { WizardRewardChoice } from './types'; -const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ - { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' }, - { name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' }, - { name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' }, - { name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' }, - { name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' }, - { name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior' }, - { name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight' }, - { name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior' }, +const BOT_PRESETS: { + name: string; + color: string; + strength: number; + piece_type: 'knight' | 'warrior' | 'troll'; + character_type?: 'player' | 'troll'; +}[] = [ + { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight', character_type: 'player' }, + { name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior', character_type: 'player' }, + { name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight', character_type: 'player' }, + { name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior', character_type: 'player' }, + { name: 'MountainTroll', color: '#16a34a', strength: 3, piece_type: 'troll', character_type: 'troll' }, + { name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight', character_type: 'player' }, + { name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior', character_type: 'player' }, + { name: 'CaveTroll', color: '#059669', strength: 2, piece_type: 'troll', character_type: 'troll' }, + { name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight', character_type: 'player' }, + { name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior', character_type: 'player' }, ]; export function App() { @@ -46,6 +54,7 @@ export function App() { fightBattle, movePlayer, passTurn, + sleepPlayer, challengeWizard, stepActiveBotTurn, startGame, @@ -99,19 +108,28 @@ export function App() { const handleQuickSpawn = async () => { const existingNames = new Set(boardState.players.map((p) => p.name)); const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name)); - const randomPiece: 'knight' | 'warrior' = Math.random() > 0.5 ? 'knight' : 'warrior'; + const roll = Math.random(); + const randomPiece: 'knight' | 'warrior' | 'troll' = roll < 0.25 ? 'troll' : roll < 0.62 ? 'knight' : 'warrior'; const preset = availablePresets.length > 0 ? availablePresets[Math.floor(Math.random() * availablePresets.length)] : { - name: `${randomPiece === 'knight' ? 'Knight' : 'Warrior'}_${Math.floor(Math.random() * 1000)}`, + name: `${randomPiece === 'knight' ? 'Knight' : randomPiece === 'warrior' ? 'Warrior' : 'Troll'}_${Math.floor(Math.random() * 1000)}`, color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, strength: Math.floor(Math.random() * 3) + 1, piece_type: randomPiece, + character_type: (randomPiece === 'troll' ? 'troll' : 'player') as 'player' | 'troll', }; try { - const player = await registerPlayer(preset.name, preset.color, preset.strength, preset.piece_type); + const player = await registerPlayer( + preset.name, + preset.color, + preset.strength, + preset.piece_type, + 10, + preset.character_type + ); showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`); } catch (err: unknown) { if (err instanceof Error) { @@ -208,6 +226,9 @@ export function App() { onPass={async (id) => { await passTurn(id); }} + onSleep={async (id) => { + await sleepPlayer(id); + }} onChallengeWizard={handleOpenWizardPrompt} onStepBot={stepActiveBotTurn} isAutoPlaying={isAutoPlaying} @@ -239,8 +260,8 @@ export function App() { setIsRegisterOpen(false)} - onRegister={async (name, color, pieceType, health) => { - const player = await registerPlayer(name, color, 1, pieceType, health); + onRegister={async (name, color, pieceType, health, characterType) => { + const player = await registerPlayer(name, color, 1, pieceType, health, characterType); showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`); }} /> diff --git a/frontend/src/components/MovementControls.tsx b/frontend/src/components/MovementControls.tsx index 7dcb773..52729e6 100644 --- a/frontend/src/components/MovementControls.tsx +++ b/frontend/src/components/MovementControls.tsx @@ -8,6 +8,7 @@ interface MovementControlsProps { availableMoves: AvailableMovesResponse | null; onMove: (playerId: string, direction: string) => Promise; onPass: (playerId: string) => Promise; + onSleep?: (playerId: string) => Promise; onChallengeWizard?: (playerId: string) => Promise; onStepBot: () => void; isAutoPlaying: boolean; @@ -20,6 +21,7 @@ export const MovementControls: React.FC = ({ availableMoves, onMove, onPass, + onSleep, onChallengeWizard, onStepBot, isAutoPlaying, @@ -34,6 +36,7 @@ export const MovementControls: React.FC = ({ : null; const controlledPlayer = liveSelectedPlayer || activePlayer; const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer)); + const isTroll = controlledPlayer?.character_type === 'troll'; const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); const isAdjacentToWizard = Boolean( @@ -45,7 +48,14 @@ export const MovementControls: React.FC = ({ Math.abs(controlledPlayer.y - boardState.wizard.y) ) <= 1 ); - const canInitiateWizardChallenge = !controlledPlayer?.party_id || controlledPlayer?.is_party_leader; + const canInitiateWizardChallenge = !isTroll && (!controlledPlayer?.party_id || controlledPlayer?.is_party_leader); + + const handleSleepClick = useCallback(() => { + if (!isStarted || !controlledPlayer || !isMyTurn || !isTroll) return; + onSleep?.(controlledPlayer.id).catch((err: unknown) => { + if (err instanceof Error) alert(err.message); + }); + }, [isStarted, controlledPlayer, isMyTurn, isTroll, onSleep]); const handleChallengeWizardClick = useCallback(() => { if (!controlledPlayer) return; @@ -149,12 +159,19 @@ export const MovementControls: React.FC = ({ e.preventDefault(); handlePassClick(); break; + case 'r': + case 'R': + if (isTroll) { + e.preventDefault(); + handleSleepClick(); + } + break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]); + }, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]); if (boardState.players.length === 0) { return null; @@ -279,8 +296,31 @@ export const MovementControls: React.FC = ({ - {/* Challenge Wizard Button if adjacent */} - {isAdjacentToWizard && onChallengeWizard && ( + {/* Troll Sleep Action */} + {isTroll && onSleep && ( + + )} + + {/* Challenge Wizard Button if adjacent (Players/Leaders only) */} + {isAdjacentToWizard && onChallengeWizard && !isTroll && ( + + diff --git a/frontend/src/components/ScoreboardModal.tsx b/frontend/src/components/ScoreboardModal.tsx index ee739ba..4888418 100644 --- a/frontend/src/components/ScoreboardModal.tsx +++ b/frontend/src/components/ScoreboardModal.tsx @@ -184,10 +184,10 @@ export const ScoreboardModal: React.FC = ({
🏆
-

VICTORY! ALL BOTS UNITED

+

VICTORY! ARENA CONCLUDED

- The game has concluded! Every bot has united into the supreme party{' '} - "{conclusion.winning_party_name || 'Dominant Squad'}"! + The game has concluded! Champion: {conclusion.winning_leader_name} ( + "{conclusion.winning_party_name || 'Lone Survivor'}")!

@@ -343,6 +343,7 @@ export const ScoreboardModal: React.FC = ({ } const isLeader = !isDead && player.id === conclusion.winning_leader_id; + const isTroll = player.character_type === 'troll'; return ( @@ -357,18 +358,21 @@ export const ScoreboardModal: React.FC = ({ /> {player.name} {isDead && 💀 Fallen} + {isTroll && !isDead && 👹 Troll} {isLeader && 👑 Leader} {isDead ? ( Fallen (Deceased) + ) : isTroll ? ( + Lone Troll Hunter ) : isLeader ? ( 'Supreme Leader' ) : ( 'Squad Member' )} - ⚡ {player.strength} + ⚡ {Number(player.strength.toFixed(1))} 🗺️ {player.visited_locations?.length || 1} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index ab28c74..37bc145 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -9,6 +9,7 @@ import type { Party, PartyDefeatResult, Player, + SleepResponse, TurnInfo, WizardChallengeResult, WizardRewardChoice, @@ -257,6 +258,14 @@ export function useGameSocket() { ...prev, turn: data.turn ?? prev.turn, })); + } else if (data.event === 'player_slept') { + setBoardState((prev) => ({ + ...prev, + players: prev.players.map((p) => (p.id === data.player?.id ? data.player : p)), + turn: data.turn ?? prev.turn, + })); + setSelectedPlayer((curr) => (curr?.id === data.player?.id ? data.player : curr)); + setLastEventMessage(`💤 ${data.player?.name || 'Troll'} slept to recover health (+0.1 HP -> ${data.sleep_result?.new_health ?? data.player?.health} HP)!`); } else if (data.event === 'player_left') { setBoardState((prev) => { const nextPlayers = prev.players.filter((p) => p.id !== data.player_id); @@ -323,12 +332,13 @@ export function useGameSocket() { color: string, strength: number = 1, piece_type?: string, - health: number = 10 + health: number = 10, + character_type?: 'player' | 'troll' ): Promise => { const res = await fetch('/api/players', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, color, strength, piece_type, health }), + body: JSON.stringify({ name, color, strength, piece_type, health, character_type }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -338,6 +348,21 @@ export function useGameSocket() { return player; }; + const sleepPlayer = async (playerId: string): Promise => { + const res = await fetch(`/api/players/${playerId}/sleep`, { + method: 'POST', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Failed to sleep'); + } + const data: SleepResponse = await res.json(); + if (data.player) { + setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr)); + } + return data; + }; + const removePlayer = async (playerId: string): Promise => { const res = await fetch(`/api/players/${playerId}`, { method: 'DELETE', @@ -501,6 +526,8 @@ export function useGameSocket() { ? `🧙 ${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.action_taken === 'slept' && data.sleep_result) { + setLastEventMessage(`💤 ${data.player_name} took a restful sleep (+${data.sleep_result.health_gained} HP -> ${data.sleep_result.new_health} HP)!`); } else if (data.move_result?.battle_result) { setActiveBattle(data.move_result.battle_result); } @@ -555,6 +582,7 @@ export function useGameSocket() { fightBattle, movePlayer, passTurn, + sleepPlayer, challengeWizard, stepActiveBotTurn, startGame, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9591949..6cfddbe 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -24,7 +24,8 @@ export interface Player { health?: number; max_health?: number; is_alive?: boolean; - piece_type?: 'knight' | 'warrior'; + piece_type?: 'knight' | 'warrior' | 'troll' | 'wizard' | 'gravestone'; + character_type?: 'player' | 'troll'; party_id?: string | null; is_party_leader: boolean; visited_locations?: { x: number; y: number }[]; @@ -255,6 +256,14 @@ export interface MoveResponse { turn: TurnInfo; } +export interface SleepResponse { + success: boolean; + player: Player; + health_gained: number; + new_health: number; + turn: TurnInfo; +} + export interface AiStepResponse { action_taken: string; player_id: string; @@ -265,6 +274,7 @@ export interface AiStepResponse { formed_party?: Party | null; battle_result?: BattleResult | null; wizard_challenge_result?: WizardChallengeResult | null; + sleep_result?: SleepResponse | null; game_concluded?: GameConclusion | null; turn: TurnInfo; } diff --git a/frontend/src/utils/pixelAvatars.tsx b/frontend/src/utils/pixelAvatars.tsx index 24c6303..619c96a 100644 --- a/frontend/src/utils/pixelAvatars.tsx +++ b/frontend/src/utils/pixelAvatars.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Player } from '../types'; -export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone'; +export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone' | 'troll'; // Hex color parser and manipulator function parseHex(hex: string): [number, number, number] { @@ -169,6 +169,25 @@ const GRAVESTONE_SPRITE: string[] = [ '..____________..', // Row 15: Drop shadow ]; +const TROLL_SPRITE: string[] = [ + '...........Mm...', // Row 0: Spiked club head spike tip + '..........HMmH..', // Row 1: Spiked wooden club head + '...KK...KK..HH..', // Row 2: Heavy sloping brute shoulders & ears + '..KCCCCCCCK.Hh..', // Row 3: Brute horned brow + '..KLDCRRCLLK.Hh.', // Row 4: Glowing red eyes & heavy brow + club haft + '..KDCWWCCDDK.Hh.', // Row 5: Snarling mouth & white upward tusks + club haft + '...KCCCCCCK..HH.', // Row 6: Thick chin / neck + club grip + '..KLDCCCCDLK....', // Row 7: Massive hunched torso in faction color + '..KLCCCCCCLK....', // Row 8: Brute torso & heavy fists + '...KSDHHDSSK....', // Row 9: Spiked iron belt & hide loincloth + '...KLL..LLK.....', // Row 10: Thick brute legs + '..KDD....DDK....', // Row 11: Heavy clawed combat feet + '...KKKKKKKKK....', // Row 12: Pedestal top bevel + '..KBBBBBBBBBBK..', // Row 13: Pedestal stone base + '.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim + '..____________..', // Row 15: Miniature drop shadow +]; + // Death status helper export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean { if (player.is_alive === false) return true; @@ -212,6 +231,7 @@ export function getPlayerPieceType(player: { name: string; id?: string; piece_type?: string; + character_type?: string; is_party_leader?: boolean; party_id?: string | null; is_alive?: boolean; @@ -221,6 +241,10 @@ export function getPlayerPieceType(player: { if (isPlayerDead(player)) { return 'gravestone'; } + // Trolls are always troll miniatures + if (player.character_type === 'troll' || player.piece_type === 'troll') { + return 'troll'; + } // Party leaders are always Knights commanding the squad if (player.is_party_leader) { return 'knight'; @@ -235,6 +259,9 @@ export function getPlayerPieceType(player: { } // Check name indicators const lower = player.name.toLowerCase(); + if (lower.includes('troll')) { + return 'troll'; + } if ( lower.includes('knight') || lower.includes('tank') || @@ -293,6 +320,8 @@ export function getSpriteCanvas( spriteMatrix = WIZARD_SPRITE; } else if (pieceType === 'gravestone') { spriteMatrix = GRAVESTONE_SPRITE; + } else if (pieceType === 'troll') { + spriteMatrix = TROLL_SPRITE; } else { spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; } @@ -414,10 +443,12 @@ export function drawPlayerPiece( ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const roleIcon = dead ? '🪦' : isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; + const isTroll = player.character_type === 'troll' || pieceType === 'troll'; + const roleIcon = dead ? '🪦' : isLeader ? '👑' : isTroll ? '👹' : pieceType === 'knight' ? '⚔️' : '🪓'; + const hpStr = player.health !== undefined ? ` ❤️${Number(player.health.toFixed(1))}` : ''; const text = dead ? `🪦 ${player.name} [DEAD]` - : `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${player.health !== undefined ? ` ❤️${player.health}` : ''}]`; + : `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`; const textMetrics = ctx.measureText(text); const bgWidth = textMetrics.width + 12; const bgHeight = 16; diff --git a/trollagent/README.md b/trollagent/README.md new file mode 100644 index 0000000..f124200 --- /dev/null +++ b/trollagent/README.md @@ -0,0 +1,50 @@ +# trollagent - Autonomous Heuristic Troll Bot + +`trollagent` is an autonomous heuristic troll agent designed for **botWebWars**. Unlike players who form alliances and battle for squad dominance, a troll is an isolated brute driven solely to hunt down and attack players. + +--- + +## 👹 Troll Rules of Engagement + +1. **No Alliances**: Trolls never band together into parties. They remain solitary hunters throughout the match. +2. **Troll Truce**: Trolls do not battle each other. When meeting another troll, they ignore each other. +3. **Player Hunting**: Trolls scan the grid with radar, locate players and player squads, and close in for mandatory 3-bout D20 tactical combat. +4. **No Strength Gain**: When a troll wins a battle, it receives **+2 victory points (score)**, but does not gain strength or absorb followers. +5. **Damage & Death**: Defeated trolls suffer 1 to 3 health points of damage. At 0 HP, a troll dies and is marked on the board with a gravestone. Dead trolls can win the final rankings based on their achieved score. +6. **No Wizard Duels**: Trolls cannot engage Gary the Wizard. +7. **Sleep & Heal**: Trolls can choose to take turns sleeping (`POST /api/players/{id}/sleep`), skipping their turn action to regenerate **+0.1 health**. + +--- + +## 🚀 Execution & Usage + +### Prerequisites +- Python 3.10+ +- `requests` library + +### Running the Troll Agent + +```bash +# Run with default settings (Name: GorgonTroll, Color: #16a34a, Str: 3, HP: 10) +python3 trollagent/troll_agent.py + +# Custom name, color, strength, health, and sleep threshold: +python3 trollagent/troll_agent.py \ + --name IronTroll \ + --color "#059669" \ + --strength 4 \ + --health 12 \ + --sleep-threshold 7.0 \ + --url http://localhost:8000/api +``` + +### CLI Options +| Flag | Short | Default | Description | +|---|---|---|---| +| `--url` | `-u` | `http://localhost:8000/api` | botWebWars API base endpoint | +| `--name` | `-n` | `GorgonTroll` | Bot avatar name | +| `--color` | `-c` | `#16a34a` | Hex color code | +| `--strength` | `-s` | `3.0` | Initial troll strength | +| `--health` | `-H` | `10.0` | Initial troll health points | +| `--sleep-threshold` | | `6.0` | HP threshold below which troll rests to heal | +| `--loop-delay` | | `1.0` | Turn polling frequency in seconds | diff --git a/trollagent/requirements.txt b/trollagent/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/trollagent/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/trollagent/troll_agent.py b/trollagent/troll_agent.py new file mode 100644 index 0000000..dbd8693 --- /dev/null +++ b/trollagent/troll_agent.py @@ -0,0 +1,354 @@ +"""Autonomous Heuristic Troll Agent for botWebWars: +- Registers as character_type='troll' and piece_type='troll'. +- Trolls do not band together or form alliances. +- Trolls do not battle each other. +- Trolls only hunt and battle players and player squads. +- Trolls do not gain strength from victory, but earn +2 victory points (score). +- Trolls take damage (1-3 HP) upon defeat and die if HP reaches 0. +- Trolls cannot duel Gary the Wizard. +- Trolls can take a rest turn to SLEEP (POST /api/players/{id}/sleep), recovering +0.1 HP. +- Configurable via CLI arguments or environment variables. +""" + +import os +import sys +import time +import argparse +import requests +from typing import Optional, Dict, Any, List + +DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api")) +DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GorgonTroll") +DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#16a34a") +DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0")) +DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0")) +DEFAULT_SLEEP_THRESHOLD = float(os.getenv("TROLL_SLEEP_THRESHOLD", "6.0")) + + +def normalize_url(url: str) -> str: + """Ensure the API URL ends with /api without trailing slashes.""" + cleaned = url.rstrip("/") + if not cleaned.endswith("/api"): + cleaned = f"{cleaned}/api" + return cleaned + + +class SmartTrollAgent: + def __init__( + self, + name: str = DEFAULT_TROLL_NAME, + color: str = DEFAULT_TROLL_COLOR, + strength: float = DEFAULT_TROLL_STRENGTH, + health: float = DEFAULT_TROLL_HEALTH, + server_url: str = DEFAULT_SERVER_URL, + sleep_threshold: float = DEFAULT_SLEEP_THRESHOLD, + loop_delay: float = 1.0, + ): + self.name = name + self.color = color + self.strength = strength + self.health = health + self.server_url = server_url + self.base_url = normalize_url(server_url) + self.sleep_threshold = sleep_threshold + self.loop_delay = loop_delay + self.bot_id: Optional[str] = None + + def register(self): + """Register the troll avatar on the 64x64 grid or reconnect if already present.""" + try: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, " + f"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, + "health": self.health, + "piece_type": "troll", + "character_type": "troll", + } + + res = requests.post(f"{self.base_url}/players", json=payload) + if res.status_code == 400 and "already registered" in res.text: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(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 Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, " + f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})" + ) + + def refresh_status(self) -> Optional[Dict[str, Any]]: + """Update troll status (health, score, position, alive status).""" + try: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 200: + return res.json() + except Exception as e: + print(f"Status refresh error: {e}") + return None + + def sleep_and_heal(self) -> bool: + """Execute sleep turn to recover +0.1 HP.""" + try: + res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep") + if res.status_code == 200: + data = res.json() + print( + f"💤 [SLEEP] {self.name} curled up and slept for 1 turn. " + f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)" + ) + return True + else: + print(f"Sleep failed ({res.status_code}): {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + except Exception as e: + print(f"Error sleeping: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + + def pass_turn(self): + """Pass turn to next queued entity.""" + try: + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + print(f"⏩ [PASS] {self.name} passed turn.") + except Exception as e: + print(f"Error passing turn: {e}") + + 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).""" + valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")} + if not valid_moves: + return None + + def dist(chk: Dict[str, Any]) -> int: + return max(abs(chk["target_x"] - target_x), abs(chk["target_y"] - target_y)) + + # Prioritize moves without penalty, then minimum distance + return min(valid_moves.keys(), key=lambda d: (valid_moves[d].get("strength_penalty", 0.0) > 0, dist(valid_moves[d]))) + + def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]: + """Find an adjacent living player or party to attack (ignoring other trolls).""" + try: + players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("id") == self.bot_id: + continue + # Trolls ignore other trolls + if p.get("character_type") == "troll": + continue + # Ignore dead players + if p.get("is_alive") is False or p.get("health", 10) <= 0: + continue + chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y)) + if chebyshev <= 1: + return p + except Exception as e: + print(f"Error scanning adjacent players: {e}") + return None + + def attack_player(self, defender: Dict[str, Any]): + """Initiate mandatory 3-bout D20 battle against adjacent player or party.""" + target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}" + print(f"⚔️ [BATTLE CLASH] Troll {self.name} engages {target_label} in 3-bout D20 combat!") + try: + res = requests.post( + f"{self.base_url}/battles/fight", + json={"challenger_id": self.bot_id, "defender_id": defender["id"]}, + ) + if res.status_code == 200: + b = res.json() + won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name) + outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (Health Damage sustained)" + print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}") + for bout in b.get("bouts", []): + print( + f" Bout #{bout['bout_number']}: " + f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs " + f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} " + f"-> Winner: {bout['winner']}" + ) + else: + print(f"Battle initiation failed ({res.status_code}): {res.text}") + self.pass_turn() + except Exception as e: + print(f"Error executing battle: {e}") + self.pass_turn() + + def decide_and_act(self): + """Evaluate troll radar, health condition, and surroundings to execute best action.""" + my_status = self.refresh_status() + if not my_status: + self.pass_turn() + return + + my_x = my_status.get("x", 0) + my_y = my_status.get("y", 0) + my_hp = float(my_status.get("health", 10.0)) + + # Check for immediate adjacent player to attack + adjacent_player = self._find_adjacent_player(my_x, my_y) + if adjacent_player: + self.attack_player(adjacent_player) + return + + # Check radar sensor + try: + radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() + except Exception as e: + print(f"Radar failure: {e}") + radar_res = {} + + recommended_action = radar_res.get("recommended_action") + + # Sleep condition: + # 1. Radar recommends sleep (e.g. damaged and no immediate contact) + # 2. Or current health is below configured sleep threshold + if (recommended_action == "sleep" or my_hp < self.sleep_threshold) and my_hp < float(my_status.get("max_health", 10.0)): + print(f"🩸 Low health alert (HP: {my_hp:.1f} < threshold: {self.sleep_threshold:.1f}). Taking sleep turn to heal...") + self.sleep_and_heal() + return + + # Navigation: Hunt closest player + rec_dir = radar_res.get("recommended_direction") + nearest = radar_res.get("nearest_target") + + moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json() + moves = moves_res.get("moves", {}) + available = [d for d, chk in moves.items() if chk.get("available")] + + if not available: + # If completely cornered/trapped, rest and sleep instead of passing + if my_hp < float(my_status.get("max_health", 10.0)): + print("🚫 All adjacent paths blocked by terrain. Sleeping to regenerate health...") + self.sleep_and_heal() + else: + print("🚫 All adjacent paths blocked by terrain. Passing turn.") + self.pass_turn() + return + + # 1. Prefer radar BFS route direction + if rec_dir and rec_dir in available: + chosen_dir = rec_dir + # 2. Move towards nearest player target + elif nearest: + chosen_dir = self._get_best_move_towards(nearest["x"], nearest["y"], moves) or available[0] + # 3. Fallback open move + else: + chosen_dir = available[0] + + target_info = f"closest player '{nearest.get('name')}' at dist {nearest.get('distance')}" if nearest else "open frontier" + print(f"👹 [HUNT] Moving {chosen_dir} towards {target_info} (HP: {my_hp:.1f}, Str: {self.strength:.1f})") + + try: + res = requests.post( + f"{self.base_url}/players/{self.bot_id}/move", + json={"direction": chosen_dir}, + ).json() + + if res.get("battle_triggered") and res.get("battle_result"): + b = res["battle_result"] + print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})") + except Exception as e: + print(f"Error during move: {e}") + self.pass_turn() + + def run(self): + """Main game loop for autonomous troll agent.""" + self.register() + try: + while True: + # Check life status + 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 TROLL] {self.name} has fallen (0 HP)! Gravestone marked on board.") + print(f"Final Achieved Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Game ended! Winner: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + + turn_info = requests.get(f"{self.base_url}/turn").json() + if not turn_info.get("game_started", False): + # Check if bot was removed by board reset + if self.bot_id: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 404: + print("\n⚠️ [RESET] Board was regenerated. Rejoining lobby...") + self.register() + + print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True) + time.sleep(1.0) + continue + + curr_player_id = turn_info.get("current_player_id") + + if curr_player_id == self.bot_id: + print(f"\n⚡ [MY TURN] Troll {self.name}'s turn (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})") + self.decide_and_act() + + # Check for game conclusion + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!") + break + + time.sleep(self.loop_delay) + + except KeyboardInterrupt: + print(f"\n👋 Troll agent {self.name} disconnecting gracefully.") + + +def main(): + parser = argparse.ArgumentParser(description="Autonomous Heuristic Troll Agent for botWebWars") + parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL") + parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name") + parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code") + parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)") + parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)") + parser.add_argument("--sleep-threshold", type=float, default=DEFAULT_SLEEP_THRESHOLD, help="HP threshold below which troll sleeps to heal (default: 6.0)") + parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds (default: 1.0)") + + args = parser.parse_args() + + agent = SmartTrollAgent( + name=args.name, + color=args.color, + strength=args.strength, + health=args.health, + server_url=args.url, + sleep_threshold=args.sleep_threshold, + loop_delay=args.loop_delay, + ) + agent.run() + + +if __name__ == "__main__": + main() diff --git a/trollagent_ai/INSTALL.md b/trollagent_ai/INSTALL.md new file mode 100644 index 0000000..b57c140 --- /dev/null +++ b/trollagent_ai/INSTALL.md @@ -0,0 +1,30 @@ +# Installation & Setup for `trollagent_ai` + +## 1. Prerequisites +- Python 3.10+ +- An accessible [Ollama](https://ollama.com/) instance serving a reasoning model (default: `gemma4:12b` or any OpenAI-compatible Ollama model). + +## 2. Environment Setup +```bash +cd trollagent_ai +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## 3. Ollama Configuration +By default, the agent connects to: +- `OLLAMA_BASE_URL`: `http://192.168.1.220:11434` (or `http://localhost:11434` for local instance) +- `OLLAMA_MODEL`: `gemma4:12b` + +Make sure the model is pulled and running: +```bash +ollama pull gemma4:12b +# Or test with llama3 / mistral +ollama pull llama3 +``` + +## 4. Run the Bot +```bash +python3 bot.py --name BloodTroll --color "#15803d" --strength 4 --health 10 +``` diff --git a/trollagent_ai/README.md b/trollagent_ai/README.md new file mode 100644 index 0000000..0f58688 --- /dev/null +++ b/trollagent_ai/README.md @@ -0,0 +1,45 @@ +# trollagent_ai - LLM-Assisted Autonomous Troll Agent (Ollama) + +`trollagent_ai` delegates tactical reasoning to a local or remote Ollama LLM while operating under the canonical Troll mechanics of **botWebWars**. + +--- + +## 👹 Troll Rules & LLM Strategy + +- **Solitary Brute**: Never forms parties or alliances. +- **Troll Truce**: Ignores other trolls and never battles them. +- **Predatory Focus**: Relentlessly hunts and battles players and squads. +- **Healing via Sleep**: When injured or navigating complex obstacle layouts, the LLM strategically chooses whether to **rest and sleep** (+0.1 HP regeneration per turn) or push forward to ambush players. +- **Obstacle Navigation**: Evaluates available paths around mountains and forests, balancing Chebyshev distance against diagonal squeeze strength penalties. +- **No Wizard Challenges**: Gary the Wizard cannot be engaged. + +--- + +## ⚙️ Configuration & Environment Variables + +| Variable | CLI Flag | Default | Description | +|---|---|---|---| +| `TROLL_SERVER_URL` | `-u`, `--url` | `http://localhost:8000/api` | botWebWars API base endpoint | +| `TROLL_NAME` | `-n`, `--name` | `GorgonAITroll` | Troll avatar name | +| `TROLL_COLOR` | `-c`, `--color` | `#15803d` | Troll hex color | +| `TROLL_STRENGTH` | `-s`, `--strength` | `3.0` | Initial troll strength | +| `TROLL_HEALTH` | `-H`, `--health` | `10.0` | Initial health points | +| `OLLAMA_BASE_URL` | `--ollama-url` | `http://192.168.1.220:11434` | Ollama server URL | +| `OLLAMA_MODEL` | `--ollama-model` | `gemma4:12b` | Ollama model identifier | +| | `--loop-delay` | `1.0` | Turn polling loop delay (s) | + +--- + +## 🚀 Execution Example + +```bash +export OLLAMA_BASE_URL="http://localhost:11434" +export OLLAMA_MODEL="gemma4:12b" + +python3 trollagent_ai/bot.py \ + --name CarnageTroll \ + --color "#047857" \ + --strength 4 \ + --health 10 \ + --url http://localhost:8000/api +``` diff --git a/trollagent_ai/bot.py b/trollagent_ai/bot.py new file mode 100644 index 0000000..d7742ad --- /dev/null +++ b/trollagent_ai/bot.py @@ -0,0 +1,429 @@ +"""AI-driven Troll Agent for botWebWars, powered by an Ollama LLM. + +Implements the unique rules and tactical gameplay of the Troll character type: +- Solitary hunter: Never joins or forms parties. +- Troll truce: Never battles other trolls. +- Relentless hunter: Mandatory tactical combat against players and squads. +- Strategic healing: Can take turns sleeping to regenerate +0.1 HP. +- Cannot challenge or duel Gary the Wizard. +- Strategic decision making via local/remote Ollama LLM: + 1. Action Choice: Evaluates health and distance to decide whether to SLEEP (recover health) or HUNT (move). + 2. Directional Navigation: Chooses the optimal passable path around obstacles towards target players. +""" + +import argparse +import json +import os +import re +import time +from typing import Any, Dict, List, Optional, Tuple + +import requests + +DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api")) +DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GorgonAITroll") +DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#15803d") +DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0")) +DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0")) + +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b") + +TROLL_RULES_SUMMARY = """ +You are an autonomous tactical Troll in botWebWars! +Troll Rules of Engagement: +- You NEVER form alliances or parties with anyone. You are a solitary brute hunter. +- You NEVER fight other trolls. Trolls maintain an instinctive truce. +- Your sole mission is to hunt down human/bot players and player squads and crush them in 3-bout D20 battles. +- When victorious in battle, you earn +2 victory points (score). You do NOT gain strength or absorb squad members. +- If defeated in battle, you take 1 to 3 health points damage. At 0 HP, you die and a gravestone is placed. +- You CANNOT duel or interact with Gary the Wizard. +- SLEEP RESTORATION: On any turn when you are not in adjacent combat, you may choose to SLEEP. Sleeping skips movement but regenerates +0.1 HP! +- Squeezing diagonally between obstacle corners costs -0.1 strength penalty. +- The game concludes when all surviving entities are resolved. You can win the game on the final scoreboards! +""" + + +def normalize_url(url: str) -> str: + """Ensure the API URL ends with /api without trailing slashes.""" + cleaned = url.rstrip("/") + if not cleaned.endswith("/api"): + cleaned = f"{cleaned}/api" + return cleaned + + +def extract_json(text: str) -> Optional[Dict[str, Any]]: + """Extract first valid JSON object from model output.""" + if not text: + return None + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + match = re.search(r"\{.*\}", cleaned, re.DOTALL) + if not match: + return None + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + return None + + +class OllamaClient: + def __init__(self, base_url: str, model: str): + self.base_url = base_url.rstrip("/") + self.model = model + + def ask_json(self, prompt: str) -> Optional[Dict[str, Any]]: + """Query Ollama with a JSON formatting constraint.""" + url = f"{self.base_url}/api/generate" + payload = { + "model": self.model, + "prompt": prompt, + "format": "json", + "stream": False, + "options": {"temperature": 0.3}, + } + try: + res = requests.post(url, json=payload, timeout=45) + res.raise_for_status() + raw = res.json().get("response", "") + return extract_json(raw) + except requests.exceptions.RequestException as e: + print(f"⚠️ [OLLAMA ERROR] {e}") + return None + + +class AITrollAgent: + def __init__( + self, + name: str = DEFAULT_TROLL_NAME, + color: str = DEFAULT_TROLL_COLOR, + strength: float = DEFAULT_TROLL_STRENGTH, + health: float = DEFAULT_TROLL_HEALTH, + server_url: str = DEFAULT_SERVER_URL, + ollama_url: str = OLLAMA_BASE_URL, + ollama_model: str = OLLAMA_MODEL, + loop_delay: float = 1.0, + ): + self.name = name + self.color = color + self.strength = strength + self.health = health + self.base_url = normalize_url(server_url) + self.llm = OllamaClient(ollama_url, ollama_model) + self.loop_delay = loop_delay + self.bot_id: Optional[str] = None + + def register(self): + """Register the troll avatar on the grid or reconnect if existing.""" + try: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, " + f"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, + "health": self.health, + "piece_type": "troll", + "character_type": "troll", + } + + res = requests.post(f"{self.base_url}/players", json=payload) + if res.status_code == 400 and "already registered" in res.text: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(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 AI Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, " + f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})" + ) + + def refresh_status(self) -> Optional[Dict[str, Any]]: + """Get live troll state.""" + try: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 200: + return res.json() + except Exception as e: + print(f"Status refresh error: {e}") + return None + + def sleep_and_heal(self) -> bool: + """Rest for 1 turn to regenerate +0.1 HP.""" + try: + res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep") + if res.status_code == 200: + data = res.json() + print( + f"💤 [SLEEP] {self.name} rested for 1 turn. " + f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)" + ) + return True + else: + print(f"Sleep failed ({res.status_code}): {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + except Exception as e: + print(f"Error sleeping: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + + def pass_turn(self): + """Pass turn when no actions are possible.""" + try: + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + print(f"⏩ [PASS] {self.name} passed turn.") + except Exception as e: + print(f"Error passing turn: {e}") + + def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]: + """Detect living adjacent player or player squad (ignores other trolls).""" + try: + players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("id") == self.bot_id: + continue + if p.get("character_type") == "troll": + continue + if p.get("is_alive") is False or p.get("health", 10) <= 0: + continue + chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y)) + if chebyshev <= 1: + return p + except Exception as e: + print(f"Error checking adjacent players: {e}") + return None + + def attack_player(self, defender: Dict[str, Any]): + """Initiate mandatory 3-bout D20 battle against adjacent player.""" + target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}" + print(f"⚔️ [BATTLE CLASH] Troll {self.name} ambushes {target_label} in 3-bout tactical D20 confrontation!") + try: + res = requests.post( + f"{self.base_url}/battles/fight", + json={"challenger_id": self.bot_id, "defender_id": defender["id"]}, + ) + if res.status_code == 200: + b = res.json() + won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name) + outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (HP damage taken)" + print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}") + for bout in b.get("bouts", []): + print( + f" Bout #{bout['bout_number']}: " + f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs " + f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} " + f"-> Winner: {bout['winner']}" + ) + else: + print(f"Battle failed ({res.status_code}): {res.text}") + self.pass_turn() + except Exception as e: + print(f"Error fighting battle: {e}") + self.pass_turn() + + def _decide_action_and_direction( + self, + my_info: Dict[str, Any], + radar_res: Dict[str, Any], + available_moves: Dict[str, Any], + ) -> Tuple[str, Optional[str], str]: + """Ask the LLM to decide whether to sleep or move, and if moving, which direction.""" + my_hp = float(my_info.get("health", 10.0)) + max_hp = float(my_info.get("max_health", 10.0)) + nearest = radar_res.get("nearest_target") + rec_dir = radar_res.get("recommended_direction") + + # Format available movement choices + choices_desc = [] + for d, chk in available_moves.items(): + if chk.get("available"): + pen = " [squeeze penalty -0.1 STR]" if chk.get("strength_penalty", 0.0) > 0 else "" + choices_desc.append(f"- {d}: Target ({chk.get('target_x')}, {chk.get('target_y')}){pen}") + + choices_str = "\n".join(choices_desc) if choices_desc else "No open moves available (all blocked by obstacles/bounds)." + + prompt = f"""{TROLL_RULES_SUMMARY} +Current Troll Status: +- Name: "{self.name}" | Current HP: {my_hp:.1f} / {max_hp:.1f} | Strength: {self.strength:.1f} | Score: {my_info['score']} +- Position: ({my_info['x']}, {my_info['y']}) +- Nearest Target Player: {nearest.get('name') if nearest else 'None detected'} (Distance: {nearest.get('distance') if nearest else 'N/A'}, Pos: {nearest.get('x') if nearest else '?'},{nearest.get('y') if nearest else '?'}) +- Radar Pathfinder Suggestion: {rec_dir or 'None'} + +Available Passable Moves: +{choices_str} + +OPTIONS: +1. "sleep" - Take a rest turn to regenerate +0.1 HP. (Recommended if wounded or trapped). +2. "move" - Move in one of the passable directions toward the nearest player. + +Decide which action to take. If action is "move", pick the best passable direction from the list. +Respond ONLY with a JSON object: +{{"action": "sleep"|"move", "direction": "UP"|"DOWN"|"LEFT"|"RIGHT"|"UP_LEFT"|"UP_RIGHT"|"DOWN_LEFT"|"DOWN_RIGHT"|null, "reasoning": "short tactical rationale"}} +""" + decision = self.llm.ask_json(prompt) or {} + action = str(decision.get("action", "move")).lower().strip() + direction = decision.get("direction") + reasoning = decision.get("reasoning", "") + + # Fallback validation + if action not in ("sleep", "move"): + action = "sleep" if my_hp < 6.0 and my_hp < max_hp else "move" + + valid_dirs = [d for d, chk in available_moves.items() if chk.get("available")] + if action == "move": + if direction not in valid_dirs: + direction = rec_dir if rec_dir in valid_dirs else (valid_dirs[0] if valid_dirs else None) + if not direction: + action = "sleep" if my_hp < max_hp else "pass" + + return action, direction, reasoning + + def decide_and_act(self): + """Turn execution pipeline: combat check -> LLM reasoning -> act.""" + my_info = self.refresh_status() + if not my_info: + self.pass_turn() + return + + my_x = my_info.get("x", 0) + my_y = my_info.get("y", 0) + + # 1. Immediate combat check: Trolls always attack adjacent players + adjacent_player = self._find_adjacent_player(my_x, my_y) + if adjacent_player: + self.attack_player(adjacent_player) + return + + # 2. Get sensor data + try: + radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() + except Exception: + radar_res = {} + + try: + moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json() + available_moves = moves_res.get("moves", {}) + except Exception: + available_moves = {} + + # 3. LLM strategic decision + action, direction, reasoning = self._decide_action_and_direction(my_info, radar_res, available_moves) + print(f"🧠 [LLM STRATEGY] Action: {action.upper()}{f' -> {direction}' if direction else ''}. Reasoning: {reasoning}") + + if action == "sleep": + self.sleep_and_heal() + elif action == "move" and direction: + try: + res = requests.post( + f"{self.base_url}/players/{self.bot_id}/move", + json={"direction": direction}, + ).json() + if res.get("battle_triggered") and res.get("battle_result"): + b = res["battle_result"] + print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})") + except Exception as e: + print(f"Move failed: {e}") + self.pass_turn() + else: + self.pass_turn() + + def run(self): + """Main game loop.""" + self.register() + try: + while True: + # Check life status + 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 TROLL] {self.name} has fallen (0 HP)! Gravestone marked on board.") + print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + + turn_info = requests.get(f"{self.base_url}/turn").json() + if not turn_info.get("game_started", False): + if self.bot_id: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 404: + print("\n⚠️ [RESET] Board regenerated. Rejoining lobby...") + self.register() + + print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True) + time.sleep(1.0) + continue + + curr_player_id = turn_info.get("current_player_id") + + if curr_player_id == self.bot_id: + print(f"\n⚡ [MY TURN] AI Troll {self.name} (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})") + self.decide_and_act() + + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!") + break + + time.sleep(self.loop_delay) + + except KeyboardInterrupt: + print(f"\n👋 AI Troll {self.name} disconnecting gracefully.") + + +def main(): + parser = argparse.ArgumentParser(description="Ollama LLM-Assisted Autonomous Troll Agent for botWebWars") + parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL") + parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name") + parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code") + parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)") + parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)") + parser.add_argument("--ollama-url", default=OLLAMA_BASE_URL, help="Ollama API base URL") + parser.add_argument("--ollama-model", default=OLLAMA_MODEL, help="Ollama model name (default: gemma4:12b)") + parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds") + + args = parser.parse_args() + + agent = AITrollAgent( + name=args.name, + color=args.color, + strength=args.strength, + health=args.health, + server_url=args.url, + ollama_url=args.ollama_url, + ollama_model=args.ollama_model, + loop_delay=args.loop_delay, + ) + agent.run() + + +if __name__ == "__main__": + main() diff --git a/trollagent_ai/requirements.txt b/trollagent_ai/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/trollagent_ai/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/trollagent_gear/INSTALL.md b/trollagent_gear/INSTALL.md new file mode 100644 index 0000000..87ce743 --- /dev/null +++ b/trollagent_gear/INSTALL.md @@ -0,0 +1,18 @@ +# Installation Guide for `trollagent_gear` + +## 1. Prerequisites +- Python 3.10+ +- Google Cloud Project with Vertex AI enabled, or a Google AI Studio Gemini API key. + +## 2. Virtual Environment & Dependencies +```bash +cd trollagent_gear +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## 3. Run the Bot +```bash +python3 bot.py --name GeminiBrute --color "#047857" --strength 4 --health 10 +``` diff --git a/trollagent_gear/README.md b/trollagent_gear/README.md new file mode 100644 index 0000000..231fc0a --- /dev/null +++ b/trollagent_gear/README.md @@ -0,0 +1,47 @@ +# trollagent_gear - Google Cloud Vertex AI (Gemini) Troll Agent + +`trollagent_gear` delegates autonomous tactical reasoning to Google Cloud Vertex AI and Gemini models (`gemini-2.5-flash`, `gemini-1.5-flash`, etc.) to drive a solitary Troll character in **botWebWars**. + +--- + +## 👹 Troll Strategy & Gemini Reasoning + +- **Hunter Instinct**: Evaluates radar minimaps to track and corner player bots and squads. +- **Troll Truce**: Instinctively ignores other trolls. +- **Sleep & Regeneration Strategy**: Leverages Gemini reasoning to evaluate current HP against target proximity—choosing when to safely **rest and sleep** (+0.1 HP regeneration) vs. when to strike. +- **Obstacle Navigation**: Balances Euclidean and Chebyshev distance against the -0.1 strength diagonal squeeze penalty. +- **No Wizard Challenges**: Never interacts with Gary the Wizard. + +--- + +## ⚙️ Configuration & CLI Options + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--url` | `-u` | `http://localhost:8000/api` | botWebWars API base endpoint | +| `--name` | `-n` | `GeminiTroll` | Troll avatar name | +| `--color` | `-c` | `#047857` | Troll hex color | +| `--strength` | `-s` | `3.0` | Initial troll strength | +| `--health` | `-H` | `10.0` | Initial troll health points | +| `--project-id` | | `None` (auto-detected) | Google Cloud Project ID | +| `--location` | | `global` | Vertex AI region (e.g. `global`, `us-central1`) | +| `--model` | | `gemini-2.5-flash` | Gemini model name | +| `--api-key` | | `None` | Google AI Studio or Vertex API key | +| `--loop-delay` | | `1.0` | Turn polling loop delay in seconds | + +--- + +## 🚀 Invocation Example + +```bash +# Using gcloud ADC authentication +gcloud auth application-default login +export VERTEX_PROJECT_ID="my-gcp-project" + +python3 trollagent_gear/bot.py \ + --name ApexTroll \ + --color "#059669" \ + --strength 5 \ + --health 12 \ + --url http://localhost:8000/api +``` diff --git a/trollagent_gear/SETUP.md b/trollagent_gear/SETUP.md new file mode 100644 index 0000000..53abb85 --- /dev/null +++ b/trollagent_gear/SETUP.md @@ -0,0 +1,38 @@ +# Google Cloud & Vertex AI Authentication Setup for `trollagent_gear` + +`trollagent_gear` connects to Google Cloud Vertex AI (or Google AI Studio) to query Gemini models (`gemini-2.5-flash`, `gemini-1.5-flash`, etc.) for tactical spatial navigation, obstacle avoidance, and health regeneration decisions. + +--- + +## Quick Setup Options + +### Option A: Application Default Credentials (ADC) - Recommended +```bash +# 1. Login with your Google Cloud account: +gcloud auth application-default login + +# 2. Set your default project: +gcloud config set project YOUR_PROJECT_ID +export VERTEX_PROJECT_ID="YOUR_PROJECT_ID" +``` + +### Option B: Direct API Key (Google AI Studio or Vertex AI) +```bash +export GEMINI_API_KEY="AIzaSy..." +# Or: +export VERTEX_API_KEY="AIzaSy..." +``` + +### Option C: Service Account Key +```bash +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +export VERTEX_PROJECT_ID="YOUR_PROJECT_ID" +``` + +--- + +## Verifying Authentication +Run the auth check directly by starting the bot with `--help`: +```bash +python3 trollagent_gear/bot.py --help +``` diff --git a/trollagent_gear/bot.py b/trollagent_gear/bot.py new file mode 100644 index 0000000..997e81a --- /dev/null +++ b/trollagent_gear/bot.py @@ -0,0 +1,613 @@ +"""AI-driven Troll Agent for botWebWars, powered by Google Cloud Vertex AI (Gemini). + +Implements the unique rules and tactical gameplay of the Troll character type: +- Solitary brute: Never joins or forms parties/alliances. +- Troll truce: Never battles other trolls. +- Relentless hunter: Mandatory tactical combat against players and squads. +- Strategic healing: Can take turns sleeping to regenerate +0.1 HP. +- Cannot challenge or duel Gary the Wizard. +- Strategic decision making via Vertex AI / Gemini: + 1. Action Choice: Evaluates health and distance to decide whether to SLEEP (recover health) or HUNT (move). + 2. Directional Navigation: Chooses the optimal passable path around obstacles towards target players. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +import requests + +# Optional google-auth integration +try: + import google.auth + import google.auth.transport.requests + HAVE_GOOGLE_AUTH = True +except ImportError: + HAVE_GOOGLE_AUTH = False + +DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api")) +DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GeminiTroll") +DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#047857") +DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0")) +DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0")) +DEFAULT_MODEL = os.getenv("VERTEX_MODEL", os.getenv("GEMINI_MODEL", "gemini-2.5-flash")) +DEFAULT_LOCATION = os.getenv("VERTEX_LOCATION", "global") + +TROLL_RULES_SUMMARY = """ +You are an autonomous tactical Troll in botWebWars! +Troll Rules of Engagement: +- You NEVER form alliances or parties with anyone. You are a solitary brute hunter. +- You NEVER fight other trolls. Trolls maintain an instinctive truce. +- Your sole mission is to hunt down human/bot players and player squads and crush them in 3-bout D20 battles. +- When victorious in battle, you earn +2 victory points (score). You do NOT gain strength or absorb squad members. +- If defeated in battle, you take 1 to 3 health points damage. At 0 HP, you die and a gravestone is placed on the board. +- You CANNOT duel or interact with Gary the Wizard. +- SLEEP RESTORATION: On any turn when you are not locked in combat, you may choose to SLEEP. Sleeping skips movement but regenerates +0.1 HP! +- Squeezing diagonally between obstacle corners costs -0.1 strength penalty. +- The game concludes when all surviving entities are resolved. You can win the game on the final scoreboards! +""" + + +def normalize_url(url: str) -> str: + """Ensure the API URL ends with /api without trailing slashes.""" + cleaned = url.rstrip("/") + if not cleaned.endswith("/api"): + cleaned = f"{cleaned}/api" + return cleaned + + +def extract_json(text: str) -> Optional[Dict[str, Any]]: + """Extract first valid JSON object from model response.""" + if not text: + return None + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + match = re.search(r"\{.*\}", cleaned, re.DOTALL) + if not match: + return None + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + return None + + +class VertexGeminiClient: + """Client for querying Gemini models on Google Cloud Vertex AI or Google AI Studio.""" + + def __init__( + self, + project_id: Optional[str] = None, + location: str = DEFAULT_LOCATION, + model: str = DEFAULT_MODEL, + api_key: Optional[str] = None, + ): + self.location = location or DEFAULT_LOCATION + self.model = model or DEFAULT_MODEL + self.api_key = api_key or os.getenv("VERTEX_API_KEY") or os.getenv("GEMINI_API_KEY") + self.project_id = project_id or os.getenv("VERTEX_PROJECT_ID") or os.getenv("GCP_PROJECT") or os.getenv("GOOGLE_CLOUD_PROJECT") + + self._cached_token: Optional[str] = None + self._token_expiry: float = 0.0 + + if not self.project_id and not self.api_key: + self.project_id = self._detect_project() + + def _detect_project(self) -> Optional[str]: + """Attempt to determine the GCP project from environment or gcloud config.""" + if HAVE_GOOGLE_AUTH: + try: + _, proj = google.auth.default() + if proj: + return proj + except Exception: + pass + + if shutil.which("gcloud"): + try: + res = subprocess.check_output( + ["gcloud", "config", "get-value", "project"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + if res and res != "(unset)": + return res + except Exception: + pass + + return None + + def _get_access_token(self) -> Optional[str]: + """Obtain a valid OAuth 2.0 Bearer access token for Vertex AI.""" + env_token = os.getenv("VERTEX_ACCESS_TOKEN") or os.getenv("GOOGLE_OAUTH_ACCESS_TOKEN") + if env_token: + return env_token + + now = time.time() + if self._cached_token and now < self._token_expiry - 60: + return self._cached_token + + if HAVE_GOOGLE_AUTH: + try: + credentials, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + auth_req = google.auth.transport.requests.Request() + credentials.refresh(auth_req) + self._cached_token = credentials.token + self._token_expiry = now + 3000 + return self._cached_token + except Exception: + pass + + if shutil.which("gcloud"): + try: + token = subprocess.check_output( + ["gcloud", "auth", "print-access-token"], + stderr=subprocess.PIPE, + text=True, + ).strip() + if token: + self._cached_token = token + self._token_expiry = now + 3000 + return token + except subprocess.CalledProcessError: + pass + + return None + + def check_auth(self) -> Tuple[bool, str]: + """Validate whether authentication is ready.""" + if self.api_key: + return True, f"Using direct API key (model: {self.model})" + + if not self.project_id: + return False, ( + "No Google Cloud project ID detected.\n" + "Please set VERTEX_PROJECT_ID= or run:\n" + " gcloud config set project " + ) + + token = self._get_access_token() + if not token: + return False, ( + "Unable to obtain Google Cloud authentication token.\n" + "Please authenticate using:\n" + " 1. gcloud auth application-default login\n" + " 2. Or set GEMINI_API_KEY / VERTEX_API_KEY" + ) + + return True, f"Authenticated to GCP Project '{self.project_id}' in region '{self.location}' (model: {self.model})" + + def _build_vertex_url(self, location: Optional[str] = None) -> str: + loc = location or self.location or "global" + if loc == "global": + endpoint = "aiplatform.googleapis.com" + else: + endpoint = f"{loc}-aiplatform.googleapis.com" + return f"https://{endpoint}/v1/projects/{self.project_id}/locations/{loc}/publishers/google/models/{self.model}:generateContent" + + def ask_json(self, prompt: str, system_instruction: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Query Gemini model requesting a structured JSON response.""" + # Method 1: Google AI Studio API key + if self.api_key: + url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}" + headers = {"Content-Type": "application/json"} + body: Dict[str, Any] = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": 0.3, + "responseMimeType": "application/json", + }, + } + if system_instruction: + body["systemInstruction"] = {"parts": [{"text": system_instruction}]} + + try: + res = requests.post(url, headers=headers, json=body, timeout=45) + res.raise_for_status() + data = res.json() + text = ( + data.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + return extract_json(text) + except Exception as e: + print(f"⚠️ [GEMINI API KEY ERROR] {e}") + return None + + # Method 2: Vertex AI endpoint + token = self._get_access_token() + if not token: + print("⚠️ [AUTH ERROR] No Google Cloud access token available.") + return None + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + body = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": 0.3, + "responseMimeType": "application/json", + }, + } + if system_instruction: + body["systemInstruction"] = {"parts": [{"text": system_instruction}]} + + url = self._build_vertex_url() + try: + res = requests.post(url, headers=headers, json=body, timeout=45) + if res.status_code == 404 and self.location == "global": + url_fallback = self._build_vertex_url("us-central1") + res = requests.post(url_fallback, headers=headers, json=body, timeout=45) + res.raise_for_status() + data = res.json() + text = ( + data.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + return extract_json(text) + except Exception as e: + print(f"⚠️ [VERTEX AI ERROR] {e}") + return None + + +class GearTrollAgent: + def __init__( + self, + name: str = DEFAULT_TROLL_NAME, + color: str = DEFAULT_TROLL_COLOR, + strength: float = DEFAULT_TROLL_STRENGTH, + health: float = DEFAULT_TROLL_HEALTH, + server_url: str = DEFAULT_SERVER_URL, + project_id: Optional[str] = None, + location: str = DEFAULT_LOCATION, + model: str = DEFAULT_MODEL, + api_key: Optional[str] = None, + loop_delay: float = 1.0, + ): + self.name = name + self.color = color + self.strength = strength + self.health = health + self.base_url = normalize_url(server_url) + self.gemini = VertexGeminiClient( + project_id=project_id, + location=location, + model=model, + api_key=api_key, + ) + self.loop_delay = loop_delay + self.bot_id: Optional[str] = None + + def register(self): + """Register the troll avatar or reconnect.""" + try: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, " + f"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, + "health": self.health, + "piece_type": "troll", + "character_type": "troll", + } + + res = requests.post(f"{self.base_url}/players", json=payload) + if res.status_code == 400 and "already registered" in res.text: + players = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("name") == self.name: + self.bot_id = p["id"] + print( + f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} " + f"(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 Vertex AI Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, " + f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})" + ) + + def refresh_status(self) -> Optional[Dict[str, Any]]: + """Get live troll state.""" + try: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 200: + return res.json() + except Exception as e: + print(f"Status refresh error: {e}") + return None + + def sleep_and_heal(self) -> bool: + """Execute sleep turn to recover +0.1 HP.""" + try: + res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep") + if res.status_code == 200: + data = res.json() + print( + f"💤 [SLEEP] {self.name} curled up and slept for 1 turn. " + f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)" + ) + return True + else: + print(f"Sleep failed ({res.status_code}): {res.text}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + except Exception as e: + print(f"Error sleeping: {e}") + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + return False + + def pass_turn(self): + """Pass turn.""" + try: + requests.post(f"{self.base_url}/players/{self.bot_id}/pass") + print(f"⏩ [PASS] {self.name} passed turn.") + except Exception as e: + print(f"Error passing turn: {e}") + + def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]: + """Detect living adjacent player or player squad (ignores other trolls).""" + try: + players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json() + for p in players: + if p.get("id") == self.bot_id: + continue + if p.get("character_type") == "troll": + continue + if p.get("is_alive") is False or p.get("health", 10) <= 0: + continue + chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y)) + if chebyshev <= 1: + return p + except Exception as e: + print(f"Error scanning adjacent players: {e}") + return None + + def attack_player(self, defender: Dict[str, Any]): + """Initiate mandatory 3-bout D20 battle against adjacent player.""" + target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}" + print(f"⚔️ [BATTLE CLASH] Troll {self.name} ambushes {target_label} in 3-bout D20 combat!") + try: + res = requests.post( + f"{self.base_url}/battles/fight", + json={"challenger_id": self.bot_id, "defender_id": defender["id"]}, + ) + if res.status_code == 200: + b = res.json() + won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name) + outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (HP damage taken)" + print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}") + for bout in b.get("bouts", []): + print( + f" Bout #{bout['bout_number']}: " + f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs " + f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} " + f"-> Winner: {bout['winner']}" + ) + else: + print(f"Battle failed ({res.status_code}): {res.text}") + self.pass_turn() + except Exception as e: + print(f"Error fighting battle: {e}") + self.pass_turn() + + def _decide_action_and_direction( + self, + my_info: Dict[str, Any], + radar_res: Dict[str, Any], + available_moves: Dict[str, Any], + ) -> Tuple[str, Optional[str], str]: + """Ask Vertex AI Gemini whether to sleep or move, and which direction.""" + my_hp = float(my_info.get("health", 10.0)) + max_hp = float(my_info.get("max_health", 10.0)) + nearest = radar_res.get("nearest_target") + rec_dir = radar_res.get("recommended_direction") + + choices_desc = [] + for d, chk in available_moves.items(): + if chk.get("available"): + pen = " [squeeze penalty -0.1 STR]" if chk.get("strength_penalty", 0.0) > 0 else "" + choices_desc.append(f"- {d}: Target ({chk.get('target_x')}, {chk.get('target_y')}){pen}") + + choices_str = "\n".join(choices_desc) if choices_desc else "No open moves available." + + prompt = f"""Current Troll Status: +- Name: "{self.name}" | HP: {my_hp:.1f} / {max_hp:.1f} | Strength: {self.strength:.1f} | Score: {my_info['score']} +- Position: ({my_info['x']}, {my_info['y']}) +- Closest Detected Player: {nearest.get('name') if nearest else 'None'} (Distance: {nearest.get('distance') if nearest else 'N/A'}, Pos: {nearest.get('x') if nearest else '?'},{nearest.get('y') if nearest else '?'}) +- Radar Pathfinder Recommendation: {rec_dir or 'None'} + +Available Passable Moves: +{choices_str} + +OPTIONS: +1. "sleep" - Rest for 1 turn to heal +0.1 HP (best if injured and not in direct pursuit). +2. "move" - Advance in one of the passable directions toward the nearest player. + +Decide which action to take. If moving, select the best direction from the available moves. +Respond ONLY with JSON: +{{"action": "sleep"|"move", "direction": "UP"|"DOWN"|"LEFT"|"RIGHT"|"UP_LEFT"|"UP_RIGHT"|"DOWN_LEFT"|"DOWN_RIGHT"|null, "reasoning": "concise strategic reasoning"}} +""" + decision = self.gemini.ask_json(prompt, system_instruction=TROLL_RULES_SUMMARY) or {} + action = str(decision.get("action", "move")).lower().strip() + direction = decision.get("direction") + reasoning = decision.get("reasoning", "") + + if action not in ("sleep", "move"): + action = "sleep" if my_hp < 6.0 and my_hp < max_hp else "move" + + valid_dirs = [d for d, chk in available_moves.items() if chk.get("available")] + if action == "move": + if direction not in valid_dirs: + direction = rec_dir if rec_dir in valid_dirs else (valid_dirs[0] if valid_dirs else None) + if not direction: + action = "sleep" if my_hp < max_hp else "pass" + + return action, direction, reasoning + + def decide_and_act(self): + """Main turn decision logic.""" + my_info = self.refresh_status() + if not my_info: + self.pass_turn() + return + + my_x = my_info.get("x", 0) + my_y = my_info.get("y", 0) + + # 1. Combat check: Trolls always attack adjacent players + adjacent_player = self._find_adjacent_player(my_x, my_y) + if adjacent_player: + self.attack_player(adjacent_player) + return + + # 2. Get sensors + try: + radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() + except Exception: + radar_res = {} + + try: + moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json() + available_moves = moves_res.get("moves", {}) + except Exception: + available_moves = {} + + # 3. Gemini reasoning + action, direction, reasoning = self._decide_action_and_direction(my_info, radar_res, available_moves) + print(f"✨ [GEMINI REASONING] Action: {action.upper()}{f' -> {direction}' if direction else ''}. Rationale: {reasoning}") + + if action == "sleep": + self.sleep_and_heal() + elif action == "move" and direction: + try: + res = requests.post( + f"{self.base_url}/players/{self.bot_id}/move", + json={"direction": direction}, + ).json() + if res.get("battle_triggered") and res.get("battle_result"): + b = res["battle_result"] + print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})") + except Exception as e: + print(f"Move failed: {e}") + self.pass_turn() + else: + self.pass_turn() + + def run(self): + """Main loop.""" + self.register() + try: + while True: + 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 TROLL] {self.name} has fallen (0 HP)! Gravestone on board.") + print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...") + while True: + try: + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'") + return + except Exception: + pass + time.sleep(2.0) + + turn_info = requests.get(f"{self.base_url}/turn").json() + if not turn_info.get("game_started", False): + if self.bot_id: + res = requests.get(f"{self.base_url}/players/{self.bot_id}") + if res.status_code == 404: + print("\n⚠️ [RESET] Board regenerated. Rejoining lobby...") + self.register() + + print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True) + time.sleep(1.0) + continue + + curr_player_id = turn_info.get("current_player_id") + + if curr_player_id == self.bot_id: + print(f"\n⚡ [MY TURN] Vertex Gemini Troll {self.name} (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})") + self.decide_and_act() + + conc = requests.get(f"{self.base_url}/game/conclusion").json() + if conc.get("concluded"): + print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!") + break + + time.sleep(self.loop_delay) + + except KeyboardInterrupt: + print(f"\n👋 Vertex Gemini Troll {self.name} disconnecting gracefully.") + + +def main(): + parser = argparse.ArgumentParser(description="Vertex AI (Gemini) Autonomous Troll Agent for botWebWars") + parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL") + parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name") + parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code") + parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)") + parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)") + parser.add_argument("--project-id", default=None, help="Google Cloud project ID (or VERTEX_PROJECT_ID)") + parser.add_argument("--location", default=DEFAULT_LOCATION, help="Vertex AI region (default: global)") + parser.add_argument("--model", default=DEFAULT_MODEL, help="Gemini model (default: gemini-2.5-flash)") + parser.add_argument("--api-key", default=None, help="Google AI Studio or Vertex API key") + parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds") + + args = parser.parse_args() + + agent = GearTrollAgent( + name=args.name, + color=args.color, + strength=args.strength, + health=args.health, + server_url=args.url, + project_id=args.project_id, + location=args.location, + model=args.model, + api_key=args.api_key, + loop_delay=args.loop_delay, + ) + + auth_ok, auth_msg = agent.gemini.check_auth() + print(f"🔐 [AUTH CHECK] {auth_msg}") + if not auth_ok: + print("⚠️ Warning: Gemini calls will fail without valid credentials.") + + agent.run() + + +if __name__ == "__main__": + main() diff --git a/trollagent_gear/requirements.txt b/trollagent_gear/requirements.txt new file mode 100644 index 0000000..360595e --- /dev/null +++ b/trollagent_gear/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +google-auth>=2.28.0 diff --git a/version.ini b/version.ini index ded21d6..db5e20d 100644 --- a/version.ini +++ b/version.ini @@ -1,2 +1,2 @@ [metadata] -version = 1.3 +version = 1.4