From d2f2eed3f7bf2b8a4acd676e6a2def409909c804 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sat, 5 Sep 2026 18:50:27 -0500 Subject: [PATCH] scoring, game win, noise --- backend/app/api/routes.py | 219 ++++--- backend/app/game.py | 438 ++++++++----- backend/app/models.py | 17 + backend/tests/test_api.py | 438 +++++++------ frontend/src/App.tsx | 20 + frontend/src/components/BattleModal.tsx | 67 +- frontend/src/components/Header.tsx | 20 + frontend/src/components/ScoreboardModal.tsx | 648 ++++++++++++++++++++ frontend/src/hooks/useGameSocket.ts | 39 ++ frontend/src/types.ts | 13 + 10 files changed, 1459 insertions(+), 460 deletions(-) create mode 100644 frontend/src/components/ScoreboardModal.tsx 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 (
{/* Top Navigation Bar */} @@ -128,6 +133,8 @@ export function App() { onQuickSpawn={handleQuickSpawn} onResetBoard={handleReset} playerCount={boardState.player_count} + isConcluded={isConcluded} + onOpenScoreboard={() => setShowScoreboard(true)} /> {/* Main Content Area */} @@ -202,6 +209,19 @@ export function App() { battle={activeBattle} onClose={() => setActiveBattle(null)} /> + + {/* Game Conclusion Scoreboard Modal */} + {showScoreboard && boardState.conclusion && boardState.conclusion.concluded && ( + setShowScoreboard(false)} + onReset={async () => { + await resetBoard(); + setShowScoreboard(false); + }} + /> + )}
); } diff --git a/frontend/src/components/BattleModal.tsx b/frontend/src/components/BattleModal.tsx index f1d35df..b9cc2e1 100644 --- a/frontend/src/components/BattleModal.tsx +++ b/frontend/src/components/BattleModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { BattleResult } from '../types'; interface BattleModalProps { @@ -7,6 +7,30 @@ interface BattleModalProps { } export const BattleModal: React.FC = ({ battle, onClose }) => { + const [countdown, setCountdown] = useState(5); + + useEffect(() => { + if (!battle) return; + + // Reset countdown to 5 whenever a new battle modal is opened + setCountdown(5); + + const timer = window.setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + window.clearInterval(timer); + onClose(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [battle, onClose]); + if (!battle) return null; return ( @@ -15,7 +39,15 @@ export const BattleModal: React.FC = ({ battle, onClose }) => {/* Glowing cyber header banner */}
-
+ {/* 5-second automatic progress bar */} +
+
+
+ +
⚔️
@@ -30,6 +62,7 @@ export const BattleModal: React.FC = ({ battle, onClose }) => @@ -99,17 +132,20 @@ export const BattleModal: React.FC = ({ battle, onClose }) =>
• Defeated Leader {battle.killed_leader_name} receives{' '} - -1 point, leaves the party, and respawns at{' '} - - ({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y}) - - . (Surviving defeated bots lose 0 points). + -1 point. + {battle.absorbed_members.includes(battle.killed_leader_id) ? ( + Resistance broken! Surrendered and joined {battle.winner_party_name}. + ) : ( + Respawned at ({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y}). + )}
-
- • {battle.absorbed_members.length} surviving bot(s) from the - defeated party surrendered and joined {battle.winner_party_name}! -
+ {battle.absorbed_members.length > 0 && !battle.absorbed_members.includes(battle.killed_leader_id) && ( +
+ • {battle.absorbed_members.length} surviving bot(s) from the + defeated party surrendered and joined {battle.winner_party_name}! +
+ )}
New Squad Size: {battle.new_party_size} bots • Total Strength:{' '} @@ -117,12 +153,15 @@ export const BattleModal: React.FC = ({ battle, onClose }) =>
- {/* Continue Button */} + {/* Continue Button with 5s Auto-close Countdown */}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 21e74af..b8998e3 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -6,6 +6,8 @@ interface HeaderProps { onQuickSpawn: () => void; onResetBoard: () => void; playerCount: number; + isConcluded?: boolean; + onOpenScoreboard?: () => void; } export const Header: React.FC = ({ @@ -14,6 +16,8 @@ export const Header: React.FC = ({ onQuickSpawn, onResetBoard, playerCount, + isConcluded, + onOpenScoreboard, }) => { return (
@@ -47,10 +51,26 @@ export const Header: React.FC = ({ {isConnected ? 'LIVE SYNC' : 'CONNECTING...'}
+ + {/* Game concluded banner tag */} + {isConcluded && ( +
+ 🏆 GAME CONCLUDED +
+ )}
{/* Action Buttons */}
+ {isConcluded && onOpenScoreboard && ( + + )} + void; + onReset: () => void; +} + +// Web Audio API Synthesizer for Victory Trumpet Fanfare +export function playVictoryTrumpetFanfare() { + try { + const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + if (!AudioCtx) return; + const ctx = new AudioCtx(); + + if (ctx.state === 'suspended') { + ctx.resume(); + } + + // Brass notes sequence: G4 -> C5 -> E5 -> G5 -> E5 -> G5 -> C6 (triumphant climax) + const notes = [ + { freq: 392.00, start: 0.0, duration: 0.18, vol: 0.35 }, // G4 + { freq: 523.25, start: 0.20, duration: 0.18, vol: 0.38 }, // C5 + { freq: 659.25, start: 0.40, duration: 0.18, vol: 0.40 }, // E5 + { freq: 783.99, start: 0.60, duration: 0.38, vol: 0.45 }, // G5 + { freq: 659.25, start: 1.05, duration: 0.16, vol: 0.38 }, // E5 + { freq: 783.99, start: 1.23, duration: 0.22, vol: 0.42 }, // G5 + { freq: 1046.50, start: 1.48, duration: 0.85, vol: 0.50 }, // C6 (grand finale) + ]; + + notes.forEach(({ freq, start, duration, vol }) => { + const startTime = ctx.currentTime + start; + const stopTime = startTime + duration; + + // Primary trumpet oscillator (sawtooth for brass buzz) + const osc1 = ctx.createOscillator(); + osc1.type = 'sawtooth'; + osc1.frequency.setValueAtTime(freq, startTime); + + // Secondary oscillator (square slightly detuned for chorus / rich brass body) + const osc2 = ctx.createOscillator(); + osc2.type = 'square'; + osc2.frequency.setValueAtTime(freq * 1.002, startTime); + + // Low-pass filter to simulate trumpet brass bell resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(freq * 1.8, startTime); + filter.frequency.exponentialRampToValueAtTime(freq * 3.5, startTime + 0.05); + filter.frequency.exponentialRampToValueAtTime(freq * 1.5, stopTime); + + // Amplitude Envelope for crisp brass attack & gentle release + const gainNode = ctx.createGain(); + gainNode.gain.setValueAtTime(0.0001, startTime); + gainNode.gain.linearRampToValueAtTime(vol, startTime + 0.03); + gainNode.gain.setValueAtTime(vol * 0.9, startTime + duration * 0.7); + gainNode.gain.exponentialRampToValueAtTime(0.0001, stopTime); + + osc1.connect(filter); + osc2.connect(filter); + filter.connect(gainNode); + gainNode.connect(ctx.destination); + + osc1.start(startTime); + osc2.start(startTime); + osc1.stop(stopTime); + osc2.stop(stopTime); + }); + } catch (err) { + console.warn('Could not play Web Audio fanfare:', err); + } +} + +export const ScoreboardModal: React.FC = ({ + conclusion, + players, + onClose, + onReset, +}) => { + const canvasRef = useRef(null); + + // Play trumpet victory fanfare when modal mounts / opens + useEffect(() => { + if (conclusion?.concluded) { + playVictoryTrumpetFanfare(); + } + }, [conclusion?.concluded]); + + // Confetti Particle Winning Animation + useEffect(() => { + if (!conclusion?.concluded) return; + + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animFrameId: number; + let width = (canvas.width = canvas.parentElement?.clientWidth || window.innerWidth); + let height = (canvas.height = canvas.parentElement?.clientHeight || window.innerHeight); + + const handleResize = () => { + if (canvas && canvas.parentElement) { + width = canvas.width = canvas.parentElement.clientWidth; + height = canvas.height = canvas.parentElement.clientHeight; + } + }; + window.addEventListener('resize', handleResize); + + const colors = ['#FFD700', '#FF4500', '#00FFCC', '#FF00AA', '#7928CA', '#00DFD8', '#FFFFFF', '#38EF7D']; + const particleCount = 100; + const particles = Array.from({ length: particleCount }).map(() => ({ + x: Math.random() * width, + y: Math.random() * -height, + size: Math.random() * 8 + 4, + color: colors[Math.floor(Math.random() * colors.length)], + speedY: Math.random() * 3 + 2, + speedX: (Math.random() - 0.5) * 3, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.1, + shape: Math.random() > 0.4 ? 'rect' : 'circle', + })); + + const render = () => { + ctx.clearRect(0, 0, width, height); + + particles.forEach((p) => { + p.y += p.speedY; + p.x += p.speedX; + p.rotation += p.rotationSpeed; + + if (p.y > height) { + p.y = -10; + p.x = Math.random() * width; + } + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rotation); + ctx.fillStyle = p.color; + + if (p.shape === 'rect') { + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6); + } else { + ctx.beginPath(); + ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + }); + + animFrameId = requestAnimationFrame(render); + }; + + render(); + + return () => { + cancelAnimationFrame(animFrameId); + window.removeEventListener('resize', handleResize); + }; + }, [conclusion?.concluded]); + + if (!conclusion || !conclusion.concluded) { + return null; + } + + // Sorted rankings list (highest score on top) + const rankedPlayers: Player[] = conclusion.rankings && conclusion.rankings.length > 0 + ? conclusion.rankings + : [...players].sort((a, b) => b.score - a.score || b.strength - a.strength); + + return ( +
+ {/* Background celebration canvas */} + + +
+ {/* Header Ribbon / Trophy Display */} +
+
+ 🏆 +
+

VICTORY! ALL BOTS UNITED

+

+ The game has concluded! Every bot has united into the supreme party{' '} + "{conclusion.winning_party_name || 'Dominant Squad'}"! +

+ +
+ +
+
+ + {/* Podium for Top 3 */} +
+ {/* 2nd Place */} + {rankedPlayers[1] && ( +
+
🥈
+
2nd Place
+
+ {rankedPlayers[1].name.substring(0, 2).toUpperCase()} +
+
{rankedPlayers[1].name}
+
{rankedPlayers[1].score} pts
+
⚡ {rankedPlayers[1].strength} STR
+
+ )} + + {/* 1st Place (Center / Tallest) */} + {rankedPlayers[0] && ( +
+
👑
+
🥇
+
+ 1st Place Champion +
+
+ {rankedPlayers[0].name.substring(0, 2).toUpperCase()} +
+
{rankedPlayers[0].name}
+
+ {rankedPlayers[0].score} pts +
+
⚡ {rankedPlayers[0].strength} STR
+ {rankedPlayers[0].id === conclusion.winning_leader_id && ( +
Supreme Leader
+ )} +
+ )} + + {/* 3rd Place */} + {rankedPlayers[2] && ( +
+
🥉
+
3rd Place
+
+ {rankedPlayers[2].name.substring(0, 2).toUpperCase()} +
+
{rankedPlayers[2].name}
+
{rankedPlayers[2].score} pts
+
⚡ {rankedPlayers[2].strength} STR
+
+ )} +
+ + {/* Complete Scoreboard Rankings Table */} +
+

🏆 Complete Scoreboard Rankings

+
+ + + + + + + + + + + + + {rankedPlayers.map((player, idx) => { + const rank = idx + 1; + let rankBadge = `#${rank}`; + let rowStyle = styles.tr; + + if (rank === 1) { + rankBadge = '🥇 1st'; + rowStyle = { ...styles.tr, ...styles.goldRow }; + } else if (rank === 2) { + rankBadge = '🥈 2nd'; + rowStyle = { ...styles.tr, ...styles.silverRow }; + } else if (rank === 3) { + rankBadge = '🥉 3rd'; + rowStyle = { ...styles.tr, ...styles.bronzeRow }; + } + + const isLeader = player.id === conclusion.winning_leader_id; + + return ( + + + + + + + + + ); + })} + +
RankBotParty RoleStrengthVisited TilesFinal Score
{rankBadge} + + {player.name} + {isLeader && 👑 Leader} + + {isLeader ? 'Supreme Leader' : 'Squad Member'} + ⚡ {player.strength} + 🗺️ {player.visited_locations?.length || 1} + + {player.score} pts +
+
+
+ + {/* Modal Action Buttons */} +
+ + +
+
+
+ ); +}; + +const styles: Record = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(10, 14, 26, 0.88)', + backdropFilter: 'blur(8px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + padding: '20px', + }, + canvas: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + pointerEvents: 'none', + zIndex: 1, + }, + modal: { + position: 'relative', + zIndex: 2, + backgroundColor: '#161b22', + border: '2px solid #30363d', + borderRadius: '16px', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.8), 0 0 40px rgba(255, 215, 0, 0.25)', + maxWidth: '780px', + width: '100%', + maxHeight: '90vh', + overflowY: 'auto', + padding: '28px', + color: '#f0f6fc', + display: 'flex', + flexDirection: 'column', + gap: '20px', + }, + header: { + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px', + }, + trophyBurst: { + fontSize: '52px', + animation: 'bounce 1.5s infinite', + marginBottom: '-8px', + }, + trophyIcon: { + filter: 'drop-shadow(0 0 16px rgba(255, 215, 0, 0.8))', + }, + title: { + fontSize: '28px', + fontWeight: 800, + letterSpacing: '1px', + background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FF4500 100%)', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + margin: 0, + }, + subtitle: { + fontSize: '15px', + color: '#8b949e', + margin: 0, + maxWidth: '540px', + }, + goldText: { + color: '#FFD700', + }, + fanfareControls: { + marginTop: '6px', + }, + fanfareButton: { + backgroundColor: '#21262d', + border: '1px solid #FFD700', + color: '#FFD700', + borderRadius: '20px', + padding: '6px 14px', + fontSize: '13px', + cursor: 'pointer', + fontWeight: 600, + transition: 'all 0.2s', + }, + podiumContainer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'flex-end', + gap: '14px', + padding: '12px 0', + }, + podiumCard: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + borderRadius: '12px', + padding: '14px 12px', + width: '180px', + position: 'relative', + boxShadow: '0 8px 24px rgba(0,0,0,0.4)', + }, + goldCard: { + backgroundColor: 'rgba(255, 215, 0, 0.08)', + border: '2px solid #FFD700', + minHeight: '210px', + order: 2, + transform: 'scale(1.05)', + }, + silverCard: { + backgroundColor: 'rgba(192, 192, 192, 0.06)', + border: '2px solid #C0C0C0', + minHeight: '180px', + order: 1, + }, + bronzeCard: { + backgroundColor: 'rgba(205, 127, 50, 0.06)', + border: '2px solid #CD7F32', + minHeight: '170px', + order: 3, + }, + crown: { + position: 'absolute', + top: '-18px', + fontSize: '24px', + filter: 'drop-shadow(0 0 8px rgba(255,215,0,0.8))', + }, + medalBadge: { + fontSize: '26px', + marginBottom: '4px', + }, + placeLabel: { + fontSize: '12px', + color: '#8b949e', + textTransform: 'uppercase', + letterSpacing: '0.5px', + marginBottom: '8px', + }, + avatarCircle: { + width: '42px', + height: '42px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 'bold', + color: '#ffffff', + textShadow: '0 1px 2px rgba(0,0,0,0.8)', + marginBottom: '8px', + }, + podiumBotName: { + fontWeight: 700, + fontSize: '14px', + textAlign: 'center', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '150px', + }, + podiumScore: { + fontSize: '18px', + fontWeight: 800, + marginTop: '4px', + }, + podiumDetails: { + fontSize: '11px', + color: '#8b949e', + marginTop: '2px', + }, + leaderBadge: { + backgroundColor: '#FFD700', + color: '#0d1117', + fontSize: '10px', + fontWeight: 'bold', + borderRadius: '10px', + padding: '2px 8px', + marginTop: '6px', + }, + rankingsSection: { + backgroundColor: '#0d1117', + border: '1px solid #30363d', + borderRadius: '10px', + padding: '16px', + }, + rankingsTitle: { + fontSize: '16px', + fontWeight: 700, + margin: '0 0 12px 0', + color: '#c9d1d9', + }, + tableWrapper: { + overflowX: 'auto', + }, + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '13px', + }, + th: { + textAlign: 'left', + padding: '8px 12px', + color: '#8b949e', + borderBottom: '1px solid #21262d', + fontWeight: 600, + }, + tr: { + borderBottom: '1px solid #161b22', + }, + goldRow: { + backgroundColor: 'rgba(255, 215, 0, 0.08)', + }, + silverRow: { + backgroundColor: 'rgba(192, 192, 192, 0.05)', + }, + bronzeRow: { + backgroundColor: 'rgba(205, 127, 50, 0.05)', + }, + tdRank: { + padding: '10px 12px', + fontWeight: 700, + fontSize: '14px', + }, + tdBot: { + padding: '10px 12px', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + botColorIndicator: { + width: '14px', + height: '14px', + borderRadius: '50%', + display: 'inline-block', + }, + botNameText: { + fontWeight: 600, + }, + inlineLeaderTag: { + backgroundColor: '#FFD700', + color: '#000', + fontSize: '10px', + fontWeight: 'bold', + borderRadius: '8px', + padding: '1px 6px', + }, + tdRole: { + padding: '10px 12px', + color: '#8b949e', + }, + tdStrength: { + padding: '10px 12px', + fontWeight: 600, + }, + tdVisited: { + padding: '10px 12px', + color: '#8b949e', + }, + tdScore: { + padding: '10px 12px', + }, + scorePill: { + backgroundColor: '#238636', + color: '#fff', + padding: '2px 8px', + borderRadius: '12px', + fontWeight: 700, + }, + footer: { + display: 'flex', + justifyContent: 'flex-end', + gap: '12px', + marginTop: '6px', + }, + resetButton: { + backgroundColor: '#238636', + color: '#fff', + border: 'none', + borderRadius: '8px', + padding: '10px 18px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, + closeButton: { + backgroundColor: '#21262d', + color: '#c9d1d9', + border: '1px solid #30363d', + borderRadius: '8px', + padding: '10px 18px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, +}; diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index b14ebcc..d363216 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -4,6 +4,7 @@ import type { AvailableMovesResponse, BattleResult, BoardState, + GameConclusion, MoveResponse, Party, PartyDefeatResult, @@ -30,6 +31,7 @@ const INITIAL_BOARD: BoardState = { turn_number: 0, turn_order: [], }, + conclusion: null, }; export function useGameSocket() { @@ -40,6 +42,7 @@ export function useGameSocket() { const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [lastEventMessage, setLastEventMessage] = useState(null); const [activeBattle, setActiveBattle] = useState(null); + const [showScoreboard, setShowScoreboard] = useState(false); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); @@ -53,7 +56,11 @@ export function useGameSocket() { setBoardState((prev) => ({ ...data, parties: data.parties || prev.parties || [], + conclusion: data.conclusion || null, })); + if (data.conclusion && data.conclusion.concluded) { + setShowScoreboard(true); + } } } catch (err) { console.error('Failed to fetch board state:', err); @@ -98,7 +105,11 @@ export function useGameSocket() { setBoardState({ ...data.state, parties: data.state.parties || [], + conclusion: data.state.conclusion || null, }); + if (data.state.conclusion && data.state.conclusion.concluded) { + setShowScoreboard(true); + } } else if (data.event === 'player_joined') { setBoardState((prev) => { const exists = prev.players.some((p) => p.id === data.player.id); @@ -163,6 +174,20 @@ export function useGameSocket() { const b: BattleResult = data.battle; setActiveBattle(b); setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`); + } else if (data.event === 'game_concluded') { + const conc: GameConclusion = data.conclusion; + setBoardState((prev) => ({ + ...prev, + players: data.players ?? prev.players, + parties: data.parties ?? prev.parties, + turn: data.turn ?? prev.turn, + conclusion: conc, + })); + setIsAutoPlaying(false); + setShowScoreboard(true); + setLastEventMessage( + `🏆 VICTORY! Game concluded! All bots united under "${conc.winning_party_name}" led by ${conc.winning_leader_name}!` + ); } else if (data.event === 'turn_passed') { setBoardState((prev) => ({ ...prev, @@ -187,10 +212,12 @@ export function useGameSocket() { players: [], parties: [], turn: data.turn ?? INITIAL_BOARD.turn, + conclusion: null, })); setSelectedPlayer(null); setAvailableMoves(null); setActiveBattle(null); + setShowScoreboard(false); } } catch (err) { console.error('Error parsing WebSocket message:', err); @@ -324,6 +351,10 @@ export function useGameSocket() { if (data.battle_triggered && data.battle_result) { setActiveBattle(data.battle_result); } + if (data.game_concluded && data.game_concluded.concluded) { + setIsAutoPlaying(false); + setShowScoreboard(true); + } return data; }; @@ -346,6 +377,7 @@ export function useGameSocket() { if (!res.ok) { throw new Error('Failed to reset board'); } + setShowScoreboard(false); }; // Step active bot turn according to its explicit autonomous goal: @@ -369,6 +401,11 @@ export function useGameSocket() { } else if (data.move_result?.battle_result) { setActiveBattle(data.move_result.battle_result); } + + if (data.game_concluded && data.game_concluded.concluded) { + setIsAutoPlaying(false); + setShowScoreboard(true); + } } } catch (err) { console.warn('Bot AI step error:', err); @@ -404,6 +441,8 @@ export function useGameSocket() { lastEventMessage, activeBattle, setActiveBattle, + showScoreboard, + setShowScoreboard, registerPlayer, removePlayer, formParty, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ec6e62c..56527ae 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -39,12 +39,23 @@ export interface TurnInfo { turn_order: string[]; } +export interface GameConclusion { + concluded: boolean; + winning_party_id?: string | null; + winning_party_name?: string | null; + winning_leader_id?: string | null; + winning_leader_name?: string | null; + total_bots?: number; + rankings?: Player[]; +} + export interface BoardState { config: GridConfig; player_count: number; players: Player[]; parties: Party[]; turn: TurnInfo; + conclusion?: GameConclusion | null; } export interface MoveCheckResult { @@ -116,6 +127,7 @@ export interface MoveResponse { formed_party?: Party | null; battle_triggered?: boolean; battle_result?: BattleResult | null; + game_concluded?: GameConclusion | null; turn: TurnInfo; } @@ -128,6 +140,7 @@ export interface AiStepResponse { move_result?: MoveResponse | null; formed_party?: Party | null; battle_result?: BattleResult | null; + game_concluded?: GameConclusion | null; turn: TurnInfo; }