From 6e8f8ef2b21c41447421e3f3d0aded8ab4bda086 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sat, 5 Sep 2026 19:32:44 -0500 Subject: [PATCH] new bot agent, in python --- botagent/bot_agent.py | 228 ++++++++++++++++++++++++ frontend/src/App.tsx | 80 ++++----- frontend/src/components/BattleModal.tsx | 59 ++++-- frontend/src/hooks/useGameSocket.ts | 78 ++++---- 4 files changed, 341 insertions(+), 104 deletions(-) create mode 100644 botagent/bot_agent.py diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py new file mode 100644 index 0000000..5918504 --- /dev/null +++ b/botagent/bot_agent.py @@ -0,0 +1,228 @@ +"""External Bot Agent with deliberate decision-making logic: +- Evaluates targets via Radar sensor. +- Decides whether to negotiate party formation or refuse & fight based on strength. +- Explicitly engages in 3-bout D20 battles when adjacent to an opposing party/refusing bot. +- Parses battle results, bout rolls, scores, and absorbed members. +""" + +import time +import requests +from typing import Optional, Dict, Any + +BASE_URL = "http://localhost:8000/api" + + +class SmartBotAgent: + def __init__(self, name: str = "ExternalCyberBot", color: str = "#10b981", strength: int = 4): + self.name = name + self.color = color + self.strength = strength + self.bot_id: Optional[str] = None + self.party_id: Optional[str] = None + self.is_leader: bool = False + + def register(self): + """Register the bot avatar on the 64x64 grid.""" + res = requests.post( + f"{BASE_URL}/players", + json={"name": self.name, "color": self.color, "strength": self.strength}, + ) + res.raise_for_status() + data = res.json() + self.bot_id = data["id"] + print(f"šŸš€ [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}) at ({data['x']}, {data['y']})") + + def refresh_status(self): + """Update bot state (party membership, leader status, score).""" + res = requests.get(f"{BASE_URL}/players/{self.bot_id}") + if res.status_code == 200: + data = res.json() + self.party_id = data.get("party_id") + self.is_leader = data.get("is_party_leader", False) + return data + return None + + def decide_and_act(self): + """Core AI decision loop executed when it is this bot's turn.""" + my_info = self.refresh_status() + if not my_info: + return + + print(f"\nšŸŽ® --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") + + # 1. Consult Radar Sensor + radar_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/radar").json() + targets = radar_res.get("targets", []) + nearest = radar_res.get("nearest_target") + + # 2. Check for immediate adjacent interaction (distance <= 1) + adjacent_target = None + for t in targets: + if t["distance"] <= 1 and not t["is_ally"]: + adjacent_target = t + break + + if adjacent_target: + self._handle_adjacent_encounter(adjacent_target) + return + + # 3. If no immediate adjacent enemy/recruit, move towards target + self._navigate_towards_goal(radar_res) + + def _handle_adjacent_encounter(self, target: Dict[str, Any]): + """Deliberate decision: Should we join, recruit, or fight?""" + target_name = target["name"] + target_str = target["strength"] + target_party = target.get("party_id") + + print(f"šŸ” [ADJACENT ENCOUNTER] Next to '{target_name}' (Str: {target_str}, Party: {target_party or 'None'})") + + # SCENARIO A: I am a Solo Bot + if not self.party_id: + if not target_party: + # Both solo: agree to form a party! Stronger bot is leader. + if self.strength >= target_str: + print(f"šŸ¤ [DECISION] Proposing party with {target_name}. I have >= strength, so I will lead!") + leader_id = self.bot_id + else: + print(f"šŸ¤ [DECISION] Proposing party with {target_name}. They are stronger, so they will lead.") + leader_id = target["id"] + + self._execute_party_formation([self.bot_id, target["id"]], leader_id) + else: + # Target belongs to a party: Check leader strength + # Leadership Rule: Desires equal or stronger leader. Refuses weaker leader! + # Fetch target's party leader + target_leader_str = target_str # default fallback + party_info = requests.get(f"{BASE_URL}/parties/{target_party}").json() + if party_info: + leader_player = requests.get(f"{BASE_URL}/players/{party_info['leader_id']}").json() + target_leader_str = leader_player.get("strength", 1) + + if self.strength <= target_leader_str: + print(f"šŸ¤ [DECISION] Party leader {party_info['leader_name']} has strength {target_leader_str} >= my {self.strength}. Willingly joining squad!") + # Step towards or engine auto-merges + self._step_or_attack(target) + else: + print(f"āš”ļø [DECISION] Party leader has lower strength ({target_leader_str} < my {self.strength})! I REFUSE to join. Engaging in battle!") + self._initiate_battle(target["id"]) + + # SCENARIO B: I am in a Party + else: + if not self.is_leader: + print(f"šŸ›”ļø [PARTY MEMBER] Under command of party leader. Awaiting leader movement.") + requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") + return + + if not target_party: + # Party Leader vs Solo Bot: + if target_str <= self.strength: + print(f"šŸ¤ [DECISION] Solo bot {target_name} is willing to join our squad under my leadership.") + self._step_or_attack(target) + else: + print(f"āš”ļø [DECISION] Solo bot {target_name} refuses weaker leader! Squad is attacking!") + self._initiate_battle(target["id"]) + else: + # Party vs Opposing Party: BATTLE! + print(f"āš”ļø [DECISION] Hostile party detected: '{target.get('party_name')}'! Engaging in 3-Bout D20 battle!") + self._initiate_battle(target["id"]) + + def _execute_party_formation(self, member_ids, leader_id): + """Form a party using the REST API.""" + try: + res = requests.post( + f"{BASE_URL}/parties", + json={"member_ids": member_ids, "leader_id": leader_id, "name": f"Squad_{self.name}"}, + ) + if res.status_code == 201: + party = res.json() + print(f"āœ… [PARTY FORMED] Squad '{party['name']}' established! Leader: {party['leader_name']} | Str: {party['total_strength']}") + else: + # Fallback: move into adjacent square to trigger engine merge + self._pass_or_step() + except Exception as e: + print(f"Party formation error: {e}") + + def _initiate_battle(self, opponent_id: str): + """Explicitly call the 3-Bout D20 Battle endpoint.""" + print(f"šŸŽ² [BATTLE INITIATED] Clashing with opponent {opponent_id}...") + res = requests.post( + f"{BASE_URL}/battles/fight", + json={"challenger_id": self.bot_id, "defender_id": opponent_id}, + ) + if res.status_code == 200: + battle = res.json() + print(f"\n--- āš”ļø 3-BOUT BATTLE RESOLUTION ---") + print(f"Bouts won: {battle['party1_name']} ({battle['party1_bouts_won']}) vs {battle['party2_name']} ({battle['party2_bouts_won']})") + for b in battle["bouts"]: + print(f" Bout #{b['bout_number']}: Roll {b['party1_roll']}Ɨ{b['party1_strength']} ({b['party1_score']}) vs Roll {b['party2_roll']}Ɨ{b['party2_strength']} ({b['party2_score']}) -> Winner: {b['winner_name']}") + + print(f"šŸ† Overall Winner: {battle['winner_party_name']} (Leader {battle['winner_leader_name']} receives +2 pts)") + print(f"šŸ’€ Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") + if battle.get("absorbed_members"): + print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") + else: + print(f"Battle failed ({res.status_code}): {res.text}") + + def _step_or_attack(self, target: Dict[str, Any]): + """Move adjacent/towards the target.""" + moves_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/available-moves").json() + available = [d for d, chk in moves_res["moves"].items() if chk["available"]] + if available: + # Move towards target + chosen = available[0] + res = requests.post(f"{BASE_URL}/players/{self.bot_id}/move", json={"direction": chosen}).json() + if res.get("battle_triggered"): + print(f"āš”ļø Move triggered battle! Winner: {res['battle_result']['winner_party_name']}") + elif res.get("party_formed_triggered"): + print(f"šŸ¤ Move resulted in party alliance!") + else: + requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") + + def _navigate_towards_goal(self, radar_res: Dict[str, Any]): + """Move towards the nearest target or explore unvisited areas.""" + rec_dir = radar_res.get("recommended_direction") + moves_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/available-moves").json() + available = [d for d, chk in moves_res["moves"].items() if chk["available"]] + + if not available: + print("🚫 No available moves. Passing turn.") + requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") + return + + chosen_dir = rec_dir if rec_dir in available else available[0] + print(f"🧭 Moving {chosen_dir} (Goal: {radar_res.get('bot_goal')}, Action: {radar_res.get('recommended_action')})") + res = requests.post(f"{BASE_URL}/players/{self.bot_id}/move", json={"direction": chosen_dir}).json() + + if res.get("battle_triggered"): + print(f"āš”ļø Encounter battle! Winner: {res['battle_result']['winner_party_name']}") + elif res.get("party_formed_triggered"): + print(f"šŸ¤ Formed or joined squad: {res.get('formed_party', {}).get('name')}") + + def run(self): + """Main game loop for external agent.""" + self.register() + try: + while True: + turn_info = requests.get(f"{BASE_URL}/turn").json() + curr_player_id = turn_info.get("current_player_id") + + if curr_player_id == self.bot_id: + self.decide_and_act() + + # Check for game conclusion + conc = requests.get(f"{BASE_URL}/game/conclusion").json() + if conc.get("concluded"): + print(f"\nšŸŽ‰ [GAME CONCLUDED] All bots united under '{conc['winning_party_name']}'!") + break + else: + time.sleep(0.4) + + except KeyboardInterrupt: + print(f"\nDisconnecting {self.name}...") + requests.delete(f"{BASE_URL}/players/{self.bot_id}") + + +if __name__ == "__main__": + agent = SmartBotAgent(name="ExternalCyberBot", color="#10b981", strength=4) + agent.run() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0a062d..311cf12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,36 +1,21 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { useGameSocket } from './hooks/useGameSocket'; -import { BattleModal } from './components/BattleModal'; -import { BoardCanvas } from './components/BoardCanvas'; import { Header } from './components/Header'; +import { BoardCanvas } from './components/BoardCanvas'; import { MovementControls } from './components/MovementControls'; -import { PartyModal } from './components/PartyModal'; import { PlayerList } from './components/PlayerList'; import { RegisterModal } from './components/RegisterModal'; +import { PartyModal } from './components/PartyModal'; +import { BattleModal } from './components/BattleModal'; import { ScoreboardModal } from './components/ScoreboardModal'; -const BOT_NAMES = [ - 'ViperStrike', - 'PulseMatrix', - 'NexusGlitch', - 'AegisPrime', - 'CobaltDrift', - 'PhantomCore', - 'ZephyrByte', - 'TitanSpark', - 'ShadowGrid', - 'QuantumFang', -]; - -const BOT_COLORS = [ - '#38BDF8', // Sky - '#F43F5E', // Rose - '#10B981', // Emerald - '#F59E0B', // Amber - '#A855F7', // Purple - '#EC4899', // Pink - '#06B6D4', // Cyan - '#6366F1', // Indigo +const BOT_PRESETS = [ + { name: 'AlphaBot', color: '#38bdf8', strength: 1 }, + { name: 'BetaTank', color: '#f43f5e', strength: 2 }, + { name: 'GammaStriker', color: '#a855f7', strength: 3 }, + { name: 'DeltaRanger', color: '#22c55e', strength: 1 }, + { name: 'OmegaTitan', color: '#eab308', strength: 4 }, + { name: 'SigmaScout', color: '#ec4899', strength: 1 }, ]; export function App() { @@ -62,6 +47,10 @@ export function App() { const [isPartyModalOpen, setIsPartyModalOpen] = useState(false); const [notification, setNotification] = useState(null); + const handleCloseBattle = useCallback(() => { + setActiveBattle(null); + }, [setActiveBattle]); + const showNotification = (msg: string) => { setNotification(msg); setTimeout(() => { @@ -70,29 +59,36 @@ export function App() { }; const handleQuickSpawn = async () => { - const name = BOT_NAMES[Math.floor(Math.random() * BOT_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100); - const color = BOT_COLORS[Math.floor(Math.random() * BOT_COLORS.length)]; + const existingNames = new Set(boardState.players.map((p) => p.name)); + const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name)); + const preset = + availablePresets.length > 0 + ? availablePresets[Math.floor(Math.random() * availablePresets.length)] + : { + name: `Bot_${Math.floor(Math.random() * 1000)}`, + color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, + strength: Math.floor(Math.random() * 3) + 1, + }; + try { - const player = await registerPlayer(name, color); - showNotification(`Spawned ${player.name} at (${player.x}, ${player.y})`); + const player = await registerPlayer(preset.name, preset.color, preset.strength); + showNotification(`Spawned ${player.name} (Str: ${player.strength}) at (${player.x}, ${player.y})`); } catch (err: unknown) { if (err instanceof Error) { - showNotification(`Error: ${err.message}`); - } else { - showNotification('Failed to spawn bot'); + showNotification(`Failed to spawn bot: ${err.message}`); } } }; const handleReset = async () => { - if (window.confirm('Are you sure you want to clear all bots and parties from the board?')) { - try { - await resetBoard(); - showNotification('Board cleared'); - } catch (err: unknown) { - if (err instanceof Error) { - showNotification(`Error: ${err.message}`); - } + try { + await resetBoard(); + setSelectedPlayer(null); + setIsAutoPlaying(false); + showNotification('Board and all game state cleared.'); + } catch (err: unknown) { + if (err instanceof Error) { + showNotification(`Reset failed: ${err.message}`); } } }; @@ -207,7 +203,7 @@ export function App() { {/* 3-Bout D20 Battle Modal */} setActiveBattle(null)} + onClose={handleCloseBattle} /> {/* Game Conclusion Scoreboard Modal */} diff --git a/frontend/src/components/BattleModal.tsx b/frontend/src/components/BattleModal.tsx index b9cc2e1..7db8ee6 100644 --- a/frontend/src/components/BattleModal.tsx +++ b/frontend/src/components/BattleModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { BattleResult } from '../types'; interface BattleModalProps { @@ -8,28 +8,53 @@ interface BattleModalProps { export const BattleModal: React.FC = ({ battle, onClose }) => { const [countdown, setCountdown] = useState(5); + const [progressPercent, setProgressPercent] = useState(100); + + // Keep onClose in a ref to decouple the timer effect from callback identity changes + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }); + + // Unique battle key so the 5s timer only restarts when a genuinely new battle arrives + const battleKey = battle + ? `${battle.winner_party_id}_${battle.defeated_party_id}_${battle.party1_total_score}_${battle.party2_total_score}_${battle.killed_leader_id}` + : null; useEffect(() => { - if (!battle) return; + if (!battleKey) { + setCountdown(5); + setProgressPercent(100); + return; + } - // Reset countdown to 5 whenever a new battle modal is opened + const DURATION_MS = 5000; + const targetEndTime = Date.now() + DURATION_MS; setCountdown(5); + setProgressPercent(100); const timer = window.setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - window.clearInterval(timer); - onClose(); - return 0; - } - return prev - 1; - }); - }, 1000); + const now = Date.now(); + const remainingMs = targetEndTime - now; + + if (remainingMs <= 0) { + window.clearInterval(timer); + setCountdown(0); + setProgressPercent(0); + onCloseRef.current(); + return; + } + + const seconds = Math.ceil(remainingMs / 1000); + const percent = Math.min(100, Math.max(0, (remainingMs / DURATION_MS) * 100)); + setCountdown(seconds); + setProgressPercent(percent); + }, 100); return () => { window.clearInterval(timer); }; - }, [battle, onClose]); + }, [battleKey]); if (!battle) return null; @@ -42,8 +67,8 @@ export const BattleModal: React.FC = ({ battle, onClose }) => {/* 5-second automatic progress bar */}
@@ -60,7 +85,7 @@ export const BattleModal: React.FC = ({ battle, onClose }) =>