feat(game): implement follow-the-leader party movement so squads form single-file lines
This commit is contained in:
parent
f04b6c78aa
commit
529f5c2dc9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue