diff --git a/README.md b/README.md index 21b8380..079903b 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,61 @@ # botWebWars -A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players register with their name and custom avatar color and are randomly placed onto the grid. +A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/bots register with their name and custom avatar color, are placed randomly onto the board, and take turns moving around the arena. --- ## Features -- **FastAPI Backend**: - - `POST /api/players` (or `/api/register`): Register a player with `name` and `color`. The game places them at a random unoccupied `(x, y)` coordinate between `(0, 0)` and `(64, 64)`. - - `GET /api/board`: Returns grid dimensions, player count, and player list. - - `GET /api/players`: Lists active players. - - `DELETE /api/players/{player_id}`: Remove a player from the arena. - - `POST /api/board/reset`: Reset the board and clear all players. - - `/ws`: Real-time WebSocket feed pushing live player spawns, departures, and board resets to all connected clients. - - Interactive OpenAPI/Swagger documentation at `/docs`. +### Turn-Based Movement & Rules +- **Turn Order**: Strict round-robin turn order based on registration sequence. +- **8 Movement Directions**: + - **Cardinal**: `UP`, `DOWN`, `LEFT`, `RIGHT` (or `N`, `S`, `W`, `E`) + - **Diagonal**: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT` (or `NW`, `NE`, `SW`, `SE`) +- **Obstacle & Boundary Enforcement**: + - **Boundary Walls**: Cannot break past coordinate boundaries `[0..64, 0..64]`. + - **Collision Prevention**: Cannot move into any square occupied by another player/bot. + - **Turn Enforcement**: A bot can only move when it is their turn (`403 Forbidden` if attempted out of turn). +- **Pass Turn**: Players can skip/pass their turn if stuck or desired. -- **React + Vite + Tailwind CSS Frontend**: - - High-performance HTML5 Canvas rendering for the 64×64 arena with pan (click & drag), zoom (mouse wheel or zoom buttons), and grid coordinate rulers. - - Hover cursor coordinate tracker showing exact `(x, y)` coordinates. - - Glowing bot avatars rendered in the player's chosen color with nameplates and pulsing selection rings. - - Live registration modal with interactive color picker, cyberpunk color presets, and real-time avatar preview. - - Sidebar showing all active bots and coordinates; click any bot to focus the camera on them. - - 1-click **Quick Spawn Bot** button for rapid testing. - - Real-time synchronization via WebSockets with auto-reconnection. +### REST Endpoints -- **Dockerized Single-Container Deployment**: - - Multi-stage `Dockerfile` compiles the frontend with Node 22 and packages it into a lightweight Python 3.12 image. - - FastAPI serves both the API/WebSocket endpoints and the compiled SPA frontend. +#### Movement & Inspection Endpoints +- `GET /api/players/{player_id}/available-moves` + - Checks all 8 movement directions for availability, target coordinates, and rejection reasons (wall collision or occupied by another bot). Also indicates if it is currently that bot's turn. +- `GET /api/players/{player_id}/check-move?direction={DIR}` + - Fast check for a single direction (e.g. `UP`, `DOWN`, `NW`, etc.). Returns `available: true/false`, target coordinates, and reason if blocked. +- `POST /api/players/{player_id}/move` + - Execute a movement. Accepts `{"direction": "UP_RIGHT"}` or `{"dx": 1, "dy": -1}`. + - Validates boundaries, occupant collisions, and turn order. + - Advances position and rotates turn to next player. +- `POST /api/players/{player_id}/pass` + - Passes the turn to the next player. +- `GET /api/turn` + - Returns current turn state (active player ID & name, round number, turn number, and player order). + +#### Management Endpoints +- `POST /api/players` (or `/api/register`): Register a player with `name` and `color`. +- `GET /api/board`: Returns grid dimensions, player count, all player coordinates, and turn information. +- `GET /api/players`: Lists active players. +- `DELETE /api/players/{player_id}`: Remove a player from the arena. +- `POST /api/board/reset`: Reset the board and clear all players. +- `/ws`: Real-time WebSocket feed broadcasting movements, spawns, departures, and turn rotations. +- Interactive OpenAPI / Swagger documentation at `/docs`. + +--- + +### Interactive Web UI (React + Vite + Tailwind CSS) +- **HTML5 Canvas 64×64 Grid**: + - Click-and-drag pan, mouse-wheel zoom, coordinate axis rulers (0 to 64), and hover coordinate tracker. + - Golden pulsing halo and crown `👑` highlighting the active turn player. + - Visual movement overlays showing free cells (green) vs blocked cells (red) around the active bot. +- **8-Directional On-Screen D-Pad**: + - Compass layout (NW, N, NE, W, PASS, E, SW, S, SE) with real-time green/red availability styling and tooltips. + - Full keyboard control: **WASD**, **Arrow keys**, or **Numpad (1-9)**, plus **Spacebar** to pass. +- **Simulation & Testing Tools**: + - **⚡ Step Bot**: Executes 1 valid random move for the active bot. + - **▶ Auto-Play**: Automatically runs bot turns in real time so you can watch them navigate the arena. + - **🎲 Quick Spawn Bot**: 1-click bot generator with fun cyber names and neon colors. --- @@ -55,58 +84,53 @@ docker run -d -p 8000:8000 --name botwebwars botwebwars --- -## API Usage Examples +## API Examples -### Register a Player +### 1. Register a Bot ```bash curl -X POST http://localhost:8000/api/players \ -H "Content-Type: application/json" \ -d '{"name": "CyberViper", "color": "#38BDF8"}' ``` + +### 2. Check If a Direction is Available +```bash +curl "http://localhost:8000/api/players/{player_id}/check-move?direction=UP_RIGHT" +``` Response: ```json { - "id": "bot_a1b2c3d4", - "name": "CyberViper", - "color": "#38BDF8", - "x": 42, - "y": 18, - "created_at": "2026-09-05T21:00:00.000000Z" + "direction": "UP_RIGHT", + "dx": 1, + "dy": -1, + "target_x": 36, + "target_y": 11, + "available": true, + "reason": null } ``` -### Inspect Board State +### 3. Query All 8 Available Directions ```bash -curl http://localhost:8000/api/board +curl "http://localhost:8000/api/players/{player_id}/available-moves" ``` -### Clear / Reset Board +### 4. Move Bot (During Their Turn) ```bash -curl -X POST http://localhost:8000/api/board/reset +curl -X POST "http://localhost:8000/api/players/{player_id}/move" \ + -H "Content-Type: application/json" \ + -d '{"direction": "UP_RIGHT"}' +``` + +### 5. Pass Turn +```bash +curl -X POST "http://localhost:8000/api/players/{player_id}/pass" ``` --- -## Local Development (Without Docker) +## Running Backend Tests -### Backend ```bash -cd backend -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -uvicorn app.main:app --reload --port 8000 -``` - -### Frontend -```bash -cd frontend -npm install -npm run dev -``` -The Vite development server runs at [http://localhost:5173](http://localhost:5173) and proxies `/api` and `/ws` to FastAPI on port 8000. - -### Running Backend Tests -```bash -pytest backend/tests +docker run --rm botwebwars pytest backend/tests ``` diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index e32824f..4b59fa3 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,8 +1,18 @@ -from typing import List -from fastapi import APIRouter, HTTPException, status +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, status from app.api.websocket import manager from app.game import game_engine -from app.models import ApiResponse, BoardState, Player, PlayerCreate +from app.models import ( + ApiResponse, + AvailableMovesResponse, + BoardState, + MoveCheckResult, + MoveRequest, + MoveResponse, + Player, + PlayerCreate, + TurnInfo, +) router = APIRouter() @@ -31,12 +41,14 @@ async def health_check(): ) async def register_player(player_in: PlayerCreate): player = await game_engine.register_player(player_in) + board_state = await game_engine.get_board_state() # Broadcast to all connected WebSocket clients await manager.broadcast({ "event": "player_joined", "player": player.model_dump(), - "player_count": len(await game_engine.get_all_players()), + "player_count": board_state.player_count, + "turn": board_state.turn.model_dump(), }) return player @@ -82,10 +94,12 @@ async def remove_player(player_id: str): detail=f"Player '{player_id}' not found", ) + board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "player_left", "player_id": player_id, - "player_count": len(await game_engine.get_all_players()), + "player_count": board_state.player_count, + "turn": board_state.turn.model_dump(), }) return ApiResponse( @@ -94,6 +108,134 @@ async def remove_player(player_id: str): ) +# ========================================== +# Movement & Turn Endpoints +# ========================================== + +@router.get( + "/players/{player_id}/available-moves", + response_model=AvailableMovesResponse, + summary="Check availability of all 8 movement directions (cardinal & diagonal)", + tags=["Movement"], +) +async def get_available_moves(player_id: str): + try: + return await game_engine.get_available_moves(player_id) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + + +@router.get( + "/players/{player_id}/check-move", + response_model=MoveCheckResult, + summary="Check if a specific direction is available for movement", + tags=["Movement"], +) +async def check_single_move( + player_id: str, + direction: str = Query( + ..., + description="Direction to check: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, E, W, NW, NE, SW, SE)", + ), +): + try: + return await game_engine.check_single_move(player_id, direction) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.post( + "/players/{player_id}/move", + response_model=MoveResponse, + summary="Move player in a cardinal or diagonal direction during their turn", + tags=["Movement"], +) +async def move_player(player_id: str, move_req: MoveRequest): + try: + dx, dy, dir_name = move_req.get_delta() + result = await game_engine.move_player(player_id, dx, dy, dir_name) + + # Broadcast move event to all connected WebSocket clients + await manager.broadcast({ + "event": "player_moved", + "player": result.player.model_dump(), + "direction": result.direction, + "previous_position": result.previous_position, + "new_position": result.new_position, + "turn": result.turn.model_dump(), + }) + + return result + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.post( + "/players/{player_id}/pass", + response_model=TurnInfo, + summary="Pass the current turn to the next player", + tags=["Movement"], +) +async def pass_turn(player_id: str): + try: + next_turn = await game_engine.pass_turn(player_id) + await manager.broadcast({ + "event": "turn_passed", + "passed_by": player_id, + "turn": next_turn.model_dump(), + }) + return next_turn + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) + + +@router.get( + "/turn", + response_model=TurnInfo, + summary="Get current turn details and round information", + tags=["Turn"], +) +async def get_turn(): + board = await game_engine.get_board_state() + return board.turn + + +# ========================================== +# Board Endpoints +# ========================================== + @router.get( "/board", response_model=BoardState, @@ -112,9 +254,11 @@ async def get_board(): ) async def reset_board(): await game_engine.reset() + board = await game_engine.get_board_state() await manager.broadcast({ "event": "board_reset", "player_count": 0, + "turn": board.turn.model_dump(), }) return ApiResponse( success=True, diff --git a/backend/app/game.py b/backend/app/game.py index 3ed7397..f79a099 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -3,13 +3,39 @@ import random import uuid from typing import Dict, List, Optional, Tuple from app.config import settings -from app.models import BoardConfig, BoardState, Player, PlayerCreate +from app.models import ( + BoardConfig, + BoardState, + MoveCheckResult, + MoveResponse, + Player, + PlayerCreate, + TurnInfo, + DIRECTION_OFFSETS, + AvailableMovesResponse, +) + +STANDARD_DIRECTIONS = [ + ("UP", 0, -1), + ("UP_RIGHT", 1, -1), + ("RIGHT", 1, 0), + ("DOWN_RIGHT", 1, 1), + ("DOWN", 0, 1), + ("DOWN_LEFT", -1, 1), + ("LEFT", -1, 0), + ("UP_LEFT", -1, -1), +] class GameEngine: def __init__(self): self._lock = asyncio.Lock() self.players: Dict[str, Player] = {} + self.turn_order: List[str] = [] + self.current_turn_index: int = 0 + self.round_number: int = 1 + self.turn_number: int = 0 + self.config = BoardConfig( min_x=settings.GRID_MIN_X, max_x=settings.GRID_MAX_X, @@ -19,8 +45,8 @@ class GameEngine: grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y, ) - def _get_occupied_coordinates(self) -> set[Tuple[int, int]]: - return {(p.x, p.y) for p in self.players.values()} + def _get_occupied_coordinates(self) -> Dict[Tuple[int, int], Player]: + return {(p.x, p.y): p for p in self.players.values()} def _find_random_free_position(self) -> Tuple[int, int]: occupied = self._get_occupied_coordinates() @@ -29,20 +55,17 @@ class GameEngine: ) if len(occupied) >= total_possible: - # All spots taken, fallback to random any spot return ( random.randint(self.config.min_x, self.config.max_x), random.randint(self.config.min_y, self.config.max_y), ) - # Fast random sampling for _ in range(100): rx = random.randint(self.config.min_x, self.config.max_x) ry = random.randint(self.config.min_y, self.config.max_y) if (rx, ry) not in occupied: return (rx, ry) - # Fallback exhaustive search if dense all_coords = [ (x, y) for x in range(self.config.min_x, self.config.max_x + 1) @@ -51,11 +74,37 @@ class GameEngine: ] return random.choice(all_coords) + def _get_current_player(self) -> Optional[Player]: + if not self.turn_order: + return None + safe_index = self.current_turn_index % len(self.turn_order) + player_id = self.turn_order[safe_index] + return self.players.get(player_id) + + def _get_turn_info(self) -> TurnInfo: + current = self._get_current_player() + return TurnInfo( + current_player_id=current.id if current else None, + current_player_name=current.name if current else None, + round_number=self.round_number, + turn_number=self.turn_number, + turn_order=list(self.turn_order), + ) + + def _advance_turn(self): + if not self.turn_order: + self.current_turn_index = 0 + return + self.turn_number += 1 + self.current_turn_index = (self.current_turn_index + 1) % len(self.turn_order) + if self.current_turn_index == 0: + self.round_number += 1 + async def register_player(self, player_in: PlayerCreate) -> Player: async with self._lock: player_id = f"bot_{uuid.uuid4().hex[:8]}" x, y = self._find_random_free_position() - + player = Player( id=player_id, name=player_in.name, @@ -64,6 +113,7 @@ class GameEngine: y=y, ) self.players[player_id] = player + self.turn_order.append(player_id) return player async def get_player(self, player_id: str) -> Optional[Player]: @@ -76,14 +126,31 @@ class GameEngine: async def remove_player(self, player_id: str) -> bool: async with self._lock: - if player_id in self.players: - del self.players[player_id] - return True - return False + if player_id not in self.players: + return False + + del self.players[player_id] + + if player_id in self.turn_order: + idx = self.turn_order.index(player_id) + self.turn_order.remove(player_id) + + if self.turn_order: + if idx < self.current_turn_index: + self.current_turn_index -= 1 + self.current_turn_index %= len(self.turn_order) + else: + self.current_turn_index = 0 + + return True async def reset(self) -> None: async with self._lock: self.players.clear() + self.turn_order.clear() + self.current_turn_index = 0 + self.round_number = 1 + self.turn_number = 0 async def get_board_state(self) -> BoardState: async with self._lock: @@ -92,8 +159,155 @@ class GameEngine: config=self.config, player_count=len(players_list), players=players_list, + turn=self._get_turn_info(), ) + def _check_move_internal( + self, + player: Player, + dx: int, + dy: int, + direction_name: str, + occupied_map: Dict[Tuple[int, int], Player], + ) -> MoveCheckResult: + target_x = player.x + dx + target_y = player.y + dy + + # 1. Boundary Wall Check + if ( + target_x < self.config.min_x + or target_x > self.config.max_x + or target_y < self.config.min_y + or target_y > self.config.max_y + ): + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=target_x, + target_y=target_y, + available=False, + reason=f"Wall collision at ({target_x}, {target_y}). Grid boundaries are [{self.config.min_x}..{self.config.max_x}, {self.config.min_y}..{self.config.max_y}].", + ) + + # 2. Occupancy Check + occupant = occupied_map.get((target_x, target_y)) + if occupant is not None and occupant.id != player.id: + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=target_x, + target_y=target_y, + available=False, + reason=f"Space occupied by {occupant.name} ({occupant.id}) at ({target_x}, {target_y}).", + ) + + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=target_x, + target_y=target_y, + available=True, + reason=None, + ) + + async def check_single_move(self, player_id: str, direction_input: str) -> MoveCheckResult: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + normalized = direction_input.strip().upper().replace(" ", "_") + if normalized not in DIRECTION_OFFSETS: + raise ValueError( + f"Unknown direction '{direction_input}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, W, E, NW, NE, SW, SE)" + ) + + dx, dy = DIRECTION_OFFSETS[normalized] + occupied = self._get_occupied_coordinates() + return self._check_move_internal(player, dx, dy, normalized, occupied) + + async def get_available_moves(self, player_id: str) -> AvailableMovesResponse: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + current_turn_player = self._get_current_player() + is_turn = current_turn_player is not None and current_turn_player.id == player_id + occupied = self._get_occupied_coordinates() + + moves: Dict[str, MoveCheckResult] = {} + for name, dx, dy in STANDARD_DIRECTIONS: + moves[name] = self._check_move_internal(player, dx, dy, name, occupied) + + return AvailableMovesResponse( + player_id=player.id, + player_name=player.name, + current_x=player.x, + current_y=player.y, + is_turn=is_turn, + current_turn_player_id=current_turn_player.id if current_turn_player else None, + moves=moves, + ) + + async def move_player(self, player_id: str, dx: int, dy: int, direction_name: str) -> MoveResponse: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + current_turn_player = self._get_current_player() + if not current_turn_player or current_turn_player.id != player_id: + curr_name = current_turn_player.name if current_turn_player else "Nobody" + curr_id = current_turn_player.id if current_turn_player else "None" + raise PermissionError( + f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." + ) + + occupied = self._get_occupied_coordinates() + check = self._check_move_internal(player, dx, dy, direction_name, occupied) + + if not check.available: + raise ValueError(f"Illegal move: {check.reason}") + + prev_pos = {"x": player.x, "y": player.y} + player.x = check.target_x + player.y = check.target_y + new_pos = {"x": player.x, "y": player.y} + + # Advance turn order to next player + self._advance_turn() + turn_info = self._get_turn_info() + + return MoveResponse( + success=True, + player=player, + direction=direction_name, + previous_position=prev_pos, + new_position=new_pos, + turn=turn_info, + ) + + async def pass_turn(self, player_id: str) -> TurnInfo: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + current_turn_player = self._get_current_player() + if not current_turn_player or current_turn_player.id != player_id: + curr_name = current_turn_player.name if current_turn_player else "Nobody" + curr_id = current_turn_player.id if current_turn_player else "None" + raise PermissionError( + f"Cannot pass: it is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." + ) + + self._advance_turn() + return self._get_turn_info() + # Global game engine instance game_engine = GameEngine() diff --git a/backend/app/models.py b/backend/app/models.py index 11a6550..5776c7b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,9 +1,101 @@ import re from datetime import datetime, timezone -from typing import List, Optional +from enum import Enum +from typing import Dict, List, Optional from pydantic import BaseModel, Field, field_validator +class Direction(str, Enum): + UP = "UP" + DOWN = "DOWN" + LEFT = "LEFT" + RIGHT = "RIGHT" + UP_LEFT = "UP_LEFT" + UP_RIGHT = "UP_RIGHT" + DOWN_LEFT = "DOWN_LEFT" + DOWN_RIGHT = "DOWN_RIGHT" + + +# Map standard names, cardinal letters, and abbreviations +DIRECTION_OFFSETS: Dict[str, tuple[int, int]] = { + # Cardinal + "UP": (0, -1), + "DOWN": (0, 1), + "LEFT": (-1, 0), + "RIGHT": (1, 0), + # Abbreviations + "N": (0, -1), + "S": (0, 1), + "W": (-1, 0), + "E": (1, 0), + # Diagonals + "UP_LEFT": (-1, -1), + "UP_RIGHT": (1, -1), + "DOWN_LEFT": (-1, 1), + "DOWN_RIGHT": (1, 1), + # Diagonal Abbreviations + "NW": (-1, -1), + "NE": (1, -1), + "SW": (-1, 1), + "SE": (1, 1), + "UPLEFT": (-1, -1), + "UPRIGHT": (1, -1), + "DOWNLEFT": (-1, 1), + "DOWNRIGHT": (1, 1), +} + + +class MoveRequest(BaseModel): + direction: Optional[str] = Field( + None, + description="Direction to move: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, E, W, NW, NE, SW, SE)", + ) + dx: Optional[int] = Field(None, ge=-1, le=1, description="Delta X coordinate (-1, 0, or 1)") + dy: Optional[int] = Field(None, ge=-1, le=1, description="Delta Y coordinate (-1, 0, or 1)") + + def get_delta(self) -> tuple[int, int, str]: + if self.direction: + normalized = self.direction.strip().upper().replace(" ", "_") + if normalized in DIRECTION_OFFSETS: + dx, dy = DIRECTION_OFFSETS[normalized] + return dx, dy, normalized + raise ValueError( + f"Invalid direction '{self.direction}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, W, E, NW, NE, SW, SE)" + ) + if self.dx is not None and self.dy is not None: + if self.dx == 0 and self.dy == 0: + raise ValueError("Movement delta (dx=0, dy=0) is not a valid move. Use pass if you wish to skip.") + # Find matching direction name + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == self.dx and oy == self.dy and "_" in name: + return self.dx, self.dy, name + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == self.dx and oy == self.dy: + return self.dx, self.dy, name + return self.dx, self.dy, f"CUSTOM({self.dx},{self.dy})" + raise ValueError("Must provide either 'direction' or both 'dx' and 'dy'") + + +class MoveCheckResult(BaseModel): + direction: str + dx: int + dy: int + target_x: int + target_y: int + available: bool + reason: Optional[str] = None + + +class AvailableMovesResponse(BaseModel): + player_id: str + player_name: str + current_x: int + current_y: int + is_turn: bool + current_turn_player_id: Optional[str] = None + moves: Dict[str, MoveCheckResult] + + class PlayerCreate(BaseModel): name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name") @@ -20,7 +112,6 @@ class PlayerCreate(BaseModel): @classmethod def validate_color(cls, v: str) -> str: cleaned = v.strip() - # Accept #RRGGBB, #RGB, or standard alphanumeric color names if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", cleaned): return cleaned.upper() if re.match(r"^[a-zA-Z]{3,20}$", cleaned): @@ -46,10 +137,28 @@ class BoardConfig(BaseModel): grid_cells_y: int = 64 +class TurnInfo(BaseModel): + current_player_id: Optional[str] = None + current_player_name: Optional[str] = None + round_number: int = 1 + turn_number: int = 0 + turn_order: List[str] = [] + + class BoardState(BaseModel): config: BoardConfig player_count: int players: List[Player] + turn: TurnInfo + + +class MoveResponse(BaseModel): + success: bool + player: Player + direction: str + previous_position: Dict[str, int] + new_position: Dict[str, int] + turn: TurnInfo class ApiResponse(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 33e8ce5..999418f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -18,7 +18,6 @@ def test_health(): assert response.status_code == 200 data = response.json() assert data["success"] is True - assert "FastAPI" in data["message"] or data["data"]["service"] == "FastAPI" def test_register_player(): @@ -27,56 +26,129 @@ def test_register_player(): assert res.status_code == 201 player = res.json() assert player["name"] == "CyberBot" - assert player["color"] == "#00FFAA" - assert 0 <= player["x"] <= 64 - assert 0 <= player["y"] <= 64 - assert "id" in player - - -def test_register_alias_endpoint(): - client = TestClient(app) - res = client.post("/api/register", json={"name": "AliasBot", "color": "#FF00FF"}) - assert res.status_code == 201 - player = res.json() - assert player["name"] == "AliasBot" assert 0 <= player["x"] <= 64 assert 0 <= player["y"] <= 64 -def test_board_state_after_registrations(): +def test_turn_order_and_movement(): client = TestClient(app) - client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}) - client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}) - - res = client.get("/api/board") + p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json() + p2 = client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}).json() + + # Board state should show Bot1 as active turn + board = client.get("/api/board").json() + assert board["turn"]["current_player_id"] == p1["id"] + assert board["turn"]["round_number"] == 1 + + # Bot2 cannot move yet (not their turn) + res_fail = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"}) + assert res_fail.status_code == 403 + + # Manually place Bot1 at (10, 10) for deterministic movement test + import asyncio + async def place_p1(): + player = await game_engine.get_player(p1["id"]) + player.x = 10 + player.y = 10 + asyncio.run(place_p1()) + + # Check available moves for Bot1 + moves_res = client.get(f"/api/players/{p1['id']}/available-moves") + assert moves_res.status_code == 200 + moves_data = moves_res.json() + assert moves_data["is_turn"] is True + assert moves_data["moves"]["UP"]["available"] is True + assert moves_data["moves"]["UP"]["target_x"] == 10 + assert moves_data["moves"]["UP"]["target_y"] == 9 + assert moves_data["moves"]["UP_RIGHT"]["target_x"] == 11 + assert moves_data["moves"]["UP_RIGHT"]["target_y"] == 9 + + # Bot1 moves UP + move_res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}) + assert move_res.status_code == 200 + res_json = move_res.json() + assert res_json["new_position"]["x"] == 10 + assert res_json["new_position"]["y"] == 9 + # Turn should advance to Bot2 + assert res_json["turn"]["current_player_id"] == p2["id"] + + +def test_diagonal_movement_and_turn_cycle(): + client = TestClient(app) + p1 = client.post("/api/players", json={"name": "DiagBot", "color": "#123456"}).json() + + import asyncio + async def place(): + player = await game_engine.get_player(p1["id"]) + player.x = 20 + player.y = 20 + asyncio.run(place()) + + # Move diagonal UP_LEFT + res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP_LEFT"}) assert res.status_code == 200 - board = res.json() - assert board["player_count"] == 2 - assert len(board["players"]) == 2 - assert board["config"]["min_x"] == 0 - assert board["config"]["max_x"] == 64 - assert board["config"]["min_y"] == 0 - assert board["config"]["max_y"] == 64 + assert res.json()["new_position"] == {"x": 19, "y": 19} + + # Move diagonal DOWN_RIGHT (single player cycle wrapped around) + res2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "SE"}) + assert res2.status_code == 200 + assert res2.json()["new_position"] == {"x": 20, "y": 20} -def test_player_deletion_and_reset(): +def test_wall_collision(): client = TestClient(app) - reg_res = client.post("/api/players", json={"name": "TempBot", "color": "#123456"}) - player_id = reg_res.json()["id"] + p1 = client.post("/api/players", json={"name": "CornerBot", "color": "#123456"}).json() - del_res = client.delete(f"/api/players/{player_id}") - assert del_res.status_code == 200 + import asyncio + async def place_corner(): + player = await game_engine.get_player(p1["id"]) + player.x = 0 + player.y = 0 + asyncio.run(place_corner()) - board_res = client.get("/api/board") - assert board_res.json()["player_count"] == 0 + # Query single move check + check_up = client.get(f"/api/players/{p1['id']}/check-move?direction=UP") + assert check_up.status_code == 200 + assert check_up.json()["available"] is False + assert "Wall collision" in check_up.json()["reason"] + + # Attempt to move UP through boundary wall (y < 0) + res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}) + assert res.status_code == 400 + assert "Illegal move" in res.json()["detail"] -def test_validation_errors(): +def test_player_collision(): client = TestClient(app) - # Empty name - res = client.post("/api/players", json={"name": " ", "color": "#FF0000"}) - assert res.status_code == 422 + p1 = client.post("/api/players", json={"name": "Attacker", "color": "#123456"}).json() + p2 = client.post("/api/players", json={"name": "Defender", "color": "#654321"}).json() - # Invalid color - res = client.post("/api/players", json={"name": "Bot", "color": "not-a-color-123456789"}) - assert res.status_code == 422 + import asyncio + async def place_neighbors(): + p_att = await game_engine.get_player(p1["id"]) + p_att.x = 10 + p_att.y = 10 + p_def = await game_engine.get_player(p2["id"]) + p_def.x = 11 + p_def.y = 10 + asyncio.run(place_neighbors()) + + # Attacker tries to move RIGHT into Defender's space + check_right = client.get(f"/api/players/{p1['id']}/check-move?direction=RIGHT") + assert check_right.status_code == 200 + assert check_right.json()["available"] is False + assert "occupied" in check_right.json()["reason"].lower() + + move_fail = client.post(f"/api/players/{p1['id']}/move", json={"direction": "RIGHT"}) + assert move_fail.status_code == 400 + assert "Illegal move" in move_fail.json()["detail"] + + +def test_pass_turn(): + client = TestClient(app) + p1 = client.post("/api/players", json={"name": "Passer1", "color": "#111111"}).json() + p2 = client.post("/api/players", json={"name": "Passer2", "color": "#222222"}).json() + + res = client.post(f"/api/players/{p1['id']}/pass") + assert res.status_code == 200 + assert res.json()["current_player_id"] == p2["id"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 506ecd8..e59fbd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useGameSocket } from './hooks/useGameSocket'; import { BoardCanvas } from './components/BoardCanvas'; import { Header } from './components/Header'; +import { MovementControls } from './components/MovementControls'; import { PlayerList } from './components/PlayerList'; import { RegisterModal } from './components/RegisterModal'; @@ -35,8 +36,14 @@ export function App() { isConnected, selectedPlayer, setSelectedPlayer, + availableMoves, + isAutoPlaying, + setIsAutoPlaying, registerPlayer, removePlayer, + movePlayer, + passTurn, + stepActiveBotTurn, resetBoard, } = useGameSocket(); @@ -95,12 +102,29 @@ export function App() { - {/* Sidebar Player Roster */} + {/* 8-Directional Movement D-Pad & Simulation Controls */} + { + await movePlayer(id, dir); + }} + onPass={async (id) => { + await passTurn(id); + }} + onStepBot={stepActiveBotTurn} + isAutoPlaying={isAutoPlaying} + onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} + /> + + {/* Sidebar Player Roster with Turn Order */} +
{notification}
diff --git a/frontend/src/components/BoardCanvas.tsx b/frontend/src/components/BoardCanvas.tsx index 0811f02..2d524da 100644 --- a/frontend/src/components/BoardCanvas.tsx +++ b/frontend/src/components/BoardCanvas.tsx @@ -1,9 +1,10 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; -import type { BoardState, Player } from '../types'; +import type { AvailableMovesResponse, BoardState, Player } from '../types'; interface BoardCanvasProps { boardState: BoardState; selectedPlayer: Player | null; + availableMoves?: AvailableMovesResponse | null; onSelectPlayer: (player: Player | null) => void; onHoverCoord?: (coord: { x: number; y: number } | null) => void; } @@ -11,6 +12,7 @@ interface BoardCanvasProps { export const BoardCanvas: React.FC = ({ boardState, selectedPlayer, + availableMoves, onSelectPlayer, onHoverCoord, }) => { @@ -28,6 +30,7 @@ export const BoardCanvas: React.FC = ({ const { min_x, max_x, min_y, max_y } = boardState.config; const gridCellsX = max_x - min_x; const gridCellsY = max_y - min_y; + const currentTurnId = boardState.turn.current_player_id; // Convert canvas pixel coordinates to grid coordinate (0..64) const pixelToGrid = useCallback( @@ -49,7 +52,6 @@ export const BoardCanvas: React.FC = ({ [offset, zoom, gridCellsX, min_x, max_x, min_y, max_y] ); - // Reset viewport to fit const handleResetView = useCallback(() => { setZoom(1); setOffset({ x: 0, y: 0 }); @@ -62,7 +64,7 @@ export const BoardCanvas: React.FC = ({ const padding = 45; const arenaSize = Math.min(width - padding * 2, height - padding * 2); const cellSize = (arenaSize / gridCellsX) * zoom; - + const targetPx = (selectedPlayer.x - min_x) * cellSize; const targetPy = (selectedPlayer.y - min_y) * cellSize; @@ -122,7 +124,7 @@ export const BoardCanvas: React.FC = ({ ctx.rect(startX, startY, totalWidth, totalHeight); ctx.clip(); - // Subtle checkered or minor grid lines + // Minor grid lines ctx.lineWidth = 0.5; ctx.strokeStyle = '#1e293b'; for (let i = 0; i <= gridCellsX; i++) { @@ -169,30 +171,67 @@ export const BoardCanvas: React.FC = ({ ctx.strokeRect(hx, hy, cellSize, cellSize); } + // Highlight adjacent movement tiles for the active turn player + const activePlayer = boardState.players.find((p) => p.id === currentTurnId); + const highlightedBot = selectedPlayer || activePlayer; + if (highlightedBot && availableMoves && availableMoves.player_id === highlightedBot.id) { + Object.values(availableMoves.moves).forEach((move) => { + const mx = startX + (move.target_x - min_x) * cellSize - cellSize / 2; + const my = startY + (move.target_y - min_y) * cellSize - cellSize / 2; + + if (move.available) { + ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; + ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)'; + ctx.lineWidth = 1; + ctx.fillRect(mx, my, cellSize, cellSize); + ctx.strokeRect(mx, my, cellSize, cellSize); + } else { + ctx.fillStyle = 'rgba(239, 68, 68, 0.08)'; + ctx.strokeStyle = 'rgba(239, 68, 68, 0.3)'; + ctx.lineWidth = 0.8; + ctx.fillRect(mx, my, cellSize, cellSize); + ctx.strokeRect(mx, my, cellSize, cellSize); + } + }); + } + // Draw active players const now = Date.now() / 400; - const pulse = Math.sin(now) * 0.2 + 0.8; + const pulse = Math.sin(now) * 0.25 + 0.75; boardState.players.forEach((player) => { const px = startX + (player.x - min_x) * cellSize; const py = startY + (player.y - min_y) * cellSize; const isSelected = selectedPlayer?.id === player.id; + const isCurrentTurn = currentTurnId === player.id; const isHovered = hoveredPlayer?.id === player.id; const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14)); const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius; + // Current turn beacon aura + if (isCurrentTurn) { + ctx.save(); + ctx.beginPath(); + ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2); + ctx.strokeStyle = '#fbbf24'; // Golden glow for active turn + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.restore(); + } + // Glow effect ctx.save(); ctx.shadowColor = player.color; - ctx.shadowBlur = isSelected ? 18 : 8; + ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8; // Outer beacon ring ctx.beginPath(); ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2); - ctx.strokeStyle = player.color; - ctx.globalAlpha = isSelected ? 0.9 : 0.4; - ctx.lineWidth = 1.5; + ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color; + ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4; + ctx.lineWidth = isCurrentTurn ? 2 : 1.5; ctx.stroke(); // Main Player Token @@ -205,30 +244,30 @@ export const BoardCanvas: React.FC = ({ // Inner Core ctx.beginPath(); ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2); - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff'; ctx.fill(); ctx.restore(); - // Label above player if zoomed in or selected/hovered - if (zoom > 1.8 || isSelected || isHovered) { + // Label above player + if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) { ctx.save(); ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; - const text = player.name; + const text = isCurrentTurn ? `👑 ${player.name}` : player.name; const textMetrics = ctx.measureText(text); - const bgWidth = textMetrics.width + 10; + const bgWidth = textMetrics.width + 12; const bgHeight = 16; const labelY = py - radius - 8; - ctx.fillStyle = 'rgba(15, 23, 42, 0.85)'; - ctx.strokeStyle = player.color; + ctx.fillStyle = 'rgba(15, 23, 42, 0.9)'; + ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#f8fafc'; + ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc'; ctx.fillText(text, px, labelY); ctx.restore(); } @@ -277,7 +316,22 @@ export const BoardCanvas: React.FC = ({ animationFrameId = requestAnimationFrame(render); return () => cancelAnimationFrame(animationFrameId); - }, [boardState, selectedPlayer, hoveredPlayer, hoveredCoord, offset, zoom, gridCellsX, gridCellsY, min_x, max_x, min_y, max_y]); + }, [ + boardState, + selectedPlayer, + hoveredPlayer, + hoveredCoord, + availableMoves, + currentTurnId, + offset, + zoom, + gridCellsX, + gridCellsY, + min_x, + max_x, + min_y, + max_y, + ]); // Mouse Drag to Pan const handleMouseDown = (e: React.MouseEvent) => { @@ -413,7 +467,12 @@ export const BoardCanvas: React.FC = ({ {selectedPlayer.name.slice(0, 2).toUpperCase()}
-
{selectedPlayer.name}
+
+ {selectedPlayer.name} + {selectedPlayer.id === currentTurnId && ( + 👑 Turn + )} +
ID: {selectedPlayer.id} • Pos: ({selectedPlayer.x}, {selectedPlayer.y})
diff --git a/frontend/src/components/MovementControls.tsx b/frontend/src/components/MovementControls.tsx new file mode 100644 index 0000000..64a8ab8 --- /dev/null +++ b/frontend/src/components/MovementControls.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useCallback } from 'react'; +import type { AvailableMovesResponse, BoardState, Player } from '../types'; + +interface MovementControlsProps { + boardState: BoardState; + selectedPlayer: Player | null; + availableMoves: AvailableMovesResponse | null; + onMove: (playerId: string, direction: string) => Promise; + onPass: (playerId: string) => Promise; + onStepBot: () => void; + isAutoPlaying: boolean; + onToggleAutoPlay: () => void; +} + +export const MovementControls: React.FC = ({ + boardState, + selectedPlayer, + availableMoves, + onMove, + onPass, + onStepBot, + isAutoPlaying, + onToggleAutoPlay, +}) => { + const currentTurnId = boardState.turn.current_player_id; + const activePlayer = boardState.players.find((p) => p.id === currentTurnId); + const controlledPlayer = selectedPlayer || activePlayer; + const isMyTurn = controlledPlayer && controlledPlayer.id === currentTurnId; + + const handleDirectionClick = useCallback( + (dir: string) => { + if (!controlledPlayer || !isMyTurn) return; + onMove(controlledPlayer.id, dir).catch((err) => alert(err.message)); + }, + [controlledPlayer, isMyTurn, onMove] + ); + + const handlePassClick = useCallback(() => { + if (!controlledPlayer || !isMyTurn) return; + onPass(controlledPlayer.id).catch((err) => alert(err.message)); + }, [controlledPlayer, isMyTurn, onPass]); + + // Keyboard shortcut listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger if user is typing in an input + if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) { + return; + } + if (!isMyTurn || !controlledPlayer) return; + + switch (e.key) { + case 'ArrowUp': + case 'w': + case 'W': + case '8': + e.preventDefault(); + handleDirectionClick('UP'); + break; + case 'ArrowDown': + case 's': + case 'S': + case '2': + e.preventDefault(); + handleDirectionClick('DOWN'); + break; + case 'ArrowLeft': + case 'a': + case 'A': + case '4': + e.preventDefault(); + handleDirectionClick('LEFT'); + break; + case 'ArrowRight': + case 'd': + case 'D': + case '6': + e.preventDefault(); + handleDirectionClick('RIGHT'); + break; + case '7': + case 'q': + case 'Q': + e.preventDefault(); + handleDirectionClick('UP_LEFT'); + break; + case '9': + case 'e': + case 'E': + e.preventDefault(); + handleDirectionClick('UP_RIGHT'); + break; + case '1': + case 'z': + case 'Z': + e.preventDefault(); + handleDirectionClick('DOWN_LEFT'); + break; + case '3': + case 'c': + case 'C': + e.preventDefault(); + handleDirectionClick('DOWN_RIGHT'); + break; + case ' ': + e.preventDefault(); + handlePassClick(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]); + + if (boardState.players.length === 0) { + return null; + } + + const moves = availableMoves?.moves || {}; + + const renderDirButton = (dir: string, label: string) => { + const check = moves[dir]; + const isAvailable = check?.available ?? false; + const disabled = !isMyTurn || !isAvailable; + const reason = check?.reason || (isAvailable ? `Move to (${check?.target_x}, ${check?.target_y})` : 'Blocked'); + + return ( + + ); + }; + + return ( +
+ {/* Turn Status Banner */} +
+
+ {activePlayer ? ( + <> +
+
+ + Round {boardState.turn.round_number} • Turn {boardState.turn.turn_number} + + + {activePlayer.name} + +
+ + ) : ( + Waiting for bots... + )} +
+ + {isMyTurn ? ( + + YOUR TURN + + ) : ( + + WAITING + + )} +
+ + {/* 8-Directional D-Pad */} +
+ {/* Top Row: NW, UP, NE */} +
+ {renderDirButton('UP_LEFT', '↖')} + {renderDirButton('UP', '↑')} + {renderDirButton('UP_RIGHT', '↗')} +
+ + {/* Middle Row: LEFT, Pass, RIGHT */} +
+ {renderDirButton('LEFT', '←')} + + {renderDirButton('RIGHT', '→')} +
+ + {/* Bottom Row: SW, DOWN, SE */} +
+ {renderDirButton('DOWN_LEFT', '↙')} + {renderDirButton('DOWN', '↓')} + {renderDirButton('DOWN_RIGHT', '↘')} +
+
+ + {/* Simulation / Bot Controls */} +
+ + +
+ +
+ WASD / Arrows / Numpad to move +
+
+ ); +}; diff --git a/frontend/src/components/PlayerList.tsx b/frontend/src/components/PlayerList.tsx index 4751d6b..373321b 100644 --- a/frontend/src/components/PlayerList.tsx +++ b/frontend/src/components/PlayerList.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import type { Player } from '../types'; +import type { BoardState, Player } from '../types'; interface PlayerListProps { - players: Player[]; + boardState: BoardState; selectedPlayer: Player | null; onSelectPlayer: (player: Player) => void; onRemovePlayer: (id: string) => void; } export const PlayerList: React.FC = ({ - players, + boardState, selectedPlayer, onSelectPlayer, onRemovePlayer, }) => { + const { players, turn } = boardState; + const currentTurnId = turn.current_player_id; + return (
{/* Header */} @@ -27,6 +30,18 @@ export const PlayerList: React.FC = ({
+ {/* Turn Info Banner */} + {players.length > 0 && ( +
+ + Round {turn.round_number} + + + Turn #{turn.turn_number} + +
+ )} + {/* Players List */}
{players.length === 0 ? ( @@ -34,37 +49,64 @@ export const PlayerList: React.FC = ({
🤖
No players registered yet.
- Register a player or spawn a random bot to place them on the grid! + Register a player or spawn a random bot to start taking turns!
) : ( players.map((player) => { const isSelected = selectedPlayer?.id === player.id; + const isCurrentTurn = currentTurnId === player.id; + const orderIndex = turn.turn_order.indexOf(player.id); + return (
onSelectPlayer(player)} className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${ - isSelected + isCurrentTurn + ? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10' + : isSelected ? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10' : 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-800/40' }`} >
-
- {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; +}