add initial movement (and auto play)

This commit is contained in:
Isaac Johnson 2026-09-05 16:20:39 -05:00
parent 1a62477528
commit 910e9d91b6
11 changed files with 1242 additions and 148 deletions

128
README.md
View File

@ -1,32 +1,61 @@
# botWebWars # 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 ## Features
- **FastAPI Backend**: ### Turn-Based Movement & Rules
- `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)`. - **Turn Order**: Strict round-robin turn order based on registration sequence.
- `GET /api/board`: Returns grid dimensions, player count, and player list. - **8 Movement Directions**:
- `GET /api/players`: Lists active players. - **Cardinal**: `UP`, `DOWN`, `LEFT`, `RIGHT` (or `N`, `S`, `W`, `E`)
- `DELETE /api/players/{player_id}`: Remove a player from the arena. - **Diagonal**: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT` (or `NW`, `NE`, `SW`, `SE`)
- `POST /api/board/reset`: Reset the board and clear all players. - **Obstacle & Boundary Enforcement**:
- `/ws`: Real-time WebSocket feed pushing live player spawns, departures, and board resets to all connected clients. - **Boundary Walls**: Cannot break past coordinate boundaries `[0..64, 0..64]`.
- Interactive OpenAPI/Swagger documentation at `/docs`. - **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**: ### REST Endpoints
- 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.
- **Dockerized Single-Container Deployment**: #### Movement & Inspection Endpoints
- Multi-stage `Dockerfile` compiles the frontend with Node 22 and packages it into a lightweight Python 3.12 image. - `GET /api/players/{player_id}/available-moves`
- FastAPI serves both the API/WebSocket endpoints and the compiled SPA frontend. - 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 ```bash
curl -X POST http://localhost:8000/api/players \ curl -X POST http://localhost:8000/api/players \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name": "CyberViper", "color": "#38BDF8"}' -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: Response:
```json ```json
{ {
"id": "bot_a1b2c3d4", "direction": "UP_RIGHT",
"name": "CyberViper", "dx": 1,
"color": "#38BDF8", "dy": -1,
"x": 42, "target_x": 36,
"y": 18, "target_y": 11,
"created_at": "2026-09-05T21:00:00.000000Z" "available": true,
"reason": null
} }
``` ```
### Inspect Board State ### 3. Query All 8 Available Directions
```bash ```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 ```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 ```bash
cd backend docker run --rm botwebwars pytest backend/tests
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
``` ```

View File

@ -1,8 +1,18 @@
from typing import List from typing import List, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, Query, status
from app.api.websocket import manager from app.api.websocket import manager
from app.game import game_engine 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() router = APIRouter()
@ -31,12 +41,14 @@ async def health_check():
) )
async def register_player(player_in: PlayerCreate): async def register_player(player_in: PlayerCreate):
player = await game_engine.register_player(player_in) player = await game_engine.register_player(player_in)
board_state = await game_engine.get_board_state()
# Broadcast to all connected WebSocket clients # Broadcast to all connected WebSocket clients
await manager.broadcast({ await manager.broadcast({
"event": "player_joined", "event": "player_joined",
"player": player.model_dump(), "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 return player
@ -82,10 +94,12 @@ async def remove_player(player_id: str):
detail=f"Player '{player_id}' not found", detail=f"Player '{player_id}' not found",
) )
board_state = await game_engine.get_board_state()
await manager.broadcast({ await manager.broadcast({
"event": "player_left", "event": "player_left",
"player_id": player_id, "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( 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( @router.get(
"/board", "/board",
response_model=BoardState, response_model=BoardState,
@ -112,9 +254,11 @@ async def get_board():
) )
async def reset_board(): async def reset_board():
await game_engine.reset() await game_engine.reset()
board = await game_engine.get_board_state()
await manager.broadcast({ await manager.broadcast({
"event": "board_reset", "event": "board_reset",
"player_count": 0, "player_count": 0,
"turn": board.turn.model_dump(),
}) })
return ApiResponse( return ApiResponse(
success=True, success=True,

View File

@ -3,13 +3,39 @@ import random
import uuid import uuid
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from app.config import settings 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: class GameEngine:
def __init__(self): def __init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self.players: Dict[str, Player] = {} 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( self.config = BoardConfig(
min_x=settings.GRID_MIN_X, min_x=settings.GRID_MIN_X,
max_x=settings.GRID_MAX_X, max_x=settings.GRID_MAX_X,
@ -19,8 +45,8 @@ class GameEngine:
grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y, grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y,
) )
def _get_occupied_coordinates(self) -> set[Tuple[int, int]]: def _get_occupied_coordinates(self) -> Dict[Tuple[int, int], Player]:
return {(p.x, p.y) for p in self.players.values()} return {(p.x, p.y): p for p in self.players.values()}
def _find_random_free_position(self) -> Tuple[int, int]: def _find_random_free_position(self) -> Tuple[int, int]:
occupied = self._get_occupied_coordinates() occupied = self._get_occupied_coordinates()
@ -29,20 +55,17 @@ class GameEngine:
) )
if len(occupied) >= total_possible: if len(occupied) >= total_possible:
# All spots taken, fallback to random any spot
return ( return (
random.randint(self.config.min_x, self.config.max_x), random.randint(self.config.min_x, self.config.max_x),
random.randint(self.config.min_y, self.config.max_y), random.randint(self.config.min_y, self.config.max_y),
) )
# Fast random sampling
for _ in range(100): for _ in range(100):
rx = random.randint(self.config.min_x, self.config.max_x) rx = random.randint(self.config.min_x, self.config.max_x)
ry = random.randint(self.config.min_y, self.config.max_y) ry = random.randint(self.config.min_y, self.config.max_y)
if (rx, ry) not in occupied: if (rx, ry) not in occupied:
return (rx, ry) return (rx, ry)
# Fallback exhaustive search if dense
all_coords = [ all_coords = [
(x, y) (x, y)
for x in range(self.config.min_x, self.config.max_x + 1) for x in range(self.config.min_x, self.config.max_x + 1)
@ -51,11 +74,37 @@ class GameEngine:
] ]
return random.choice(all_coords) 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 def register_player(self, player_in: PlayerCreate) -> Player:
async with self._lock: async with self._lock:
player_id = f"bot_{uuid.uuid4().hex[:8]}" player_id = f"bot_{uuid.uuid4().hex[:8]}"
x, y = self._find_random_free_position() x, y = self._find_random_free_position()
player = Player( player = Player(
id=player_id, id=player_id,
name=player_in.name, name=player_in.name,
@ -64,6 +113,7 @@ class GameEngine:
y=y, y=y,
) )
self.players[player_id] = player self.players[player_id] = player
self.turn_order.append(player_id)
return player return player
async def get_player(self, player_id: str) -> Optional[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 def remove_player(self, player_id: str) -> bool:
async with self._lock: async with self._lock:
if player_id in self.players: if player_id not in self.players:
del self.players[player_id] return False
return True
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 def reset(self) -> None:
async with self._lock: async with self._lock:
self.players.clear() 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 def get_board_state(self) -> BoardState:
async with self._lock: async with self._lock:
@ -92,8 +159,155 @@ class GameEngine:
config=self.config, config=self.config,
player_count=len(players_list), player_count=len(players_list),
players=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 # Global game engine instance
game_engine = GameEngine() game_engine = GameEngine()

View File

@ -1,9 +1,101 @@
import re import re
from datetime import datetime, timezone 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 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): class PlayerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") 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") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
@ -20,7 +112,6 @@ class PlayerCreate(BaseModel):
@classmethod @classmethod
def validate_color(cls, v: str) -> str: def validate_color(cls, v: str) -> str:
cleaned = v.strip() cleaned = v.strip()
# Accept #RRGGBB, #RGB, or standard alphanumeric color names
if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", cleaned): if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", cleaned):
return cleaned.upper() return cleaned.upper()
if re.match(r"^[a-zA-Z]{3,20}$", cleaned): if re.match(r"^[a-zA-Z]{3,20}$", cleaned):
@ -46,10 +137,28 @@ class BoardConfig(BaseModel):
grid_cells_y: int = 64 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): class BoardState(BaseModel):
config: BoardConfig config: BoardConfig
player_count: int player_count: int
players: List[Player] 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): class ApiResponse(BaseModel):

View File

@ -18,7 +18,6 @@ def test_health():
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["success"] is True
assert "FastAPI" in data["message"] or data["data"]["service"] == "FastAPI"
def test_register_player(): def test_register_player():
@ -27,56 +26,129 @@ def test_register_player():
assert res.status_code == 201 assert res.status_code == 201
player = res.json() player = res.json()
assert player["name"] == "CyberBot" 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["x"] <= 64
assert 0 <= player["y"] <= 64 assert 0 <= player["y"] <= 64
def test_board_state_after_registrations(): def test_turn_order_and_movement():
client = TestClient(app) client = TestClient(app)
client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}) p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json()
client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}) p2 = client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}).json()
res = client.get("/api/board") # 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 assert res.status_code == 200
board = res.json() assert res.json()["new_position"] == {"x": 19, "y": 19}
assert board["player_count"] == 2
assert len(board["players"]) == 2 # Move diagonal DOWN_RIGHT (single player cycle wrapped around)
assert board["config"]["min_x"] == 0 res2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "SE"})
assert board["config"]["max_x"] == 64 assert res2.status_code == 200
assert board["config"]["min_y"] == 0 assert res2.json()["new_position"] == {"x": 20, "y": 20}
assert board["config"]["max_y"] == 64
def test_player_deletion_and_reset(): def test_wall_collision():
client = TestClient(app) client = TestClient(app)
reg_res = client.post("/api/players", json={"name": "TempBot", "color": "#123456"}) p1 = client.post("/api/players", json={"name": "CornerBot", "color": "#123456"}).json()
player_id = reg_res.json()["id"]
del_res = client.delete(f"/api/players/{player_id}") import asyncio
assert del_res.status_code == 200 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") # Query single move check
assert board_res.json()["player_count"] == 0 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) client = TestClient(app)
# Empty name p1 = client.post("/api/players", json={"name": "Attacker", "color": "#123456"}).json()
res = client.post("/api/players", json={"name": " ", "color": "#FF0000"}) p2 = client.post("/api/players", json={"name": "Defender", "color": "#654321"}).json()
assert res.status_code == 422
# Invalid color import asyncio
res = client.post("/api/players", json={"name": "Bot", "color": "not-a-color-123456789"}) async def place_neighbors():
assert res.status_code == 422 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"]

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useGameSocket } from './hooks/useGameSocket'; import { useGameSocket } from './hooks/useGameSocket';
import { BoardCanvas } from './components/BoardCanvas'; import { BoardCanvas } from './components/BoardCanvas';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { MovementControls } from './components/MovementControls';
import { PlayerList } from './components/PlayerList'; import { PlayerList } from './components/PlayerList';
import { RegisterModal } from './components/RegisterModal'; import { RegisterModal } from './components/RegisterModal';
@ -35,8 +36,14 @@ export function App() {
isConnected, isConnected,
selectedPlayer, selectedPlayer,
setSelectedPlayer, setSelectedPlayer,
availableMoves,
isAutoPlaying,
setIsAutoPlaying,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
movePlayer,
passTurn,
stepActiveBotTurn,
resetBoard, resetBoard,
} = useGameSocket(); } = useGameSocket();
@ -95,12 +102,29 @@ export function App() {
<BoardCanvas <BoardCanvas
boardState={boardState} boardState={boardState}
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
/> />
{/* Sidebar Player Roster */} {/* 8-Directional Movement D-Pad & Simulation Controls */}
<MovementControls
boardState={boardState}
selectedPlayer={selectedPlayer}
availableMoves={availableMoves}
onMove={async (id, dir) => {
await movePlayer(id, dir);
}}
onPass={async (id) => {
await passTurn(id);
}}
onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
/>
{/* Sidebar Player Roster with Turn Order */}
<PlayerList <PlayerList
players={boardState.players} boardState={boardState}
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
onRemovePlayer={removePlayer} onRemovePlayer={removePlayer}
@ -108,7 +132,7 @@ export function App() {
{/* Toast Notification */} {/* Toast Notification */}
{notification && ( {notification && (
<div className="absolute bottom-6 right-84 z-50 bg-slate-900 border border-sky-500/50 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 animate-bounce"> <div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-slate-900 border border-sky-500/50 text-sky-200 px-4 py-2 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-400" /> <span className="w-2 h-2 rounded-full bg-sky-400" />
{notification} {notification}
</div> </div>

View File

@ -1,9 +1,10 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { BoardState, Player } from '../types'; import type { AvailableMovesResponse, BoardState, Player } from '../types';
interface BoardCanvasProps { interface BoardCanvasProps {
boardState: BoardState; boardState: BoardState;
selectedPlayer: Player | null; selectedPlayer: Player | null;
availableMoves?: AvailableMovesResponse | null;
onSelectPlayer: (player: Player | null) => void; onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void; onHoverCoord?: (coord: { x: number; y: number } | null) => void;
} }
@ -11,6 +12,7 @@ interface BoardCanvasProps {
export const BoardCanvas: React.FC<BoardCanvasProps> = ({ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
boardState, boardState,
selectedPlayer, selectedPlayer,
availableMoves,
onSelectPlayer, onSelectPlayer,
onHoverCoord, onHoverCoord,
}) => { }) => {
@ -28,6 +30,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
const { min_x, max_x, min_y, max_y } = boardState.config; const { min_x, max_x, min_y, max_y } = boardState.config;
const gridCellsX = max_x - min_x; const gridCellsX = max_x - min_x;
const gridCellsY = max_y - min_y; const gridCellsY = max_y - min_y;
const currentTurnId = boardState.turn.current_player_id;
// Convert canvas pixel coordinates to grid coordinate (0..64) // Convert canvas pixel coordinates to grid coordinate (0..64)
const pixelToGrid = useCallback( const pixelToGrid = useCallback(
@ -49,7 +52,6 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
[offset, zoom, gridCellsX, min_x, max_x, min_y, max_y] [offset, zoom, gridCellsX, min_x, max_x, min_y, max_y]
); );
// Reset viewport to fit
const handleResetView = useCallback(() => { const handleResetView = useCallback(() => {
setZoom(1); setZoom(1);
setOffset({ x: 0, y: 0 }); setOffset({ x: 0, y: 0 });
@ -62,7 +64,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
const padding = 45; const padding = 45;
const arenaSize = Math.min(width - padding * 2, height - padding * 2); const arenaSize = Math.min(width - padding * 2, height - padding * 2);
const cellSize = (arenaSize / gridCellsX) * zoom; const cellSize = (arenaSize / gridCellsX) * zoom;
const targetPx = (selectedPlayer.x - min_x) * cellSize; const targetPx = (selectedPlayer.x - min_x) * cellSize;
const targetPy = (selectedPlayer.y - min_y) * cellSize; const targetPy = (selectedPlayer.y - min_y) * cellSize;
@ -122,7 +124,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.rect(startX, startY, totalWidth, totalHeight); ctx.rect(startX, startY, totalWidth, totalHeight);
ctx.clip(); ctx.clip();
// Subtle checkered or minor grid lines // Minor grid lines
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.strokeStyle = '#1e293b'; ctx.strokeStyle = '#1e293b';
for (let i = 0; i <= gridCellsX; i++) { for (let i = 0; i <= gridCellsX; i++) {
@ -169,30 +171,67 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.strokeRect(hx, hy, cellSize, cellSize); 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 // Draw active players
const now = Date.now() / 400; 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) => { boardState.players.forEach((player) => {
const px = startX + (player.x - min_x) * cellSize; const px = startX + (player.x - min_x) * cellSize;
const py = startY + (player.y - min_y) * cellSize; const py = startY + (player.y - min_y) * cellSize;
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const isHovered = hoveredPlayer?.id === player.id; const isHovered = hoveredPlayer?.id === player.id;
const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14)); const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14));
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius; 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 // Glow effect
ctx.save(); ctx.save();
ctx.shadowColor = player.color; ctx.shadowColor = player.color;
ctx.shadowBlur = isSelected ? 18 : 8; ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8;
// Outer beacon ring // Outer beacon ring
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2); ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
ctx.strokeStyle = player.color; ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
ctx.globalAlpha = isSelected ? 0.9 : 0.4; ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4;
ctx.lineWidth = 1.5; ctx.lineWidth = isCurrentTurn ? 2 : 1.5;
ctx.stroke(); ctx.stroke();
// Main Player Token // Main Player Token
@ -205,30 +244,30 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
// Inner Core // Inner Core
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2); ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff';
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();
// Label above player if zoomed in or selected/hovered // Label above player
if (zoom > 1.8 || isSelected || isHovered) { if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) {
ctx.save(); ctx.save();
ctx.font = 'bold 11px monospace'; ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const text = player.name; const text = isCurrentTurn ? `👑 ${player.name}` : player.name;
const textMetrics = ctx.measureText(text); const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10; const bgWidth = textMetrics.width + 12;
const bgHeight = 16; const bgHeight = 16;
const labelY = py - radius - 8; const labelY = py - radius - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)'; ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
ctx.strokeStyle = player.color; ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
ctx.fillStyle = '#f8fafc'; ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc';
ctx.fillText(text, px, labelY); ctx.fillText(text, px, labelY);
ctx.restore(); ctx.restore();
} }
@ -277,7 +316,22 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
animationFrameId = requestAnimationFrame(render); animationFrameId = requestAnimationFrame(render);
return () => cancelAnimationFrame(animationFrameId); 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 // Mouse Drag to Pan
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
@ -413,7 +467,12 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
{selectedPlayer.name.slice(0, 2).toUpperCase()} {selectedPlayer.name.slice(0, 2).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 truncate">{selectedPlayer.name}</div> <div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
{selectedPlayer.name}
{selectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-amber-400 font-mono">👑 Turn</span>
)}
</div>
<div className="text-slate-400 font-mono text-[11px]"> <div className="text-slate-400 font-mono text-[11px]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y}) ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y})
</div> </div>

View File

@ -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<unknown>;
onPass: (playerId: string) => Promise<unknown>;
onStepBot: () => void;
isAutoPlaying: boolean;
onToggleAutoPlay: () => void;
}
export const MovementControls: React.FC<MovementControlsProps> = ({
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 (
<button
onClick={() => handleDirectionClick(dir)}
disabled={disabled}
title={`${dir}: ${reason}`}
className={`w-10 h-10 rounded-xl font-bold text-sm flex items-center justify-center transition-all duration-150 ${
disabled
? 'bg-slate-900/60 text-slate-600 border border-slate-800 cursor-not-allowed'
: 'bg-slate-800 hover:bg-emerald-600 hover:text-white text-emerald-400 border border-emerald-500/40 hover:border-emerald-400 shadow-md shadow-emerald-950/40 active:scale-95'
}`}
>
{label}
</button>
);
};
return (
<div className="absolute bottom-4 right-84 z-20 bg-slate-900/95 backdrop-blur-md border border-slate-700/80 rounded-2xl p-4 shadow-2xl flex flex-col gap-3 min-w-[240px]">
{/* Turn Status Banner */}
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
<div className="flex items-center gap-2">
{activePlayer ? (
<>
<div
className="w-3.5 h-3.5 rounded-full ring-2 ring-white/50 animate-pulse"
style={{ backgroundColor: activePlayer.color }}
/>
<div className="flex flex-col">
<span className="text-[10px] text-slate-400 font-mono leading-none">
Round {boardState.turn.round_number} Turn {boardState.turn.turn_number}
</span>
<span className="text-xs font-bold text-slate-100 truncate max-w-[130px]">
{activePlayer.name}
</span>
</div>
</>
) : (
<span className="text-xs text-slate-400">Waiting for bots...</span>
)}
</div>
{isMyTurn ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
YOUR TURN
</span>
) : (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700">
WAITING
</span>
)}
</div>
{/* 8-Directional D-Pad */}
<div className="flex flex-col items-center gap-1.5 py-1">
{/* Top Row: NW, UP, NE */}
<div className="flex gap-1.5">
{renderDirButton('UP_LEFT', '↖')}
{renderDirButton('UP', '↑')}
{renderDirButton('UP_RIGHT', '↗')}
</div>
{/* Middle Row: LEFT, Pass, RIGHT */}
<div className="flex gap-1.5">
{renderDirButton('LEFT', '←')}
<button
onClick={handlePassClick}
disabled={!isMyTurn}
title="Pass turn (Spacebar)"
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
!isMyTurn
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
}`}
>
PASS
</button>
{renderDirButton('RIGHT', '→')}
</div>
{/* Bottom Row: SW, DOWN, SE */}
<div className="flex gap-1.5">
{renderDirButton('DOWN_LEFT', '↙')}
{renderDirButton('DOWN', '↓')}
{renderDirButton('DOWN_RIGHT', '↘')}
</div>
</div>
{/* Simulation / Bot Controls */}
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
<button
onClick={onStepBot}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-mono py-1.5 px-2.5 rounded-lg border border-slate-700 transition-colors flex items-center justify-center gap-1"
title="Make 1 random valid move for the active bot"
>
<span></span> Step Bot
</button>
<button
onClick={onToggleAutoPlay}
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-all flex items-center justify-center gap-1 ${
isAutoPlaying
? 'bg-amber-950/80 border-amber-500 text-amber-300 shadow-md shadow-amber-950/50 animate-pulse'
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'
}`}
title="Automatically cycle bot turns"
>
<span>{isAutoPlaying ? '⏸' : '▶'}</span> {isAutoPlaying ? 'Auto: ON' : 'Auto Play'}
</button>
</div>
<div className="text-[10px] text-slate-500 font-mono text-center">
WASD / Arrows / Numpad to move
</div>
</div>
);
};

View File

@ -1,19 +1,22 @@
import React from 'react'; import React from 'react';
import type { Player } from '../types'; import type { BoardState, Player } from '../types';
interface PlayerListProps { interface PlayerListProps {
players: Player[]; boardState: BoardState;
selectedPlayer: Player | null; selectedPlayer: Player | null;
onSelectPlayer: (player: Player) => void; onSelectPlayer: (player: Player) => void;
onRemovePlayer: (id: string) => void; onRemovePlayer: (id: string) => void;
} }
export const PlayerList: React.FC<PlayerListProps> = ({ export const PlayerList: React.FC<PlayerListProps> = ({
players, boardState,
selectedPlayer, selectedPlayer,
onSelectPlayer, onSelectPlayer,
onRemovePlayer, onRemovePlayer,
}) => { }) => {
const { players, turn } = boardState;
const currentTurnId = turn.current_player_id;
return ( return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80"> <div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */} {/* Header */}
@ -27,6 +30,18 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</span> </span>
</div> </div>
{/* Turn Info Banner */}
{players.length > 0 && (
<div className="px-4 py-2.5 bg-slate-950/70 border-b border-slate-800/80 flex items-center justify-between text-xs font-mono">
<span className="text-slate-400">
Round <strong className="text-sky-400">{turn.round_number}</strong>
</span>
<span className="text-slate-400">
Turn <strong className="text-amber-400">#{turn.turn_number}</strong>
</span>
</div>
)}
{/* Players List */} {/* Players List */}
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? ( {players.length === 0 ? (
@ -34,37 +49,64 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<div className="text-2xl mb-2">🤖</div> <div className="text-2xl mb-2">🤖</div>
No players registered yet. No players registered yet.
<br /> <br />
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!
</div> </div>
) : ( ) : (
players.map((player) => { players.map((player) => {
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const orderIndex = turn.turn_order.indexOf(player.id);
return ( return (
<div <div
key={player.id} key={player.id}
onClick={() => onSelectPlayer(player)} onClick={() => onSelectPlayer(player)}
className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${ 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-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' : 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-800/40'
}`} }`}
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div <div className="relative">
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow" <div
style={{ className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
backgroundColor: player.color, style={{
boxShadow: `0 0 10px ${player.color}55`, backgroundColor: player.color,
}} boxShadow: isCurrentTurn
> ? '0 0 14px #f59e0b'
{player.name.slice(0, 2).toUpperCase()} : `0 0 10px ${player.color}55`,
}}
>
{player.name.slice(0, 2).toUpperCase()}
</div>
{isCurrentTurn && (
<span className="absolute -top-1.5 -right-1 text-[11px] leading-none">
👑
</span>
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-semibold text-slate-200 truncate"> <div className="flex items-center gap-1.5">
{player.name} <span className="text-xs font-semibold text-slate-200 truncate">
{player.name}
</span>
{orderIndex !== -1 && (
<span className="text-[10px] font-mono text-slate-500">
#{orderIndex + 1}
</span>
)}
</div> </div>
<div className="text-[11px] font-mono text-slate-400"> <div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> pos: <span className="text-emerald-400">({player.x}, {player.y})</span>
{isCurrentTurn && (
<span className="ml-1.5 text-amber-400 font-bold text-[10px]">
TURN
</span>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react'; 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 = { const INITIAL_BOARD: BoardState = {
config: { config: {
@ -12,14 +12,25 @@ const INITIAL_BOARD: BoardState = {
}, },
player_count: 0, player_count: 0,
players: [], players: [],
turn: {
current_player_id: null,
current_player_name: null,
round_number: 1,
turn_number: 0,
turn_order: [],
},
}; };
export function useGameSocket() { export function useGameSocket() {
const [boardState, setBoardState] = useState<BoardState>(INITIAL_BOARD); const [boardState, setBoardState] = useState<BoardState>(INITIAL_BOARD);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null); const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
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 fetchBoard = useCallback(async () => { const fetchBoard = useCallback(async () => {
try { 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(() => { const connectWebSocket = useCallback(() => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return; return;
@ -65,8 +90,20 @@ export function useGameSocket() {
...prev, ...prev,
player_count: data.player_count ?? nextPlayers.length, player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers, 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') { } else if (data.event === 'player_left') {
setBoardState((prev) => { setBoardState((prev) => {
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id); const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
@ -74,6 +111,7 @@ export function useGameSocket() {
...prev, ...prev,
player_count: data.player_count ?? nextPlayers.length, player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers, players: nextPlayers,
turn: data.turn ?? prev.turn,
}; };
}); });
setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev)); setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev));
@ -82,8 +120,10 @@ export function useGameSocket() {
...prev, ...prev,
player_count: 0, player_count: 0,
players: [], players: [],
turn: data.turn ?? INITIAL_BOARD.turn,
})); }));
setSelectedPlayer(null); setSelectedPlayer(null);
setAvailableMoves(null);
} }
} catch (err) { } catch (err) {
console.error('Error parsing WebSocket message:', err); console.error('Error parsing WebSocket message:', err);
@ -117,7 +157,7 @@ export function useGameSocket() {
}; };
}, [fetchBoard, connectWebSocket]); }, [fetchBoard, connectWebSocket]);
// Keep player selection in sync if player updates // Keep selected player synced with board state
useEffect(() => { useEffect(() => {
if (selectedPlayer) { if (selectedPlayer) {
const updated = boardState.players.find((p) => p.id === selectedPlayer.id); const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
@ -125,10 +165,21 @@ export function useGameSocket() {
setSelectedPlayer(updated); setSelectedPlayer(updated);
} else { } else {
setSelectedPlayer(null); setSelectedPlayer(null);
setAvailableMoves(null);
} }
} }
}, [boardState.players, selectedPlayer]); }, [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<Player> => { const registerPlayer = async (name: string, color: string): Promise<Player> => {
const res = await fetch('/api/players', { const res = await fetch('/api/players', {
method: 'POST', method: 'POST',
@ -153,6 +204,32 @@ export function useGameSocket() {
} }
}; };
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();
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 resetBoard = async () => {
const res = await fetch('/api/board/reset', { const res = await fetch('/api/board/reset', {
method: 'POST', 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 { return {
boardState, boardState,
isConnected, isConnected,
selectedPlayer, selectedPlayer,
setSelectedPlayer, setSelectedPlayer,
availableMoves,
isAutoPlaying,
setIsAutoPlaying,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
movePlayer,
passTurn,
stepActiveBotTurn,
resetBoard, resetBoard,
refresh: fetchBoard, refresh: fetchBoard,
}; };

View File

@ -16,13 +16,51 @@ export interface BoardConfig {
grid_cells_y: number; 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 { export interface BoardState {
config: BoardConfig; config: BoardConfig;
player_count: number; player_count: number;
players: Player[]; players: Player[];
turn: TurnInfo;
} }
export interface PlayerCreateRequest { export interface PlayerCreateRequest {
name: string; name: string;
color: 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<string, MoveCheckResult>;
}
export interface MoveResponse {
success: boolean;
player: Player;
direction: string;
previous_position: { x: number; y: number };
new_position: { x: number; y: number };
turn: TurnInfo;
}