Compare commits
2 Commits
327bfb43ab
...
4c69a86726
| Author | SHA1 | Date |
|---|---|---|
|
|
4c69a86726 | |
|
|
65f76a00ac |
15
AGENTS.md
15
AGENTS.md
|
|
@ -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`
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -124,3 +124,46 @@ $ python troll_agent.py --url "http://localhost:8000" --name "Fat Troll" --color
|
|||
```
|
||||
|
||||
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.
|
||||
|
|
@ -47,6 +47,8 @@ export function App() {
|
|||
setActiveWizardChallenge,
|
||||
showScoreboard,
|
||||
setShowScoreboard,
|
||||
showPopups,
|
||||
setShowPopups,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
|
|
@ -202,6 +204,8 @@ export function App() {
|
|||
playerCount={boardState.player_count}
|
||||
isConcluded={isConcluded}
|
||||
onOpenScoreboard={() => setShowScoreboard(true)}
|
||||
showPopups={showPopups}
|
||||
onTogglePopups={setShowPopups}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
|
|
@ -233,6 +237,8 @@ export function App() {
|
|||
onStepBot={stepActiveBotTurn}
|
||||
isAutoPlaying={isAutoPlaying}
|
||||
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
||||
showPopups={showPopups}
|
||||
onTogglePopups={setShowPopups}
|
||||
/>
|
||||
|
||||
{/* Sidebar Player Roster with Turn Order & Parties */}
|
||||
|
|
@ -278,10 +284,12 @@ export function App() {
|
|||
/>
|
||||
|
||||
{/* 3-Bout D20 Battle Modal */}
|
||||
{showPopups && (
|
||||
<BattleModal
|
||||
battle={activeBattle}
|
||||
onClose={handleCloseBattle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard Challenge Reward Selector Prompt Modal */}
|
||||
<WizardPromptModal
|
||||
|
|
@ -293,10 +301,12 @@ export function App() {
|
|||
/>
|
||||
|
||||
{/* 3-Bout D20 Wizard Challenge Modal */}
|
||||
{showPopups && (
|
||||
<WizardChallengeModal
|
||||
challenge={activeWizardChallenge}
|
||||
onClose={handleCloseWizardChallenge}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Game Conclusion Scoreboard Modal */}
|
||||
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ interface HeaderProps {
|
|||
playerCount: number;
|
||||
isConcluded?: boolean;
|
||||
onOpenScoreboard?: () => void;
|
||||
showPopups?: boolean;
|
||||
onTogglePopups?: (value?: boolean) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
|
|
@ -22,6 +24,8 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
playerCount,
|
||||
isConcluded,
|
||||
onOpenScoreboard,
|
||||
showPopups = true,
|
||||
onTogglePopups,
|
||||
}) => {
|
||||
return (
|
||||
<header className="h-16 border-b border-slate-800 bg-slate-900/90 backdrop-blur px-6 flex items-center justify-between z-10">
|
||||
|
|
@ -108,6 +112,33 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Battle & Wizard Result Popups Toggle */}
|
||||
<label
|
||||
className={`text-xs font-mono font-medium px-2.5 py-1.5 rounded-lg border transition-all flex items-center gap-2 cursor-pointer select-none ${
|
||||
showPopups
|
||||
? 'bg-slate-800/90 text-sky-300 border-sky-500/50 shadow-sm shadow-sky-950/50 hover:bg-slate-800'
|
||||
: 'bg-slate-950/60 text-slate-400 border-slate-800 hover:text-slate-300 hover:border-slate-700'
|
||||
}`}
|
||||
title={
|
||||
showPopups
|
||||
? 'Result popups enabled: Click to hide battle and wizard popups'
|
||||
: 'Result popups disabled: Click to show battle and wizard popups'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPopups}
|
||||
onChange={(e) => onTogglePopups?.(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
|
||||
/>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>Popups:</span>
|
||||
<strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>
|
||||
{showPopups ? 'ON' : 'OFF'}
|
||||
</strong>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ interface MovementControlsProps {
|
|||
onStepBot: () => void;
|
||||
isAutoPlaying: boolean;
|
||||
onToggleAutoPlay: () => void;
|
||||
showPopups?: boolean;
|
||||
onTogglePopups?: (value?: boolean) => void;
|
||||
}
|
||||
|
||||
export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||
|
|
@ -26,6 +28,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
onStepBot,
|
||||
isAutoPlaying,
|
||||
onToggleAutoPlay,
|
||||
showPopups = true,
|
||||
onTogglePopups,
|
||||
}) => {
|
||||
const isStarted = Boolean(boardState.turn?.game_started);
|
||||
const currentTurnId = boardState.turn.current_player_id;
|
||||
|
|
@ -382,6 +386,28 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result Popups Toggle */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex items-center justify-between px-0.5">
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title={
|
||||
showPopups
|
||||
? 'Result popups enabled: Click to hide battle and wizard popups'
|
||||
: 'Result popups disabled: Click to show battle and wizard popups'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPopups}
|
||||
onChange={(e) => onTogglePopups?.(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px]">
|
||||
Popups: <strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>{showPopups ? 'ON' : 'OFF'}</strong>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-slate-500 font-mono text-center">
|
||||
{isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function useGameSocket() {
|
|||
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
||||
const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null);
|
||||
const [showScoreboard, setShowScoreboard] = useState(false);
|
||||
const [showPopups, setShowPopups] = useState(true);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
|
@ -59,6 +60,20 @@ export function useGameSocket() {
|
|||
activeBattleRef.current = activeBattle;
|
||||
const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null);
|
||||
activeWizardChallengeRef.current = activeWizardChallenge;
|
||||
const showPopupsRef = useRef(true);
|
||||
showPopupsRef.current = showPopups;
|
||||
|
||||
const updateShowPopups = useCallback((val?: boolean | ((prev: boolean) => boolean)) => {
|
||||
setShowPopups((prev) => {
|
||||
const next = typeof val === 'function' ? val(prev) : typeof val === 'boolean' ? val : !prev;
|
||||
showPopupsRef.current = next;
|
||||
if (!next) {
|
||||
setActiveBattle(null);
|
||||
setActiveWizardChallenge(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchBoard = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -205,7 +220,9 @@ export function useGameSocket() {
|
|||
turn: data.turn ?? prev.turn,
|
||||
}));
|
||||
const b: BattleResult = data.battle;
|
||||
if (showPopupsRef.current) {
|
||||
setActiveBattle(b);
|
||||
}
|
||||
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
||||
} else if (data.event === 'wizard_challenge_resolved') {
|
||||
setBoardState((prev) => ({
|
||||
|
|
@ -215,7 +232,9 @@ export function useGameSocket() {
|
|||
turn: data.turn ?? prev.turn,
|
||||
}));
|
||||
const c: WizardChallengeResult = data.challenge_result || data.challenge;
|
||||
if (showPopupsRef.current) {
|
||||
setActiveWizardChallenge(c);
|
||||
}
|
||||
const wizName = c.wizard_name || 'Gary the Wizard';
|
||||
let rewardLabel = `+${c.score_change} score`;
|
||||
if (c.reward_chosen === 'strength') {
|
||||
|
|
@ -413,7 +432,9 @@ export function useGameSocket() {
|
|||
throw new Error(err.detail || 'Battle failed');
|
||||
}
|
||||
const result: BattleResult = await res.json();
|
||||
if (showPopupsRef.current) {
|
||||
setActiveBattle(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
@ -432,8 +453,10 @@ export function useGameSocket() {
|
|||
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
|
||||
}
|
||||
if (data.battle_triggered && data.battle_result) {
|
||||
if (showPopupsRef.current) {
|
||||
setActiveBattle(data.battle_result);
|
||||
}
|
||||
}
|
||||
if (data.game_concluded && data.game_concluded.concluded) {
|
||||
setIsAutoPlaying(false);
|
||||
setShowScoreboard(true);
|
||||
|
|
@ -490,7 +513,9 @@ export function useGameSocket() {
|
|||
throw new Error(err.detail || 'Failed to challenge wizard');
|
||||
}
|
||||
const data: WizardChallengeResult = await res.json();
|
||||
if (showPopupsRef.current) {
|
||||
setActiveWizardChallenge(data);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
|
|
@ -515,11 +540,15 @@ export function useGameSocket() {
|
|||
if (data.action_taken === 'formed_party' && data.formed_party) {
|
||||
setLastEventMessage(`🤝 ${data.player_name} formed party "${data.formed_party.name}" under leader ${data.formed_party.leader_name}!`);
|
||||
} else if (data.action_taken === 'battled' && data.battle_result) {
|
||||
if (showPopupsRef.current) {
|
||||
setActiveBattle(data.battle_result);
|
||||
}
|
||||
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
|
||||
} else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) {
|
||||
const wcr = data.wizard_challenge_result;
|
||||
if (showPopupsRef.current) {
|
||||
setActiveWizardChallenge(wcr);
|
||||
}
|
||||
const wizName = wcr.wizard_name || 'Gary the Wizard';
|
||||
setLastEventMessage(
|
||||
wcr.player_won
|
||||
|
|
@ -529,8 +558,10 @@ export function useGameSocket() {
|
|||
} else if (data.action_taken === 'slept' && data.sleep_result) {
|
||||
setLastEventMessage(`💤 ${data.player_name} took a restful sleep (+${data.sleep_result.health_gained} HP -> ${data.sleep_result.new_health} HP)!`);
|
||||
} else if (data.move_result?.battle_result) {
|
||||
if (showPopupsRef.current) {
|
||||
setActiveBattle(data.move_result.battle_result);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.game_concluded && data.game_concluded.concluded) {
|
||||
setIsAutoPlaying(false);
|
||||
|
|
@ -575,6 +606,8 @@ export function useGameSocket() {
|
|||
setActiveWizardChallenge,
|
||||
showScoreboard,
|
||||
setShowScoreboard,
|
||||
showPopups,
|
||||
setShowPopups: updateShowPopups,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
[metadata]
|
||||
version = 1.4
|
||||
version = 1.5
|
||||
|
|
|
|||
Loading…
Reference in New Issue