add GEAR, add AGENTS.md
This commit is contained in:
parent
1635cfe8fd
commit
80200ca1f3
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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!
|
||||||
|
|
@ -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!
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
requests>=2.31.0
|
||||||
|
google-auth>=2.29.0
|
||||||
Loading…
Reference in New Issue