diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8add9be --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/botagent_gear/INSTALL.md b/botagent_gear/INSTALL.md new file mode 100644 index 0000000..72f6254 --- /dev/null +++ b/botagent_gear/INSTALL.md @@ -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. diff --git a/botagent_gear/README.md b/botagent_gear/README.md new file mode 100644 index 0000000..23bfe33 --- /dev/null +++ b/botagent_gear/README.md @@ -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! diff --git a/botagent_gear/SETUP.md b/botagent_gear/SETUP.md new file mode 100644 index 0000000..69c0686 --- /dev/null +++ b/botagent_gear/SETUP.md @@ -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 ` or export `VERTEX_PROJECT_ID` | + +Once setup is complete, proceed to [INSTALL.md](INSTALL.md) and [README.md](README.md) to install dependencies and run your agent! diff --git a/botagent_gear/bot.py b/botagent_gear/bot.py new file mode 100644 index 0000000..f177849 --- /dev/null +++ b/botagent_gear/bot.py @@ -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= or run:\n" + " gcloud config set project \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": "", "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() diff --git a/botagent_gear/requirements.txt b/botagent_gear/requirements.txt new file mode 100644 index 0000000..25e0ced --- /dev/null +++ b/botagent_gear/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +google-auth>=2.29.0