feat(game): implement follow-the-leader party movement so squads form single-file lines

This commit is contained in:
Isaac Johnson 2026-09-06 13:14:41 -05:00
parent f04b6c78aa
commit 529f5c2dc9
2 changed files with 238 additions and 53 deletions

View File

@ -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

View File

@ -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