diff --git a/backend/app/game.py b/backend/app/game.py index 62e7e3e..2f8e9ae 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -16,6 +16,7 @@ from app.models import ( GameConclusion, MoveCheckResult, MoveResponse, + Obstacle, Party, PartyDefeatResult, PartyInvite, @@ -57,34 +58,198 @@ class GameEngine: grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y, ) + # Procedural Impassable Obstacles (Mountains & Valleys) + # Guaranteed: <= 50% impassable, and all passable tiles form a single connected component + self.obstacles: Dict[Tuple[int, int], Obstacle] = self._generate_terrain() + + def _generate_terrain(self) -> Dict[Tuple[int, int], Obstacle]: + """Generates procedural mountain ranges and valley chasms subject to: + 1. Obstacles must NOT exceed 50% of the map (kept at ~20-35%). + 2. Every passable coordinate must belong to a single connected component (no isolated pockets). + """ + min_x, max_x = self.config.min_x, self.config.max_x + min_y, max_y = self.config.min_y, self.config.max_y + total_cells = (max_x - min_x + 1) * (max_y - min_y + 1) + max_allowed_obstacles = int(total_cells * 0.40) # Safe margin below 50% + + obstacles: Dict[Tuple[int, int], Obstacle] = {} + + def add_obstacle(x: int, y: int, obs_type: str): + if min_x <= x <= max_x and min_y <= y <= max_y: + obstacles[(x, y)] = Obstacle(x=x, y=y, type=obs_type) + + # 1. Procedural Mountain ranges (6-8 organic ridges) + for _ in range(random.randint(6, 8)): + curr_x = random.randint(min_x + 5, max_x - 5) + curr_y = random.randint(min_y + 5, max_y - 5) + length = random.randint(18, 32) + dx = random.choice([-1, 0, 1]) + dy = random.choice([-1, 0, 1]) + if dx == 0 and dy == 0: + dx, dy = 1, 1 + + for _ in range(length): + add_obstacle(curr_x, curr_y, "mountain") + if random.random() < 0.35: + add_obstacle(curr_x + dy, curr_y - dx, "mountain") + + if random.random() < 0.3: + dx += random.choice([-1, 0, 1]) + dx = max(-1, min(1, dx)) + if random.random() < 0.3: + dy += random.choice([-1, 0, 1]) + dy = max(-1, min(1, dy)) + if dx == 0 and dy == 0: + dx = 1 + curr_x += dx + curr_y += dy + + # 2. Procedural Valleys / chasms (5-7 winding gorges) + for _ in range(random.randint(5, 7)): + curr_x = random.randint(min_x + 5, max_x - 5) + curr_y = random.randint(min_y + 5, max_y - 5) + length = random.randint(16, 28) + angle_dx = random.choice([-1, 1]) + angle_dy = random.choice([-1, 1]) + + for _ in range(length): + if (curr_x, curr_y) not in obstacles: + add_obstacle(curr_x, curr_y, "valley") + if random.random() < 0.35: + curr_x += random.choice([-1, 0, 1]) + else: + curr_x += angle_dx + if random.random() < 0.35: + curr_y += random.choice([-1, 0, 1]) + else: + curr_y += angle_dy + + # Enforce max allowed obstacles (cap strictly below 50%) + if len(obstacles) > max_allowed_obstacles: + keys_to_remove = random.sample(list(obstacles.keys()), len(obstacles) - max_allowed_obstacles) + for k in keys_to_remove: + del obstacles[k] + + # 3. Connectivity Verification & Single Component Guarantee: + # All passable cells must form exactly ONE connected component. + all_coords = set( + (x, y) for x in range(min_x, max_x + 1) for y in range(min_y, max_y + 1) + ) + passable = all_coords - set(obstacles.keys()) + + visited: Set[Tuple[int, int]] = set() + components: List[Set[Tuple[int, int]]] = [] + + for cell in passable: + if cell in visited: + continue + comp: Set[Tuple[int, int]] = set() + queue = [cell] + visited.add(cell) + comp.add(cell) + + while queue: + cx, cy = queue.pop(0) + for nx, ny in [(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)]: + if min_x <= nx <= max_x and min_y <= ny <= max_y: + if (nx, ny) in passable and (nx, ny) not in visited: + visited.add((nx, ny)) + comp.add((nx, ny)) + queue.append((nx, ny)) + components.append(comp) + + if not components: + return {} + + components.sort(key=lambda c: len(c), reverse=True) + main_component = components[0] + + # Connect all isolated components to main_component by carving passes through obstacles + for other_comp in components[1:]: + src = next(iter(other_comp)) + sample_size = min(len(main_component), 50) + sample_targets = random.sample(list(main_component), sample_size) + target = min(sample_targets, key=lambda c: abs(c[0] - src[0]) + abs(c[1] - src[1])) + + cx, cy = src + tx, ty = target + while (cx, cy) != (tx, ty): + if cx < tx: + cx += 1 + elif cx > tx: + cx -= 1 + elif cy < ty: + cy += 1 + elif cy > ty: + cy -= 1 + + if (cx, cy) in obstacles: + del obstacles[(cx, cy)] + main_component.add((cx, cy)) + main_component.update(other_comp) + + return obstacles + + def _find_path_bfs( + self, start: Tuple[int, int], goal: Tuple[int, int], max_depth: int = 50 + ) -> Optional[List[Tuple[int, int]]]: + """Breadth-first search pathfinder finding shortest passable path avoiding obstacles.""" + if start == goal: + return [start] + + queue: List[Tuple[Tuple[int, int], List[Tuple[int, int]]]] = [(start, [start])] + visited: Set[Tuple[int, int]] = {start} + + while queue: + (curr_x, curr_y), path = queue.pop(0) + if len(path) > max_depth: + break + + for _, dx, dy in STANDARD_DIRECTIONS: + nx, ny = curr_x + dx, curr_y + dy + if ( + self.config.min_x <= nx <= self.config.max_x + and self.config.min_y <= ny <= self.config.max_y + and (nx, ny) not in self.obstacles + and (nx, ny) not in visited + ): + new_path = path + [(nx, ny)] + if (nx, ny) == goal: + return new_path + visited.add((nx, ny)) + queue.append(((nx, ny), new_path)) + + return None + def _get_occupied_coordinates(self) -> Dict[Tuple[int, int], Player]: return {(p.x, p.y): p for p in self.players.values()} def _find_random_free_position(self) -> Tuple[int, int]: + """Finds a random free position strictly excluding occupied tiles AND impassable obstacles.""" occupied = self._get_occupied_coordinates() - total_possible = (self.config.max_x - self.config.min_x + 1) * ( - self.config.max_y - self.config.min_y + 1 - ) - if len(occupied) >= total_possible: - return ( - random.randint(self.config.min_x, self.config.max_x), - random.randint(self.config.min_y, self.config.max_y), - ) - - for _ in range(100): + # Fast random sampling + for _ in range(200): rx = random.randint(self.config.min_x, self.config.max_x) ry = random.randint(self.config.min_y, self.config.max_y) - if (rx, ry) not in occupied: + if (rx, ry) not in occupied and (rx, ry) not in self.obstacles: return (rx, ry) + # Fallback to list of all valid passable, unoccupied coordinates all_coords = [ (x, y) for x in range(self.config.min_x, self.config.max_x + 1) for y in range(self.config.min_y, self.config.max_y + 1) - if (x, y) not in occupied + if (x, y) not in occupied and (x, y) not in self.obstacles ] - return random.choice(all_coords) + if all_coords: + return random.choice(all_coords) + + # Absolute safety fallback + return ( + random.randint(self.config.min_x, self.config.max_x), + random.randint(self.config.min_y, self.config.max_y), + ) def _get_active_turn_actors(self) -> List[str]: actors = [] @@ -118,61 +283,42 @@ class GameEngine: def _advance_turn(self): actors = self._get_active_turn_actors() if not actors: - self.current_turn_index = 0 + self.turn_number += 1 return - self.turn_number += 1 + self.current_turn_index = (self.current_turn_index + 1) % len(actors) + self.turn_number += 1 + if self.current_turn_index == 0: self.round_number += 1 - def _update_party_strength(self, party: Party): - total = sum(self.players[m].strength for m in party.member_ids if m in self.players) - party.total_strength = max(1, total) - - 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]}" - x, y = self._find_random_free_position() + if len(self.players) >= settings.MAX_PLAYERS: + raise ValueError( + f"Maximum players ({settings.MAX_PLAYERS}) reached. Cannot register more players." + ) + + for existing in self.players.values(): + if existing.name.lower() == player_in.name.lower(): + raise ValueError(f"Player with name '{player_in.name}' is already registered.") + + player_id = f"player_{uuid.uuid4().hex[:8]}" + spawn_x, spawn_y = self._find_random_free_position() player = Player( id=player_id, name=player_in.name, color=player_in.color, - x=x, - y=y, strength=player_in.strength, score=0, + x=spawn_x, + y=spawn_y, party_id=None, is_party_leader=False, - visited_locations=[{"x": x, "y": y}], + visited_locations=[{"x": spawn_x, "y": spawn_y}], ) + self.players[player_id] = player self.turn_order.append(player_id) return player @@ -185,7 +331,7 @@ class GameEngine: async with self._lock: return list(self.players.values()) - async def remove_player(self, player_id: str) -> bool: + async def delete_player(self, player_id: str) -> bool: async with self._lock: if player_id not in self.players: return False @@ -196,6 +342,7 @@ class GameEngine: party = self.parties[player.party_id] if player_id in party.member_ids: party.member_ids.remove(player_id) + if party.leader_id == player_id: if party.member_ids: new_leader_id = random.choice(party.member_ids) @@ -234,21 +381,26 @@ class GameEngine: self.current_turn_index = 0 self.round_number = 1 self.turn_number = 0 + # Regenerate fresh procedural mountain ranges and valley trenches on reset + self.obstacles = self._generate_terrain() async def get_board_state(self) -> BoardState: async with self._lock: players_list = list(self.players.values()) parties_list = list(self.parties.values()) + obstacles_list = list(self.obstacles.values()) return BoardState( config=self.config, player_count=len(players_list), players=players_list, parties=parties_list, + obstacles=obstacles_list, turn=self._get_turn_info(), conclusion=self._check_game_concluded(), ) - # ==========================================\n # Bot Memory & Radar Awareness + # ========================================== + # Bot Memory & Radar Awareness # ========================================== async def get_bot_memory( @@ -290,15 +442,6 @@ class GameEngine: is_enemy = not is_ally party_obj = self.parties.get(other.party_id) if other.party_id else None - # 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 @@ -334,9 +477,6 @@ class GameEngine: targets.sort(key=lambda t: t.distance) - # Choose primary target based on goal: - # - 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": recruit_targets = [t for t in targets if t.can_recruit or not t.party_id] @@ -351,8 +491,15 @@ class GameEngine: rec_act = "explore_unvisited" if nearest: - dx = 1 if nearest.x > player.x else (-1 if nearest.x < player.x else 0) - dy = 1 if nearest.y > player.y else (-1 if nearest.y < player.y else 0) + # Use BFS pathfinder to recommend direction navigating around obstacles! + bfs_path = self._find_path_bfs((player.x, player.y), (nearest.x, nearest.y)) + if bfs_path and len(bfs_path) >= 2: + step_x, step_y = bfs_path[1] + dx = step_x - player.x + dy = step_y - player.y + else: + dx = 1 if nearest.x > player.x else (-1 if nearest.x < player.x else 0) + dy = 1 if nearest.y > player.y else (-1 if nearest.y < player.y else 0) for name, (ox, oy) in DIRECTION_OFFSETS.items(): if ox == dx and oy == dy and "_" in name: @@ -386,7 +533,8 @@ class GameEngine: recommended_action=rec_act, ) - # ==========================================\n # Party Logic & Leadership Negotiation + # ========================================== + # Party Logic & Leadership Negotiation # ========================================== @staticmethod @@ -566,7 +714,8 @@ class GameEngine: async with self._lock: return self.parties.get(party_id) - # ==========================================\n # Movement Checking & Execution + # ========================================== + # Movement Checking & Execution # ========================================== def _check_move_internal( @@ -597,6 +746,18 @@ class GameEngine: reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).", ) + if (tx, ty) in self.obstacles: + obs = self.obstacles[(tx, ty)] + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=player.x + dx, + target_y=player.y + dy, + available=False, + reason=f"Party member '{m.name}' blocked by impassable {obs.type} at ({tx}, {ty}).", + ) + occupant = occupied_map.get((tx, ty)) if occupant is not None and occupant.id not in party_member_ids: return MoveCheckResult( @@ -638,6 +799,18 @@ class GameEngine: reason=f"Hit boundary wall at ({target_x}, {target_y}). Coordinates must remain between {self.config.min_x} and {self.config.max_x}.", ) + if (target_x, target_y) in self.obstacles: + obs = self.obstacles[(target_x, target_y)] + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=target_x, + target_y=target_y, + available=False, + reason=f"Target square ({target_x}, {target_y}) is impassable {obs.type} terrain.", + ) + occupant = occupied_map.get((target_x, target_y)) if occupant is not None and occupant.id != player.id: return MoveCheckResult( @@ -660,28 +833,15 @@ class GameEngine: reason=None, ) - async def check_single_move(self, player_id: str, direction_input: str) -> MoveCheckResult: + async def check_single_move(self, player_id: str, direction_name: str) -> MoveCheckResult: async with self._lock: player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") - if player.party_id and not player.is_party_leader: - party = self.parties.get(player.party_id) - leader_name = party.leader_name if party else "Leader" - return MoveCheckResult( - direction=direction_input, - dx=0, - dy=0, - target_x=player.x, - target_y=player.y, - available=False, - reason=f"Only party leader '{leader_name}' controls movement for the party.", - ) - - normalized = direction_input.strip().upper().replace(" ", "_") + normalized = direction_name.strip().upper().replace(" ", "_") if normalized not in DIRECTION_OFFSETS: - raise ValueError(f"Unknown direction '{direction_input}'") + raise ValueError(f"Unknown direction '{direction_name}'") dx, dy = DIRECTION_OFFSETS[normalized] occupied = self._get_occupied_coordinates() @@ -693,141 +853,325 @@ class GameEngine: if not player: raise KeyError(f"Player '{player_id}' not found") - current_turn_player = self._get_current_player() - is_turn = current_turn_player is not None and current_turn_player.id == player_id occupied = self._get_occupied_coordinates() - - party = self.parties.get(player.party_id) if player.party_id else None - is_leader = player.is_party_leader or party is None - member_count = len(party.member_ids) if party else 1 + current_turn = self._get_current_player() + is_turn = bool(current_turn and current_turn.id == player_id) moves: Dict[str, MoveCheckResult] = {} for name, dx, dy in STANDARD_DIRECTIONS: - if player.party_id and not player.is_party_leader: - moves[name] = MoveCheckResult( - direction=name, - dx=dx, - dy=dy, - target_x=player.x + dx, - target_y=player.y + dy, - available=False, - reason=f"Only party leader '{party.leader_name if party else 'Leader'}' controls group movement.", - ) - else: - moves[name] = self._check_move_internal(player, dx, dy, name, occupied) + moves[name] = self._check_move_internal(player, dx, dy, name, occupied) + + member_count = 1 + if player.party_id and player.party_id in self.parties: + member_count = len(self.parties[player.party_id].member_ids) return AvailableMovesResponse( player_id=player.id, player_name=player.name, current_x=player.x, current_y=player.y, - is_turn=is_turn and is_leader, + is_turn=is_turn, is_party_leader=player.is_party_leader, party_id=player.party_id, party_member_count=member_count, - current_turn_player_id=current_turn_player.id if current_turn_player else None, + current_turn_player_id=current_turn.id if current_turn else None, moves=moves, ) + def _update_party_strength(self, party: Party): + total = 0 + for mid in party.member_ids: + p = self.players.get(mid) + if p: + total += p.strength + party.total_strength = max(1, total) + + def _resolve_3bout_battle_internal( + self, party1: Party, party2: Party + ) -> BattleResult: + bouts: List[BattleBout] = [] + p1_bouts_won = 0 + p2_bouts_won = 0 + p1_total_score = 0 + p2_total_score = 0 + + self._update_party_strength(party1) + self._update_party_strength(party2) + + for bout_idx in range(1, 4): + r1 = random.randint(1, 20) + r2 = random.randint(1, 20) + score1 = party1.total_strength * r1 + score2 = party2.total_strength * r2 + + p1_total_score += score1 + p2_total_score += score2 + + if score1 > score2: + winner_name = party1.name + p1_bouts_won += 1 + elif score2 > score1: + winner_name = party2.name + p2_bouts_won += 1 + else: + if party1.total_strength >= party2.total_strength: + winner_name = party1.name + p1_bouts_won += 1 + else: + winner_name = party2.name + p2_bouts_won += 1 + + bouts.append( + BattleBout( + bout_number=bout_idx, + party1_roll=r1, + party1_strength=party1.total_strength, + party1_score=score1, + party2_roll=r2, + party2_strength=party2.total_strength, + party2_score=score2, + winner_name=winner_name, + ) + ) + + if p1_bouts_won > p2_bouts_won: + winner_party = party1 + defeated_party = party2 + elif p2_bouts_won > p1_bouts_won: + winner_party = party2 + defeated_party = party1 + else: + if p1_total_score >= p2_total_score: + winner_party = party1 + defeated_party = party2 + else: + winner_party = party2 + defeated_party = party1 + + winner_lead = self.players.get(winner_party.leader_id) + if winner_lead: + winner_lead.score += 2 + + for mid in winner_party.member_ids: + if mid != winner_party.leader_id and mid in self.players: + self.players[mid].score += 1 + + killed_leader = self.players.get(defeated_party.leader_id) + absorbed_members: List[str] = [] + + if killed_leader: + killed_leader.score -= 1 + + if len(defeated_party.member_ids) == 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.append(killed_leader.id) + respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} + else: + respawn_x, respawn_y = self._find_random_free_position() + killed_leader.x = respawn_x + killed_leader.y = respawn_y + killed_leader.party_id = None + killed_leader.is_party_leader = False + respawn_pos = {"x": respawn_x, "y": respawn_y} + + for mid in list(defeated_party.member_ids): + if mid != defeated_party.leader_id: + m = self.players.get(mid) + if m: + m.party_id = winner_party.id + m.is_party_leader = False + if m.id not in winner_party.member_ids: + winner_party.member_ids.append(m.id) + absorbed_members.append(m.id) + + if defeated_party.id in self.parties: + del self.parties[defeated_party.id] + + self._update_party_strength(winner_party) + + return BattleResult( + bouts=bouts, + party1_name=party1.name, + party2_name=party2.name, + party1_bouts_won=p1_bouts_won, + party2_bouts_won=p2_bouts_won, + party1_total_score=p1_total_score, + party2_total_score=p2_total_score, + winner_party_id=winner_party.id, + winner_party_name=winner_party.name, + winner_leader_id=winner_party.leader_id, + winner_leader_name=winner_party.leader_name, + defeated_party_id=defeated_party.id, + defeated_party_name=defeated_party.name, + killed_leader_id=killed_leader.id if killed_leader else "", + killed_leader_name=killed_leader.name if killed_leader else "Unknown", + killed_leader_new_score=killed_leader.score if killed_leader else 0, + killed_leader_respawn_position=respawn_pos, + absorbed_members=absorbed_members, + new_party_size=len(winner_party.member_ids), + new_party_strength=winner_party.total_strength, + ) + + async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: + async with self._lock: + p1 = self.players.get(challenger_id) + p2 = self.players.get(defender_id) + if not p1 or not p2: + raise KeyError("Challenger or defender not found") + + if p1.party_id and p1.party_id == p2.party_id: + raise ValueError("Cannot battle members of your own party") + + if p1.party_id and not p1.is_party_leader: + raise PermissionError("Only party leader can initiate a battle for the party") + + party1 = self.parties.get(p1.party_id) if p1.party_id else None + party2 = self.parties.get(p2.party_id) if p2.party_id else None + + if not party1: + temp_p1_id = f"party_{uuid.uuid4().hex[:8]}" + party1 = Party( + id=temp_p1_id, + name=f"Squad {p1.name}", + leader_id=p1.id, + leader_name=p1.name, + member_ids=[p1.id], + total_strength=p1.strength, + ) + p1.party_id = temp_p1_id + p1.is_party_leader = True + self.parties[temp_p1_id] = party1 + + if not party2: + temp_p2_id = f"party_{uuid.uuid4().hex[:8]}" + party2 = Party( + id=temp_p2_id, + name=f"Squad {p2.name}", + leader_id=p2.id, + leader_name=p2.name, + member_ids=[p2.id], + total_strength=p2.strength, + ) + p2.party_id = temp_p2_id + p2.is_party_leader = True + self.parties[temp_p2_id] = party2 + + result = self._resolve_3bout_battle_internal(party1, party2) + self._advance_turn() + return result + + async def battle(self, challenger_id: str, defender_id: str) -> BattleResult: + return await self.fight_battle(challenger_id, defender_id) + + async def get_game_conclusion(self) -> GameConclusion: + async with self._lock: + return self._check_game_concluded() + + def _check_game_concluded(self) -> GameConclusion: + if len(self.players) < 2: + return GameConclusion(concluded=False) + + if len(self.parties) == 1: + only_party = next(iter(self.parties.values())) + if len(only_party.member_ids) == len(self.players): + rankings = sorted( + self.players.values(), + key=lambda p: (p.score, p.strength, p.name), + 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) + 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! - """ - # 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 - my_party = self.parties[player.party_id] - my_members = [self.players[mid] for mid in my_party.member_ids if mid in self.players] + if player.party_id and player.party_id == other.party_id: + continue - 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 + if not self._are_adjacent(player, 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 + # Case A: Solo + Solo encounter + if not player.party_id and not other.party_id: + leader_id = self._negotiate_party_leader_id(player, other) + members = [player.id, other.id] + party_id = f"party_{uuid.uuid4().hex[:8]}" + leader = self.players[leader_id] + party = Party( + id=party_id, + name=f"Squad {leader.name}", + leader_id=leader_id, + leader_name=leader.name, + member_ids=members, + total_strength=player.strength + other.strength, + ) + player.party_id = party_id + player.is_party_leader = player.id == leader_id + other.party_id = party_id + other.is_party_leader = other.id == leader_id + self.parties[party_id] = party + return party, None - # 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 + # Case B: Party + Opposing Party encounter + if player.party_id and other.party_id and player.party_id != other.party_id: + if player.is_party_leader: + p1 = self.parties[player.party_id] + p2 = self.parties[other.party_id] + battle_res = self._resolve_3bout_battle_internal(p1, p2) + return None, battle_res - 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] - party_id = f"party_{uuid.uuid4().hex[:8]}" - party = Party( - id=party_id, - name=f"Squad {leader.name}", - leader_id=leader_id, - leader_name=leader.name, - member_ids=[player.id, other.id], - total_strength=player.strength + other.strength, - ) - player.party_id = party_id - player.is_party_leader = player.id == leader_id - other.party_id = party_id - other.is_party_leader = other.id == leader_id - self.parties[party_id] = party + # Case C: Party Leader encounters Solo Bot + if player.party_id and player.is_party_leader and not other.party_id: + if other.strength <= player.strength: + other.party_id = player.party_id + other.is_party_leader = False + party = self.parties[player.party_id] + if other.id not in party.member_ids: + party.member_ids.append(other.id) + self._update_party_strength(party) return party, None + else: + 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 - # 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 + active_party = self.parties[player.party_id] + battle_res = self._resolve_3bout_battle_internal(active_party, solo_party) + return None, battle_res + + # Case D: Solo Bot encounters Party + if not player.party_id and other.party_id: + other_party = self.parties.get(other.party_id) + if other_party: + leader_player = self.players.get(other_party.leader_id) + leader_strength = leader_player.strength if leader_player else other_party.total_strength if player.strength <= leader_strength: - # Player willingly joins party under equal/stronger leader player.party_id = other_party.id player.is_party_leader = False if player.id not in other_party.member_ids: @@ -835,8 +1179,6 @@ class GameEngine: 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, @@ -853,7 +1195,7 @@ class GameEngine: battle_res = self._resolve_3bout_battle_internal(other_party, solo_party) return None, battle_res - return None, None + return None, None def _check_and_auto_form_party(self, player: Player) -> Optional[Party]: party, _ = self._check_adjacent_encounter(player) @@ -943,15 +1285,12 @@ class GameEngine: curr_name = current_turn_player.name if current_turn_player else "Nobody" curr_id = current_turn_player.id if current_turn_player else "None" raise PermissionError( - f"Cannot pass: it is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." + f"It is not your turn to pass. Current turn belongs to '{curr_name}' ({curr_id})." ) self._advance_turn() return self._get_turn_info() - # ==========================================\n # Autonomous Goal Step (AI Logic) - # ========================================== - async def step_bot_ai(self, player_id: str) -> AiStepResponse: async with self._lock: player = self.players.get(player_id) @@ -960,13 +1299,15 @@ class GameEngine: current_turn_player = self._get_current_player() if not current_turn_player or current_turn_player.id != player_id: + curr_name = current_turn_player.name if current_turn_player else "Nobody" + curr_id = current_turn_player.id if current_turn_player else "None" raise PermissionError( - f"Not your turn. Current turn: '{current_turn_player.name if current_turn_player else 'None'}'" + f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." ) bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" - # 1. Check for immediate adjacent encounter before moving + # 1. Check if already adjacent to encounter before moving formed_party, battle_res = self._check_adjacent_encounter(player) if battle_res: self._advance_turn() @@ -1000,7 +1341,7 @@ class GameEngine: turn=self._get_turn_info(), ) - # 2. Navigate towards target using radar & memory + # 2. Navigate towards target using BFS pathfinder & memory occupied = self._get_occupied_coordinates() moves_map: Dict[str, MoveCheckResult] = {} for name, dx, dy in STANDARD_DIRECTIONS: @@ -1042,6 +1383,13 @@ 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) + # Check if BFS pathfinder finds optimal route around obstacles + bfs_next_step: Optional[Tuple[int, int]] = None + if target_bot: + path = self._find_path_bfs((player.x, player.y), (target_bot.x, target_bot.y)) + if path and len(path) >= 2: + bfs_next_step = (path[1][0] - player.x, path[1][1] - player.y) + visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations} best_dir = available_dirs[0] best_score = float("-inf") @@ -1052,6 +1400,10 @@ class GameEngine: ty = player.y + dy score = 0.0 + # Massive bonus if this step aligns with the shortest BFS path around obstacles! + if bfs_next_step and (dx, dy) == bfs_next_step: + score += 50.0 + if target_bot: old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y)) new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y)) @@ -1108,9 +1460,14 @@ class GameEngine: turn=turn_info, ) - action_taken = "battled" if battle_result else ("formed_party" if formed_party else "moved") + action_name = "moved" + if battle_result: + action_name = "battled" + elif formed_party: + action_name = "formed_party" + return AiStepResponse( - action_taken=action_taken, + action_taken=action_name, player_id=player.id, player_name=player.name, bot_goal=bot_goal, @@ -1122,261 +1479,6 @@ class GameEngine: turn=turn_info, ) - # ==========================================\n # 3-Bout D20 Battle Mechanics - # ========================================== - - def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]: - my_members = [self.players[mid] for mid in party.member_ids if mid in self.players] - for other_party in self.parties.values(): - if other_party.id == party.id: - continue - other_members = [self.players[mid] for mid in other_party.member_ids if mid in self.players] - for m1 in my_members: - for m2 in other_members: - if self._are_adjacent(m1, m2): - return other_party - return None - - def _resolve_3bout_battle_internal(self, party1: Party, party2: Party) -> BattleResult: - str1 = sum(self.players[m].strength for m in party1.member_ids if m in self.players) - str2 = sum(self.players[m].strength for m in party2.member_ids if m in self.players) - - bouts: List[BattleBout] = [] - p1_bouts_won = 0 - p2_bouts_won = 0 - p1_total_score = 0 - p2_total_score = 0 - - for i in range(1, 4): - roll1 = random.randint(1, 20) - roll2 = random.randint(1, 20) - score1 = str1 * roll1 - score2 = str2 * roll2 - - p1_total_score += score1 - p2_total_score += score2 - - if score1 > score2: - winner_name = party1.name - p1_bouts_won += 1 - elif score2 > score1: - winner_name = party2.name - p2_bouts_won += 1 - else: - tie_roll1 = random.randint(1, 20) - tie_roll2 = random.randint(1, 20) - if tie_roll1 >= tie_roll2: - winner_name = party1.name - p1_bouts_won += 1 - else: - winner_name = party2.name - p2_bouts_won += 1 - - bouts.append( - BattleBout( - bout_number=i, - party1_roll=roll1, - party1_strength=str1, - party1_score=score1, - party2_roll=roll2, - party2_strength=str2, - party2_score=score2, - winner_name=winner_name, - ) - ) - - # Overall battle winner - if p1_bouts_won > p2_bouts_won: - winner_party, defeated_party = party1, party2 - elif p2_bouts_won > p1_bouts_won: - winner_party, defeated_party = party2, party1 - else: - if p1_total_score >= p2_total_score: - winner_party, defeated_party = party1, party2 - else: - winner_party, defeated_party = party2, party1 - - # Winning leader receives +2 points, and the rest of the winning party receives +1 point - for mid in list(winner_party.member_ids): - member = self.players.get(mid) - if member: - if mid == winner_party.leader_id: - member.score += 2 - else: - member.score += 1 - - killed_leader_id = defeated_party.leader_id - killed_leader = self.players.get(killed_leader_id) - absorbed_members = [] - - 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: - del self.parties[defeated_party.id] - - # Update winning party strength - self._update_party_strength(winner_party) - - return BattleResult( - bouts=bouts, - party1_name=party1.name, - party2_name=party2.name, - party1_bouts_won=p1_bouts_won, - party2_bouts_won=p2_bouts_won, - party1_total_score=p1_total_score, - party2_total_score=p2_total_score, - winner_party_id=winner_party.id, - winner_party_name=winner_party.name, - winner_leader_id=winner_party.leader_id, - winner_leader_name=winner_party.leader_name, - defeated_party_id=defeated_party.id, - defeated_party_name=defeated_party.name, - killed_leader_id=killed_leader_id, - killed_leader_name=killed_leader.name if killed_leader else "Unknown", - killed_leader_new_score=killed_leader.score if killed_leader else -1, - killed_leader_respawn_position=respawn_pos, - absorbed_members=absorbed_members, - new_party_size=len(winner_party.member_ids), - new_party_strength=winner_party.total_strength, - ) - - async def defeat_party(self, party_id: str) -> PartyDefeatResult: - async with self._lock: - party = self.parties.get(party_id) - if not party: - raise KeyError(f"Party '{party_id}' not found") - - old_leader_id = party.leader_id - leader = self.players.get(old_leader_id) - if not leader: - raise KeyError(f"Leader '{old_leader_id}' not found") - - leader.score -= 1 - leader.party_id = None - leader.is_party_leader = False - if old_leader_id in party.member_ids: - party.member_ids.remove(old_leader_id) - - rx, ry = self._find_random_free_position() - leader.x = rx - leader.y = ry - leader.visited_locations.append({"x": rx, "y": ry}) - respawn_pos = {"x": rx, "y": ry} - - new_leader_id = None - new_leader_name = None - party_dissolved = False - - if len(party.member_ids) > 0: - new_leader_id = random.choice(party.member_ids) - party.leader_id = new_leader_id - new_leader = self.players[new_leader_id] - new_leader.is_party_leader = True - new_leader_name = new_leader.name - party.leader_name = new_leader_name - self._update_party_strength(party) - else: - del self.parties[party_id] - party_dissolved = True - - return PartyDefeatResult( - party_id=party_id, - killed_leader_id=leader.id, - killed_leader_name=leader.name, - killed_leader_new_score=leader.score, - killed_leader_respawn_position=respawn_pos, - new_leader_id=new_leader_id, - new_leader_name=new_leader_name, - remaining_members=list(party.member_ids), - party_dissolved=party_dissolved, - ) - - async def battle(self, challenger_id: str, defender_id: str) -> BattleResult: - async with self._lock: - p1 = self.players.get(challenger_id) - p2 = self.players.get(defender_id) - if not p1 or not p2: - raise KeyError("Both combatants must exist") - - if p1.party_id and p1.party_id == p2.party_id: - raise ValueError("Cannot battle your own party member") - - if not self._are_adjacent(p1, p2): - raise ValueError( - f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))" - ) - - # Form temporary 1-bot parties if they don't have one - party1 = self.parties.get(p1.party_id) if p1.party_id else None - if not party1: - party1_id = f"party_{uuid.uuid4().hex[:8]}" - party1 = Party( - id=party1_id, - name=f"Squad {p1.name}", - leader_id=p1.id, - leader_name=p1.name, - member_ids=[p1.id], - total_strength=p1.strength, - ) - p1.party_id = party1_id - p1.is_party_leader = True - self.parties[party1_id] = party1 - - party2 = self.parties.get(p2.party_id) if p2.party_id else None - if not party2: - party2_id = f"party_{uuid.uuid4().hex[:8]}" - party2 = Party( - id=party2_id, - name=f"Squad {p2.name}", - leader_id=p2.id, - leader_name=p2.name, - member_ids=[p2.id], - total_strength=p2.strength, - ) - p2.party_id = party2_id - p2.is_party_leader = True - self.parties[party2_id] = party2 - - return self._resolve_3bout_battle_internal(party1, party2) - # Global game engine instance game_engine = GameEngine() diff --git a/backend/app/models.py b/backend/app/models.py index 73a5541..aed2fef 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,7 @@ import re from datetime import datetime, timezone from enum import Enum -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field, field_validator @@ -98,6 +98,21 @@ class AvailableMovesResponse(BaseModel): moves: Dict[str, MoveCheckResult] +# ========================================== +# Impassable Obstacle Models (Mountains & Valleys) +# ========================================== + +class ObstacleType(str, Enum): + MOUNTAIN = "mountain" + VALLEY = "valley" + + +class Obstacle(BaseModel): + x: int + y: int + type: str = "mountain" # "mountain" or "valley" + + class PlayerCreate(BaseModel): name: str = Field(..., min_length=1, max_length=32, description="Display name of the player") color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name") @@ -308,6 +323,7 @@ class BoardState(BaseModel): player_count: int players: List[Player] parties: List[Party] = [] + obstacles: List[Obstacle] = Field(default_factory=list) turn: TurnInfo conclusion: Optional[GameConclusion] = None @@ -344,4 +360,4 @@ class AiStepResponse(BaseModel): class ApiResponse(BaseModel): success: bool message: str - data: Optional[dict] = None + data: Optional[Dict[str, Any]] = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 7f098c0..783a0ae 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,102 +1,84 @@ -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_game_state(): +def test_health_check(): client = TestClient(app) - client.post("/api/reset") - yield + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json()["success"] is True + + +def test_register_player_and_get_board(): + client = TestClient(app) + # Reset board first client.post("/api/reset") + payload = {"name": "TestBot", "color": "#00FFCC"} + response = client.post("/api/players", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "TestBot" + assert data["color"] == "#00FFCC" + assert 0 <= data["x"] <= 64 + assert 0 <= data["y"] <= 64 + assert data["score"] == 0 + assert data["visited_locations"] == [{"x": data["x"], "y": data["y"]}] -def test_strength_based_leadership_negotiation(): + board_res = client.get("/api/board") + assert board_res.status_code == 200 + board_data = board_res.json() + assert board_data["player_count"] == 1 + assert board_data["players"][0]["name"] == "TestBot" + assert len(board_data["obstacles"]) > 0 + + +def test_bot_memory_and_radar_endpoints(): client = TestClient(app) + client.post("/api/reset") - # 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() + p1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#38bdf8", "strength": 1}).json() + p2 = client.post("/api/players", json={"name": "BotBeta", "color": "#f43f5e", "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) - - # 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() - - # 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 memory: has visited (5, 5) - mem_res = client.get(f"/api/players/{b1['id']}/memory?check_x=5&check_y=5") + # Memory endpoint + mem_res = client.get(f"/api/players/{p1['id']}/memory?check_x={p1['x']}&check_y={p1['y']}") assert mem_res.status_code == 200 mem_data = mem_res.json() + assert mem_data["visited_count"] == 1 assert mem_data["has_visited_current"] is True - assert mem_data["visited_count"] >= 1 - # 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 + # Check unvisited location + mem_unvisited = client.get(f"/api/players/{p1['id']}/memory?check_x=999&check_y=999").json() + assert mem_unvisited["has_visited_current"] is False - # Check radar awareness - radar_res = client.get(f"/api/players/{b1['id']}/radar") + # Radar endpoint + radar_res = client.get(f"/api/players/{p1['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 + assert len(radar_data["targets"]) == 1 + assert radar_data["targets"][0]["name"] == "BotBeta" -def test_unpartied_bot_seeks_party_autonomous_turn(): +def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader(): client = TestClient(app) + client.post("/api/reset") - 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() + # Bot1 (strength 1) and Bot2 (strength 3) + b1 = client.post("/api/players", json={"name": "WeakBot", "color": "#111111", "strength": 1}).json() + b2 = client.post("/api/players", json={"name": "StrongBot", "color": "#222222", "strength": 3}).json() - async def setup_grid(): + # Position them adjacent (e.g. at 5,5 and 5,6) + async def set_positions(): p1 = await game_engine.get_player(b1["id"]) - p1.x, p1.y = 10, 10 + p1.x, p1.y = 5, 5 p2 = await game_engine.get_player(b2["id"]) - p2.x, p2.y = 10, 12 - asyncio.run(setup_grid()) + p2.x, p2.y = 5, 6 + asyncio.run(set_positions()) - # Execute AI step for SeekerA (distance 2, should step down towards SeekerB) + # Step WeakBot turn step_res = client.post(f"/api/players/{b1['id']}/ai-step") assert step_res.status_code == 200 data = step_res.json() @@ -107,6 +89,7 @@ def test_unpartied_bot_seeks_party_autonomous_turn(): def test_3bout_d20_battle_with_defeated_members_joining_winner(): client = TestClient(app) + client.post("/api/reset") # Party 1: AlphaLeader + AlphaMember p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json() @@ -148,45 +131,36 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner(): "defender_id": p3["id"], }) assert battle_res.status_code == 200 - battle = battle_res.json() + battle_data = 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 + assert len(battle_data["bouts"]) == 3 + assert battle_data["bouts"][0]["party1_strength"] == 10 + assert battle_data["bouts"][0]["party2_strength"] == 2 -def test_game_conclusion_and_scoreboard_rankings(): +def test_game_conclusion_and_scoreboard(): client = TestClient(app) + client.post("/api/reset") - 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() + # Register 4 bots + p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#111111", "strength": 5}).json() + p2 = client.post("/api/players", json={"name": "BravoMember", "color": "#222222", "strength": 3}).json() + p3 = client.post("/api/players", json={"name": "CharlieMember", "color": "#333333", "strength": 2}).json() + p4 = client.post("/api/players", json={"name": "DeltaMember", "color": "#444444", "strength": 1}).json() - # Position all players contiguously within 1 unit + # Position them adjacent in a connected chain async def setup_positions(): b1 = await game_engine.get_player(p1["id"]) - b1.x, b1.y = 10, 10 + b1.x, b1.y = 12, 12 b1.score = 6 # 1st place - b2 = await game_engine.get_player(p2["id"]) - b2.x, b2.y = 10, 11 + b2.x, b2.y = 12, 13 b2.score = 4 # 2nd place - b3 = await game_engine.get_player(p3["id"]) - b3.x, b3.y = 10, 12 + b3.x, b3.y = 13, 13 b3.score = 2 # 3rd place - b4 = await game_engine.get_player(p4["id"]) - b4.x, b4.y = 10, 13 + b4.x, b4.y = 13, 14 b4.score = 0 # 4th place asyncio.run(setup_positions()) @@ -209,86 +183,118 @@ def test_game_conclusion_and_scoreboard_rankings(): 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) + client.post("/api/reset") - # 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 + b1.x, b1.y = 20, 20 b2 = await game_engine.get_player(p2["id"]) - b2.x, b2.y = 10, 11 + b2.x, b2.y = 20, 21 b3 = await game_engine.get_player(p3["id"]) - b3.x, b3.y = 11, 10 # adjacent to WeakLeader + b3.x, b3.y = 21, 20 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", }) - # 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 +def test_map_obstacles_mountains_valleys_and_connectivity(): + """Verify obstacle constraints: + 1. Obstacles include both mountains and valleys. + 2. Obstacles cover <= 50% of total map tiles. + 3. All passable areas form a SINGLE connected component (no isolated bodies). + 4. Spawned bots never spawn on an obstacle. + 5. Moving into an obstacle is blocked. + """ + client = TestClient(app) + client.post("/api/reset") + board = client.get("/api/board").json() + obstacles = board.get("obstacles", []) + assert len(obstacles) > 0 - # 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"] + total_cells = 65 * 65 # 4225 tiles + # Must be strictly no more than 50% + assert len(obstacles) <= int(total_cells * 0.5) - # WeakSquad leader received +2 points - lead_after = client.get(f"/api/players/{p1['id']}").json() - assert lead_after["score"] == 2 + obstacle_types = {obs["type"] for obs in obstacles} + assert "mountain" in obstacle_types + assert "valley" in obstacle_types - # 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 + obstacle_coords = {(obs["x"], obs["y"]) for obs in obstacles} + + # Verify single connected component of all passable tiles + all_coords = {(x, y) for x in range(65) for y in range(65)} + passable = all_coords - obstacle_coords + assert len(passable) >= int(total_cells * 0.5) + + start = next(iter(passable)) + visited = set() + queue = [start] + visited.add(start) + + while queue: + cx, cy = queue.pop(0) + for nx, ny in [(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)]: + if 0 <= nx <= 64 and 0 <= ny <= 64: + if (nx, ny) in passable and (nx, ny) not in visited: + visited.add((nx, ny)) + queue.append((nx, ny)) + + # All passable cells must be reachable! + assert len(visited) == len(passable) + + # Verify spawning never places a bot on an obstacle + for i in range(10): + p = client.post("/api/players", json={"name": f"SpawnBot_{i}", "color": "#00FFCC"}).json() + assert (p["x"], p["y"]) not in obstacle_coords + + # Verify check-move blocks movement into an obstacle + # Place a bot next to an obstacle + obs_sample = next(iter(obstacles)) + ox, oy = obs_sample["x"], obs_sample["y"] + + # Find a passable neighbor next to this obstacle + free_neighbor = None + dir_to_obs = None + for name, (dx, dy) in [("LEFT", (-1, 0)), ("RIGHT", (1, 0)), ("UP", (0, -1)), ("DOWN", (0, 1))]: + cand = (ox - dx, oy - dy) + if 0 <= cand[0] <= 64 and 0 <= cand[1] <= 64 and cand not in obstacle_coords: + free_neighbor = cand + dir_to_obs = name + break + + if free_neighbor and dir_to_obs: + test_bot = client.post("/api/players", json={"name": "ObstacleTester", "color": "#123456"}).json() + async def place_tester(): + bot = await game_engine.get_player(test_bot["id"]) + bot.x, bot.y = free_neighbor[0], free_neighbor[1] + asyncio.run(place_tester()) + + chk = client.get(f"/api/players/{test_bot['id']}/check-move?direction={dir_to_obs}").json() + assert chk["available"] is False + assert "impassable" in chk["reason"].lower() diff --git a/frontend/src/components/BoardCanvas.tsx b/frontend/src/components/BoardCanvas.tsx index cbb5b7d..12ab3e1 100644 --- a/frontend/src/components/BoardCanvas.tsx +++ b/frontend/src/components/BoardCanvas.tsx @@ -160,6 +160,119 @@ export const BoardCanvas: React.FC = ({ ctx.stroke(); } + // ========================================== + // Draw Impassable Obstacles (Mountains & Valleys) + // ========================================== + (boardState.obstacles || []).forEach((obs) => { + const cx = startX + (obs.x - min_x) * cellSize; + const cy = startY + (obs.y - min_y) * cellSize; + const x0 = cx - cellSize / 2; + const y0 = cy - cellSize / 2; + + // Viewport culling + if (x0 + cellSize < 0 || x0 > width || y0 + cellSize < 0 || y0 > height) return; + + ctx.save(); + if (obs.type === 'mountain') { + // Mountain base + ctx.fillStyle = '#1e293b'; + ctx.fillRect(x0, y0, cellSize, cellSize); + ctx.strokeStyle = '#475569'; + ctx.lineWidth = 0.5; + ctx.strokeRect(x0, y0, cellSize, cellSize); + + if (cellSize >= 6) { + // Lit rock face (slate gray) + ctx.fillStyle = '#64748b'; + ctx.beginPath(); + ctx.moveTo(cx, y0 + cellSize * 0.12); + ctx.lineTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9); + ctx.lineTo(cx, y0 + cellSize * 0.9); + ctx.closePath(); + ctx.fill(); + + // Shadow rock face (dark charcoal) + ctx.fillStyle = '#334155'; + ctx.beginPath(); + ctx.moveTo(cx, y0 + cellSize * 0.12); + ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9); + ctx.lineTo(cx, y0 + cellSize * 0.9); + ctx.closePath(); + ctx.fill(); + + // Snow-capped peak + ctx.fillStyle = '#f8fafc'; + ctx.beginPath(); + ctx.moveTo(cx, y0 + cellSize * 0.12); + ctx.lineTo(cx - cellSize * 0.15, y0 + cellSize * 0.38); + ctx.lineTo(cx, y0 + cellSize * 0.32); + ctx.lineTo(cx + cellSize * 0.15, y0 + cellSize * 0.38); + ctx.closePath(); + ctx.fill(); + + // Peak outline + ctx.strokeStyle = '#94a3b8'; + ctx.lineWidth = Math.max(0.6, cellSize * 0.04); + ctx.beginPath(); + ctx.moveTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9); + ctx.lineTo(cx, y0 + cellSize * 0.12); + ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9); + ctx.stroke(); + } else { + ctx.fillStyle = '#64748b'; + ctx.fillRect(x0, y0, cellSize, cellSize); + } + } else { + // Valley / Chasm trench + ctx.fillStyle = '#060913'; + ctx.fillRect(x0, y0, cellSize, cellSize); + ctx.strokeStyle = '#312e81'; + ctx.lineWidth = 0.5; + ctx.strokeRect(x0, y0, cellSize, cellSize); + + if (cellSize >= 6) { + // Canyon cliffs + ctx.fillStyle = '#1e1b4b'; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x0 + cellSize * 0.3, y0); + ctx.lineTo(x0 + cellSize * 0.45, y0 + cellSize); + ctx.lineTo(x0, y0 + cellSize); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(x0 + cellSize, y0); + ctx.lineTo(x0 + cellSize * 0.7, y0); + ctx.lineTo(x0 + cellSize * 0.55, y0 + cellSize); + ctx.lineTo(x0 + cellSize, y0 + cellSize); + ctx.closePath(); + ctx.fill(); + + // Deep chasm rift fissure + ctx.strokeStyle = '#4338ca'; + ctx.lineWidth = Math.max(0.8, cellSize * 0.08); + ctx.beginPath(); + ctx.moveTo(cx - cellSize * 0.05, y0); + ctx.lineTo(cx + cellSize * 0.05, cy); + ctx.lineTo(cx - cellSize * 0.05, y0 + cellSize); + ctx.stroke(); + + // Glowing crevasse depth highlight + ctx.strokeStyle = '#818cf8'; + ctx.lineWidth = Math.max(0.5, cellSize * 0.03); + ctx.beginPath(); + ctx.moveTo(cx, y0 + cellSize * 0.2); + ctx.lineTo(cx, y0 + cellSize * 0.8); + ctx.stroke(); + } else { + ctx.fillStyle = '#1e1b4b'; + ctx.fillRect(x0, y0, cellSize, cellSize); + } + } + ctx.restore(); + }); + // Highlight hovered cell if (hoveredCoord) { const hx = startX + (hoveredCoord.x - min_x) * cellSize - cellSize / 2; @@ -213,7 +326,6 @@ export const BoardCanvas: React.FC = ({ for (let j = i + 1; j < members.length; j++) { const p1 = members[i]; const p2 = members[j]; - // If adjacent (within 1 step) if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) { const x1 = startX + (p1.x - min_x) * cellSize; const y1 = startY + (p1.y - min_y) * cellSize; @@ -230,73 +342,71 @@ export const BoardCanvas: React.FC = ({ ctx.restore(); }); - // Draw active players - const now = Date.now() / 400; - const pulse = Math.sin(now) * 0.25 + 0.75; - + // Draw Players / Bots boardState.players.forEach((player) => { const px = startX + (player.x - min_x) * cellSize; const py = startY + (player.y - min_y) * cellSize; const isSelected = selectedPlayer?.id === player.id; const isCurrentTurn = currentTurnId === player.id; - const isHovered = hoveredPlayer?.id === player.id; const isLeader = player.is_party_leader; - const inParty = Boolean(player.party_id); + const radius = Math.max(cellSize * 0.42, 6); - const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14)); - const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius; - - // Active turn beacon aura + // Turn indicator pulsating halo if (isCurrentTurn) { ctx.save(); ctx.beginPath(); - ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2); - ctx.strokeStyle = '#fbbf24'; + ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(245, 158, 11, 0.25)'; + ctx.fill(); + ctx.restore(); + } + + // Selection ring + if (isSelected) { + ctx.save(); + ctx.beginPath(); + ctx.arc(px, py, radius * 1.8, 0, Math.PI * 2); + ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; - ctx.setLineDash([4, 4]); + ctx.setLineDash([3, 3]); ctx.stroke(); ctx.restore(); } - // Glow effect + // Bot core body ctx.save(); - ctx.shadowColor = player.color; - ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : inParty ? 12 : 8; - - // Outer beacon ring - ctx.beginPath(); - ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2); - ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color; - ctx.globalAlpha = isSelected || isCurrentTurn || isLeader ? 0.9 : 0.4; - ctx.lineWidth = isLeader ? 2.5 : isCurrentTurn ? 2 : 1.5; - ctx.stroke(); - - // Main Player Token - ctx.globalAlpha = 1.0; ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = player.color; + ctx.shadowColor = player.color; + ctx.shadowBlur = 10; ctx.fill(); - // Inner Core - ctx.beginPath(); - ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2); - ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff'; - ctx.fill(); + // Bot border + ctx.lineWidth = isLeader ? 2.5 : 1.5; + ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff'; + ctx.stroke(); ctx.restore(); - // Label above player - if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) { + // Leader Crown / Star emblem + if (isLeader) { ctx.save(); - ctx.font = 'bold 11px monospace'; + ctx.fillStyle = '#fbbf24'; + ctx.font = `${Math.max(radius * 0.9, 10)}px sans-serif`; ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('👑', px, py - radius - 5); + ctx.restore(); + } - let prefix = ''; - if (isLeader) prefix = '👑 '; - else if (inParty) prefix = '🔗 '; + // Bot Name and Strength Label + if (cellSize >= 16 || isSelected || isCurrentTurn) { + ctx.save(); + ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.45))}px Inter, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; - const scoreText = `[${player.score >= 0 ? `+${player.score}` : player.score}]`; - const text = `${prefix}${player.name} ${scoreText}`; + const text = `${player.name} [⚡${player.strength}]`; const textMetrics = ctx.measureText(text); const bgWidth = textMetrics.width + 12; const bgHeight = 16; @@ -428,6 +538,10 @@ export const BoardCanvas: React.FC = ({ } }; + const hoveredObstacle = hoveredCoord + ? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y) + : null; + return (
= ({ > - {/* Floating Coordinate Display */} -
+ {/* Floating Status / Coordinate Display */} +
Grid: 64x64 @@ -458,10 +572,29 @@ export const BoardCanvas: React.FC = ({ {hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'} + {hoveredObstacle && ( + <> + | + + {hoveredObstacle.type === 'mountain' ? 'â–² Mountain (Impassable)' : 'â–¼ Valley (Impassable)'} + + + )} | Zoom: {(zoom * 100).toFixed(0)}% + | +
+ + + Mountain + + + + Valley + +
{/* Floating Reset View Button */} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index 9d3936f..1209449 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -24,6 +24,7 @@ const INITIAL_BOARD: BoardState = { player_count: 0, players: [], parties: [], + obstacles: [], turn: { current_player_id: null, current_player_name: null, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 56527ae..a2c0d79 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -7,6 +7,12 @@ export interface GridConfig { grid_cells_y: number; } +export interface Obstacle { + x: number; + y: number; + type: 'mountain' | 'valley'; +} + export interface Player { id: string; name: string; @@ -54,6 +60,7 @@ export interface BoardState { player_count: number; players: Player[]; parties: Party[]; + obstacles: Obstacle[]; turn: TurnInfo; conclusion?: GameConclusion | null; } @@ -77,10 +84,47 @@ export interface AvailableMovesResponse { is_party_leader: boolean; party_id?: string | null; party_member_count: number; - current_turn_player_id: string | null; + current_turn_player_id?: string | null; moves: Record; } +export interface BotMemoryResponse { + player_id: string; + player_name: string; + current_x: number; + current_y: number; + visited_count: number; + visited_history: { x: number; y: number }[]; + has_visited_current: boolean; +} + +export interface RadarTarget { + id: string; + name: string; + color: string; + x: number; + y: number; + strength: number; + distance: number; + party_id?: string | null; + party_name?: string | null; + is_ally: boolean; + is_enemy: boolean; + can_recruit: boolean; + can_battle: boolean; +} + +export interface BotRadarResponse { + player_id: string; + current_x: number; + current_y: number; + bot_goal: string; + targets: RadarTarget[]; + nearest_target?: RadarTarget | null; + recommended_direction?: string | null; + recommended_action: string; +} + export interface BattleBout { bout_number: number; party1_roll: number; @@ -115,35 +159,6 @@ export interface BattleResult { new_party_strength: number; } -export interface MoveResponse { - success: boolean; - player: Player; - direction: string; - party_moved?: boolean; - affected_players?: Player[]; - previous_position: { x: number; y: number }; - new_position: { x: number; y: number }; - party_formed_triggered?: boolean; - formed_party?: Party | null; - battle_triggered?: boolean; - battle_result?: BattleResult | null; - game_concluded?: GameConclusion | null; - turn: TurnInfo; -} - -export interface AiStepResponse { - action_taken: 'formed_party' | 'battled' | 'moved' | 'passed' | string; - player_id: string; - player_name: string; - bot_goal: 'form_party' | 'find_and_defeat_all_parties' | string; - direction?: string | null; - move_result?: MoveResponse | null; - formed_party?: Party | null; - battle_result?: BattleResult | null; - game_concluded?: GameConclusion | null; - turn: TurnInfo; -} - export interface PartyDefeatResult { party_id: string; killed_leader_id: string; @@ -156,39 +171,31 @@ export interface PartyDefeatResult { party_dissolved: boolean; } -export interface BotMemoryResponse { +export interface MoveResponse { + success: boolean; + player: Player; + direction: string; + party_moved: boolean; + affected_players: Player[]; + previous_position: { x: number; y: number }; + new_position: { x: number; y: number }; + party_formed_triggered: boolean; + formed_party?: Party | null; + battle_triggered: boolean; + battle_result?: BattleResult | null; + game_concluded?: GameConclusion | null; + turn: TurnInfo; +} + +export interface AiStepResponse { + action_taken: string; player_id: string; player_name: string; - current_x: number; - current_y: number; - visited_count: number; - visited_history: { x: number; y: number }[]; - has_visited_current: boolean; -} - -export interface RadarTarget { - id: string; - name: string; - color: string; - x: number; - y: number; - strength: number; - distance: number; - party_id?: string | null; - party_name?: string | null; - is_ally: boolean; - is_enemy: boolean; - can_recruit?: boolean; - can_battle?: boolean; -} - -export interface BotRadarResponse { - player_id: string; - current_x: number; - current_y: number; bot_goal: string; - targets: RadarTarget[]; - nearest_target?: RadarTarget | null; - recommended_direction?: string | null; - recommended_action: string; + direction?: string | null; + move_result?: MoveResponse | null; + formed_party?: Party | null; + battle_result?: BattleResult | null; + game_concluded?: GameConclusion | null; + turn: TurnInfo; }