new bot agent, in python
This commit is contained in:
parent
d2f2eed3f7
commit
6e8f8ef2b2
|
|
@ -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()
|
||||||
|
|
@ -1,36 +1,21 @@
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useGameSocket } from './hooks/useGameSocket';
|
import { useGameSocket } from './hooks/useGameSocket';
|
||||||
import { BattleModal } from './components/BattleModal';
|
|
||||||
import { BoardCanvas } from './components/BoardCanvas';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
|
import { BoardCanvas } from './components/BoardCanvas';
|
||||||
import { MovementControls } from './components/MovementControls';
|
import { MovementControls } from './components/MovementControls';
|
||||||
import { PartyModal } from './components/PartyModal';
|
|
||||||
import { PlayerList } from './components/PlayerList';
|
import { PlayerList } from './components/PlayerList';
|
||||||
import { RegisterModal } from './components/RegisterModal';
|
import { RegisterModal } from './components/RegisterModal';
|
||||||
|
import { PartyModal } from './components/PartyModal';
|
||||||
|
import { BattleModal } from './components/BattleModal';
|
||||||
import { ScoreboardModal } from './components/ScoreboardModal';
|
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||||
|
|
||||||
const BOT_NAMES = [
|
const BOT_PRESETS = [
|
||||||
'ViperStrike',
|
{ name: 'AlphaBot', color: '#38bdf8', strength: 1 },
|
||||||
'PulseMatrix',
|
{ name: 'BetaTank', color: '#f43f5e', strength: 2 },
|
||||||
'NexusGlitch',
|
{ name: 'GammaStriker', color: '#a855f7', strength: 3 },
|
||||||
'AegisPrime',
|
{ name: 'DeltaRanger', color: '#22c55e', strength: 1 },
|
||||||
'CobaltDrift',
|
{ name: 'OmegaTitan', color: '#eab308', strength: 4 },
|
||||||
'PhantomCore',
|
{ name: 'SigmaScout', color: '#ec4899', strength: 1 },
|
||||||
'ZephyrByte',
|
|
||||||
'TitanSpark',
|
|
||||||
'ShadowGrid',
|
|
||||||
'QuantumFang',
|
|
||||||
];
|
|
||||||
|
|
||||||
const BOT_COLORS = [
|
|
||||||
'#38BDF8', // Sky
|
|
||||||
'#F43F5E', // Rose
|
|
||||||
'#10B981', // Emerald
|
|
||||||
'#F59E0B', // Amber
|
|
||||||
'#A855F7', // Purple
|
|
||||||
'#EC4899', // Pink
|
|
||||||
'#06B6D4', // Cyan
|
|
||||||
'#6366F1', // Indigo
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|
@ -62,6 +47,10 @@ export function App() {
|
||||||
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
|
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
|
||||||
const [notification, setNotification] = useState<string | null>(null);
|
const [notification, setNotification] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCloseBattle = useCallback(() => {
|
||||||
|
setActiveBattle(null);
|
||||||
|
}, [setActiveBattle]);
|
||||||
|
|
||||||
const showNotification = (msg: string) => {
|
const showNotification = (msg: string) => {
|
||||||
setNotification(msg);
|
setNotification(msg);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -70,29 +59,36 @@ export function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickSpawn = async () => {
|
const handleQuickSpawn = async () => {
|
||||||
const name = BOT_NAMES[Math.floor(Math.random() * BOT_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100);
|
const existingNames = new Set(boardState.players.map((p) => p.name));
|
||||||
const color = BOT_COLORS[Math.floor(Math.random() * BOT_COLORS.length)];
|
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 {
|
try {
|
||||||
const player = await registerPlayer(name, color);
|
const player = await registerPlayer(preset.name, preset.color, preset.strength);
|
||||||
showNotification(`Spawned ${player.name} at (${player.x}, ${player.y})`);
|
showNotification(`Spawned ${player.name} (Str: ${player.strength}) at (${player.x}, ${player.y})`);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
showNotification(`Error: ${err.message}`);
|
showNotification(`Failed to spawn bot: ${err.message}`);
|
||||||
} else {
|
|
||||||
showNotification('Failed to spawn bot');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (window.confirm('Are you sure you want to clear all bots and parties from the board?')) {
|
|
||||||
try {
|
try {
|
||||||
await resetBoard();
|
await resetBoard();
|
||||||
showNotification('Board cleared');
|
setSelectedPlayer(null);
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
showNotification('Board and all game state cleared.');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
showNotification(`Error: ${err.message}`);
|
showNotification(`Reset failed: ${err.message}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -207,7 +203,7 @@ export function App() {
|
||||||
{/* 3-Bout D20 Battle Modal */}
|
{/* 3-Bout D20 Battle Modal */}
|
||||||
<BattleModal
|
<BattleModal
|
||||||
battle={activeBattle}
|
battle={activeBattle}
|
||||||
onClose={() => setActiveBattle(null)}
|
onClose={handleCloseBattle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Game Conclusion Scoreboard Modal */}
|
{/* Game Conclusion Scoreboard Modal */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import type { BattleResult } from '../types';
|
import type { BattleResult } from '../types';
|
||||||
|
|
||||||
interface BattleModalProps {
|
interface BattleModalProps {
|
||||||
|
|
@ -8,28 +8,53 @@ interface BattleModalProps {
|
||||||
|
|
||||||
export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) => {
|
export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) => {
|
||||||
const [countdown, setCountdown] = useState(5);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!battle) return;
|
if (!battleKey) {
|
||||||
|
|
||||||
// Reset countdown to 5 whenever a new battle modal is opened
|
|
||||||
setCountdown(5);
|
setCountdown(5);
|
||||||
|
setProgressPercent(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_MS = 5000;
|
||||||
|
const targetEndTime = Date.now() + DURATION_MS;
|
||||||
|
setCountdown(5);
|
||||||
|
setProgressPercent(100);
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setCountdown((prev) => {
|
const now = Date.now();
|
||||||
if (prev <= 1) {
|
const remainingMs = targetEndTime - now;
|
||||||
|
|
||||||
|
if (remainingMs <= 0) {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
onClose();
|
setCountdown(0);
|
||||||
return 0;
|
setProgressPercent(0);
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return prev - 1;
|
|
||||||
});
|
const seconds = Math.ceil(remainingMs / 1000);
|
||||||
}, 1000);
|
const percent = Math.min(100, Math.max(0, (remainingMs / DURATION_MS) * 100));
|
||||||
|
setCountdown(seconds);
|
||||||
|
setProgressPercent(percent);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [battle, onClose]);
|
}, [battleKey]);
|
||||||
|
|
||||||
if (!battle) return null;
|
if (!battle) return null;
|
||||||
|
|
||||||
|
|
@ -42,8 +67,8 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
{/* 5-second automatic progress bar */}
|
{/* 5-second automatic progress bar */}
|
||||||
<div className="absolute top-2 left-0 right-0 h-1 bg-slate-800">
|
<div className="absolute top-2 left-0 right-0 h-1 bg-slate-800">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-amber-400 transition-all duration-1000 ease-linear"
|
className="h-full bg-amber-400 transition-all duration-100 ease-linear"
|
||||||
style={{ width: `${(countdown / 5) * 100}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -60,7 +85,7 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={() => onCloseRef.current()}
|
||||||
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
||||||
title="Dismiss now"
|
title="Dismiss now"
|
||||||
>
|
>
|
||||||
|
|
@ -155,7 +180,7 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
|
|
||||||
{/* Continue Button with 5s Auto-close Countdown */}
|
{/* Continue Button with 5s Auto-close Countdown */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={() => onCloseRef.current()}
|
||||||
className="w-full bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs font-mono transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2"
|
className="w-full bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs font-mono transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<span>Acknowledge & Continue Battle</span>
|
<span>Acknowledge & Continue Battle</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
AiStepResponse,
|
AiStepResponse,
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
|
|
@ -47,6 +47,8 @@ export function useGameSocket() {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
const autoPlayIntervalRef = useRef<number | null>(null);
|
const autoPlayIntervalRef = useRef<number | null>(null);
|
||||||
|
const activeBattleRef = useRef<BattleResult | null>(null);
|
||||||
|
activeBattleRef.current = activeBattle;
|
||||||
|
|
||||||
const fetchBoard = useCallback(async () => {
|
const fetchBoard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -198,41 +200,35 @@ export function useGameSocket() {
|
||||||
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
|
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
player_count: data.player_count ?? nextPlayers.length,
|
player_count: nextPlayers.length,
|
||||||
players: nextPlayers,
|
players: nextPlayers,
|
||||||
parties: data.parties ?? prev.parties.filter((party) => party.member_ids.includes(data.player_id)),
|
|
||||||
turn: data.turn ?? prev.turn,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev));
|
|
||||||
} else if (data.event === 'board_reset') {
|
} else if (data.event === 'board_reset') {
|
||||||
setBoardState((prev) => ({
|
setBoardState({
|
||||||
...prev,
|
...data.board,
|
||||||
player_count: 0,
|
parties: data.board.parties || [],
|
||||||
players: [],
|
conclusion: data.board.conclusion || null,
|
||||||
parties: [],
|
});
|
||||||
turn: data.turn ?? INITIAL_BOARD.turn,
|
|
||||||
conclusion: null,
|
|
||||||
}));
|
|
||||||
setSelectedPlayer(null);
|
setSelectedPlayer(null);
|
||||||
setAvailableMoves(null);
|
setAvailableMoves(null);
|
||||||
setActiveBattle(null);
|
setActiveBattle(null);
|
||||||
setShowScoreboard(false);
|
setShowScoreboard(false);
|
||||||
|
setLastEventMessage('🔄 Board has been reset.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error parsing WebSocket message:', err);
|
console.error('Error handling WebSocket message:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
console.log('WebSocket closed, attempting reconnect in 3s...');
|
||||||
connectWebSocket();
|
reconnectTimeoutRef.current = window.setTimeout(connectWebSocket, 3000);
|
||||||
}, 2500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
console.error('WebSocket encountered an error:', err);
|
console.error('WebSocket encountered error:', err);
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -253,24 +249,11 @@ export function useGameSocket() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlayer) {
|
if (selectedPlayer) {
|
||||||
const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
|
fetchAvailableMoves(selectedPlayer.id);
|
||||||
if (updated) {
|
|
||||||
setSelectedPlayer(updated);
|
|
||||||
} else {
|
|
||||||
setSelectedPlayer(null);
|
|
||||||
setAvailableMoves(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [boardState.players, selectedPlayer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const targetId = selectedPlayer?.id || boardState.turn.current_player_id;
|
|
||||||
if (targetId) {
|
|
||||||
fetchAvailableMoves(targetId);
|
|
||||||
} else {
|
} else {
|
||||||
setAvailableMoves(null);
|
setAvailableMoves(null);
|
||||||
}
|
}
|
||||||
}, [selectedPlayer?.id, boardState.turn.current_player_id, boardState.players, fetchAvailableMoves]);
|
}, [selectedPlayer, fetchAvailableMoves]);
|
||||||
|
|
||||||
const registerPlayer = async (name: string, color: string, strength: number = 1): Promise<Player> => {
|
const registerPlayer = async (name: string, color: string, strength: number = 1): Promise<Player> => {
|
||||||
const res = await fetch('/api/players', {
|
const res = await fetch('/api/players', {
|
||||||
|
|
@ -279,20 +262,23 @@ export function useGameSocket() {
|
||||||
body: JSON.stringify({ name, color, strength }),
|
body: JSON.stringify({ name, color, strength }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(errorData.detail || 'Failed to register player');
|
throw new Error(err.detail || 'Failed to register player');
|
||||||
}
|
}
|
||||||
const newPlayer: Player = await res.json();
|
const player: Player = await res.json();
|
||||||
setSelectedPlayer(newPlayer);
|
return player;
|
||||||
return newPlayer;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removePlayer = async (id: string) => {
|
const removePlayer = async (playerId: string): Promise<void> => {
|
||||||
const res = await fetch(`/api/players/${id}`, {
|
const res = await fetch(`/api/players/${playerId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to remove player');
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to delete player');
|
||||||
|
}
|
||||||
|
if (selectedPlayer?.id === playerId) {
|
||||||
|
setSelectedPlayer(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -304,7 +290,7 @@ export function useGameSocket() {
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Failed to form party');
|
throw new Error(err.detail || 'Failed to create party');
|
||||||
}
|
}
|
||||||
const party: Party = await res.json();
|
const party: Party = await res.json();
|
||||||
return party;
|
return party;
|
||||||
|
|
@ -316,7 +302,7 @@ export function useGameSocket() {
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Failed to defeat party');
|
throw new Error(err.detail || 'Failed to trigger party defeat');
|
||||||
}
|
}
|
||||||
const result: PartyDefeatResult = await res.json();
|
const result: PartyDefeatResult = await res.json();
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -371,7 +357,7 @@ export function useGameSocket() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetBoard = async () => {
|
const resetBoard = async () => {
|
||||||
const res = await fetch('/api/board/reset', {
|
const res = await fetch('/api/reset', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -384,6 +370,9 @@ export function useGameSocket() {
|
||||||
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
||||||
// - Party leader: seeks other parties to find and defeat all other parties
|
// - Party leader: seeks other parties to find and defeat all other parties
|
||||||
const stepActiveBotTurn = useCallback(async () => {
|
const stepActiveBotTurn = useCallback(async () => {
|
||||||
|
// If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes
|
||||||
|
if (activeBattleRef.current) return;
|
||||||
|
|
||||||
const currentId = boardState.turn.current_player_id;
|
const currentId = boardState.turn.current_player_id;
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
|
|
||||||
|
|
@ -452,6 +441,5 @@ export function useGameSocket() {
|
||||||
passTurn,
|
passTurn,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
resetBoard,
|
resetBoard,
|
||||||
refresh: fetchBoard,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue