botWebWars/frontend/src/hooks/useGameSocket.ts

429 lines
14 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import type {
AvailableMovesResponse,
BattleResult,
BoardState,
MoveResponse,
Party,
PartyDefeatResult,
Player,
TurnInfo,
} from '../types';
const INITIAL_BOARD: BoardState = {
config: {
min_x: 0,
max_x: 64,
min_y: 0,
max_y: 64,
grid_cells_x: 64,
grid_cells_y: 64,
},
player_count: 0,
players: [],
parties: [],
turn: {
current_player_id: null,
current_player_name: null,
round_number: 1,
turn_number: 0,
turn_order: [],
},
};
export function useGameSocket() {
const [boardState, setBoardState] = useState<BoardState>(INITIAL_BOARD);
const [isConnected, setIsConnected] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const autoPlayIntervalRef = useRef<number | null>(null);
const fetchBoard = useCallback(async () => {
try {
const res = await fetch('/api/board');
if (res.ok) {
const data: BoardState = await res.json();
setBoardState((prev) => ({
...data,
parties: data.parties || prev.parties || [],
}));
}
} catch (err) {
console.error('Failed to fetch board state:', err);
}
}, []);
const fetchAvailableMoves = useCallback(async (playerId: string) => {
try {
const res = await fetch(`/api/players/${playerId}/available-moves`);
if (res.ok) {
const data: AvailableMovesResponse = await res.json();
setAvailableMoves(data);
return data;
}
} catch (err) {
console.error('Failed to fetch available moves:', err);
}
return null;
}, []);
const connectWebSocket = useCallback(() => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
console.log('Connected to botWebWars WebSocket');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.event === 'init') {
setBoardState({
...data.state,
parties: data.state.parties || [],
});
} else if (data.event === 'player_joined') {
setBoardState((prev) => {
const exists = prev.players.some((p) => p.id === data.player.id);
const nextPlayers = exists
? prev.players.map((p) => (p.id === data.player.id ? data.player : p))
: [...prev.players, data.player];
return {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
turn: data.turn ?? prev.turn,
};
});
} else if (data.event === 'player_moved') {
setBoardState((prev) => {
const updatedMap = new Map<string, Player>();
if (data.affected_players) {
data.affected_players.forEach((p: Player) => updatedMap.set(p.id, p));
} else if (data.players) {
data.players.forEach((p: Player) => updatedMap.set(p.id, p));
} else if (data.player) {
updatedMap.set(data.player.id, data.player);
}
return {
...prev,
players: prev.players.map((p) => updatedMap.get(p.id) || p),
turn: data.turn ?? prev.turn,
};
});
} else if (data.event === 'party_formed' || data.event === 'party_updated') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: [
...prev.parties.filter((p) => p.id !== data.party.id),
data.party,
],
turn: data.turn ?? prev.turn,
}));
setLastEventMessage(`Party "${data.party.name}" established under leader ${data.party.leader_name}!`);
} else if (data.event === 'party_defeated') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: data.parties ?? prev.parties,
turn: data.turn ?? prev.turn,
}));
const details: PartyDefeatResult = data.defeat_details;
setLastEventMessage(
`💥 Party Defeat! Leader ${details.killed_leader_name} killed (-1 pt) and respawned. ${
details.new_leader_name ? `New leader: ${details.new_leader_name}` : 'Party dissolved.'
}`
);
} else if (data.event === 'battle_resolved') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: data.parties ?? prev.parties,
turn: data.turn ?? prev.turn,
}));
const b: BattleResult = data.battle;
setActiveBattle(b);
setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
} else if (data.event === 'turn_passed') {
setBoardState((prev) => ({
...prev,
turn: data.turn ?? prev.turn,
}));
} else if (data.event === 'player_left') {
setBoardState((prev) => {
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
return {
...prev,
player_count: data.player_count ?? nextPlayers.length,
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') {
setBoardState((prev) => ({
...prev,
player_count: 0,
players: [],
parties: [],
turn: data.turn ?? INITIAL_BOARD.turn,
}));
setSelectedPlayer(null);
setAvailableMoves(null);
setActiveBattle(null);
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
ws.onclose = () => {
setIsConnected(false);
reconnectTimeoutRef.current = window.setTimeout(() => {
connectWebSocket();
}, 2500);
};
ws.onerror = (err) => {
console.error('WebSocket encountered an error:', err);
ws.close();
};
}, []);
useEffect(() => {
fetchBoard();
connectWebSocket();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [fetchBoard, connectWebSocket]);
useEffect(() => {
if (selectedPlayer) {
const updated = boardState.players.find((p) => p.id === 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 {
setAvailableMoves(null);
}
}, [selectedPlayer?.id, boardState.turn.current_player_id, boardState.players, fetchAvailableMoves]);
const registerPlayer = async (name: string, color: string): Promise<Player> => {
const res = await fetch('/api/players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to register player');
}
const newPlayer: Player = await res.json();
setSelectedPlayer(newPlayer);
return newPlayer;
};
const removePlayer = async (id: string) => {
const res = await fetch(`/api/players/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Failed to remove player');
}
};
const formParty = async (memberIds: string[], leaderId: string, name?: string): Promise<Party> => {
const res = await fetch('/api/parties', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_ids: memberIds, leader_id: leaderId, name }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to form party');
}
const party: Party = await res.json();
return party;
};
const defeatParty = async (partyId: string): Promise<PartyDefeatResult> => {
const res = await fetch(`/api/parties/${partyId}/defeat`, {
method: 'POST',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to defeat party');
}
const result: PartyDefeatResult = await res.json();
return result;
};
const fightBattle = async (challengerId: string, defenderId: string): Promise<BattleResult> => {
const res = await fetch('/api/battles/fight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenger_id: challengerId, defender_id: defenderId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Battle failed');
}
const result: BattleResult = await res.json();
setActiveBattle(result);
return result;
};
const movePlayer = async (playerId: string, direction: string): Promise<MoveResponse> => {
const res = await fetch(`/api/players/${playerId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to move player');
}
const data: MoveResponse = await res.json();
if (data.battle_triggered && data.battle_result) {
setActiveBattle(data.battle_result);
}
return data;
};
const passTurn = async (playerId: string): Promise<TurnInfo> => {
const res = await fetch(`/api/players/${playerId}/pass`, {
method: 'POST',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to pass turn');
}
const data: TurnInfo = await res.json();
return data;
};
const resetBoard = async () => {
const res = await fetch('/api/board/reset', {
method: 'POST',
});
if (!res.ok) {
throw new Error('Failed to reset board');
}
};
// Step a single turn: Bot seeking behavior using Radar and Memory
const stepActiveBotTurn = useCallback(async () => {
const currentId = boardState.turn.current_player_id;
if (!currentId) return;
try {
// 1. Check radar for nearest bot to seek/join or engage
const radarRes = await fetch(`/api/players/${currentId}/radar`).catch(() => null);
let targetDirection: string | null = null;
if (radarRes && radarRes.ok) {
const radarData = await radarRes.json();
targetDirection = radarData.recommended_direction;
}
// 2. Check available moves
const movesData = await fetchAvailableMoves(currentId);
if (!movesData) return;
const validDirections = Object.entries(movesData.moves)
.filter(([, result]) => result.available)
.map(([dir]) => dir);
if (validDirections.length > 0) {
// Prefer recommended direction towards nearest bot if available
let chosenDir = targetDirection && validDirections.includes(targetDirection) ? targetDirection : null;
if (!chosenDir) {
chosenDir = validDirections[Math.floor(Math.random() * validDirections.length)];
}
await movePlayer(currentId, chosenDir);
} else {
await passTurn(currentId);
}
} catch (err) {
console.warn('Bot turn step error:', err);
}
}, [boardState.turn.current_player_id, fetchAvailableMoves]);
// Autoplay interval loop
useEffect(() => {
if (isAutoPlaying) {
autoPlayIntervalRef.current = window.setInterval(() => {
stepActiveBotTurn();
}, 750);
} else if (autoPlayIntervalRef.current) {
clearInterval(autoPlayIntervalRef.current);
autoPlayIntervalRef.current = null;
}
return () => {
if (autoPlayIntervalRef.current) {
clearInterval(autoPlayIntervalRef.current);
}
};
}, [isAutoPlaying, stepActiveBotTurn]);
return {
boardState,
isConnected,
selectedPlayer,
setSelectedPlayer,
availableMoves,
isAutoPlaying,
setIsAutoPlaying,
lastEventMessage,
activeBattle,
setActiveBattle,
registerPlayer,
removePlayer,
formParty,
defeatParty,
fightBattle,
movePlayer,
passTurn,
stepActiveBotTurn,
resetBoard,
refresh: fetchBoard,
};
}