diff --git a/README.md b/README.md index 610ba76..3696a7f 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,83 @@ # 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, 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**. +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, navigate autonomously towards their goals, negotiate **Parties** based on relative strength, take turns moving around the arena, and engage in **3-bout D20 tactical battles**. --- -## Features +## Autonomous Goals & Behaviors -### 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. +### 1. Goal of a Bot Without a Party: Form a Party +- **Primary Objective**: Seek other bots across the arena and form a party as soon as contact is made. +- **Seeking & Memory**: + - Uses location memory history to prevent circular looping and prioritize unexplored coordinates. + - Radar targets the closest candidate bot. +- **Leadership Negotiation Rule**: + - **Insistence on Leadership**: If a bot considers the other bot less than them (**higher strength**), it **insists on being the party leader**. + - **Desire to Join**: A bot desires to join a bot that is **equal or stronger**. + - **Agreed Outcome**: + - When two unpartied bots meet, the bot with the **higher strength** becomes the agreed **Party Leader**. + - If strengths are equal, tie-breaking chooses the bot with higher score or the initiator, and both agree since each is equal in strength. + - If an unpartied bot meets an existing party: if the solo bot is stronger than the party's total strength, it insists on becoming the new leader; otherwise, it joins under the existing leader. -### 2. Parties & Squad Formations -- **Goal & Linked Connectivity**: - - 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. Goal of a Party: Find and Defeat All Other Parties +- **Primary Objective**: Seek out and eliminate all opposing parties on the board. +- **Squad Navigation**: + - The Party Leader controls group movement, steering the linked squad across the grid towards opposing squads. + - All party members maintain linked connectivity ($\le 1$ Chebyshev distance). +- **Battle Engagement**: + - As soon as a party becomes adjacent to an opposing party, combat is engaged. -### 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. +--- -### 4. 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. +## 3-Bout D20 Battle Mechanics & Scoring + +### 1. Engagement & 3-Bout Resolution +- Each battle consists of **3 bouts**. +- In each bout, both parties roll a **20-sided die (D20)** (1 to 20). +- Multiplier rule: + $$\text{Bout Score} = \text{Party Strength} \times \text{D20 Roll}$$ +- Winner is determined by whoever wins more bouts (or has a higher aggregate score in case of ties). + +### 2. Score Distribution +- **Winning Party Leader**: Receives **+2 points**. +- **Rest of Winning Party**: Each member receives **+1 point**. +- **Losing Party Leader**: Receives **-1 point**, loses leadership, is removed from the party, and respawns at a random free position. +- **Rest of Losing Party**: Loses **0 points** (scores remain unchanged). + +### 3. Surrender & Absorption +- The **surviving remainder of the defeated party joins the winning party**. +- The winning squad grows in numbers and total strength, and immediately resumes hunting down any remaining parties! --- ## 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"}` -- `GET /api/parties`: List all active parties, leaders, members, and total strength. -- `POST /api/parties/{party_id}/defeat`: Trigger party defeat. -- `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! +### AI & Autonomous Endpoints +- `POST /api/players/{player_id}/ai-step`: Executes one autonomous turn according to the bot's goal: + - If unpartied: seeks other bots; negotiates and forms a party under the stronger leader upon contact. + - If partied leader: hunts down opposing parties; engages and resolves 3-bout D20 battles. +- `GET /api/players/{player_id}/radar`: Scans surroundings for nearby bots, calculates distance, determines allies/enemies, and identifies recruit/battle opportunities. +- `GET /api/players/{player_id}/memory`: Retrieves coordinate history and tracks visited locations. ### 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. Automatically detects and resolves battles upon engagement. +- `POST /api/players/{player_id}/move`: Move bot or entire party if leader. Automatically checks for party formation or battle engagement upon move completion. - `POST /api/players/{player_id}/pass`: Pass turn to next player. - `GET /api/turn`: Get current turn status, round number, and turn queue. +### Party & Battle Endpoints +- `POST /api/parties`: Directly form a party with linked members and an agreed leader. +- `GET /api/parties`: List all active parties, leaders, members, and total strength. +- `POST /api/parties/{party_id}/defeat`: Trigger party defeat. +- `POST /api/battles/fight`: Initiate a 3-bout D20 battle between two adjacent parties/bots. + ### Player & Board Endpoints - `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, 3-bout D20 battles, defeats, and turn changes. +- `/ws`: Real-time WebSocket broadcasting movements, party formations, battles, and turn changes. --- diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 46d39bc..84d8f0d 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Query, status from app.api.websocket import manager from app.game import game_engine from app.models import ( + AiStepResponse, ApiResponse, AvailableMovesResponse, BattleRequest, @@ -44,33 +45,33 @@ async def health_check(): "/players", response_model=Player, status_code=status.HTTP_201_CREATED, - summary="Register a new player on the board", + summary="Register a new bot/player with an avatar name and color", tags=["Players"], ) -@router.post( - "/register", - response_model=Player, - status_code=status.HTTP_201_CREATED, - include_in_schema=False, -) async def register_player(player_in: PlayerCreate): - player = await game_engine.register_player(player_in) - board_state = await game_engine.get_board_state() + try: + player = await game_engine.register_player(player_in) + board_state = await game_engine.get_board_state() - await manager.broadcast({ - "event": "player_joined", - "player": player.model_dump(), - "player_count": board_state.player_count, - "turn": board_state.turn.model_dump(), - }) + await manager.broadcast({ + "event": "player_joined", + "player": player.model_dump(), + "player_count": board_state.player_count, + "turn": board_state.turn.model_dump(), + }) - return player + return player + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) @router.get( "/players", response_model=List[Player], - summary="List all registered players", + summary="List all registered players and their scores/status", tags=["Players"], ) async def list_players(): @@ -80,7 +81,7 @@ async def list_players(): @router.get( "/players/{player_id}", response_model=Player, - summary="Get details of a specific player", + summary="Get details of a specific player by ID", tags=["Players"], ) async def get_player(player_id: str): @@ -96,7 +97,7 @@ async def get_player(player_id: str): @router.delete( "/players/{player_id}", response_model=ApiResponse, - summary="Remove a player from the board", + summary="Remove a player from the game board", tags=["Players"], ) async def remove_player(player_id: str): @@ -118,7 +119,7 @@ async def remove_player(player_id: str): return ApiResponse( success=True, - message=f"Player '{player_id}' successfully removed", + message=f"Player '{player_id}' removed from board", ) @@ -129,13 +130,13 @@ async def remove_player(player_id: str): @router.get( "/players/{player_id}/memory", response_model=BotMemoryResponse, - summary="Retrieve a bot's current location and visited locations memory", - tags=["Bot Intelligence"], + summary="Get bot's location memory history and check if location was visited before", + tags=["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"), + check_x: Optional[int] = Query(None, description="Optional X coordinate to check if bot has visited before"), + check_y: Optional[int] = Query(None, description="Optional Y coordinate to check if bot has visited before"), ): try: return await game_engine.get_bot_memory(player_id, check_x, check_y) @@ -149,8 +150,8 @@ async def get_bot_memory( @router.get( "/players/{player_id}/radar", response_model=BotRadarResponse, - summary="Scan surroundings for other bots, distance, allies/enemies, and recommended direction", - tags=["Bot Intelligence"], + summary="Scan surroundings for nearby bots, determine allies/enemies, and recommend action/direction", + tags=["Intelligence"], ) async def get_bot_radar(player_id: str): try: @@ -163,7 +164,7 @@ async def get_bot_radar(player_id: str): # ========================================== -# Party & Squad Endpoints +# Party Management & 3-Bout Battles # ========================================== @router.get( @@ -388,6 +389,14 @@ async def move_player(player_id: str, move_req: MoveRequest): "turn": result.turn.model_dump(), }) + if result.party_formed_triggered and result.formed_party: + await manager.broadcast({ + "event": "party_formed", + "party": result.formed_party.model_dump(), + "players": [p.model_dump() for p in board_state.players], + "turn": board_state.turn.model_dump(), + }) + if result.battle_triggered and result.battle_result: await manager.broadcast({ "event": "battle_resolved", @@ -415,6 +424,69 @@ async def move_player(player_id: str, move_req: MoveRequest): ) +@router.post( + "/players/{player_id}/ai-step", + response_model=AiStepResponse, + summary="Autonomous AI turn: unpartied bots seek & form parties (stronger bot is leader); parties hunt & defeat all other parties", + tags=["Movement"], +) +async def step_bot_ai(player_id: str): + try: + result = await game_engine.step_bot_ai(player_id) + board_state = await game_engine.get_board_state() + + if result.formed_party: + await manager.broadcast({ + "event": "party_formed", + "party": result.formed_party.model_dump(), + "players": [p.model_dump() for p in board_state.players], + "turn": board_state.turn.model_dump(), + }) + elif 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(), + }) + elif result.move_result: + await manager.broadcast({ + "event": "player_moved", + "player": result.move_result.player.model_dump(), + "direction": result.move_result.direction, + "party_moved": result.move_result.party_moved, + "affected_players": [p.model_dump() for p in result.move_result.affected_players], + "previous_position": result.move_result.previous_position, + "new_position": result.move_result.new_position, + "players": [p.model_dump() for p in board_state.players], + "turn": result.move_result.turn.model_dump(), + }) + else: + await manager.broadcast({ + "event": "turn_passed", + "passed_by": player_id, + "turn": result.turn.model_dump(), + }) + + return result + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Player '{player_id}' not found", + ) + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + @router.post( "/players/{player_id}/pass", response_model=TurnInfo, diff --git a/backend/app/game.py b/backend/app/game.py index 87cd28e..1f8babc 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -4,6 +4,7 @@ import uuid from typing import Dict, List, Optional, Set, Tuple from app.config import settings from app.models import ( + AiStepResponse, AvailableMovesResponse, BattleBout, BattleResult, @@ -250,7 +251,9 @@ class GameEngine: if not player: raise KeyError(f"Player '{player_id}' not found") + bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" targets: List[RadarTarget] = [] + for other in self.players.values(): if other.id == player.id: continue @@ -259,6 +262,11 @@ class GameEngine: is_enemy = not is_ally party_obj = self.parties.get(other.party_id) if other.party_id else None + can_recruit = (player.party_id is None and other.party_id is None) or ( + player.party_id and player.is_party_leader and other.party_id is None + ) + can_battle = is_enemy and bool(player.party_id and other.party_id) + targets.append( RadarTarget( id=other.id, @@ -266,16 +274,33 @@ class GameEngine: color=other.color, x=other.x, y=other.y, + strength=other.strength, 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, + can_recruit=can_recruit, + can_battle=can_battle, ) ) targets.sort(key=lambda t: t.distance) - nearest = targets[0] if targets else None + + # Choose primary target based on goal: + # - If unpartied: seek closest bot to form party with + # - If partied: seek closest enemy party to battle + primary_targets = [] + if bot_goal == "form_party": + # Prefer unpartied bots or friendly recruitment targets + recruit_targets = [t for t in targets if t.can_recruit or not t.party_id] + primary_targets = recruit_targets if recruit_targets else targets + else: + # Partied: seek enemy parties + battle_targets = [t for t in targets if t.is_enemy and t.party_id] + primary_targets = battle_targets if battle_targets else [t for t in targets if t.is_enemy] + + nearest = primary_targets[0] if primary_targets else (targets[0] if targets else None) rec_dir = None rec_act = "explore_unvisited" @@ -295,14 +320,21 @@ class GameEngine: break if nearest.distance <= 1: - rec_act = "engage_battle" if (nearest.is_enemy and nearest.party_id) else "seek_join" + if bot_goal == "form_party" or nearest.can_recruit: + rec_act = "form_party" + else: + rec_act = "engage_battle" else: - rec_act = "seek_join" if not nearest.party_id else "engage_battle" + if bot_goal == "form_party": + rec_act = "seek_partner" + else: + rec_act = "hunt_party" return BotRadarResponse( player_id=player.id, current_x=player.x, current_y=player.y, + bot_goal=bot_goal, targets=targets, nearest_target=nearest, recommended_direction=rec_dir, @@ -310,7 +342,7 @@ class GameEngine: ) # ========================================== - # Party Logic & Graph Adjacency + # Party Logic & Leadership Negotiation # ========================================== @staticmethod @@ -334,6 +366,26 @@ class GameEngine: return len(visited) == len(member_players) + def _negotiate_party_leader_id(self, bot_a: Player, bot_b: Player) -> str: + """Rules: + + - If a bot considers the other bot less than them (lower strength), they insist on being + leader. + - A bot desires to join a bot that is equal or stronger. + - Therefore, the bot with higher strength is the agreed leader. + - If strengths are equal, break ties by score or bot_a. + """ + if bot_a.strength > bot_b.strength: + return bot_a.id + elif bot_b.strength > bot_a.strength: + return bot_b.id + else: + if bot_a.score > bot_b.score: + return bot_a.id + elif bot_b.score > bot_a.score: + return bot_b.id + return bot_a.id + async def form_party( self, member_ids: List[str], leader_id: str, name: Optional[str] = None ) -> Party: @@ -637,6 +689,56 @@ class GameEngine: moves=moves, ) + def _check_and_auto_form_party(self, player: Player) -> Optional[Party]: + """When an unpartied bot sees it is adjacent to another bot, negotiate and form a party: + + - Lower strength insists on joining higher strength. + - Higher strength insists on being leader. + """ + if player.party_id: + return None + + for other in self.players.values(): + if other.id == player.id: + continue + if self._are_adjacent(player, other): + if other.party_id is None: + leader_id = self._negotiate_party_leader_id(player, other) + leader = self.players[leader_id] + party_id = f"party_{uuid.uuid4().hex[:8]}" + party = Party( + id=party_id, + name=f"Squad {leader.name}", + leader_id=leader_id, + leader_name=leader.name, + member_ids=[player.id, other.id], + total_strength=player.strength + other.strength, + ) + player.party_id = party_id + player.is_party_leader = player.id == leader_id + other.party_id = party_id + other.is_party_leader = other.id == leader_id + self.parties[party_id] = party + return party + elif other.is_party_leader and other.party_id in self.parties: + party = self.parties[other.party_id] + if player.strength > party.total_strength: + # Player is stronger than entire party: player insists on leading + party.member_ids.append(player.id) + player.party_id = party.id + player.is_party_leader = True + other.is_party_leader = False + party.leader_id = player.id + party.leader_name = player.name + else: + # Party is equal or stronger: player joins under existing leader + party.member_ids.append(player.id) + player.party_id = party.id + player.is_party_leader = False + self._update_party_strength(party) + return party + return None + async def move_player( self, player_id: str, dx: int, dy: int, direction_name: str ) -> MoveResponse: @@ -687,7 +789,12 @@ class GameEngine: new_pos = {"x": player.x, "y": player.y} - # Check if moving party / bot engages another party in battle + # Check for party formation if solo bot moved next to another bot + formed_party = None + if player.party_id is None: + formed_party = self._check_and_auto_form_party(player) + + # Check if moving party engages an opposing party in battle battle_result = None battle_triggered = False @@ -695,7 +802,6 @@ class GameEngine: 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 @@ -710,6 +816,8 @@ class GameEngine: affected_players=affected_players, previous_position=prev_pos, new_position=new_pos, + party_formed_triggered=bool(formed_party), + formed_party=formed_party, battle_triggered=battle_triggered, battle_result=battle_result, turn=turn_info, @@ -732,6 +840,203 @@ class GameEngine: self._advance_turn() return self._get_turn_info() + # ========================================== + # Autonomous Goal Step (AI Logic) + # ========================================== + + async def step_bot_ai(self, player_id: str) -> AiStepResponse: + """Executes one autonomous turn for the bot or party leader following exact goals: + + 1. Bot without party: Goal is to seek other bots and form a party (stronger bot insists on + being leader). + 2. Party leader: Goal is to find and defeat all other parties. + """ + async with self._lock: + player = self.players.get(player_id) + if not player: + raise KeyError(f"Player '{player_id}' not found") + + current_turn_player = self._get_current_player() + if not current_turn_player or current_turn_player.id != player_id: + raise PermissionError( + f"Not your turn. Current turn: '{current_turn_player.name if current_turn_player else 'None'}'" + ) + + bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" + + # 1. If unpartied and already adjacent to another bot, form a party immediately! + if not player.party_id: + formed = self._check_and_auto_form_party(player) + if formed: + self._advance_turn() + return AiStepResponse( + action_taken="formed_party", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=formed, + battle_result=None, + turn=self._get_turn_info(), + ) + + # 2. If partied leader and already adjacent to an opposing party, fight immediately! + if player.party_id and player.is_party_leader and player.party_id in self.parties: + my_party = self.parties[player.party_id] + opposing = self._find_adjacent_opposing_party(my_party) + if opposing: + battle_res = self._resolve_3bout_battle_internal(my_party, opposing) + self._advance_turn() + return AiStepResponse( + action_taken="battled", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=battle_res, + turn=self._get_turn_info(), + ) + + # 3. Otherwise: navigate towards target using radar & memory + occupied = self._get_occupied_coordinates() + moves_map: Dict[str, MoveCheckResult] = {} + for name, dx, dy in STANDARD_DIRECTIONS: + moves_map[name] = self._check_move_internal(player, dx, dy, name, occupied) + + available_dirs = [name for name, chk in moves_map.items() if chk.available] + if not available_dirs: + # No move available, pass turn + self._advance_turn() + return AiStepResponse( + action_taken="passed", + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=None, + move_result=None, + formed_party=None, + battle_result=None, + turn=self._get_turn_info(), + ) + + # Find nearest target according to goal + targets = [] + for other in self.players.values(): + if other.id == player.id: + continue + if player.party_id and player.party_id == other.party_id: + continue + dist = max(abs(player.x - other.x), abs(player.y - other.y)) + targets.append((dist, other)) + + targets.sort(key=lambda t: t[0]) + + # Filter targets: + # - If solo bot: prioritize unpartied bots + # - If party: prioritize enemy parties + if bot_goal == "form_party": + preferred = [t for t in targets if not t[1].party_id] + target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) + else: + preferred = [t for t in targets if t[1].party_id] + target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None) + + # Score each available direction based on: + # 1. Getting closer to target + # 2. Preferring unvisited tiles (location memory) + visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations} + best_dir = available_dirs[0] + best_score = float("-inf") + + for dir_name in available_dirs: + dx, dy = DIRECTION_OFFSETS[dir_name] + tx = player.x + dx + ty = player.y + dy + score = 0.0 + + if target_bot: + old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y)) + new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y)) + score += (old_dist - new_dist) * 10.0 # Reward moving closer + + # Memory penalty for visited coordinates + if (tx, ty) not in visited_set: + score += 2.0 # Exploration bonus + + if score > best_score: + best_score = score + best_dir = dir_name + + # Execute move with chosen direction + dx, dy = DIRECTION_OFFSETS[best_dir] + prev_pos = {"x": player.x, "y": player.y} + affected_players: List[Player] = [] + + 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 + m.visited_locations.append({"x": m.x, "y": m.y}) + affected_players.append(m) + else: + player.x += dx + player.y += dy + player.visited_locations.append({"x": player.x, "y": player.y}) + affected_players.append(player) + + new_pos = {"x": player.x, "y": player.y} + + # Check party formation / battle engagement after move + formed_party = None + if player.party_id is None: + formed_party = self._check_and_auto_form_party(player) + + 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: + battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party) + battle_triggered = True + + self._advance_turn() + turn_info = self._get_turn_info() + + move_res = MoveResponse( + success=True, + player=player, + direction=best_dir, + party_moved=bool(player.party_id), + affected_players=affected_players, + previous_position=prev_pos, + new_position=new_pos, + party_formed_triggered=bool(formed_party), + formed_party=formed_party, + battle_triggered=battle_triggered, + battle_result=battle_result, + turn=turn_info, + ) + + action_taken = "battled" if battle_triggered else ("formed_party" if formed_party else "moved") + return AiStepResponse( + action_taken=action_taken, + player_id=player.id, + player_name=player.name, + bot_goal=bot_goal, + direction=best_dir, + move_result=move_res, + formed_party=formed_party, + battle_result=battle_result, + turn=turn_info, + ) + # ========================================== # 3-Bout D20 Battle Mechanics # ========================================== @@ -749,14 +1054,6 @@ class GameEngine: return None 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) @@ -804,7 +1101,7 @@ class GameEngine: ) ) - # Overall battle winner (most bouts won, or higher total score) + # Overall battle winner if p1_bouts_won > p2_bouts_won: winner_party, defeated_party = party1, party2 elif p2_bouts_won > p1_bouts_won: diff --git a/backend/app/models.py b/backend/app/models.py index 2c562f0..09e128d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -251,21 +251,25 @@ class RadarTarget(BaseModel): color: str x: int y: int + strength: int = 1 distance: int party_id: Optional[str] = None party_name: Optional[str] = None is_ally: bool is_enemy: bool + can_recruit: bool = False + can_battle: bool = False class BotRadarResponse(BaseModel): player_id: str current_x: int current_y: int + bot_goal: str # "form_party" or "find_and_defeat_all_parties" targets: List[RadarTarget] nearest_target: Optional[RadarTarget] = None recommended_direction: Optional[str] = None - recommended_action: str # "seek_join", "engage_battle", "explore_unvisited" + recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited" class BoardConfig(BaseModel): @@ -301,11 +305,25 @@ class MoveResponse(BaseModel): affected_players: List[Player] = [] previous_position: Dict[str, int] new_position: Dict[str, int] + party_formed_triggered: bool = False + formed_party: Optional[Party] = None battle_triggered: bool = False battle_result: Optional[BattleResult] = None turn: TurnInfo +class AiStepResponse(BaseModel): + action_taken: str # "formed_party", "battled", "moved", "passed" + player_id: str + player_name: str + bot_goal: str # "form_party" or "find_and_defeat_all_parties" + direction: Optional[str] = None + move_result: Optional[MoveResponse] = None + formed_party: Optional[Party] = None + battle_result: Optional[BattleResult] = None + turn: TurnInfo + + class ApiResponse(BaseModel): success: bool message: str diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 93587f0..7a4e536 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -52,6 +52,47 @@ def test_bot_location_memory_and_radar(): assert mem2["visited_history"][-1] == {"x": 15, "y": 14} +def test_strength_based_leadership_negotiation(): + """Rule test: + + - If a bot considers the other bot less than them (lower strength), they insist on being party + leader. + - A bot desires to join a bot that is equal or stronger. + """ + client = TestClient(app) + # Bot A (strength 10), Bot B (strength 2) placed adjacent + pA = client.post("/api/players", json={"name": "StrongBot", "color": "#111111", "strength": 10}).json() + pB = client.post("/api/players", json={"name": "WeakerBot", "color": "#222222", "strength": 2}).json() + + import asyncio + async def place_adjacent(): + bA = await game_engine.get_player(pA["id"]) + bA.x, bA.y = 25, 25 + bB = await game_engine.get_player(pB["id"]) + bB.x, bB.y = 25, 26 + asyncio.run(place_adjacent()) + + # Step AI for pA: see pB adjacent, negotiate party + ai_res = client.post(f"/api/players/{pA['id']}/ai-step") + assert ai_res.status_code == 200 + data = ai_res.json() + assert data["action_taken"] == "formed_party" + assert data["formed_party"] is not None + + # StrongBot insisted on being leader, WeakerBot joined stronger + formed = data["formed_party"] + assert formed["leader_id"] == pA["id"] + assert formed["leader_name"] == "StrongBot" + assert formed["total_strength"] == 12 + + botA_state = client.get(f"/api/players/{pA['id']}").json() + botB_state = client.get(f"/api/players/{pB['id']}").json() + assert botA_state["is_party_leader"] is True + assert botB_state["is_party_leader"] is False + assert botA_state["party_id"] == formed["id"] + assert botB_state["party_id"] == formed["id"] + + def test_3bout_d20_battle_with_defeated_members_joining_winner(): client = TestClient(app) # Party 1: Leader + 2 Followers (Total 3 bots, strength 3) diff --git a/frontend/src/components/PartyModal.tsx b/frontend/src/components/PartyModal.tsx index e8033ec..7b87955 100644 --- a/frontend/src/components/PartyModal.tsx +++ b/frontend/src/components/PartyModal.tsx @@ -41,12 +41,22 @@ export const PartyModal: React.FC = ({ return null; }; + const pickStrongestLeader = (ids: string[]) => { + if (ids.length === 0) return ''; + const selectedBots = players.filter((p) => ids.includes(p.id)); + selectedBots.sort((a, b) => (b.strength || 1) - (a.strength || 1)); + return selectedBots[0]?.id || ids[0]; + }; + const handleQuickPair = () => { const pair = findAdjacentPairs(); if (pair) { - setSelectedIds([pair[0].id, pair[1].id]); - setLeaderId(pair[0].id); - setPartyName(`Squad ${pair[0].name}`); + const ids = [pair[0].id, pair[1].id]; + setSelectedIds(ids); + const chosenLeader = pickStrongestLeader(ids); + setLeaderId(chosenLeader); + const leadBot = players.find((p) => p.id === chosenLeader) || pair[0]; + setPartyName(`Squad ${leadBot.name}`); setError(null); } else { setError('No adjacent bots found! Move bots within 1 distance of each other to link them.'); @@ -57,9 +67,7 @@ export const PartyModal: React.FC = ({ setError(null); setSelectedIds((prev) => { const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]; - if (!next.includes(leaderId)) { - setLeaderId(next[0] || ''); - } + setLeaderId(pickStrongestLeader(next)); return next; }); }; @@ -108,9 +116,7 @@ export const PartyModal: React.FC = ({
- 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. + The goal of a bot without a party is to form a party! Bots insist on being leader if they consider the other bot less than them (higher strength), and desire to join a bot that is equal or stronger.
@@ -160,6 +166,7 @@ export const PartyModal: React.FC = ({ style={{ backgroundColor: p.color }} /> {p.name} + ⚡{p.strength || 1} ({p.x}, {p.y}) @@ -179,9 +186,14 @@ export const PartyModal: React.FC = ({ {/* Agreed Leader Selection */} {selectedIds.length > 0 && (
- +
+ + + (Stronger bots insist on leading) + +
{selectedIds.map((id) => { const bot = players.find((p) => p.id === id); @@ -192,14 +204,17 @@ export const PartyModal: React.FC = ({ type="button" key={id} onClick={() => setLeaderId(id)} - className={`p-2 rounded-lg border text-xs font-mono flex items-center gap-2 transition-all ${ + className={`p-2 rounded-lg border text-xs font-mono flex items-center justify-between gap-1 transition-all ${ isLeader ? 'bg-amber-950/80 border-amber-500 text-amber-200 shadow' : 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700' }`} > - {isLeader ? '👑' : '🛡️'} - {bot.name} +
+ {isLeader ? '👑' : '🛡️'} + {bot.name} +
+ ⚡{bot.strength || 1} ); })} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index b200667..b14ebcc 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import type { + AiStepResponse, AvailableMovesResponse, BattleResult, BoardState, @@ -138,7 +139,7 @@ export function useGameSocket() { ], turn: data.turn ?? prev.turn, })); - setLastEventMessage(`Party "${data.party.name}" established under leader ${data.party.leader_name}!`); + setLastEventMessage(`🤝 Party "${data.party.name}" established under leader ${data.party.leader_name}!`); } else if (data.event === 'party_defeated') { setBoardState((prev) => ({ ...prev, @@ -161,7 +162,7 @@ export function useGameSocket() { })); const b: BattleResult = data.battle; setActiveBattle(b); - setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); + setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); } else if (data.event === 'turn_passed') { setBoardState((prev) => ({ ...prev, @@ -244,11 +245,11 @@ export function useGameSocket() { } }, [selectedPlayer?.id, boardState.turn.current_player_id, boardState.players, fetchAvailableMoves]); - const registerPlayer = async (name: string, color: string): Promise => { + const registerPlayer = async (name: string, color: string, strength: number = 1): Promise => { const res = await fetch('/api/players', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, color }), + body: JSON.stringify({ name, color, strength }), }); if (!res.ok) { const errorData = await res.json().catch(() => ({})); @@ -347,43 +348,32 @@ export function useGameSocket() { } }; - // Step a single turn: Bot seeking behavior using Radar and Memory + // Step active bot turn according to its explicit autonomous goal: + // - Bot without party: seeks other bots to form a party (stronger bot insists on being leader) + // - Party leader: seeks other parties to find and defeat all other parties 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; - - const validDirections = Object.entries(movesData.moves) - .filter(([, result]) => result.available) - .map(([dir]) => dir); - - if (validDirections.length > 0) { - // 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)]; + const res = await fetch(`/api/players/${currentId}/ai-step`, { + method: 'POST', + }); + if (res.ok) { + const data: AiStepResponse = await res.json(); + if (data.action_taken === 'formed_party' && data.formed_party) { + setLastEventMessage(`🤝 ${data.player_name} formed party "${data.formed_party.name}" under leader ${data.formed_party.leader_name}!`); + } else if (data.action_taken === 'battled' && data.battle_result) { + setActiveBattle(data.battle_result); + setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`); + } else if (data.move_result?.battle_result) { + setActiveBattle(data.move_result.battle_result); } - await movePlayer(currentId, chosenDir); - } else { - await passTurn(currentId); } } catch (err) { - console.warn('Bot turn step error:', err); + console.warn('Bot AI step error:', err); } - }, [boardState.turn.current_player_id, fetchAvailableMoves]); + }, [boardState.turn.current_player_id]); // Autoplay interval loop useEffect(() => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 92cc5e2..ec6e62c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,12 @@ +export interface GridConfig { + min_x: number; + max_x: number; + min_y: number; + max_y: number; + grid_cells_x: number; + grid_cells_y: number; +} + export interface Player { id: string; name: string; @@ -7,9 +16,9 @@ export interface Player { strength: number; score: number; party_id?: string | null; - is_party_leader?: boolean; + is_party_leader: boolean; visited_locations?: { x: number; y: number }[]; - created_at: string; + created_at?: string; } export interface Party { @@ -22,15 +31,6 @@ export interface Party { created_at?: string; } -export interface BoardConfig { - min_x: number; - max_x: number; - min_y: number; - max_y: number; - grid_cells_x: number; - grid_cells_y: number; -} - export interface TurnInfo { current_player_id: string | null; current_player_name: string | null; @@ -40,19 +40,13 @@ export interface TurnInfo { } export interface BoardState { - config: BoardConfig; + config: GridConfig; player_count: number; players: Player[]; parties: Party[]; turn: TurnInfo; } -export interface PlayerCreateRequest { - name: string; - color: string; - strength?: number; -} - export interface MoveCheckResult { direction: string; dx: number; @@ -72,7 +66,7 @@ export interface AvailableMovesResponse { is_party_leader: boolean; party_id?: string | null; party_member_count: number; - current_turn_player_id?: string | null; + current_turn_player_id: string | null; moves: Record; } @@ -118,11 +112,25 @@ export interface MoveResponse { affected_players?: Player[]; previous_position: { x: number; y: number }; new_position: { x: number; y: number }; + party_formed_triggered?: boolean; + formed_party?: Party | null; battle_triggered?: boolean; battle_result?: BattleResult | null; turn: TurnInfo; } +export interface AiStepResponse { + action_taken: 'formed_party' | 'battled' | 'moved' | 'passed' | string; + player_id: string; + player_name: string; + bot_goal: 'form_party' | 'find_and_defeat_all_parties' | string; + direction?: string | null; + move_result?: MoveResponse | null; + formed_party?: Party | null; + battle_result?: BattleResult | null; + turn: TurnInfo; +} + export interface PartyDefeatResult { party_id: string; killed_leader_id: string; @@ -151,17 +159,21 @@ export interface RadarTarget { color: string; x: number; y: number; + strength: number; distance: number; party_id?: string | null; party_name?: string | null; is_ally: boolean; is_enemy: boolean; + can_recruit?: boolean; + can_battle?: boolean; } export interface BotRadarResponse { player_id: string; current_x: number; current_y: number; + bot_goal: string; targets: RadarTarget[]; nearest_target?: RadarTarget | null; recommended_direction?: string | null;