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

120
README.md
View File

@ -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.
### 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.
### REST Endpoints
#### 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 pushing live player spawns, departures, and board resets to all connected clients.
- `/ws`: Real-time WebSocket feed broadcasting movements, spawns, departures, and turn rotations.
- Interactive OpenAPI / Swagger documentation at `/docs`.
- **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.
---
- **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.
### 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
```

View File

@ -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,

View File

@ -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,6 +74,32 @@ 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]}"
@ -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
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()

View File

@ -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):

View File

@ -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"})
p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json()
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
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"]

View File

@ -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() {
<BoardCanvas
boardState={boardState}
selectedPlayer={selectedPlayer}
availableMoves={availableMoves}
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
players={boardState.players}
boardState={boardState}
selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer}
onRemovePlayer={removePlayer}
@ -108,7 +132,7 @@ export function App() {
{/* Toast 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" />
{notification}
</div>

View File

@ -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<BoardCanvasProps> = ({
boardState,
selectedPlayer,
availableMoves,
onSelectPlayer,
onHoverCoord,
}) => {
@ -28,6 +30,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
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<BoardCanvasProps> = ({
[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 });
@ -122,7 +124,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
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<BoardCanvasProps> = ({
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<BoardCanvasProps> = ({
// 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<BoardCanvasProps> = ({
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<BoardCanvasProps> = ({
{selectedPlayer.name.slice(0, 2).toUpperCase()}
</div>
<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]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y})
</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 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<PlayerListProps> = ({
players,
boardState,
selectedPlayer,
onSelectPlayer,
onRemovePlayer,
}) => {
const { players, turn } = boardState;
const currentTurnId = turn.current_player_id;
return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */}
@ -27,6 +30,18 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</span>
</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 */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? (
@ -34,37 +49,64 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<div className="text-2xl mb-2">🤖</div>
No players registered yet.
<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>
) : (
players.map((player) => {
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const orderIndex = turn.turn_order.indexOf(player.id);
return (
<div
key={player.id}
onClick={() => 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'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<div
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
style={{
backgroundColor: player.color,
boxShadow: `0 0 10px ${player.color}55`,
boxShadow: isCurrentTurn
? '0 0 14px #f59e0b'
: `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 className="min-w-0">
<div className="text-xs font-semibold text-slate-200 truncate">
<div className="flex items-center gap-1.5">
<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 className="text-[11px] font-mono text-slate-400">
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>

View File

@ -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<BoardState>(INITIAL_BOARD);
const [isConnected, setIsConnected] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const autoPlayIntervalRef = useRef<number | null>(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<Player> => {
const res = await fetch('/api/players', {
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 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,
};

View File

@ -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<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;
}