Bot and Troll launchers

This commit is contained in:
Isaac Johnson 2026-09-14 06:24:46 -05:00
parent 327bfb43ab
commit 65f76a00ac
7 changed files with 721 additions and 1 deletions

View File

@ -31,6 +31,11 @@ botWebWars/
├── 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
├── launch_bots.py # Top-level batch launcher for botagent_ai instances
├── launch_bots.sh # Shell wrapper for launch_bots.py
├── launch_trolls.py # Top-level batch launcher for trollagent_ai instances
├── launch_trolls.sh # Shell wrapper for launch_trolls.py
├── launcher_common.py # Shared agent process orchestrator and log multiplexer
├── backend/ # FastAPI application & game engine
│ ├── app/
@ -278,6 +283,11 @@ The application is containerized into a single unified image via [Dockerfile](Do
export OLLAMA_MODEL="gemma4:12b"
python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6"
```
- **Batch Multi-Session Launch (Top-Level Script)**:
```bash
# Launch 3 bots (named botagent_ai_gemma4_e4b_1, botagent_ai_gemma4_e4b_2, ...)
./launch_bots.sh -n 3 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
```
### C. Vertex AI (Gemini) Bot: `botagent_gear/`
- **Entry File**: `botagent_gear/bot.py`
@ -329,6 +339,11 @@ The application is containerized into a single unified image via [Dockerfile](Do
export OLLAMA_MODEL="gemma4:12b"
python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d"
```
- **Batch Multi-Session Launch (Top-Level Script)**:
```bash
# Launch 2 trolls (named trollagent_ai_gemma4_e4b_1, trollagent_ai_gemma4_e4b_2)
./launch_trolls.sh -n 2 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
```
### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/`
- **Entry File**: `trollagent_gear/bot.py`

View File

@ -123,4 +123,47 @@ $ pip install -r requirements.txt
$ python troll_agent.py --url "http://localhost:8000" --name "Fat Troll" --color "#10B981" -s 2 -H 2
```
BotAgents work the same way
BotAgents work the same way
---
## Multi-Agent Batch Launchers (Top-Level Scripts)
Instead of opening multiple terminal tabs manually, you can launch pools of AI bots or trolls using the top-level launcher scripts. The scripts automatically name agents as `{folder}_{sanitized_model}_{number}` (e.g. `botagent_ai_gemma4_e4b_1`, `botagent_ai_gemma4_e4b_2`, etc.), auto-assign distinct colors, multiplex real-time colorized logs in one terminal, save individual log files under `logs/`, and gracefully clean up all sessions when interrupted (`Ctrl+C`).
### Launch Multiple AI Bots (`botagent_ai`)
```bash
# Launch 3 AI bots with default parameters (gemma4:e4b, str 2, hp 2)
./launch_bots.sh -n 3
# Or with python directly, specifying custom parameters:
python3 launch_bots.py -n 4 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_bots.py -n 3 --dry-run
```
### Launch Multiple AI Trolls (`trollagent_ai`)
```bash
# Launch 2 AI trolls with default parameters (gemma4:e4b, str 2, hp 2)
./launch_trolls.sh -n 2
# Or with python directly, specifying custom parameters:
python3 launch_trolls.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_trolls.py -n 2 --dry-run
```
### CLI Options Supported:
- `-n`, `--count`, `--num`: Number of agents to launch (default: `1`).
- `-s`, `--strength`: Strength multiplier (default: `2`).
- `-H`, `--health`: Starting health points (default: `2`).
- `--ollama-url`: Ollama API base URL (default: `http://192.168.1.220:11434`).
- `-m`, `--model`, `--ollama-model`: Ollama model name (default: `gemma4:e4b`).
- `-u`, `--url`, `--server-url`: botWebWars backend URL (default: `http://localhost:8000`).
- `-c`, `--color`: Custom avatar hex color (default: auto-cycles vibrant palette).
- `--start-index`: Starting number index (e.g. `--start-index 4` for instances `_4`, `_5`, ...).
- `--dry-run`: Display all commands without launching.
- `--log-dir`: Directory for per-agent log files (default: `logs/`).
- `--no-logs`: Disable writing log files to disk.

151
launch_bots.py Executable file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars.
Example usage:
# Launch 3 bots with default parameters (gemma4:e4b, str 2, hp 2)
./launch_bots.py -n 3
# Custom model, health, strength, and Ollama server
./launch_bots.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_bots.py -n 3 --dry-run
"""
import argparse
import os
import sys
from pathlib import Path
# Ensure workspace root is in sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launcher_common import (
DEFAULT_BOT_COLORS,
run_agent_launcher,
)
DEFAULT_SERVER_URL = os.getenv("BOT_SERVER_URL", "http://localhost:8000")
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
DEFAULT_BOT_STRENGTH = int(os.getenv("BOT_STRENGTH", "2"))
DEFAULT_BOT_HEALTH = int(os.getenv("BOT_HEALTH", "2"))
def parse_args():
parser = argparse.ArgumentParser(
description="Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-n", "--count", "--num", "--num-bots",
dest="count",
type=int,
default=1,
help="Number of bot agent instances to launch",
)
parser.add_argument(
"-s", "--strength",
dest="strength",
type=int,
default=DEFAULT_BOT_STRENGTH,
help="Bot strength multiplier (1-10)",
)
parser.add_argument(
"-H", "--health",
dest="health",
type=int,
default=DEFAULT_BOT_HEALTH,
help="Starting health points",
)
parser.add_argument(
"--ollama-url",
dest="ollama_url",
default=DEFAULT_OLLAMA_URL,
help="Ollama API base URL",
)
parser.add_argument(
"-m", "--model", "--ollama-model",
dest="model",
default=DEFAULT_OLLAMA_MODEL,
help="Ollama model identifier",
)
parser.add_argument(
"-u", "--url", "--server-url",
dest="server_url",
default=DEFAULT_SERVER_URL,
help="botWebWars server URL",
)
parser.add_argument(
"-c", "--color",
dest="color",
default=None,
help="Custom hex color code for avatar (default: auto-cycles vibrant palette)",
)
parser.add_argument(
"--start-index",
dest="start_index",
type=int,
default=1,
help="Starting index number for bot naming (e.g. start at 4 for botagent_ai_model_4)",
)
parser.add_argument(
"--prefix",
dest="prefix",
default="botagent_ai",
help="Custom naming prefix before model and index",
)
parser.add_argument(
"--piece-type",
dest="piece_type",
choices=["knight", "warrior"],
default=None,
help="Optional board piece class",
)
parser.add_argument(
"--log-dir",
dest="log_dir",
default="logs",
help="Directory to store per-bot log files",
)
parser.add_argument(
"--no-logs",
dest="no_logs",
action="store_true",
help="Disable writing logs to files",
)
parser.add_argument(
"--python",
dest="python",
default=None,
help="Custom path to python interpreter binary",
)
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="Print the launch plan and commands without executing them",
)
return parser.parse_args()
def extra_args_builder(args):
extra = []
if args.piece_type:
extra.extend(["--piece-type", args.piece_type])
return extra
def main():
args = parse_args()
run_agent_launcher(
agent_type="Bot",
folder="botagent_ai",
palette=DEFAULT_BOT_COLORS,
args=args,
extra_args_builder=extra_args_builder,
)
if __name__ == "__main__":
main()

4
launch_bots.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Shell wrapper for launch_bots.py
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/launch_bots.py" "$@"

151
launch_trolls.py Executable file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.
Example usage:
# Launch 2 trolls with default parameters (gemma4:e4b, str 2, hp 2)
./launch_trolls.py -n 2
# Custom strength, health, model, and Ollama server
./launch_trolls.py -n 3 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_trolls.py -n 2 --dry-run
"""
import argparse
import os
import sys
from pathlib import Path
# Ensure workspace root is in sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launcher_common import (
DEFAULT_TROLL_COLORS,
run_agent_launcher,
)
DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000"))
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "2.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "2.0"))
def parse_args():
parser = argparse.ArgumentParser(
description="Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-n", "--count", "--num", "--num-trolls",
dest="count",
type=int,
default=1,
help="Number of troll agent instances to launch",
)
parser.add_argument(
"-s", "--strength",
dest="strength",
type=float,
default=DEFAULT_TROLL_STRENGTH,
help="Troll strength multiplier (1-10)",
)
parser.add_argument(
"-H", "--health",
dest="health",
type=float,
default=DEFAULT_TROLL_HEALTH,
help="Starting health points",
)
parser.add_argument(
"--ollama-url",
dest="ollama_url",
default=DEFAULT_OLLAMA_URL,
help="Ollama API base URL",
)
parser.add_argument(
"-m", "--model", "--ollama-model",
dest="model",
default=DEFAULT_OLLAMA_MODEL,
help="Ollama model identifier",
)
parser.add_argument(
"-u", "--url", "--server-url",
dest="server_url",
default=DEFAULT_SERVER_URL,
help="botWebWars server URL",
)
parser.add_argument(
"-c", "--color",
dest="color",
default=None,
help="Custom hex color code for avatar (default: auto-cycles troll green palette)",
)
parser.add_argument(
"--start-index",
dest="start_index",
type=int,
default=1,
help="Starting index number for troll naming (e.g. start at 3 for trollagent_ai_model_3)",
)
parser.add_argument(
"--prefix",
dest="prefix",
default="trollagent_ai",
help="Custom naming prefix before model and index",
)
parser.add_argument(
"--loop-delay",
dest="loop_delay",
type=float,
default=1.0,
help="Turn polling interval in seconds",
)
parser.add_argument(
"--log-dir",
dest="log_dir",
default="logs",
help="Directory to store per-troll log files",
)
parser.add_argument(
"--no-logs",
dest="no_logs",
action="store_true",
help="Disable writing logs to files",
)
parser.add_argument(
"--python",
dest="python",
default=None,
help="Custom path to python interpreter binary",
)
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="Print the launch plan and commands without executing them",
)
return parser.parse_args()
def extra_args_builder(args):
extra = []
if args.loop_delay:
extra.extend(["--loop-delay", str(args.loop_delay)])
return extra
def main():
args = parse_args()
run_agent_launcher(
agent_type="Troll",
folder="trollagent_ai",
palette=DEFAULT_TROLL_COLORS,
args=args,
extra_args_builder=extra_args_builder,
)
if __name__ == "__main__":
main()

4
launch_trolls.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Shell wrapper for launch_trolls.py
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/launch_trolls.py" "$@"

352
launcher_common.py Normal file
View File

@ -0,0 +1,352 @@
"""Common process management and launcher utilities for botWebWars agents."""
import argparse
import os
import re
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import List, Optional, Tuple
# ANSI color codes for pretty terminal logging
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[1;32m"
YELLOW = "\033[1;33m"
RED = "\033[1;31m"
CYAN = "\033[1;36m"
MAGENTA = "\033[1;35m"
BLUE = "\033[1;34m"
CONSOLE_COLORS = [
"\033[1;34m", # Blue
"\033[1;35m", # Magenta
"\033[1;36m", # Cyan
"\033[1;32m", # Green
"\033[1;33m", # Yellow
"\033[1;31m", # Red
"\033[1;94m", # Light Blue
"\033[1;95m", # Light Magenta
"\033[1;96m", # Light Cyan
"\033[1;92m", # Light Green
]
DEFAULT_BOT_COLORS = [
"#3b82f6", # Blue
"#8b5cf6", # Purple
"#ec4899", # Pink
"#06b6d4", # Cyan
"#f59e0b", # Amber
"#10b981", # Emerald
"#6366f1", # Indigo
"#f43f5e", # Rose
"#14b8a6", # Teal
"#e11d48", # Crimson
]
DEFAULT_TROLL_COLORS = [
"#15803d", # Forest Green
"#166534", # Dark Green
"#047857", # Emerald / Swamp
"#4d7c0f", # Olive Lime
"#3f6212", # Deep Olive
"#b45309", # Dark Amber / Brown
"#7c2d12", # Rust / Bloodwood
"#581c87", # Dark Purple
"#0f766e", # Deep Teal
"#1e293b", # Slate Dark
]
def sanitize_model_name(model: str) -> str:
"""Sanitizes model name by replacing non-alphanumeric characters with underscores.
Example: 'gemma4:e4b' -> 'gemma4_e4b', 'llama3.2:1b' -> 'llama3_2_1b'
"""
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", model).strip("_")
return cleaned or "model"
def make_agent_name(folder: str, model: str, index: int, prefix: Optional[str] = None) -> str:
"""Generates the standardized agent name in the format: {folder}_{model}_{index}.
Example: 'botagent_ai_gemma4_e4b_3'
"""
base_prefix = prefix.strip("_") if prefix else folder
clean_model = sanitize_model_name(model)
return f"{base_prefix}_{clean_model}_{index}"
def find_python_executable(agent_dir: Path, custom_python: Optional[str] = None) -> str:
"""Locates the appropriate Python binary:
1. Custom explicit python path if provided.
2. Active virtual environment if invoked inside one.
3. Agent directory local virtual environment (e.g. {agent_dir}/venv/bin/python).
4. sys.executable or system python3.
"""
if custom_python:
return custom_python
# If the user explicitly activated an environment outside
if getattr(sys, "base_prefix", None) != sys.prefix:
return sys.executable
# Check local venv inside the agent's folder
local_venv = agent_dir / "venv" / "bin" / "python"
if local_venv.is_file() and os.access(local_venv, os.X_OK):
return str(local_venv)
# Fallback to current sys.executable
return sys.executable
def stream_output(
agent_name: str,
color_code: str,
proc: subprocess.Popen,
log_file: Optional[Path],
) -> None:
"""Streams child process stdout/stderr line-by-line to console and log file."""
log_fp = None
if log_file:
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_fp = open(log_file, "a", encoding="utf-8")
except Exception as e:
print(f"{RED}⚠️ Failed to open log file {log_file}: {e}{RESET}", flush=True)
last_lobby_time = 0.0
try:
if proc.stdout:
for raw_line in iter(proc.stdout.readline, ""):
if not raw_line:
break
if log_fp:
log_fp.write(raw_line)
log_fp.flush()
line = raw_line.rstrip("\r\n")
if not line:
continue
# Throttle lobby polling notifications to avoid console flooding
if "[LOBBY]" in line:
now = time.time()
if now - last_lobby_time < 8.0:
continue
last_lobby_time = now
print(f"{color_code}[{agent_name}]{RESET} {line}", flush=True)
except Exception:
pass
finally:
if log_fp:
try:
log_fp.close()
except Exception:
pass
def stop_all_processes(procs: List[Tuple[str, subprocess.Popen]]) -> None:
"""Gracefully terminates all child processes by sending SIGINT first."""
if not procs:
return
running_procs = [(name, p) for name, p in procs if p.poll() is None]
if not running_procs:
return
print(f"\n{YELLOW}🛑 Stopping {len(running_procs)} agent session(s) gracefully...{RESET}", flush=True)
# 1. Send SIGINT so bot.py catches KeyboardInterrupt and cleanly deregisters from the board
for name, proc in running_procs:
if proc.poll() is None:
try:
proc.send_signal(signal.SIGINT)
except (ProcessLookupError, OSError):
pass
# 2. Give processes up to 4 seconds to deregister and exit cleanly
deadline = time.time() + 4.0
for name, proc in running_procs:
remaining = max(0.1, deadline - time.time())
try:
proc.wait(timeout=remaining)
except subprocess.TimeoutExpired:
pass
# 3. Terminate/kill any processes that hung
for name, proc in running_procs:
if proc.poll() is None:
print(f"{RED}⚠️ Agent {name} did not exit in time; terminating...{RESET}", flush=True)
try:
proc.terminate()
proc.wait(timeout=1.0)
except Exception:
try:
proc.kill()
except Exception:
pass
print(f"{GREEN}✅ All agent sessions stopped cleanly.{RESET}\n", flush=True)
def run_agent_launcher(
agent_type: str,
folder: str,
palette: List[str],
args: argparse.Namespace,
extra_args_builder=None,
) -> None:
"""Generic orchestrator for launching bot and troll agent pools."""
root_dir = Path(__file__).resolve().parent
agent_dir = root_dir / folder
if not agent_dir.is_dir():
print(f"{RED}❌ Error: Agent directory '{agent_dir}' not found.{RESET}", file=sys.stderr)
sys.exit(1)
entry_file = agent_dir / "bot.py"
if not entry_file.is_file():
print(f"{RED}❌ Error: Entrypoint '{entry_file}' not found.{RESET}", file=sys.stderr)
sys.exit(1)
python_bin = find_python_executable(agent_dir, args.python)
count = max(1, args.count)
start_index = max(1, args.start_index)
prefix = getattr(args, "prefix", None)
# Prepare list of commands and configurations
agent_configs = []
for offset in range(count):
idx = start_index + offset
name = make_agent_name(folder=folder, model=args.model, index=idx, prefix=prefix)
color = args.color if args.color else palette[offset % len(palette)]
console_color = CONSOLE_COLORS[offset % len(CONSOLE_COLORS)]
cmd = [
python_bin,
"bot.py",
"--name", name,
"--color", color,
"-s", str(args.strength),
"-H", str(args.health),
"--ollama-url", args.ollama_url,
"--ollama-model", args.model,
"-u", args.server_url,
]
if extra_args_builder:
extra = extra_args_builder(args)
if extra:
cmd.extend(extra)
log_file = None
if not args.no_logs and args.log_dir:
log_file = Path(args.log_dir).resolve() / f"{name}.log"
agent_configs.append({
"index": idx,
"name": name,
"color": color,
"console_color": console_color,
"cmd": cmd,
"log_file": log_file,
})
# Dry-run display mode
if args.dry_run:
print(f"\n{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}🔍 DRY RUN: {count} {agent_type}(s) [{folder}]{RESET}")
print(f"{CYAN}{'=' * 68}{RESET}")
print(f"Model: {BOLD}{args.model}{RESET}")
print(f"Ollama URL: {args.ollama_url}")
print(f"Server URL: {args.server_url}")
print(f"Strength: {args.strength} | Health: {args.health}")
print(f"Interpreter: {python_bin}")
print(f"Working Dir: {agent_dir}")
if not args.no_logs and args.log_dir:
print(f"Log Dir: {Path(args.log_dir).resolve()}")
print(f"{CYAN}{'-' * 68}{RESET}")
for item in agent_configs:
cmd_str = " ".join(item["cmd"])
print(f"[{item['index']}] Name: {BOLD}{item['name']}{RESET}")
print(f" Color: {item['color']}")
print(f" Cmd: {cmd_str}\n")
print(f"{CYAN}{'=' * 68}{RESET}\n")
return
# Normal execution
print(f"\n{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}🚀 Launching {count} {agent_type}(s) [{folder}]{RESET}")
print(f"Model: {BOLD}{args.model}{RESET}")
print(f"Ollama URL: {args.ollama_url}")
print(f"Server URL: {args.server_url}")
print(f"Strength: {args.strength} | Health: {args.health}")
print(f"Interpreter: {python_bin}")
if not args.no_logs and args.log_dir:
print(f"Logs: {Path(args.log_dir).resolve()}/<agent_name>.log")
print(f"{CYAN}{'=' * 68}{RESET}")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["BOT_SERVER_URL"] = args.server_url
if "troll" in folder.lower():
env["TROLL_SERVER_URL"] = args.server_url
procs: List[Tuple[str, subprocess.Popen]] = []
threads: List[threading.Thread] = []
interrupted = threading.Event()
def handle_signal(signum, frame):
interrupted.set()
# Register signal traps
old_sigint = signal.signal(signal.SIGINT, handle_signal)
old_sigterm = signal.signal(signal.SIGTERM, handle_signal)
try:
for item in agent_configs:
print(f"✨ Spawning {item['console_color']}{item['name']}{RESET} ({item['color']})...")
proc = subprocess.Popen(
item["cmd"],
cwd=str(agent_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
procs.append((item["name"], proc))
t = threading.Thread(
target=stream_output,
args=(item["name"], item["console_color"], proc, item["log_file"]),
daemon=True,
)
t.start()
threads.append(t)
time.sleep(0.15) # Slight stagger for clean initial connections
print(f"{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}💡 All {len(procs)} agent(s) spawned. Press Ctrl+C at any time to stop.{RESET}")
print(f"{CYAN}{'=' * 68}{RESET}\n")
# Monitor loop
while not interrupted.is_set():
# Check if all processes have exited naturally
if all(p.poll() is not None for _, p in procs):
break
interrupted.wait(timeout=0.5)
except KeyboardInterrupt:
interrupted.set()
finally:
stop_all_processes(procs)
signal.signal(signal.SIGINT, old_sigint)
signal.signal(signal.SIGTERM, old_sigterm)