add initial movement (and auto play)
This commit is contained in:
parent
1a62477528
commit
910e9d91b6
128
README.md
128
README.md
|
|
@ -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
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,6 +74,32 @@ 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]}"
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue