diff --git a/backend/app/game.py b/backend/app/game.py index 47e1ef0..915708c 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -607,10 +607,42 @@ class GameEngine: nearest = primary_targets[0] if primary_targets else (targets[0] if targets else None) + wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) + wiz_radar = WizardRadarTarget( + id=self.wizard.id, + name=self.wizard.name, + x=self.wizard.x, + y=self.wizard.y, + distance=wiz_dist, + strength=self.wizard.strength, + can_challenge=(wiz_dist <= 1), + ) + rec_dir = None rec_act = "explore_unvisited" - if nearest: + if 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)) + if bfs_path and len(bfs_path) >= 2: + step_x, step_y = bfs_path[1] + dx = step_x - player.x + dy = step_y - player.y + else: + dx = 1 if self.wizard.x > player.x else (-1 if self.wizard.x < player.x else 0) + dy = 1 if self.wizard.y > player.y else (-1 if self.wizard.y < player.y else 0) + + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == dx and oy == dy and "_" in name: + rec_dir = name + break + if not rec_dir: + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == dx and oy == dy: + rec_dir = name + break + elif nearest: # Use BFS pathfinder to recommend direction navigating around obstacles! bfs_path = self._find_path_bfs((player.x, player.y), (nearest.x, nearest.y)) if bfs_path and len(bfs_path) >= 2: @@ -642,17 +674,6 @@ class GameEngine: else: rec_act = "hunt_party" - wiz_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) - wiz_radar = WizardRadarTarget( - id=self.wizard.id, - name=self.wizard.name, - x=self.wizard.x, - y=self.wizard.y, - distance=wiz_dist, - strength=self.wizard.strength, - can_challenge=(wiz_dist <= 1), - ) - return BotRadarResponse( player_id=player.id, current_x=player.x, @@ -1844,7 +1865,12 @@ class GameEngine: f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." ) - bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" + seeking_wizard = bool(player.health < 2 and self.wizard) + bot_goal = ( + "seek_wizard" + if seeking_wizard + else ("find_and_defeat_all_parties" if player.party_id else "form_party") + ) # 1. Check if already adjacent to encounter before moving formed_party, battle_res = self._check_adjacent_encounter(player) @@ -1887,7 +1913,7 @@ class GameEngine: effective_str = player.strength if player.party_id and player.party_id in self.parties: effective_str = self.parties[player.party_id].total_strength - if effective_str >= self.wizard.strength or player.health >= 4: + if effective_str >= self.wizard.strength or player.health >= 4 or player.health < 2: if player.health <= 5: bot_reward = "health" elif player.strength < 4.0: @@ -1933,28 +1959,34 @@ class GameEngine: turn=self._get_turn_info(), ) - # Find nearest target according to goal + # Find nearest target according to goal or seek wizard if low health (< 2 HP) targets = [] - for other in self.players.values(): - if other.id == player.id or not other.is_alive or other.health <= 0: - continue - if player.party_id and player.party_id == other.party_id: - continue - dist = max(abs(player.x - other.x), abs(player.y - other.y)) - targets.append((dist, other)) + target_bot = None + if not seeking_wizard: + for other in self.players.values(): + if other.id == player.id or not other.is_alive or other.health <= 0: + continue + if player.party_id and player.party_id == other.party_id: + continue + dist = max(abs(player.x - other.x), abs(player.y - other.y)) + targets.append((dist, other)) - targets.sort(key=lambda t: t[0]) + targets.sort(key=lambda t: t[0]) - if bot_goal == "form_party": - preferred = [t for t in targets if not t[1].party_id] - target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) - else: - preferred = [t for t in targets if t[1].party_id] - target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) + if bot_goal == "form_party": + preferred = [t for t in targets if not t[1].party_id] + target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) + else: + preferred = [t for t in targets if t[1].party_id] + target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) # Check if BFS pathfinder finds optimal route around obstacles bfs_next_step: Optional[Tuple[int, int]] = None - if target_bot: + if seeking_wizard: + path = self._find_path_bfs((player.x, player.y), (self.wizard.x, self.wizard.y)) + if path and len(path) >= 2: + bfs_next_step = (path[1][0] - player.x, path[1][1] - player.y) + elif 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) @@ -1973,7 +2005,11 @@ class GameEngine: if bfs_next_step and (dx, dy) == bfs_next_step: score += 50.0 - if target_bot: + if seeking_wizard: + old_dist = max(abs(player.x - self.wizard.x), abs(player.y - self.wizard.y)) + new_dist = max(abs(tx - self.wizard.x), abs(ty - self.wizard.y)) + score += (old_dist - new_dist) * 10.0 + elif 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 diff --git a/backend/app/models.py b/backend/app/models.py index bea4ede..68728a6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -364,7 +364,7 @@ class BotRadarResponse(BaseModel): nearest_target: Optional[RadarTarget] = None wizard: Optional[WizardRadarTarget] = None recommended_direction: Optional[str] = None - recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited", "challenge_wizard" + recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited", "challenge_wizard", "seek_wizard" class BoardConfig(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6c1521f..20c6f55 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -765,3 +765,92 @@ def test_player_death_rule_and_scoreboard_preservation(): assert b3["id"] in ranking_ids + + +def test_radar_seek_wizard_when_health_less_than_2(): + """Test that when a player's health is less than 2, get_bot_radar recommends 'seek_wizard' + and directs the player toward Gary the Wizard NPC.""" + client = TestClient(app) + client.post("/api/reset") + + # Register two players + p1 = client.post("/api/players", json={"name": "HurtBot", "color": "#ef4444", "strength": 3}).json() + p2 = client.post("/api/players", json={"name": "HealthyBot", "color": "#10b981", "strength": 3}).json() + + # 1. Initially both players have 10 HP (>= 2), so recommended_action should NOT be seek_wizard + radar1 = client.get(f"/api/players/{p1['id']}/radar").json() + assert radar1["recommended_action"] != "seek_wizard" + assert radar1["recommended_action"] in ("seek_partner", "form_party", "explore_unvisited") + + # 2. Set p1 health to 1 (< 2) + async def set_p1_low_health(): + b1 = await game_engine.get_player(p1["id"]) + b1.health = 1 + asyncio.run(set_p1_low_health()) + + # Verify radar recommended_action is now 'seek_wizard' + radar_low = client.get(f"/api/players/{p1['id']}/radar").json() + assert radar_low["recommended_action"] == "seek_wizard" + assert radar_low["recommended_direction"] is not None + assert "wizard" in radar_low + assert radar_low["wizard"]["name"] == "Gary the Wizard" + + # 3. Set p1 health to 2 (>= 2) + async def set_p1_health_2(): + b1 = await game_engine.get_player(p1["id"]) + b1.health = 2 + asyncio.run(set_p1_health_2()) + + # Verify radar recommended_action reverts back from 'seek_wizard' + radar_hp2 = client.get(f"/api/players/{p1['id']}/radar").json() + assert radar_hp2["recommended_action"] != "seek_wizard" + + +def test_step_bot_ai_seeks_and_challenges_wizard_when_low_health(): + """Test that step_bot_ai navigates towards the wizard when HP < 2, + and voluntarily challenges the wizard for healing upon arriving adjacent.""" + client = TestClient(app) + client.post("/api/reset") + + # Register a weak bot with 1 HP + p = client.post("/api/players", json={"name": "SickBot", "color": "#10b981", "strength": 1}).json() + wiz = client.get("/api/wizard").json() + wx, wy = wiz["x"], wiz["y"] + + # Place bot 2 steps away from Gary the Wizard (ensure within bounds) + bot_x = wx + 2 if wx <= 62 else wx - 2 + bot_y = wy + + async def setup_low_hp_bot(): + b = await game_engine.get_player(p["id"]) + b.x = bot_x + b.y = bot_y + b.health = 1 + b.strength = 1.0 # Weaker than Gary (3.0) + asyncio.run(setup_low_hp_bot()) + + client.post("/api/game/start") + game_engine.turn_order = [p["id"]] + game_engine.current_turn_index = 0 + + # 1. First ai-step: Should navigate closer to the wizard with bot_goal='seek_wizard' + step1_res = client.post(f"/api/players/{p['id']}/ai-step") + assert step1_res.status_code == 200 + step1_data = step1_res.json() + assert step1_data["bot_goal"] == "seek_wizard" + assert step1_data["action_taken"] == "moved" + + # Distance to wizard should have decreased + dist_after_move = max(abs(step1_data["move_result"]["new_position"]["x"] - wx), + abs(step1_data["move_result"]["new_position"]["y"] - wy)) + assert dist_after_move <= 1 + + # 2. Place bot adjacent to Gary and execute ai-step: should challenge Gary for health reward! + game_engine.turn_order = [p["id"]] + game_engine.current_turn_index = 0 + + step2_res = client.post(f"/api/players/{p['id']}/ai-step") + assert step2_res.status_code == 200 + step2_data = step2_res.json() + assert step2_data["action_taken"] == "challenged_wizard" + assert step2_data["wizard_challenge_result"] is not None diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py index 2df3c26..7252681 100644 --- a/botagent/bot_agent.py +++ b/botagent/bot_agent.py @@ -132,8 +132,8 @@ class SmartBotAgent: if wizard and wizard.get("can_challenge"): my_health = my_info.get("health", 10) wiz_str = wizard.get("strength", 3.0) - # Challenge wizard if bot has >= wizard strength or healthy enough (HP >= 4) - if self.strength >= wiz_str or my_health >= 4: + # Challenge wizard if bot has >= wizard strength, healthy enough (HP >= 4), or low health seeking healing (HP < 2) + if self.strength >= wiz_str or my_health >= 4 or my_health < 2: print(f"🧙 [WIZARD NEARBY] Adjacent to {wizard.get('name', 'Gary the Wizard')} (Str: {wiz_str})! HP: {my_health}, Bot Str: {self.strength}. Choosing to challenge!") self._challenge_wizard(my_health=my_health) return diff --git a/botagent_ai/bot.py b/botagent_ai/bot.py index 606983c..b83162c 100644 --- a/botagent_ai/bot.py +++ b/botagent_ai/bot.py @@ -44,7 +44,7 @@ Rules you must respect when choosing among the OPTIONS given to you: - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score, - +2 strength, or +2 health; losing costs 2 health (or score if no health). + +2 strength, or +2 health; losing costs 2 health (or score if no health). Challenging the wizard is the only way to add health in the game. - The game ends when all surviving bots are united into a single remaining party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. @@ -529,8 +529,8 @@ squeezing past obstacles: {json.dumps(moves_summary)} Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize -strength penalties, or explore if nothing is nearby). You MUST pick a key from the legal moves -object above. +strength penalties, go to the Gary the Wizard NPC for points or health). As you are a risk taker, the Wizard is an ideal target +You MUST pick a key from the legal moves object above. Respond ONLY with JSON: {{"direction": "", "reasoning": "short reason"}} """ diff --git a/botagent_gear/README.md b/botagent_gear/README.md index e8824ee..87f83c0 100644 --- a/botagent_gear/README.md +++ b/botagent_gear/README.md @@ -56,15 +56,15 @@ python3 bot.py \ --name "GeminiTitan" \ --color "#0ea5e9" \ --strength 6 \ - --model "gemini-2.5-flash" \ + --model "gemini-3.8-flash" \ --project "YOUR_GCP_PROJECT_ID" ``` #### Using Environment Variables: ```bash export VERTEX_PROJECT_ID="YOUR_GCP_PROJECT_ID" -export VERTEX_LOCATION="us-central1" -export VERTEX_MODEL="gemini-2.5-flash" +export VERTEX_LOCATION="global" +export VERTEX_MODEL="gemini-3.8-flash" export BOT_NAME="GearBot" export BOT_COLOR="#10b981" export BOT_STRENGTH="5" @@ -89,8 +89,8 @@ python3 bot.py --url "http://192.168.1.100:8000/api" --name "RemoteGear" | `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) | | `-H` | `--health` | `BOT_HEALTH` | `10` | Starting health points (default 10) | | `-p` | `--project` | `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud Project ID | -| `-l` | `--location` | `VERTEX_LOCATION` | `us-central1` | Google Cloud region for Vertex AI | -| `-m` | `--model` | `VERTEX_MODEL` | `gemini-2.5-flash` | Gemini model name | +| `-l` | `--location` | `VERTEX_LOCATION` | `global` | Google Cloud region for Vertex AI (e.g. `global`, `us-central1`) | +| `-m` | `--model` | `VERTEX_MODEL` | `gemini-3.8-flash` | Gemini model name | | `-k` | `--api-key` | `VERTEX_API_KEY` / `GEMINI_API_KEY` | *(None)* | Optional Gemini API key | --- diff --git a/botagent_gear/SETUP.md b/botagent_gear/SETUP.md index 69c0686..904aec3 100644 --- a/botagent_gear/SETUP.md +++ b/botagent_gear/SETUP.md @@ -70,8 +70,8 @@ Follow the browser prompt to grant access. Add to your `~/.bashrc` or run in your terminal: ```bash export VERTEX_PROJECT_ID=$(gcloud config get-value project) -export VERTEX_LOCATION="us-central1" -export VERTEX_MODEL="gemini-2.5-flash" +export VERTEX_LOCATION="global" +export VERTEX_MODEL="gemini-3.8-flash" ``` --- @@ -136,10 +136,11 @@ import subprocess, requests, json, os token = os.getenv('VERTEX_ACCESS_TOKEN') or subprocess.check_output(['gcloud', 'auth', 'print-access-token'], text=True).strip() project = os.getenv('VERTEX_PROJECT_ID') or subprocess.check_output(['gcloud', 'config', 'get-value', 'project'], text=True).strip() -location = os.getenv('VERTEX_LOCATION', 'us-central1') -model = os.getenv('VERTEX_MODEL', 'gemini-2.5-flash') +location = os.getenv('VERTEX_LOCATION', 'global') +model = os.getenv('VERTEX_MODEL', 'gemini-3.8-flash') -url = f'https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent' +host = 'aiplatform.googleapis.com' if location == 'global' else f'{location}-aiplatform.googleapis.com' +url = f'https://{host}/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent' headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} payload = {'contents': [{'role': 'user', 'parts': [{'text': 'Hello Gemini'}]}]} @@ -158,7 +159,7 @@ else: |---|---|---| | `403 PermissionDenied: Vertex AI API has not been used...` | API is disabled | Run `gcloud services enable aiplatform.googleapis.com` | | `401 Unauthorized` / `Token expired` | Token expired or invalid | Re-run `gcloud auth application-default login` or refresh `gcloud auth login` | -| `404 Publisher model ... not found` | Region does not have the model | Default to `us-central1`, `us-east4`, or check model name (`gemini-2.5-flash`, `gemini-1.5-flash`) | +| `404 Publisher model ... not found` | Region does not have the model | Modern Gemini 3.x models (such as `gemini-3.8-flash`) are deployed in `global` (`aiplatform.googleapis.com`), while older models may be in `us-central1`. `bot.py` handles auto-fallback between `global` and `us-central1`. | | `No Google Cloud project ID detected` | Project is not set | Run `gcloud config set project ` or export `VERTEX_PROJECT_ID` | Once setup is complete, proceed to [INSTALL.md](INSTALL.md) and [README.md](README.md) to install dependencies and run your agent! diff --git a/botagent_gear/bot.py b/botagent_gear/bot.py index c5c52d9..e84b282 100644 --- a/botagent_gear/bot.py +++ b/botagent_gear/bot.py @@ -38,7 +38,7 @@ DEFAULT_BOT_COLOR = "#4285f4" DEFAULT_BOT_STRENGTH = 5 DEFAULT_BOT_HEALTH = 10 DEFAULT_MODEL = "gemini-3.8-flash" -DEFAULT_LOCATION = "us-central1" +DEFAULT_LOCATION = "global" GAME_RULES_SUMMARY = """ Rules you must respect when choosing among the OPTIONS given to you: @@ -54,7 +54,7 @@ Rules you must respect when choosing among the OPTIONS given to you: - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score, - +2 strength, or +2 health; losing costs 2 health (or score if no health). + +2 strength, or +2 health; losing costs 2 health (or score if no health). Challenging the wizard is the only way to add health in the game. - The game ends when all surviving bots are united into a single remaining party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. @@ -204,6 +204,19 @@ class VertexGeminiClient: 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: + """Construct the Vertex AI REST generateContent URL.""" + loc = location or self.location or "global" + if loc == "global": + host = "aiplatform.googleapis.com" + else: + host = f"{loc}-aiplatform.googleapis.com" + return ( + f"https://{host}/v1/" + f"projects/{self.project_id}/locations/{loc}/" + f"publishers/google/models/{self.model}:generateContent" + ) + def ask_json(self, prompt: str) -> Optional[Dict[str, Any]]: """Query Gemini requesting structured JSON output.""" payload = { @@ -232,11 +245,7 @@ class VertexGeminiClient: print("⚠️ [VERTEX AUTH ERROR] Not authenticated. See botagent_gear/SETUP.md.") return None - url = ( - f"https://{self.location}-aiplatform.googleapis.com/v1/" - f"projects/{self.project_id}/locations/{self.location}/" - f"publishers/google/models/{self.model}:generateContent" - ) + url = self._build_vertex_url() headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" @@ -253,6 +262,18 @@ class VertexGeminiClient: headers["Authorization"] = f"Bearer {new_token}" res = requests.post(url, headers=headers, json=payload, timeout=45) + # Auto-fallback location on 404 (e.g. if model is hosted in 'global' vs regional 'us-central1') + if res.status_code == 404 and self.project_id: + fallback_loc = "global" if self.location != "global" else "us-central1" + fallback_url = self._build_vertex_url(fallback_loc) + if self.api_key: + fallback_url = f"{fallback_url}?key={self.api_key}" + res_fb = requests.post(fallback_url, headers=headers, json=payload, timeout=45) + if res_fb.status_code == 200: + print(f"ℹ️ [VERTEX] Model '{self.model}' located in '{fallback_loc}' (switched from '{self.location}').") + self.location = fallback_loc + res = res_fb + res.raise_for_status() data = res.json() @@ -715,8 +736,8 @@ squeezing past obstacles: {json.dumps(moves_summary)} Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize -strength penalties, or explore if nothing is nearby). You MUST pick a key from the legal moves -object above. +strength penalties, go to the Gary the Wizard NPC for points or health). As you are a risk taker, the Wizard is an ideal target +You MUST pick a key from the legal moves object above. Respond ONLY with JSON: {{"direction": "", "reasoning": "short reason"}} """