From 529f5c2dc96fa2b1fb75652de99cf765de88b9ca Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sun, 6 Sep 2026 13:14:41 -0500 Subject: [PATCH] feat(game): implement follow-the-leader party movement so squads form single-file lines --- backend/app/game.py | 207 ++++++++++++++++++++++++++++---------- backend/tests/test_api.py | 84 ++++++++++++++++ 2 files changed, 238 insertions(+), 53 deletions(-) diff --git a/backend/app/game.py b/backend/app/game.py index 3658872..db4b655 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -749,6 +749,122 @@ class GameEngine: async with self._lock: return self.parties.get(party_id) + def _get_ordered_party_chain(self, party: Party, leader: Player) -> List[Player]: + chain = [leader] + for mid in party.member_ids: + if mid != leader.id and mid in self.players: + chain.append(self.players[mid]) + return chain + + def _compute_party_move( + self, + party: Party, + leader: Player, + dx: int, + dy: int, + occupied_map: Dict[Tuple[int, int], Player], + ) -> Tuple[Optional[Dict[str, Tuple[int, int]]], Optional[str], float]: + target_x = leader.x + dx + target_y = leader.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 + ): + return ( + None, + f"Hit boundary wall at ({target_x}, {target_y}). Coordinates must remain between {self.config.min_x} and {self.config.max_x}.", + 0.0, + ) + + if (target_x, target_y) in self.obstacles: + obs = self.obstacles[(target_x, target_y)] + return None, f"Target square ({target_x}, {target_y}) is impassable {obs.type} terrain.", 0.0 + + party_member_ids = set(party.member_ids) + occupant = occupied_map.get((target_x, target_y)) + if occupant is not None and occupant.id not in party_member_ids: + return None, f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.", 0.0 + + strength_penalty = 0.0 + if dx != 0 and dy != 0: + if (leader.x + dx, leader.y) in self.obstacles and (leader.x, leader.y + dy) in self.obstacles: + strength_penalty = 0.2 + + chain = self._get_ordered_party_chain(party, leader) + old_positions = {p.id: (p.x, p.y) for p in chain} + new_positions: Dict[str, Tuple[int, int]] = {leader.id: (target_x, target_y)} + occupied_new: Set[Tuple[int, int]] = {(target_x, target_y)} + + for i in range(1, len(chain)): + curr = chain[i] + pred = chain[i - 1] + target_pref = old_positions[pred.id] + + dist_to_pref = max(abs(curr.x - target_pref[0]), abs(curr.y - target_pref[1])) + if ( + dist_to_pref <= 1 + and target_pref not in occupied_new + and target_pref not in self.obstacles + and ( + occupied_map.get(target_pref) is None + or occupied_map.get(target_pref).id in party_member_ids + ) + ): + chosen = target_pref + else: + pred_new = new_positions[pred.id] + candidates = [] + for cdx in (-1, 0, 1): + for cdy in (-1, 0, 1): + cand = (curr.x + cdx, curr.y + cdy) + if ( + self.config.min_x <= cand[0] <= self.config.max_x + and self.config.min_y <= cand[1] <= self.config.max_y + and cand not in self.obstacles + and cand not in occupied_new + ): + occ = occupied_map.get(cand) + if occ is None or occ.id in party_member_ids: + if max(abs(cand[0] - pred_new[0]), abs(cand[1] - pred_new[1])) <= 1: + candidates.append(cand) + + if not candidates: + for cdx in (-1, 0, 1): + for cdy in (-1, 0, 1): + cand = (curr.x + cdx, curr.y + cdy) + if ( + self.config.min_x <= cand[0] <= self.config.max_x + and self.config.min_y <= cand[1] <= self.config.max_y + and cand not in self.obstacles + and cand not in occupied_new + ): + occ = occupied_map.get(cand) + if occ is None or occ.id in party_member_ids: + if any( + max(abs(cand[0] - pos[0]), abs(cand[1] - pos[1])) <= 1 + for pos in new_positions.values() + ): + candidates.append(cand) + + if not candidates: + return None, f"Party member '{curr.name}' has no available moves to maintain party connection.", 0.0 + + chosen = min( + candidates, + key=lambda c: ( + max(abs(c[0] - target_pref[0]), abs(c[1] - target_pref[1])), + max(abs(c[0] - target_x), abs(c[1] - target_y)), + ), + ) + + new_positions[curr.id] = chosen + occupied_new.add(chosen) + + return new_positions, None, strength_penalty + # ========================================== # Movement Checking & Execution # ========================================== @@ -763,48 +879,20 @@ class GameEngine: ) -> MoveCheckResult: if player.party_id and player.party_id in self.parties and player.is_party_leader: party = self.parties[player.party_id] - party_members = [self.players[mid] for mid in party.member_ids if mid in self.players] - party_member_ids = {m.id for m in party_members} - - for m in party_members: - tx = m.x + dx - ty = m.y + dy - - if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y: - return MoveCheckResult( - direction=direction_name, - dx=dx, - dy=dy, - target_x=player.x + dx, - target_y=player.y + dy, - available=False, - 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( - 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}' path blocked by '{occupant.name}' at ({tx}, {ty}).", - ) - + new_positions, failure_reason, strength_penalty = self._compute_party_move( + party, player, dx, dy, occupied_map + ) + if new_positions is None: + return MoveCheckResult( + direction=direction_name, + dx=dx, + dy=dy, + target_x=player.x + dx, + target_y=player.y + dy, + available=False, + reason=failure_reason or "Blocked", + strength_penalty=0.0, + ) return MoveCheckResult( direction=direction_name, dx=dx, @@ -813,6 +901,7 @@ class GameEngine: target_y=player.y + dy, available=True, reason=None, + strength_penalty=strength_penalty, ) target_x = player.x + dx @@ -1289,21 +1378,33 @@ class GameEngine: prev_pos = {"x": player.x, "y": player.y} affected_players: List[Player] = [] - # Move party in unison or single bot + # Move party in follow-the-leader chain (forming a line) or single bot if player.party_id and player.party_id in self.parties: party = self.parties[player.party_id] - for mid in party.member_ids: - m = self.players.get(mid) - if m: - m.x += dx - m.y += dy - m.visited_locations.append({"x": m.x, "y": m.y}) - if check.strength_penalty > 0: + new_positions, failure_reason, _ = self._compute_party_move( + party, player, dx, dy, occupied + ) + if not new_positions: + raise ValueError(f"Illegal move: {failure_reason or 'Blocked'}") + + chain = self._get_ordered_party_chain(party, player) + for m in chain: + old_mx, old_my = m.x, m.y + new_mx, new_my = new_positions[m.id] + step_dx = new_mx - old_mx + step_dy = new_my - old_my + m.x = new_mx + m.y = new_my + m.visited_locations.append({"x": m.x, "y": m.y}) + + if step_dx != 0 and step_dy != 0: + if (old_mx + step_dx, old_my) in self.obstacles and (old_mx, old_my + step_dy) in self.obstacles: penalty = 0.2 if m.is_party_leader else 0.1 m.strength = round(max(0.1, m.strength - penalty), 1) - affected_players.append(m) - if check.strength_penalty > 0: - self._update_party_strength(party) + + affected_players.append(m) + + self._update_party_strength(party) else: player.x = check.target_x player.y = check.target_y diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fd4d49d..1c5bc86 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -361,3 +361,87 @@ def test_map_obstacles_mountains_valleys_and_connectivity(): 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() + +def test_party_movement_forms_line(): + client = TestClient(app) + client.post("/api/reset") + + # Clear obstacles in 15..25 x 15..25 so we have a completely open test area + for x in range(15, 26): + for y in range(15, 26): + if (x, y) in game_engine.obstacles: + del game_engine.obstacles[(x, y)] + + # Spawn 3 bots + p1 = client.post("/api/players", json={"name": "LineLeader", "color": "#FF0000", "strength": 3}).json() + p2 = client.post("/api/players", json={"name": "LineBot1", "color": "#00FF00", "strength": 2}).json() + p3 = client.post("/api/players", json={"name": "LineBot2", "color": "#0000FF", "strength": 1}).json() + + # Place in a horizontal row: (20, 20), (21, 20), (22, 20) + async def place_bots(): + b1 = await game_engine.get_player(p1["id"]) + b2 = await game_engine.get_player(p2["id"]) + b3 = await game_engine.get_player(p3["id"]) + b1.x, b1.y = 20, 20 + b2.x, b2.y = 21, 20 + b3.x, b3.y = 22, 20 + asyncio.run(place_bots()) + + client.post("/api/game/start") + + # Form party + res = client.post("/api/parties", json={ + "member_ids": [p1["id"], p2["id"], p3["id"]], + "leader_id": p1["id"], + "name": "LineSquad" + }) + assert res.status_code == 201 + + # Ensure LineLeader turn is active + game_engine.turn_order = [p1["id"], p2["id"], p3["id"]] + game_engine.current_turn_index = 0 + + # Step 1: Move UP (0, -1) + move1 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}).json() + assert move1["success"] is True + + b1 = client.get(f"/api/players/{p1['id']}").json() + b2 = client.get(f"/api/players/{p2['id']}").json() + b3 = client.get(f"/api/players/{p3['id']}").json() + + # Leader moved UP, B1 stepped into Leader's old spot, B2 stepped into B1's old spot + assert (b1["x"], b1["y"]) == (20, 19) + assert (b2["x"], b2["y"]) == (20, 20) + assert (b3["x"], b3["y"]) == (21, 20) + + # Step 2: Set turn to leader again and Move UP (0, -1) + game_engine.current_turn_index = 0 + move2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}).json() + assert move2["success"] is True + + b1 = client.get(f"/api/players/{p1['id']}").json() + b2 = client.get(f"/api/players/{p2['id']}").json() + b3 = client.get(f"/api/players/{p3['id']}").json() + + # Now completely in a vertical line! + assert (b1["x"], b1["y"]) == (20, 18) + assert (b2["x"], b2["y"]) == (20, 19) + assert (b3["x"], b3["y"]) == (20, 20) + + # Step 3: Turn RIGHT (1, 0) + game_engine.current_turn_index = 0 + move3 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "RIGHT"}).json() + assert move3["success"] is True + + b1 = client.get(f"/api/players/{p1['id']}").json() + b2 = client.get(f"/api/players/{p2['id']}").json() + b3 = client.get(f"/api/players/{p3['id']}").json() + + # Follow-the-leader around the corner + assert (b1["x"], b1["y"]) == (21, 18) + assert (b2["x"], b2["y"]) == (20, 18) + assert (b3["x"], b3["y"]) == (20, 19) + + # Distances are all <= 1 + assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1 + assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1