- {player.name.slice(0, 2).toUpperCase()}
+
+
+ {player.name.slice(0, 2).toUpperCase()}
+
+ {isCurrentTurn && (
+
+ 👑
+
+ )}
+
-
- {player.name}
+
+
+ {player.name}
+
+ {orderIndex !== -1 && (
+
+ #{orderIndex + 1}
+
+ )}
pos: ({player.x}, {player.y})
+ {isCurrentTurn && (
+
+ • TURN
+
+ )}
diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts
index 10ac211..eb3801e 100644
--- a/frontend/src/hooks/useGameSocket.ts
+++ b/frontend/src/hooks/useGameSocket.ts
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
-import type { BoardState, Player } from '../types';
+import type { AvailableMovesResponse, BoardState, MoveResponse, Player, TurnInfo } from '../types';
const INITIAL_BOARD: BoardState = {
config: {
@@ -12,14 +12,25 @@ const INITIAL_BOARD: BoardState = {
},
player_count: 0,
players: [],
+ turn: {
+ current_player_id: null,
+ current_player_name: null,
+ round_number: 1,
+ turn_number: 0,
+ turn_order: [],
+ },
};
export function useGameSocket() {
const [boardState, setBoardState] = useState
(INITIAL_BOARD);
const [isConnected, setIsConnected] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState(null);
+ const [availableMoves, setAvailableMoves] = useState(null);
+ const [isAutoPlaying, setIsAutoPlaying] = useState(false);
+
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
+ const autoPlayIntervalRef = useRef(null);
const fetchBoard = useCallback(async () => {
try {
@@ -33,6 +44,20 @@ export function useGameSocket() {
}
}, []);
+ 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;
@@ -65,8 +90,20 @@ export function useGameSocket() {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
+ turn: data.turn ?? prev.turn,
};
});
+ } else if (data.event === 'player_moved') {
+ setBoardState((prev) => ({
+ ...prev,
+ players: prev.players.map((p) => (p.id === data.player.id ? data.player : p)),
+ turn: data.turn ?? prev.turn,
+ }));
+ } 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);
@@ -74,6 +111,7 @@ export function useGameSocket() {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
+ turn: data.turn ?? prev.turn,
};
});
setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev));
@@ -82,8 +120,10 @@ export function useGameSocket() {
...prev,
player_count: 0,
players: [],
+ turn: data.turn ?? INITIAL_BOARD.turn,
}));
setSelectedPlayer(null);
+ setAvailableMoves(null);
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
@@ -117,7 +157,7 @@ export function useGameSocket() {
};
}, [fetchBoard, connectWebSocket]);
- // Keep player selection in sync if player updates
+ // Keep selected player synced with board state
useEffect(() => {
if (selectedPlayer) {
const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
@@ -125,10 +165,21 @@ export function useGameSocket() {
setSelectedPlayer(updated);
} else {
setSelectedPlayer(null);
+ setAvailableMoves(null);
}
}
}, [boardState.players, selectedPlayer]);
+ // Auto-fetch available moves for active player or selected player
+ 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 => {
const res = await fetch('/api/players', {
method: 'POST',
@@ -153,6 +204,32 @@ export function useGameSocket() {
}
};
+ const movePlayer = async (playerId: string, direction: string): Promise => {
+ 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();
+ return data;
+ };
+
+ const passTurn = async (playerId: string): Promise => {
+ 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',
@@ -162,13 +239,62 @@ export function useGameSocket() {
}
};
+ // Step a single turn for the active bot (random valid move)
+ const stepActiveBotTurn = useCallback(async () => {
+ const currentId = boardState.turn.current_player_id;
+ if (!currentId) return;
+
+ try {
+ 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) {
+ const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)];
+ await movePlayer(currentId, randomDir);
+ } else {
+ // No moves available, pass turn
+ 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();
+ }, 700);
+ } 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,
registerPlayer,
removePlayer,
+ movePlayer,
+ passTurn,
+ stepActiveBotTurn,
resetBoard,
refresh: fetchBoard,
};
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index dcb3379..5357fc5 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -16,13 +16,51 @@ export interface BoardConfig {
grid_cells_y: number;
}
+export interface TurnInfo {
+ current_player_id: string | null;
+ current_player_name: string | null;
+ round_number: number;
+ turn_number: number;
+ turn_order: string[];
+}
+
export interface BoardState {
config: BoardConfig;
player_count: number;
players: Player[];
+ turn: TurnInfo;
}
export interface PlayerCreateRequest {
name: string;
color: string;
}
+
+export interface MoveCheckResult {
+ direction: string;
+ dx: number;
+ dy: number;
+ target_x: number;
+ target_y: number;
+ available: boolean;
+ reason?: string | null;
+}
+
+export interface AvailableMovesResponse {
+ player_id: string;
+ player_name: string;
+ current_x: number;
+ current_y: number;
+ is_turn: boolean;
+ current_turn_player_id?: string | null;
+ moves: Record;
+}
+
+export interface MoveResponse {
+ success: boolean;
+ player: Player;
+ direction: string;
+ previous_position: { x: number; y: number };
+ new_position: { x: number; y: number };
+ turn: TurnInfo;
+}