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
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/bots register with their name and custom avatar color, are placed randomly onto the board, and take turns moving around the arena.
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/bots register with their name and avatar color, find teammates, form **Parties**, take turns moving around the arena, and engage in tactical squad battles.
---
## Features
### Turn-Based Movement & Rules
- **Turn Order**: Strict round-robin turn order based on registration sequence.
- **8 Movement Directions**:
- **Cardinal**: `UP`, `DOWN`, `LEFT`, `RIGHT` (or `N`, `S`, `W`, `E`)
- **Diagonal**: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT` (or `NW`, `NE`, `SW`, `SE`)
- **Obstacle & Boundary Enforcement**:
- **Boundary Walls**: Cannot break past coordinate boundaries `[0..64, 0..64]`.
- **Collision Prevention**: Cannot move into any square occupied by another player/bot.
- **Turn Enforcement**: A bot can only move when it is their turn (`403 Forbidden` if attempted out of turn).
- **Pass Turn**: Players can skip/pass their turn if stuck or desired.
### 1. Parties & Squad Formations
- **Goal & Linked Connectivity**:
- Bots can find each other and join together as a **Party**.
- All party members must be within **1 distance** of each other (Chebyshev distance $\le 1$, including diagonals and cardinals), forming linked connections (clusters or single-file lines).
- Bots must agree on a **Party Leader**.
- **Leader Group Movement**:
- The Party Leader controls the movement for the entire squad.
- All party members translate together in the chosen direction (preserving their relative shape and linked adjacency).
- If any party member's path is blocked by an outside bot or boundary wall, the entire group move is prevented.
- Non-leader party members cannot move independently.
### REST Endpoints
### 2. Battle, Defeat & Respawn Mechanics
- **Scoring**:
- All players/bots start with a score of **0**.
- **Leader Defeat**:
- If a party is defeated in battle (or triggered via `/api/parties/{id}/defeat`), the **Party Leader is killed**:
1. Leader's score decreases by 1 (`score -= 1`).
2. Leader is removed from their prior party (`party_id = null`, `is_party_leader = false`).
3. Leader respawns at a random unoccupied position on the board.
4. A **new leader is randomly assigned** from the remainder of the party.
5. If no members remain, the party dissolves.
#### Movement & Inspection Endpoints
- `GET /api/players/{player_id}/available-moves`
- Checks all 8 movement directions for availability, target coordinates, and rejection reasons (wall collision or occupied by another bot). Also indicates if it is currently that bot's turn.
- `GET /api/players/{player_id}/check-move?direction={DIR}`
- Fast check for a single direction (e.g. `UP`, `DOWN`, `NW`, etc.). Returns `available: true/false`, target coordinates, and reason if blocked.
- `POST /api/players/{player_id}/move`
- Execute a movement. Accepts `{"direction": "UP_RIGHT"}` or `{"dx": 1, "dy": -1}`.
- Validates boundaries, occupant collisions, and turn order.
- Advances position and rotates turn to next player.
- `POST /api/players/{player_id}/pass`
- Passes the turn to the next player.
- `GET /api/turn`
- Returns current turn state (active player ID & name, round number, turn number, and player order).
#### Management Endpoints
- `POST /api/players` (or `/api/register`): Register a player with `name` and `color`.
- `GET /api/board`: Returns grid dimensions, player count, all player coordinates, and turn information.
- `GET /api/players`: Lists active players.
- `DELETE /api/players/{player_id}`: Remove a player from the arena.
- `POST /api/board/reset`: Reset the board and clear all players.
- `/ws`: Real-time WebSocket feed broadcasting movements, spawns, departures, and turn rotations.
- Interactive OpenAPI / Swagger documentation at `/docs`.
### 3. Turn-Based 8-Directional Movement
- **8 Directions**:
- Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
- Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
- **Collision & Wall Avoidance**:
- Cannot break through boundary walls `[0..64, 0..64]`.
- Cannot move into cells occupied by outside bots.
---
### Interactive Web UI (React + Vite + Tailwind CSS)
- **HTML5 Canvas 64×64 Grid**:
- Click-and-drag pan, mouse-wheel zoom, coordinate axis rulers (0 to 64), and hover coordinate tracker.
- Golden pulsing halo and crown `👑` highlighting the active turn player.
- Visual movement overlays showing free cells (green) vs blocked cells (red) around the active bot.
- **8-Directional On-Screen D-Pad**:
- Compass layout (NW, N, NE, W, PASS, E, SW, S, SE) with real-time green/red availability styling and tooltips.
- Full keyboard control: **WASD**, **Arrow keys**, or **Numpad (1-9)**, plus **Spacebar** to pass.
- **Simulation & Testing Tools**:
- **⚡ Step Bot**: Executes 1 valid random move for the active bot.
- **▶ Auto-Play**: Automatically runs bot turns in real time so you can watch them navigate the arena.
- **🎲 Quick Spawn Bot**: 1-click bot generator with fun cyber names and neon colors.
## REST API Endpoints
### Party & Battle Endpoints
- `POST /api/parties`: Form a party directly.
- Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
- Validates that members are within 1 distance of each other and agree on the leader.
- `POST /api/parties/invite`: Propose a party invite to an adjacent bot with an agreed leader.
- `POST /api/parties/invites/{invite_id}/respond`: Accept or reject party invite (`{"accept": true}`).
- `GET /api/parties`: List all active parties, leaders, and members.
- `GET /api/parties/{party_id}`: Get party details.
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
- Leader is killed, loses 1 point, leaves party, and respawns randomly.
- Remainder of party elects a new random leader.
- `POST /api/battles/fight`: Initiate a battle between adjacent bots/parties.
- Body: `{"challenger_id": "bot_1", "defender_id": "bot_2"}`
### Movement & Turn Endpoints
- `GET /api/players/{player_id}/available-moves`: Checks availability of all 8 directions (for a solo bot or the whole party if leader).
- `GET /api/players/{player_id}/check-move?direction={DIR}`: Check single direction.
- `POST /api/players/{player_id}/move`: Move bot or entire party if leader (`{"direction": "UP_RIGHT"}`).
- `POST /api/players/{player_id}/pass`: Pass turn to next player.
- `GET /api/turn`: Get current turn status, round number, and turn queue.
### Player & Board Endpoints
- `POST /api/players`: Register a new player with `name` and `color`.
- `GET /api/players`: List active players and their scores.
- `GET /api/board`: Full board state, player positions, parties, and turn data.
- `POST /api/board/reset`: Clear board, players, and parties.
- `/ws`: Real-time WebSocket connection broadcasting player joins, squad movements, party formations, defeats, and turn changes.
---
## Interactive Web UI (React 19 + Vite + Tailwind CSS)
- **Canvas Arena**:
- Laser energy beams connecting adjacent party members.
- Crown `👑` on the Party Leader and golden pulsing aura for active turn.
- Hover coordinate tracker, zoom & pan controls.
- **Controls & Modals**:
- **8-Directional D-Pad** with real-time green/red cell availability and keyboard bindings (WASD / Arrows / Numpad).
- **Form / Join Party Modal** with auto-detection of nearest adjacent bots and agreed leader selector.
- **💥 Defeat Button** on parties in the roster to test the leader death, respawn, and leadership succession flow.
- **⚡ Step Bot** & **▶ Auto-Play** simulation mode.
---
## Quick Start with Docker
### Option 1: Docker Compose (Recommended)
```bash
# Build and run with Docker Compose
docker compose up --build
```
Open [http://localhost:8000](http://localhost:8000) in your browser.
### Option 2: Docker CLI
### Running Backend Test Suite
```bash
# Build the Docker image
docker build -t botwebwars .
# Run the container
docker run -d -p 8000:8000 --name botwebwars botwebwars
```
- Webapp & Arena: [http://localhost:8000](http://localhost:8000)
- Swagger API Docs: [http://localhost:8000/docs](http://localhost:8000/docs)
---
## API Examples
### 1. Register a Bot
```bash
curl -X POST http://localhost:8000/api/players \
-H "Content-Type: application/json" \
-d '{"name": "CyberViper", "color": "#38BDF8"}'
```
### 2. Check If a Direction is Available
```bash
curl "http://localhost:8000/api/players/{player_id}/check-move?direction=UP_RIGHT"
```
Response:
```json
{
"direction": "UP_RIGHT",
"dx": 1,
"dy": -1,
"target_x": 36,
"target_y": 11,
"available": true,
"reason": null
}
```
### 3. Query All 8 Available Directions
```bash
curl "http://localhost:8000/api/players/{player_id}/available-moves"
```
### 4. Move Bot (During Their Turn)
```bash
curl -X POST "http://localhost:8000/api/players/{player_id}/move" \
-H "Content-Type: application/json" \
-d '{"direction": "UP_RIGHT"}'
```
### 5. Pass Turn
```bash
curl -X POST "http://localhost:8000/api/players/{player_id}/pass"
```
---
## Running Backend Tests
```bash
docker run --rm botwebwars pytest backend/tests
docker run --rm botwebwars:test pytest backend/tests
```

View File

@ -5,10 +5,18 @@ from app.game import game_engine
from app.models import (
ApiResponse,
AvailableMovesResponse,
BattleRequest,
BattleResult,
BoardState,
MoveCheckResult,
MoveRequest,
MoveResponse,
Party,
PartyDefeatResult,
PartyDirectForm,
PartyInvite,
PartyInviteCreate,
PartyInviteResponse,
Player,
PlayerCreate,
TurnInfo,
@ -26,6 +34,10 @@ async def health_check():
)
# ==========================================
# Player Registration & Management
# ==========================================
@router.post(
"/players",
response_model=Player,
@ -42,15 +54,14 @@ async def health_check():
async def register_player(player_in: PlayerCreate):
player = await game_engine.register_player(player_in)
board_state = await game_engine.get_board_state()
# Broadcast to all connected WebSocket clients
await manager.broadcast({
"event": "player_joined",
"player": player.model_dump(),
"player_count": board_state.player_count,
"turn": board_state.turn.model_dump(),
})
return player
@ -93,21 +104,177 @@ async def remove_player(player_id: str):
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Player '{player_id}' not found",
)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "player_left",
"player_id": player_id,
"player_count": board_state.player_count,
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return ApiResponse(
success=True,
message=f"Player '{player_id}' successfully removed",
)
# ==========================================
# Party & Squad Endpoints
# ==========================================
@router.get(
"/parties",
response_model=List[Party],
summary="List all active parties",
tags=["Parties"],
)
async def list_parties():
return await game_engine.get_all_parties()
@router.get(
"/parties/{party_id}",
response_model=Party,
summary="Get details of a specific party",
tags=["Parties"],
)
async def get_party(party_id: str):
party = await game_engine.get_party(party_id)
if not party:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Party '{party_id}' not found",
)
return party
@router.post(
"/parties",
response_model=Party,
status_code=status.HTTP_201_CREATED,
summary="Directly form a party with adjacent linked members and an agreed leader",
tags=["Parties"],
)
async def form_party(form_req: PartyDirectForm):
try:
party = await game_engine.form_party(
member_ids=form_req.member_ids,
leader_id=form_req.leader_id,
name=form_req.name,
)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "party_formed",
"party": party.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"turn": board_state.turn.model_dump(),
})
return party
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post(
"/parties/invite",
response_model=PartyInvite,
summary="Invite an adjacent bot to join/form a party with an agreed leader",
tags=["Parties"],
)
async def invite_to_party(invite_req: PartyInviteCreate):
try:
invite = await game_engine.invite_to_party(
inviter_id=invite_req.inviter_id,
invitee_id=invite_req.invitee_id,
proposed_leader_id=invite_req.proposed_leader_id,
party_name=invite_req.party_name,
)
return invite
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post(
"/parties/invites/{invite_id}/respond",
response_model=Optional[Party],
summary="Accept or reject a party invitation",
tags=["Parties"],
)
async def respond_to_invite(invite_id: str, resp: PartyInviteResponse):
try:
party = await game_engine.respond_to_invite(invite_id, resp.accept)
if party:
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "party_updated",
"party": party.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"turn": board_state.turn.model_dump(),
})
return party
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post(
"/parties/{party_id}/defeat",
response_model=PartyDefeatResult,
summary="Trigger defeat for a party: leader killed, respawns, score -1, remainder assigns new leader",
tags=["Parties"],
)
async def defeat_party(party_id: str):
try:
result = await game_engine.defeat_party(party_id)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "party_defeated",
"defeat_details": result.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return result
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
@router.post(
"/battles/fight",
response_model=BattleResult,
summary="Initiate battle between two adjacent bots/parties",
tags=["Battles"],
)
async def fight_battle(battle_req: BattleRequest):
try:
result = await game_engine.battle(battle_req.challenger_id, battle_req.defender_id)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "battle_resolved",
"battle": result.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return result
except KeyError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ==========================================
# Movement & Turn Endpoints
# ==========================================
@ -115,7 +282,7 @@ async def remove_player(player_id: str):
@router.get(
"/players/{player_id}/available-moves",
response_model=AvailableMovesResponse,
summary="Check availability of all 8 movement directions (cardinal & diagonal)",
summary="Check availability of all 8 movement directions for a bot or their party",
tags=["Movement"],
)
async def get_available_moves(player_id: str):
@ -138,7 +305,7 @@ async def check_single_move(
player_id: str,
direction: str = Query(
...,
description="Direction to check: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, E, W, NW, NE, SW, SE)",
description="Direction to check: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT",
),
):
try:
@ -158,7 +325,7 @@ async def check_single_move(
@router.post(
"/players/{player_id}/move",
response_model=MoveResponse,
summary="Move player in a cardinal or diagonal direction during their turn",
summary="Move player (or entire party if leader) in a cardinal or diagonal direction during turn",
tags=["Movement"],
)
async def move_player(player_id: str, move_req: MoveRequest):
@ -166,13 +333,16 @@ async def move_player(player_id: str, move_req: MoveRequest):
dx, dy, dir_name = move_req.get_delta()
result = await game_engine.move_player(player_id, dx, dy, dir_name)
# Broadcast move event to all connected WebSocket clients
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "player_moved",
"player": result.player.model_dump(),
"direction": result.direction,
"party_moved": result.party_moved,
"affected_players": [p.model_dump() for p in result.affected_players],
"previous_position": result.previous_position,
"new_position": result.new_position,
"players": [p.model_dump() for p in board_state.players],
"turn": result.turn.model_dump(),
})
@ -239,7 +409,7 @@ async def get_turn():
@router.get(
"/board",
response_model=BoardState,
summary="Get full board configuration and player positions",
summary="Get full board configuration, parties, and player positions",
tags=["Board"],
)
async def get_board():
@ -249,7 +419,7 @@ async def get_board():
@router.post(
"/board/reset",
response_model=ApiResponse,
summary="Reset the board and clear all players",
summary="Reset the board and clear all players and parties",
tags=["Board"],
)
async def reset_board():

View File

@ -1,18 +1,22 @@
import asyncio
import random
import uuid
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from app.config import settings
from app.models import (
AvailableMovesResponse,
BattleResult,
BoardConfig,
BoardState,
DIRECTION_OFFSETS,
MoveCheckResult,
MoveResponse,
Party,
PartyDefeatResult,
PartyInvite,
Player,
PlayerCreate,
TurnInfo,
DIRECTION_OFFSETS,
AvailableMovesResponse,
)
STANDARD_DIRECTIONS = [
@ -31,6 +35,8 @@ class GameEngine:
def __init__(self):
self._lock = asyncio.Lock()
self.players: Dict[str, Player] = {}
self.parties: Dict[str, Party] = {}
self.invites: Dict[str, PartyInvite] = {}
self.turn_order: List[str] = []
self.current_turn_index: int = 0
self.round_number: int = 1
@ -74,29 +80,43 @@ class GameEngine:
]
return random.choice(all_coords)
def _get_active_turn_actors(self) -> List[str]:
# Return actors that can take turns: party leaders and solo players
actors = []
for pid in self.turn_order:
p = self.players.get(pid)
if not p:
continue
if not p.party_id or p.is_party_leader:
actors.append(pid)
return actors
def _get_current_player(self) -> Optional[Player]:
if not self.turn_order:
actors = self._get_active_turn_actors()
if not actors:
return None
safe_index = self.current_turn_index % len(self.turn_order)
player_id = self.turn_order[safe_index]
safe_index = self.current_turn_index % len(actors)
player_id = actors[safe_index]
return self.players.get(player_id)
def _get_turn_info(self) -> TurnInfo:
current = self._get_current_player()
actors = self._get_active_turn_actors()
return TurnInfo(
current_player_id=current.id if current else None,
current_player_name=current.name if current else None,
round_number=self.round_number,
turn_number=self.turn_number,
turn_order=list(self.turn_order),
turn_order=actors,
)
def _advance_turn(self):
if not self.turn_order:
actors = self._get_active_turn_actors()
if not actors:
self.current_turn_index = 0
return
self.turn_number += 1
self.current_turn_index = (self.current_turn_index + 1) % len(self.turn_order)
self.current_turn_index = (self.current_turn_index + 1) % len(actors)
if self.current_turn_index == 0:
self.round_number += 1
@ -111,6 +131,9 @@ class GameEngine:
color=player_in.color,
x=x,
y=y,
score=0,
party_id=None,
is_party_leader=False,
)
self.players[player_id] = player
self.turn_order.append(player_id)
@ -129,24 +152,44 @@ class GameEngine:
if player_id not in self.players:
return False
player = self.players[player_id]
# If player was in a party, clean up party membership
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
if player_id in party.member_ids:
party.member_ids.remove(player_id)
if party.leader_id == player_id:
if party.member_ids:
new_leader_id = random.choice(party.member_ids)
party.leader_id = new_leader_id
new_lead = self.players.get(new_leader_id)
if new_lead:
new_lead.is_party_leader = True
party.leader_name = new_lead.name
else:
del self.parties[party.id]
elif not party.member_ids:
del self.parties[party.id]
del self.players[player_id]
if player_id in self.turn_order:
idx = self.turn_order.index(player_id)
self.turn_order.remove(player_id)
if self.turn_order:
if idx < self.current_turn_index:
self.current_turn_index -= 1
self.current_turn_index %= len(self.turn_order)
else:
self.current_turn_index = 0
actors = self._get_active_turn_actors()
if actors:
self.current_turn_index %= len(actors)
else:
self.current_turn_index = 0
return True
async def reset(self) -> None:
async with self._lock:
self.players.clear()
self.parties.clear()
self.invites.clear()
self.turn_order.clear()
self.current_turn_index = 0
self.round_number = 1
@ -155,13 +198,193 @@ class GameEngine:
async def get_board_state(self) -> BoardState:
async with self._lock:
players_list = list(self.players.values())
parties_list = list(self.parties.values())
return BoardState(
config=self.config,
player_count=len(players_list),
players=players_list,
parties=parties_list,
turn=self._get_turn_info(),
)
# ==========================================
# Party Logic & Graph Adjacency
# ==========================================
@staticmethod
def _are_adjacent(p1: Player, p2: Player) -> bool:
# Distance <= 1 (cardinal or diagonal Chebyshev distance)
return max(abs(p1.x - p2.x), abs(p1.y - p2.y)) <= 1
def _verify_party_connectivity(self, member_players: List[Player]) -> bool:
"""Verifies all party members form a single connected component where each is within 1 distance."""
if len(member_players) <= 1:
return True
visited: Set[str] = set()
queue = [member_players[0]]
visited.add(member_players[0].id)
while queue:
curr = queue.pop(0)
for other in member_players:
if other.id not in visited and self._are_adjacent(curr, other):
visited.add(other.id)
queue.append(other)
return len(visited) == len(member_players)
async def form_party(
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
) -> Party:
async with self._lock:
if len(member_ids) < 2:
raise ValueError("A party must have at least 2 members")
if leader_id not in member_ids:
raise ValueError("Agreed leader must be one of the party members")
member_players: List[Player] = []
for mid in member_ids:
p = self.players.get(mid)
if not p:
raise KeyError(f"Player '{mid}' not found")
member_players.append(p)
# Check adjacency / connected link
if not self._verify_party_connectivity(member_players):
raise ValueError(
"All party members must be linked within 1 distance of each other (directly or via connected teammates)."
)
# Clean up any existing party memberships
for p in member_players:
if p.party_id and p.party_id in self.parties:
old_party = self.parties[p.party_id]
if p.id in old_party.member_ids:
old_party.member_ids.remove(p.id)
if not old_party.member_ids:
del self.parties[old_party.id]
party_id = f"party_{uuid.uuid4().hex[:8]}"
leader = self.players[leader_id]
party_name = name or f"Squad {leader.name}"
party = Party(
id=party_id,
name=party_name,
leader_id=leader_id,
leader_name=leader.name,
member_ids=list(member_ids),
)
for p in member_players:
p.party_id = party_id
p.is_party_leader = p.id == leader_id
self.parties[party_id] = party
return party
async def invite_to_party(
self,
inviter_id: str,
invitee_id: str,
proposed_leader_id: str,
party_name: Optional[str] = None,
) -> PartyInvite:
async with self._lock:
inviter = self.players.get(inviter_id)
invitee = self.players.get(invitee_id)
if not inviter or not invitee:
raise KeyError("Inviter or invitee not found")
# Must be adjacent to inviter or to at least one member of inviter's party
eligible_hosts = [inviter]
if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id]
eligible_hosts = [self.players[mid] for mid in party.member_ids if mid in self.players]
is_adjacent = any(self._are_adjacent(invitee, host) for host in eligible_hosts)
if not is_adjacent:
raise ValueError(
f"Bot '{invitee.name}' is too far away. Must be within 1 distance of a party member to join."
)
invite_id = f"inv_{uuid.uuid4().hex[:8]}"
invite = PartyInvite(
id=invite_id,
inviter_id=inviter_id,
invitee_id=invitee_id,
proposed_leader_id=proposed_leader_id,
party_id=inviter.party_id,
)
self.invites[invite_id] = invite
return invite
async def respond_to_invite(self, invite_id: str, accept: bool) -> Optional[Party]:
async with self._lock:
invite = self.invites.get(invite_id)
if not invite:
raise KeyError(f"Invite '{invite_id}' not found")
if invite.status != "pending":
raise ValueError(f"Invite already {invite.status}")
if not accept:
invite.status = "rejected"
return None
invite.status = "accepted"
inviter = self.players.get(invite.inviter_id)
invitee = self.players.get(invite.invitee_id)
if not inviter or not invitee:
raise KeyError("Inviter or invitee no longer active")
# If inviter is already in a party, add invitee to that party
if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id]
if invitee.id not in party.member_ids:
party.member_ids.append(invitee.id)
invitee.party_id = party.id
# Set agreed leader
if invite.proposed_leader_id in party.member_ids:
party.leader_id = invite.proposed_leader_id
for mid in party.member_ids:
p = self.players.get(mid)
if p:
p.is_party_leader = p.id == party.leader_id
party.leader_name = self.players[party.leader_id].name
return party
# Otherwise form new party with both
members = [inviter.id, invitee.id]
leader_id = invite.proposed_leader_id if invite.proposed_leader_id in members else inviter.id
party_id = f"party_{uuid.uuid4().hex[:8]}"
leader = self.players[leader_id]
party = Party(
id=party_id,
name=f"Squad {leader.name}",
leader_id=leader_id,
leader_name=leader.name,
member_ids=members,
)
inviter.party_id = party_id
inviter.is_party_leader = inviter.id == leader_id
invitee.party_id = party_id
invitee.is_party_leader = invitee.id == leader_id
self.parties[party_id] = party
return party
async def get_all_parties(self) -> List[Party]:
async with self._lock:
return list(self.parties.values())
async def get_party(self, party_id: str) -> Optional[Party]:
async with self._lock:
return self.parties.get(party_id)
# ==========================================
# Movement Checking (Single bot or Party)
# ==========================================
def _check_move_internal(
self,
player: Player,
@ -170,16 +393,56 @@ class GameEngine:
direction_name: str,
occupied_map: Dict[Tuple[int, int], Player],
) -> MoveCheckResult:
# If player is part of a party and IS the leader, check if ALL members can translate by (dx, dy)
if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id]
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
party_member_ids = {m.id for m in party_members}
for m in party_members:
tx = m.x + dx
ty = m.y + dy
# Wall boundary
if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).",
)
# Collision with non-party bot
occupant = occupied_map.get((tx, ty))
if occupant is not None and occupant.id not in party_member_ids:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Party member '{m.name}' path blocked by '{occupant.name}' at ({tx}, {ty}).",
)
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=True,
reason=None,
)
# Single player or non-leader
target_x = player.x + dx
target_y = player.y + dy
# 1. Boundary Wall Check
if (
target_x < self.config.min_x
or target_x > self.config.max_x
or target_y < self.config.min_y
or target_y > self.config.max_y
):
if target_x < self.config.min_x or target_x > self.config.max_x or target_y < self.config.min_y or target_y > self.config.max_y:
return MoveCheckResult(
direction=direction_name,
dx=dx,
@ -187,10 +450,9 @@ class GameEngine:
target_x=target_x,
target_y=target_y,
available=False,
reason=f"Wall collision at ({target_x}, {target_y}). Grid boundaries are [{self.config.min_x}..{self.config.max_x}, {self.config.min_y}..{self.config.max_y}].",
reason=f"Wall collision at ({target_x}, {target_y}).",
)
# 2. Occupancy Check
occupant = occupied_map.get((target_x, target_y))
if occupant is not None and occupant.id != player.id:
return MoveCheckResult(
@ -219,10 +481,24 @@ class GameEngine:
if not player:
raise KeyError(f"Player '{player_id}' not found")
# Non-leaders cannot move independently
if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id)
leader_name = party.leader_name if party else "Leader"
return MoveCheckResult(
direction=direction_input,
dx=0,
dy=0,
target_x=player.x,
target_y=player.y,
available=False,
reason=f"Only party leader '{leader_name}' controls movement for the party.",
)
normalized = direction_input.strip().upper().replace(" ", "_")
if normalized not in DIRECTION_OFFSETS:
raise ValueError(
f"Unknown direction '{direction_input}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT (or N, S, W, E, NW, NE, SW, SE)"
f"Unknown direction '{direction_input}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT"
)
dx, dy = DIRECTION_OFFSETS[normalized]
@ -239,26 +515,54 @@ class GameEngine:
is_turn = current_turn_player is not None and current_turn_player.id == player_id
occupied = self._get_occupied_coordinates()
party = self.parties.get(player.party_id) if player.party_id else None
is_leader = player.is_party_leader or party is None
member_count = len(party.member_ids) if party else 1
moves: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS:
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
if player.party_id and not player.is_party_leader:
moves[name] = MoveCheckResult(
direction=name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Only party leader '{party.leader_name if party else 'Leader'}' controls group movement.",
)
else:
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
return AvailableMovesResponse(
player_id=player.id,
player_name=player.name,
current_x=player.x,
current_y=player.y,
is_turn=is_turn,
is_turn=is_turn and is_leader,
is_party_leader=player.is_party_leader,
party_id=player.party_id,
party_member_count=member_count,
current_turn_player_id=current_turn_player.id if current_turn_player else None,
moves=moves,
)
async def move_player(self, player_id: str, dx: int, dy: int, direction_name: str) -> MoveResponse:
async def move_player(
self, player_id: str, dx: int, dy: int, direction_name: str
) -> MoveResponse:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
# Non-leaders cannot move
if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id)
leader_name = party.leader_name if party else "Leader"
raise PermissionError(
f"Party member '{player.name}' cannot move individually. Only party leader '{leader_name}' controls movement for the party."
)
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody"
@ -274,11 +578,24 @@ class GameEngine:
raise ValueError(f"Illegal move: {check.reason}")
prev_pos = {"x": player.x, "y": player.y}
player.x = check.target_x
player.y = check.target_y
affected_players: List[Player] = []
# If party leader, move all party members in unison
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
for mid in party.member_ids:
m = self.players.get(mid)
if m:
m.x += dx
m.y += dy
affected_players.append(m)
else:
player.x = check.target_x
player.y = check.target_y
affected_players.append(player)
new_pos = {"x": player.x, "y": player.y}
# Advance turn order to next player
self._advance_turn()
turn_info = self._get_turn_info()
@ -286,6 +603,8 @@ class GameEngine:
success=True,
player=player,
direction=direction_name,
party_moved=bool(player.party_id),
affected_players=affected_players,
previous_position=prev_pos,
new_position=new_pos,
turn=turn_info,
@ -308,6 +627,176 @@ class GameEngine:
self._advance_turn()
return self._get_turn_info()
# ==========================================
# Battle & Party Defeat Logic
# ==========================================
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
"""Kills the party leader: score -= 1, respawns at random position, removed from party,
and randomly assigns a new leader from the remainder of the party.
"""
async with self._lock:
party = self.parties.get(party_id)
if not party:
raise KeyError(f"Party '{party_id}' not found")
old_leader_id = party.leader_id
leader = self.players.get(old_leader_id)
if not leader:
raise KeyError(f"Leader '{old_leader_id}' not found")
# 1. Leader killed: score goes down by 1
leader.score -= 1
# 2. Leader is removed from party
leader.party_id = None
leader.is_party_leader = False
if old_leader_id in party.member_ids:
party.member_ids.remove(old_leader_id)
# 3. Leader respawns at random free position
rx, ry = self._find_random_free_position()
leader.x = rx
leader.y = ry
respawn_pos = {"x": rx, "y": ry}
# 4. New leader is randomly assigned from the remainder of the party
new_leader_id = None
new_leader_name = None
party_dissolved = False
if len(party.member_ids) > 0:
new_leader_id = random.choice(party.member_ids)
party.leader_id = new_leader_id
new_leader = self.players[new_leader_id]
new_leader.is_party_leader = True
new_leader_name = new_leader.name
party.leader_name = new_leader_name
else:
# No members left in party
del self.parties[party_id]
party_dissolved = True
return PartyDefeatResult(
party_id=party_id,
killed_leader_id=leader.id,
killed_leader_name=leader.name,
killed_leader_new_score=leader.score,
killed_leader_respawn_position=respawn_pos,
new_leader_id=new_leader_id,
new_leader_name=new_leader_name,
remaining_members=list(party.member_ids),
party_dissolved=party_dissolved,
)
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
"""Resolves a battle between two adjacent players/bots.
The losing party's leader is killed, respawns, score -1, and remainder assigns new leader.
"""
async with self._lock:
p1 = self.players.get(challenger_id)
p2 = self.players.get(defender_id)
if not p1 or not p2:
raise KeyError("Both combatants must exist")
if p1.party_id and p1.party_id == p2.party_id:
raise ValueError("Cannot battle your own party member")
if not self._are_adjacent(p1, p2):
raise ValueError(
f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))"
)
# Party size calculation for battle advantage
size1 = len(self.parties[p1.party_id].member_ids) if p1.party_id and p1.party_id in self.parties else 1
size2 = len(self.parties[p2.party_id].member_ids) if p2.party_id and p2.party_id in self.parties else 1
if size1 > size2:
winner, loser = p1, p2
elif size2 > size1:
winner, loser = p2, p1
else:
# Tied party sizes: 50/50 roll
if random.random() < 0.5:
winner, loser = p1, p2
else:
winner, loser = p2, p1
# Winner score +1
winner.score += 1
# Resolve defeat for loser
if loser.party_id and loser.party_id in self.parties:
# Must release lock temporarily or run defeat internally
party_id = loser.party_id
party = self.parties[party_id]
old_leader_id = party.leader_id
leader = self.players[old_leader_id]
leader.score -= 1
leader.party_id = None
leader.is_party_leader = False
if old_leader_id in party.member_ids:
party.member_ids.remove(old_leader_id)
rx, ry = self._find_random_free_position()
leader.x = rx
leader.y = ry
respawn_pos = {"x": rx, "y": ry}
new_leader_id = None
new_leader_name = None
party_dissolved = False
if len(party.member_ids) > 0:
new_leader_id = random.choice(party.member_ids)
party.leader_id = new_leader_id
new_leader = self.players[new_leader_id]
new_leader.is_party_leader = True
new_leader_name = new_leader.name
party.leader_name = new_leader_name
else:
del self.parties[party_id]
party_dissolved = True
defeat_result = PartyDefeatResult(
party_id=party_id,
killed_leader_id=leader.id,
killed_leader_name=leader.name,
killed_leader_new_score=leader.score,
killed_leader_respawn_position=respawn_pos,
new_leader_id=new_leader_id,
new_leader_name=new_leader_name,
remaining_members=list(party.member_ids),
party_dissolved=party_dissolved,
)
else:
# Solo loser
loser.score -= 1
rx, ry = self._find_random_free_position()
loser.x = rx
loser.y = ry
defeat_result = PartyDefeatResult(
party_id="none",
killed_leader_id=loser.id,
killed_leader_name=loser.name,
killed_leader_new_score=loser.score,
killed_leader_respawn_position={"x": rx, "y": ry},
new_leader_id=None,
new_leader_name=None,
remaining_members=[],
party_dissolved=True,
)
return BattleResult(
winner_id=winner.id,
winner_name=winner.name,
loser_id=loser.id,
loser_name=loser.name,
defeat_details=defeat_result,
)
# Global game engine instance
game_engine = GameEngine()

View File

@ -65,7 +65,6 @@ class MoveRequest(BaseModel):
if self.dx is not None and self.dy is not None:
if self.dx == 0 and self.dy == 0:
raise ValueError("Movement delta (dx=0, dy=0) is not a valid move. Use pass if you wish to skip.")
# Find matching direction name
for name, (ox, oy) in DIRECTION_OFFSETS.items():
if ox == self.dx and oy == self.dy and "_" in name:
return self.dx, self.dy, name
@ -92,6 +91,9 @@ class AvailableMovesResponse(BaseModel):
current_x: int
current_y: int
is_turn: bool
is_party_leader: bool = False
party_id: Optional[str] = None
party_member_count: int = 1
current_turn_player_id: Optional[str] = None
moves: Dict[str, MoveCheckResult]
@ -125,9 +127,73 @@ class Player(BaseModel):
color: str
x: int
y: int
score: int = 0
party_id: Optional[str] = None
is_party_leader: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Party(BaseModel):
id: str
name: str
leader_id: str
leader_name: str
member_ids: List[str]
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PartyInviteCreate(BaseModel):
inviter_id: str
invitee_id: str
proposed_leader_id: str
party_name: Optional[str] = None
class PartyInvite(BaseModel):
id: str
inviter_id: str
invitee_id: str
proposed_leader_id: str
party_id: Optional[str] = None
status: str = "pending" # pending, accepted, rejected
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PartyInviteResponse(BaseModel):
accept: bool
class PartyDirectForm(BaseModel):
member_ids: List[str] = Field(..., min_length=2, description="List of player IDs to form a party")
leader_id: str = Field(..., description="Agreed leader ID (must be one of the member_ids)")
name: Optional[str] = None
class PartyDefeatResult(BaseModel):
party_id: str
killed_leader_id: str
killed_leader_name: str
killed_leader_new_score: int
killed_leader_respawn_position: Dict[str, int]
new_leader_id: Optional[str] = None
new_leader_name: Optional[str] = None
remaining_members: List[str] = []
party_dissolved: bool = False
class BattleRequest(BaseModel):
challenger_id: str
defender_id: str
class BattleResult(BaseModel):
winner_id: str
winner_name: str
loser_id: str
loser_name: str
defeat_details: PartyDefeatResult
class BoardConfig(BaseModel):
min_x: int = 0
max_x: int = 64
@ -149,6 +215,7 @@ class BoardState(BaseModel):
config: BoardConfig
player_count: int
players: List[Player]
parties: List[Party] = []
turn: TurnInfo
@ -156,6 +223,8 @@ class MoveResponse(BaseModel):
success: bool
player: Player
direction: str
party_moved: bool = False
affected_players: List[Player] = []
previous_position: Dict[str, int]
new_position: Dict[str, int]
turn: TurnInfo

View File

@ -12,143 +12,166 @@ def reset_board_state():
asyncio.run(game_engine.reset())
def test_health():
def test_party_formation_and_group_movement():
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
# Place adjacent: Leader at (10, 10), Follower at (10, 11) (within distance 1)
import asyncio
async def place_adjacent():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 10, 10
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 10, 11
asyncio.run(place_adjacent())
def test_register_player():
client = TestClient(app)
res = client.post("/api/players", json={"name": "CyberBot", "color": "#00FFAA"})
assert res.status_code == 201
player = res.json()
assert player["name"] == "CyberBot"
assert 0 <= player["x"] <= 64
assert 0 <= player["y"] <= 64
# Form party with agreed leader p1
form_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
"name": "AlphaSquad",
})
assert form_res.status_code == 201
party = form_res.json()
assert party["leader_id"] == p1["id"]
assert len(party["member_ids"]) == 2
# Follower cannot move individually
res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
assert res_follow.status_code == 403
assert "Only party leader" in res_follow.json()["detail"]
def test_turn_order_and_movement():
client = TestClient(app)
p1 = client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"}).json()
p2 = client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"}).json()
# Leader moves UP: Leader moves from (10,10)->(10,9), Follower moves from (10,11)->(10,10)
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert res_move.status_code == 200
move_data = res_move.json()
assert move_data["party_moved"] is True
assert len(move_data["affected_players"]) == 2
# Board state should show Bot1 as active turn
# Check positions in board
board = client.get("/api/board").json()
assert board["turn"]["current_player_id"] == p1["id"]
assert board["turn"]["round_number"] == 1
# Bot2 cannot move yet (not their turn)
res_fail = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
assert res_fail.status_code == 403
# Manually place Bot1 at (10, 10) for deterministic movement test
import asyncio
async def place_p1():
player = await game_engine.get_player(p1["id"])
player.x = 10
player.y = 10
asyncio.run(place_p1())
# Check available moves for Bot1
moves_res = client.get(f"/api/players/{p1['id']}/available-moves")
assert moves_res.status_code == 200
moves_data = moves_res.json()
assert moves_data["is_turn"] is True
assert moves_data["moves"]["UP"]["available"] is True
assert moves_data["moves"]["UP"]["target_x"] == 10
assert moves_data["moves"]["UP"]["target_y"] == 9
assert moves_data["moves"]["UP_RIGHT"]["target_x"] == 11
assert moves_data["moves"]["UP_RIGHT"]["target_y"] == 9
# Bot1 moves UP
move_res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 200
res_json = move_res.json()
assert res_json["new_position"]["x"] == 10
assert res_json["new_position"]["y"] == 9
# Turn should advance to Bot2
assert res_json["turn"]["current_player_id"] == p2["id"]
bots_by_id = {b["id"]: b for b in board["players"]}
assert bots_by_id[p1["id"]]["x"] == 10
assert bots_by_id[p1["id"]]["y"] == 9
assert bots_by_id[p2["id"]]["x"] == 10
assert bots_by_id[p2["id"]]["y"] == 10
def test_diagonal_movement_and_turn_cycle():
def test_party_wall_collision_for_follower():
client = TestClient(app)
p1 = client.post("/api/players", json={"name": "DiagBot", "color": "#123456"}).json()
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
# Place Leader at (5, 1), Follower at (5, 0)
# Moving UP would push Follower to (5, -1) which hits the wall!
import asyncio
async def place():
player = await game_engine.get_player(p1["id"])
player.x = 20
player.y = 20
asyncio.run(place())
async def setup_near_wall():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 5, 1
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 5, 0
asyncio.run(setup_near_wall())
# Move diagonal UP_LEFT
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP_LEFT"})
assert res.status_code == 200
assert res.json()["new_position"] == {"x": 19, "y": 19}
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
})
# Move diagonal DOWN_RIGHT (single player cycle wrapped around)
res2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "SE"})
assert res2.status_code == 200
assert res2.json()["new_position"] == {"x": 20, "y": 20}
# Available moves check should mark UP as blocked due to follower hitting wall
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
assert moves["moves"]["UP"]["available"] is False
assert "boundary wall" in moves["moves"]["UP"]["reason"]
def test_wall_collision():
client = TestClient(app)
p1 = client.post("/api/players", json={"name": "CornerBot", "color": "#123456"}).json()
import asyncio
async def place_corner():
player = await game_engine.get_player(p1["id"])
player.x = 0
player.y = 0
asyncio.run(place_corner())
# Query single move check
check_up = client.get(f"/api/players/{p1['id']}/check-move?direction=UP")
assert check_up.status_code == 200
assert check_up.json()["available"] is False
assert "Wall collision" in check_up.json()["reason"]
# Attempt to move UP through boundary wall (y < 0)
# Trying to move UP should fail
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert res.status_code == 400
assert "Illegal move" in res.json()["detail"]
def test_player_collision():
def test_party_defeat_and_leader_respawn():
client = TestClient(app)
p1 = client.post("/api/players", json={"name": "Attacker", "color": "#123456"}).json()
p2 = client.post("/api/players", json={"name": "Defender", "color": "#654321"}).json()
p1 = client.post("/api/players", json={"name": "LeaderToDie", "color": "#AA0000"}).json()
p2 = client.post("/api/players", json={"name": "Survivor1", "color": "#00AA00"}).json()
p3 = client.post("/api/players", json={"name": "Survivor2", "color": "#0000AA"}).json()
import asyncio
async def place_neighbors():
p_att = await game_engine.get_player(p1["id"])
p_att.x = 10
p_att.y = 10
p_def = await game_engine.get_player(p2["id"])
p_def.x = 11
p_def.y = 10
asyncio.run(place_neighbors())
async def place_trio():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 20, 20
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 20, 21
b3 = await game_engine.get_player(p3["id"])
b3.x, b3.y = 21, 21
asyncio.run(place_trio())
# Attacker tries to move RIGHT into Defender's space
check_right = client.get(f"/api/players/{p1['id']}/check-move?direction=RIGHT")
assert check_right.status_code == 200
assert check_right.json()["available"] is False
assert "occupied" in check_right.json()["reason"].lower()
party = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"], p3["id"]],
"leader_id": p1["id"],
}).json()
move_fail = client.post(f"/api/players/{p1['id']}/move", json={"direction": "RIGHT"})
assert move_fail.status_code == 400
assert "Illegal move" in move_fail.json()["detail"]
# Trigger defeat on party
defeat_res = client.post(f"/api/parties/{party['id']}/defeat")
assert defeat_res.status_code == 200
data = defeat_res.json()
# 1. Leader was killed, score is -1
assert data["killed_leader_id"] == p1["id"]
assert data["killed_leader_new_score"] == -1
# 2. Leader respawned position is recorded
assert "x" in data["killed_leader_respawn_position"]
# 3. New leader randomly assigned from remaining members
assert data["new_leader_id"] in [p2["id"], p3["id"]]
assert len(data["remaining_members"]) == 2
# Verify leader is no longer part of the party
dead_leader = client.get(f"/api/players/{p1['id']}").json()
assert dead_leader["party_id"] is None
assert dead_leader["is_party_leader"] is False
assert dead_leader["score"] == -1
# Verify new leader in party
updated_party = client.get(f"/api/parties/{party['id']}").json()
assert updated_party["leader_id"] == data["new_leader_id"]
assert p1["id"] not in updated_party["member_ids"]
def test_pass_turn():
def test_battle_between_parties():
client = TestClient(app)
p1 = client.post("/api/players", json={"name": "Passer1", "color": "#111111"}).json()
p2 = client.post("/api/players", json={"name": "Passer2", "color": "#222222"}).json()
# Party 1 has 2 bots
p1 = client.post("/api/players", json={"name": "Warrior1", "color": "#111111"}).json()
p2 = client.post("/api/players", json={"name": "Warrior2", "color": "#222222"}).json()
# Party 2 has 1 solo bot
p3 = client.post("/api/players", json={"name": "SoloDefender", "color": "#333333"}).json()
res = client.post(f"/api/players/{p1['id']}/pass")
import asyncio
async def place_combatants():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 30, 30
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 30, 31
b3 = await game_engine.get_player(p3["id"])
b3.x, b3.y = 31, 30 # adjacent to b1
asyncio.run(place_combatants())
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
})
# Initiate battle: 2-bot party vs 1-bot solo -> Party 1 wins
res = client.post("/api/battles/fight", json={
"challenger_id": p1["id"],
"defender_id": p3["id"],
})
assert res.status_code == 200
assert res.json()["current_player_id"] == p2["id"]
battle = res.json()
assert battle["winner_id"] == p1["id"]
assert battle["loser_id"] == p3["id"]
# Check loser score = -1
loser_bot = client.get(f"/api/players/{p3['id']}").json()
assert loser_bot["score"] == -1
# Check winner score = +1
winner_bot = client.get(f"/api/players/{p1['id']}").json()
assert winner_bot["score"] == 1

View File

@ -3,6 +3,7 @@ import { useGameSocket } from './hooks/useGameSocket';
import { BoardCanvas } from './components/BoardCanvas';
import { Header } from './components/Header';
import { MovementControls } from './components/MovementControls';
import { PartyModal } from './components/PartyModal';
import { PlayerList } from './components/PlayerList';
import { RegisterModal } from './components/RegisterModal';
@ -39,8 +40,11 @@ export function App() {
availableMoves,
isAutoPlaying,
setIsAutoPlaying,
lastEventMessage,
registerPlayer,
removePlayer,
formParty,
defeatParty,
movePlayer,
passTurn,
stepActiveBotTurn,
@ -48,13 +52,14 @@ export function App() {
} = useGameSocket();
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
const [notification, setNotification] = useState<string | null>(null);
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => {
setNotification((curr) => (curr === msg ? null : curr));
}, 3500);
}, 4000);
};
const handleQuickSpawn = async () => {
@ -73,7 +78,7 @@ export function App() {
};
const handleReset = async () => {
if (window.confirm('Are you sure you want to clear all bots from the board?')) {
if (window.confirm('Are you sure you want to clear all bots and parties from the board?')) {
try {
await resetBoard();
showNotification('Board cleared');
@ -85,6 +90,21 @@ export function App() {
}
};
const handleDefeatParty = async (partyId: string) => {
try {
const res = await defeatParty(partyId);
showNotification(
`Defeat! Leader ${res.killed_leader_name} was killed (-1 pt) & respawned. ${
res.new_leader_name ? `New leader: ${res.new_leader_name}` : 'Party dissolved.'
}`
);
} catch (err: unknown) {
if (err instanceof Error) {
showNotification(`Error: ${err.message}`);
}
}
};
return (
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Top Navigation Bar */}
@ -122,19 +142,21 @@ export function App() {
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
/>
{/* Sidebar Player Roster with Turn Order */}
{/* Sidebar Player Roster with Turn Order & Parties */}
<PlayerList
boardState={boardState}
selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer}
onRemovePlayer={removePlayer}
onOpenPartyModal={() => setIsPartyModalOpen(true)}
onDefeatParty={handleDefeatParty}
/>
{/* Toast Notification */}
{notification && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-slate-900 border border-sky-500/50 text-sky-200 px-4 py-2 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-400" />
{notification}
{/* Live Event Feed Notification */}
{(lastEventMessage || notification) && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-slate-900/95 border border-sky-500/60 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 max-w-lg truncate">
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
<span className="truncate">{notification || lastEventMessage}</span>
</div>
)}
</div>
@ -145,7 +167,18 @@ export function App() {
onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color) => {
const player = await registerPlayer(name, color);
showNotification(`Deployed ${player.name} at coordinate (${player.x}, ${player.y})!`);
showNotification(`Deployed ${player.name} at (${player.x}, ${player.y})!`);
}}
/>
{/* Party Formation Modal */}
<PartyModal
isOpen={isPartyModalOpen}
boardState={boardState}
onClose={() => setIsPartyModalOpen(false)}
onFormParty={async (memberIds, leaderId, name) => {
const party = await formParty(memberIds, leaderId, name);
showNotification(`Party "${party.name}" established under leader ${party.leader_name}!`);
}}
/>
</div>

View File

@ -98,7 +98,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.save();
ctx.scale(dpr, dpr);
// Deep sci-fi cyberpunk background
// Cyberpunk dark background
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#090d16');
bgGrad.addColorStop(1, '#05070c');
@ -171,14 +171,14 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.strokeRect(hx, hy, cellSize, cellSize);
}
// Highlight adjacent movement tiles for the active turn player
// Highlight adjacent movement tiles for the active turn player / party leader
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
const highlightedBot = selectedPlayer || activePlayer;
if (highlightedBot && availableMoves && availableMoves.player_id === highlightedBot.id) {
Object.values(availableMoves.moves).forEach((move) => {
const mx = startX + (move.target_x - min_x) * cellSize - cellSize / 2;
const my = startY + (move.target_y - min_y) * cellSize - cellSize / 2;
if (move.available) {
ctx.fillStyle = 'rgba(16, 185, 129, 0.12)';
ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)';
@ -195,6 +195,41 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
});
}
// Draw Party Links (Beams connecting adjacent party members)
const playerMap = new Map<string, Player>();
boardState.players.forEach((p) => playerMap.set(p.id, p));
(boardState.parties || []).forEach((party) => {
const members = party.member_ids.map((id) => playerMap.get(id)).filter(Boolean) as Player[];
ctx.save();
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 2.5;
ctx.setLineDash([4, 3]);
ctx.shadowColor = '#38bdf8';
ctx.shadowBlur = 8;
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const p1 = members[i];
const p2 = members[j];
// If adjacent (within 1 step)
if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) {
const x1 = startX + (p1.x - min_x) * cellSize;
const y1 = startY + (p1.y - min_y) * cellSize;
const x2 = startX + (p2.x - min_x) * cellSize;
const y2 = startY + (p2.y - min_y) * cellSize;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
});
// Draw active players
const now = Date.now() / 400;
const pulse = Math.sin(now) * 0.25 + 0.75;
@ -205,16 +240,18 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const isHovered = hoveredPlayer?.id === player.id;
const isLeader = player.is_party_leader;
const inParty = Boolean(player.party_id);
const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14));
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius;
// Current turn beacon aura
// Active turn beacon aura
if (isCurrentTurn) {
ctx.save();
ctx.beginPath();
ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2);
ctx.strokeStyle = '#fbbf24'; // Golden glow for active turn
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.stroke();
@ -224,14 +261,14 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
// Glow effect
ctx.save();
ctx.shadowColor = player.color;
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8;
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : inParty ? 12 : 8;
// Outer beacon ring
ctx.beginPath();
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4;
ctx.lineWidth = isCurrentTurn ? 2 : 1.5;
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
ctx.globalAlpha = isSelected || isCurrentTurn || isLeader ? 0.9 : 0.4;
ctx.lineWidth = isLeader ? 2.5 : isCurrentTurn ? 2 : 1.5;
ctx.stroke();
// Main Player Token
@ -244,30 +281,36 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
// Inner Core
ctx.beginPath();
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff';
ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff';
ctx.fill();
ctx.restore();
// Label above player
if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) {
if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) {
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
const text = isCurrentTurn ? `👑 ${player.name}` : player.name;
let prefix = '';
if (isLeader) prefix = '👑 ';
else if (inParty) prefix = '🔗 ';
const scoreText = `[${player.score >= 0 ? `+${player.score}` : player.score}]`;
const text = `${prefix}${player.name} ${scoreText}`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = py - radius - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc';
ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
ctx.fillText(text, px, labelY);
ctx.restore();
}
@ -280,7 +323,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.lineWidth = 2;
ctx.strokeRect(startX, startY, totalWidth, totalHeight);
// Draw Coordinate Axis Rulers (0, 8, 16, 24, 32, 40, 48, 56, 64)
// Coordinate Axis Rulers
ctx.font = '10px monospace';
ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center';
@ -370,14 +413,12 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
setIsDragging(false);
};
// Wheel to Zoom
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88;
setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0));
};
// Click to Select Player or Coordinate
const handleClick = () => {
if (hoveredPlayer) {
onSelectPlayer(hoveredPlayer);
@ -469,12 +510,18 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
{selectedPlayer.name}
{selectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)}
{selectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-amber-400 font-mono">👑 Turn</span>
<span className="text-[10px] text-emerald-400 font-mono"> Turn</span>
)}
</div>
<div className="text-slate-400 font-mono text-[11px]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y})
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Score:{' '}
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{selectedPlayer.score}
</span>
</div>
</div>
<button

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;
onSelectPlayer: (player: Player) => void;
onRemovePlayer: (id: string) => void;
onOpenPartyModal?: () => void;
onDefeatParty?: (partyId: string) => void;
}
export const PlayerList: React.FC<PlayerListProps> = ({
@ -13,24 +15,31 @@ export const PlayerList: React.FC<PlayerListProps> = ({
selectedPlayer,
onSelectPlayer,
onRemovePlayer,
onOpenPartyModal,
onDefeatParty,
}) => {
const { players, turn } = boardState;
const { players, parties = [], turn } = boardState;
const currentTurnId = turn.current_player_id;
const partyMap = new Map<string, string>();
parties.forEach((p) => {
p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
});
return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */}
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-400" />
<h3 className="font-semibold text-slate-100 text-sm">Active Bots</h3>
<h3 className="font-semibold text-slate-100 text-sm">Active Bots & Parties</h3>
</div>
<span className="text-xs font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700">
{players.length} / 4096
{players.length} Bots
</span>
</div>
{/* Turn Info Banner */}
{/* Turn & Round Banner */}
{players.length > 0 && (
<div className="px-4 py-2.5 bg-slate-950/70 border-b border-slate-800/80 flex items-center justify-between text-xs font-mono">
<span className="text-slate-400">
@ -42,6 +51,54 @@ export const PlayerList: React.FC<PlayerListProps> = ({
</div>
)}
{/* Parties Section */}
{parties.length > 0 && (
<div className="p-3 border-b border-slate-800 bg-slate-950/40">
<div className="text-[11px] font-mono text-slate-400 uppercase tracking-wider mb-2 flex items-center justify-between">
<span>🛡 Active Parties ({parties.length})</span>
</div>
<div className="space-y-1.5">
{parties.map((party) => (
<div
key={party.id}
className="bg-slate-900/90 border border-sky-500/40 rounded-lg p-2 flex items-center justify-between text-xs"
>
<div className="min-w-0 flex-1">
<div className="font-bold text-sky-300 truncate flex items-center gap-1">
<span>👑</span> {party.name}
</div>
<div className="text-[10px] text-slate-400 font-mono">
Leader: <strong className="text-amber-300">{party.leader_name}</strong> {' '}
{party.member_ids.length} members
</div>
</div>
{onDefeatParty && (
<button
onClick={() => onDefeatParty(party.id)}
className="ml-2 bg-rose-950/80 hover:bg-rose-900 text-rose-300 border border-rose-700/60 px-2 py-1 rounded text-[10px] font-mono transition-all"
title="Defeat party: kills leader (-1 pt), leader respawns, remainder elects new leader"
>
💥 Defeat
</button>
)}
</div>
))}
</div>
</div>
)}
{/* Action to form party */}
{players.length >= 2 && onOpenPartyModal && (
<div className="p-2 border-b border-slate-800 bg-slate-950/30">
<button
onClick={onOpenPartyModal}
className="w-full bg-slate-800 hover:bg-sky-700 text-slate-200 hover:text-white py-1.5 px-3 rounded-lg text-xs font-mono border border-slate-700 transition-colors flex items-center justify-center gap-1.5"
>
<span>🤝</span> Form / Join Party
</button>
</div>
)}
{/* Players List */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? (
@ -55,7 +112,8 @@ export const PlayerList: React.FC<PlayerListProps> = ({
players.map((player) => {
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const orderIndex = turn.turn_order.indexOf(player.id);
const isLeader = player.is_party_leader;
const partyName = partyMap.get(player.id);
return (
<div
@ -75,15 +133,17 @@ export const PlayerList: React.FC<PlayerListProps> = ({
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
style={{
backgroundColor: player.color,
boxShadow: isCurrentTurn
boxShadow: isLeader
? '0 0 14px #fbbf24'
: isCurrentTurn
? '0 0 14px #f59e0b'
: `0 0 10px ${player.color}55`,
}}
>
{player.name.slice(0, 2).toUpperCase()}
</div>
{isCurrentTurn && (
<span className="absolute -top-1.5 -right-1 text-[11px] leading-none">
{isLeader && (
<span className="absolute -top-2 -right-1 text-[12px] leading-none">
👑
</span>
)}
@ -94,17 +154,23 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<span className="text-xs font-semibold text-slate-200 truncate">
{player.name}
</span>
{orderIndex !== -1 && (
<span className="text-[10px] font-mono text-slate-500">
#{orderIndex + 1}
</span>
)}
<span
className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
player.score < 0
? 'bg-rose-950 text-rose-300 border border-rose-800'
: player.score > 0
? 'bg-emerald-950 text-emerald-300 border border-emerald-800'
: 'bg-slate-800 text-slate-400'
}`}
>
{player.score >= 0 ? `+${player.score}` : player.score} pts
</span>
</div>
<div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span>
{isCurrentTurn && (
<span className="ml-1.5 text-amber-400 font-bold text-[10px]">
TURN
{partyName && (
<span className="ml-1 text-sky-400 truncate font-sans">
{isLeader ? 'Leader' : 'Squad'}
</span>
)}
</div>

View File

@ -1,5 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { AvailableMovesResponse, BoardState, MoveResponse, Player, TurnInfo } from '../types';
import type {
AvailableMovesResponse,
BattleResult,
BoardState,
MoveResponse,
Party,
PartyDefeatResult,
Player,
TurnInfo,
} from '../types';
const INITIAL_BOARD: BoardState = {
config: {
@ -12,6 +21,7 @@ const INITIAL_BOARD: BoardState = {
},
player_count: 0,
players: [],
parties: [],
turn: {
current_player_id: null,
current_player_name: null,
@ -27,6 +37,7 @@ export function useGameSocket() {
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
@ -37,7 +48,10 @@ export function useGameSocket() {
const res = await fetch('/api/board');
if (res.ok) {
const data: BoardState = await res.json();
setBoardState(data);
setBoardState((prev) => ({
...data,
parties: data.parties || prev.parties || [],
}));
}
} catch (err) {
console.error('Failed to fetch board state:', err);
@ -79,7 +93,10 @@ export function useGameSocket() {
try {
const data = JSON.parse(event.data);
if (data.event === 'init') {
setBoardState(data.state);
setBoardState({
...data.state,
parties: data.state.parties || [],
});
} else if (data.event === 'player_joined') {
setBoardState((prev) => {
const exists = prev.players.some((p) => p.id === data.player.id);
@ -94,11 +111,55 @@ export function useGameSocket() {
};
});
} else if (data.event === 'player_moved') {
setBoardState((prev) => {
const updatedMap = new Map<string, Player>();
if (data.affected_players) {
data.affected_players.forEach((p: Player) => updatedMap.set(p.id, p));
} else if (data.players) {
data.players.forEach((p: Player) => updatedMap.set(p.id, p));
} else if (data.player) {
updatedMap.set(data.player.id, data.player);
}
return {
...prev,
players: prev.players.map((p) => updatedMap.get(p.id) || p),
turn: data.turn ?? prev.turn,
};
});
} else if (data.event === 'party_formed' || data.event === 'party_updated') {
setBoardState((prev) => ({
...prev,
players: prev.players.map((p) => (p.id === data.player.id ? data.player : p)),
players: data.players ?? prev.players,
parties: [
...prev.parties.filter((p) => p.id !== data.party.id),
data.party,
],
turn: data.turn ?? prev.turn,
}));
setLastEventMessage(`Party "${data.party.name}" established under leader ${data.party.leader_name}!`);
} else if (data.event === 'party_defeated') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: data.parties ?? prev.parties,
turn: data.turn ?? prev.turn,
}));
const details: PartyDefeatResult = data.defeat_details;
setLastEventMessage(
`💥 Party Defeat! Leader ${details.killed_leader_name} killed (-1 pt) and respawned. ${
details.new_leader_name ? `New leader: ${details.new_leader_name}` : 'Party dissolved.'
}`
);
} else if (data.event === 'battle_resolved') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: data.parties ?? prev.parties,
turn: data.turn ?? prev.turn,
}));
const b: BattleResult = data.battle;
setLastEventMessage(`⚔️ Battle Victory: ${b.winner_name} defeated ${b.loser_name}!`);
} else if (data.event === 'turn_passed') {
setBoardState((prev) => ({
...prev,
@ -111,6 +172,7 @@ export function useGameSocket() {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
parties: data.parties ?? prev.parties.filter((party) => party.member_ids.includes(data.player_id)),
turn: data.turn ?? prev.turn,
};
});
@ -120,6 +182,7 @@ export function useGameSocket() {
...prev,
player_count: 0,
players: [],
parties: [],
turn: data.turn ?? INITIAL_BOARD.turn,
}));
setSelectedPlayer(null);
@ -204,6 +267,46 @@ export function useGameSocket() {
}
};
const formParty = async (memberIds: string[], leaderId: string, name?: string): Promise<Party> => {
const res = await fetch('/api/parties', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_ids: memberIds, leader_id: leaderId, name }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to form party');
}
const party: Party = await res.json();
return party;
};
const defeatParty = async (partyId: string): Promise<PartyDefeatResult> => {
const res = await fetch(`/api/parties/${partyId}/defeat`, {
method: 'POST',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to defeat party');
}
const result: PartyDefeatResult = await res.json();
return result;
};
const fightBattle = async (challengerId: string, defenderId: string): Promise<BattleResult> => {
const res = await fetch('/api/battles/fight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenger_id: challengerId, defender_id: defenderId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Battle failed');
}
const result: BattleResult = await res.json();
return result;
};
const movePlayer = async (playerId: string, direction: string): Promise<MoveResponse> => {
const res = await fetch(`/api/players/${playerId}/move`, {
method: 'POST',
@ -239,7 +342,7 @@ export function useGameSocket() {
}
};
// Step a single turn for the active bot (random valid move)
// Step a single turn for the active bot / party leader
const stepActiveBotTurn = useCallback(async () => {
const currentId = boardState.turn.current_player_id;
if (!currentId) return;
@ -256,7 +359,6 @@ export function useGameSocket() {
const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)];
await movePlayer(currentId, randomDir);
} else {
// No moves available, pass turn
await passTurn(currentId);
}
} catch (err) {
@ -269,7 +371,7 @@ export function useGameSocket() {
if (isAutoPlaying) {
autoPlayIntervalRef.current = window.setInterval(() => {
stepActiveBotTurn();
}, 700);
}, 750);
} else if (autoPlayIntervalRef.current) {
clearInterval(autoPlayIntervalRef.current);
autoPlayIntervalRef.current = null;
@ -290,8 +392,12 @@ export function useGameSocket() {
availableMoves,
isAutoPlaying,
setIsAutoPlaying,
lastEventMessage,
registerPlayer,
removePlayer,
formParty,
defeatParty,
fightBattle,
movePlayer,
passTurn,
stepActiveBotTurn,

View File

@ -4,9 +4,21 @@ export interface Player {
color: string;
x: number;
y: number;
score: number;
party_id?: string | null;
is_party_leader?: boolean;
created_at: string;
}
export interface Party {
id: string;
name: string;
leader_id: string;
leader_name: string;
member_ids: string[];
created_at?: string;
}
export interface BoardConfig {
min_x: number;
max_x: number;
@ -28,6 +40,7 @@ export interface BoardState {
config: BoardConfig;
player_count: number;
players: Player[];
parties: Party[];
turn: TurnInfo;
}
@ -52,6 +65,9 @@ export interface AvailableMovesResponse {
current_x: number;
current_y: number;
is_turn: boolean;
is_party_leader: boolean;
party_id?: string | null;
party_member_count: number;
current_turn_player_id?: string | null;
moves: Record<string, MoveCheckResult>;
}
@ -60,7 +76,29 @@ export interface MoveResponse {
success: boolean;
player: Player;
direction: string;
party_moved?: boolean;
affected_players?: Player[];
previous_position: { x: number; y: number };
new_position: { x: number; y: number };
turn: TurnInfo;
}
export interface PartyDefeatResult {
party_id: string;
killed_leader_id: string;
killed_leader_name: string;
killed_leader_new_score: number;
killed_leader_respawn_position: { x: number; y: number };
new_leader_id?: string | null;
new_leader_name?: string | null;
remaining_members: string[];
party_dissolved: boolean;
}
export interface BattleResult {
winner_id: string;
winner_name: string;
loser_id: string;
loser_name: string;
defeat_details: PartyDefeatResult;
}