2026-09-05 21:10:18 +00:00
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
2026-09-05 21:55:07 +00:00
|
|
|
import type {
|
|
|
|
|
AvailableMovesResponse,
|
|
|
|
|
BattleResult,
|
|
|
|
|
BoardState,
|
|
|
|
|
MoveResponse,
|
|
|
|
|
Party,
|
|
|
|
|
PartyDefeatResult,
|
|
|
|
|
Player,
|
|
|
|
|
TurnInfo,
|
|
|
|
|
} from '../types';
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
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: [],
|
2026-09-05 21:55:07 +00:00
|
|
|
parties: [],
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: {
|
|
|
|
|
current_player_id: null,
|
|
|
|
|
current_player_name: null,
|
|
|
|
|
round_number: 1,
|
|
|
|
|
turn_number: 0,
|
|
|
|
|
turn_order: [],
|
|
|
|
|
},
|
2026-09-05 21:10:18 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function useGameSocket() {
|
|
|
|
|
const [boardState, setBoardState] = useState<BoardState>(INITIAL_BOARD);
|
|
|
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
|
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
|
2026-09-05 21:20:39 +00:00
|
|
|
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
|
|
|
|
|
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
2026-09-05 21:55:07 +00:00
|
|
|
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
2026-09-05 22:09:18 +00:00
|
|
|
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
2026-09-05 21:20:39 +00:00
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
2026-09-05 21:20:39 +00:00
|
|
|
const autoPlayIntervalRef = useRef<number | null>(null);
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
const fetchBoard = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/board');
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
const data: BoardState = await res.json();
|
2026-09-05 21:55:07 +00:00
|
|
|
setBoardState((prev) => ({
|
|
|
|
|
...data,
|
|
|
|
|
parties: data.parties || prev.parties || [],
|
|
|
|
|
}));
|
2026-09-05 21:10:18 +00:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch board state:', err);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
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;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
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') {
|
2026-09-05 21:55:07 +00:00
|
|
|
setBoardState({
|
|
|
|
|
...data.state,
|
|
|
|
|
parties: data.state.parties || [],
|
|
|
|
|
});
|
2026-09-05 21:10:18 +00:00
|
|
|
} 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,
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: data.turn ?? prev.turn,
|
2026-09-05 21:10:18 +00:00
|
|
|
};
|
|
|
|
|
});
|
2026-09-05 21:20:39 +00:00
|
|
|
} else if (data.event === 'player_moved') {
|
2026-09-05 21:55:07 +00:00
|
|
|
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') {
|
2026-09-05 21:20:39 +00:00
|
|
|
setBoardState((prev) => ({
|
|
|
|
|
...prev,
|
2026-09-05 21:55:07 +00:00
|
|
|
players: data.players ?? prev.players,
|
|
|
|
|
parties: data.parties ?? prev.parties,
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: data.turn ?? prev.turn,
|
|
|
|
|
}));
|
2026-09-05 21:55:07 +00:00
|
|
|
const b: BattleResult = data.battle;
|
2026-09-05 22:09:18 +00:00
|
|
|
setActiveBattle(b);
|
|
|
|
|
setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
2026-09-05 21:20:39 +00:00
|
|
|
} else if (data.event === 'turn_passed') {
|
|
|
|
|
setBoardState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
turn: data.turn ?? prev.turn,
|
|
|
|
|
}));
|
2026-09-05 21:10:18 +00:00
|
|
|
} 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,
|
2026-09-05 21:55:07 +00:00
|
|
|
parties: data.parties ?? prev.parties.filter((party) => party.member_ids.includes(data.player_id)),
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: data.turn ?? prev.turn,
|
2026-09-05 21:10:18 +00:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev));
|
|
|
|
|
} else if (data.event === 'board_reset') {
|
|
|
|
|
setBoardState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
player_count: 0,
|
|
|
|
|
players: [],
|
2026-09-05 21:55:07 +00:00
|
|
|
parties: [],
|
2026-09-05 21:20:39 +00:00
|
|
|
turn: data.turn ?? INITIAL_BOARD.turn,
|
2026-09-05 21:10:18 +00:00
|
|
|
}));
|
|
|
|
|
setSelectedPlayer(null);
|
2026-09-05 21:20:39 +00:00
|
|
|
setAvailableMoves(null);
|
2026-09-05 22:09:18 +00:00
|
|
|
setActiveBattle(null);
|
2026-09-05 21:10:18 +00:00
|
|
|
}
|
|
|
|
|
} 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);
|
2026-09-05 21:20:39 +00:00
|
|
|
setAvailableMoves(null);
|
2026-09-05 21:10:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [boardState.players, selectedPlayer]);
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
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();
|
2026-09-05 22:09:18 +00:00
|
|
|
setActiveBattle(result);
|
2026-09-05 21:55:07 +00:00
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
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();
|
2026-09-05 22:09:18 +00:00
|
|
|
if (data.battle_triggered && data.battle_result) {
|
|
|
|
|
setActiveBattle(data.battle_result);
|
|
|
|
|
}
|
2026-09-05 21:20:39 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
const resetBoard = async () => {
|
|
|
|
|
const res = await fetch('/api/board/reset', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error('Failed to reset board');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 22:09:18 +00:00
|
|
|
// Step a single turn: Bot seeking behavior using Radar and Memory
|
2026-09-05 21:20:39 +00:00
|
|
|
const stepActiveBotTurn = useCallback(async () => {
|
|
|
|
|
const currentId = boardState.turn.current_player_id;
|
|
|
|
|
if (!currentId) return;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-09-05 22:09:18 +00:00
|
|
|
// 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
|
2026-09-05 21:20:39 +00:00
|
|
|
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) {
|
2026-09-05 22:09:18 +00:00
|
|
|
// 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);
|
2026-09-05 21:20:39 +00:00
|
|
|
} 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();
|
2026-09-05 21:55:07 +00:00
|
|
|
}, 750);
|
2026-09-05 21:20:39 +00:00
|
|
|
} else if (autoPlayIntervalRef.current) {
|
|
|
|
|
clearInterval(autoPlayIntervalRef.current);
|
|
|
|
|
autoPlayIntervalRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (autoPlayIntervalRef.current) {
|
|
|
|
|
clearInterval(autoPlayIntervalRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [isAutoPlaying, stepActiveBotTurn]);
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
return {
|
|
|
|
|
boardState,
|
|
|
|
|
isConnected,
|
|
|
|
|
selectedPlayer,
|
|
|
|
|
setSelectedPlayer,
|
2026-09-05 21:20:39 +00:00
|
|
|
availableMoves,
|
|
|
|
|
isAutoPlaying,
|
|
|
|
|
setIsAutoPlaying,
|
2026-09-05 21:55:07 +00:00
|
|
|
lastEventMessage,
|
2026-09-05 22:09:18 +00:00
|
|
|
activeBattle,
|
|
|
|
|
setActiveBattle,
|
2026-09-05 21:10:18 +00:00
|
|
|
registerPlayer,
|
|
|
|
|
removePlayer,
|
2026-09-05 21:55:07 +00:00
|
|
|
formParty,
|
|
|
|
|
defeatParty,
|
|
|
|
|
fightBattle,
|
2026-09-05 21:20:39 +00:00
|
|
|
movePlayer,
|
|
|
|
|
passTurn,
|
|
|
|
|
stepActiveBotTurn,
|
2026-09-05 21:10:18 +00:00
|
|
|
resetBoard,
|
|
|
|
|
refresh: fetchBoard,
|
|
|
|
|
};
|
|
|
|
|
}
|