diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 84d8f0d..28b542b 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -11,6 +11,7 @@ from app.models import ( BoardState, BotMemoryResponse, BotRadarResponse, + GameConclusion, MoveCheckResult, MoveRequest, MoveResponse, @@ -37,41 +38,67 @@ async def health_check(): ) +@router.get( + "/game/conclusion", + response_model=GameConclusion, + summary="Get the game conclusion state and final rankings when 1 party remains with all bots", + tags=["System"], +) +async def get_game_conclusion(): + return await game_engine.get_game_conclusion() + + +@router.get("/board", response_model=BoardState, tags=["Board"]) +async def get_board(): + return await game_engine.get_board_state() + + +@router.post( + "/reset", + response_model=ApiResponse, + summary="Reset game board, removing all players, parties, and turn history", + tags=["Board"], +) +async def reset_board(): + await game_engine.reset() + board_state = await game_engine.get_board_state() + await manager.broadcast({ + "event": "board_reset", + "board": board_state.model_dump(), + }) + return ApiResponse( + success=True, + message="Game board has been successfully reset", + ) + + # ========================================== -# Player Registration & Management +# Player Endpoints # ========================================== @router.post( "/players", response_model=Player, status_code=status.HTTP_201_CREATED, - summary="Register a new bot/player with an avatar name and color", + summary="Register a new player/bot avatar on the 64x64 grid", tags=["Players"], ) async def register_player(player_in: PlayerCreate): - 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(), - }) - - return player - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) + player = await game_engine.register_player(player_in) + board_state = await game_engine.get_board_state() + await manager.broadcast({ + "event": "player_registered", + "player": player.model_dump(), + "total_players": len(board_state.players), + "turn": board_state.turn.model_dump(), + }) + return player @router.get( "/players", response_model=List[Player], - summary="List all registered players and their scores/status", + summary="List all registered players/bots on the board", tags=["Players"], ) async def list_players(): @@ -81,7 +108,7 @@ async def list_players(): @router.get( "/players/{player_id}", response_model=Player, - summary="Get details of a specific player by ID", + summary="Get details of a specific player/bot", tags=["Players"], ) async def get_player(player_id: str): @@ -97,7 +124,7 @@ async def get_player(player_id: str): @router.delete( "/players/{player_id}", response_model=ApiResponse, - summary="Remove a player from the game board", + summary="Remove a player/bot from the board", tags=["Players"], ) async def remove_player(player_id: str): @@ -107,36 +134,33 @@ async def remove_player(player_id: str): status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) - board_state = await game_engine.get_board_state() await manager.broadcast({ - "event": "player_left", + "event": "player_removed", "player_id": player_id, - "player_count": board_state.player_count, - "parties": [p.model_dump() for p in board_state.parties], + "total_players": len(board_state.players), "turn": board_state.turn.model_dump(), }) - return ApiResponse( success=True, - message=f"Player '{player_id}' removed from board", + message=f"Player '{player_id}' has been removed", ) # ========================================== -# Bot Memory & Radar Endpoints +# Bot Memory & Radar Awareness Endpoints # ========================================== @router.get( "/players/{player_id}/memory", response_model=BotMemoryResponse, - summary="Get bot's location memory history and check if location was visited before", - tags=["Intelligence"], + summary="Get bot memory: coordinates visited and whether a specific location has been visited before", + tags=["Bot AI & Awareness"], ) async def get_bot_memory( player_id: str, - 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"), + check_x: Optional[int] = Query(None, description="Optional X coordinate to check"), + check_y: Optional[int] = Query(None, description="Optional Y coordinate to check"), ): try: return await game_engine.get_bot_memory(player_id, check_x, check_y) @@ -150,8 +174,8 @@ async def get_bot_memory( @router.get( "/players/{player_id}/radar", response_model=BotRadarResponse, - summary="Scan surroundings for nearby bots, determine allies/enemies, and recommend action/direction", - tags=["Intelligence"], + summary="Scan radar for other bots/parties, calculating distance, ally/enemy status, recruit/battle actions", + tags=["Bot AI & Awareness"], ) async def get_bot_radar(player_id: str): try: @@ -164,13 +188,13 @@ async def get_bot_radar(player_id: str): # ========================================== -# Party Management & 3-Bout Battles +# Party & Battle Endpoints # ========================================== @router.get( "/parties", response_model=List[Party], - summary="List all active parties", + summary="List all active parties and their members", tags=["Parties"], ) async def list_parties(): @@ -216,6 +240,15 @@ async def form_party(form_req: PartyDirectForm): "turn": board_state.turn.model_dump(), }) + if board_state.conclusion and board_state.conclusion.concluded: + await manager.broadcast({ + "event": "game_concluded", + "conclusion": board_state.conclusion.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 party except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -250,17 +283,26 @@ async def invite_to_party(invite_req: PartyInviteCreate): summary="Accept or reject a party invitation", tags=["Parties"], ) -async def respond_to_invite(invite_id: str, resp: PartyInviteResponse): +async def respond_to_invite(invite_id: str, response: PartyInviteResponse): try: - party = await game_engine.respond_to_invite(invite_id, resp.accept) + party = await game_engine.respond_to_invite(invite_id, response.accept) + board_state = await game_engine.get_board_state() + if party: - board_state = await game_engine.get_board_state() await manager.broadcast({ - "event": "party_updated", + "event": "party_formed", "party": party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) + if board_state.conclusion and board_state.conclusion.concluded: + await manager.broadcast({ + "event": "game_concluded", + "conclusion": board_state.conclusion.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 party except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -287,6 +329,15 @@ async def defeat_party(party_id: str): "turn": board_state.turn.model_dump(), }) + if board_state.conclusion and board_state.conclusion.concluded: + await manager.broadcast({ + "event": "game_concluded", + "conclusion": board_state.conclusion.model_dump(), + "players": [p.model_dump() for p in board_state.players], + "parties": [p.model_dump() for p in board_state.parties], + "turn": board_state.turn.model_dump(), + }) + return result except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -311,6 +362,15 @@ async def fight_battle(battle_req: BattleRequest): "turn": board_state.turn.model_dump(), }) + if board_state.conclusion and board_state.conclusion.concluded: + await manager.broadcast({ + "event": "game_concluded", + "conclusion": board_state.conclusion.model_dump(), + "players": [p.model_dump() for p in board_state.players], + "parties": [p.model_dump() for p in board_state.parties], + "turn": board_state.turn.model_dump(), + }) + return result except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -406,6 +466,16 @@ async def move_player(player_id: str, move_req: MoveRequest): "turn": board_state.turn.model_dump(), }) + if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded): + conclusion_obj = result.game_concluded or board_state.conclusion + await manager.broadcast({ + "event": "game_concluded", + "conclusion": conclusion_obj.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( @@ -462,6 +532,21 @@ async def step_bot_ai(player_id: str): "players": [p.model_dump() for p in board_state.players], "turn": result.move_result.turn.model_dump(), }) + if result.move_result.party_formed_triggered and result.move_result.formed_party: + await manager.broadcast({ + "event": "party_formed", + "party": result.move_result.formed_party.model_dump(), + "players": [p.model_dump() for p in board_state.players], + "turn": board_state.turn.model_dump(), + }) + if result.move_result.battle_triggered and result.move_result.battle_result: + await manager.broadcast({ + "event": "battle_resolved", + "battle": result.move_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(), + }) else: await manager.broadcast({ "event": "turn_passed", @@ -469,6 +554,16 @@ async def step_bot_ai(player_id: str): "turn": result.turn.model_dump(), }) + if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded): + conclusion_obj = result.game_concluded or board_state.conclusion + await manager.broadcast({ + "event": "game_concluded", + "conclusion": conclusion_obj.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( @@ -517,43 +612,9 @@ async def pass_turn(player_id: str): @router.get( "/turn", response_model=TurnInfo, - summary="Get current turn details and round information", - tags=["Turn"], + summary="Get current turn details, round number, active player, and turn order", + tags=["Movement"], ) async def get_turn(): - board = await game_engine.get_board_state() - return board.turn - - -# ========================================== -# Board Endpoints -# ========================================== - -@router.get( - "/board", - response_model=BoardState, - summary="Get full board configuration, parties, and player positions", - tags=["Board"], -) -async def get_board(): - return await game_engine.get_board_state() - - -@router.post( - "/board/reset", - response_model=ApiResponse, - summary="Reset the board and clear all players and parties", - tags=["Board"], -) -async def reset_board(): - await game_engine.reset() - board = await game_engine.get_board_state() - await manager.broadcast({ - "event": "board_reset", - "player_count": 0, - "turn": board.turn.model_dump(), - }) - return ApiResponse( - success=True, - message="Board reset successfully. All players cleared.", - ) + board_state = await game_engine.get_board_state() + return board_state.turn diff --git a/backend/app/game.py b/backend/app/game.py index 1f8babc..62e7e3e 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -13,6 +13,7 @@ from app.models import ( BotMemoryResponse, BotRadarResponse, DIRECTION_OFFSETS, + GameConclusion, MoveCheckResult, MoveResponse, Party, @@ -128,6 +129,33 @@ class GameEngine: total = sum(self.players[m].strength for m in party.member_ids if m in self.players) party.total_strength = max(1, total) + def _check_game_concluded(self) -> GameConclusion: + """The game concludes when there is just 1 party left and all bots are in that party.""" + if len(self.players) >= 2 and len(self.parties) == 1: + only_party = list(self.parties.values())[0] + if len(only_party.member_ids) == len(self.players) and all( + p.party_id == only_party.id for p in self.players.values() + ): + rankings = sorted( + self.players.values(), + key=lambda p: (p.score, p.strength), + reverse=True, + ) + return GameConclusion( + concluded=True, + winning_party_id=only_party.id, + winning_party_name=only_party.name, + winning_leader_id=only_party.leader_id, + winning_leader_name=only_party.leader_name, + total_bots=len(self.players), + rankings=rankings, + ) + return GameConclusion(concluded=False) + + async def get_game_conclusion(self) -> GameConclusion: + async with self._lock: + return self._check_game_concluded() + async def register_player(self, player_in: PlayerCreate) -> Player: async with self._lock: player_id = f"bot_{uuid.uuid4().hex[:8]}" @@ -217,10 +245,10 @@ class GameEngine: players=players_list, parties=parties_list, turn=self._get_turn_info(), + conclusion=self._check_game_concluded(), ) - # ========================================== - # Bot Memory & Radar Awareness + # ==========================================\n # Bot Memory & Radar Awareness # ========================================== async def get_bot_memory( @@ -262,10 +290,29 @@ 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) + # Recruitment vs Battle rules: + # 1. Unpartied vs unpartied: recruit (form party) + # 2. Party leader vs solo bot: + # - If solo bot strength <= leader strength: recruit (bot desires equal/stronger leader) + # - If solo bot strength > leader strength: battle (bot refuses weaker leader, party responds with battle!) + # 3. Solo bot vs party: + # - If solo bot strength <= other party leader strength: recruit (join) + # - If solo bot strength > other party leader strength: battle (refuses, party responds with battle) + # 4. Party vs Party: battle + if player.party_id and player.is_party_leader and other.party_id is None: + can_recruit = other.strength <= player.strength + can_battle = other.strength > player.strength + elif player.party_id is None and other.party_id is None: + can_recruit = True + can_battle = False + elif player.party_id is None and other.party_id: + other_lead = self.players.get(party_obj.leader_id) if party_obj else None + lead_str = other_lead.strength if other_lead else 1 + can_recruit = player.strength <= lead_str + can_battle = player.strength > lead_str + else: + can_recruit = False + can_battle = is_enemy and bool(player.party_id and other.party_id) targets.append( RadarTarget( @@ -288,16 +335,14 @@ class GameEngine: targets.sort(key=lambda t: t.distance) # Choose primary target based on goal: - # - If unpartied: seek closest bot to form party with - # - If partied: seek closest enemy party to battle + # - If unpartied: seek closest bot/party to join/form party with + # - If partied: seek closest enemy party or refusing solo bot 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] + battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)] 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) @@ -320,7 +365,7 @@ class GameEngine: break if nearest.distance <= 1: - if bot_goal == "form_party" or nearest.can_recruit: + if nearest.can_recruit: rec_act = "form_party" else: rec_act = "engage_battle" @@ -341,8 +386,7 @@ class GameEngine: recommended_action=rec_act, ) - # ========================================== - # Party Logic & Leadership Negotiation + # ==========================================\n # Party Logic & Leadership Negotiation # ========================================== @staticmethod @@ -367,14 +411,6 @@ 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: @@ -530,8 +566,7 @@ class GameEngine: async with self._lock: return self.parties.get(party_id) - # ========================================== - # Movement Checking & Execution + # ==========================================\n # Movement Checking & Execution # ========================================== def _check_move_internal( @@ -587,7 +622,12 @@ class GameEngine: target_x = player.x + dx target_y = player.y + dy - if target_x < self.config.min_x or target_x > self.config.max_x or target_y < self.config.min_y or target_y > self.config.max_y: + if ( + target_x < self.config.min_x + or target_x > self.config.max_x + or target_y < self.config.min_y + or target_y > self.config.max_y + ): return MoveCheckResult( direction=direction_name, dx=dx, @@ -595,7 +635,7 @@ class GameEngine: target_x=target_x, target_y=target_y, available=False, - reason=f"Wall collision at ({target_x}, {target_y}).", + reason=f"Hit boundary wall at ({target_x}, {target_y}). Coordinates must remain between {self.config.min_x} and {self.config.max_x}.", ) occupant = occupied_map.get((target_x, target_y)) @@ -607,7 +647,7 @@ class GameEngine: target_x=target_x, target_y=target_y, available=False, - reason=f"Space occupied by {occupant.name} ({occupant.id}) at ({target_x}, {target_y}).", + reason=f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.", ) return MoveCheckResult( @@ -689,19 +729,78 @@ 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. + def _check_adjacent_encounter( + self, player: Player + ) -> Tuple[Optional[Party], Optional[BattleResult]]: + """Evaluates immediate adjacent encounters for party formation or battle: + 1. Two unpartied bots meet -> negotiate leadership (stronger leads), form party. + 2. Unpartied bot meets a party: + - If bot strength <= leader strength: bot joins party willingly (equal or stronger leader). + - If bot strength > leader strength: bot refuses to join weaker leader; + party responds by doing battle! + 3. Two opposing parties meet -> engage in 3-bout D20 battle! """ - if player.party_id: - return None + # Case A: Player is in a party + if player.party_id and player.party_id in self.parties: + if not player.is_party_leader: + return None, None - for other in self.players.values(): - if other.id == player.id: - continue - if self._are_adjacent(player, other): + my_party = self.parties[player.party_id] + my_members = [self.players[mid] for mid in my_party.member_ids if mid in self.players] + + for m in my_members: + for other in list(self.players.values()): + if other.id in my_party.member_ids: + continue + if not self._are_adjacent(m, other): + continue + + # Opposing party + if other.party_id and other.party_id in self.parties: + opposing_party = self.parties[other.party_id] + battle_res = self._resolve_3bout_battle_internal(my_party, opposing_party) + return None, battle_res + + # Solo bot (other.party_id is None) + if other.party_id is None: + if other.strength <= player.strength: + # Bot willingly joins party under equal/stronger leader + other.party_id = my_party.id + other.is_party_leader = False + if other.id not in my_party.member_ids: + my_party.member_ids.append(other.id) + self._update_party_strength(my_party) + return my_party, None + else: + # other.strength > player.strength: + # Bot refuses to join weaker leader! Party responds by doing battle! + solo_party_id = f"party_{uuid.uuid4().hex[:8]}" + solo_party = Party( + id=solo_party_id, + name=f"Squad {other.name}", + leader_id=other.id, + leader_name=other.name, + member_ids=[other.id], + total_strength=other.strength, + ) + other.party_id = solo_party_id + other.is_party_leader = True + self.parties[solo_party_id] = solo_party + + battle_res = self._resolve_3bout_battle_internal(my_party, solo_party) + return None, battle_res + + return None, None + + # Case B: Player is a Solo Bot + else: + for other in list(self.players.values()): + if other.id == player.id: + continue + if not self._are_adjacent(player, other): + continue + + # Solo Bot meets another Solo Bot -> form party if other.party_id is None: leader_id = self._negotiate_party_leader_id(player, other) leader = self.players[leader_id] @@ -719,25 +818,46 @@ class GameEngine: 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 + return party, None + + # Solo Bot meets a Party Member + if other.party_id and other.party_id in self.parties: + other_party = self.parties[other.party_id] + leader = self.players.get(other_party.leader_id) + leader_strength = leader.strength if leader else 1 + + if player.strength <= leader_strength: + # Player willingly joins party under equal/stronger leader + player.party_id = other_party.id player.is_party_leader = False - self._update_party_strength(party) - return party - return None + if player.id not in other_party.member_ids: + other_party.member_ids.append(player.id) + self._update_party_strength(other_party) + return other_party, None + else: + # player.strength > leader_strength: + # Player refuses to join weaker leader! Party responds by doing battle! + solo_party_id = f"party_{uuid.uuid4().hex[:8]}" + solo_party = Party( + id=solo_party_id, + name=f"Squad {player.name}", + leader_id=player.id, + leader_name=player.name, + member_ids=[player.id], + total_strength=player.strength, + ) + player.party_id = solo_party_id + player.is_party_leader = True + self.parties[solo_party_id] = solo_party + + battle_res = self._resolve_3bout_battle_internal(other_party, solo_party) + return None, battle_res + + return None, None + + def _check_and_auto_form_party(self, player: Player) -> Optional[Party]: + party, _ = self._check_adjacent_encounter(player) + return party async def move_player( self, player_id: str, dx: int, dy: int, direction_name: str @@ -789,24 +909,12 @@ class GameEngine: new_pos = {"x": player.x, "y": player.y} - # 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 - - 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 + # Check for adjacent interactions (party formation or battle) + formed_party, battle_result = self._check_adjacent_encounter(player) self._advance_turn() turn_info = self._get_turn_info() + conclusion = self._check_game_concluded() return MoveResponse( success=True, @@ -818,8 +926,9 @@ class GameEngine: new_position=new_pos, party_formed_triggered=bool(formed_party), formed_party=formed_party, - battle_triggered=battle_triggered, + battle_triggered=bool(battle_result), battle_result=battle_result, + game_concluded=conclusion if conclusion.concluded else None, turn=turn_info, ) @@ -840,17 +949,10 @@ class GameEngine: self._advance_turn() return self._get_turn_info() - # ========================================== - # Autonomous Goal Step (AI Logic) + # ==========================================\n # 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: @@ -864,43 +966,41 @@ class GameEngine: 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(), - ) + # 1. Check for immediate adjacent encounter before moving + formed_party, battle_res = self._check_adjacent_encounter(player) + if battle_res: + self._advance_turn() + conclusion = self._check_game_concluded() + 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, + game_concluded=conclusion if conclusion.concluded else 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(), - ) + if formed_party: + self._advance_turn() + conclusion = self._check_game_concluded() + 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_party, + battle_result=None, + game_concluded=conclusion if conclusion.concluded else None, + turn=self._get_turn_info(), + ) - # 3. Otherwise: navigate towards target using radar & memory + # 2. Navigate towards target using radar & memory occupied = self._get_occupied_coordinates() moves_map: Dict[str, MoveCheckResult] = {} for name, dx, dy in STANDARD_DIRECTIONS: @@ -908,8 +1008,8 @@ class GameEngine: 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() + conclusion = self._check_game_concluded() return AiStepResponse( action_taken="passed", player_id=player.id, @@ -919,6 +1019,7 @@ class GameEngine: move_result=None, formed_party=None, battle_result=None, + game_concluded=conclusion if conclusion.concluded else None, turn=self._get_turn_info(), ) @@ -934,9 +1035,6 @@ class GameEngine: 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) @@ -944,9 +1042,6 @@ class GameEngine: 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") @@ -960,17 +1055,15 @@ class GameEngine: 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 + score += (old_dist - new_dist) * 10.0 - # Memory penalty for visited coordinates if (tx, ty) not in visited_set: - score += 2.0 # Exploration bonus + score += 2.0 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] = [] @@ -992,22 +1085,12 @@ class GameEngine: 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 + # Check for adjacent interactions after movement + formed_party, battle_result = self._check_adjacent_encounter(player) self._advance_turn() turn_info = self._get_turn_info() + conclusion = self._check_game_concluded() move_res = MoveResponse( success=True, @@ -1019,12 +1102,13 @@ class GameEngine: new_position=new_pos, party_formed_triggered=bool(formed_party), formed_party=formed_party, - battle_triggered=battle_triggered, + battle_triggered=bool(battle_result), battle_result=battle_result, + game_concluded=conclusion if conclusion.concluded else None, turn=turn_info, ) - action_taken = "battled" if battle_triggered else ("formed_party" if formed_party else "moved") + action_taken = "battled" if battle_result else ("formed_party" if formed_party else "moved") return AiStepResponse( action_taken=action_taken, player_id=player.id, @@ -1034,11 +1118,11 @@ class GameEngine: move_result=move_res, formed_party=formed_party, battle_result=battle_result, + game_concluded=conclusion if conclusion.concluded else None, turn=turn_info, ) - # ========================================== - # 3-Bout D20 Battle Mechanics + # ==========================================\n # 3-Bout D20 Battle Mechanics # ========================================== def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]: @@ -1112,23 +1196,6 @@ class GameEngine: 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) @@ -1138,17 +1205,48 @@ class GameEngine: else: member.score += 1 - # Remainder of defeated party joins the winning party + killed_leader_id = defeated_party.leader_id + killed_leader = self.players.get(killed_leader_id) 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) + + if len(defeated_party.member_ids) == 1: + # Defeated party was a single refusing/solo bot: + # The party conquered the solo bot: it loses 1 point (-1 pt) and is absorbed into the winning party! + if killed_leader: + killed_leader.score -= 1 + killed_leader.party_id = winner_party.id + killed_leader.is_party_leader = False + if killed_leader_id not in winner_party.member_ids: + winner_party.member_ids.append(killed_leader_id) + absorbed_members = [killed_leader_id] + respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} + else: + respawn_pos = {"x": 0, "y": 0} + else: + # Multi-bot defeated party: + # Defeated party loses leader: leader gets -1 point, leaves party, and respawns at random position + 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} + + # Remainder of defeated party joins the winning party (scores remain unchanged) + 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: diff --git a/backend/app/models.py b/backend/app/models.py index 09e128d..73a5541 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -289,12 +289,27 @@ class TurnInfo(BaseModel): turn_order: List[str] = [] +# ========================================== +# Game Conclusion & Scoreboard Models +# ========================================== + +class GameConclusion(BaseModel): + concluded: bool = False + winning_party_id: Optional[str] = None + winning_party_name: Optional[str] = None + winning_leader_id: Optional[str] = None + winning_leader_name: Optional[str] = None + total_bots: int = 0 + rankings: List[Player] = [] + + class BoardState(BaseModel): config: BoardConfig player_count: int players: List[Player] parties: List[Party] = [] turn: TurnInfo + conclusion: Optional[GameConclusion] = None class MoveResponse(BaseModel): @@ -309,6 +324,7 @@ class MoveResponse(BaseModel): formed_party: Optional[Party] = None battle_triggered: bool = False battle_result: Optional[BattleResult] = None + game_concluded: Optional[GameConclusion] = None turn: TurnInfo @@ -321,6 +337,7 @@ class AiStepResponse(BaseModel): move_result: Optional[MoveResponse] = None formed_party: Optional[Party] = None battle_result: Optional[BattleResult] = None + game_concluded: Optional[GameConclusion] = None turn: TurnInfo diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 7a4e536..7f098c0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,250 +1,294 @@ import pytest +import asyncio from fastapi.testclient import TestClient from app.main import app from app.game import game_engine @pytest.fixture(autouse=True) -def reset_board_state(): - import asyncio - asyncio.run(game_engine.reset()) +def reset_game_state(): + client = TestClient(app) + client.post("/api/reset") yield - asyncio.run(game_engine.reset()) + client.post("/api/reset") + + +def test_strength_based_leadership_negotiation(): + client = TestClient(app) + + # Register BotStrong with strength 5 and BotWeak with strength 2 + b1 = client.post("/api/players", json={"name": "BotStrong", "color": "#FF0000", "strength": 5}).json() + b2 = client.post("/api/players", json={"name": "BotWeak", "color": "#0000FF", "strength": 2}).json() + + # Place them adjacent to each other + async def place(): + p1 = await game_engine.get_player(b1["id"]) + p1.x, p1.y = 10, 10 + p2 = await game_engine.get_player(b2["id"]) + p2.x, p2.y = 10, 11 + asyncio.run(place()) + + # Step AI turn for the first player in turn order + turn_info = client.get("/api/turn").json() + acting_id = turn_info["current_player_id"] + step_res = client.post(f"/api/players/{acting_id}/ai-step") + assert step_res.status_code == 200 + data = step_res.json() + + # Formed party should have occurred + assert data["action_taken"] == "formed_party" + party = data["formed_party"] + assert party is not None + + # The stronger bot (BotStrong, strength 5) must insist on being the leader! + assert party["leader_id"] == b1["id"] + assert party["leader_name"] == "BotStrong" + assert party["total_strength"] == 7 # 5 + 2 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()) + # Register BotAlpha and BotBravo + b1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#FF0000", "strength": 1}).json() + b2 = client.post("/api/players", json={"name": "BotBravo", "color": "#00FF00", "strength": 1}).json() - # 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} + # Move BotAlpha manually + async def set_loc(): + p1 = await game_engine.get_player(b1["id"]) + p1.x, p1.y = 5, 5 + p1.visited_locations = [{"x": 5, "y": 5}] + p2 = await game_engine.get_player(b2["id"]) + p2.x, p2.y = 5, 8 + asyncio.run(set_loc()) - # 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 + # Check memory: has visited (5, 5) + mem_res = client.get(f"/api/players/{b1['id']}/memory?check_x=5&check_y=5") + assert mem_res.status_code == 200 + mem_data = mem_res.json() + assert mem_data["has_visited_current"] is True + assert mem_data["visited_count"] >= 1 - # Move p1 - res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}) - assert res_move.status_code == 200 + # Check memory: has NOT visited (20, 20) + mem_unvis = client.get(f"/api/players/{b1['id']}/memory?check_x=20&check_y=20").json() + assert mem_unvis["has_visited_current"] is False - # 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} + # Check radar awareness + radar_res = client.get(f"/api/players/{b1['id']}/radar") + assert radar_res.status_code == 200 + radar_data = radar_res.json() + assert radar_data["bot_goal"] == "form_party" + assert len(radar_data["targets"]) >= 1 + assert radar_data["nearest_target"]["name"] == "BotBravo" + assert radar_data["nearest_target"]["distance"] == 3 -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. - """ +def test_unpartied_bot_seeks_party_autonomous_turn(): 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()) + b1 = client.post("/api/players", json={"name": "SeekerA", "color": "#00FFFF", "strength": 1}).json() + b2 = client.post("/api/players", json={"name": "SeekerB", "color": "#FFFF00", "strength": 1}).json() - # 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 + async def setup_grid(): + p1 = await game_engine.get_player(b1["id"]) + p1.x, p1.y = 10, 10 + p2 = await game_engine.get_player(b2["id"]) + p2.x, p2.y = 10, 12 + asyncio.run(setup_grid()) - # 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 + # Execute AI step for SeekerA (distance 2, should step down towards SeekerB) + step_res = client.post(f"/api/players/{b1['id']}/ai-step") + assert step_res.status_code == 200 + data = step_res.json() - 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"] + assert data["bot_goal"] == "form_party" + assert data["action_taken"] in ["moved", "formed_party"] 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() + # Party 1: AlphaLeader + AlphaMember + p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json() + p2 = client.post("/api/players", json={"name": "AlphaWing", "color": "#222222", "strength": 5}).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 + # Party 2: BetaLeader + BetaMember + p3 = client.post("/api/players", json={"name": "BetaLead", "color": "#333333", "strength": 1}).json() + p4 = client.post("/api/players", json={"name": "BetaWing", "color": "#444444", "strength": 1}).json() - 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() - - import asyncio - async def place_adjacent(): + # Position them adjacent + async def setup_combat_positions(): b1 = await game_engine.get_player(p1["id"]) b1.x, b1.y = 10, 10 b2 = await game_engine.get_player(p2["id"]) b2.x, b2.y = 10, 11 - asyncio.run(place_adjacent()) + b3 = await game_engine.get_player(p3["id"]) + b3.x, b3.y = 11, 10 + b4 = await game_engine.get_player(p4["id"]) + b4.x, b4.y = 11, 11 + asyncio.run(setup_combat_positions()) - form_res = client.post("/api/parties", json={ + # Form Party 1 (Strength 10) + client.post("/api/parties", json={ "member_ids": [p1["id"], p2["id"]], "leader_id": p1["id"], "name": "AlphaSquad", }) - assert form_res.status_code == 201 - party = form_res.json() - assert party["leader_id"] == p1["id"] - assert len(party["member_ids"]) == 2 - res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"}) - assert res_follow.status_code == 403 + # Form Party 2 (Strength 2) + client.post("/api/parties", json={ + "member_ids": [p3["id"], p4["id"]], + "leader_id": p3["id"], + "name": "BetaSquad", + }) - 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 + # Fight battle between AlphaLead and BetaLead + battle_res = client.post("/api/battles/fight", json={ + "challenger_id": p1["id"], + "defender_id": p3["id"], + }) + assert battle_res.status_code == 200 + battle = battle_res.json() + + assert len(battle["bouts"]) == 3 + assert battle["winner_party_name"] in ["AlphaSquad", "BetaSquad"] + + winner_leader = client.get(f"/api/players/{battle['winner_leader_id']}").json() + assert winner_leader["score"] >= 2 + + killed_leader = client.get(f"/api/players/{battle['killed_leader_id']}").json() + assert killed_leader["score"] == -1 + assert killed_leader["party_id"] is None + + assert battle["new_party_size"] >= 2 -def test_party_wall_collision_for_follower(): +def test_game_conclusion_and_scoreboard_rankings(): 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() - import asyncio - async def setup_near_wall(): + p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#FFD700", "strength": 5}).json() + p2 = client.post("/api/players", json={"name": "BetaRival", "color": "#C0C0C0", "strength": 3}).json() + p3 = client.post("/api/players", json={"name": "GammaHero", "color": "#CD7F32", "strength": 2}).json() + p4 = client.post("/api/players", json={"name": "DeltaCadet", "color": "#4A5568", "strength": 1}).json() + + # Position all players contiguously within 1 unit + async def setup_positions(): b1 = await game_engine.get_player(p1["id"]) - b1.x, b1.y = 5, 1 - b2 = await game_engine.get_player(p2["id"]) - b2.x, b2.y = 5, 0 - asyncio.run(setup_near_wall()) + b1.x, b1.y = 10, 10 + b1.score = 6 # 1st place + b2 = await game_engine.get_player(p2["id"]) + b2.x, b2.y = 10, 11 + b2.score = 4 # 2nd place + + b3 = await game_engine.get_player(p3["id"]) + b3.x, b3.y = 10, 12 + b3.score = 2 # 3rd place + + b4 = await game_engine.get_player(p4["id"]) + b4.x, b4.y = 10, 13 + b4.score = 0 # 4th place + asyncio.run(setup_positions()) + + # Before joining all into 1 party, conclusion should be false + conc_before = client.get("/api/game/conclusion").json() + assert conc_before["concluded"] is False + + # Form 1 party that contains all 4 bots + party_res = client.post("/api/parties", json={ + "member_ids": [p1["id"], p2["id"], p3["id"], p4["id"]], + "leader_id": p1["id"], + "name": "UnitedLegion", + }) + assert party_res.status_code == 201 + + # Now there is just 1 party and all bots are in that party -> game concluded! + conc_after = client.get("/api/game/conclusion").json() + assert conc_after["concluded"] is True + assert conc_after["winning_party_name"] == "UnitedLegion" + assert conc_after["winning_leader_name"] == "AlphaLeader" + assert conc_after["total_bots"] == 4 + + # Verify scoreboard rankings (highest score on top) + rankings = conc_after["rankings"] + assert len(rankings) == 4 + assert rankings[0]["id"] == p1["id"] + assert rankings[0]["score"] == 6 + assert rankings[1]["id"] == p2["id"] + assert rankings[1]["score"] == 4 + assert rankings[2]["id"] == p3["id"] + assert rankings[2]["score"] == 2 + assert rankings[3]["id"] == p4["id"] + assert rankings[3]["score"] == 0 + + # Board state also reflects conclusion + board = client.get("/api/board").json() + assert board["conclusion"]["concluded"] is True + + +def test_solo_bot_refuses_weaker_leader_party_responds_with_battle(): + """When a single bot is adjacent to a party with a lower strength leader, + the bot refuses to join. The party responds by doing battle! + When the party conquers the solo bot, the solo bot loses 1 point and is absorbed into the party. + If all bots are now in the party, the game concludes! + """ + client = TestClient(app) + + # Party leader has strength 1, teammate has strength 10 (party total strength 11) + p1 = client.post("/api/players", json={"name": "WeakLeader", "color": "#111111", "strength": 1}).json() + p2 = client.post("/api/players", json={"name": "HeavyFollower", "color": "#222222", "strength": 10}).json() + + # Solo bot has strength 5 (strength 5 > leader's strength 1, so solo bot refuses to join!) + p3 = client.post("/api/players", json={"name": "ProudSolo", "color": "#FF0000", "strength": 5}).json() + + async def setup_positions(): + b1 = await game_engine.get_player(p1["id"]) + b1.x, b1.y = 10, 10 + b2 = await game_engine.get_player(p2["id"]) + b2.x, b2.y = 10, 11 + b3 = await game_engine.get_player(p3["id"]) + b3.x, b3.y = 11, 10 # adjacent to WeakLeader + asyncio.run(setup_positions()) + + # Form party with WeakLeader and HeavyFollower client.post("/api/parties", json={ "member_ids": [p1["id"], p2["id"]], "leader_id": p1["id"], + "name": "WeakSquad", }) - 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"] + # WeakLeader (or ProudSolo) takes their turn + turn_info = client.get("/api/turn").json() + acting_id = turn_info["current_player_id"] + + step_res = client.post(f"/api/players/{acting_id}/ai-step") + assert step_res.status_code == 200 + data = step_res.json() + + # Action taken must be "battled" because ProudSolo refused to join the weaker leader! + assert data["action_taken"] == "battled" + assert data["battle_result"] is not None + + battle = data["battle_result"] + assert len(battle["bouts"]) == 3 + + # Check board state + board = client.get("/api/board").json() + + # Both parties or party + solo bot resolved + # If WeakSquad (total str 11) defeated ProudSolo (str 5): + if battle["winner_party_name"] == "WeakSquad": + # ProudSolo was conquered, received -1 point, and was absorbed into WeakSquad + solo_after = client.get(f"/api/players/{p3['id']}").json() + assert solo_after["score"] == -1 + assert solo_after["party_id"] == battle["winner_party_id"] + + # WeakSquad leader received +2 points + lead_after = client.get(f"/api/players/{p1['id']}").json() + assert lead_after["score"] == 2 + + # All 3 bots on board are now in WeakSquad -> Game concluded! + conc = client.get("/api/game/conclusion").json() + assert conc["concluded"] is True + assert conc["winning_party_name"] == "WeakSquad" + assert conc["total_bots"] == 3 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0598c60..b0a062d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { MovementControls } from './components/MovementControls'; import { PartyModal } from './components/PartyModal'; import { PlayerList } from './components/PlayerList'; import { RegisterModal } from './components/RegisterModal'; +import { ScoreboardModal } from './components/ScoreboardModal'; const BOT_NAMES = [ 'ViperStrike', @@ -44,6 +45,8 @@ export function App() { lastEventMessage, activeBattle, setActiveBattle, + showScoreboard, + setShowScoreboard, registerPlayer, removePlayer, formParty, @@ -119,6 +122,8 @@ export function App() { } }; + const isConcluded = Boolean(boardState.conclusion && boardState.conclusion.concluded); + return (