Compare commits

..

2 Commits

Author SHA1 Message Date
Isaac Johnson 4c69a86726 1.5 - option to hide popups for auto playing
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m6s Details
2026-09-14 07:30:52 -05:00
Isaac Johnson 65f76a00ac Bot and Troll launchers 2026-09-14 06:24:46 -05:00
12 changed files with 838 additions and 18 deletions

View File

@ -31,6 +31,11 @@ botWebWars/
├── README.md # Human-facing overview and quickstart ├── README.md # Human-facing overview and quickstart
├── Dockerfile # Multi-stage production container build (frontend + backend) ├── Dockerfile # Multi-stage production container build (frontend + backend)
├── docker-compose.yml # Single-service container composition on port 8000 ├── 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 ├── backend/ # FastAPI application & game engine
│ ├── app/ │ ├── app/
@ -278,6 +283,11 @@ The application is containerized into a single unified image via [Dockerfile](Do
export OLLAMA_MODEL="gemma4:12b" export OLLAMA_MODEL="gemma4:12b"
python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6" 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/` ### C. Vertex AI (Gemini) Bot: `botagent_gear/`
- **Entry File**: `botagent_gear/bot.py` - **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" export OLLAMA_MODEL="gemma4:12b"
python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d" 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/` ### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/`
- **Entry File**: `trollagent_gear/bot.py` - **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 $ 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.

View File

@ -47,6 +47,8 @@ export function App() {
setActiveWizardChallenge, setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
showPopups,
setShowPopups,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
formParty, formParty,
@ -202,6 +204,8 @@ export function App() {
playerCount={boardState.player_count} playerCount={boardState.player_count}
isConcluded={isConcluded} isConcluded={isConcluded}
onOpenScoreboard={() => setShowScoreboard(true)} onOpenScoreboard={() => setShowScoreboard(true)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
/> />
{/* Main Content Area */} {/* Main Content Area */}
@ -233,6 +237,8 @@ export function App() {
onStepBot={stepActiveBotTurn} onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying} isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
/> />
{/* Sidebar Player Roster with Turn Order & Parties */} {/* Sidebar Player Roster with Turn Order & Parties */}
@ -278,10 +284,12 @@ export function App() {
/> />
{/* 3-Bout D20 Battle Modal */} {/* 3-Bout D20 Battle Modal */}
<BattleModal {showPopups && (
battle={activeBattle} <BattleModal
onClose={handleCloseBattle} battle={activeBattle}
/> onClose={handleCloseBattle}
/>
)}
{/* Wizard Challenge Reward Selector Prompt Modal */} {/* Wizard Challenge Reward Selector Prompt Modal */}
<WizardPromptModal <WizardPromptModal
@ -293,10 +301,12 @@ export function App() {
/> />
{/* 3-Bout D20 Wizard Challenge Modal */} {/* 3-Bout D20 Wizard Challenge Modal */}
<WizardChallengeModal {showPopups && (
challenge={activeWizardChallenge} <WizardChallengeModal
onClose={handleCloseWizardChallenge} challenge={activeWizardChallenge}
/> onClose={handleCloseWizardChallenge}
/>
)}
{/* Game Conclusion Scoreboard Modal */} {/* Game Conclusion Scoreboard Modal */}
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && ( {showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (

View File

@ -10,6 +10,8 @@ interface HeaderProps {
playerCount: number; playerCount: number;
isConcluded?: boolean; isConcluded?: boolean;
onOpenScoreboard?: () => void; onOpenScoreboard?: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
} }
export const Header: React.FC<HeaderProps> = ({ export const Header: React.FC<HeaderProps> = ({
@ -22,6 +24,8 @@ export const Header: React.FC<HeaderProps> = ({
playerCount, playerCount,
isConcluded, isConcluded,
onOpenScoreboard, onOpenScoreboard,
showPopups = true,
onTogglePopups,
}) => { }) => {
return ( 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"> <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> </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 <a
href="/docs" href="/docs"
target="_blank" target="_blank"

View File

@ -13,6 +13,8 @@ interface MovementControlsProps {
onStepBot: () => void; onStepBot: () => void;
isAutoPlaying: boolean; isAutoPlaying: boolean;
onToggleAutoPlay: () => void; onToggleAutoPlay: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
} }
export const MovementControls: React.FC<MovementControlsProps> = ({ export const MovementControls: React.FC<MovementControlsProps> = ({
@ -26,6 +28,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
onStepBot, onStepBot,
isAutoPlaying, isAutoPlaying,
onToggleAutoPlay, onToggleAutoPlay,
showPopups = true,
onTogglePopups,
}) => { }) => {
const isStarted = Boolean(boardState.turn?.game_started); const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id; const currentTurnId = boardState.turn.current_player_id;
@ -382,6 +386,28 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
</button> </button>
</div> </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"> <div className="text-[10px] text-slate-500 font-mono text-center">
{isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'} {isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
</div> </div>

View File

@ -51,6 +51,7 @@ export function useGameSocket() {
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null); const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null); const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null);
const [showScoreboard, setShowScoreboard] = useState(false); const [showScoreboard, setShowScoreboard] = useState(false);
const [showPopups, setShowPopups] = useState(true);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null); const reconnectTimeoutRef = useRef<number | null>(null);
@ -59,6 +60,20 @@ export function useGameSocket() {
activeBattleRef.current = activeBattle; activeBattleRef.current = activeBattle;
const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null); const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null);
activeWizardChallengeRef.current = activeWizardChallenge; 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 () => { const fetchBoard = useCallback(async () => {
try { try {
@ -205,7 +220,9 @@ export function useGameSocket() {
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
})); }));
const b: BattleResult = data.battle; const b: BattleResult = data.battle;
setActiveBattle(b); if (showPopupsRef.current) {
setActiveBattle(b);
}
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
} else if (data.event === 'wizard_challenge_resolved') { } else if (data.event === 'wizard_challenge_resolved') {
setBoardState((prev) => ({ setBoardState((prev) => ({
@ -215,7 +232,9 @@ export function useGameSocket() {
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
})); }));
const c: WizardChallengeResult = data.challenge_result || data.challenge; const c: WizardChallengeResult = data.challenge_result || data.challenge;
setActiveWizardChallenge(c); if (showPopupsRef.current) {
setActiveWizardChallenge(c);
}
const wizName = c.wizard_name || 'Gary the Wizard'; const wizName = c.wizard_name || 'Gary the Wizard';
let rewardLabel = `+${c.score_change} score`; let rewardLabel = `+${c.score_change} score`;
if (c.reward_chosen === 'strength') { if (c.reward_chosen === 'strength') {
@ -413,7 +432,9 @@ export function useGameSocket() {
throw new Error(err.detail || 'Battle failed'); throw new Error(err.detail || 'Battle failed');
} }
const result: BattleResult = await res.json(); const result: BattleResult = await res.json();
setActiveBattle(result); if (showPopupsRef.current) {
setActiveBattle(result);
}
return result; return result;
}; };
@ -432,7 +453,9 @@ export function useGameSocket() {
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr)); setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
} }
if (data.battle_triggered && data.battle_result) { if (data.battle_triggered && data.battle_result) {
setActiveBattle(data.battle_result); if (showPopupsRef.current) {
setActiveBattle(data.battle_result);
}
} }
if (data.game_concluded && data.game_concluded.concluded) { if (data.game_concluded && data.game_concluded.concluded) {
setIsAutoPlaying(false); setIsAutoPlaying(false);
@ -490,7 +513,9 @@ export function useGameSocket() {
throw new Error(err.detail || 'Failed to challenge wizard'); throw new Error(err.detail || 'Failed to challenge wizard');
} }
const data: WizardChallengeResult = await res.json(); const data: WizardChallengeResult = await res.json();
setActiveWizardChallenge(data); if (showPopupsRef.current) {
setActiveWizardChallenge(data);
}
return data; return data;
}; };
@ -515,11 +540,15 @@ export function useGameSocket() {
if (data.action_taken === 'formed_party' && data.formed_party) { 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}!`); 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) { } else if (data.action_taken === 'battled' && data.battle_result) {
setActiveBattle(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}!`); 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) { } else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) {
const wcr = data.wizard_challenge_result; const wcr = data.wizard_challenge_result;
setActiveWizardChallenge(wcr); if (showPopupsRef.current) {
setActiveWizardChallenge(wcr);
}
const wizName = wcr.wizard_name || 'Gary the Wizard'; const wizName = wcr.wizard_name || 'Gary the Wizard';
setLastEventMessage( setLastEventMessage(
wcr.player_won wcr.player_won
@ -529,7 +558,9 @@ export function useGameSocket() {
} else if (data.action_taken === 'slept' && data.sleep_result) { } 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)!`); 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) { } else if (data.move_result?.battle_result) {
setActiveBattle(data.move_result.battle_result); if (showPopupsRef.current) {
setActiveBattle(data.move_result.battle_result);
}
} }
if (data.game_concluded && data.game_concluded.concluded) { if (data.game_concluded && data.game_concluded.concluded) {
@ -575,6 +606,8 @@ export function useGameSocket() {
setActiveWizardChallenge, setActiveWizardChallenge,
showScoreboard, showScoreboard,
setShowScoreboard, setShowScoreboard,
showPopups,
setShowPopups: updateShowPopups,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
formParty, formParty,

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)

View File

@ -1,2 +1,2 @@
[metadata] [metadata]
version = 1.4 version = 1.5