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

Active Bots

+

Active Bots & Parties

- {players.length} / 4096 + {players.length} Bots
- {/* Turn Info Banner */} + {/* Turn & Round Banner */} {players.length > 0 && (
@@ -42,6 +51,54 @@ export const PlayerList: React.FC = ({
)} + {/* Parties Section */} + {parties.length > 0 && ( +
+
+ 🛡️ Active Parties ({parties.length}) +
+
+ {parties.map((party) => ( +
+
+
+ 👑 {party.name} +
+
+ Leader: {party.leader_name} •{' '} + {party.member_ids.length} members +
+
+ {onDefeatParty && ( + + )} +
+ ))} +
+
+ )} + + {/* Action to form party */} + {players.length >= 2 && onOpenPartyModal && ( +
+ +
+ )} + {/* Players List */}
{players.length === 0 ? ( @@ -55,7 +112,8 @@ export const PlayerList: React.FC = ({ 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 (
= ({ 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()}
- {isCurrentTurn && ( - + {isLeader && ( + 👑 )} @@ -94,17 +154,23 @@ export const PlayerList: React.FC = ({ {player.name} - {orderIndex !== -1 && ( - - #{orderIndex + 1} - - )} + 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 +
pos: ({player.x}, {player.y}) - {isCurrentTurn && ( - - • TURN + {partyName && ( + + • {isLeader ? 'Leader' : 'Squad'} )}
diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index eb3801e..fd94092 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -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(null); const [availableMoves, setAvailableMoves] = useState(null); const [isAutoPlaying, setIsAutoPlaying] = useState(false); + const [lastEventMessage, setLastEventMessage] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(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(); + 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 => { + 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 => { + 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 => { + 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 => { 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, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5357fc5..349bcae 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } @@ -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; +}