add GEAR, add AGENTS.md

This commit is contained in:
Isaac Johnson 2026-09-08 06:13:25 -05:00
parent 1635cfe8fd
commit 80200ca1f3
6 changed files with 1407 additions and 0 deletions

290
AGENTS.md Normal file
View File

@ -0,0 +1,290 @@
# AGENTS.md - botWebWars AI Agent Guide
Welcome to **botWebWars**! This document provides architectural context, component specifications, game rule references, and developer workflows for GenAI coding agents (and human developers) working in this repository.
---
## 1. System Overview
**botWebWars** is an autonomous tactical bot arena built on a **64 × 64 discrete grid** (coordinates `(0, 0)` to `(64, 64)`). Bots register with custom names, colors, and strength values, explore the terrain, negotiate party alliances based on relative strength, maintain squad formations, and battle opposing parties in **3-bout D20 tactical confrontations**.
### Tech Stack
- **Backend**: Python 3.12, FastAPI, Uvicorn, Pydantic v2, WebSockets, Pytest.
- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS v4, Lucide React icons.
- **Deployment**: Multi-stage Dockerfile (Node 22 build -> Python 3.12-slim runtime), Docker Compose.
- **Bots**:
- `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).
---
## 2. Repository Layout
```
botWebWars/
├── AGENTS.md # This file: AI agent orientation and system manual
├── GAME_RULES.md # Canonical rules of engagement, movement, and scoring
├── README.md # Human-facing overview and quickstart
├── Dockerfile # Multi-stage production container build (frontend + backend)
├── docker-compose.yml # Single-service container composition on port 8000
├── backend/ # FastAPI application & game engine
│ ├── app/
│ │ ├── main.py # FastAPI entry point, CORS, static SPA serving, WebSockets
│ │ ├── game.py # Core game logic, grid state, movement, battle resolution
│ │ ├── models.py # Pydantic schemas (Player, Party, Battle, Move, etc.)
│ │ ├── config.py # App settings & environment configurations
│ │ └── api/
│ │ ├── routes.py # REST endpoints (/api/players, /api/board, /api/parties, etc.)
│ │ └── websocket.py # Connection manager for real-time WebSocket clients
│ ├── tests/
│ │ └── test_api.py # Pytest test suite for game mechanics & API routes
│ └── requirements.txt # Backend dependencies
├── frontend/ # React + Vite + TypeScript application
│ ├── src/
│ │ ├── App.tsx # Main UI shell, turn HUD, control panel, modals
│ │ ├── components/ # Grid, radar, scoreboard, battle log, player controls
│ │ ├── hooks/ # WebSocket connections and state synchronization
│ │ └── types.ts # Frontend TypeScript interfaces mirroring backend models
│ ├── package.json # Frontend dependencies and build scripts
│ └── vite.config.ts # Vite build configuration
├── botagent/ # Heuristic autonomous bot
│ ├── bot_agent.py # Autonomous client using radar, memory, and game API
│ └── README.md # Execution command examples
├── botagent_ai/ # LLM-assisted autonomous bot (Ollama)
│ ├── bot.py # Bot delegating strategic pathing/alliances to an LLM
│ ├── requirements.txt # Dependencies (ollama, requests)
│ └── INSTALL.md # Setup, Ollama configuration, and execution instructions
├── botagent_gear/ # LLM-assisted autonomous bot (Google Vertex AI / Gemini)
│ ├── bot.py # Bot delegating decisions to Vertex AI Gemini
│ ├── requirements.txt # Dependencies (google-auth, requests)
│ ├── SETUP.md # Google Cloud authentication & Vertex AI setup guide
│ ├── INSTALL.md # Installation, virtual environment, and dependency instructions
│ └── README.md # Feature overview and invocation examples
└── examples/ # Reference screenshots and board style designs
```
---
## 3. Game Rules & Mechanics (`GAME_RULES.md` Reference)
When writing bots, backend engine logic, or game simulations, adhere strictly to [GAME_RULES.md](GAME_RULES.md):
### 1. Alliances (Solo vs. Solo)
- When two solo bots encounter each other (adjacent or on same tile), they may form an alliance.
- The bot with **greater strength** (or higher score if tied) becomes the **Party Leader**.
- While alliances are voluntary, larger parties have a statistical advantage in battle.
### 2. Solo vs. Party Encounters
- A solo bot **joins** an existing party if the party leader's strength is >= the solo bot's strength.
- If the party leader is **weaker** than the solo bot, the solo bot refuses to join, and the party engages the solo bot in battle.
### 3. Party vs. Party Battles (3-Bout D20)
- Two opposing parties that encounter each other **must** battle.
- **Bout Resolution**:
- Exactly 3 bouts are conducted.
- In each bout, each party rolls a 20-sided die (D20 between 1 and 20).
- Bout Score = Squad Total Strength * D20 Roll.
- Highest score wins the bout. Best 2-out-of-3 bouts wins the overall battle.
- **Scoring & Penalties**:
- **Winning Leader**: +2 score points.
- **Winning Party Members**: +1 score point each.
- **Losing Leader**: -1 score point, stripped of leadership, removed from party, and respawned at a random open grid coordinate.
- **Losing Party Members**: 0 score penalty; all surviving followers are **absorbed** into the winning party.
### 4. Party Squad Movement
- The party leader chooses movement direction.
- Follower members follow the leader in a single-file line (each member moves to the position vacated by the bot immediately ahead of them) maintaining Chebyshev distance <= 1.
### 5. Terrain Obstacles & Diagonal Squeeze
- Mountains and forests are impassable.
- Bots can squeeze diagonally between adjacent obstacle corners, with strength penalties:
- **Solo bot**: -0.1 strength.
- **Party**: Leader loses -0.2 strength; followers each lose -0.1 strength.
- Moving along outer obstacle edges has no penalty. Minimum bot strength floor is 0.1.
### 6. Game Conclusion
- The game concludes when all bots on the board are united into a **single remaining party**.
- Final rankings/trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker).
---
## 4. Backend & API Specifications
The backend serves both REST endpoints under `/api` and a live WebSocket stream at `/ws`.
### Key REST Endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/health` | Container healthcheck endpoint |
| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int}` |
| `GET` | `/api/players` | List all active players, scores, and positions |
| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, current turn) |
| `POST` | `/api/board/reset` | Clear board, reset parties, and reset players |
| `GET` | `/api/players/{id}/radar` | Scans surroundings, finds closest bots, identifies allies/opponents |
| `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}/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 |
| `GET` | `/api/parties` | List all parties, leaders, and member IDs |
| `POST` | `/api/parties` | Manually construct an alliance party |
| `POST` | `/api/battles/fight` | Initiate a 3-bout D20 battle between adjacent parties |
### Real-Time WebSocket (`/ws`)
- Automatically broadcasts state changes (`init`, `move`, `battle`, `party_formed`, `turn_change`, `game_over`).
- Client can send ping messages `{"action": "ping"}` and receives `{"event": "pong"}`.
---
## 5. Frontend Architecture
- **Path**: `frontend/`
- **Framework**: React 19 + TypeScript bundled with Vite.
- **Styling**: Tailwind CSS v4.
- **Key Features**:
- Real-time 64x64 board renderer with zoom/pan and custom obstacle textures.
- Live turn indicators, active player highlight, and party squad link lines.
- Radar visualization panel showing target distances and alignment.
- Battle modal showing animated D20 rolls, multipliers, bout winners, and score adjustments.
- Autonomous loop toggle to run rounds continuously from the browser.
---
## 6. Docker & Container Deployment
The application is containerized into a single unified image via [Dockerfile](Dockerfile) and [docker-compose.yml](docker-compose.yml):
1. **Stage 1 (`frontend-builder`)**:
- Base image: `node:22-alpine`
- Installs packages and runs `npm run build` to generate `frontend/dist`.
2. **Stage 2 (`production`)**:
- Base image: `python:3.12-slim`
- Installs backend requirements from `backend/requirements.txt`.
- Copies `backend/` source and static assets from `frontend/dist` into `frontend/dist`.
- FastAPI serves static assets at `/` and `/assets` while mounting API routes at `/api` and `/ws`.
- Built-in container healthcheck calls `curl -f http://localhost:8000/api/health`.
---
## 7. Bot Implementations
### A. Heuristic Bot: `botagent/`
- **Entry File**: `botagent/bot_agent.py`
- **Technique**: Deterministic rule-based autonomous agent.
- **Workflow**:
1. Registers bot via `POST /api/players`.
2. Polls `GET /api/turn` to wait for its turn.
3. Uses `GET /api/players/{id}/radar` to find nearest target.
4. Avoids looping using internal coordinate history.
5. Computes vector direction, evaluates diagonal obstacles, and moves.
6. Evaluates alliances vs. fights strictly according to strength hierarchy.
- **Run Command**:
```bash
python3 botagent/bot_agent.py --name CyberBot --color "#10b981" -s 4 --url http://localhost:8000/api
```
### B. LLM-Assisted Bot: `botagent_ai/`
- **Entry File**: `botagent_ai/bot.py`
- **Technique**: LLM strategic decision maker with deterministic rule safeguards.
- **LLM Integration**:
- Communicates with an LLM backend (configured for local/remote Ollama HTTP API at `/api/generate` with model `gemma4:12b`, or adaptable to OpenAI-compatible endpoints).
- Delegates discretionary decisions to the model:
- Voluntary alliances (whether to ally or keep hunting when solo meets solo).
- Navigation direction toward radar targets while balancing obstacle squeeze trade-offs.
- Mandatory rules (forced battles, forced absorption when weaker than leader) are enforced deterministically by the game engine regardless of LLM preference.
- **Configuration & Environment Variables**:
- `OLLAMA_BASE_URL` / `--ollama-url`: LLM server address (default: `http://192.168.1.220:11434`).
- `OLLAMA_MODEL` / `--ollama-model`: Model identifier (default: `gemma4:12b`).
- `BOT_SERVER_URL` / `-u`: botWebWars backend URL (default: `http://localhost:8000/api`).
- `BOT_NAME` / `-n`: Bot name.
- `BOT_COLOR` / `-c`: Bot hex color.
- `BOT_STRENGTH` / `-s`: Starting strength (1-10).
- **Run Command**:
```bash
export OLLAMA_BASE_URL="http://localhost:11434"
export OLLAMA_MODEL="gemma4:12b"
python3 botagent_ai/bot.py -n MyAIBot -s 4 -c "#8b5cf6"
```
### C. Vertex AI (Gemini) Bot: `botagent_gear/`
- **Entry File**: `botagent_gear/bot.py`
- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for spatial navigation, minimap analysis, and strategic voluntary alliances.
- **LLM Integration**:
- Communicates with Google Cloud Vertex AI generateContent REST endpoint or Google AI Studio Gemini API.
- Features structured JSON generation (`responseMimeType: application/json`) and system instruction enforcement.
- Supports multiple authentication methods:
- Interactive developer login (`gcloud auth application-default login` or `gcloud auth login`).
- Automated service account keys (`GOOGLE_APPLICATION_CREDENTIALS`).
- Direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`).
- **Documentation**:
- [SETUP.md](botagent_gear/SETUP.md): Google Cloud authentication, project creation, API enablement, and credentials setup.
- [INSTALL.md](botagent_gear/INSTALL.md): Virtual environment and dependency installation instructions.
- [README.md](botagent_gear/README.md): Bot overview, CLI reference, and execution examples.
- **Run Command**:
```bash
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5
```
---
## 8. Common Developer Workflows
### Run with Docker Compose (Recommended)
```bash
docker compose up --build
```
Access UI at `http://localhost:8000`.
### Local Development (Manual Setup)
#### 1. Backend:
```bash
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
#### 2. Frontend:
```bash
cd frontend
npm install
npm run dev
```
#### 3. Run Backend Test Suite:
```bash
# Run locally with pytest
PYTHONPATH=backend pytest backend/tests
# Or run via docker container
docker run --rm botwebwars:test pytest backend/tests
```
---
## 9. Guidelines for GenAI Agents Modifying this Codebase
1. **Preserve Rules Fidelity**:
- Any updates to alliance logic, battles, diagonal squeezes, or victory states must match [GAME_RULES.md](GAME_RULES.md).
- Update `GAME_RULES.md` and tests in `backend/tests/test_api.py` whenever game mechanics are modified.
2. **Frontend-Backend Sync**:
- When modifying schemas in `backend/app/models.py`, update matching TypeScript interfaces in `frontend/src/types.ts`.
- Maintain WebSocket payload compatibility between `backend/app/api/websocket.py` and frontend hooks (`frontend/src/hooks/`).
3. **Bot Compatibility**:
- Ensure REST endpoint signature modifications are reflected in `botagent/bot_agent.py`, `botagent_ai/bot.py`, and `botagent_gear/bot.py`.
- Preserve CLI flag compatibility across all bot scripts.
4. **Multi-Stage Build Integrity**:
- If adding frontend build dependencies, verify `Dockerfile` compiles cleanly in stage 1 (`npm run build`) and correctly mounts to `frontend/dist` in stage 2.

77
botagent_gear/INSTALL.md Normal file
View File

@ -0,0 +1,77 @@
# Installation & Setup Guide for botagent_gear
`botagent_gear` is an autonomous botWebWars agent powered by **Google Cloud Vertex AI** and **Gemini** models.
---
## Prerequisites
- **Python 3.9+** (tested on 3.10, 3.11, 3.12, and 3.14).
- Active Google Cloud Vertex AI access or Gemini API Key (see [SETUP.md](SETUP.md)).
- Running **botWebWars** game server (by default at `http://localhost:8000/api`).
---
## Installation Steps
### 1. Navigate to the bot directory
```bash
cd botagent_gear
```
### 2. (Recommended) Create and activate a Virtual Environment
```bash
python3 -m venv venv
source venv/bin/activate
```
*(On Windows: `venv\Scripts\activate`)*
### 3. Install Python Dependencies
```bash
pip install -r requirements.txt
```
The dependencies are kept lightweight and robust:
- `requests`: HTTP client for the botWebWars game API and Vertex AI REST endpoints.
- `google-auth`: Google Cloud Application Default Credentials (ADC) and OAuth 2.0 token management.
---
## Configuration
You can configure the bot via environment variables or command-line arguments.
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `VERTEX_PROJECT_ID` | Auto-detected from `gcloud` | Google Cloud project ID |
| `VERTEX_LOCATION` | `us-central1` | Vertex AI region (`us-central1`, `us-east4`, etc.) |
| `VERTEX_MODEL` | `gemini-2.5-flash` | Model ID (`gemini-2.5-flash`, `gemini-1.5-flash`, `gemini-2.5-pro`) |
| `GEMINI_API_KEY` / `VERTEX_API_KEY` | *(None)* | Optional API key for Google AI Studio or Vertex express mode |
| `BOT_SERVER_URL` | `http://localhost:8000/api` | botWebWars REST API endpoint |
| `BOT_NAME` | `GeminiGearBot` | Bot display name on the grid |
| `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar |
| `BOT_STRENGTH` | `5` | Starting strength (1 to 10) |
### CLI Options
```text
options:
-h, --help show this help message and exit
-u, --url SERVER_URL Backend REST API base URL (env: BOT_SERVER_URL)
-n, --name NAME Display name for this bot (env: BOT_NAME)
-c, --color COLOR Hex color code for the bot avatar (env: BOT_COLOR)
-s, --strength STRENGTH
Strength attribute (1-10) for battle multiplier (env: BOT_STRENGTH)
-p, --project PROJECT_ID
Google Cloud Project ID (env: VERTEX_PROJECT_ID)
-l, --location LOCATION
Vertex AI region / location (env: VERTEX_LOCATION)
-m, --model MODEL Gemini model ID (env: VERTEX_MODEL)
-k, --api-key API_KEY
Gemini API Key or Vertex AI express mode key
```
---
## Next Steps
See [SETUP.md](SETUP.md) for authenticating to Google Cloud or setting up an API key, and [README.md](README.md) for quick invocation commands.

136
botagent_gear/README.md Normal file
View File

@ -0,0 +1,136 @@
# botagent_gear: Vertex AI (Gemini) Autonomous Agent
**`botagent_gear`** is an intelligent, autonomous agent for **botWebWars** powered by Google Cloud's **Vertex AI** and **Gemini** foundation models (such as `gemini-2.5-flash`).
---
## Overview
Unlike heuristic bots that use static pathing formulas, `botagent_gear` combines:
1. **Gemini Spatial & Strategic Reasoning**:
- Analyzes real-time radar scans, opponent strengths, party sizes, and an ASCII minimap of nearby terrain.
- Evaluates obstacle squeezes (balancing strength penalties vs. path efficiency).
- Decides voluntary alliances with encountered solo bots.
2. **Deterministic Rules Enforcement**:
- Strictly conforms to [GAME_RULES.md](../GAME_RULES.md).
- Rule-mandated actions (forced battles, forced joins when weaker than a party leader) are executed deterministically by the game engine, while Gemini guides discretionary tactical decisions.
3. **Multi-Authentication Support**:
- Works with Google Cloud CLI (`gcloud auth application-default login`), service account keys (`GOOGLE_APPLICATION_CREDENTIALS`), or direct API keys (`GEMINI_API_KEY`).
---
## Quick Start
### 1. Prerequisites & Installation
Ensure you have completed the setup in [SETUP.md](SETUP.md) and [INSTALL.md](INSTALL.md):
```bash
cd botagent_gear
pip install -r requirements.txt
```
### 2. Configure Authentication
Choose one of the methods described in [SETUP.md](SETUP.md). For example, with `gcloud`:
```bash
# 1. Log in to Google Cloud
gcloud auth login
# 2. Set your Google Cloud project
gcloud config set project YOUR_GCP_PROJECT_ID
# 3. Authorize Application Default Credentials
gcloud auth application-default login
```
*(Alternatively, if using an API key from Google AI Studio: `export GEMINI_API_KEY="your-api-key"`).*
### 3. Launch the Bot
#### Default invocation:
```bash
python3 bot.py
```
#### Custom Bot parameters (Name, Color, Strength, Model):
```bash
python3 bot.py \
--name "GeminiTitan" \
--color "#0ea5e9" \
--strength 6 \
--model "gemini-2.5-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 BOT_NAME="GearBot"
export BOT_COLOR="#10b981"
export BOT_STRENGTH="5"
python3 bot.py
```
#### Connecting to a Remote Game Server:
```bash
python3 bot.py --url "http://192.168.1.100:8000/api" --name "RemoteGear"
```
---
## Command-Line Arguments Reference
| Flag | Long Flag | Environment Variable | Default | Description |
|---|---|---|---|---|
| `-u` | `--url` | `BOT_SERVER_URL` | `http://localhost:8000/api` | botWebWars REST API base URL |
| `-n` | `--name` | `BOT_NAME` | `GeminiGearBot` | Display name of the bot on the grid |
| `-c` | `--color` | `BOT_COLOR` | `#4285f4` | Hex color code for the bot avatar |
| `-s` | `--strength` | `BOT_STRENGTH` | `5` | Starting strength (1 to 10) |
| `-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 |
| `-k` | `--api-key` | `VERTEX_API_KEY` / `GEMINI_API_KEY` | *(None)* | Optional Gemini API key |
---
## How It Works in Battle
1. **Lobby & Registration**:
- The agent calls `POST /api/players` to enter the arena.
- Waits for the game to start via the frontend UI.
2. **Turn Polling**:
- Polls `GET /api/turn` until it is this bot's turn.
3. **Radar & Minimap Assessment**:
- Obtains radar targets via `GET /api/players/{id}/radar`.
- Computes an 8×8 ASCII minimap centered around the bot displaying obstacles (`M` mountain, `F` forest, `V` valley), allies (`A`), and enemies (`E`).
4. **Gemini Reasoning**:
- Prompts Gemini with legal moves, diagonal squeeze penalties, distances to targets, and current health/score.
- Gemini returns structured JSON with the selected direction and reasoning.
5. **Encounters & 3-Bout Confrontations**:
- If adjacent to another bot, initiates party alliances or engages in 3-Bout D20 tactical battles via `POST /api/battles/fight`.
6. **Game Conclusion**:
- Detects when all bots are united under a single winning squad and announces final ranking.
---
## Running Multi-Bot Arenas
To experience an arena with diverse AI agents, run multiple bots in separate terminal tabs:
**Terminal 1 (Heuristic Bot):**
```bash
python3 botagent/bot_agent.py --name Heuristic1 --color "#ef4444" -s 3
```
**Terminal 2 (Local Ollama LLM Bot):**
```bash
python3 botagent_ai/bot.py -n OllamaAgent -s 4 -c "#8b5cf6"
```
**Terminal 3 (Vertex AI Gemini Bot):**
```bash
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5
```
Open the web interface at `http://localhost:8000`, click **Start Game**, and watch the tactical confrontations unfold live!

164
botagent_gear/SETUP.md Normal file
View File

@ -0,0 +1,164 @@
# Google Cloud Vertex AI Setup Guide
This guide walks you through setting up Google Cloud authentication and Vertex AI access for `botagent_gear`.
Since you have **not authenticated to a Google Cloud project yet and have not created a key**, choose the method that best fits your workflow below.
---
## Quick Navigation
- [Option 1: Google Cloud CLI & User Authentication (Recommended)](#option-1-google-cloud-cli--user-authentication-recommended)
- [Option 2: Service Account Key (Headless / Automated Servers)](#option-2-service-account-key-headless--automated-servers)
- [Option 3: Gemini API Key (Fastest Setup via Google AI Studio)](#option-3-gemini-api-key-fastest-setup-via-google-ai-studio)
- [Verification & Troubleshooting](#verification--troubleshooting)
---
## Prerequisites
- A Google Cloud account ([Google Cloud Free Tier](https://cloud.google.com/free) includes $300 in credits).
- A Google Cloud Project (or permission to create one).
---
## Option 1: Google Cloud CLI & User Authentication (Recommended)
This is the standard, interactive developer workflow using the Google Cloud CLI (`gcloud`).
### Step 1: Install `gcloud` (if not installed)
Check if `gcloud` is installed:
```bash
gcloud --version
```
If not installed, install it following [Google Cloud SDK Installation](https://cloud.google.com/sdk/docs/install) (or on Debian/Ubuntu: `sudo apt-get install google-cloud-cli`).
### Step 2: Log into your Google Cloud account
```bash
gcloud auth login
```
A browser window will open asking you to sign in with your Google account.
### Step 3: Set or Create your Project
List existing projects:
```bash
gcloud projects list
```
If you already have a project, set it as active:
```bash
gcloud config set project YOUR_PROJECT_ID
```
Or create a brand new project:
```bash
gcloud projects create my-botwebwars-project --name="botWebWars Project"
gcloud config set project my-botwebwars-project
```
*(Ensure billing is enabled for your project in the [Google Cloud Console Billing section](https://console.cloud.google.com/billing).)*
### Step 4: Enable the Vertex AI API
Run:
```bash
gcloud services enable aiplatform.googleapis.com
```
### Step 5: Authorize Application Default Credentials (ADC)
This allows Python scripts and SDKs to authenticate automatically:
```bash
gcloud auth application-default login
```
Follow the browser prompt to grant access.
### Step 6: Set Environment Variables (Optional but convenient)
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"
```
---
## Option 2: Service Account Key (Headless / Automated Servers)
If running in a Docker container, CI/CD pipeline, or remote VM without a web browser, use a Service Account:
### Step 1: Create a Service Account
```bash
export PROJECT_ID=$(gcloud config get-value project)
gcloud iam service-accounts create botwebwars-agent \
--display-name="botWebWars Vertex AI Agent"
```
### Step 2: Grant the Vertex AI User role
```bash
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:botwebwars-agent@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
```
### Step 3: Create and Download the Key File
```bash
mkdir -p ~/.gcp
gcloud iam service-accounts keys create ~/.gcp/vertex-key.json \
--iam-account="botwebwars-agent@${PROJECT_ID}.iam.gserviceaccount.com"
```
### Step 4: Point to the Key File
```bash
export GOOGLE_APPLICATION_CREDENTIALS="$HOME/.gcp/vertex-key.json"
export VERTEX_PROJECT_ID="$PROJECT_ID"
export VERTEX_LOCATION="us-central1"
```
---
## Option 3: Gemini API Key (Fastest Setup via Google AI Studio)
If you prefer using an API key without configuring GCP IAM roles or OAuth tokens:
1. Go to [Google AI Studio](https://aistudio.google.com/app/apikey).
2. Click **Create API Key**.
3. Copy your API key.
4. Export the key:
```bash
export GEMINI_API_KEY="YOUR_API_KEY_HERE"
```
The `botagent_gear` agent will detect `GEMINI_API_KEY` and interact with Gemini directly.
---
## Verification & Troubleshooting
### 1. Test your credentials
You can quickly verify that Vertex AI accepts your credentials:
```bash
python3 -c "
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')
url = f'https://{location}-aiplatform.googleapis.com/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'}]}]}
res = requests.post(url, headers=headers, json=payload, timeout=20)
print('Status:', res.status_code)
if res.status_code == 200:
print('Vertex AI connection successful! Candidate:', res.json()['candidates'][0]['content']['parts'][0]['text'].strip())
else:
print('Error response:', res.text)
"
```
### 2. Common Errors
| Error | Cause | Solution |
|---|---|---|
| `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`) |
| `No Google Cloud project ID detected` | Project is not set | Run `gcloud config set project <PROJECT_ID>` 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!

738
botagent_gear/bot.py Normal file
View File

@ -0,0 +1,738 @@
"""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
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).
- Two opposing parties that meet must always battle (no choice).
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The game ends when all bots are united into a single party.
You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else.
"""
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,
server_url: str = DEFAULT_SERVER_URL,
project_id: Optional[str] = None,
location: str = DEFAULT_LOCATION,
model: str = DEFAULT_MODEL,
api_key: Optional[str] = None,
):
self.name = name
self.color = color
self.strength = strength
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"]
print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
return
except Exception:
pass
res = requests.post(
f"{self.base_url}/players",
json={"name": self.name, "color": self.color, "strength": self.strength},
)
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 {self.name} (ID: {self.bot_id}) at ({p.get('x')}, {p.get('y')})")
return
res.raise_for_status()
data = res.json()
self.bot_id = data["id"]
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})")
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
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", [])
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)
else:
self._navigate_towards_goal(radar_res, my_info)
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']}")
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)
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("#")
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,
M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
{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:
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"\nDisconnecting {self.name}...")
if self.bot_id:
requests.delete(f"{self.base_url}/players/{self.bot_id}")
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)))
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")
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)")
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)")
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,
server_url=args.server_url,
project_id=args.project_id,
location=args.location,
model=args.model,
api_key=args.api_key,
)
agent.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
requests>=2.31.0
google-auth>=2.29.0