parties can be created. if a party defeated, leader is respawned and looses a point
This commit is contained in:
parent
910e9d91b6
commit
2f02259c38
186
README.md
186
README.md
|
|
@ -1,136 +1,98 @@
|
|||
# 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/bots register with their name and custom avatar color, are placed randomly onto the board, and take turns moving around the arena.
|
||||
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 avatar color, find teammates, form **Parties**, take turns moving around the arena, and engage in tactical squad battles.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 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.
|
||||
### 1. Parties & Squad Formations
|
||||
- **Goal & Linked Connectivity**:
|
||||
- Bots can find each other and join together as a **Party**.
|
||||
- All party members must be within **1 distance** of each other (Chebyshev distance $\le 1$, including diagonals and cardinals), forming linked connections (clusters or single-file lines).
|
||||
- Bots must agree on a **Party Leader**.
|
||||
- **Leader Group Movement**:
|
||||
- The Party Leader controls the movement for the entire squad.
|
||||
- All party members translate together in the chosen direction (preserving their relative shape and linked adjacency).
|
||||
- If any party member's path is blocked by an outside bot or boundary wall, the entire group move is prevented.
|
||||
- Non-leader party members cannot move independently.
|
||||
|
||||
### REST Endpoints
|
||||
### 2. Battle, Defeat & Respawn Mechanics
|
||||
- **Scoring**:
|
||||
- All players/bots start with a score of **0**.
|
||||
- **Leader Defeat**:
|
||||
- If a party is defeated in battle (or triggered via `/api/parties/{id}/defeat`), the **Party Leader is killed**:
|
||||
1. Leader's score decreases by 1 (`score -= 1`).
|
||||
2. Leader is removed from their prior party (`party_id = null`, `is_party_leader = false`).
|
||||
3. Leader respawns at a random unoccupied position on the board.
|
||||
4. A **new leader is randomly assigned** from the remainder of the party.
|
||||
5. If no members remain, the party dissolves.
|
||||
|
||||
#### Movement & Inspection Endpoints
|
||||
- `GET /api/players/{player_id}/available-moves`
|
||||
- Checks all 8 movement directions for availability, target coordinates, and rejection reasons (wall collision or occupied by another bot). Also indicates if it is currently that bot's turn.
|
||||
- `GET /api/players/{player_id}/check-move?direction={DIR}`
|
||||
- Fast check for a single direction (e.g. `UP`, `DOWN`, `NW`, etc.). Returns `available: true/false`, target coordinates, and reason if blocked.
|
||||
- `POST /api/players/{player_id}/move`
|
||||
- Execute a movement. Accepts `{"direction": "UP_RIGHT"}` or `{"dx": 1, "dy": -1}`.
|
||||
- Validates boundaries, occupant collisions, and turn order.
|
||||
- Advances position and rotates turn to next player.
|
||||
- `POST /api/players/{player_id}/pass`
|
||||
- Passes the turn to the next player.
|
||||
- `GET /api/turn`
|
||||
- Returns current turn state (active player ID & name, round number, turn number, and player order).
|
||||
|
||||
#### Management Endpoints
|
||||
- `POST /api/players` (or `/api/register`): Register a player with `name` and `color`.
|
||||
- `GET /api/board`: Returns grid dimensions, player count, all player coordinates, and turn information.
|
||||
- `GET /api/players`: Lists active players.
|
||||
- `DELETE /api/players/{player_id}`: Remove a player from the arena.
|
||||
- `POST /api/board/reset`: Reset the board and clear all players.
|
||||
- `/ws`: Real-time WebSocket feed broadcasting movements, spawns, departures, and turn rotations.
|
||||
- Interactive OpenAPI / Swagger documentation at `/docs`.
|
||||
### 3. Turn-Based 8-Directional Movement
|
||||
- **8 Directions**:
|
||||
- Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
|
||||
- Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
|
||||
- **Collision & Wall Avoidance**:
|
||||
- Cannot break through boundary walls `[0..64, 0..64]`.
|
||||
- Cannot move into cells occupied by outside bots.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
## REST API Endpoints
|
||||
|
||||
### Party & Battle Endpoints
|
||||
- `POST /api/parties`: Form a party directly.
|
||||
- Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
|
||||
- Validates that members are within 1 distance of each other and agree on the leader.
|
||||
- `POST /api/parties/invite`: Propose a party invite to an adjacent bot with an agreed leader.
|
||||
- `POST /api/parties/invites/{invite_id}/respond`: Accept or reject party invite (`{"accept": true}`).
|
||||
- `GET /api/parties`: List all active parties, leaders, and members.
|
||||
- `GET /api/parties/{party_id}`: Get party details.
|
||||
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
|
||||
- Leader is killed, loses 1 point, leaves party, and respawns randomly.
|
||||
- Remainder of party elects a new random leader.
|
||||
- `POST /api/battles/fight`: Initiate a battle between adjacent bots/parties.
|
||||
- Body: `{"challenger_id": "bot_1", "defender_id": "bot_2"}`
|
||||
|
||||
### Movement & Turn Endpoints
|
||||
- `GET /api/players/{player_id}/available-moves`: Checks availability of all 8 directions (for a solo bot or the whole party if leader).
|
||||
- `GET /api/players/{player_id}/check-move?direction={DIR}`: Check single direction.
|
||||
- `POST /api/players/{player_id}/move`: Move bot or entire party if leader (`{"direction": "UP_RIGHT"}`).
|
||||
- `POST /api/players/{player_id}/pass`: Pass turn to next player.
|
||||
- `GET /api/turn`: Get current turn status, round number, and turn queue.
|
||||
|
||||
### Player & Board Endpoints
|
||||
- `POST /api/players`: Register a new player with `name` and `color`.
|
||||
- `GET /api/players`: List active players and their scores.
|
||||
- `GET /api/board`: Full board state, player positions, parties, and turn data.
|
||||
- `POST /api/board/reset`: Clear board, players, and parties.
|
||||
- `/ws`: Real-time WebSocket connection broadcasting player joins, squad movements, party formations, defeats, and turn changes.
|
||||
|
||||
---
|
||||
|
||||
## Interactive Web UI (React 19 + Vite + Tailwind CSS)
|
||||
|
||||
- **Canvas Arena**:
|
||||
- Laser energy beams connecting adjacent party members.
|
||||
- Crown `👑` on the Party Leader and golden pulsing aura for active turn.
|
||||
- Hover coordinate tracker, zoom & pan controls.
|
||||
- **Controls & Modals**:
|
||||
- **8-Directional D-Pad** with real-time green/red cell availability and keyboard bindings (WASD / Arrows / Numpad).
|
||||
- **Form / Join Party Modal** with auto-detection of nearest adjacent bots and agreed leader selector.
|
||||
- **💥 Defeat Button** on parties in the roster to test the leader death, respawn, and leadership succession flow.
|
||||
- **⚡ Step Bot** & **▶ Auto-Play** simulation mode.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||
|
||||
### Option 2: Docker CLI
|
||||
|
||||
### Running Backend Test Suite
|
||||
```bash
|
||||
# Build the Docker image
|
||||
docker build -t botwebwars .
|
||||
|
||||
# Run the container
|
||||
docker run -d -p 8000:8000 --name botwebwars botwebwars
|
||||
```
|
||||
|
||||
- Webapp & Arena: [http://localhost:8000](http://localhost:8000)
|
||||
- Swagger API Docs: [http://localhost:8000/docs](http://localhost:8000/docs)
|
||||
|
||||
---
|
||||
|
||||
## API Examples
|
||||
|
||||
### 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
|
||||
{
|
||||
"direction": "UP_RIGHT",
|
||||
"dx": 1,
|
||||
"dy": -1,
|
||||
"target_x": 36,
|
||||
"target_y": 11,
|
||||
"available": true,
|
||||
"reason": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Query All 8 Available Directions
|
||||
```bash
|
||||
curl "http://localhost:8000/api/players/{player_id}/available-moves"
|
||||
```
|
||||
|
||||
### 4. Move Bot (During Their Turn)
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Backend Tests
|
||||
|
||||
```bash
|
||||
docker run --rm botwebwars pytest backend/tests
|
||||
docker run --rm botwebwars:test pytest backend/tests
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,10 +5,18 @@ from app.game import game_engine
|
|||
from app.models import (
|
||||
ApiResponse,
|
||||
AvailableMovesResponse,
|
||||
BattleRequest,
|
||||
BattleResult,
|
||||
BoardState,
|
||||
MoveCheckResult,
|
||||
MoveRequest,
|
||||
MoveResponse,
|
||||
Party,
|
||||
PartyDefeatResult,
|
||||
PartyDirectForm,
|
||||
PartyInvite,
|
||||
PartyInviteCreate,
|
||||
PartyInviteResponse,
|
||||
Player,
|
||||
PlayerCreate,
|
||||
TurnInfo,
|
||||
|
|
@ -26,6 +34,10 @@ async def health_check():
|
|||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Player Registration & Management
|
||||
# ==========================================
|
||||
|
||||
@router.post(
|
||||
"/players",
|
||||
response_model=Player,
|
||||
|
|
@ -43,7 +55,6 @@ 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(),
|
||||
|
|
@ -99,6 +110,7 @@ async def remove_player(player_id: str):
|
|||
"event": "player_left",
|
||||
"player_id": player_id,
|
||||
"player_count": board_state.player_count,
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
|
|
@ -108,6 +120,161 @@ async def remove_player(player_id: str):
|
|||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Party & Squad Endpoints
|
||||
# ==========================================
|
||||
|
||||
@router.get(
|
||||
"/parties",
|
||||
response_model=List[Party],
|
||||
summary="List all active parties",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def list_parties():
|
||||
return await game_engine.get_all_parties()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/parties/{party_id}",
|
||||
response_model=Party,
|
||||
summary="Get details of a specific party",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def get_party(party_id: str):
|
||||
party = await game_engine.get_party(party_id)
|
||||
if not party:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Party '{party_id}' not found",
|
||||
)
|
||||
return party
|
||||
|
||||
|
||||
@router.post(
|
||||
"/parties",
|
||||
response_model=Party,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Directly form a party with adjacent linked members and an agreed leader",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def form_party(form_req: PartyDirectForm):
|
||||
try:
|
||||
party = await game_engine.form_party(
|
||||
member_ids=form_req.member_ids,
|
||||
leader_id=form_req.leader_id,
|
||||
name=form_req.name,
|
||||
)
|
||||
board_state = await game_engine.get_board_state()
|
||||
|
||||
await manager.broadcast({
|
||||
"event": "party_formed",
|
||||
"party": party.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return party
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/parties/invite",
|
||||
response_model=PartyInvite,
|
||||
summary="Invite an adjacent bot to join/form a party with an agreed leader",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def invite_to_party(invite_req: PartyInviteCreate):
|
||||
try:
|
||||
invite = await game_engine.invite_to_party(
|
||||
inviter_id=invite_req.inviter_id,
|
||||
invitee_id=invite_req.invitee_id,
|
||||
proposed_leader_id=invite_req.proposed_leader_id,
|
||||
party_name=invite_req.party_name,
|
||||
)
|
||||
return invite
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/parties/invites/{invite_id}/respond",
|
||||
response_model=Optional[Party],
|
||||
summary="Accept or reject a party invitation",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def respond_to_invite(invite_id: str, resp: PartyInviteResponse):
|
||||
try:
|
||||
party = await game_engine.respond_to_invite(invite_id, resp.accept)
|
||||
if party:
|
||||
board_state = await game_engine.get_board_state()
|
||||
await manager.broadcast({
|
||||
"event": "party_updated",
|
||||
"party": party.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
return party
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/parties/{party_id}/defeat",
|
||||
response_model=PartyDefeatResult,
|
||||
summary="Trigger defeat for a party: leader killed, respawns, score -1, remainder assigns new leader",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def defeat_party(party_id: str):
|
||||
try:
|
||||
result = await game_engine.defeat_party(party_id)
|
||||
board_state = await game_engine.get_board_state()
|
||||
|
||||
await manager.broadcast({
|
||||
"event": "party_defeated",
|
||||
"defeat_details": result.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/battles/fight",
|
||||
response_model=BattleResult,
|
||||
summary="Initiate battle between two adjacent bots/parties",
|
||||
tags=["Battles"],
|
||||
)
|
||||
async def fight_battle(battle_req: BattleRequest):
|
||||
try:
|
||||
result = await game_engine.battle(battle_req.challenger_id, battle_req.defender_id)
|
||||
board_state = await game_engine.get_board_state()
|
||||
|
||||
await manager.broadcast({
|
||||
"event": "battle_resolved",
|
||||
"battle": result.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Movement & Turn Endpoints
|
||||
# ==========================================
|
||||
|
|
@ -115,7 +282,7 @@ async def remove_player(player_id: str):
|
|||
@router.get(
|
||||
"/players/{player_id}/available-moves",
|
||||
response_model=AvailableMovesResponse,
|
||||
summary="Check availability of all 8 movement directions (cardinal & diagonal)",
|
||||
summary="Check availability of all 8 movement directions for a bot or their party",
|
||||
tags=["Movement"],
|
||||
)
|
||||
async def get_available_moves(player_id: str):
|
||||
|
|
@ -138,7 +305,7 @@ 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)",
|
||||
description="Direction to check: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT",
|
||||
),
|
||||
):
|
||||
try:
|
||||
|
|
@ -158,7 +325,7 @@ async def check_single_move(
|
|||
@router.post(
|
||||
"/players/{player_id}/move",
|
||||
response_model=MoveResponse,
|
||||
summary="Move player in a cardinal or diagonal direction during their turn",
|
||||
summary="Move player (or entire party if leader) in a cardinal or diagonal direction during turn",
|
||||
tags=["Movement"],
|
||||
)
|
||||
async def move_player(player_id: str, move_req: MoveRequest):
|
||||
|
|
@ -166,13 +333,16 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
|||
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
|
||||
board_state = await game_engine.get_board_state()
|
||||
await manager.broadcast({
|
||||
"event": "player_moved",
|
||||
"player": result.player.model_dump(),
|
||||
"direction": result.direction,
|
||||
"party_moved": result.party_moved,
|
||||
"affected_players": [p.model_dump() for p in result.affected_players],
|
||||
"previous_position": result.previous_position,
|
||||
"new_position": result.new_position,
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": result.turn.model_dump(),
|
||||
})
|
||||
|
||||
|
|
@ -239,7 +409,7 @@ async def get_turn():
|
|||
@router.get(
|
||||
"/board",
|
||||
response_model=BoardState,
|
||||
summary="Get full board configuration and player positions",
|
||||
summary="Get full board configuration, parties, and player positions",
|
||||
tags=["Board"],
|
||||
)
|
||||
async def get_board():
|
||||
|
|
@ -249,7 +419,7 @@ async def get_board():
|
|||
@router.post(
|
||||
"/board/reset",
|
||||
response_model=ApiResponse,
|
||||
summary="Reset the board and clear all players",
|
||||
summary="Reset the board and clear all players and parties",
|
||||
tags=["Board"],
|
||||
)
|
||||
async def reset_board():
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import asyncio
|
||||
import random
|
||||
import uuid
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from app.config import settings
|
||||
from app.models import (
|
||||
AvailableMovesResponse,
|
||||
BattleResult,
|
||||
BoardConfig,
|
||||
BoardState,
|
||||
DIRECTION_OFFSETS,
|
||||
MoveCheckResult,
|
||||
MoveResponse,
|
||||
Party,
|
||||
PartyDefeatResult,
|
||||
PartyInvite,
|
||||
Player,
|
||||
PlayerCreate,
|
||||
TurnInfo,
|
||||
DIRECTION_OFFSETS,
|
||||
AvailableMovesResponse,
|
||||
)
|
||||
|
||||
STANDARD_DIRECTIONS = [
|
||||
|
|
@ -31,6 +35,8 @@ class GameEngine:
|
|||
def __init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
self.players: Dict[str, Player] = {}
|
||||
self.parties: Dict[str, Party] = {}
|
||||
self.invites: Dict[str, PartyInvite] = {}
|
||||
self.turn_order: List[str] = []
|
||||
self.current_turn_index: int = 0
|
||||
self.round_number: int = 1
|
||||
|
|
@ -74,29 +80,43 @@ class GameEngine:
|
|||
]
|
||||
return random.choice(all_coords)
|
||||
|
||||
def _get_active_turn_actors(self) -> List[str]:
|
||||
# Return actors that can take turns: party leaders and solo players
|
||||
actors = []
|
||||
for pid in self.turn_order:
|
||||
p = self.players.get(pid)
|
||||
if not p:
|
||||
continue
|
||||
if not p.party_id or p.is_party_leader:
|
||||
actors.append(pid)
|
||||
return actors
|
||||
|
||||
def _get_current_player(self) -> Optional[Player]:
|
||||
if not self.turn_order:
|
||||
actors = self._get_active_turn_actors()
|
||||
if not actors:
|
||||
return None
|
||||
safe_index = self.current_turn_index % len(self.turn_order)
|
||||
player_id = self.turn_order[safe_index]
|
||||
safe_index = self.current_turn_index % len(actors)
|
||||
player_id = actors[safe_index]
|
||||
return self.players.get(player_id)
|
||||
|
||||
def _get_turn_info(self) -> TurnInfo:
|
||||
current = self._get_current_player()
|
||||
actors = self._get_active_turn_actors()
|
||||
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),
|
||||
turn_order=actors,
|
||||
)
|
||||
|
||||
def _advance_turn(self):
|
||||
if not self.turn_order:
|
||||
actors = self._get_active_turn_actors()
|
||||
if not actors:
|
||||
self.current_turn_index = 0
|
||||
return
|
||||
self.turn_number += 1
|
||||
self.current_turn_index = (self.current_turn_index + 1) % len(self.turn_order)
|
||||
self.current_turn_index = (self.current_turn_index + 1) % len(actors)
|
||||
if self.current_turn_index == 0:
|
||||
self.round_number += 1
|
||||
|
||||
|
|
@ -111,6 +131,9 @@ class GameEngine:
|
|||
color=player_in.color,
|
||||
x=x,
|
||||
y=y,
|
||||
score=0,
|
||||
party_id=None,
|
||||
is_party_leader=False,
|
||||
)
|
||||
self.players[player_id] = player
|
||||
self.turn_order.append(player_id)
|
||||
|
|
@ -129,24 +152,44 @@ class GameEngine:
|
|||
if player_id not in self.players:
|
||||
return False
|
||||
|
||||
player = self.players[player_id]
|
||||
|
||||
# If player was in a party, clean up party membership
|
||||
if player.party_id and player.party_id in self.parties:
|
||||
party = self.parties[player.party_id]
|
||||
if player_id in party.member_ids:
|
||||
party.member_ids.remove(player_id)
|
||||
if party.leader_id == player_id:
|
||||
if party.member_ids:
|
||||
new_leader_id = random.choice(party.member_ids)
|
||||
party.leader_id = new_leader_id
|
||||
new_lead = self.players.get(new_leader_id)
|
||||
if new_lead:
|
||||
new_lead.is_party_leader = True
|
||||
party.leader_name = new_lead.name
|
||||
else:
|
||||
del self.parties[party.id]
|
||||
elif not party.member_ids:
|
||||
del self.parties[party.id]
|
||||
|
||||
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
|
||||
actors = self._get_active_turn_actors()
|
||||
if actors:
|
||||
self.current_turn_index %= len(actors)
|
||||
else:
|
||||
self.current_turn_index = 0
|
||||
|
||||
return True
|
||||
|
||||
async def reset(self) -> None:
|
||||
async with self._lock:
|
||||
self.players.clear()
|
||||
self.parties.clear()
|
||||
self.invites.clear()
|
||||
self.turn_order.clear()
|
||||
self.current_turn_index = 0
|
||||
self.round_number = 1
|
||||
|
|
@ -155,13 +198,193 @@ class GameEngine:
|
|||
async def get_board_state(self) -> BoardState:
|
||||
async with self._lock:
|
||||
players_list = list(self.players.values())
|
||||
parties_list = list(self.parties.values())
|
||||
return BoardState(
|
||||
config=self.config,
|
||||
player_count=len(players_list),
|
||||
players=players_list,
|
||||
parties=parties_list,
|
||||
turn=self._get_turn_info(),
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# Party Logic & Graph Adjacency
|
||||
# ==========================================
|
||||
|
||||
@staticmethod
|
||||
def _are_adjacent(p1: Player, p2: Player) -> bool:
|
||||
# Distance <= 1 (cardinal or diagonal Chebyshev distance)
|
||||
return max(abs(p1.x - p2.x), abs(p1.y - p2.y)) <= 1
|
||||
|
||||
def _verify_party_connectivity(self, member_players: List[Player]) -> bool:
|
||||
"""Verifies all party members form a single connected component where each is within 1 distance."""
|
||||
if len(member_players) <= 1:
|
||||
return True
|
||||
|
||||
visited: Set[str] = set()
|
||||
queue = [member_players[0]]
|
||||
visited.add(member_players[0].id)
|
||||
|
||||
while queue:
|
||||
curr = queue.pop(0)
|
||||
for other in member_players:
|
||||
if other.id not in visited and self._are_adjacent(curr, other):
|
||||
visited.add(other.id)
|
||||
queue.append(other)
|
||||
|
||||
return len(visited) == len(member_players)
|
||||
|
||||
async def form_party(
|
||||
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
|
||||
) -> Party:
|
||||
async with self._lock:
|
||||
if len(member_ids) < 2:
|
||||
raise ValueError("A party must have at least 2 members")
|
||||
if leader_id not in member_ids:
|
||||
raise ValueError("Agreed leader must be one of the party members")
|
||||
|
||||
member_players: List[Player] = []
|
||||
for mid in member_ids:
|
||||
p = self.players.get(mid)
|
||||
if not p:
|
||||
raise KeyError(f"Player '{mid}' not found")
|
||||
member_players.append(p)
|
||||
|
||||
# Check adjacency / connected link
|
||||
if not self._verify_party_connectivity(member_players):
|
||||
raise ValueError(
|
||||
"All party members must be linked within 1 distance of each other (directly or via connected teammates)."
|
||||
)
|
||||
|
||||
# Clean up any existing party memberships
|
||||
for p in member_players:
|
||||
if p.party_id and p.party_id in self.parties:
|
||||
old_party = self.parties[p.party_id]
|
||||
if p.id in old_party.member_ids:
|
||||
old_party.member_ids.remove(p.id)
|
||||
if not old_party.member_ids:
|
||||
del self.parties[old_party.id]
|
||||
|
||||
party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||
leader = self.players[leader_id]
|
||||
party_name = name or f"Squad {leader.name}"
|
||||
|
||||
party = Party(
|
||||
id=party_id,
|
||||
name=party_name,
|
||||
leader_id=leader_id,
|
||||
leader_name=leader.name,
|
||||
member_ids=list(member_ids),
|
||||
)
|
||||
|
||||
for p in member_players:
|
||||
p.party_id = party_id
|
||||
p.is_party_leader = p.id == leader_id
|
||||
|
||||
self.parties[party_id] = party
|
||||
return party
|
||||
|
||||
async def invite_to_party(
|
||||
self,
|
||||
inviter_id: str,
|
||||
invitee_id: str,
|
||||
proposed_leader_id: str,
|
||||
party_name: Optional[str] = None,
|
||||
) -> PartyInvite:
|
||||
async with self._lock:
|
||||
inviter = self.players.get(inviter_id)
|
||||
invitee = self.players.get(invitee_id)
|
||||
if not inviter or not invitee:
|
||||
raise KeyError("Inviter or invitee not found")
|
||||
|
||||
# Must be adjacent to inviter or to at least one member of inviter's party
|
||||
eligible_hosts = [inviter]
|
||||
if inviter.party_id and inviter.party_id in self.parties:
|
||||
party = self.parties[inviter.party_id]
|
||||
eligible_hosts = [self.players[mid] for mid in party.member_ids if mid in self.players]
|
||||
|
||||
is_adjacent = any(self._are_adjacent(invitee, host) for host in eligible_hosts)
|
||||
if not is_adjacent:
|
||||
raise ValueError(
|
||||
f"Bot '{invitee.name}' is too far away. Must be within 1 distance of a party member to join."
|
||||
)
|
||||
|
||||
invite_id = f"inv_{uuid.uuid4().hex[:8]}"
|
||||
invite = PartyInvite(
|
||||
id=invite_id,
|
||||
inviter_id=inviter_id,
|
||||
invitee_id=invitee_id,
|
||||
proposed_leader_id=proposed_leader_id,
|
||||
party_id=inviter.party_id,
|
||||
)
|
||||
self.invites[invite_id] = invite
|
||||
return invite
|
||||
|
||||
async def respond_to_invite(self, invite_id: str, accept: bool) -> Optional[Party]:
|
||||
async with self._lock:
|
||||
invite = self.invites.get(invite_id)
|
||||
if not invite:
|
||||
raise KeyError(f"Invite '{invite_id}' not found")
|
||||
if invite.status != "pending":
|
||||
raise ValueError(f"Invite already {invite.status}")
|
||||
|
||||
if not accept:
|
||||
invite.status = "rejected"
|
||||
return None
|
||||
|
||||
invite.status = "accepted"
|
||||
inviter = self.players.get(invite.inviter_id)
|
||||
invitee = self.players.get(invite.invitee_id)
|
||||
if not inviter or not invitee:
|
||||
raise KeyError("Inviter or invitee no longer active")
|
||||
|
||||
# If inviter is already in a party, add invitee to that party
|
||||
if inviter.party_id and inviter.party_id in self.parties:
|
||||
party = self.parties[inviter.party_id]
|
||||
if invitee.id not in party.member_ids:
|
||||
party.member_ids.append(invitee.id)
|
||||
invitee.party_id = party.id
|
||||
# Set agreed leader
|
||||
if invite.proposed_leader_id in party.member_ids:
|
||||
party.leader_id = invite.proposed_leader_id
|
||||
for mid in party.member_ids:
|
||||
p = self.players.get(mid)
|
||||
if p:
|
||||
p.is_party_leader = p.id == party.leader_id
|
||||
party.leader_name = self.players[party.leader_id].name
|
||||
return party
|
||||
|
||||
# Otherwise form new party with both
|
||||
members = [inviter.id, invitee.id]
|
||||
leader_id = invite.proposed_leader_id if invite.proposed_leader_id in members else inviter.id
|
||||
party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||
leader = self.players[leader_id]
|
||||
party = Party(
|
||||
id=party_id,
|
||||
name=f"Squad {leader.name}",
|
||||
leader_id=leader_id,
|
||||
leader_name=leader.name,
|
||||
member_ids=members,
|
||||
)
|
||||
inviter.party_id = party_id
|
||||
inviter.is_party_leader = inviter.id == leader_id
|
||||
invitee.party_id = party_id
|
||||
invitee.is_party_leader = invitee.id == leader_id
|
||||
self.parties[party_id] = party
|
||||
return party
|
||||
|
||||
async def get_all_parties(self) -> List[Party]:
|
||||
async with self._lock:
|
||||
return list(self.parties.values())
|
||||
|
||||
async def get_party(self, party_id: str) -> Optional[Party]:
|
||||
async with self._lock:
|
||||
return self.parties.get(party_id)
|
||||
|
||||
# ==========================================
|
||||
# Movement Checking (Single bot or Party)
|
||||
# ==========================================
|
||||
|
||||
def _check_move_internal(
|
||||
self,
|
||||
player: Player,
|
||||
|
|
@ -170,16 +393,56 @@ class GameEngine:
|
|||
direction_name: str,
|
||||
occupied_map: Dict[Tuple[int, int], Player],
|
||||
) -> MoveCheckResult:
|
||||
# If player is part of a party and IS the leader, check if ALL members can translate by (dx, dy)
|
||||
if player.party_id and player.party_id in self.parties and player.is_party_leader:
|
||||
party = self.parties[player.party_id]
|
||||
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
|
||||
party_member_ids = {m.id for m in party_members}
|
||||
|
||||
for m in party_members:
|
||||
tx = m.x + dx
|
||||
ty = m.y + dy
|
||||
|
||||
# Wall boundary
|
||||
if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y:
|
||||
return MoveCheckResult(
|
||||
direction=direction_name,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
target_x=player.x + dx,
|
||||
target_y=player.y + dy,
|
||||
available=False,
|
||||
reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).",
|
||||
)
|
||||
|
||||
# Collision with non-party bot
|
||||
occupant = occupied_map.get((tx, ty))
|
||||
if occupant is not None and occupant.id not in party_member_ids:
|
||||
return MoveCheckResult(
|
||||
direction=direction_name,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
target_x=player.x + dx,
|
||||
target_y=player.y + dy,
|
||||
available=False,
|
||||
reason=f"Party member '{m.name}' path blocked by '{occupant.name}' at ({tx}, {ty}).",
|
||||
)
|
||||
|
||||
return MoveCheckResult(
|
||||
direction=direction_name,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
target_x=player.x + dx,
|
||||
target_y=player.y + dy,
|
||||
available=True,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
# Single player or non-leader
|
||||
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
|
||||
):
|
||||
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,
|
||||
|
|
@ -187,10 +450,9 @@ class GameEngine:
|
|||
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}].",
|
||||
reason=f"Wall collision at ({target_x}, {target_y}).",
|
||||
)
|
||||
|
||||
# 2. Occupancy Check
|
||||
occupant = occupied_map.get((target_x, target_y))
|
||||
if occupant is not None and occupant.id != player.id:
|
||||
return MoveCheckResult(
|
||||
|
|
@ -219,10 +481,24 @@ class GameEngine:
|
|||
if not player:
|
||||
raise KeyError(f"Player '{player_id}' not found")
|
||||
|
||||
# Non-leaders cannot move independently
|
||||
if player.party_id and not player.is_party_leader:
|
||||
party = self.parties.get(player.party_id)
|
||||
leader_name = party.leader_name if party else "Leader"
|
||||
return MoveCheckResult(
|
||||
direction=direction_input,
|
||||
dx=0,
|
||||
dy=0,
|
||||
target_x=player.x,
|
||||
target_y=player.y,
|
||||
available=False,
|
||||
reason=f"Only party leader '{leader_name}' controls movement for the party.",
|
||||
)
|
||||
|
||||
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)"
|
||||
f"Unknown direction '{direction_input}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT"
|
||||
)
|
||||
|
||||
dx, dy = DIRECTION_OFFSETS[normalized]
|
||||
|
|
@ -239,26 +515,54 @@ class GameEngine:
|
|||
is_turn = current_turn_player is not None and current_turn_player.id == player_id
|
||||
occupied = self._get_occupied_coordinates()
|
||||
|
||||
party = self.parties.get(player.party_id) if player.party_id else None
|
||||
is_leader = player.is_party_leader or party is None
|
||||
member_count = len(party.member_ids) if party else 1
|
||||
|
||||
moves: Dict[str, MoveCheckResult] = {}
|
||||
for name, dx, dy in STANDARD_DIRECTIONS:
|
||||
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
|
||||
if player.party_id and not player.is_party_leader:
|
||||
moves[name] = MoveCheckResult(
|
||||
direction=name,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
target_x=player.x + dx,
|
||||
target_y=player.y + dy,
|
||||
available=False,
|
||||
reason=f"Only party leader '{party.leader_name if party else 'Leader'}' controls group movement.",
|
||||
)
|
||||
else:
|
||||
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,
|
||||
is_turn=is_turn and is_leader,
|
||||
is_party_leader=player.is_party_leader,
|
||||
party_id=player.party_id,
|
||||
party_member_count=member_count,
|
||||
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 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")
|
||||
|
||||
# Non-leaders cannot move
|
||||
if player.party_id and not player.is_party_leader:
|
||||
party = self.parties.get(player.party_id)
|
||||
leader_name = party.leader_name if party else "Leader"
|
||||
raise PermissionError(
|
||||
f"Party member '{player.name}' cannot move individually. Only party leader '{leader_name}' controls movement for the party."
|
||||
)
|
||||
|
||||
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"
|
||||
|
|
@ -274,11 +578,24 @@ class GameEngine:
|
|||
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
|
||||
affected_players: List[Player] = []
|
||||
|
||||
# If party leader, move all party members in unison
|
||||
if player.party_id and player.party_id in self.parties:
|
||||
party = self.parties[player.party_id]
|
||||
for mid in party.member_ids:
|
||||
m = self.players.get(mid)
|
||||
if m:
|
||||
m.x += dx
|
||||
m.y += dy
|
||||
affected_players.append(m)
|
||||
else:
|
||||
player.x = check.target_x
|
||||
player.y = check.target_y
|
||||
affected_players.append(player)
|
||||
|
||||
new_pos = {"x": player.x, "y": player.y}
|
||||
|
||||
# Advance turn order to next player
|
||||
self._advance_turn()
|
||||
turn_info = self._get_turn_info()
|
||||
|
||||
|
|
@ -286,6 +603,8 @@ class GameEngine:
|
|||
success=True,
|
||||
player=player,
|
||||
direction=direction_name,
|
||||
party_moved=bool(player.party_id),
|
||||
affected_players=affected_players,
|
||||
previous_position=prev_pos,
|
||||
new_position=new_pos,
|
||||
turn=turn_info,
|
||||
|
|
@ -308,6 +627,176 @@ class GameEngine:
|
|||
self._advance_turn()
|
||||
return self._get_turn_info()
|
||||
|
||||
# ==========================================
|
||||
# Battle & Party Defeat Logic
|
||||
# ==========================================
|
||||
|
||||
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
|
||||
"""Kills the party leader: score -= 1, respawns at random position, removed from party,
|
||||
|
||||
and randomly assigns a new leader from the remainder of the party.
|
||||
"""
|
||||
async with self._lock:
|
||||
party = self.parties.get(party_id)
|
||||
if not party:
|
||||
raise KeyError(f"Party '{party_id}' not found")
|
||||
|
||||
old_leader_id = party.leader_id
|
||||
leader = self.players.get(old_leader_id)
|
||||
if not leader:
|
||||
raise KeyError(f"Leader '{old_leader_id}' not found")
|
||||
|
||||
# 1. Leader killed: score goes down by 1
|
||||
leader.score -= 1
|
||||
|
||||
# 2. Leader is removed from party
|
||||
leader.party_id = None
|
||||
leader.is_party_leader = False
|
||||
if old_leader_id in party.member_ids:
|
||||
party.member_ids.remove(old_leader_id)
|
||||
|
||||
# 3. Leader respawns at random free position
|
||||
rx, ry = self._find_random_free_position()
|
||||
leader.x = rx
|
||||
leader.y = ry
|
||||
respawn_pos = {"x": rx, "y": ry}
|
||||
|
||||
# 4. New leader is randomly assigned from the remainder of the party
|
||||
new_leader_id = None
|
||||
new_leader_name = None
|
||||
party_dissolved = False
|
||||
|
||||
if len(party.member_ids) > 0:
|
||||
new_leader_id = random.choice(party.member_ids)
|
||||
party.leader_id = new_leader_id
|
||||
new_leader = self.players[new_leader_id]
|
||||
new_leader.is_party_leader = True
|
||||
new_leader_name = new_leader.name
|
||||
party.leader_name = new_leader_name
|
||||
else:
|
||||
# No members left in party
|
||||
del self.parties[party_id]
|
||||
party_dissolved = True
|
||||
|
||||
return PartyDefeatResult(
|
||||
party_id=party_id,
|
||||
killed_leader_id=leader.id,
|
||||
killed_leader_name=leader.name,
|
||||
killed_leader_new_score=leader.score,
|
||||
killed_leader_respawn_position=respawn_pos,
|
||||
new_leader_id=new_leader_id,
|
||||
new_leader_name=new_leader_name,
|
||||
remaining_members=list(party.member_ids),
|
||||
party_dissolved=party_dissolved,
|
||||
)
|
||||
|
||||
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
||||
"""Resolves a battle between two adjacent players/bots.
|
||||
|
||||
The losing party's leader is killed, respawns, score -1, and remainder assigns new leader.
|
||||
"""
|
||||
async with self._lock:
|
||||
p1 = self.players.get(challenger_id)
|
||||
p2 = self.players.get(defender_id)
|
||||
if not p1 or not p2:
|
||||
raise KeyError("Both combatants must exist")
|
||||
|
||||
if p1.party_id and p1.party_id == p2.party_id:
|
||||
raise ValueError("Cannot battle your own party member")
|
||||
|
||||
if not self._are_adjacent(p1, p2):
|
||||
raise ValueError(
|
||||
f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))"
|
||||
)
|
||||
|
||||
# Party size calculation for battle advantage
|
||||
size1 = len(self.parties[p1.party_id].member_ids) if p1.party_id and p1.party_id in self.parties else 1
|
||||
size2 = len(self.parties[p2.party_id].member_ids) if p2.party_id and p2.party_id in self.parties else 1
|
||||
|
||||
if size1 > size2:
|
||||
winner, loser = p1, p2
|
||||
elif size2 > size1:
|
||||
winner, loser = p2, p1
|
||||
else:
|
||||
# Tied party sizes: 50/50 roll
|
||||
if random.random() < 0.5:
|
||||
winner, loser = p1, p2
|
||||
else:
|
||||
winner, loser = p2, p1
|
||||
|
||||
# Winner score +1
|
||||
winner.score += 1
|
||||
|
||||
# Resolve defeat for loser
|
||||
if loser.party_id and loser.party_id in self.parties:
|
||||
# Must release lock temporarily or run defeat internally
|
||||
party_id = loser.party_id
|
||||
party = self.parties[party_id]
|
||||
old_leader_id = party.leader_id
|
||||
leader = self.players[old_leader_id]
|
||||
leader.score -= 1
|
||||
leader.party_id = None
|
||||
leader.is_party_leader = False
|
||||
if old_leader_id in party.member_ids:
|
||||
party.member_ids.remove(old_leader_id)
|
||||
|
||||
rx, ry = self._find_random_free_position()
|
||||
leader.x = rx
|
||||
leader.y = ry
|
||||
respawn_pos = {"x": rx, "y": ry}
|
||||
|
||||
new_leader_id = None
|
||||
new_leader_name = None
|
||||
party_dissolved = False
|
||||
|
||||
if len(party.member_ids) > 0:
|
||||
new_leader_id = random.choice(party.member_ids)
|
||||
party.leader_id = new_leader_id
|
||||
new_leader = self.players[new_leader_id]
|
||||
new_leader.is_party_leader = True
|
||||
new_leader_name = new_leader.name
|
||||
party.leader_name = new_leader_name
|
||||
else:
|
||||
del self.parties[party_id]
|
||||
party_dissolved = True
|
||||
|
||||
defeat_result = PartyDefeatResult(
|
||||
party_id=party_id,
|
||||
killed_leader_id=leader.id,
|
||||
killed_leader_name=leader.name,
|
||||
killed_leader_new_score=leader.score,
|
||||
killed_leader_respawn_position=respawn_pos,
|
||||
new_leader_id=new_leader_id,
|
||||
new_leader_name=new_leader_name,
|
||||
remaining_members=list(party.member_ids),
|
||||
party_dissolved=party_dissolved,
|
||||
)
|
||||
else:
|
||||
# Solo loser
|
||||
loser.score -= 1
|
||||
rx, ry = self._find_random_free_position()
|
||||
loser.x = rx
|
||||
loser.y = ry
|
||||
defeat_result = PartyDefeatResult(
|
||||
party_id="none",
|
||||
killed_leader_id=loser.id,
|
||||
killed_leader_name=loser.name,
|
||||
killed_leader_new_score=loser.score,
|
||||
killed_leader_respawn_position={"x": rx, "y": ry},
|
||||
new_leader_id=None,
|
||||
new_leader_name=None,
|
||||
remaining_members=[],
|
||||
party_dissolved=True,
|
||||
)
|
||||
|
||||
return BattleResult(
|
||||
winner_id=winner.id,
|
||||
winner_name=winner.name,
|
||||
loser_id=loser.id,
|
||||
loser_name=loser.name,
|
||||
defeat_details=defeat_result,
|
||||
)
|
||||
|
||||
|
||||
# Global game engine instance
|
||||
game_engine = GameEngine()
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ class MoveRequest(BaseModel):
|
|||
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
|
||||
|
|
@ -92,6 +91,9 @@ class AvailableMovesResponse(BaseModel):
|
|||
current_x: int
|
||||
current_y: int
|
||||
is_turn: bool
|
||||
is_party_leader: bool = False
|
||||
party_id: Optional[str] = None
|
||||
party_member_count: int = 1
|
||||
current_turn_player_id: Optional[str] = None
|
||||
moves: Dict[str, MoveCheckResult]
|
||||
|
||||
|
|
@ -125,9 +127,73 @@ class Player(BaseModel):
|
|||
color: str
|
||||
x: int
|
||||
y: int
|
||||
score: int = 0
|
||||
party_id: Optional[str] = None
|
||||
is_party_leader: bool = False
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Party(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
leader_id: str
|
||||
leader_name: str
|
||||
member_ids: List[str]
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class PartyInviteCreate(BaseModel):
|
||||
inviter_id: str
|
||||
invitee_id: str
|
||||
proposed_leader_id: str
|
||||
party_name: Optional[str] = None
|
||||
|
||||
|
||||
class PartyInvite(BaseModel):
|
||||
id: str
|
||||
inviter_id: str
|
||||
invitee_id: str
|
||||
proposed_leader_id: str
|
||||
party_id: Optional[str] = None
|
||||
status: str = "pending" # pending, accepted, rejected
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class PartyInviteResponse(BaseModel):
|
||||
accept: bool
|
||||
|
||||
|
||||
class PartyDirectForm(BaseModel):
|
||||
member_ids: List[str] = Field(..., min_length=2, description="List of player IDs to form a party")
|
||||
leader_id: str = Field(..., description="Agreed leader ID (must be one of the member_ids)")
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class PartyDefeatResult(BaseModel):
|
||||
party_id: str
|
||||
killed_leader_id: str
|
||||
killed_leader_name: str
|
||||
killed_leader_new_score: int
|
||||
killed_leader_respawn_position: Dict[str, int]
|
||||
new_leader_id: Optional[str] = None
|
||||
new_leader_name: Optional[str] = None
|
||||
remaining_members: List[str] = []
|
||||
party_dissolved: bool = False
|
||||
|
||||
|
||||
class BattleRequest(BaseModel):
|
||||
challenger_id: str
|
||||
defender_id: str
|
||||
|
||||
|
||||
class BattleResult(BaseModel):
|
||||
winner_id: str
|
||||
winner_name: str
|
||||
loser_id: str
|
||||
loser_name: str
|
||||
defeat_details: PartyDefeatResult
|
||||
|
||||
|
||||
class BoardConfig(BaseModel):
|
||||
min_x: int = 0
|
||||
max_x: int = 64
|
||||
|
|
@ -149,6 +215,7 @@ class BoardState(BaseModel):
|
|||
config: BoardConfig
|
||||
player_count: int
|
||||
players: List[Player]
|
||||
parties: List[Party] = []
|
||||
turn: TurnInfo
|
||||
|
||||
|
||||
|
|
@ -156,6 +223,8 @@ class MoveResponse(BaseModel):
|
|||
success: bool
|
||||
player: Player
|
||||
direction: str
|
||||
party_moved: bool = False
|
||||
affected_players: List[Player] = []
|
||||
previous_position: Dict[str, int]
|
||||
new_position: Dict[str, int]
|
||||
turn: TurnInfo
|
||||
|
|
|
|||
|
|
@ -12,143 +12,166 @@ def reset_board_state():
|
|||
asyncio.run(game_engine.reset())
|
||||
|
||||
|
||||
def test_health():
|
||||
def test_party_formation_and_group_movement():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
|
||||
|
||||
# Place adjacent: Leader at (10, 10), Follower at (10, 11) (within distance 1)
|
||||
import asyncio
|
||||
async def place_adjacent():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
b1.x, b1.y = 10, 10
|
||||
b2 = await game_engine.get_player(p2["id"])
|
||||
b2.x, b2.y = 10, 11
|
||||
asyncio.run(place_adjacent())
|
||||
|
||||
def test_register_player():
|
||||
client = TestClient(app)
|
||||
res = client.post("/api/players", json={"name": "CyberBot", "color": "#00FFAA"})
|
||||
assert res.status_code == 201
|
||||
player = res.json()
|
||||
assert player["name"] == "CyberBot"
|
||||
assert 0 <= player["x"] <= 64
|
||||
assert 0 <= player["y"] <= 64
|
||||
# Form party with agreed leader p1
|
||||
form_res = client.post("/api/parties", json={
|
||||
"member_ids": [p1["id"], p2["id"]],
|
||||
"leader_id": p1["id"],
|
||||
"name": "AlphaSquad",
|
||||
})
|
||||
assert form_res.status_code == 201
|
||||
party = form_res.json()
|
||||
assert party["leader_id"] == p1["id"]
|
||||
assert len(party["member_ids"]) == 2
|
||||
|
||||
# Follower cannot move individually
|
||||
res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
|
||||
assert res_follow.status_code == 403
|
||||
assert "Only party leader" in res_follow.json()["detail"]
|
||||
|
||||
def test_turn_order_and_movement():
|
||||
client = TestClient(app)
|
||||
p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}).json()
|
||||
# Leader moves UP: Leader moves from (10,10)->(10,9), Follower moves from (10,11)->(10,10)
|
||||
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
||||
assert res_move.status_code == 200
|
||||
move_data = res_move.json()
|
||||
assert move_data["party_moved"] is True
|
||||
assert len(move_data["affected_players"]) == 2
|
||||
|
||||
# Board state should show Bot1 as active turn
|
||||
# Check positions in board
|
||||
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"]
|
||||
bots_by_id = {b["id"]: b for b in board["players"]}
|
||||
assert bots_by_id[p1["id"]]["x"] == 10
|
||||
assert bots_by_id[p1["id"]]["y"] == 9
|
||||
assert bots_by_id[p2["id"]]["x"] == 10
|
||||
assert bots_by_id[p2["id"]]["y"] == 10
|
||||
|
||||
|
||||
def test_diagonal_movement_and_turn_cycle():
|
||||
def test_party_wall_collision_for_follower():
|
||||
client = TestClient(app)
|
||||
p1 = client.post("/api/players", json={"name": "DiagBot", "color": "#123456"}).json()
|
||||
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
|
||||
|
||||
# Place Leader at (5, 1), Follower at (5, 0)
|
||||
# Moving UP would push Follower to (5, -1) which hits the wall!
|
||||
import asyncio
|
||||
async def place():
|
||||
player = await game_engine.get_player(p1["id"])
|
||||
player.x = 20
|
||||
player.y = 20
|
||||
asyncio.run(place())
|
||||
async def setup_near_wall():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
b1.x, b1.y = 5, 1
|
||||
b2 = await game_engine.get_player(p2["id"])
|
||||
b2.x, b2.y = 5, 0
|
||||
asyncio.run(setup_near_wall())
|
||||
|
||||
# Move diagonal UP_LEFT
|
||||
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP_LEFT"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["new_position"] == {"x": 19, "y": 19}
|
||||
client.post("/api/parties", json={
|
||||
"member_ids": [p1["id"], p2["id"]],
|
||||
"leader_id": p1["id"],
|
||||
})
|
||||
|
||||
# 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}
|
||||
# Available moves check should mark UP as blocked due to follower hitting wall
|
||||
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
||||
assert moves["moves"]["UP"]["available"] is False
|
||||
assert "boundary wall" in moves["moves"]["UP"]["reason"]
|
||||
|
||||
|
||||
def test_wall_collision():
|
||||
client = TestClient(app)
|
||||
p1 = client.post("/api/players", json={"name": "CornerBot", "color": "#123456"}).json()
|
||||
|
||||
import asyncio
|
||||
async def place_corner():
|
||||
player = await game_engine.get_player(p1["id"])
|
||||
player.x = 0
|
||||
player.y = 0
|
||||
asyncio.run(place_corner())
|
||||
|
||||
# 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)
|
||||
# Trying to move UP should fail
|
||||
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_player_collision():
|
||||
def test_party_defeat_and_leader_respawn():
|
||||
client = TestClient(app)
|
||||
p1 = client.post("/api/players", json={"name": "Attacker", "color": "#123456"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Defender", "color": "#654321"}).json()
|
||||
p1 = client.post("/api/players", json={"name": "LeaderToDie", "color": "#AA0000"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Survivor1", "color": "#00AA00"}).json()
|
||||
p3 = client.post("/api/players", json={"name": "Survivor2", "color": "#0000AA"}).json()
|
||||
|
||||
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())
|
||||
async def place_trio():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
b1.x, b1.y = 20, 20
|
||||
b2 = await game_engine.get_player(p2["id"])
|
||||
b2.x, b2.y = 20, 21
|
||||
b3 = await game_engine.get_player(p3["id"])
|
||||
b3.x, b3.y = 21, 21
|
||||
asyncio.run(place_trio())
|
||||
|
||||
# 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()
|
||||
party = client.post("/api/parties", json={
|
||||
"member_ids": [p1["id"], p2["id"], p3["id"]],
|
||||
"leader_id": p1["id"],
|
||||
}).json()
|
||||
|
||||
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"]
|
||||
# Trigger defeat on party
|
||||
defeat_res = client.post(f"/api/parties/{party['id']}/defeat")
|
||||
assert defeat_res.status_code == 200
|
||||
data = defeat_res.json()
|
||||
|
||||
# 1. Leader was killed, score is -1
|
||||
assert data["killed_leader_id"] == p1["id"]
|
||||
assert data["killed_leader_new_score"] == -1
|
||||
# 2. Leader respawned position is recorded
|
||||
assert "x" in data["killed_leader_respawn_position"]
|
||||
# 3. New leader randomly assigned from remaining members
|
||||
assert data["new_leader_id"] in [p2["id"], p3["id"]]
|
||||
assert len(data["remaining_members"]) == 2
|
||||
|
||||
# Verify leader is no longer part of the party
|
||||
dead_leader = client.get(f"/api/players/{p1['id']}").json()
|
||||
assert dead_leader["party_id"] is None
|
||||
assert dead_leader["is_party_leader"] is False
|
||||
assert dead_leader["score"] == -1
|
||||
|
||||
# Verify new leader in party
|
||||
updated_party = client.get(f"/api/parties/{party['id']}").json()
|
||||
assert updated_party["leader_id"] == data["new_leader_id"]
|
||||
assert p1["id"] not in updated_party["member_ids"]
|
||||
|
||||
|
||||
def test_pass_turn():
|
||||
def test_battle_between_parties():
|
||||
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()
|
||||
# Party 1 has 2 bots
|
||||
p1 = client.post("/api/players", json={"name": "Warrior1", "color": "#111111"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Warrior2", "color": "#222222"}).json()
|
||||
# Party 2 has 1 solo bot
|
||||
p3 = client.post("/api/players", json={"name": "SoloDefender", "color": "#333333"}).json()
|
||||
|
||||
res = client.post(f"/api/players/{p1['id']}/pass")
|
||||
import asyncio
|
||||
async def place_combatants():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
b1.x, b1.y = 30, 30
|
||||
b2 = await game_engine.get_player(p2["id"])
|
||||
b2.x, b2.y = 30, 31
|
||||
b3 = await game_engine.get_player(p3["id"])
|
||||
b3.x, b3.y = 31, 30 # adjacent to b1
|
||||
asyncio.run(place_combatants())
|
||||
|
||||
client.post("/api/parties", json={
|
||||
"member_ids": [p1["id"], p2["id"]],
|
||||
"leader_id": p1["id"],
|
||||
})
|
||||
|
||||
# Initiate battle: 2-bot party vs 1-bot solo -> Party 1 wins
|
||||
res = client.post("/api/battles/fight", json={
|
||||
"challenger_id": p1["id"],
|
||||
"defender_id": p3["id"],
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["current_player_id"] == p2["id"]
|
||||
battle = res.json()
|
||||
assert battle["winner_id"] == p1["id"]
|
||||
assert battle["loser_id"] == p3["id"]
|
||||
|
||||
# Check loser score = -1
|
||||
loser_bot = client.get(f"/api/players/{p3['id']}").json()
|
||||
assert loser_bot["score"] == -1
|
||||
# Check winner score = +1
|
||||
winner_bot = client.get(f"/api/players/{p1['id']}").json()
|
||||
assert winner_bot["score"] == 1
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useGameSocket } from './hooks/useGameSocket';
|
|||
import { BoardCanvas } from './components/BoardCanvas';
|
||||
import { Header } from './components/Header';
|
||||
import { MovementControls } from './components/MovementControls';
|
||||
import { PartyModal } from './components/PartyModal';
|
||||
import { PlayerList } from './components/PlayerList';
|
||||
import { RegisterModal } from './components/RegisterModal';
|
||||
|
||||
|
|
@ -39,8 +40,11 @@ export function App() {
|
|||
availableMoves,
|
||||
isAutoPlaying,
|
||||
setIsAutoPlaying,
|
||||
lastEventMessage,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
defeatParty,
|
||||
movePlayer,
|
||||
passTurn,
|
||||
stepActiveBotTurn,
|
||||
|
|
@ -48,13 +52,14 @@ export function App() {
|
|||
} = useGameSocket();
|
||||
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
|
||||
const [notification, setNotification] = useState<string | null>(null);
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setNotification(msg);
|
||||
setTimeout(() => {
|
||||
setNotification((curr) => (curr === msg ? null : curr));
|
||||
}, 3500);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const handleQuickSpawn = async () => {
|
||||
|
|
@ -73,7 +78,7 @@ export function App() {
|
|||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (window.confirm('Are you sure you want to clear all bots from the board?')) {
|
||||
if (window.confirm('Are you sure you want to clear all bots and parties from the board?')) {
|
||||
try {
|
||||
await resetBoard();
|
||||
showNotification('Board cleared');
|
||||
|
|
@ -85,6 +90,21 @@ export function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDefeatParty = async (partyId: string) => {
|
||||
try {
|
||||
const res = await defeatParty(partyId);
|
||||
showNotification(
|
||||
`Defeat! Leader ${res.killed_leader_name} was killed (-1 pt) & respawned. ${
|
||||
res.new_leader_name ? `New leader: ${res.new_leader_name}` : 'Party dissolved.'
|
||||
}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
showNotification(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||
{/* Top Navigation Bar */}
|
||||
|
|
@ -122,19 +142,21 @@ export function App() {
|
|||
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* Sidebar Player Roster with Turn Order */}
|
||||
{/* Sidebar Player Roster with Turn Order & Parties */}
|
||||
<PlayerList
|
||||
boardState={boardState}
|
||||
selectedPlayer={selectedPlayer}
|
||||
onSelectPlayer={setSelectedPlayer}
|
||||
onRemovePlayer={removePlayer}
|
||||
onOpenPartyModal={() => setIsPartyModalOpen(true)}
|
||||
onDefeatParty={handleDefeatParty}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{notification && (
|
||||
<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}
|
||||
{/* Live Event Feed Notification */}
|
||||
{(lastEventMessage || notification) && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-slate-900/95 border border-sky-500/60 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 max-w-lg truncate">
|
||||
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
|
||||
<span className="truncate">{notification || lastEventMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -145,7 +167,18 @@ export function App() {
|
|||
onClose={() => setIsRegisterOpen(false)}
|
||||
onRegister={async (name, color) => {
|
||||
const player = await registerPlayer(name, color);
|
||||
showNotification(`Deployed ${player.name} at coordinate (${player.x}, ${player.y})!`);
|
||||
showNotification(`Deployed ${player.name} at (${player.x}, ${player.y})!`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Party Formation Modal */}
|
||||
<PartyModal
|
||||
isOpen={isPartyModalOpen}
|
||||
boardState={boardState}
|
||||
onClose={() => setIsPartyModalOpen(false)}
|
||||
onFormParty={async (memberIds, leaderId, name) => {
|
||||
const party = await formParty(memberIds, leaderId, name);
|
||||
showNotification(`Party "${party.name}" established under leader ${party.leader_name}!`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Deep sci-fi cyberpunk background
|
||||
// Cyberpunk dark background
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
|
||||
bgGrad.addColorStop(0, '#090d16');
|
||||
bgGrad.addColorStop(1, '#05070c');
|
||||
|
|
@ -171,7 +171,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
ctx.strokeRect(hx, hy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Highlight adjacent movement tiles for the active turn player
|
||||
// Highlight adjacent movement tiles for the active turn player / party leader
|
||||
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
|
||||
const highlightedBot = selectedPlayer || activePlayer;
|
||||
if (highlightedBot && availableMoves && availableMoves.player_id === highlightedBot.id) {
|
||||
|
|
@ -195,6 +195,41 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// Draw Party Links (Beams connecting adjacent party members)
|
||||
const playerMap = new Map<string, Player>();
|
||||
boardState.players.forEach((p) => playerMap.set(p.id, p));
|
||||
|
||||
(boardState.parties || []).forEach((party) => {
|
||||
const members = party.member_ids.map((id) => playerMap.get(id)).filter(Boolean) as Player[];
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#38bdf8';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.shadowColor = '#38bdf8';
|
||||
ctx.shadowBlur = 8;
|
||||
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
for (let j = i + 1; j < members.length; j++) {
|
||||
const p1 = members[i];
|
||||
const p2 = members[j];
|
||||
// If adjacent (within 1 step)
|
||||
if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) {
|
||||
const x1 = startX + (p1.x - min_x) * cellSize;
|
||||
const y1 = startY + (p1.y - min_y) * cellSize;
|
||||
const x2 = startX + (p2.x - min_x) * cellSize;
|
||||
const y2 = startY + (p2.y - min_y) * cellSize;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Draw active players
|
||||
const now = Date.now() / 400;
|
||||
const pulse = Math.sin(now) * 0.25 + 0.75;
|
||||
|
|
@ -205,16 +240,18 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
const isSelected = selectedPlayer?.id === player.id;
|
||||
const isCurrentTurn = currentTurnId === player.id;
|
||||
const isHovered = hoveredPlayer?.id === player.id;
|
||||
const isLeader = player.is_party_leader;
|
||||
const inParty = Boolean(player.party_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
|
||||
// Active 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.strokeStyle = '#fbbf24';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.stroke();
|
||||
|
|
@ -224,14 +261,14 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
// Glow effect
|
||||
ctx.save();
|
||||
ctx.shadowColor = player.color;
|
||||
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8;
|
||||
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : inParty ? 12 : 8;
|
||||
|
||||
// Outer beacon ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
|
||||
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
|
||||
ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4;
|
||||
ctx.lineWidth = isCurrentTurn ? 2 : 1.5;
|
||||
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
|
||||
ctx.globalAlpha = isSelected || isCurrentTurn || isLeader ? 0.9 : 0.4;
|
||||
ctx.lineWidth = isLeader ? 2.5 : isCurrentTurn ? 2 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Main Player Token
|
||||
|
|
@ -244,30 +281,36 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
// Inner Core
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff';
|
||||
ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Label above player
|
||||
if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) {
|
||||
if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) {
|
||||
ctx.save();
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const text = isCurrentTurn ? `👑 ${player.name}` : player.name;
|
||||
|
||||
let prefix = '';
|
||||
if (isLeader) prefix = '👑 ';
|
||||
else if (inParty) prefix = '🔗 ';
|
||||
|
||||
const scoreText = `[${player.score >= 0 ? `+${player.score}` : player.score}]`;
|
||||
const text = `${prefix}${player.name} ${scoreText}`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const bgWidth = textMetrics.width + 12;
|
||||
const bgHeight = 16;
|
||||
const labelY = py - radius - 8;
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
|
||||
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
|
||||
ctx.strokeStyle = isLeader ? '#fbbf24' : 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 = isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
||||
ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
||||
ctx.fillText(text, px, labelY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -280,7 +323,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(startX, startY, totalWidth, totalHeight);
|
||||
|
||||
// Draw Coordinate Axis Rulers (0, 8, 16, 24, 32, 40, 48, 56, 64)
|
||||
// Coordinate Axis Rulers
|
||||
ctx.font = '10px monospace';
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.textAlign = 'center';
|
||||
|
|
@ -370,14 +413,12 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Wheel to Zoom
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88;
|
||||
setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0));
|
||||
};
|
||||
|
||||
// Click to Select Player or Coordinate
|
||||
const handleClick = () => {
|
||||
if (hoveredPlayer) {
|
||||
onSelectPlayer(hoveredPlayer);
|
||||
|
|
@ -469,12 +510,18 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
|
||||
{selectedPlayer.name}
|
||||
{selectedPlayer.is_party_leader && (
|
||||
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
|
||||
)}
|
||||
{selectedPlayer.id === currentTurnId && (
|
||||
<span className="text-[10px] text-amber-400 font-mono">👑 Turn</span>
|
||||
<span className="text-[10px] text-emerald-400 font-mono">• Turn</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-slate-400 font-mono text-[11px]">
|
||||
ID: {selectedPlayer.id} • Pos: ({selectedPlayer.x}, {selectedPlayer.y})
|
||||
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) • Score:{' '}
|
||||
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{selectedPlayer.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { BoardState, Player } from '../types';
|
||||
|
||||
interface PartyModalProps {
|
||||
isOpen: boolean;
|
||||
boardState: BoardState;
|
||||
onClose: () => void;
|
||||
onFormParty: (memberIds: string[], leaderId: string, name?: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const PartyModal: React.FC<PartyModalProps> = ({
|
||||
isOpen,
|
||||
boardState,
|
||||
onClose,
|
||||
onFormParty,
|
||||
}) => {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [leaderId, setLeaderId] = useState<string>('');
|
||||
const [partyName, setPartyName] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const { players } = boardState;
|
||||
|
||||
// Check if two players are adjacent (Chebyshev distance <= 1)
|
||||
const areAdjacent = (p1: Player, p2: Player) => {
|
||||
return Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1;
|
||||
};
|
||||
|
||||
// Find any adjacent pairs in the game for quick selection
|
||||
const findAdjacentPairs = () => {
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = i + 1; j < players.length; j++) {
|
||||
if (areAdjacent(players[i], players[j])) {
|
||||
return [players[i], players[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleQuickPair = () => {
|
||||
const pair = findAdjacentPairs();
|
||||
if (pair) {
|
||||
setSelectedIds([pair[0].id, pair[1].id]);
|
||||
setLeaderId(pair[0].id);
|
||||
setPartyName(`Squad ${pair[0].name}`);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('No adjacent bots found! Move bots within 1 distance of each other to link them.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setError(null);
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
|
||||
if (!next.includes(leaderId)) {
|
||||
setLeaderId(next[0] || '');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (selectedIds.length < 2) {
|
||||
setError('Select at least 2 bots to form a party');
|
||||
return;
|
||||
}
|
||||
if (!leaderId) {
|
||||
setError('You must agree on a party leader');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onFormParty(selectedIds, leaderId, partyName.trim() || undefined);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Failed to form party');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-md shadow-2xl p-6 relative">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🤝</span>
|
||||
<h2 className="text-lg font-bold text-slate-100">Form a Bot Party</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-200 transition-colors text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 mb-4 leading-relaxed">
|
||||
Bots can unite into a linked squad if they are within 1 distance of each other. The agreed{' '}
|
||||
<strong className="text-amber-300">Party Leader</strong> controls group movement. If defeated, the leader is
|
||||
killed (-1 score) & respawns, and the remainder elects a new leader.
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickPair}
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 border border-sky-500/40 text-sky-300 text-xs font-mono py-2 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>🎯</span> Auto-Select Nearest Adjacent Bots
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Party Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-slate-400 mb-1">Party Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={partyName}
|
||||
onChange={(e) => setPartyName(e.target.value)}
|
||||
placeholder="e.g. NeonVipers"
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-100 focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Member Selection */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-slate-400 mb-1.5">
|
||||
Select Members (At least 2):
|
||||
</label>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5 border border-slate-800 rounded-xl p-2 bg-slate-950/50">
|
||||
{players.map((p) => {
|
||||
const isChecked = selectedIds.includes(p.id);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => toggleSelect(p.id)}
|
||||
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer border text-xs transition-colors ${
|
||||
isChecked
|
||||
? 'bg-sky-950/60 border-sky-500/70 text-sky-200'
|
||||
: 'bg-slate-900/60 border-slate-800 text-slate-400 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
<span className="font-semibold text-slate-200">{p.name}</span>
|
||||
<span className="font-mono text-[10px] text-slate-500">
|
||||
({p.x}, {p.y})
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {}}
|
||||
className="rounded border-slate-700 text-sky-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agreed Leader Selection */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-slate-400 mb-1.5">
|
||||
👑 Agreed Party Leader:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{selectedIds.map((id) => {
|
||||
const bot = players.find((p) => p.id === id);
|
||||
if (!bot) return null;
|
||||
const isLeader = leaderId === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
onClick={() => setLeaderId(id)}
|
||||
className={`p-2 rounded-lg border text-xs font-mono flex items-center gap-2 transition-all ${
|
||||
isLeader
|
||||
? 'bg-amber-950/80 border-amber-500 text-amber-200 shadow'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span>{isLeader ? '👑' : '🛡️'}</span>
|
||||
<span className="truncate">{bot.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-2.5 rounded-lg bg-red-950/70 border border-red-800/80 text-red-300 text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 py-2 rounded-xl text-xs font-mono transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || selectedIds.length < 2 || !leaderId}
|
||||
className="flex-1 bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 rounded-xl text-xs font-mono transition-colors shadow-lg shadow-sky-950/50"
|
||||
>
|
||||
{loading ? 'Forming...' : 'Confirm Party'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,8 @@ interface PlayerListProps {
|
|||
selectedPlayer: Player | null;
|
||||
onSelectPlayer: (player: Player) => void;
|
||||
onRemovePlayer: (id: string) => void;
|
||||
onOpenPartyModal?: () => void;
|
||||
onDefeatParty?: (partyId: string) => void;
|
||||
}
|
||||
|
||||
export const PlayerList: React.FC<PlayerListProps> = ({
|
||||
|
|
@ -13,24 +15,31 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
selectedPlayer,
|
||||
onSelectPlayer,
|
||||
onRemovePlayer,
|
||||
onOpenPartyModal,
|
||||
onDefeatParty,
|
||||
}) => {
|
||||
const { players, turn } = boardState;
|
||||
const { players, parties = [], turn } = boardState;
|
||||
const currentTurnId = turn.current_player_id;
|
||||
|
||||
const partyMap = new Map<string, string>();
|
||||
parties.forEach((p) => {
|
||||
p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-sky-400" />
|
||||
<h3 className="font-semibold text-slate-100 text-sm">Active Bots</h3>
|
||||
<h3 className="font-semibold text-slate-100 text-sm">Active Bots & Parties</h3>
|
||||
</div>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700">
|
||||
{players.length} / 4096
|
||||
{players.length} Bots
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Turn Info Banner */}
|
||||
{/* Turn & Round 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">
|
||||
|
|
@ -42,6 +51,54 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Parties Section */}
|
||||
{parties.length > 0 && (
|
||||
<div className="p-3 border-b border-slate-800 bg-slate-950/40">
|
||||
<div className="text-[11px] font-mono text-slate-400 uppercase tracking-wider mb-2 flex items-center justify-between">
|
||||
<span>🛡️ Active Parties ({parties.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{parties.map((party) => (
|
||||
<div
|
||||
key={party.id}
|
||||
className="bg-slate-900/90 border border-sky-500/40 rounded-lg p-2 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-sky-300 truncate flex items-center gap-1">
|
||||
<span>👑</span> {party.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">
|
||||
Leader: <strong className="text-amber-300">{party.leader_name}</strong> •{' '}
|
||||
{party.member_ids.length} members
|
||||
</div>
|
||||
</div>
|
||||
{onDefeatParty && (
|
||||
<button
|
||||
onClick={() => onDefeatParty(party.id)}
|
||||
className="ml-2 bg-rose-950/80 hover:bg-rose-900 text-rose-300 border border-rose-700/60 px-2 py-1 rounded text-[10px] font-mono transition-all"
|
||||
title="Defeat party: kills leader (-1 pt), leader respawns, remainder elects new leader"
|
||||
>
|
||||
💥 Defeat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action to form party */}
|
||||
{players.length >= 2 && onOpenPartyModal && (
|
||||
<div className="p-2 border-b border-slate-800 bg-slate-950/30">
|
||||
<button
|
||||
onClick={onOpenPartyModal}
|
||||
className="w-full bg-slate-800 hover:bg-sky-700 text-slate-200 hover:text-white py-1.5 px-3 rounded-lg text-xs font-mono border border-slate-700 transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<span>🤝</span> Form / Join Party
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Players List */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{players.length === 0 ? (
|
||||
|
|
@ -55,7 +112,8 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
players.map((player) => {
|
||||
const isSelected = selectedPlayer?.id === player.id;
|
||||
const isCurrentTurn = currentTurnId === player.id;
|
||||
const orderIndex = turn.turn_order.indexOf(player.id);
|
||||
const isLeader = player.is_party_leader;
|
||||
const partyName = partyMap.get(player.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -75,15 +133,17 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
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: isCurrentTurn
|
||||
boxShadow: isLeader
|
||||
? '0 0 14px #fbbf24'
|
||||
: 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">
|
||||
{isLeader && (
|
||||
<span className="absolute -top-2 -right-1 text-[12px] leading-none">
|
||||
👑
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -94,17 +154,23 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
<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>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
|
||||
player.score < 0
|
||||
? 'bg-rose-950 text-rose-300 border border-rose-800'
|
||||
: player.score > 0
|
||||
? 'bg-emerald-950 text-emerald-300 border border-emerald-800'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{player.score >= 0 ? `+${player.score}` : player.score} pts
|
||||
</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
|
||||
{partyName && (
|
||||
<span className="ml-1 text-sky-400 truncate font-sans">
|
||||
• {isLeader ? 'Leader' : 'Squad'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { AvailableMovesResponse, BoardState, MoveResponse, Player, TurnInfo } from '../types';
|
||||
import type {
|
||||
AvailableMovesResponse,
|
||||
BattleResult,
|
||||
BoardState,
|
||||
MoveResponse,
|
||||
Party,
|
||||
PartyDefeatResult,
|
||||
Player,
|
||||
TurnInfo,
|
||||
} from '../types';
|
||||
|
||||
const INITIAL_BOARD: BoardState = {
|
||||
config: {
|
||||
|
|
@ -12,6 +21,7 @@ const INITIAL_BOARD: BoardState = {
|
|||
},
|
||||
player_count: 0,
|
||||
players: [],
|
||||
parties: [],
|
||||
turn: {
|
||||
current_player_id: null,
|
||||
current_player_name: null,
|
||||
|
|
@ -27,6 +37,7 @@ export function useGameSocket() {
|
|||
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
|
||||
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
||||
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
|
@ -37,7 +48,10 @@ export function useGameSocket() {
|
|||
const res = await fetch('/api/board');
|
||||
if (res.ok) {
|
||||
const data: BoardState = await res.json();
|
||||
setBoardState(data);
|
||||
setBoardState((prev) => ({
|
||||
...data,
|
||||
parties: data.parties || prev.parties || [],
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch board state:', err);
|
||||
|
|
@ -79,7 +93,10 @@ export function useGameSocket() {
|
|||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'init') {
|
||||
setBoardState(data.state);
|
||||
setBoardState({
|
||||
...data.state,
|
||||
parties: data.state.parties || [],
|
||||
});
|
||||
} else if (data.event === 'player_joined') {
|
||||
setBoardState((prev) => {
|
||||
const exists = prev.players.some((p) => p.id === data.player.id);
|
||||
|
|
@ -94,11 +111,55 @@ export function useGameSocket() {
|
|||
};
|
||||
});
|
||||
} else if (data.event === 'player_moved') {
|
||||
setBoardState((prev) => {
|
||||
const updatedMap = new Map<string, Player>();
|
||||
if (data.affected_players) {
|
||||
data.affected_players.forEach((p: Player) => updatedMap.set(p.id, p));
|
||||
} else if (data.players) {
|
||||
data.players.forEach((p: Player) => updatedMap.set(p.id, p));
|
||||
} else if (data.player) {
|
||||
updatedMap.set(data.player.id, data.player);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
players: prev.players.map((p) => updatedMap.get(p.id) || p),
|
||||
turn: data.turn ?? prev.turn,
|
||||
};
|
||||
});
|
||||
} else if (data.event === 'party_formed' || data.event === 'party_updated') {
|
||||
setBoardState((prev) => ({
|
||||
...prev,
|
||||
players: prev.players.map((p) => (p.id === data.player.id ? data.player : p)),
|
||||
players: data.players ?? prev.players,
|
||||
parties: [
|
||||
...prev.parties.filter((p) => p.id !== data.party.id),
|
||||
data.party,
|
||||
],
|
||||
turn: data.turn ?? prev.turn,
|
||||
}));
|
||||
setLastEventMessage(`Party "${data.party.name}" established under leader ${data.party.leader_name}!`);
|
||||
} else if (data.event === 'party_defeated') {
|
||||
setBoardState((prev) => ({
|
||||
...prev,
|
||||
players: data.players ?? prev.players,
|
||||
parties: data.parties ?? prev.parties,
|
||||
turn: data.turn ?? prev.turn,
|
||||
}));
|
||||
const details: PartyDefeatResult = data.defeat_details;
|
||||
setLastEventMessage(
|
||||
`💥 Party Defeat! Leader ${details.killed_leader_name} killed (-1 pt) and respawned. ${
|
||||
details.new_leader_name ? `New leader: ${details.new_leader_name}` : 'Party dissolved.'
|
||||
}`
|
||||
);
|
||||
} else if (data.event === 'battle_resolved') {
|
||||
setBoardState((prev) => ({
|
||||
...prev,
|
||||
players: data.players ?? prev.players,
|
||||
parties: data.parties ?? prev.parties,
|
||||
turn: data.turn ?? prev.turn,
|
||||
}));
|
||||
const b: BattleResult = data.battle;
|
||||
setLastEventMessage(`⚔️ Battle Victory: ${b.winner_name} defeated ${b.loser_name}!`);
|
||||
} else if (data.event === 'turn_passed') {
|
||||
setBoardState((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -111,6 +172,7 @@ export function useGameSocket() {
|
|||
...prev,
|
||||
player_count: data.player_count ?? nextPlayers.length,
|
||||
players: nextPlayers,
|
||||
parties: data.parties ?? prev.parties.filter((party) => party.member_ids.includes(data.player_id)),
|
||||
turn: data.turn ?? prev.turn,
|
||||
};
|
||||
});
|
||||
|
|
@ -120,6 +182,7 @@ export function useGameSocket() {
|
|||
...prev,
|
||||
player_count: 0,
|
||||
players: [],
|
||||
parties: [],
|
||||
turn: data.turn ?? INITIAL_BOARD.turn,
|
||||
}));
|
||||
setSelectedPlayer(null);
|
||||
|
|
@ -204,6 +267,46 @@ export function useGameSocket() {
|
|||
}
|
||||
};
|
||||
|
||||
const formParty = async (memberIds: string[], leaderId: string, name?: string): Promise<Party> => {
|
||||
const res = await fetch('/api/parties', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ member_ids: memberIds, leader_id: leaderId, name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to form party');
|
||||
}
|
||||
const party: Party = await res.json();
|
||||
return party;
|
||||
};
|
||||
|
||||
const defeatParty = async (partyId: string): Promise<PartyDefeatResult> => {
|
||||
const res = await fetch(`/api/parties/${partyId}/defeat`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to defeat party');
|
||||
}
|
||||
const result: PartyDefeatResult = await res.json();
|
||||
return result;
|
||||
};
|
||||
|
||||
const fightBattle = async (challengerId: string, defenderId: string): Promise<BattleResult> => {
|
||||
const res = await fetch('/api/battles/fight', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ challenger_id: challengerId, defender_id: defenderId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Battle failed');
|
||||
}
|
||||
const result: BattleResult = await res.json();
|
||||
return result;
|
||||
};
|
||||
|
||||
const movePlayer = async (playerId: string, direction: string): Promise<MoveResponse> => {
|
||||
const res = await fetch(`/api/players/${playerId}/move`, {
|
||||
method: 'POST',
|
||||
|
|
@ -239,7 +342,7 @@ export function useGameSocket() {
|
|||
}
|
||||
};
|
||||
|
||||
// Step a single turn for the active bot (random valid move)
|
||||
// Step a single turn for the active bot / party leader
|
||||
const stepActiveBotTurn = useCallback(async () => {
|
||||
const currentId = boardState.turn.current_player_id;
|
||||
if (!currentId) return;
|
||||
|
|
@ -256,7 +359,6 @@ export function useGameSocket() {
|
|||
const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)];
|
||||
await movePlayer(currentId, randomDir);
|
||||
} else {
|
||||
// No moves available, pass turn
|
||||
await passTurn(currentId);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -269,7 +371,7 @@ export function useGameSocket() {
|
|||
if (isAutoPlaying) {
|
||||
autoPlayIntervalRef.current = window.setInterval(() => {
|
||||
stepActiveBotTurn();
|
||||
}, 700);
|
||||
}, 750);
|
||||
} else if (autoPlayIntervalRef.current) {
|
||||
clearInterval(autoPlayIntervalRef.current);
|
||||
autoPlayIntervalRef.current = null;
|
||||
|
|
@ -290,8 +392,12 @@ export function useGameSocket() {
|
|||
availableMoves,
|
||||
isAutoPlaying,
|
||||
setIsAutoPlaying,
|
||||
lastEventMessage,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
defeatParty,
|
||||
fightBattle,
|
||||
movePlayer,
|
||||
passTurn,
|
||||
stepActiveBotTurn,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,21 @@ export interface Player {
|
|||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
score: number;
|
||||
party_id?: string | null;
|
||||
is_party_leader?: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Party {
|
||||
id: string;
|
||||
name: string;
|
||||
leader_id: string;
|
||||
leader_name: string;
|
||||
member_ids: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface BoardConfig {
|
||||
min_x: number;
|
||||
max_x: number;
|
||||
|
|
@ -28,6 +40,7 @@ export interface BoardState {
|
|||
config: BoardConfig;
|
||||
player_count: number;
|
||||
players: Player[];
|
||||
parties: Party[];
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +65,9 @@ export interface AvailableMovesResponse {
|
|||
current_x: number;
|
||||
current_y: number;
|
||||
is_turn: boolean;
|
||||
is_party_leader: boolean;
|
||||
party_id?: string | null;
|
||||
party_member_count: number;
|
||||
current_turn_player_id?: string | null;
|
||||
moves: Record<string, MoveCheckResult>;
|
||||
}
|
||||
|
|
@ -60,7 +76,29 @@ export interface MoveResponse {
|
|||
success: boolean;
|
||||
player: Player;
|
||||
direction: string;
|
||||
party_moved?: boolean;
|
||||
affected_players?: Player[];
|
||||
previous_position: { x: number; y: number };
|
||||
new_position: { x: number; y: number };
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
export interface PartyDefeatResult {
|
||||
party_id: string;
|
||||
killed_leader_id: string;
|
||||
killed_leader_name: string;
|
||||
killed_leader_new_score: number;
|
||||
killed_leader_respawn_position: { x: number; y: number };
|
||||
new_leader_id?: string | null;
|
||||
new_leader_name?: string | null;
|
||||
remaining_members: string[];
|
||||
party_dissolved: boolean;
|
||||
}
|
||||
|
||||
export interface BattleResult {
|
||||
winner_id: string;
|
||||
winner_name: string;
|
||||
loser_id: string;
|
||||
loser_name: string;
|
||||
defeat_details: PartyDefeatResult;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue