forced movement when stuck, 1.6
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 59s Details

This commit is contained in:
Isaac Johnson 2026-09-14 21:36:44 -05:00
parent 4c69a86726
commit f90690dccb
14 changed files with 754 additions and 28 deletions

View File

@ -127,6 +127,12 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
### 4. Party Squad Movement
- The party leader chooses movement direction.
- Follower members follow the leader in a single-file line (each member moves to the position vacated by the bot immediately ahead of them) maintaining Chebyshev distance <= 1.
- **Forced Unstuck Maneuver (2 Subsequent Passes)**:
- When squads are stuck against forests, mountain ranges, or dead ends where no valid directional moves can be made without breaking squad formation, the leader's only available action is "pass".
- After **2 subsequent passes** by the party leader (`consecutive_passes >= 2`), the forced unstuck mechanic activates automatically (via `POST /api/players/{id}/pass` or autonomous `POST /api/players/{id}/ai-step`).
- All party members are forced to move around by exactly 1 space (Chebyshev distance == 1, no stationary bots).
- The leader moves 1 space away from the nearest obstacle(s), and each squad follower moves 1 space to follow suit and maintain chain connectivity (Chebyshev distance <= 1).
- Executing any successful directional move, attack, or encounter resets `consecutive_passes` back to 0.
### 5. Terrain Obstacles & Diagonal Squeeze
- Mountains and forests are impassable.

View File

@ -19,6 +19,12 @@
4. **Party Movement**:
1. The party leader controls the movement direction.
2. Squad members follow the leader in a single-file line (each member steps into the spot vacated by the bot ahead of them) while maintaining a distance of 1.
3. **Forced Unstuck Maneuver (2 Subsequent Passes)**:
- When larger squads navigate tight terrain bottlenecks, dead ends, or corners surrounded by mountains and forests, the leader may have no valid moves to advance or turn without breaking squad connectivity, making "pass" the only option.
- If a party leader passes their turn **2 subsequent times** (consecutive passes >= 2), the forced unstuck maneuver triggers automatically.
- All characters in the party are forced to move around by exactly 1 space (Chebyshev distance == 1, nobody stays stationary).
- The party leader moves 1 space away from the nearest obstacle(s), and each squad member moves 1 space to follow suit while maintaining Chebyshev distance <= 1 chain connectivity.
- Executing any successful move, attack, or action resets the party's consecutive pass counter back to 0.
5. **Obstacles & Terrain Squeeze**:
1. Mountains and forests are impassable.

View File

@ -604,6 +604,7 @@ async def step_bot_ai(player_id: str):
"previous_position": result.move_result.previous_position,
"new_position": result.move_result.new_position,
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": result.move_result.turn.model_dump(),
})
if result.move_result.party_formed_triggered and result.move_result.formed_party:
@ -711,11 +712,41 @@ async def sleep_player(player_id: str):
async def pass_turn(player_id: str):
try:
next_turn = await game_engine.pass_turn(player_id)
board_state = await game_engine.get_board_state()
if getattr(next_turn, "unstuck_triggered", False):
await manager.broadcast({
"event": "player_moved",
"player_id": player_id,
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": next_turn.model_dump(),
"unstuck": True,
"unstuck_message": next_turn.unstuck_message,
})
await manager.broadcast({
"event": "turn_passed",
"passed_by": player_id,
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": next_turn.model_dump(),
"unstuck_message": next_turn.unstuck_message,
})
else:
await manager.broadcast({
"event": "turn_passed",
"passed_by": player_id,
"turn": next_turn.model_dump(),
})
if board_state.conclusion and board_state.conclusion.concluded:
await manager.broadcast({
"event": "game_concluded",
"conclusion": board_state.conclusion.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return next_turn
except KeyError:
raise HTTPException(

View File

@ -338,7 +338,12 @@ class GameEngine:
player_id = actors[safe_index]
return self.players.get(player_id)
def _get_turn_info(self) -> TurnInfo:
def _get_turn_info(
self,
unstuck_triggered: bool = False,
unstuck_party_id: Optional[str] = None,
unstuck_message: Optional[str] = None,
) -> TurnInfo:
if not self.game_started:
return TurnInfo(
game_started=False,
@ -347,6 +352,9 @@ class GameEngine:
round_number=0,
turn_number=0,
turn_order=[],
unstuck_triggered=unstuck_triggered,
unstuck_party_id=unstuck_party_id,
unstuck_message=unstuck_message,
)
current = self._get_current_player()
actors = self._get_active_turn_actors()
@ -357,6 +365,9 @@ class GameEngine:
round_number=self.round_number,
turn_number=self.turn_number,
turn_order=actors,
unstuck_triggered=unstuck_triggered,
unstuck_party_id=unstuck_party_id,
unstuck_message=unstuck_message,
)
def _advance_turn(self):
@ -969,7 +980,7 @@ class GameEngine:
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:
if occupant is not None:
return None, f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.", 0.0
strength_penalty = 0.0
@ -1049,6 +1060,229 @@ class GameEngine:
return new_positions, None, strength_penalty
def _compute_party_unstuck_move(
self,
party: Party,
leader: Player,
occupied_map: Dict[Tuple[int, int], Player],
) -> Optional[Dict[str, Tuple[int, int]]]:
"""
Compute forced unstuck coordinates for all party members after 2 subsequent passes.
The leader moves 1 space away from the nearest obstacle(s), and all followers
move around by 1 space to follow suit and maintain party connectivity (Chebyshev dist <= 1).
Handles squads of any size and geometry (straight lines, clusters, L-shapes).
"""
chain = self._get_ordered_party_chain(party, leader)
chain = [p for p in chain if p.is_alive and p.health > 0]
if not chain:
return None
old_positions = {p.id: (p.x, p.y) for p in chain}
party_member_ids = set(party.member_ids)
lx, ly = leader.x, leader.y
min_obs_dist = float("inf")
closest_obstacles: List[Tuple[int, int]] = []
if self.obstacles:
for (ox, oy) in self.obstacles.keys():
d = max(abs(lx - ox), abs(ly - oy))
if d < min_obs_dist:
min_obs_dist = d
closest_obstacles = [(ox, oy)]
elif d == min_obs_dist:
closest_obstacles.append((ox, oy))
def cand_obs_score(pos: Tuple[int, int]) -> Tuple[float, float, float]:
cx, cy = pos
near_cheb = (
min(max(abs(cx - ox), abs(cy - oy)) for (ox, oy) in closest_obstacles)
if closest_obstacles
else 0.0
)
near_euc = (
min((cx - ox) ** 2 + (cy - oy) ** 2 for (ox, oy) in closest_obstacles)
if closest_obstacles
else 0.0
)
all_cheb = (
min(max(abs(cx - ox), abs(cy - oy)) for (ox, oy) in self.obstacles.keys())
if self.obstacles
else 0.0
)
return (near_cheb, all_cheb, near_euc)
leader_candidates: List[Tuple[int, int]] = []
for _, dx, dy in STANDARD_DIRECTIONS:
tx = lx + dx
ty = ly + dy
if not (
self.config.min_x <= tx <= self.config.max_x
and self.config.min_y <= ty <= self.config.max_y
):
continue
if (tx, ty) in self.obstacles:
continue
occ = occupied_map.get((tx, ty))
if occ is not None and occ.id not in party_member_ids:
continue
leader_candidates.append((tx, ty))
# Sort candidate positions so moving furthest away from nearest obstacle is prioritized
leader_candidates.sort(key=cand_obs_score, reverse=True)
if not leader_candidates:
return None
# Two-pass solver:
# Pass 1: Try to move every single member by 1 space (allow_stationary = False)
# Pass 2: If tight terrain/obstacles prevent some member from moving, allow stationary fallback
for allow_stationary in (False, True):
def solve(
idx: int,
current_pos: Dict[str, Tuple[int, int]],
occ_set: Set[Tuple[int, int]],
) -> Optional[Dict[str, Tuple[int, int]]]:
if idx == len(chain):
return current_pos
curr = chain[idx]
curr_old = old_positions[curr.id]
pred = chain[idx - 1]
pred_new = current_pos[pred.id]
pred_old = old_positions[pred.id]
cands_strict_line: List[Tuple[int, int]] = []
cands_cluster_move: List[Tuple[int, int]] = []
cands_stationary: List[Tuple[int, int]] = []
for cdx in (-1, 0, 1):
for cdy in (-1, 0, 1):
cx = curr_old[0] + cdx
cy = curr_old[1] + cdy
cand = (cx, cy)
if not (
self.config.min_x <= cx <= self.config.max_x
and self.config.min_y <= cy <= self.config.max_y
):
continue
if cand in self.obstacles or cand in occ_set:
continue
occ = occupied_map.get(cand)
if occ is not None and occ.id not in party_member_ids:
continue
is_moving = (cdx != 0 or cdy != 0)
is_pred_adjacent = (
max(abs(cx - pred_new[0]), abs(cy - pred_new[1])) <= 1
)
is_any_adjacent = any(
max(abs(cx - pos[0]), abs(cy - pos[1])) <= 1
for pos in current_pos.values()
)
if is_moving and is_pred_adjacent:
cands_strict_line.append(cand)
elif is_moving and is_any_adjacent:
cands_cluster_move.append(cand)
elif not is_moving and is_any_adjacent and allow_stationary:
cands_stationary.append(cand)
def sort_key(c: Tuple[int, int]) -> Tuple[int, float]:
pref = 2 if c == pred_old else 0
return (pref, cand_obs_score(c)[0])
cands_strict_line.sort(key=sort_key, reverse=True)
cands_cluster_move.sort(key=sort_key, reverse=True)
ordered_cands = cands_strict_line + cands_cluster_move + cands_stationary
for cand in ordered_cands:
current_pos[curr.id] = cand
occ_set.add(cand)
res = solve(idx + 1, current_pos, occ_set)
if res is not None:
return res
occ_set.remove(cand)
del current_pos[curr.id]
return None
for l_cand in leader_candidates:
curr_pos = {leader.id: l_cand}
occ_set = {l_cand}
sol = solve(1, curr_pos, occ_set)
if sol is not None:
return sol
return None
def _execute_party_unstuck(
self, party: Party, leader: Player
) -> Optional[MoveResponse]:
"""
Execute forced unstuck movement for a party after 2 subsequent passes.
Updates coordinates, visited locations, diagonal squeeze penalties, and returns MoveResponse.
"""
occupied = self._get_occupied_coordinates()
new_positions = self._compute_party_unstuck_move(party, leader, occupied)
if not new_positions:
return None
chain = self._get_ordered_party_chain(party, leader)
chain = [p for p in chain if p.is_alive and p.health > 0]
old_pos = {leader.id: {"x": leader.x, "y": leader.y}}
affected_players: List[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)
self._update_party_strength(party)
party.consecutive_passes = 0
ldx = leader.x - old_pos[leader.id]["x"]
ldy = leader.y - old_pos[leader.id]["y"]
dir_name = "UNSTUCK"
for dname, dx, dy in STANDARD_DIRECTIONS:
if dx == ldx and dy == ldy:
dir_name = dname
break
formed_party, battle_result = self._check_adjacent_encounter(leader)
turn_info = self._get_turn_info()
conclusion = self._check_game_concluded()
return MoveResponse(
success=True,
player=leader,
direction=dir_name,
party_moved=True,
affected_players=affected_players,
previous_position=old_pos[leader.id],
new_position={"x": leader.x, "y": leader.y},
party_formed_triggered=bool(formed_party),
formed_party=formed_party,
battle_triggered=bool(battle_result),
battle_result=battle_result,
game_concluded=conclusion if conclusion.concluded else None,
turn=turn_info,
)
# ==========================================
# Movement Checking & Execution
# ==========================================
@ -1965,6 +2199,7 @@ class GameEngine:
affected_players.append(m)
self._update_party_strength(party)
party.consecutive_passes = 0
else:
player.x = check.target_x
player.y = check.target_y
@ -2016,8 +2251,28 @@ class GameEngine:
f"It is not your turn to pass. Current turn belongs to '{curr_name}' ({curr_id})."
)
unstuck_triggered = False
unstuck_party_id = None
unstuck_message = None
if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id]
party.consecutive_passes += 1
if party.consecutive_passes >= 2:
move_res = self._execute_party_unstuck(party, player)
if move_res:
unstuck_triggered = True
unstuck_party_id = party.id
unstuck_message = (
f"🔄 Party '{party.name}' was unstuck and forced to move by 1 space away from obstacles after 2 subsequent passes!"
)
self._advance_turn()
return self._get_turn_info()
return self._get_turn_info(
unstuck_triggered=unstuck_triggered,
unstuck_party_id=unstuck_party_id,
unstuck_message=unstuck_message,
)
async def sleep_player(self, player_id: str) -> SleepResponse:
async with self._lock:
@ -2239,6 +2494,8 @@ class GameEngine:
# 1. Check if already adjacent to encounter before moving
formed_party, battle_res = self._check_adjacent_encounter(player)
if battle_res:
if player.party_id and player.party_id in self.parties:
self.parties[player.party_id].consecutive_passes = 0
self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse(
@ -2255,6 +2512,8 @@ class GameEngine:
)
if formed_party:
if player.party_id and player.party_id in self.parties:
self.parties[player.party_id].consecutive_passes = 0
self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse(
@ -2278,6 +2537,8 @@ class GameEngine:
if player.party_id and player.party_id in self.parties:
effective_str = self.parties[player.party_id].total_strength
if effective_str >= self.wizard.strength or player.health >= 4 or player.health < 2:
if player.party_id and player.party_id in self.parties:
self.parties[player.party_id].consecutive_passes = 0
if player.health <= 5:
bot_reward = "health"
elif player.strength < 4.0:
@ -2308,6 +2569,33 @@ class GameEngine:
available_dirs = [name for name, chk in moves_map.items() if chk.available]
if not available_dirs:
if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id]
party.consecutive_passes += 1
if party.consecutive_passes >= 2:
move_res = self._execute_party_unstuck(party, player)
if move_res:
self._advance_turn()
turn_info = self._get_turn_info(
unstuck_triggered=True,
unstuck_party_id=party.id,
unstuck_message=f"🔄 Party '{party.name}' was unstuck and forced to move by 1 space away from obstacles after 2 subsequent passes!",
)
move_res.turn = turn_info
conclusion = self._check_game_concluded()
return AiStepResponse(
action_taken="moved",
player_id=player.id,
player_name=player.name,
bot_goal="unstuck_from_obstacles",
direction=move_res.direction,
move_result=move_res,
formed_party=move_res.formed_party,
battle_result=move_res.battle_result,
game_concluded=conclusion if conclusion.concluded else None,
turn=turn_info,
)
self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse(
@ -2388,15 +2676,35 @@ class GameEngine:
dx, dy = DIRECTION_OFFSETS[best_dir]
prev_pos = {"x": player.x, "y": player.y}
affected_players: List[Player] = []
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
new_positions, _, _ = self._compute_party_move(
party, player, dx, dy, occupied
)
if new_positions:
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)
self._update_party_strength(party)
party.consecutive_passes = 0
else:
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})
affected_players.append(m)
else:
player.x += dx

View File

@ -168,6 +168,7 @@ class Party(BaseModel):
leader_name: str
member_ids: List[str]
total_strength: float = 1.0
consecutive_passes: int = 0
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@ -385,6 +386,9 @@ class TurnInfo(BaseModel):
round_number: int = 1
turn_number: int = 0
turn_order: List[str] = []
unstuck_triggered: bool = False
unstuck_party_id: Optional[str] = None
unstuck_message: Optional[str] = None
# ==========================================

View File

@ -887,6 +887,8 @@ def test_trolls_do_not_band_together_or_battle_each_other():
# Place trolls adjacent
async def place_trolls():
for coord in [(10, 10), (10, 11), (11, 10)]:
game_engine.obstacles.pop(coord, None)
b1 = await game_engine.get_player(t1["id"])
b2 = await game_engine.get_player(t2["id"])
b1.x, b1.y = 10, 10
@ -934,6 +936,7 @@ def test_troll_vs_player_mandatory_battle_and_mechanics():
# Place them 1 tile apart: player at (10, 10), troll at (10, 12)
async def place_combatants():
game_engine.obstacles.pop((10, 11), None)
t = await game_engine.get_player(troll["id"])
p = await game_engine.get_player(player["id"])
t.x, t.y = 10, 12
@ -1139,3 +1142,272 @@ def test_game_concludes_when_single_entity_remains():
assert conc["winning_leader_id"] == p1["id"]
assert conc["winning_leader_name"] == "LoneSurvivor"
def test_party_unstuck_after_two_subsequent_passes():
"""
When a party leader passes for 2 subsequent turns, all characters in the party
are forced to move around by 1 space to get unstuck, with the leader moving away
from the nearest obstacle and followers maintaining party connectivity.
"""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
# Add a 3rd entity so game doesn't conclude when party is formed
t1 = client.post("/api/players", json={"name": "WatcherTroll", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
# Set up positions near an obstacle
async def setup_party_near_obstacle():
# Clear obstacles in the immediate testing area
for x in range(18, 23):
for y in range(19, 24):
game_engine.obstacles.pop((x, y), None)
# Place obstacle at (20, 20)
from app.models import Obstacle
game_engine.obstacles[(20, 20)] = Obstacle(x=20, y=20, type="mountain")
# Place leader adjacent to obstacle at (20, 21)
lead = await game_engine.get_player(p1["id"])
lead.x = 20
lead.y = 21
# Place follower at (20, 22)
fol = await game_engine.get_player(p2["id"])
fol.x = 20
fol.y = 22
# Place troll far away
tr = await game_engine.get_player(t1["id"])
tr.x = 40
tr.y = 40
asyncio.run(setup_party_near_obstacle())
client.post("/api/game/start")
# Form party now that game is started and bots are adjacent
party_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
"name": "SquadLeaderBot",
})
assert party_res.status_code == 201
party = party_res.json()
# Ensure it is leader's turn
game_engine.turn_order = [p1["id"], t1["id"]]
game_engine.current_turn_index = 0
# Verify initial positions
party_obj = client.get(f"/api/parties/{party['id']}").json()
assert party_obj["consecutive_passes"] == 0
# 1st Pass: consecutive_passes should become 1, no unstuck move
pass1_res = client.post(f"/api/players/{p1['id']}/pass")
assert pass1_res.status_code == 200
pass1_data = pass1_res.json()
assert pass1_data.get("unstuck_triggered") is False
party_after_pass1 = client.get(f"/api/parties/{party['id']}").json()
assert party_after_pass1["consecutive_passes"] == 1
# Leader and follower should NOT have moved yet
lead_pos1 = client.get(f"/api/players/{p1['id']}").json()
fol_pos1 = client.get(f"/api/players/{p2['id']}").json()
assert (lead_pos1["x"], lead_pos1["y"]) == (20, 21)
assert (fol_pos1["x"], fol_pos1["y"]) == (20, 22)
# Troll passes turn back to leader
client.post(f"/api/players/{t1['id']}/pass")
# 2nd Subsequent Pass: Should trigger unstuck!
pass2_res = client.post(f"/api/players/{p1['id']}/pass")
assert pass2_res.status_code == 200
pass2_data = pass2_res.json()
assert pass2_data.get("unstuck_triggered") is True
assert "unstuck" in pass2_data.get("unstuck_message", "").lower()
# Consecutive passes should be reset to 0
party_after_pass2 = client.get(f"/api/parties/{party['id']}").json()
assert party_after_pass2["consecutive_passes"] == 0
# Leader and follower MUST have moved by 1 space
lead_pos2 = client.get(f"/api/players/{p1['id']}").json()
fol_pos2 = client.get(f"/api/players/{p2['id']}").json()
# Both must move by Chebyshev distance == 1
lead_dist = max(abs(lead_pos2["x"] - 20), abs(lead_pos2["y"] - 21))
fol_dist = max(abs(fol_pos2["x"] - 20), abs(fol_pos2["y"] - 22))
assert lead_dist == 1
assert fol_dist == 1
# Leader must have moved 1 space away from the obstacle at (20, 20)
# Old dist from (20, 20) was 1 (max(0, 1) = 1)
new_obs_dist = max(abs(lead_pos2["x"] - 20), abs(lead_pos2["y"] - 20))
assert new_obs_dist >= 1
# Follower and leader must maintain connectivity (Chebyshev distance <= 1)
inter_member_dist = max(abs(lead_pos2["x"] - fol_pos2["x"]), abs(lead_pos2["y"] - fol_pos2["y"]))
assert inter_member_dist <= 1
def test_moving_resets_party_consecutive_passes():
"""Moving successfully resets consecutive_passes back to 0."""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "ResetLead", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
p2 = client.post("/api/players", json={"name": "ResetFollower", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
t1 = client.post("/api/players", json={"name": "TrollWatcher", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
# Set coordinates in open field
async def setup_open_field():
for x in range(28, 33):
for y in range(28, 33):
game_engine.obstacles.pop((x, y), None)
lead = await game_engine.get_player(p1["id"])
lead.x = 30
lead.y = 30
fol = await game_engine.get_player(p2["id"])
fol.x = 30
fol.y = 31
tr = await game_engine.get_player(t1["id"])
tr.x = 50
tr.y = 50
asyncio.run(setup_open_field())
client.post("/api/game/start")
party_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
"name": "SquadResetLead",
})
assert party_res.status_code == 201
party = party_res.json()
game_engine.turn_order = [p1["id"], t1["id"]]
game_engine.current_turn_index = 0
# 1st Pass
client.post(f"/api/players/{p1['id']}/pass")
party_state = client.get(f"/api/parties/{party['id']}").json()
assert party_state["consecutive_passes"] == 1
# Troll passes
client.post(f"/api/players/{t1['id']}/pass")
# Now leader moves instead of passing
move_res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 200
# consecutive_passes must be reset to 0!
party_after_move = client.get(f"/api/parties/{party['id']}").json()
assert party_after_move["consecutive_passes"] == 0
def test_party_unstuck_via_ai_step():
"""When a bot party has no available moves, 2 consecutive ai-step calls trigger unstuck."""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "AiLead", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
p2 = client.post("/api/players", json={"name": "AiFollower1", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
p3 = client.post("/api/players", json={"name": "AiFollower2", "color": "#a855f7", "strength": 2, "character_type": "player"}).json()
p4 = client.post("/api/players", json={"name": "AiFollower3", "color": "#f59e0b", "strength": 2, "character_type": "player"}).json()
t1 = client.post("/api/players", json={"name": "AiTroll", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
# Set up party of 4 in a horizontal dead-end corridor:
# Leader at (10, 10), F1 at (11, 10), F2 at (12, 10), F3 at (13, 10).
# Impassable obstacles wall in the leader on left/top/bottom and corridor edges.
async def setup_dead_end():
from app.models import Obstacle
# Clear area first
for x in range(8, 16):
for y in range(8, 13):
game_engine.obstacles.pop((x, y), None)
# Place obstacles
blocked_coords = [
(10, 11), (9, 10), (10, 9), (9, 11), (9, 9),
(11, 11), (11, 9),
(12, 11), (12, 9),
(13, 11), (13, 9),
]
for bx, by in blocked_coords:
game_engine.obstacles[(bx, by)] = Obstacle(x=bx, y=by, type="mountain")
lead = await game_engine.get_player(p1["id"])
lead.x = 10
lead.y = 10
fol1 = await game_engine.get_player(p2["id"])
fol1.x = 11
fol1.y = 10
fol2 = await game_engine.get_player(p3["id"])
fol2.x = 12
fol2.y = 10
fol3 = await game_engine.get_player(p4["id"])
fol3.x = 13
fol3.y = 10
tr = await game_engine.get_player(t1["id"])
tr.x = 50
tr.y = 50
asyncio.run(setup_dead_end())
client.post("/api/game/start")
party_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"], p3["id"], p4["id"]],
"leader_id": p1["id"],
"name": "AiSquad",
})
assert party_res.status_code == 201
party = party_res.json()
game_engine.turn_order = [p1["id"], t1["id"]]
game_engine.current_turn_index = 0
# Verify Leader has 0 available moves
moves_res = client.get(f"/api/players/{p1['id']}/available-moves").json()
available_count = sum(1 for m in moves_res["moves"].values() if m["available"])
assert available_count == 0
# 1st ai-step: Should pass
step1_res = client.post(f"/api/players/{p1['id']}/ai-step")
assert step1_res.status_code == 200
step1_data = step1_res.json()
assert step1_data["action_taken"] == "passed"
party_state1 = client.get(f"/api/parties/{party['id']}").json()
assert party_state1["consecutive_passes"] == 1
# Troll passes
client.post(f"/api/players/{t1['id']}/pass")
# 2nd ai-step: Should trigger unstuck maneuver!
step2_res = client.post(f"/api/players/{p1['id']}/ai-step")
assert step2_res.status_code == 200
step2_data = step2_res.json()
assert step2_data["action_taken"] == "moved"
assert step2_data["bot_goal"] == "unstuck_from_obstacles"
assert step2_data["move_result"] is not None
# Consecutive passes reset to 0
party_state2 = client.get(f"/api/parties/{party['id']}").json()
assert party_state2["consecutive_passes"] == 0
# Verify all 4 bots moved by 1 space
lead_pos = client.get(f"/api/players/{p1['id']}").json()
fol1_pos = client.get(f"/api/players/{p2['id']}").json()
fol2_pos = client.get(f"/api/players/{p3['id']}").json()
fol3_pos = client.get(f"/api/players/{p4['id']}").json()
assert max(abs(lead_pos["x"] - 10), abs(lead_pos["y"] - 10)) == 1
assert max(abs(fol1_pos["x"] - 11), abs(fol1_pos["y"] - 10)) == 1
assert max(abs(fol2_pos["x"] - 12), abs(fol2_pos["y"] - 10)) == 1
assert max(abs(fol3_pos["x"] - 13), abs(fol3_pos["y"] - 10)) == 1
# Check connectivity maintained
assert max(abs(lead_pos["x"] - fol1_pos["x"]), abs(lead_pos["y"] - fol1_pos["y"])) <= 1
assert max(abs(fol1_pos["x"] - fol2_pos["x"]), abs(fol1_pos["y"] - fol2_pos["y"])) <= 1
assert max(abs(fol2_pos["x"] - fol3_pos["x"]), abs(fol2_pos["y"] - fol3_pos["y"])) <= 1

View File

@ -65,6 +65,7 @@ export function App() {
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
const [showNamesAndControls, setShowNamesAndControls] = useState(true);
const [promptChallengerId, setPromptChallengerId] = useState<string | null>(null);
const [notification, setNotification] = useState<string | null>(null);
@ -206,6 +207,8 @@ export function App() {
onOpenScoreboard={() => setShowScoreboard(true)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
showNamesAndControls={showNamesAndControls}
onToggleNamesAndControls={setShowNamesAndControls}
/>
{/* Main Content Area */}
@ -217,6 +220,7 @@ export function App() {
availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer}
onChallengeWizard={handleOpenWizardPrompt}
showNametags={showNamesAndControls}
/>
{/* 8-Directional Movement D-Pad & Simulation Controls */}
@ -239,6 +243,8 @@ export function App() {
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
showNamesAndControls={showNamesAndControls}
onToggleNamesAndControls={setShowNamesAndControls}
/>
{/* Sidebar Player Roster with Turn Order & Parties */}

View File

@ -9,6 +9,7 @@ interface BoardCanvasProps {
onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
onChallengeWizard?: (playerId: string) => void;
showNametags?: boolean;
}
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
@ -18,6 +19,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
onSelectPlayer,
onHoverCoord,
onChallengeWizard,
showNametags = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -428,7 +430,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
if (boardState.wizard) {
const wx = startX + (boardState.wizard.x - min_x) * cellSize;
const wy = startY + (boardState.wizard.y - min_y) * cellSize;
drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize);
drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize, showNametags);
}
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
@ -438,7 +440,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn);
drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn, showNametags);
});
ctx.restore(); // end clip
@ -499,6 +501,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
max_x,
min_y,
max_y,
showNametags,
]);
// Mouse Drag to Pan

View File

@ -12,6 +12,8 @@ interface HeaderProps {
onOpenScoreboard?: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
showNamesAndControls?: boolean;
onToggleNamesAndControls?: (value: boolean) => void;
}
export const Header: React.FC<HeaderProps> = ({
@ -26,6 +28,8 @@ export const Header: React.FC<HeaderProps> = ({
onOpenScoreboard,
showPopups = true,
onTogglePopups,
showNamesAndControls = true,
onToggleNamesAndControls,
}) => {
return (
<header className="h-16 border-b border-slate-800 bg-slate-900/90 backdrop-blur px-6 flex items-center justify-between z-10">
@ -139,6 +143,33 @@ export const Header: React.FC<HeaderProps> = ({
</span>
</label>
{/* Character Nametags & Manual Controls Toggle */}
<label
className={`text-xs font-mono font-medium px-2.5 py-1.5 rounded-lg border transition-all flex items-center gap-2 cursor-pointer select-none ${
showNamesAndControls
? 'bg-slate-800/90 text-sky-300 border-sky-500/50 shadow-sm shadow-sky-950/50 hover:bg-slate-800'
: 'bg-slate-950/60 text-slate-400 border-slate-800 hover:text-slate-300 hover:border-slate-700'
}`}
title={
showNamesAndControls
? 'Names & controls enabled: Click to hide character nametags and manual control box'
: 'Names & controls disabled: Click to show character nametags and manual control box'
}
>
<input
type="checkbox"
checked={showNamesAndControls}
onChange={(e) => onToggleNamesAndControls?.(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
/>
<span className="flex items-center gap-1.5">
<span>Names & Controls:</span>
<strong className={showNamesAndControls ? 'text-sky-300' : 'text-slate-500'}>
{showNamesAndControls ? 'ON' : 'OFF'}
</strong>
</span>
</label>
<a
href="/docs"
target="_blank"

View File

@ -15,6 +15,8 @@ interface MovementControlsProps {
onToggleAutoPlay: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
showNamesAndControls?: boolean;
onToggleNamesAndControls?: (value: boolean) => void;
}
export const MovementControls: React.FC<MovementControlsProps> = ({
@ -30,6 +32,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
onToggleAutoPlay,
showPopups = true,
onTogglePopups,
showNamesAndControls = true,
onToggleNamesAndControls,
}) => {
const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id;
@ -43,6 +47,13 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
const isTroll = controlledPlayer?.character_type === 'troll';
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const playerParty = controlledPlayer?.party_id
? (boardState.parties || []).find((p) => p.id === controlledPlayer.party_id)
: null;
const isLeader = Boolean(controlledPlayer?.is_party_leader);
const consecutivePasses = playerParty?.consecutive_passes ?? 0;
const willUnstuckOnPass = isLeader && consecutivePasses === 1;
const isAdjacentToWizard = Boolean(
!isDead &&
controlledPlayer &&
@ -177,7 +188,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
if (boardState.players.length === 0) {
if (boardState.players.length === 0 || !showNamesAndControls) {
return null;
}
@ -280,14 +291,25 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
<button
onClick={handlePassClick}
disabled={!isStarted || !isMyTurn}
title={!isStarted ? "Game has not started yet" : "Pass turn (Spacebar)"}
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
title={
!isStarted
? "Game has not started yet"
: willUnstuckOnPass
? "Pass turn (Spacebar) - 2nd consecutive pass forces squad to move 1 space away from obstacles to get unstuck!"
: "Pass turn (Spacebar)"
}
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex flex-col items-center justify-center transition-all ${
!isStarted || !isMyTurn
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
: willUnstuckOnPass
? 'bg-amber-950/80 hover:bg-amber-600 hover:text-white text-amber-300 border border-amber-400/80 shadow-md ring-1 ring-amber-400/50 active:scale-95'
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
}`}
>
PASS
<span>PASS</span>
{willUnstuckOnPass && (
<span className="text-[7px] text-amber-200 uppercase tracking-tighter leading-none">Unstick</span>
)}
</button>
{renderDirButton('RIGHT', '→')}
</div>
@ -386,8 +408,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
</button>
</div>
{/* Result Popups Toggle */}
<div className="pt-2 border-t border-slate-800/80 flex items-center justify-between px-0.5">
{/* Result Popups & Names/Controls Toggles */}
<div className="pt-2 border-t border-slate-800/80 flex flex-col gap-1.5 px-0.5">
<label
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
title={
@ -406,6 +428,24 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
Popups: <strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>{showPopups ? 'ON' : 'OFF'}</strong>
</span>
</label>
<label
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
title={
showNamesAndControls
? 'Names & controls enabled: Click to hide character nametags and manual control box'
: 'Names & controls disabled: Click to show character nametags and manual control box'
}
>
<input
type="checkbox"
checked={showNamesAndControls}
onChange={(e) => onToggleNamesAndControls?.(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
/>
<span className="text-[11px]">
Names & Controls: <strong className={showNamesAndControls ? 'text-sky-300' : 'text-slate-500'}>{showNamesAndControls ? 'ON' : 'OFF'}</strong>
</span>
</label>
</div>
<div className="text-[10px] text-slate-500 font-mono text-center">

View File

@ -275,8 +275,13 @@ export function useGameSocket() {
} else if (data.event === 'turn_passed') {
setBoardState((prev) => ({
...prev,
players: data.players ?? prev.players,
parties: data.parties ?? prev.parties,
turn: data.turn ?? prev.turn,
}));
if (data.unstuck_message) {
setLastEventMessage(data.unstuck_message);
}
} else if (data.event === 'player_slept') {
setBoardState((prev) => ({
...prev,
@ -557,6 +562,8 @@ export function useGameSocket() {
);
} else if (data.action_taken === 'slept' && data.sleep_result) {
setLastEventMessage(`💤 ${data.player_name} took a restful sleep (+${data.sleep_result.health_gained} HP -> ${data.sleep_result.new_health} HP)!`);
} else if (data.bot_goal === 'unstuck_from_obstacles') {
setLastEventMessage(`🔄 Squad "${data.player_name}" unstuck from obstacles! All members moved 1 space away.`);
} else if (data.move_result?.battle_result) {
if (showPopupsRef.current) {
setActiveBattle(data.move_result.battle_result);

View File

@ -39,6 +39,7 @@ export interface Party {
leader_name: string;
member_ids: string[];
total_strength: number;
consecutive_passes?: number;
created_at?: string;
}
@ -49,6 +50,9 @@ export interface TurnInfo {
round_number: number;
turn_number: number;
turn_order: string[];
unstuck_triggered?: boolean;
unstuck_party_id?: string | null;
unstuck_message?: string | null;
}
export interface GameConclusion {

View File

@ -372,7 +372,8 @@ export function drawPlayerPiece(
py: number,
cellSize: number,
isSelected: boolean,
isCurrentTurn: boolean
isCurrentTurn: boolean,
showNametags: boolean = true
): void {
const dead = isPlayerDead(player);
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
@ -447,8 +448,12 @@ export function drawPlayerPiece(
const roleIcon = dead ? '🪦' : isLeader ? '👑' : isTroll ? '👹' : pieceType === 'knight' ? '⚔️' : '🪓';
const hpStr = player.health !== undefined ? ` ❤️${Number(player.health.toFixed(1))}` : '';
const text = dead
? showNametags
? `🪦 ${player.name} [DEAD]`
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`;
: '🪦 [DEAD]'
: showNametags
? `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`
: `${player.strength.toFixed(1)}${hpStr}`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
@ -475,7 +480,8 @@ export function drawWizardPiece(
wizard: { x: number; y: number; name: string; strength: number; color?: string },
px: number,
py: number,
cellSize: number
cellSize: number,
showNametags: boolean = true
): void {
const color = wizard.color || '#A855F7';
const spriteCanvas = getSpriteCanvas('wizard', color, false);
@ -518,7 +524,9 @@ export function drawWizardPiece(
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`;
const text = showNametags
? `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`
: `${wizard.strength.toFixed(1)}`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;

View File

@ -1,2 +1,2 @@
[metadata]
version = 1.5
version = 1.6