2026-09-08 11:13:25 +00:00
""" AI-driven Bot Agent for botWebWars, powered by Google Cloud Vertex AI (Gemini).
Replicates the capabilities of botagent / bot_agent . py and botagent_ai / bot . py
( registration , radar - based navigation , obstacle avoidance , party formation , battles )
while delegating strategic decisions to Google Vertex AI Gemini models :
- Whether to propose a voluntary alliance with another solo bot .
- Which direction to move towards when navigating ( radar / obstacle / local map data ) .
Outcomes mandated by GAME_RULES . md ( forced battles , forced joins based on
relative strength ) are always resolved deterministically by the game engine
regardless of what the LLM prefers - the LLM is only ever offered a choice
among legal options .
"""
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 = " http://localhost:8000/api "
DEFAULT_BOT_NAME = " GeminiGearBot "
DEFAULT_BOT_COLOR = " #4285f4 "
DEFAULT_BOT_STRENGTH = 5
2026-09-09 22:11:54 +00:00
DEFAULT_BOT_HEALTH = 10
2026-09-08 11:13:25 +00:00
DEFAULT_MODEL = " gemini-3.8-flash "
DEFAULT_LOCATION = " us-central1 "
GAME_RULES_SUMMARY = """
Rules you must respect when choosing among the OPTIONS given to you :
- Two solo bots that meet MAY voluntarily ally ( not required ) . The stronger bot ( or higher
score if tied ) leads . Larger parties have an advantage in battle .
- A solo bot always joins a party if the party leader ' s strength >= its own (no choice).
- A solo bot always refuses and fights if the party leader is weaker ( no choice ) .
2026-09-09 22:11:54 +00:00
- Two opposing parties that meet must always battle ( no choice ) . Defeated party members ( including leader )
lose 1 - 3 health points ( randomized ) .
2026-09-10 00:32:22 +00:00
- When a bot ' s health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
replaces its icon on the board , and it can no longer move or take turns . Its final score remains
preserved on the scoreboard .
2026-09-08 11:13:25 +00:00
- Diagonal squeezes between obstacle corners cost strength ( 0.1 solo , 0.2 leader / 0.1 followers ) .
2026-09-09 22:11:54 +00:00
- The Wandering Wizard NPC can be voluntarily challenged when adjacent ( distance < = 1 ) . The challenge
2026-09-10 00:32:22 +00:00
is a 3 - bout D20 duel ( strength * D20 ) . If victorious , the player decides whether to receive + 2 score ,
+ 2 strength , or + 2 health ; losing costs 2 health ( or score if no health ) .
- The game ends when all surviving bots are united into a single remaining party .
2026-09-08 11:13:25 +00:00
You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else .
"""
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 ] ] :
""" Best-effort extraction of a JSON object from a model response. """
if not text :
return None
cleaned = text . strip ( )
# Remove markdown code fences if present
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
# Auto-detect project if not explicitly supplied
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. """
# 1. Direct environment variable token
env_token = os . getenv ( " VERTEX_ACCESS_TOKEN " ) or os . getenv ( " GOOGLE_OAUTH_ACCESS_TOKEN " )
if env_token :
return env_token
# Check cached token freshness
now = time . time ( )
if self . _cached_token and now < self . _token_expiry - 60 :
return self . _cached_token
# 2. Use google-auth library if available
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
# Cache for up to 50 minutes
self . _token_expiry = now + 3000
return self . _cached_token
except Exception as e :
# Fall back to gcloud CLI
pass
# 3. Fall back to gcloud CLI
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=<your-project-id> or run: \n "
" gcloud config set project <your-project-id> \n "
" Refer to botagent_gear/SETUP.md for full instructions. "
)
token = self . _get_access_token ( )
if not token :
return False , (
" Unable to obtain Google Cloud authentication token. \n "
" Please authenticate using one of the methods in botagent_gear/SETUP.md: \n "
" 1. Run: gcloud auth application-default login \n "
" 2. Or run: gcloud auth login \n "
" 3. Or export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json \n "
" 4. 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 ask_json ( self , prompt : str ) - > Optional [ Dict [ str , Any ] ] :
""" Query Gemini requesting structured JSON output. """
payload = {
" contents " : [
{
" role " : " user " ,
" parts " : [ { " text " : prompt } ] ,
}
] ,
" systemInstruction " : {
" parts " : [ { " text " : GAME_RULES_SUMMARY } ]
} ,
" generationConfig " : {
" responseMimeType " : " application/json " ,
" temperature " : 0.3 ,
} ,
}
# Route to Google AI Studio if API key without GCP Project
if self . api_key and not self . project_id :
url = f " https://generativelanguage.googleapis.com/v1beta/models/ { self . model } :generateContent?key= { self . api_key } "
headers = { " Content-Type " : " application/json " }
else :
token = self . _get_access_token ( )
if not token and not self . api_key :
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 "
)
headers = { " Content-Type " : " application/json " }
if token :
headers [ " Authorization " ] = f " Bearer { token } "
elif self . api_key :
url = f " { url } ?key= { self . api_key } "
try :
res = requests . post ( url , headers = headers , json = payload , timeout = 45 )
if res . status_code == 401 :
# Token might have expired, invalidate cache and retry once
self . _cached_token = None
new_token = self . _get_access_token ( )
if new_token :
headers [ " Authorization " ] = f " Bearer { new_token } "
res = requests . post ( url , headers = headers , json = payload , timeout = 45 )
res . raise_for_status ( )
data = res . json ( )
candidates = data . get ( " candidates " , [ ] )
if not candidates :
print ( " ⚠️ [VERTEX EMPTY CANDIDATES] " , data )
return None
parts = candidates [ 0 ] . get ( " content " , { } ) . get ( " parts " , [ ] )
if not parts :
return None
raw_text = parts [ 0 ] . get ( " text " , " " )
return extract_json ( raw_text )
except requests . exceptions . HTTPError as e :
print ( f " ⚠️ [VERTEX HTTP ERROR { res . status_code } ]: { res . text } " )
return None
except requests . exceptions . RequestException as e :
print ( f " ⚠️ [VERTEX REQUEST ERROR]: { e } " )
return None
class VertexAIBotAgent :
def __init__ (
self ,
name : str = DEFAULT_BOT_NAME ,
color : str = DEFAULT_BOT_COLOR ,
strength : int = DEFAULT_BOT_STRENGTH ,
2026-09-09 22:11:54 +00:00
health : int = DEFAULT_BOT_HEALTH ,
2026-09-08 11:13:25 +00:00
server_url : str = DEFAULT_SERVER_URL ,
project_id : Optional [ str ] = None ,
location : str = DEFAULT_LOCATION ,
model : str = DEFAULT_MODEL ,
api_key : Optional [ str ] = None ,
2026-09-09 20:41:06 +00:00
piece_type : Optional [ str ] = None ,
2026-09-08 11:13:25 +00:00
) :
self . name = name
self . color = color
self . strength = strength
2026-09-09 22:11:54 +00:00
self . health = health
2026-09-09 20:41:06 +00:00
self . piece_type = piece_type
2026-09-08 11:13:25 +00:00
self . base_url = normalize_url ( server_url )
self . llm = VertexGeminiClient (
project_id = project_id ,
location = location ,
model = model ,
api_key = api_key ,
)
self . bot_id : Optional [ str ] = None
self . party_id : Optional [ str ] = None
self . is_leader : bool = False
# ------------------------------------------------------------------
# Registration / status
# ------------------------------------------------------------------
def register ( self ) :
""" Register the bot avatar on the 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 " ]
2026-09-09 22:11:54 +00:00
print ( f " 🔄 [RECONNECT] Reconnected to existing { self . name } (ID: { self . bot_id } , HP: { p . get ( ' health ' , self . health ) } ) at ( { p . get ( ' x ' ) } , { p . get ( ' y ' ) } ) " )
2026-09-08 11:13:25 +00:00
return
except Exception :
pass
2026-09-09 22:11:54 +00:00
payload = {
" name " : self . name ,
" color " : self . color ,
" strength " : self . strength ,
" health " : self . health ,
}
2026-09-09 20:41:06 +00:00
if self . piece_type :
payload [ " piece_type " ] = self . piece_type
2026-09-08 11:13:25 +00:00
res = requests . post (
f " { self . base_url } /players " ,
2026-09-09 20:41:06 +00:00
json = payload ,
2026-09-08 11:13:25 +00:00
)
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 " ]
2026-09-09 22:11:54 +00:00
print ( f " 🔄 [RECONNECT] Reconnected to existing { self . name } (ID: { self . bot_id } , HP: { p . get ( ' health ' , self . health ) } ) at ( { p . get ( ' x ' ) } , { p . get ( ' y ' ) } ) " )
2026-09-08 11:13:25 +00:00
return
res . raise_for_status ( )
data = res . json ( )
self . bot_id = data [ " id " ]
2026-09-09 22:11:54 +00:00
print ( f " 🚀 [REGISTER] Spawned { self . name } (ID: { self . bot_id } , Str: { self . strength } , HP: { data . get ( ' health ' , self . health ) } ) at ( { data [ ' x ' ] } , { data [ ' y ' ] } ) " )
2026-09-08 11:13:25 +00:00
def refresh_status ( self ) :
""" Update bot state (party membership, leader status, score). """
res = requests . get ( f " { self . base_url } /players/ { self . bot_id } " )
if res . status_code == 200 :
data = res . json ( )
self . party_id = data . get ( " party_id " )
self . is_leader = data . get ( " is_party_leader " , False )
return data
return None
# ------------------------------------------------------------------
# Core decision loop
# ------------------------------------------------------------------
def decide_and_act ( self ) :
my_info = self . refresh_status ( )
if not my_info :
return
2026-09-10 00:32:22 +00:00
# Death rule: 0 HP means fallen, gravestone on board, no turns or actions
if my_info . get ( " is_alive " ) is False or my_info . get ( " health " , 10 ) < = 0 :
print ( f " 🪦 [FALLEN] { self . name } has fallen (0 HP). Gravestone on board; skipping actions. " )
return
2026-09-08 11:13:25 +00:00
print ( f " \n 🤖 --- Turn for { self . name } | Score: { my_info [ ' score ' ] } | Str: { my_info [ ' strength ' ] } | Party: { self . party_id or ' Solo ' } --- " )
radar_res = requests . get ( f " { self . base_url } /players/ { self . bot_id } /radar " ) . json ( )
targets = radar_res . get ( " targets " , [ ] )
2026-09-09 22:11:54 +00:00
wizard = radar_res . get ( " wizard " )
2026-09-08 11:13:25 +00:00
adjacent_target = None
for t in targets :
if t [ " distance " ] < = 1 and not t [ " is_ally " ] :
adjacent_target = t
break
if adjacent_target :
self . _handle_adjacent_encounter ( adjacent_target , my_info )
2026-09-09 22:11:54 +00:00
elif wizard and wizard . get ( " can_challenge " ) :
2026-09-10 00:32:22 +00:00
should_challenge , reward_choice = self . _decide_wizard_challenge ( wizard , my_info )
if should_challenge :
self . _challenge_wizard ( wizard , reward_choice = reward_choice )
2026-09-09 22:11:54 +00:00
else :
self . _navigate_towards_goal ( radar_res , my_info )
2026-09-08 11:13:25 +00:00
else :
self . _navigate_towards_goal ( radar_res , my_info )
2026-09-10 00:32:22 +00:00
def _decide_wizard_challenge ( self , wizard : Dict [ str , Any ] , my_info : Dict [ str , Any ] ) - > Tuple [ bool , str ] :
""" Ask Gemini whether to challenge the adjacent Wizard NPC and which reward to choose on win. """
2026-09-09 22:11:54 +00:00
prompt = f """ { GAME_RULES_SUMMARY }
You are bot " {self.name} " ( strength { self . strength } , score { my_info [ ' score ' ] } , health { my_info . get ( ' health ' , 10 ) } )
at position ( { my_info [ ' x ' ] } , { my_info [ ' y ' ] } ) .
2026-09-10 00:46:30 +00:00
You are adjacent to the NPC Gary the Wizard ( strength { wizard [ ' strength ' ] } ) .
2026-09-09 22:11:54 +00:00
Challenging the wizard initiates a 3 - bout D20 duel ( strength * roll ) .
2026-09-10 00:32:22 +00:00
- If you win : You choose one reward : + 2 score , + 2 strength , or + 2 health !
2026-09-09 22:11:54 +00:00
- If you lose : - 2 health points ( or - 2 score if no health ) !
2026-09-10 00:32:22 +00:00
Do you want to challenge the wizard to a duel , and if you win , which reward do you want ( " score " , " strength " , or " health " ) ?
2026-09-09 22:11:54 +00:00
2026-09-10 00:32:22 +00:00
Respond ONLY with JSON : { { " challenge_wizard " : true | false , " reward_choice " : " score " | " strength " | " health " , " reasoning " : " short reason " } }
2026-09-09 22:11:54 +00:00
"""
decision = self . llm . ask_json ( prompt ) or { }
challenge = decision . get ( " challenge_wizard " , False )
2026-09-10 00:32:22 +00:00
reward_choice = str ( decision . get ( " reward_choice " , " score " ) ) . lower ( ) . strip ( )
if reward_choice not in ( " score " , " strength " , " health " ) :
reward_choice = " score "
2026-09-09 22:11:54 +00:00
reasoning = decision . get ( " reasoning " , " " )
2026-09-10 00:32:22 +00:00
print ( f " 🧙 [GEMINI DECISION] Challenge Wizard: { challenge } (Reward choice: { reward_choice } ). { reasoning } " )
return bool ( challenge ) , reward_choice
2026-09-09 22:11:54 +00:00
2026-09-10 00:32:22 +00:00
def _challenge_wizard ( self , wizard : Dict [ str , Any ] , reward_choice : str = " score " ) :
2026-09-09 22:11:54 +00:00
""" Execute the challenge against the Wizard NPC. """
2026-09-10 00:46:30 +00:00
print ( f " 🧙 [WIZARD CHALLENGE] Challenging { wizard . get ( ' name ' , ' Gary the Wizard ' ) } to a 3-bout D20 duel (reward if won: +2 { reward_choice } )... " )
2026-09-09 22:11:54 +00:00
try :
res = requests . post (
f " { self . base_url } /wizard/challenge " ,
2026-09-10 00:32:22 +00:00
json = { " player_id " : self . bot_id , " reward_choice " : reward_choice } ,
2026-09-09 22:11:54 +00:00
)
if res . status_code == 200 :
result = res . json ( )
2026-09-10 00:32:22 +00:00
outcome = f " VICTORY (+2 { result . get ( ' reward_chosen ' , reward_choice ) } ) " if result . get ( " player_won " ) else " DEFEAT (-2 HP/pts) "
2026-09-09 22:11:54 +00:00
print ( f " 🧙 [RESULT] { outcome } : Player { result . get ( ' player_bouts_won ' ) } - Wizard { result . get ( ' wizard_bouts_won ' ) } " )
for b in result . get ( " bouts " , [ ] ) :
print ( f " Bout # { b [ ' bout_number ' ] } : Bot D20( { b [ ' player_roll ' ] } )× Str( { b [ ' player_strength ' ] } )= { b [ ' player_score ' ] } vs Wizard D20( { b [ ' wizard_roll ' ] } )× Str( { b [ ' wizard_strength ' ] } )= { b [ ' wizard_score ' ] } -> Winner: { b [ ' winner ' ] } " )
2026-09-10 00:32:22 +00:00
print ( f " Score: { result . get ( ' new_score ' ) } | Strength: { result . get ( ' new_strength ' ) } | HP: { result . get ( ' new_health ' ) } " )
if result . get ( " player_died " ) or result . get ( " new_health " , 10 ) < = 0 :
print ( f " 💀 [DECEASED] { self . name } suffered lethal damage (0 HP) and died! Gravestone placed on board. " )
2026-09-09 22:11:54 +00:00
pos = result . get ( " wizard_respawn_position " )
if pos :
print ( f " 🔮 Wizard vanished and teleported to ( { pos . get ( ' x ' ) } , { pos . get ( ' y ' ) } ) " )
else :
print ( f " Wizard challenge failed: { res . text } " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
except Exception as e :
print ( f " Error challenging wizard: { e } " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
2026-09-08 11:13:25 +00:00
def _handle_adjacent_encounter ( self , target : Dict [ str , Any ] , my_info : Dict [ str , Any ] ) :
""" Resolve the encounter; Gemini only gets a say when the rules allow a choice. """
target_name = target [ " name " ]
target_str = target [ " strength " ]
target_party = target . get ( " party_id " )
print ( f " 🔍 [ADJACENT ENCOUNTER] Next to ' { target_name } ' (Str: { target_str } , Party: { target_party or ' None ' } ) " )
# SCENARIO A: I am a Solo Bot
if not self . party_id :
if not target_party :
# Both solo: alliance is OPTIONAL - ask Gemini
self . _decide_voluntary_alliance ( target , my_info )
else :
# Target belongs to a party: joining/refusing is mandated by relative strength
party_info = requests . get ( f " { self . base_url } /parties/ { target_party } " ) . json ( )
target_leader_str = target_str
if party_info :
leader_player = requests . get ( f " { self . base_url } /players/ { party_info [ ' leader_id ' ] } " ) . json ( )
target_leader_str = leader_player . get ( " strength " , 1 )
if self . strength < = target_leader_str :
print ( f " 🤝 [RULE] Party leader strength { target_leader_str } >= my { self . strength } . Willingly joining squad! " )
self . _step_or_attack ( target )
else :
print ( f " ⚔️ [RULE] Party leader is weaker ( { target_leader_str } < my { self . strength } ). Must refuse and fight! " )
self . _initiate_battle ( target [ " id " ] )
# SCENARIO B: I am in a Party
else :
if not self . is_leader :
print ( " 🛡️ [PARTY MEMBER] Under command of party leader. Awaiting leader movement. " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
return
if not target_party :
if target_str < = self . strength :
print ( f " 🤝 [RULE] Solo bot { target_name } is willing to join our squad under my leadership. " )
self . _step_or_attack ( target )
else :
print ( f " ⚔️ [RULE] Solo bot { target_name } refuses weaker leader! Squad is attacking! " )
self . _initiate_battle ( target [ " id " ] )
else :
print ( f " ⚔️ [RULE] Hostile party detected: ' { target . get ( ' party_name ' ) } ' ! Battle is mandatory! " )
self . _initiate_battle ( target [ " id " ] )
def _decide_voluntary_alliance ( self , target : Dict [ str , Any ] , my_info : Dict [ str , Any ] ) :
""" Ask Gemini whether to propose a voluntary alliance with another solo bot. """
prompt = f """ { GAME_RULES_SUMMARY }
You are bot " {self.name} " ( strength { self . strength } , score { my_info [ ' score ' ] } ) .
You just encountered another solo bot " {target['name']} " ( strength { target [ ' strength ' ] } ) .
Whoever has greater strength ( or higher score if tied ) will lead the new party .
Forming an alliance is optional - larger parties are stronger in future battles , but you
give up independent control if you are not the stronger one .
Respond ONLY with JSON : { { " form_alliance " : true | false , " reasoning " : " short reason " } }
"""
decision = self . llm . ask_json ( prompt ) or { }
form_alliance = decision . get ( " form_alliance " , True )
reasoning = decision . get ( " reasoning " , " " )
if form_alliance :
leader_id = self . bot_id if self . strength > = target [ " strength " ] else target [ " id " ]
print ( f " 🤝 [GEMINI DECISION] Ally with { target [ ' name ' ] } ! Leader: { ' me ' if leader_id == self . bot_id else target [ ' name ' ] } . { reasoning } " )
self . _execute_party_formation ( [ self . bot_id , target [ " id " ] ] , leader_id )
else :
print ( f " 🚶 [GEMINI DECISION] Declining alliance with { target [ ' name ' ] } . { reasoning } " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
def _execute_party_formation ( self , member_ids : List [ str ] , leader_id : str ) :
""" Form a party using the REST API. """
try :
res = requests . post (
f " { self . base_url } /parties " ,
json = { " member_ids " : member_ids , " leader_id " : leader_id , " name " : f " Squad_ { self . name } " } ,
)
if res . status_code == 201 :
party = res . json ( )
print ( f " ✅ [PARTY FORMED] Squad ' { party [ ' name ' ] } ' established! Leader: { party [ ' leader_name ' ] } | Str: { party [ ' total_strength ' ] } " )
else :
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
except Exception as e :
print ( f " Party formation error: { e } " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
def _initiate_battle ( self , opponent_id : str ) :
""" Explicitly call the 3-Bout D20 Battle endpoint. """
print ( f " 🎲 [BATTLE INITIATED] Clashing with opponent { opponent_id } ... " )
res = requests . post (
f " { self . base_url } /battles/fight " ,
json = { " challenger_id " : self . bot_id , " defender_id " : opponent_id } ,
)
if res . status_code == 200 :
battle = res . json ( )
print ( " \n --- ⚔️ 3-BOUT BATTLE RESOLUTION --- " )
print ( f " Bouts won: { battle [ ' party1_name ' ] } ( { battle [ ' party1_bouts_won ' ] } ) vs { battle [ ' party2_name ' ] } ( { battle [ ' party2_bouts_won ' ] } ) " )
for b in battle [ " bouts " ] :
print ( f " Bout # { b [ ' bout_number ' ] } : Roll { b [ ' party1_roll ' ] } × { b [ ' party1_strength ' ] } ( { b [ ' party1_score ' ] } ) vs Roll { b [ ' party2_roll ' ] } × { b [ ' party2_strength ' ] } ( { b [ ' party2_score ' ] } ) -> Winner: { b [ ' winner_name ' ] } " )
print ( f " 🏆 Overall Winner: { battle [ ' winner_party_name ' ] } (Leader { battle [ ' winner_leader_name ' ] } receives +2 pts) " )
print ( f " 💀 Defeated: { battle [ ' defeated_party_name ' ] } (Leader { battle [ ' killed_leader_name ' ] } -1 pt) " )
if battle . get ( " absorbed_members " ) :
print ( f " 🧲 Absorbed { len ( battle [ ' absorbed_members ' ] ) } member(s) into { battle [ ' winner_party_name ' ] } " )
2026-09-10 00:32:22 +00:00
if battle . get ( " dead_players " ) :
print ( f " 🪦 Casualties: { ' , ' . join ( battle [ ' dead_players ' ] ) } reached 0 HP and fell! " )
if self . bot_id in battle [ " dead_players " ] :
print ( f " 💀 [DECEASED] { self . name } suffered lethal damage (0 HP) and died! Gravestone placed on board. " )
2026-09-08 11:13:25 +00:00
else :
print ( f " Battle failed ( { res . status_code } ): { res . text } " )
def _step_or_attack ( self , target : Dict [ str , Any ] ) :
""" Move adjacent/towards the target while avoiding obstacles. """
moves_res = requests . get ( f " { self . base_url } /players/ { self . bot_id } /available-moves " ) . json ( )
moves = moves_res . get ( " moves " , { } )
chosen = self . _closest_direction_to ( target [ " x " ] , target [ " y " ] , moves )
if chosen :
res = requests . post ( f " { self . base_url } /players/ { self . bot_id } /move " , json = { " direction " : chosen } ) . json ( )
if res . get ( " battle_triggered " ) :
print ( f " ⚔️ Move triggered battle! Winner: { res [ ' battle_result ' ] [ ' winner_party_name ' ] } " )
elif res . get ( " party_formed_triggered " ) :
print ( " 🤝 Move resulted in party alliance! " )
else :
print ( " ⚠️ No passable moves adjacent to target. Passing turn. " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
@staticmethod
def _closest_direction_to ( 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 ) )
return min ( valid_moves . keys ( ) , key = lambda d : ( valid_moves [ d ] . get ( " strength_penalty " , 0.0 ) > 0 , dist ( valid_moves [ d ] ) ) )
# ------------------------------------------------------------------
# Navigation (Gemini-driven direction choice among legal moves)
# ------------------------------------------------------------------
def _navigate_towards_goal ( self , radar_res : Dict [ str , Any ] , my_info : Dict [ str , Any ] ) :
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 :
print ( " 🚫 All adjacent paths blocked by borders/obstacles. Passing turn. " )
requests . post ( f " { self . base_url } /players/ { self . bot_id } /pass " )
return
chosen_dir = self . _ask_llm_for_direction ( radar_res , moves , available , my_info )
if chosen_dir not in available :
# Guard-rail: fall back to server-recommended or nearest-distance direction
rec_dir = radar_res . get ( " recommended_direction " )
nearest = radar_res . get ( " nearest_target " )
if rec_dir in available :
chosen_dir = rec_dir
elif nearest :
chosen_dir = self . _closest_direction_to ( nearest [ " x " ] , nearest [ " y " ] , moves ) or available [ 0 ]
else :
chosen_dir = available [ 0 ]
print ( f " 🧭 Moving { chosen_dir } (Goal: { radar_res . get ( ' bot_goal ' ) } , Action: { radar_res . get ( ' recommended_action ' ) } ) " )
res = requests . post ( f " { self . base_url } /players/ { self . bot_id } /move " , json = { " direction " : chosen_dir } ) . json ( )
if res . get ( " battle_triggered " ) :
print ( f " ⚔️ Encounter battle! Winner: { res [ ' battle_result ' ] [ ' winner_party_name ' ] } " )
elif res . get ( " party_formed_triggered " ) :
print ( f " 🤝 Formed or joined squad: { res . get ( ' formed_party ' , { } ) . get ( ' name ' ) } " )
def _get_board_snapshot ( self ) - > Optional [ Dict [ str , Any ] ] :
""" Fetch the full board so Gemini sees more than radar ' s nearest few. """
try :
res = requests . get ( f " { self . base_url } /board " , timeout = 10 )
res . raise_for_status ( )
return res . json ( )
except requests . exceptions . RequestException as e :
print ( f " ⚠️ [BOARD FETCH ERROR] { e } " )
return None
OBSTACLE_SYMBOLS = { " mountain " : " M " , " forest " : " F " , " valley " : " V " }
LOCAL_MAP_RADIUS = 8
def _build_local_map ( self , board : Dict [ str , Any ] , center_x : int , center_y : int ) - > List [ str ] :
""" Render an ASCII minimap centered on the bot: @ = self, A = ally, E = enemy, M/F/V = obstacles, . = open. """
config = board . get ( " config " , { } )
max_x = config . get ( " max_x " , 64 )
max_y = config . get ( " max_y " , 64 )
radius = self . LOCAL_MAP_RADIUS
obstacle_at = { ( o [ " x " ] , o [ " y " ] ) : o . get ( " type " , " mountain " ) for o in board . get ( " obstacles " , [ ] ) }
player_at : Dict [ Tuple [ int , int ] , List [ Dict [ str , Any ] ] ] = { }
for p in board . get ( " players " , [ ] ) :
if p [ " id " ] == self . bot_id :
continue
player_at . setdefault ( ( p [ " x " ] , p [ " y " ] ) , [ ] ) . append ( p )
2026-09-09 22:11:54 +00:00
wizard = board . get ( " wizard " )
wizard_pos = ( wizard [ " x " ] , wizard [ " y " ] ) if wizard else None
2026-09-08 11:13:25 +00:00
rows = [ ]
for y in range ( center_y - radius , center_y + radius + 1 ) :
row_chars = [ ]
for x in range ( center_x - radius , center_x + radius + 1 ) :
if x == center_x and y == center_y :
row_chars . append ( " @ " )
elif x < 0 or y < 0 or x > = max_x or y > = max_y :
row_chars . append ( " # " )
2026-09-09 22:11:54 +00:00
elif wizard_pos and ( x , y ) == wizard_pos :
row_chars . append ( " W " )
2026-09-08 11:13:25 +00:00
elif ( x , y ) in obstacle_at :
row_chars . append ( self . OBSTACLE_SYMBOLS . get ( obstacle_at [ ( x , y ) ] , " M " ) )
elif ( x , y ) in player_at :
occupants = player_at [ ( x , y ) ]
is_ally = self . party_id and any ( o . get ( " party_id " ) == self . party_id for o in occupants )
row_chars . append ( " A " if is_ally else " E " )
else :
row_chars . append ( " . " )
rows . append ( " " . join ( row_chars ) )
return rows
def _ask_llm_for_direction (
self ,
radar_res : Dict [ str , Any ] ,
moves : Dict [ str , Any ] ,
available : List [ str ] ,
my_info : Dict [ str , Any ] ,
) - > Optional [ str ] :
targets_summary = [
{
" name " : t [ " name " ] ,
" x " : t [ " x " ] ,
" y " : t [ " y " ] ,
" distance " : t [ " distance " ] ,
" strength " : t [ " strength " ] ,
" party " : t . get ( " party_name " ) or ( " solo " if not t . get ( " party_id " ) else t . get ( " party_id " ) ) ,
" is_ally " : t . get ( " is_ally " , False ) ,
}
for t in radar_res . get ( " targets " , [ ] )
]
moves_summary = {
d : {
" target_x " : chk . get ( " target_x " ) ,
" target_y " : chk . get ( " target_y " ) ,
" strength_penalty " : chk . get ( " strength_penalty " , 0.0 ) ,
}
for d , chk in moves . items ( )
if d in available
}
board = self . _get_board_snapshot ( )
map_section = " "
if board :
local_map = self . _build_local_map ( board , my_info [ " x " ] , my_info [ " y " ] )
map_section = f """
Local map ( radius { self . LOCAL_MAP_RADIUS } around you , row = one Y line , top - to - bottom is
increasing Y , left - to - right is increasing X ) : @ = you , A = ally , E = enemy / neutral bot ,
2026-09-10 00:46:30 +00:00
W = Gary the Wizard NPC , M = mountain , F = forest , V = valley , # = out of bounds, . = open ground.
2026-09-08 11:13:25 +00:00
{ chr ( 10 ) . join ( local_map ) }
"""
prompt = f """ { GAME_RULES_SUMMARY }
You are bot " {self.name} " ( strength { self . strength } , score { my_info [ ' score ' ] } , party : { self . party_id or ' Solo ' } )
at position ( { my_info [ ' x ' ] } , { my_info [ ' y ' ] } ) .
Server ' s radar suggestion: recommended_direction= { radar_res.get( ' recommended_direction ' )},
recommended_action = { radar_res . get ( ' recommended_action ' ) } , goal = { radar_res . get ( ' bot_goal ' ) } .
{ map_section }
All known bots / parties on the board ( sorted nearest first ) : { json . dumps ( targets_summary ) }
Your ONLY legal moves this turn , with resulting coordinates and any strength penalty for
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 .
Respond ONLY with JSON : { { " direction " : " <one of {available} > " , " reasoning " : " short reason " } }
"""
decision = self . llm . ask_json ( prompt ) or { }
direction = decision . get ( " direction " )
reasoning = decision . get ( " reasoning " , " " )
if reasoning :
print ( f " 🧠 [GEMINI] { reasoning } " )
return direction
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def run ( self ) :
# Validate authentication upfront
auth_ok , auth_msg = self . llm . check_auth ( )
if not auth_ok :
print ( f " \n ❌ [AUTH SETUP REQUIRED] \n { auth_msg } \n " )
sys . exit ( 1 )
else :
print ( f " ✨ [AUTH SUCCESS] { auth_msg } " )
self . register ( )
try :
while True :
2026-09-10 00:32:22 +00:00
# Check life status: dead players cannot act but remain on board/scores
my_status = self . refresh_status ( )
if my_status and ( my_status . get ( " is_alive " ) is False or my_status . get ( " health " , 10 ) < = 0 ) :
print ( f " \n 🪦 [FALLEN] { self . name } has fallen (0 HP)! Gravestone marked on board. " )
print ( f " Final Score: { my_status . get ( ' score ' , 0 ) } pts preserved on scoreboard. Spectating until game conclusion... " )
while True :
try :
conc = requests . get ( f " { self . base_url } /game/conclusion " ) . json ( )
if conc . get ( " concluded " ) :
print ( f " \n 🎉 [GAME CONCLUDED] Game ended! Winning squad: ' { conc . get ( ' winning_party_name ' ) } ' " )
return
except Exception :
pass
time . sleep ( 2.0 )
2026-09-08 11:13:25 +00:00
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 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 :
self . decide_and_act ( )
conc = requests . get ( f " { self . base_url } /game/conclusion " ) . json ( )
if conc . get ( " concluded " ) :
print ( f " \n 🎉 [GAME CONCLUDED] All bots united under ' { conc [ ' winning_party_name ' ] } ' ! " )
break
else :
time . sleep ( 0.4 )
except KeyboardInterrupt :
print ( f " \n Disconnecting { self . name } ... " )
2026-09-10 00:32:22 +00:00
# If still alive, remove from board; if deceased, preserve on board and scoreboard
my_status = self . refresh_status ( )
if my_status and my_status . get ( " is_alive " , True ) and my_status . get ( " health " , 10 ) > 0 :
2026-09-08 11:13:25 +00:00
requests . delete ( f " { self . base_url } /players/ { self . bot_id } " )
2026-09-10 00:32:22 +00:00
else :
print ( f " Preserving fallen { self . name } (0 HP gravestone) on board and scoreboard. " )
2026-09-08 11:13:25 +00:00
def main ( ) :
env_url = os . environ . get ( " BOT_SERVER_URL " ) or os . environ . get ( " SERVER_URL " ) or DEFAULT_SERVER_URL
env_name = os . environ . get ( " BOT_NAME " , DEFAULT_BOT_NAME )
env_color = os . environ . get ( " BOT_COLOR " , DEFAULT_BOT_COLOR )
env_strength = int ( os . environ . get ( " BOT_STRENGTH " , str ( DEFAULT_BOT_STRENGTH ) ) )
2026-09-09 22:11:54 +00:00
env_health = int ( os . environ . get ( " BOT_HEALTH " , str ( DEFAULT_BOT_HEALTH ) ) )
2026-09-08 11:13:25 +00:00
env_project = os . environ . get ( " VERTEX_PROJECT_ID " ) or os . environ . get ( " GCP_PROJECT " ) or os . environ . get ( " GOOGLE_CLOUD_PROJECT " )
env_location = os . environ . get ( " VERTEX_LOCATION " ) or os . environ . get ( " GCP_REGION " ) or DEFAULT_LOCATION
env_model = os . environ . get ( " VERTEX_MODEL " , DEFAULT_MODEL )
env_api_key = os . environ . get ( " VERTEX_API_KEY " ) or os . environ . get ( " GEMINI_API_KEY " )
2026-09-09 20:41:06 +00:00
env_piece_type = os . environ . get ( " BOT_PIECE_TYPE " )
2026-09-08 11:13:25 +00:00
parser = argparse . ArgumentParser (
description = " Vertex AI (Gemini) Bot Agent for botWebWars " ,
formatter_class = argparse . ArgumentDefaultsHelpFormatter ,
)
parser . add_argument ( " -u " , " --url " , dest = " server_url " , default = env_url ,
help = " Backend REST API base URL (env: BOT_SERVER_URL) " )
parser . add_argument ( " -n " , " --name " , dest = " name " , default = env_name ,
help = " Display name for this bot (env: BOT_NAME) " )
parser . add_argument ( " -c " , " --color " , dest = " color " , default = env_color ,
help = " Hex color code for the bot avatar (env: BOT_COLOR) " )
parser . add_argument ( " -s " , " --strength " , dest = " strength " , type = int , default = env_strength ,
help = " Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH) " )
2026-09-09 22:11:54 +00:00
parser . add_argument ( " -H " , " --health " , dest = " health " , type = int , default = env_health ,
help = " Starting health points (default 10) (env: BOT_HEALTH) " )
2026-09-08 11:13:25 +00:00
parser . add_argument ( " -p " , " --project " , dest = " project_id " , default = env_project ,
help = " Google Cloud Project ID (env: VERTEX_PROJECT_ID or GCP_PROJECT) " )
parser . add_argument ( " -l " , " --location " , dest = " location " , default = env_location ,
help = " Vertex AI region / location (env: VERTEX_LOCATION) " )
parser . add_argument ( " -m " , " --model " , dest = " model " , default = env_model ,
help = " Gemini model ID (env: VERTEX_MODEL) " )
parser . add_argument ( " -k " , " --api-key " , dest = " api_key " , default = env_api_key ,
help = " Gemini API Key or Vertex AI express mode key (env: VERTEX_API_KEY or GEMINI_API_KEY) " )
2026-09-09 20:41:06 +00:00
parser . add_argument ( " --piece-type " , dest = " piece_type " , choices = [ " knight " , " warrior " ] , default = env_piece_type ,
help = " Board game piece class: ' knight ' or ' warrior ' (env: BOT_PIECE_TYPE) " )
2026-09-08 11:13:25 +00:00
args = parser . parse_args ( )
print ( f " --- Vertex AI Gemini Bot Agent --- " )
print ( f " Target Model: { args . model } " )
print ( f " Region: { args . location } " )
if args . project_id :
print ( f " GCP Project: { args . project_id } " )
print ( )
agent = VertexAIBotAgent (
name = args . name ,
color = args . color ,
strength = args . strength ,
2026-09-09 22:11:54 +00:00
health = args . health ,
2026-09-08 11:13:25 +00:00
server_url = args . server_url ,
project_id = args . project_id ,
location = args . location ,
model = args . model ,
api_key = args . api_key ,
2026-09-09 20:41:06 +00:00
piece_type = args . piece_type ,
2026-09-08 11:13:25 +00:00
)
agent . run ( )
if __name__ == " __main__ " :
main ( )