parties can be created. if a party defeated, leader is respawned and looses a point

This commit is contained in:
Isaac Johnson 2026-09-05 16:55:07 -05:00
parent 910e9d91b6
commit 2f02259c38
11 changed files with 1556 additions and 317 deletions

186
README.md
View File

@ -1,136 +1,98 @@
# botWebWars # botWebWars
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/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 ## Features
### Turn-Based Movement & Rules ### 1. Parties & Squad Formations
- **Turn Order**: Strict round-robin turn order based on registration sequence. - **Goal & Linked Connectivity**:
- **8 Movement Directions**: - Bots can find each other and join together as a **Party**.
- **Cardinal**: `UP`, `DOWN`, `LEFT`, `RIGHT` (or `N`, `S`, `W`, `E`) - 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).
- **Diagonal**: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT` (or `NW`, `NE`, `SW`, `SE`) - Bots must agree on a **Party Leader**.
- **Obstacle & Boundary Enforcement**: - **Leader Group Movement**:
- **Boundary Walls**: Cannot break past coordinate boundaries `[0..64, 0..64]`. - The Party Leader controls the movement for the entire squad.
- **Collision Prevention**: Cannot move into any square occupied by another player/bot. - All party members translate together in the chosen direction (preserving their relative shape and linked adjacency).
- **Turn Enforcement**: A bot can only move when it is their turn (`403 Forbidden` if attempted out of turn). - If any party member's path is blocked by an outside bot or boundary wall, the entire group move is prevented.
- **Pass Turn**: Players can skip/pass their turn if stuck or desired. - 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 ### 3. Turn-Based 8-Directional Movement
- `GET /api/players/{player_id}/available-moves` - **8 Directions**:
- 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. - Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
- `GET /api/players/{player_id}/check-move?direction={DIR}` - Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
- Fast check for a single direction (e.g. `UP`, `DOWN`, `NW`, etc.). Returns `available: true/false`, target coordinates, and reason if blocked. - **Collision & Wall Avoidance**:
- `POST /api/players/{player_id}/move` - Cannot break through boundary walls `[0..64, 0..64]`.
- Execute a movement. Accepts `{"direction": "UP_RIGHT"}` or `{"dx": 1, "dy": -1}`. - Cannot move into cells occupied by outside bots.
- Validates boundaries, occupant collisions, and turn order.
- Advances position and rotates turn to next player.
- `POST /api/players/{player_id}/pass`
- Passes the turn to the next player.
- `GET /api/turn`
- Returns current turn state (active player ID & name, round number, turn number, and player order).
#### Management Endpoints
- `POST /api/players` (or `/api/register`): Register a player with `name` and `color`.
- `GET /api/board`: Returns grid dimensions, player count, all player coordinates, and turn information.
- `GET /api/players`: Lists active players.
- `DELETE /api/players/{player_id}`: Remove a player from the arena.
- `POST /api/board/reset`: Reset the board and clear all players.
- `/ws`: Real-time WebSocket feed broadcasting movements, spawns, departures, and turn rotations.
- Interactive OpenAPI / Swagger documentation at `/docs`.
--- ---
### Interactive Web UI (React + Vite + Tailwind CSS) ## REST API Endpoints
- **HTML5 Canvas 64×64 Grid**:
- Click-and-drag pan, mouse-wheel zoom, coordinate axis rulers (0 to 64), and hover coordinate tracker. ### Party & Battle Endpoints
- Golden pulsing halo and crown `👑` highlighting the active turn player. - `POST /api/parties`: Form a party directly.
- Visual movement overlays showing free cells (green) vs blocked cells (red) around the active bot. - Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
- **8-Directional On-Screen D-Pad**: - Validates that members are within 1 distance of each other and agree on the leader.
- Compass layout (NW, N, NE, W, PASS, E, SW, S, SE) with real-time green/red availability styling and tooltips. - `POST /api/parties/invite`: Propose a party invite to an adjacent bot with an agreed leader.
- Full keyboard control: **WASD**, **Arrow keys**, or **Numpad (1-9)**, plus **Spacebar** to pass. - `POST /api/parties/invites/{invite_id}/respond`: Accept or reject party invite (`{"accept": true}`).
- **Simulation & Testing Tools**: - `GET /api/parties`: List all active parties, leaders, and members.
- **⚡ Step Bot**: Executes 1 valid random move for the active bot. - `GET /api/parties/{party_id}`: Get party details.
- **▶ Auto-Play**: Automatically runs bot turns in real time so you can watch them navigate the arena. - `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
- **🎲 Quick Spawn Bot**: 1-click bot generator with fun cyber names and neon colors. - 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 ## Quick Start with Docker
### Option 1: Docker Compose (Recommended)
```bash ```bash
# Build and run with Docker Compose
docker compose up --build docker compose up --build
``` ```
Open [http://localhost:8000](http://localhost:8000) in your browser. Open [http://localhost:8000](http://localhost:8000) in your browser.
### Option 2: Docker CLI ### Running Backend Test Suite
```bash ```bash
# Build the Docker image docker run --rm botwebwars:test pytest backend/tests
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
``` ```

View File

@ -5,10 +5,18 @@ from app.game import game_engine
from app.models import ( from app.models import (
ApiResponse, ApiResponse,
AvailableMovesResponse, AvailableMovesResponse,
BattleRequest,
BattleResult,
BoardState, BoardState,
MoveCheckResult, MoveCheckResult,
MoveRequest, MoveRequest,
MoveResponse, MoveResponse,
Party,
PartyDefeatResult,
PartyDirectForm,
PartyInvite,
PartyInviteCreate,
PartyInviteResponse,
Player, Player,
PlayerCreate, PlayerCreate,
TurnInfo, TurnInfo,
@ -26,6 +34,10 @@ async def health_check():
) )
# ==========================================
# Player Registration & Management
# ==========================================
@router.post( @router.post(
"/players", "/players",
response_model=Player, response_model=Player,
@ -43,7 +55,6 @@ async def register_player(player_in: PlayerCreate):
player = await game_engine.register_player(player_in) player = await game_engine.register_player(player_in)
board_state = await game_engine.get_board_state() board_state = await game_engine.get_board_state()
# Broadcast to all connected WebSocket clients
await manager.broadcast({ await manager.broadcast({
"event": "player_joined", "event": "player_joined",
"player": player.model_dump(), "player": player.model_dump(),
@ -99,6 +110,7 @@ async def remove_player(player_id: str):
"event": "player_left", "event": "player_left",
"player_id": player_id, "player_id": player_id,
"player_count": board_state.player_count, "player_count": board_state.player_count,
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(), "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 # Movement & Turn Endpoints
# ========================================== # ==========================================
@ -115,7 +282,7 @@ async def remove_player(player_id: str):
@router.get( @router.get(
"/players/{player_id}/available-moves", "/players/{player_id}/available-moves",
response_model=AvailableMovesResponse, 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"], tags=["Movement"],
) )
async def get_available_moves(player_id: str): async def get_available_moves(player_id: str):
@ -138,7 +305,7 @@ async def check_single_move(
player_id: str, player_id: str,
direction: str = Query( 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: try:
@ -158,7 +325,7 @@ async def check_single_move(
@router.post( @router.post(
"/players/{player_id}/move", "/players/{player_id}/move",
response_model=MoveResponse, 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"], tags=["Movement"],
) )
async def move_player(player_id: str, move_req: MoveRequest): 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() dx, dy, dir_name = move_req.get_delta()
result = await game_engine.move_player(player_id, dx, dy, dir_name) 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({ await manager.broadcast({
"event": "player_moved", "event": "player_moved",
"player": result.player.model_dump(), "player": result.player.model_dump(),
"direction": result.direction, "direction": result.direction,
"party_moved": result.party_moved,
"affected_players": [p.model_dump() for p in result.affected_players],
"previous_position": result.previous_position, "previous_position": result.previous_position,
"new_position": result.new_position, "new_position": result.new_position,
"players": [p.model_dump() for p in board_state.players],
"turn": result.turn.model_dump(), "turn": result.turn.model_dump(),
}) })
@ -239,7 +409,7 @@ async def get_turn():
@router.get( @router.get(
"/board", "/board",
response_model=BoardState, response_model=BoardState,
summary="Get full board configuration and player positions", summary="Get full board configuration, parties, and player positions",
tags=["Board"], tags=["Board"],
) )
async def get_board(): async def get_board():
@ -249,7 +419,7 @@ async def get_board():
@router.post( @router.post(
"/board/reset", "/board/reset",
response_model=ApiResponse, response_model=ApiResponse,
summary="Reset the board and clear all players", summary="Reset the board and clear all players and parties",
tags=["Board"], tags=["Board"],
) )
async def reset_board(): async def reset_board():

View File

@ -1,18 +1,22 @@
import asyncio import asyncio
import random import random
import uuid import uuid
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Set, Tuple
from app.config import settings from app.config import settings
from app.models import ( from app.models import (
AvailableMovesResponse,
BattleResult,
BoardConfig, BoardConfig,
BoardState, BoardState,
DIRECTION_OFFSETS,
MoveCheckResult, MoveCheckResult,
MoveResponse, MoveResponse,
Party,
PartyDefeatResult,
PartyInvite,
Player, Player,
PlayerCreate, PlayerCreate,
TurnInfo, TurnInfo,
DIRECTION_OFFSETS,
AvailableMovesResponse,
) )
STANDARD_DIRECTIONS = [ STANDARD_DIRECTIONS = [
@ -31,6 +35,8 @@ class GameEngine:
def __init__(self): def __init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self.players: Dict[str, Player] = {} self.players: Dict[str, Player] = {}
self.parties: Dict[str, Party] = {}
self.invites: Dict[str, PartyInvite] = {}
self.turn_order: List[str] = [] self.turn_order: List[str] = []
self.current_turn_index: int = 0 self.current_turn_index: int = 0
self.round_number: int = 1 self.round_number: int = 1
@ -74,29 +80,43 @@ class GameEngine:
] ]
return random.choice(all_coords) 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]: def _get_current_player(self) -> Optional[Player]:
if not self.turn_order: actors = self._get_active_turn_actors()
if not actors:
return None return None
safe_index = self.current_turn_index % len(self.turn_order) safe_index = self.current_turn_index % len(actors)
player_id = self.turn_order[safe_index] player_id = actors[safe_index]
return self.players.get(player_id) return self.players.get(player_id)
def _get_turn_info(self) -> TurnInfo: def _get_turn_info(self) -> TurnInfo:
current = self._get_current_player() current = self._get_current_player()
actors = self._get_active_turn_actors()
return TurnInfo( return TurnInfo(
current_player_id=current.id if current else None, current_player_id=current.id if current else None,
current_player_name=current.name if current else None, current_player_name=current.name if current else None,
round_number=self.round_number, round_number=self.round_number,
turn_number=self.turn_number, turn_number=self.turn_number,
turn_order=list(self.turn_order), turn_order=actors,
) )
def _advance_turn(self): def _advance_turn(self):
if not self.turn_order: actors = self._get_active_turn_actors()
if not actors:
self.current_turn_index = 0 self.current_turn_index = 0
return return
self.turn_number += 1 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: if self.current_turn_index == 0:
self.round_number += 1 self.round_number += 1
@ -111,6 +131,9 @@ class GameEngine:
color=player_in.color, color=player_in.color,
x=x, x=x,
y=y, y=y,
score=0,
party_id=None,
is_party_leader=False,
) )
self.players[player_id] = player self.players[player_id] = player
self.turn_order.append(player_id) self.turn_order.append(player_id)
@ -129,16 +152,34 @@ class GameEngine:
if player_id not in self.players: if player_id not in self.players:
return False 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] del self.players[player_id]
if player_id in self.turn_order: if player_id in self.turn_order:
idx = self.turn_order.index(player_id)
self.turn_order.remove(player_id) self.turn_order.remove(player_id)
if self.turn_order: actors = self._get_active_turn_actors()
if idx < self.current_turn_index: if actors:
self.current_turn_index -= 1 self.current_turn_index %= len(actors)
self.current_turn_index %= len(self.turn_order)
else: else:
self.current_turn_index = 0 self.current_turn_index = 0
@ -147,6 +188,8 @@ class GameEngine:
async def reset(self) -> None: async def reset(self) -> None:
async with self._lock: async with self._lock:
self.players.clear() self.players.clear()
self.parties.clear()
self.invites.clear()
self.turn_order.clear() self.turn_order.clear()
self.current_turn_index = 0 self.current_turn_index = 0
self.round_number = 1 self.round_number = 1
@ -155,13 +198,193 @@ class GameEngine:
async def get_board_state(self) -> BoardState: async def get_board_state(self) -> BoardState:
async with self._lock: async with self._lock:
players_list = list(self.players.values()) players_list = list(self.players.values())
parties_list = list(self.parties.values())
return BoardState( return BoardState(
config=self.config, config=self.config,
player_count=len(players_list), player_count=len(players_list),
players=players_list, players=players_list,
parties=parties_list,
turn=self._get_turn_info(), 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( def _check_move_internal(
self, self,
player: Player, player: Player,
@ -170,16 +393,56 @@ class GameEngine:
direction_name: str, direction_name: str,
occupied_map: Dict[Tuple[int, int], Player], occupied_map: Dict[Tuple[int, int], Player],
) -> MoveCheckResult: ) -> 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_x = player.x + dx
target_y = player.y + dy 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( return MoveCheckResult(
direction=direction_name, direction=direction_name,
dx=dx, dx=dx,
@ -187,10 +450,9 @@ class GameEngine:
target_x=target_x, target_x=target_x,
target_y=target_y, target_y=target_y,
available=False, 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)) occupant = occupied_map.get((target_x, target_y))
if occupant is not None and occupant.id != player.id: if occupant is not None and occupant.id != player.id:
return MoveCheckResult( return MoveCheckResult(
@ -219,10 +481,24 @@ class GameEngine:
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") 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(" ", "_") normalized = direction_input.strip().upper().replace(" ", "_")
if normalized not in DIRECTION_OFFSETS: if normalized not in DIRECTION_OFFSETS:
raise ValueError( 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] dx, dy = DIRECTION_OFFSETS[normalized]
@ -239,8 +515,23 @@ class GameEngine:
is_turn = current_turn_player is not None and current_turn_player.id == player_id is_turn = current_turn_player is not None and current_turn_player.id == player_id
occupied = self._get_occupied_coordinates() 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] = {} moves: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS: for name, dx, dy in STANDARD_DIRECTIONS:
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) moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
return AvailableMovesResponse( return AvailableMovesResponse(
@ -248,17 +539,30 @@ class GameEngine:
player_name=player.name, player_name=player.name,
current_x=player.x, current_x=player.x,
current_y=player.y, 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, current_turn_player_id=current_turn_player.id if current_turn_player else None,
moves=moves, 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: async with self._lock:
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") 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() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody" curr_name = current_turn_player.name if current_turn_player else "Nobody"
@ -274,11 +578,24 @@ class GameEngine:
raise ValueError(f"Illegal move: {check.reason}") raise ValueError(f"Illegal move: {check.reason}")
prev_pos = {"x": player.x, "y": player.y} prev_pos = {"x": player.x, "y": player.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.x = check.target_x
player.y = check.target_y player.y = check.target_y
affected_players.append(player)
new_pos = {"x": player.x, "y": player.y} new_pos = {"x": player.x, "y": player.y}
# Advance turn order to next player
self._advance_turn() self._advance_turn()
turn_info = self._get_turn_info() turn_info = self._get_turn_info()
@ -286,6 +603,8 @@ class GameEngine:
success=True, success=True,
player=player, player=player,
direction=direction_name, direction=direction_name,
party_moved=bool(player.party_id),
affected_players=affected_players,
previous_position=prev_pos, previous_position=prev_pos,
new_position=new_pos, new_position=new_pos,
turn=turn_info, turn=turn_info,
@ -308,6 +627,176 @@ class GameEngine:
self._advance_turn() self._advance_turn()
return self._get_turn_info() 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 # Global game engine instance
game_engine = GameEngine() game_engine = GameEngine()

View File

@ -65,7 +65,6 @@ class MoveRequest(BaseModel):
if self.dx is not None and self.dy is not None: if self.dx is not None and self.dy is not None:
if self.dx == 0 and self.dy == 0: 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.") 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(): for name, (ox, oy) in DIRECTION_OFFSETS.items():
if ox == self.dx and oy == self.dy and "_" in name: if ox == self.dx and oy == self.dy and "_" in name:
return self.dx, self.dy, name return self.dx, self.dy, name
@ -92,6 +91,9 @@ class AvailableMovesResponse(BaseModel):
current_x: int current_x: int
current_y: int current_y: int
is_turn: bool 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 current_turn_player_id: Optional[str] = None
moves: Dict[str, MoveCheckResult] moves: Dict[str, MoveCheckResult]
@ -125,9 +127,73 @@ class Player(BaseModel):
color: str color: str
x: int x: int
y: 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)) 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): class BoardConfig(BaseModel):
min_x: int = 0 min_x: int = 0
max_x: int = 64 max_x: int = 64
@ -149,6 +215,7 @@ class BoardState(BaseModel):
config: BoardConfig config: BoardConfig
player_count: int player_count: int
players: List[Player] players: List[Player]
parties: List[Party] = []
turn: TurnInfo turn: TurnInfo
@ -156,6 +223,8 @@ class MoveResponse(BaseModel):
success: bool success: bool
player: Player player: Player
direction: str direction: str
party_moved: bool = False
affected_players: List[Player] = []
previous_position: Dict[str, int] previous_position: Dict[str, int]
new_position: Dict[str, int] new_position: Dict[str, int]
turn: TurnInfo turn: TurnInfo

View File

@ -12,143 +12,166 @@ def reset_board_state():
asyncio.run(game_engine.reset()) asyncio.run(game_engine.reset())
def test_health(): def test_party_formation_and_group_movement():
client = TestClient(app) client = TestClient(app)
response = client.get("/api/health") p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
assert response.status_code == 200 p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
data = response.json()
assert data["success"] is True
# 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(): # Form party with agreed leader p1
client = TestClient(app) form_res = client.post("/api/parties", json={
res = client.post("/api/players", json={"name": "CyberBot", "color": "#00FFAA"}) "member_ids": [p1["id"], p2["id"]],
assert res.status_code == 201 "leader_id": p1["id"],
player = res.json() "name": "AlphaSquad",
assert player["name"] == "CyberBot" })
assert 0 <= player["x"] <= 64 assert form_res.status_code == 201
assert 0 <= player["y"] <= 64 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(): # Leader moves UP: Leader moves from (10,10)->(10,9), Follower moves from (10,11)->(10,10)
client = TestClient(app) res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json() assert res_move.status_code == 200
p2 = client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}).json() 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() board = client.get("/api/board").json()
assert board["turn"]["current_player_id"] == p1["id"] bots_by_id = {b["id"]: b for b in board["players"]}
assert board["turn"]["round_number"] == 1 assert bots_by_id[p1["id"]]["x"] == 10
assert bots_by_id[p1["id"]]["y"] == 9
# Bot2 cannot move yet (not their turn) assert bots_by_id[p2["id"]]["x"] == 10
res_fail = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"}) assert bots_by_id[p2["id"]]["y"] == 10
assert res_fail.status_code == 403
# Manually place Bot1 at (10, 10) for deterministic movement test
import asyncio
async def place_p1():
player = await game_engine.get_player(p1["id"])
player.x = 10
player.y = 10
asyncio.run(place_p1())
# Check available moves for Bot1
moves_res = client.get(f"/api/players/{p1['id']}/available-moves")
assert moves_res.status_code == 200
moves_data = moves_res.json()
assert moves_data["is_turn"] is True
assert moves_data["moves"]["UP"]["available"] is True
assert moves_data["moves"]["UP"]["target_x"] == 10
assert moves_data["moves"]["UP"]["target_y"] == 9
assert moves_data["moves"]["UP_RIGHT"]["target_x"] == 11
assert moves_data["moves"]["UP_RIGHT"]["target_y"] == 9
# Bot1 moves UP
move_res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 200
res_json = move_res.json()
assert res_json["new_position"]["x"] == 10
assert res_json["new_position"]["y"] == 9
# Turn should advance to Bot2
assert res_json["turn"]["current_player_id"] == p2["id"]
def test_diagonal_movement_and_turn_cycle(): def test_party_wall_collision_for_follower():
client = TestClient(app) 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 import asyncio
async def place(): async def setup_near_wall():
player = await game_engine.get_player(p1["id"]) b1 = await game_engine.get_player(p1["id"])
player.x = 20 b1.x, b1.y = 5, 1
player.y = 20 b2 = await game_engine.get_player(p2["id"])
asyncio.run(place()) b2.x, b2.y = 5, 0
asyncio.run(setup_near_wall())
# Move diagonal UP_LEFT client.post("/api/parties", json={
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP_LEFT"}) "member_ids": [p1["id"], p2["id"]],
assert res.status_code == 200 "leader_id": p1["id"],
assert res.json()["new_position"] == {"x": 19, "y": 19} })
# Move diagonal DOWN_RIGHT (single player cycle wrapped around) # Available moves check should mark UP as blocked due to follower hitting wall
res2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "SE"}) moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
assert res2.status_code == 200 assert moves["moves"]["UP"]["available"] is False
assert res2.json()["new_position"] == {"x": 20, "y": 20} assert "boundary wall" in moves["moves"]["UP"]["reason"]
# Trying to move UP should fail
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)
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}) res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert res.status_code == 400 assert res.status_code == 400
assert "Illegal move" in res.json()["detail"] assert "Illegal move" in res.json()["detail"]
def test_player_collision(): def test_party_defeat_and_leader_respawn():
client = TestClient(app) client = TestClient(app)
p1 = client.post("/api/players", json={"name": "Attacker", "color": "#123456"}).json() p1 = client.post("/api/players", json={"name": "LeaderToDie", "color": "#AA0000"}).json()
p2 = client.post("/api/players", json={"name": "Defender", "color": "#654321"}).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 import asyncio
async def place_neighbors(): async def place_trio():
p_att = await game_engine.get_player(p1["id"]) b1 = await game_engine.get_player(p1["id"])
p_att.x = 10 b1.x, b1.y = 20, 20
p_att.y = 10 b2 = await game_engine.get_player(p2["id"])
p_def = await game_engine.get_player(p2["id"]) b2.x, b2.y = 20, 21
p_def.x = 11 b3 = await game_engine.get_player(p3["id"])
p_def.y = 10 b3.x, b3.y = 21, 21
asyncio.run(place_neighbors()) asyncio.run(place_trio())
# Attacker tries to move RIGHT into Defender's space party = client.post("/api/parties", json={
check_right = client.get(f"/api/players/{p1['id']}/check-move?direction=RIGHT") "member_ids": [p1["id"], p2["id"], p3["id"]],
assert check_right.status_code == 200 "leader_id": p1["id"],
assert check_right.json()["available"] is False }).json()
assert "occupied" in check_right.json()["reason"].lower()
move_fail = client.post(f"/api/players/{p1['id']}/move", json={"direction": "RIGHT"}) # Trigger defeat on party
assert move_fail.status_code == 400 defeat_res = client.post(f"/api/parties/{party['id']}/defeat")
assert "Illegal move" in move_fail.json()["detail"] 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) client = TestClient(app)
p1 = client.post("/api/players", json={"name": "Passer1", "color": "#111111"}).json() # Party 1 has 2 bots
p2 = client.post("/api/players", json={"name": "Passer2", "color": "#222222"}).json() 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.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

View File

@ -3,6 +3,7 @@ import { useGameSocket } from './hooks/useGameSocket';
import { BoardCanvas } from './components/BoardCanvas'; import { BoardCanvas } from './components/BoardCanvas';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { MovementControls } from './components/MovementControls'; import { MovementControls } from './components/MovementControls';
import { PartyModal } from './components/PartyModal';
import { PlayerList } from './components/PlayerList'; import { PlayerList } from './components/PlayerList';
import { RegisterModal } from './components/RegisterModal'; import { RegisterModal } from './components/RegisterModal';
@ -39,8 +40,11 @@ export function App() {
availableMoves, availableMoves,
isAutoPlaying, isAutoPlaying,
setIsAutoPlaying, setIsAutoPlaying,
lastEventMessage,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
formParty,
defeatParty,
movePlayer, movePlayer,
passTurn, passTurn,
stepActiveBotTurn, stepActiveBotTurn,
@ -48,13 +52,14 @@ export function App() {
} = useGameSocket(); } = useGameSocket();
const [isRegisterOpen, setIsRegisterOpen] = useState(false); const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
const [notification, setNotification] = useState<string | null>(null); const [notification, setNotification] = useState<string | null>(null);
const showNotification = (msg: string) => { const showNotification = (msg: string) => {
setNotification(msg); setNotification(msg);
setTimeout(() => { setTimeout(() => {
setNotification((curr) => (curr === msg ? null : curr)); setNotification((curr) => (curr === msg ? null : curr));
}, 3500); }, 4000);
}; };
const handleQuickSpawn = async () => { const handleQuickSpawn = async () => {
@ -73,7 +78,7 @@ export function App() {
}; };
const handleReset = async () => { 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 { try {
await resetBoard(); await resetBoard();
showNotification('Board cleared'); 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 ( return (
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100"> <div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
@ -122,19 +142,21 @@ export function App() {
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)} onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
/> />
{/* Sidebar Player Roster with Turn Order */} {/* Sidebar Player Roster with Turn Order & Parties */}
<PlayerList <PlayerList
boardState={boardState} boardState={boardState}
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer} onSelectPlayer={setSelectedPlayer}
onRemovePlayer={removePlayer} onRemovePlayer={removePlayer}
onOpenPartyModal={() => setIsPartyModalOpen(true)}
onDefeatParty={handleDefeatParty}
/> />
{/* Toast Notification */} {/* Live Event Feed Notification */}
{notification && ( {(lastEventMessage || 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"> <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" /> <span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
{notification} <span className="truncate">{notification || lastEventMessage}</span>
</div> </div>
)} )}
</div> </div>
@ -145,7 +167,18 @@ export function App() {
onClose={() => setIsRegisterOpen(false)} onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color) => { onRegister={async (name, color) => {
const player = await registerPlayer(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> </div>

View File

@ -98,7 +98,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.save(); ctx.save();
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
// Deep sci-fi cyberpunk background // Cyberpunk dark background
const bgGrad = ctx.createLinearGradient(0, 0, width, height); const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#090d16'); bgGrad.addColorStop(0, '#090d16');
bgGrad.addColorStop(1, '#05070c'); bgGrad.addColorStop(1, '#05070c');
@ -171,7 +171,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.strokeRect(hx, hy, cellSize, cellSize); ctx.strokeRect(hx, hy, cellSize, cellSize);
} }
// Highlight adjacent movement tiles for the active turn player // Highlight adjacent movement tiles for the active turn player / party leader
const activePlayer = boardState.players.find((p) => p.id === currentTurnId); const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
const highlightedBot = selectedPlayer || activePlayer; const highlightedBot = selectedPlayer || activePlayer;
if (highlightedBot && availableMoves && availableMoves.player_id === highlightedBot.id) { 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 // Draw active players
const now = Date.now() / 400; const now = Date.now() / 400;
const pulse = Math.sin(now) * 0.25 + 0.75; 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 isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id; const isCurrentTurn = currentTurnId === player.id;
const isHovered = hoveredPlayer?.id === 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 baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14));
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius; const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius;
// Current turn beacon aura // Active turn beacon aura
if (isCurrentTurn) { if (isCurrentTurn) {
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2); 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.lineWidth = 2;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
ctx.stroke(); ctx.stroke();
@ -224,14 +261,14 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
// Glow effect // Glow effect
ctx.save(); ctx.save();
ctx.shadowColor = player.color; ctx.shadowColor = player.color;
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8; ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : inParty ? 12 : 8;
// Outer beacon ring // Outer beacon ring
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2); ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color; ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4; ctx.globalAlpha = isSelected || isCurrentTurn || isLeader ? 0.9 : 0.4;
ctx.lineWidth = isCurrentTurn ? 2 : 1.5; ctx.lineWidth = isLeader ? 2.5 : isCurrentTurn ? 2 : 1.5;
ctx.stroke(); ctx.stroke();
// Main Player Token // Main Player Token
@ -244,30 +281,36 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
// Inner Core // Inner Core
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2); ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff'; ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff';
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();
// Label above player // Label above player
if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) { if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) {
ctx.save(); ctx.save();
ctx.font = 'bold 11px monospace'; ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center'; 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 textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12; const bgWidth = textMetrics.width + 12;
const bgHeight = 16; const bgHeight = 16;
const labelY = py - radius - 8; const labelY = py - radius - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)'; 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.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc'; ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
ctx.fillText(text, px, labelY); ctx.fillText(text, px, labelY);
ctx.restore(); ctx.restore();
} }
@ -280,7 +323,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(startX, startY, totalWidth, totalHeight); 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.font = '10px monospace';
ctx.fillStyle = '#94a3b8'; ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@ -370,14 +413,12 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
setIsDragging(false); setIsDragging(false);
}; };
// Wheel to Zoom
const handleWheel = (e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88; const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88;
setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0)); setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0));
}; };
// Click to Select Player or Coordinate
const handleClick = () => { const handleClick = () => {
if (hoveredPlayer) { if (hoveredPlayer) {
onSelectPlayer(hoveredPlayer); onSelectPlayer(hoveredPlayer);
@ -469,12 +510,18 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate"> <div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
{selectedPlayer.name} {selectedPlayer.name}
{selectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)}
{selectedPlayer.id === currentTurnId && ( {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>
<div className="text-slate-400 font-mono text-[11px]"> <div className="text-slate-400 font-mono text-[11px]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Score:{' '}
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{selectedPlayer.score}
</span>
</div> </div>
</div> </div>
<button <button

View File

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

View File

@ -6,6 +6,8 @@ interface PlayerListProps {
selectedPlayer: Player | null; selectedPlayer: Player | null;
onSelectPlayer: (player: Player) => void; onSelectPlayer: (player: Player) => void;
onRemovePlayer: (id: string) => void; onRemovePlayer: (id: string) => void;
onOpenPartyModal?: () => void;
onDefeatParty?: (partyId: string) => void;
} }
export const PlayerList: React.FC<PlayerListProps> = ({ export const PlayerList: React.FC<PlayerListProps> = ({
@ -13,24 +15,31 @@ export const PlayerList: React.FC<PlayerListProps> = ({
selectedPlayer, selectedPlayer,
onSelectPlayer, onSelectPlayer,
onRemovePlayer, onRemovePlayer,
onOpenPartyModal,
onDefeatParty,
}) => { }) => {
const { players, turn } = boardState; const { players, parties = [], turn } = boardState;
const currentTurnId = turn.current_player_id; 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 ( return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80"> <div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */} {/* Header */}
<div className="p-4 border-b border-slate-800 flex items-center justify-between"> <div className="p-4 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-400" /> <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> </div>
<span className="text-xs font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700"> <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> </span>
</div> </div>
{/* Turn Info Banner */} {/* Turn & Round Banner */}
{players.length > 0 && ( {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"> <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"> <span className="text-slate-400">
@ -42,6 +51,54 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div> </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 */} {/* Players List */}
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? ( {players.length === 0 ? (
@ -55,7 +112,8 @@ export const PlayerList: React.FC<PlayerListProps> = ({
players.map((player) => { players.map((player) => {
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === 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 ( return (
<div <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" className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
style={{ style={{
backgroundColor: player.color, backgroundColor: player.color,
boxShadow: isCurrentTurn boxShadow: isLeader
? '0 0 14px #fbbf24'
: isCurrentTurn
? '0 0 14px #f59e0b' ? '0 0 14px #f59e0b'
: `0 0 10px ${player.color}55`, : `0 0 10px ${player.color}55`,
}} }}
> >
{player.name.slice(0, 2).toUpperCase()} {player.name.slice(0, 2).toUpperCase()}
</div> </div>
{isCurrentTurn && ( {isLeader && (
<span className="absolute -top-1.5 -right-1 text-[11px] leading-none"> <span className="absolute -top-2 -right-1 text-[12px] leading-none">
👑 👑
</span> </span>
)} )}
@ -94,17 +154,23 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<span className="text-xs font-semibold text-slate-200 truncate"> <span className="text-xs font-semibold text-slate-200 truncate">
{player.name} {player.name}
</span> </span>
{orderIndex !== -1 && ( <span
<span className="text-[10px] font-mono text-slate-500"> className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
#{orderIndex + 1} 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> </span>
)}
</div> </div>
<div className="text-[11px] font-mono text-slate-400"> <div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> pos: <span className="text-emerald-400">({player.x}, {player.y})</span>
{isCurrentTurn && ( {partyName && (
<span className="ml-1.5 text-amber-400 font-bold text-[10px]"> <span className="ml-1 text-sky-400 truncate font-sans">
TURN {isLeader ? 'Leader' : 'Squad'}
</span> </span>
)} )}
</div> </div>

View File

@ -1,5 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react'; 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 = { const INITIAL_BOARD: BoardState = {
config: { config: {
@ -12,6 +21,7 @@ const INITIAL_BOARD: BoardState = {
}, },
player_count: 0, player_count: 0,
players: [], players: [],
parties: [],
turn: { turn: {
current_player_id: null, current_player_id: null,
current_player_name: null, current_player_name: null,
@ -27,6 +37,7 @@ export function useGameSocket() {
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null); const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null); const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [isAutoPlaying, setIsAutoPlaying] = useState(false);
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null); const reconnectTimeoutRef = useRef<number | null>(null);
@ -37,7 +48,10 @@ export function useGameSocket() {
const res = await fetch('/api/board'); const res = await fetch('/api/board');
if (res.ok) { if (res.ok) {
const data: BoardState = await res.json(); const data: BoardState = await res.json();
setBoardState(data); setBoardState((prev) => ({
...data,
parties: data.parties || prev.parties || [],
}));
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch board state:', err); console.error('Failed to fetch board state:', err);
@ -79,7 +93,10 @@ export function useGameSocket() {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.event === 'init') { if (data.event === 'init') {
setBoardState(data.state); setBoardState({
...data.state,
parties: data.state.parties || [],
});
} else if (data.event === 'player_joined') { } else if (data.event === 'player_joined') {
setBoardState((prev) => { setBoardState((prev) => {
const exists = prev.players.some((p) => p.id === data.player.id); const exists = prev.players.some((p) => p.id === data.player.id);
@ -94,11 +111,55 @@ export function useGameSocket() {
}; };
}); });
} else if (data.event === 'player_moved') { } 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) => ({ setBoardState((prev) => ({
...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, 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') { } else if (data.event === 'turn_passed') {
setBoardState((prev) => ({ setBoardState((prev) => ({
...prev, ...prev,
@ -111,6 +172,7 @@ export function useGameSocket() {
...prev, ...prev,
player_count: data.player_count ?? nextPlayers.length, player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers, players: nextPlayers,
parties: data.parties ?? prev.parties.filter((party) => party.member_ids.includes(data.player_id)),
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
}; };
}); });
@ -120,6 +182,7 @@ export function useGameSocket() {
...prev, ...prev,
player_count: 0, player_count: 0,
players: [], players: [],
parties: [],
turn: data.turn ?? INITIAL_BOARD.turn, turn: data.turn ?? INITIAL_BOARD.turn,
})); }));
setSelectedPlayer(null); 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 movePlayer = async (playerId: string, direction: string): Promise<MoveResponse> => {
const res = await fetch(`/api/players/${playerId}/move`, { const res = await fetch(`/api/players/${playerId}/move`, {
method: 'POST', 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 stepActiveBotTurn = useCallback(async () => {
const currentId = boardState.turn.current_player_id; const currentId = boardState.turn.current_player_id;
if (!currentId) return; if (!currentId) return;
@ -256,7 +359,6 @@ export function useGameSocket() {
const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)]; const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)];
await movePlayer(currentId, randomDir); await movePlayer(currentId, randomDir);
} else { } else {
// No moves available, pass turn
await passTurn(currentId); await passTurn(currentId);
} }
} catch (err) { } catch (err) {
@ -269,7 +371,7 @@ export function useGameSocket() {
if (isAutoPlaying) { if (isAutoPlaying) {
autoPlayIntervalRef.current = window.setInterval(() => { autoPlayIntervalRef.current = window.setInterval(() => {
stepActiveBotTurn(); stepActiveBotTurn();
}, 700); }, 750);
} else if (autoPlayIntervalRef.current) { } else if (autoPlayIntervalRef.current) {
clearInterval(autoPlayIntervalRef.current); clearInterval(autoPlayIntervalRef.current);
autoPlayIntervalRef.current = null; autoPlayIntervalRef.current = null;
@ -290,8 +392,12 @@ export function useGameSocket() {
availableMoves, availableMoves,
isAutoPlaying, isAutoPlaying,
setIsAutoPlaying, setIsAutoPlaying,
lastEventMessage,
registerPlayer, registerPlayer,
removePlayer, removePlayer,
formParty,
defeatParty,
fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
stepActiveBotTurn, stepActiveBotTurn,

View File

@ -4,9 +4,21 @@ export interface Player {
color: string; color: string;
x: number; x: number;
y: number; y: number;
score: number;
party_id?: string | null;
is_party_leader?: boolean;
created_at: string; 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 { export interface BoardConfig {
min_x: number; min_x: number;
max_x: number; max_x: number;
@ -28,6 +40,7 @@ export interface BoardState {
config: BoardConfig; config: BoardConfig;
player_count: number; player_count: number;
players: Player[]; players: Player[];
parties: Party[];
turn: TurnInfo; turn: TurnInfo;
} }
@ -52,6 +65,9 @@ export interface AvailableMovesResponse {
current_x: number; current_x: number;
current_y: number; current_y: number;
is_turn: boolean; is_turn: boolean;
is_party_leader: boolean;
party_id?: string | null;
party_member_count: number;
current_turn_player_id?: string | null; current_turn_player_id?: string | null;
moves: Record<string, MoveCheckResult>; moves: Record<string, MoveCheckResult>;
} }
@ -60,7 +76,29 @@ export interface MoveResponse {
success: boolean; success: boolean;
player: Player; player: Player;
direction: string; direction: string;
party_moved?: boolean;
affected_players?: Player[];
previous_position: { x: number; y: number }; previous_position: { x: number; y: number };
new_position: { x: number; y: number }; new_position: { x: number; y: number };
turn: TurnInfo; 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;
}