diff --git a/README.md b/README.md index bcec923..610ba76 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,56 @@ # 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 avatar color, find teammates, form **Parties**, take turns moving around the arena, and engage in tactical squad battles. +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, color, and strength, remember everywhere they have been, seek other bots to form **Parties**, take turns moving around the arena, and engage in **3-bout D20 tactical battles**. --- ## Features -### 1. Parties & Squad Formations +### 1. Bot Location Memory & Awareness +- **Location Awareness**: + - Every bot knows its current coordinate `(x, y)` and tracks a memory history of all coordinates it has visited. + - Queryable via `GET /api/players/{player_id}/memory`. + - Enables bots to remember if they have visited a location before to avoid looping and explore unvisited territory. +- **Bot Seeking & Radar**: + - Bots can actively seek other bots via `GET /api/players/{player_id}/radar`. + - Scans for nearest friendly/neutral bots to recruit into a party, or detects enemy squads to engage in battle. + +### 2. Parties & Squad Formations - **Goal & Linked Connectivity**: - - Bots can find each other and join together as a **Party**. + - Bots seek each other out to 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**. +- **Party Strength**: + - Each bot has a default **strength of 1**. + - Total party strength is the sum of all bots in the party: + $$\text{Party Strength} = \sum_{b \in \text{Party}} \text{bot.strength}$$ - **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. -### 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. +### 3. 3-Bout D20 Battle Mechanics & Scoring Rules +- **Engagement**: + - When two opposing parties engage (move into adjacent contact or call battle), a battle must be fought. +- **3-Bout Resolution with D20 Roll Multiplier**: + - Each battle consists of **3 bouts**. + - In each bout, both parties roll a **20-sided die (D20)** (random number from 1 to 20). + - The roll acts as a multiplier on the party's total strength: + $$\text{Bout Score} = \text{Party Strength} \times \text{D20 Roll}$$ + - The party with the higher bout score wins that bout. + - The overall battle winner is the party that scores the higher outcome across the 3 bouts. +- **Battle Scoring Rules**: + - **Winning Party Leader**: Receives **+2 points**. + - **Rest of Winning Party**: Each member receives **+1 point**. + - **Losing Party Leader**: Receives **-1 point**, loses leadership, and respawns at a random free position. + - **Rest of Losing Party**: Loses **0 points** (scores remain unchanged). +- **Surrender & Absorption**: + - The **remainder of the defeated party joins the winning party**! + - Surviving bots have their party affiliation updated to the winning party. + - The winning squad grows in size and increases its total strength, and the round continues. -### 3. Turn-Based 8-Directional Movement +### 4. Turn-Based 8-Directional Movement - **8 Directions**: - Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT` - Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT` @@ -40,47 +62,32 @@ A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with ## REST API Endpoints +### Intelligence & Memory Endpoints +- `GET /api/players/{player_id}/memory`: Retrieve a bot's current location, count of visited locations, and visited coordinates history. +- `GET /api/players/{player_id}/radar`: Scan for nearby bots, calculate distances, classify allies vs enemies, and provide recommended navigation direction (`seek_join`, `engage_battle`, `explore_unvisited`). + ### 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. +- `GET /api/parties`: List all active parties, leaders, members, and total strength. - `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. +- `POST /api/battles/fight`: Initiate a 3-bout D20 battle between two adjacent parties/bots. - Body: `{"challenger_id": "bot_1", "defender_id": "bot_2"}` + - Awards **+2 pts** to winning leader, **+1 pt** to winning members, **-1 pt** to losing leader, **0 pts lost** for losing members, and absorbs surviving bots into the winning squad! ### 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}/move`: Move bot or entire party if leader. Automatically detects and resolves battles upon engagement. - `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`. +- `POST /api/players`: Register a new player with `name`, `color`, and optional `strength` (default 1). - `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. +- `/ws`: Real-time WebSocket connection broadcasting player joins, squad movements, party formations, 3-bout D20 battles, defeats, and turn changes. --- diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index c3237c1..46d39bc 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -8,6 +8,8 @@ from app.models import ( BattleRequest, BattleResult, BoardState, + BotMemoryResponse, + BotRadarResponse, MoveCheckResult, MoveRequest, MoveResponse, @@ -120,6 +122,46 @@ async def remove_player(player_id: str): ) +# ========================================== +# Bot Memory & Radar Endpoints +# ========================================== + +@router.get( + "/players/{player_id}/memory", + response_model=BotMemoryResponse, + summary="Retrieve a bot's current location and visited locations memory", + tags=["Bot Intelligence"], +) +async def get_bot_memory( + player_id: str, + check_x: Optional[int] = Query(None, description="Check if bot has visited this X coordinate"), + check_y: Optional[int] = Query(None, description="Check if bot has visited this Y coordinate"), +): + try: + return await game_engine.get_bot_memory(player_id, check_x, check_y) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + + +@router.get( + "/players/{player_id}/radar", + response_model=BotRadarResponse, + summary="Scan surroundings for other bots, distance, allies/enemies, and recommended direction", + tags=["Bot Intelligence"], +) +async def get_bot_radar(player_id: str): + try: + return await game_engine.get_bot_radar(player_id) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + + # ========================================== # Party & Squad Endpoints # ========================================== @@ -252,7 +294,7 @@ async def defeat_party(party_id: str): @router.post( "/battles/fight", response_model=BattleResult, - summary="Initiate battle between two adjacent bots/parties", + summary="Initiate a 3-bout D20 battle between two adjacent parties/bots", tags=["Battles"], ) async def fight_battle(battle_req: BattleRequest): @@ -346,6 +388,15 @@ async def move_player(player_id: str, move_req: MoveRequest): "turn": result.turn.model_dump(), }) + if result.battle_triggered and result.battle_result: + await manager.broadcast({ + "event": "battle_resolved", + "battle": result.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: raise HTTPException( diff --git a/backend/app/game.py b/backend/app/game.py index 42c0b60..87cd28e 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -5,9 +5,12 @@ from typing import Dict, List, Optional, Set, Tuple from app.config import settings from app.models import ( AvailableMovesResponse, + BattleBout, BattleResult, BoardConfig, BoardState, + BotMemoryResponse, + BotRadarResponse, DIRECTION_OFFSETS, MoveCheckResult, MoveResponse, @@ -16,6 +19,7 @@ from app.models import ( PartyInvite, Player, PlayerCreate, + RadarTarget, TurnInfo, ) @@ -81,7 +85,6 @@ 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) @@ -120,6 +123,10 @@ class GameEngine: if self.current_turn_index == 0: self.round_number += 1 + def _update_party_strength(self, party: Party): + total = sum(self.players[m].strength for m in party.member_ids if m in self.players) + party.total_strength = max(1, total) + async def register_player(self, player_in: PlayerCreate) -> Player: async with self._lock: player_id = f"bot_{uuid.uuid4().hex[:8]}" @@ -131,9 +138,11 @@ class GameEngine: color=player_in.color, x=x, y=y, + strength=player_in.strength, score=0, party_id=None, is_party_leader=False, + visited_locations=[{"x": x, "y": y}], ) self.players[player_id] = player self.turn_order.append(player_id) @@ -154,7 +163,6 @@ class GameEngine: 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: @@ -167,9 +175,12 @@ class GameEngine: if new_lead: new_lead.is_party_leader = True party.leader_name = new_lead.name + self._update_party_strength(party) else: del self.parties[party.id] - elif not party.member_ids: + elif party.member_ids: + self._update_party_strength(party) + else: del self.parties[party.id] del self.players[player_id] @@ -207,17 +218,106 @@ class GameEngine: turn=self._get_turn_info(), ) + # ========================================== + # Bot Memory & Radar Awareness + # ========================================== + + async def get_bot_memory( + self, player_id: str, check_x: Optional[int] = None, check_y: Optional[int] = None + ) -> BotMemoryResponse: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + has_visited = True + if check_x is not None and check_y is not None: + has_visited = any(loc["x"] == check_x and loc["y"] == check_y for loc in player.visited_locations) + + return BotMemoryResponse( + player_id=player.id, + player_name=player.name, + current_x=player.x, + current_y=player.y, + visited_count=len(player.visited_locations), + visited_history=list(player.visited_locations), + has_visited_current=has_visited, + ) + + async def get_bot_radar(self, player_id: str) -> BotRadarResponse: + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + targets: List[RadarTarget] = [] + for other in self.players.values(): + if other.id == player.id: + continue + dist = max(abs(player.x - other.x), abs(player.y - other.y)) + is_ally = bool(player.party_id and player.party_id == other.party_id) + is_enemy = not is_ally + party_obj = self.parties.get(other.party_id) if other.party_id else None + + targets.append( + RadarTarget( + id=other.id, + name=other.name, + color=other.color, + x=other.x, + y=other.y, + distance=dist, + party_id=other.party_id, + party_name=party_obj.name if party_obj else None, + is_ally=is_ally, + is_enemy=is_enemy, + ) + ) + + targets.sort(key=lambda t: t.distance) + nearest = targets[0] if targets else None + + rec_dir = None + rec_act = "explore_unvisited" + + if nearest: + dx = 1 if nearest.x > player.x else (-1 if nearest.x < player.x else 0) + dy = 1 if nearest.y > player.y else (-1 if nearest.y < player.y else 0) + + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == dx and oy == dy and "_" in name: + rec_dir = name + break + if not rec_dir: + for name, (ox, oy) in DIRECTION_OFFSETS.items(): + if ox == dx and oy == dy: + rec_dir = name + break + + if nearest.distance <= 1: + rec_act = "engage_battle" if (nearest.is_enemy and nearest.party_id) else "seek_join" + else: + rec_act = "seek_join" if not nearest.party_id else "engage_battle" + + return BotRadarResponse( + player_id=player.id, + current_x=player.x, + current_y=player.y, + targets=targets, + nearest_target=nearest, + recommended_direction=rec_dir, + recommended_action=rec_act, + ) + # ========================================== # 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 @@ -250,13 +350,11 @@ class GameEngine: 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] @@ -275,6 +373,7 @@ class GameEngine: leader_id=leader_id, leader_name=leader.name, member_ids=list(member_ids), + total_strength=sum(p.strength for p in member_players), ) for p in member_players: @@ -297,7 +396,6 @@ class GameEngine: 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] @@ -338,13 +436,11 @@ class GameEngine: 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: @@ -352,9 +448,9 @@ class GameEngine: if p: p.is_party_leader = p.id == party.leader_id party.leader_name = self.players[party.leader_id].name + self._update_party_strength(party) 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]}" @@ -365,6 +461,7 @@ class GameEngine: leader_id=leader_id, leader_name=leader.name, member_ids=members, + total_strength=inviter.strength + invitee.strength, ) inviter.party_id = party_id inviter.is_party_leader = inviter.id == leader_id @@ -382,7 +479,7 @@ class GameEngine: return self.parties.get(party_id) # ========================================== - # Movement Checking (Single bot or Party) + # Movement Checking & Execution # ========================================== def _check_move_internal( @@ -393,7 +490,6 @@ 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] @@ -403,7 +499,6 @@ class GameEngine: 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, @@ -415,7 +510,6 @@ class GameEngine: 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( @@ -438,7 +532,6 @@ class GameEngine: reason=None, ) - # Single player or non-leader target_x = player.x + dx target_y = player.y + dy @@ -481,7 +574,6 @@ 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" @@ -497,9 +589,7 @@ class GameEngine: 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" - ) + raise ValueError(f"Unknown direction '{direction_input}'") dx, dy = DIRECTION_OFFSETS[normalized] occupied = self._get_occupied_coordinates() @@ -555,7 +645,6 @@ class GameEngine: 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" @@ -580,7 +669,7 @@ class GameEngine: prev_pos = {"x": player.x, "y": player.y} affected_players: List[Player] = [] - # If party leader, move all party members in unison + # Move party in unison or single bot if player.party_id and player.party_id in self.parties: party = self.parties[player.party_id] for mid in party.member_ids: @@ -588,14 +677,28 @@ class GameEngine: if m: m.x += dx m.y += dy + m.visited_locations.append({"x": m.x, "y": m.y}) affected_players.append(m) else: player.x = check.target_x player.y = check.target_y + player.visited_locations.append({"x": player.x, "y": player.y}) affected_players.append(player) new_pos = {"x": player.x, "y": player.y} + # Check if moving party / bot engages another party in battle + battle_result = None + battle_triggered = False + + if player.party_id and player.party_id in self.parties: + my_party = self.parties[player.party_id] + opposing_party = self._find_adjacent_opposing_party(my_party) + if opposing_party: + # Automatic battle engagement! + battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party) + battle_triggered = True + self._advance_turn() turn_info = self._get_turn_info() @@ -607,6 +710,8 @@ class GameEngine: affected_players=affected_players, previous_position=prev_pos, new_position=new_pos, + battle_triggered=battle_triggered, + battle_result=battle_result, turn=turn_info, ) @@ -628,14 +733,157 @@ class GameEngine: return self._get_turn_info() # ========================================== - # Battle & Party Defeat Logic + # 3-Bout D20 Battle Mechanics # ========================================== - async def defeat_party(self, party_id: str) -> PartyDefeatResult: - """Kills the party leader: score -= 1, respawns at random position, removed from party, + def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]: + my_members = [self.players[mid] for mid in party.member_ids if mid in self.players] + for other_party in self.parties.values(): + if other_party.id == party.id: + continue + other_members = [self.players[mid] for mid in other_party.member_ids if mid in self.players] + for m1 in my_members: + for m2 in other_members: + if self._are_adjacent(m1, m2): + return other_party + return None - and randomly assigns a new leader from the remainder of the party. + def _resolve_3bout_battle_internal(self, party1: Party, party2: Party) -> BattleResult: + """Executes a 3-bout battle with a 20-sided die roll multiplier. + + Scores: + - Winning leader receives +2 points. + - Rest of the winning party receives +1 point. + - Losing leader receives -1 point and respawns. + - Rest of the losing party loses 0 points and joins the winning party. """ + str1 = sum(self.players[m].strength for m in party1.member_ids if m in self.players) + str2 = sum(self.players[m].strength for m in party2.member_ids if m in self.players) + + bouts: List[BattleBout] = [] + p1_bouts_won = 0 + p2_bouts_won = 0 + p1_total_score = 0 + p2_total_score = 0 + + for i in range(1, 4): + roll1 = random.randint(1, 20) + roll2 = random.randint(1, 20) + score1 = str1 * roll1 + score2 = str2 * roll2 + + p1_total_score += score1 + p2_total_score += score2 + + if score1 > score2: + winner_name = party1.name + p1_bouts_won += 1 + elif score2 > score1: + winner_name = party2.name + p2_bouts_won += 1 + else: + tie_roll1 = random.randint(1, 20) + tie_roll2 = random.randint(1, 20) + if tie_roll1 >= tie_roll2: + winner_name = party1.name + p1_bouts_won += 1 + else: + winner_name = party2.name + p2_bouts_won += 1 + + bouts.append( + BattleBout( + bout_number=i, + party1_roll=roll1, + party1_strength=str1, + party1_score=score1, + party2_roll=roll2, + party2_strength=str2, + party2_score=score2, + winner_name=winner_name, + ) + ) + + # Overall battle winner (most bouts won, or higher total score) + if p1_bouts_won > p2_bouts_won: + winner_party, defeated_party = party1, party2 + elif p2_bouts_won > p1_bouts_won: + winner_party, defeated_party = party2, party1 + else: + if p1_total_score >= p2_total_score: + winner_party, defeated_party = party1, party2 + else: + winner_party, defeated_party = party2, party1 + + # Defeated Party loses leader: leader gets -1 point and respawns + killed_leader_id = defeated_party.leader_id + killed_leader = self.players.get(killed_leader_id) + if killed_leader: + killed_leader.score -= 1 + killed_leader.party_id = None + killed_leader.is_party_leader = False + rx, ry = self._find_random_free_position() + killed_leader.x = rx + killed_leader.y = ry + killed_leader.visited_locations.append({"x": rx, "y": ry}) + respawn_pos = {"x": rx, "y": ry} + else: + respawn_pos = {"x": 0, "y": 0} + + # Rest of losing party loses 0 points (scores remain unchanged) + + # Winning leader receives +2 points, and the rest of the winning party receives +1 point + for mid in list(winner_party.member_ids): + member = self.players.get(mid) + if member: + if mid == winner_party.leader_id: + member.score += 2 + else: + member.score += 1 + + # Remainder of defeated party joins the winning party + absorbed_members = [] + for mid in list(defeated_party.member_ids): + if mid != killed_leader_id: + m = self.players.get(mid) + if m: + m.party_id = winner_party.id + m.is_party_leader = False + if mid not in winner_party.member_ids: + winner_party.member_ids.append(mid) + absorbed_members.append(mid) + + # Defeated party is dissolved + if defeated_party.id in self.parties: + del self.parties[defeated_party.id] + + # Update winning party strength + self._update_party_strength(winner_party) + + return BattleResult( + bouts=bouts, + party1_name=party1.name, + party2_name=party2.name, + party1_bouts_won=p1_bouts_won, + party2_bouts_won=p2_bouts_won, + party1_total_score=p1_total_score, + party2_total_score=p2_total_score, + winner_party_id=winner_party.id, + winner_party_name=winner_party.name, + winner_leader_id=winner_party.leader_id, + winner_leader_name=winner_party.leader_name, + defeated_party_id=defeated_party.id, + defeated_party_name=defeated_party.name, + killed_leader_id=killed_leader_id, + killed_leader_name=killed_leader.name if killed_leader else "Unknown", + killed_leader_new_score=killed_leader.score if killed_leader else -1, + killed_leader_respawn_position=respawn_pos, + absorbed_members=absorbed_members, + new_party_size=len(winner_party.member_ids), + new_party_strength=winner_party.total_strength, + ) + + async def defeat_party(self, party_id: str) -> PartyDefeatResult: async with self._lock: party = self.parties.get(party_id) if not party: @@ -646,22 +894,18 @@ class GameEngine: 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 + leader.visited_locations.append({"x": rx, "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 @@ -673,8 +917,8 @@ class GameEngine: new_leader.is_party_leader = True new_leader_name = new_leader.name party.leader_name = new_leader_name + self._update_party_strength(party) else: - # No members left in party del self.parties[party_id] party_dissolved = True @@ -691,10 +935,6 @@ class GameEngine: ) 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) @@ -709,93 +949,38 @@ class GameEngine: 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, + # Form temporary 1-bot parties if they don't have one + party1 = self.parties.get(p1.party_id) if p1.party_id else None + if not party1: + party1_id = f"party_{uuid.uuid4().hex[:8]}" + party1 = Party( + id=party1_id, + name=f"Squad {p1.name}", + leader_id=p1.id, + leader_name=p1.name, + member_ids=[p1.id], + total_strength=p1.strength, ) + p1.party_id = party1_id + p1.is_party_leader = True + self.parties[party1_id] = party1 - return BattleResult( - winner_id=winner.id, - winner_name=winner.name, - loser_id=loser.id, - loser_name=loser.name, - defeat_details=defeat_result, - ) + party2 = self.parties.get(p2.party_id) if p2.party_id else None + if not party2: + party2_id = f"party_{uuid.uuid4().hex[:8]}" + party2 = Party( + id=party2_id, + name=f"Squad {p2.name}", + leader_id=p2.id, + leader_name=p2.name, + member_ids=[p2.id], + total_strength=p2.strength, + ) + p2.party_id = party2_id + p2.is_party_leader = True + self.parties[party2_id] = party2 + + return self._resolve_3bout_battle_internal(party1, party2) # Global game engine instance diff --git a/backend/app/models.py b/backend/app/models.py index a870cb2..2c562f0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -101,6 +101,7 @@ class AvailableMovesResponse(BaseModel): class PlayerCreate(BaseModel): name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name") + strength: int = Field(default=1, ge=1, description="Bot strength (default is 1)") @field_validator("name") @classmethod @@ -127,9 +128,11 @@ class Player(BaseModel): color: str x: int y: int + strength: int = 1 score: int = 0 party_id: Optional[str] = None is_party_leader: bool = False + visited_locations: List[Dict[str, int]] = Field(default_factory=list) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -139,6 +142,7 @@ class Party(BaseModel): leader_id: str leader_name: str member_ids: List[str] + total_strength: int = 1 created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -181,17 +185,87 @@ class PartyDefeatResult(BaseModel): party_dissolved: bool = False +# ========================================== +# 3-Bout D20 Battle Models +# ========================================== + +class BattleBout(BaseModel): + bout_number: int + party1_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 1") + party1_strength: int + party1_score: int = Field(..., description="party1_strength * party1_roll") + party2_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 2") + party2_strength: int + party2_score: int = Field(..., description="party2_strength * party2_roll") + winner_name: Optional[str] = None + + +class BattleResult(BaseModel): + bouts: List[BattleBout] + party1_name: str + party2_name: str + party1_bouts_won: int + party2_bouts_won: int + party1_total_score: int + party2_total_score: int + winner_party_id: str + winner_party_name: str + winner_leader_id: str + winner_leader_name: str + defeated_party_id: str + defeated_party_name: str + killed_leader_id: str + killed_leader_name: str + killed_leader_new_score: int + killed_leader_respawn_position: Dict[str, int] + absorbed_members: List[str] = Field( + default_factory=list, + description="Remaining bots from the defeated party that merged into the winning party", + ) + new_party_size: int + new_party_strength: int + + 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 +# ========================================== +# Memory & Radar Models +# ========================================== + +class BotMemoryResponse(BaseModel): + player_id: str + player_name: str + current_x: int + current_y: int + visited_count: int + visited_history: List[Dict[str, int]] + has_visited_current: bool = True + + +class RadarTarget(BaseModel): + id: str + name: str + color: str + x: int + y: int + distance: int + party_id: Optional[str] = None + party_name: Optional[str] = None + is_ally: bool + is_enemy: bool + + +class BotRadarResponse(BaseModel): + player_id: str + current_x: int + current_y: int + targets: List[RadarTarget] + nearest_target: Optional[RadarTarget] = None + recommended_direction: Optional[str] = None + recommended_action: str # "seek_join", "engage_battle", "explore_unvisited" class BoardConfig(BaseModel): @@ -227,6 +301,8 @@ class MoveResponse(BaseModel): affected_players: List[Player] = [] previous_position: Dict[str, int] new_position: Dict[str, int] + battle_triggered: bool = False + battle_result: Optional[BattleResult] = None turn: TurnInfo diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index df78336..93587f0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,12 +12,152 @@ def reset_board_state(): asyncio.run(game_engine.reset()) +def test_bot_location_memory_and_radar(): + client = TestClient(app) + p1 = client.post("/api/players", json={"name": "MemoryBot", "color": "#00FFAA"}).json() + p2 = client.post("/api/players", json={"name": "NearbyBot", "color": "#FF00AA"}).json() + + import asyncio + async def place_bots(): + b1 = await game_engine.get_player(p1["id"]) + b1.x, b1.y = 15, 15 + b1.visited_locations = [{"x": 15, "y": 15}] + b2 = await game_engine.get_player(p2["id"]) + b2.x, b2.y = 16, 16 + b2.visited_locations = [{"x": 16, "y": 16}] + asyncio.run(place_bots()) + + # Check memory of p1 + mem = client.get(f"/api/players/{p1['id']}/memory").json() + assert mem["player_id"] == p1["id"] + assert mem["current_x"] == 15 + assert mem["current_y"] == 15 + assert mem["visited_count"] == 1 + assert mem["visited_history"][0] == {"x": 15, "y": 15} + + # Check radar + radar = client.get(f"/api/players/{p1['id']}/radar").json() + assert radar["player_id"] == p1["id"] + assert len(radar["targets"]) == 1 + assert radar["targets"][0]["id"] == p2["id"] + assert radar["targets"][0]["distance"] == 1 + + # Move p1 + res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}) + assert res_move.status_code == 200 + + # Memory should now have 2 visited locations + mem2 = client.get(f"/api/players/{p1['id']}/memory").json() + assert mem2["visited_count"] == 2 + assert mem2["visited_history"][-1] == {"x": 15, "y": 14} + + +def test_3bout_d20_battle_with_defeated_members_joining_winner(): + client = TestClient(app) + # Party 1: Leader + 2 Followers (Total 3 bots, strength 3) + p1 = client.post("/api/players", json={"name": "SquadLeader1", "color": "#111111"}).json() + p2 = client.post("/api/players", json={"name": "WingmanA", "color": "#222222"}).json() + p3 = client.post("/api/players", json={"name": "WingmanB", "color": "#333333"}).json() + + # Party 2: Leader + 1 Follower (Total 2 bots, strength 2) + p4 = client.post("/api/players", json={"name": "SquadLeader2", "color": "#444444"}).json() + p5 = client.post("/api/players", json={"name": "WingmanC", "color": "#555555"}).json() + + import asyncio + async def setup_parties(): + 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 + + b4 = await game_engine.get_player(p4["id"]) + b4.x, b4.y = 21, 20 # Adjacent to b1 + b5 = await game_engine.get_player(p5["id"]) + b5.x, b5.y = 22, 20 + asyncio.run(setup_parties()) + + # Form Party 1 + party1 = client.post("/api/parties", json={ + "member_ids": [p1["id"], p2["id"], p3["id"]], + "leader_id": p1["id"], + "name": "Titans", + }).json() + + # Form Party 2 + party2 = client.post("/api/parties", json={ + "member_ids": [p4["id"], p5["id"]], + "leader_id": p4["id"], + "name": "Phantoms", + }).json() + + # Fight battle between the adjacent parties + res = client.post("/api/battles/fight", json={ + "challenger_id": p1["id"], + "defender_id": p4["id"], + }) + assert res.status_code == 200 + battle = res.json() + + # Verify 3 bouts were fought + assert len(battle["bouts"]) == 3 + for bout in battle["bouts"]: + assert 1 <= bout["party1_roll"] <= 20 + assert 1 <= bout["party2_roll"] <= 20 + assert bout["party1_score"] == bout["party1_strength"] * bout["party1_roll"] + assert bout["party2_score"] == bout["party2_strength"] * bout["party2_roll"] + + # Winner was crowned + winner_id = battle["winner_party_id"] + defeated_id = battle["defeated_party_id"] + assert winner_id in [party1["id"], party2["id"]] + assert defeated_id in [party1["id"], party2["id"]] + assert winner_id != defeated_id + + # Defeated leader was killed and received -1 point + killed_leader_id = battle["killed_leader_id"] + dead_leader = client.get(f"/api/players/{killed_leader_id}").json() + assert dead_leader["score"] == -1 + assert dead_leader["party_id"] is None + assert dead_leader["is_party_leader"] is False + + # Winning leader received +2 points + winning_leader_id = battle["winner_leader_id"] + winning_leader = client.get(f"/api/players/{winning_leader_id}").json() + assert winning_leader["score"] == 2 + + # Winning original squad members received +1 point + # Defeated party members lost 0 points (score remains 0) + if winner_id == party1["id"]: + # Titans won: p2, p3 were in winning party -> score 1 + # p5 was in defeated party -> score 0 + assert client.get(f"/api/players/{p2['id']}").json()["score"] == 1 + assert client.get(f"/api/players/{p3['id']}").json()["score"] == 1 + assert client.get(f"/api/players/{p5['id']}").json()["score"] == 0 + else: + # Phantoms won: p5 was in winning party -> score 1 + # p2, p3 were in defeated party -> score 0 + assert client.get(f"/api/players/{p5['id']}").json()["score"] == 1 + assert client.get(f"/api/players/{p2['id']}").json()["score"] == 0 + assert client.get(f"/api/players/{p3['id']}").json()["score"] == 0 + + # The remainder of the defeated party joined the winning party + winning_party = client.get(f"/api/parties/{winner_id}").json() + assert battle["new_party_size"] == len(winning_party["member_ids"]) + + # Absorbed bots are now members of winning_party + for absorbed_id in battle["absorbed_members"]: + absorbed_player = client.get(f"/api/players/{absorbed_id}").json() + assert absorbed_player["party_id"] == winner_id + assert absorbed_id in winning_party["member_ids"] + + def test_party_formation_and_group_movement(): client = TestClient(app) 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"]) @@ -26,7 +166,6 @@ def test_party_formation_and_group_movement(): b2.x, b2.y = 10, 11 asyncio.run(place_adjacent()) - # Form party with agreed leader p1 form_res = client.post("/api/parties", json={ "member_ids": [p1["id"], p2["id"]], "leader_id": p1["id"], @@ -37,34 +176,21 @@ def test_party_formation_and_group_movement(): 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"] - # 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 - # Check positions in board - board = client.get("/api/board").json() - 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_party_wall_collision_for_follower(): client = TestClient(app) 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 setup_near_wall(): b1 = await game_engine.get_player(p1["id"]) @@ -78,100 +204,6 @@ def test_party_wall_collision_for_follower(): "leader_id": p1["id"], }) - # 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"] - - # 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_party_defeat_and_leader_respawn(): - client = TestClient(app) - 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_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()) - - party = client.post("/api/parties", json={ - "member_ids": [p1["id"], p2["id"], p3["id"]], - "leader_id": p1["id"], - }).json() - - # 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_battle_between_parties(): - client = TestClient(app) - # 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() - - 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 - 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 7c963cc..0598c60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useGameSocket } from './hooks/useGameSocket'; +import { BattleModal } from './components/BattleModal'; import { BoardCanvas } from './components/BoardCanvas'; import { Header } from './components/Header'; import { MovementControls } from './components/MovementControls'; @@ -41,10 +42,13 @@ export function App() { isAutoPlaying, setIsAutoPlaying, lastEventMessage, + activeBattle, + setActiveBattle, registerPlayer, removePlayer, formParty, defeatParty, + fightBattle, movePlayer, passTurn, stepActiveBotTurn, @@ -105,6 +109,16 @@ export function App() { } }; + const handleFightBattle = async (challengerId: string, defenderId: string) => { + try { + await fightBattle(challengerId, defenderId); + } catch (err: unknown) { + if (err instanceof Error) { + showNotification(`Battle error: ${err.message}`); + } + } + }; + return (
{/* Top Navigation Bar */} @@ -150,11 +164,12 @@ export function App() { onRemovePlayer={removePlayer} onOpenPartyModal={() => setIsPartyModalOpen(true)} onDefeatParty={handleDefeatParty} + onFightBattle={handleFightBattle} /> {/* Live Event Feed Notification */} {(lastEventMessage || notification) && ( -
+
{notification || lastEventMessage}
@@ -181,6 +196,12 @@ export function App() { showNotification(`Party "${party.name}" established under leader ${party.leader_name}!`); }} /> + + {/* 3-Bout D20 Battle Modal */} + setActiveBattle(null)} + />
); } diff --git a/frontend/src/components/BattleModal.tsx b/frontend/src/components/BattleModal.tsx new file mode 100644 index 0000000..f1d35df --- /dev/null +++ b/frontend/src/components/BattleModal.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import type { BattleResult } from '../types'; + +interface BattleModalProps { + battle: BattleResult | null; + onClose: () => void; +} + +export const BattleModal: React.FC = ({ battle, onClose }) => { + if (!battle) return null; + + return ( +
+
+ {/* Glowing cyber header banner */} +
+ +
+
+ ⚔️ +
+

+ PARTY ENGAGEMENT: 3-BOUT D20 CLASH +

+

+ {battle.party1_name} vs {battle.party2_name} +

+
+
+ +
+ + {/* 3 Bouts Breakdown */} +
+ {battle.bouts.map((bout) => ( +
+
+ BOUT #{bout.bout_number} + + Winner: {bout.winner_name || 'Tied'} + +
+ +
+ {/* Party 1 */} +
+
+ {battle.party1_name} +
+
+ + Str: {bout.party1_strength} × 🎲 {bout.party1_roll} = + + {bout.party1_score} +
+
+ + {/* Party 2 */} +
+
+ {battle.party2_name} +
+
+ + Str: {bout.party2_strength} × 🎲 {bout.party2_roll} = + + {bout.party2_score} +
+
+
+
+ ))} +
+ + {/* Battle Resolution Summary */} +
+
+ + 🏆 VICTORY: {battle.winner_party_name} + + + Bouts: {battle.party1_bouts_won} - {battle.party2_bouts_won} + +
+ +
+ • Winning Leader {battle.winner_leader_name} receives{' '} + +2 points, and all other winning squad members receive{' '} + +1 point! +
+ +
+ • Defeated Leader {battle.killed_leader_name} receives{' '} + -1 point, leaves the party, and respawns at{' '} + + ({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y}) + + . (Surviving defeated bots lose 0 points). +
+ +
+ • {battle.absorbed_members.length} surviving bot(s) from the + defeated party surrendered and joined {battle.winner_party_name}! +
+ +
+ New Squad Size: {battle.new_party_size} bots • Total Strength:{' '} + {battle.new_party_strength} +
+
+ + {/* Continue Button */} + +
+
+ ); +}; diff --git a/frontend/src/components/PlayerList.tsx b/frontend/src/components/PlayerList.tsx index 55d8168..7d9c755 100644 --- a/frontend/src/components/PlayerList.tsx +++ b/frontend/src/components/PlayerList.tsx @@ -8,6 +8,7 @@ interface PlayerListProps { onRemovePlayer: (id: string) => void; onOpenPartyModal?: () => void; onDefeatParty?: (partyId: string) => void; + onFightBattle?: (challengerId: string, defenderId: string) => void; } export const PlayerList: React.FC = ({ @@ -69,14 +70,14 @@ export const PlayerList: React.FC = ({
Leader: {party.leader_name} •{' '} - {party.member_ids.length} members + {party.member_ids.length} bots • ⚡{party.total_strength}
{onDefeatParty && ( @@ -167,7 +168,7 @@ export const PlayerList: React.FC = ({
- pos: ({player.x}, {player.y}) + pos: ({player.x}, {player.y}) • Str: ⚡{player.strength || 1} {partyName && ( • {isLeader ? 'Leader' : 'Squad'} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index fd94092..b200667 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -38,6 +38,7 @@ export function useGameSocket() { const [availableMoves, setAvailableMoves] = useState(null); const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [lastEventMessage, setLastEventMessage] = useState(null); + const [activeBattle, setActiveBattle] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); @@ -159,7 +160,8 @@ export function useGameSocket() { turn: data.turn ?? prev.turn, })); const b: BattleResult = data.battle; - setLastEventMessage(`⚔️ Battle Victory: ${b.winner_name} defeated ${b.loser_name}!`); + setActiveBattle(b); + setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); } else if (data.event === 'turn_passed') { setBoardState((prev) => ({ ...prev, @@ -187,6 +189,7 @@ export function useGameSocket() { })); setSelectedPlayer(null); setAvailableMoves(null); + setActiveBattle(null); } } catch (err) { console.error('Error parsing WebSocket message:', err); @@ -220,7 +223,6 @@ export function useGameSocket() { }; }, [fetchBoard, connectWebSocket]); - // Keep selected player synced with board state useEffect(() => { if (selectedPlayer) { const updated = boardState.players.find((p) => p.id === selectedPlayer.id); @@ -233,7 +235,6 @@ export function useGameSocket() { } }, [boardState.players, selectedPlayer]); - // Auto-fetch available moves for active player or selected player useEffect(() => { const targetId = selectedPlayer?.id || boardState.turn.current_player_id; if (targetId) { @@ -304,6 +305,7 @@ export function useGameSocket() { throw new Error(err.detail || 'Battle failed'); } const result: BattleResult = await res.json(); + setActiveBattle(result); return result; }; @@ -318,6 +320,9 @@ export function useGameSocket() { throw new Error(err.detail || 'Failed to move player'); } const data: MoveResponse = await res.json(); + if (data.battle_triggered && data.battle_result) { + setActiveBattle(data.battle_result); + } return data; }; @@ -342,12 +347,22 @@ export function useGameSocket() { } }; - // Step a single turn for the active bot / party leader + // Step a single turn: Bot seeking behavior using Radar and Memory const stepActiveBotTurn = useCallback(async () => { const currentId = boardState.turn.current_player_id; if (!currentId) return; try { + // 1. Check radar for nearest bot to seek/join or engage + const radarRes = await fetch(`/api/players/${currentId}/radar`).catch(() => null); + let targetDirection: string | null = null; + + if (radarRes && radarRes.ok) { + const radarData = await radarRes.json(); + targetDirection = radarData.recommended_direction; + } + + // 2. Check available moves const movesData = await fetchAvailableMoves(currentId); if (!movesData) return; @@ -356,8 +371,12 @@ export function useGameSocket() { .map(([dir]) => dir); if (validDirections.length > 0) { - const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)]; - await movePlayer(currentId, randomDir); + // Prefer recommended direction towards nearest bot if available + let chosenDir = targetDirection && validDirections.includes(targetDirection) ? targetDirection : null; + if (!chosenDir) { + chosenDir = validDirections[Math.floor(Math.random() * validDirections.length)]; + } + await movePlayer(currentId, chosenDir); } else { await passTurn(currentId); } @@ -393,6 +412,8 @@ export function useGameSocket() { isAutoPlaying, setIsAutoPlaying, lastEventMessage, + activeBattle, + setActiveBattle, registerPlayer, removePlayer, formParty, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 349bcae..92cc5e2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,9 +4,11 @@ export interface Player { color: string; x: number; y: number; + strength: number; score: number; party_id?: string | null; is_party_leader?: boolean; + visited_locations?: { x: number; y: number }[]; created_at: string; } @@ -16,6 +18,7 @@ export interface Party { leader_id: string; leader_name: string; member_ids: string[]; + total_strength: number; created_at?: string; } @@ -47,6 +50,7 @@ export interface BoardState { export interface PlayerCreateRequest { name: string; color: string; + strength?: number; } export interface MoveCheckResult { @@ -72,6 +76,40 @@ export interface AvailableMovesResponse { moves: Record; } +export interface BattleBout { + bout_number: number; + party1_roll: number; + party1_strength: number; + party1_score: number; + party2_roll: number; + party2_strength: number; + party2_score: number; + winner_name?: string | null; +} + +export interface BattleResult { + bouts: BattleBout[]; + party1_name: string; + party2_name: string; + party1_bouts_won: number; + party2_bouts_won: number; + party1_total_score: number; + party2_total_score: number; + winner_party_id: string; + winner_party_name: string; + winner_leader_id: string; + winner_leader_name: string; + defeated_party_id: string; + defeated_party_name: string; + killed_leader_id: string; + killed_leader_name: string; + killed_leader_new_score: number; + killed_leader_respawn_position: { x: number; y: number }; + absorbed_members: string[]; + new_party_size: number; + new_party_strength: number; +} + export interface MoveResponse { success: boolean; player: Player; @@ -80,6 +118,8 @@ export interface MoveResponse { affected_players?: Player[]; previous_position: { x: number; y: number }; new_position: { x: number; y: number }; + battle_triggered?: boolean; + battle_result?: BattleResult | null; turn: TurnInfo; } @@ -95,10 +135,35 @@ export interface PartyDefeatResult { party_dissolved: boolean; } -export interface BattleResult { - winner_id: string; - winner_name: string; - loser_id: string; - loser_name: string; - defeat_details: PartyDefeatResult; +export interface BotMemoryResponse { + player_id: string; + player_name: string; + current_x: number; + current_y: number; + visited_count: number; + visited_history: { x: number; y: number }[]; + has_visited_current: boolean; +} + +export interface RadarTarget { + id: string; + name: string; + color: string; + x: number; + y: number; + distance: number; + party_id?: string | null; + party_name?: string | null; + is_ally: boolean; + is_enemy: boolean; +} + +export interface BotRadarResponse { + player_id: string; + current_x: number; + current_y: number; + targets: RadarTarget[]; + nearest_target?: RadarTarget | null; + recommended_direction?: string | null; + recommended_action: string; }