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:
|
async with self._lock:
|
||||||
return self.parties.get(party_id)
|
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
|
# Movement Checking & Execution
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -763,48 +879,20 @@ class GameEngine:
|
||||||
) -> MoveCheckResult:
|
) -> MoveCheckResult:
|
||||||
if player.party_id and player.party_id in self.parties and player.is_party_leader:
|
if player.party_id and player.party_id in self.parties and player.is_party_leader:
|
||||||
party = self.parties[player.party_id]
|
party = self.parties[player.party_id]
|
||||||
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
|
new_positions, failure_reason, strength_penalty = self._compute_party_move(
|
||||||
party_member_ids = {m.id for m in party_members}
|
party, player, dx, dy, occupied_map
|
||||||
|
)
|
||||||
for m in party_members:
|
if new_positions is None:
|
||||||
tx = m.x + dx
|
return MoveCheckResult(
|
||||||
ty = m.y + dy
|
direction=direction_name,
|
||||||
|
dx=dx,
|
||||||
if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y:
|
dy=dy,
|
||||||
return MoveCheckResult(
|
target_x=player.x + dx,
|
||||||
direction=direction_name,
|
target_y=player.y + dy,
|
||||||
dx=dx,
|
available=False,
|
||||||
dy=dy,
|
reason=failure_reason or "Blocked",
|
||||||
target_x=player.x + dx,
|
strength_penalty=0.0,
|
||||||
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}).",
|
|
||||||
)
|
|
||||||
|
|
||||||
return MoveCheckResult(
|
return MoveCheckResult(
|
||||||
direction=direction_name,
|
direction=direction_name,
|
||||||
dx=dx,
|
dx=dx,
|
||||||
|
|
@ -813,6 +901,7 @@ class GameEngine:
|
||||||
target_y=player.y + dy,
|
target_y=player.y + dy,
|
||||||
available=True,
|
available=True,
|
||||||
reason=None,
|
reason=None,
|
||||||
|
strength_penalty=strength_penalty,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_x = player.x + dx
|
target_x = player.x + dx
|
||||||
|
|
@ -1289,21 +1378,33 @@ class GameEngine:
|
||||||
prev_pos = {"x": player.x, "y": player.y}
|
prev_pos = {"x": player.x, "y": player.y}
|
||||||
affected_players: List[Player] = []
|
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:
|
if player.party_id and player.party_id in self.parties:
|
||||||
party = self.parties[player.party_id]
|
party = self.parties[player.party_id]
|
||||||
for mid in party.member_ids:
|
new_positions, failure_reason, _ = self._compute_party_move(
|
||||||
m = self.players.get(mid)
|
party, player, dx, dy, occupied
|
||||||
if m:
|
)
|
||||||
m.x += dx
|
if not new_positions:
|
||||||
m.y += dy
|
raise ValueError(f"Illegal move: {failure_reason or 'Blocked'}")
|
||||||
m.visited_locations.append({"x": m.x, "y": m.y})
|
|
||||||
if check.strength_penalty > 0:
|
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
|
penalty = 0.2 if m.is_party_leader else 0.1
|
||||||
m.strength = round(max(0.1, m.strength - penalty), 1)
|
m.strength = round(max(0.1, m.strength - penalty), 1)
|
||||||
affected_players.append(m)
|
|
||||||
if check.strength_penalty > 0:
|
affected_players.append(m)
|
||||||
self._update_party_strength(party)
|
|
||||||
|
self._update_party_strength(party)
|
||||||
else:
|
else:
|
||||||
player.x = check.target_x
|
player.x = check.target_x
|
||||||
player.y = check.target_y
|
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()
|
chk = client.get(f"/api/players/{test_bot['id']}/check-move?direction={dir_to_obs}").json()
|
||||||
assert chk["available"] is False
|
assert chk["available"] is False
|
||||||
assert "impassable" in chk["reason"].lower()
|
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