forced movement when stuck, 1.6
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 59s
Details
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 59s
Details
This commit is contained in:
parent
4c69a86726
commit
f90690dccb
|
|
@ -127,6 +127,12 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
|
||||||
### 4. Party Squad Movement
|
### 4. Party Squad Movement
|
||||||
- The party leader chooses movement direction.
|
- 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.
|
- 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
|
### 5. Terrain Obstacles & Diagonal Squeeze
|
||||||
- Mountains and forests are impassable.
|
- Mountains and forests are impassable.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
4. **Party Movement**:
|
4. **Party Movement**:
|
||||||
1. The party leader controls the movement direction.
|
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.
|
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**:
|
5. **Obstacles & Terrain Squeeze**:
|
||||||
1. Mountains and forests are impassable.
|
1. Mountains and forests are impassable.
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,7 @@ async def step_bot_ai(player_id: str):
|
||||||
"previous_position": result.move_result.previous_position,
|
"previous_position": result.move_result.previous_position,
|
||||||
"new_position": result.move_result.new_position,
|
"new_position": result.move_result.new_position,
|
||||||
"players": [p.model_dump() for p in board_state.players],
|
"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(),
|
"turn": result.move_result.turn.model_dump(),
|
||||||
})
|
})
|
||||||
if result.move_result.party_formed_triggered and result.move_result.formed_party:
|
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):
|
async def pass_turn(player_id: str):
|
||||||
try:
|
try:
|
||||||
next_turn = await game_engine.pass_turn(player_id)
|
next_turn = await game_engine.pass_turn(player_id)
|
||||||
await manager.broadcast({
|
board_state = await game_engine.get_board_state()
|
||||||
"event": "turn_passed",
|
if getattr(next_turn, "unstuck_triggered", False):
|
||||||
"passed_by": player_id,
|
await manager.broadcast({
|
||||||
"turn": next_turn.model_dump(),
|
"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
|
return next_turn
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,12 @@ class GameEngine:
|
||||||
player_id = actors[safe_index]
|
player_id = actors[safe_index]
|
||||||
return self.players.get(player_id)
|
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:
|
if not self.game_started:
|
||||||
return TurnInfo(
|
return TurnInfo(
|
||||||
game_started=False,
|
game_started=False,
|
||||||
|
|
@ -347,6 +352,9 @@ class GameEngine:
|
||||||
round_number=0,
|
round_number=0,
|
||||||
turn_number=0,
|
turn_number=0,
|
||||||
turn_order=[],
|
turn_order=[],
|
||||||
|
unstuck_triggered=unstuck_triggered,
|
||||||
|
unstuck_party_id=unstuck_party_id,
|
||||||
|
unstuck_message=unstuck_message,
|
||||||
)
|
)
|
||||||
current = self._get_current_player()
|
current = self._get_current_player()
|
||||||
actors = self._get_active_turn_actors()
|
actors = self._get_active_turn_actors()
|
||||||
|
|
@ -357,6 +365,9 @@ class GameEngine:
|
||||||
round_number=self.round_number,
|
round_number=self.round_number,
|
||||||
turn_number=self.turn_number,
|
turn_number=self.turn_number,
|
||||||
turn_order=actors,
|
turn_order=actors,
|
||||||
|
unstuck_triggered=unstuck_triggered,
|
||||||
|
unstuck_party_id=unstuck_party_id,
|
||||||
|
unstuck_message=unstuck_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _advance_turn(self):
|
def _advance_turn(self):
|
||||||
|
|
@ -969,7 +980,7 @@ class GameEngine:
|
||||||
|
|
||||||
party_member_ids = set(party.member_ids)
|
party_member_ids = set(party.member_ids)
|
||||||
occupant = occupied_map.get((target_x, target_y))
|
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
|
return None, f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.", 0.0
|
||||||
|
|
||||||
strength_penalty = 0.0
|
strength_penalty = 0.0
|
||||||
|
|
@ -1049,6 +1060,229 @@ class GameEngine:
|
||||||
|
|
||||||
return new_positions, None, strength_penalty
|
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
|
# Movement Checking & Execution
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -1965,6 +2199,7 @@ class GameEngine:
|
||||||
affected_players.append(m)
|
affected_players.append(m)
|
||||||
|
|
||||||
self._update_party_strength(party)
|
self._update_party_strength(party)
|
||||||
|
party.consecutive_passes = 0
|
||||||
else:
|
else:
|
||||||
player.x = check.target_x
|
player.x = check.target_x
|
||||||
player.y = check.target_y
|
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})."
|
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()
|
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 def sleep_player(self, player_id: str) -> SleepResponse:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
|
@ -2239,6 +2494,8 @@ class GameEngine:
|
||||||
# 1. Check if already adjacent to encounter before moving
|
# 1. Check if already adjacent to encounter before moving
|
||||||
formed_party, battle_res = self._check_adjacent_encounter(player)
|
formed_party, battle_res = self._check_adjacent_encounter(player)
|
||||||
if battle_res:
|
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()
|
self._advance_turn()
|
||||||
conclusion = self._check_game_concluded()
|
conclusion = self._check_game_concluded()
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
|
|
@ -2255,6 +2512,8 @@ class GameEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
if formed_party:
|
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()
|
self._advance_turn()
|
||||||
conclusion = self._check_game_concluded()
|
conclusion = self._check_game_concluded()
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
|
|
@ -2278,6 +2537,8 @@ class GameEngine:
|
||||||
if player.party_id and player.party_id in self.parties:
|
if player.party_id and player.party_id in self.parties:
|
||||||
effective_str = self.parties[player.party_id].total_strength
|
effective_str = self.parties[player.party_id].total_strength
|
||||||
if effective_str >= self.wizard.strength or player.health >= 4 or player.health < 2:
|
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:
|
if player.health <= 5:
|
||||||
bot_reward = "health"
|
bot_reward = "health"
|
||||||
elif player.strength < 4.0:
|
elif player.strength < 4.0:
|
||||||
|
|
@ -2308,6 +2569,33 @@ class GameEngine:
|
||||||
|
|
||||||
available_dirs = [name for name, chk in moves_map.items() if chk.available]
|
available_dirs = [name for name, chk in moves_map.items() if chk.available]
|
||||||
if not available_dirs:
|
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()
|
self._advance_turn()
|
||||||
conclusion = self._check_game_concluded()
|
conclusion = self._check_game_concluded()
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
|
|
@ -2388,16 +2676,36 @@ class GameEngine:
|
||||||
dx, dy = DIRECTION_OFFSETS[best_dir]
|
dx, dy = DIRECTION_OFFSETS[best_dir]
|
||||||
prev_pos = {"x": player.x, "y": player.y}
|
prev_pos = {"x": player.x, "y": player.y}
|
||||||
affected_players: List[Player] = []
|
affected_players: List[Player] = []
|
||||||
|
|
||||||
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, _, _ = self._compute_party_move(
|
||||||
m = self.players.get(mid)
|
party, player, dx, dy, occupied
|
||||||
if m:
|
)
|
||||||
m.x += dx
|
if new_positions:
|
||||||
m.y += dy
|
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})
|
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)
|
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:
|
||||||
|
affected_players.append(m)
|
||||||
else:
|
else:
|
||||||
player.x += dx
|
player.x += dx
|
||||||
player.y += dy
|
player.y += dy
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ class Party(BaseModel):
|
||||||
leader_name: str
|
leader_name: str
|
||||||
member_ids: List[str]
|
member_ids: List[str]
|
||||||
total_strength: float = 1.0
|
total_strength: float = 1.0
|
||||||
|
consecutive_passes: int = 0
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -385,6 +386,9 @@ class TurnInfo(BaseModel):
|
||||||
round_number: int = 1
|
round_number: int = 1
|
||||||
turn_number: int = 0
|
turn_number: int = 0
|
||||||
turn_order: List[str] = []
|
turn_order: List[str] = []
|
||||||
|
unstuck_triggered: bool = False
|
||||||
|
unstuck_party_id: Optional[str] = None
|
||||||
|
unstuck_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
|
||||||
|
|
@ -887,6 +887,8 @@ def test_trolls_do_not_band_together_or_battle_each_other():
|
||||||
|
|
||||||
# Place trolls adjacent
|
# Place trolls adjacent
|
||||||
async def place_trolls():
|
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"])
|
b1 = await game_engine.get_player(t1["id"])
|
||||||
b2 = await game_engine.get_player(t2["id"])
|
b2 = await game_engine.get_player(t2["id"])
|
||||||
b1.x, b1.y = 10, 10
|
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)
|
# Place them 1 tile apart: player at (10, 10), troll at (10, 12)
|
||||||
async def place_combatants():
|
async def place_combatants():
|
||||||
|
game_engine.obstacles.pop((10, 11), None)
|
||||||
t = await game_engine.get_player(troll["id"])
|
t = await game_engine.get_player(troll["id"])
|
||||||
p = await game_engine.get_player(player["id"])
|
p = await game_engine.get_player(player["id"])
|
||||||
t.x, t.y = 10, 12
|
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_id"] == p1["id"]
|
||||||
assert conc["winning_leader_name"] == "LoneSurvivor"
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export function App() {
|
||||||
|
|
||||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||||
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
|
const [isPartyModalOpen, setIsPartyModalOpen] = useState(false);
|
||||||
|
const [showNamesAndControls, setShowNamesAndControls] = useState(true);
|
||||||
const [promptChallengerId, setPromptChallengerId] = useState<string | null>(null);
|
const [promptChallengerId, setPromptChallengerId] = useState<string | null>(null);
|
||||||
const [notification, setNotification] = useState<string | null>(null);
|
const [notification, setNotification] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -206,6 +207,8 @@ export function App() {
|
||||||
onOpenScoreboard={() => setShowScoreboard(true)}
|
onOpenScoreboard={() => setShowScoreboard(true)}
|
||||||
showPopups={showPopups}
|
showPopups={showPopups}
|
||||||
onTogglePopups={setShowPopups}
|
onTogglePopups={setShowPopups}
|
||||||
|
showNamesAndControls={showNamesAndControls}
|
||||||
|
onToggleNamesAndControls={setShowNamesAndControls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
|
|
@ -217,6 +220,7 @@ export function App() {
|
||||||
availableMoves={availableMoves}
|
availableMoves={availableMoves}
|
||||||
onSelectPlayer={setSelectedPlayer}
|
onSelectPlayer={setSelectedPlayer}
|
||||||
onChallengeWizard={handleOpenWizardPrompt}
|
onChallengeWizard={handleOpenWizardPrompt}
|
||||||
|
showNametags={showNamesAndControls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 8-Directional Movement D-Pad & Simulation Controls */}
|
{/* 8-Directional Movement D-Pad & Simulation Controls */}
|
||||||
|
|
@ -239,6 +243,8 @@ export function App() {
|
||||||
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
|
||||||
showPopups={showPopups}
|
showPopups={showPopups}
|
||||||
onTogglePopups={setShowPopups}
|
onTogglePopups={setShowPopups}
|
||||||
|
showNamesAndControls={showNamesAndControls}
|
||||||
|
onToggleNamesAndControls={setShowNamesAndControls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar Player Roster with Turn Order & Parties */}
|
{/* Sidebar Player Roster with Turn Order & Parties */}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface BoardCanvasProps {
|
||||||
onSelectPlayer: (player: Player | null) => void;
|
onSelectPlayer: (player: Player | null) => void;
|
||||||
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
|
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
|
||||||
onChallengeWizard?: (playerId: string) => void;
|
onChallengeWizard?: (playerId: string) => void;
|
||||||
|
showNametags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
|
|
@ -18,6 +19,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
onSelectPlayer,
|
onSelectPlayer,
|
||||||
onHoverCoord,
|
onHoverCoord,
|
||||||
onChallengeWizard,
|
onChallengeWizard,
|
||||||
|
showNametags = true,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -428,7 +430,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
if (boardState.wizard) {
|
if (boardState.wizard) {
|
||||||
const wx = startX + (boardState.wizard.x - min_x) * cellSize;
|
const wx = startX + (boardState.wizard.x - min_x) * cellSize;
|
||||||
const wy = startY + (boardState.wizard.y - min_y) * 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)
|
// 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 isSelected = selectedPlayer?.id === player.id;
|
||||||
const isCurrentTurn = currentTurnId === 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
|
ctx.restore(); // end clip
|
||||||
|
|
@ -499,6 +501,7 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
max_x,
|
max_x,
|
||||||
min_y,
|
min_y,
|
||||||
max_y,
|
max_y,
|
||||||
|
showNametags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mouse Drag to Pan
|
// Mouse Drag to Pan
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ interface HeaderProps {
|
||||||
onOpenScoreboard?: () => void;
|
onOpenScoreboard?: () => void;
|
||||||
showPopups?: boolean;
|
showPopups?: boolean;
|
||||||
onTogglePopups?: (value?: boolean) => void;
|
onTogglePopups?: (value?: boolean) => void;
|
||||||
|
showNamesAndControls?: boolean;
|
||||||
|
onToggleNamesAndControls?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
|
@ -26,6 +28,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
onOpenScoreboard,
|
onOpenScoreboard,
|
||||||
showPopups = true,
|
showPopups = true,
|
||||||
onTogglePopups,
|
onTogglePopups,
|
||||||
|
showNamesAndControls = true,
|
||||||
|
onToggleNamesAndControls,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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">
|
<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>
|
</span>
|
||||||
</label>
|
</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
|
<a
|
||||||
href="/docs"
|
href="/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ interface MovementControlsProps {
|
||||||
onToggleAutoPlay: () => void;
|
onToggleAutoPlay: () => void;
|
||||||
showPopups?: boolean;
|
showPopups?: boolean;
|
||||||
onTogglePopups?: (value?: boolean) => void;
|
onTogglePopups?: (value?: boolean) => void;
|
||||||
|
showNamesAndControls?: boolean;
|
||||||
|
onToggleNamesAndControls?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MovementControls: React.FC<MovementControlsProps> = ({
|
export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
|
|
@ -30,6 +32,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
onToggleAutoPlay,
|
onToggleAutoPlay,
|
||||||
showPopups = true,
|
showPopups = true,
|
||||||
onTogglePopups,
|
onTogglePopups,
|
||||||
|
showNamesAndControls = true,
|
||||||
|
onToggleNamesAndControls,
|
||||||
}) => {
|
}) => {
|
||||||
const isStarted = Boolean(boardState.turn?.game_started);
|
const isStarted = Boolean(boardState.turn?.game_started);
|
||||||
const currentTurnId = boardState.turn.current_player_id;
|
const currentTurnId = boardState.turn.current_player_id;
|
||||||
|
|
@ -43,6 +47,13 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
const isTroll = controlledPlayer?.character_type === 'troll';
|
const isTroll = controlledPlayer?.character_type === 'troll';
|
||||||
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
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(
|
const isAdjacentToWizard = Boolean(
|
||||||
!isDead &&
|
!isDead &&
|
||||||
controlledPlayer &&
|
controlledPlayer &&
|
||||||
|
|
@ -177,7 +188,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
|
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
|
||||||
|
|
||||||
if (boardState.players.length === 0) {
|
if (boardState.players.length === 0 || !showNamesAndControls) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,14 +291,25 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
<button
|
<button
|
||||||
onClick={handlePassClick}
|
onClick={handlePassClick}
|
||||||
disabled={!isStarted || !isMyTurn}
|
disabled={!isStarted || !isMyTurn}
|
||||||
title={!isStarted ? "Game has not started yet" : "Pass turn (Spacebar)"}
|
title={
|
||||||
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
|
!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
|
!isStarted || !isMyTurn
|
||||||
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
|
? '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'
|
: '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>
|
</button>
|
||||||
{renderDirButton('RIGHT', '→')}
|
{renderDirButton('RIGHT', '→')}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,8 +408,8 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result Popups Toggle */}
|
{/* Result Popups & Names/Controls Toggles */}
|
||||||
<div className="pt-2 border-t border-slate-800/80 flex items-center justify-between px-0.5">
|
<div className="pt-2 border-t border-slate-800/80 flex flex-col gap-1.5 px-0.5">
|
||||||
<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"
|
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
title={
|
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>
|
Popups: <strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>{showPopups ? 'ON' : 'OFF'}</strong>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono text-center">
|
<div className="text-[10px] text-slate-500 font-mono text-center">
|
||||||
|
|
|
||||||
|
|
@ -275,8 +275,13 @@ export function useGameSocket() {
|
||||||
} else if (data.event === 'turn_passed') {
|
} else if (data.event === 'turn_passed') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
players: data.players ?? prev.players,
|
||||||
|
parties: data.parties ?? prev.parties,
|
||||||
turn: data.turn ?? prev.turn,
|
turn: data.turn ?? prev.turn,
|
||||||
}));
|
}));
|
||||||
|
if (data.unstuck_message) {
|
||||||
|
setLastEventMessage(data.unstuck_message);
|
||||||
|
}
|
||||||
} else if (data.event === 'player_slept') {
|
} else if (data.event === 'player_slept') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -557,6 +562,8 @@ export function useGameSocket() {
|
||||||
);
|
);
|
||||||
} else if (data.action_taken === 'slept' && data.sleep_result) {
|
} 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)!`);
|
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) {
|
} else if (data.move_result?.battle_result) {
|
||||||
if (showPopupsRef.current) {
|
if (showPopupsRef.current) {
|
||||||
setActiveBattle(data.move_result.battle_result);
|
setActiveBattle(data.move_result.battle_result);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export interface Party {
|
||||||
leader_name: string;
|
leader_name: string;
|
||||||
member_ids: string[];
|
member_ids: string[];
|
||||||
total_strength: number;
|
total_strength: number;
|
||||||
|
consecutive_passes?: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +50,9 @@ export interface TurnInfo {
|
||||||
round_number: number;
|
round_number: number;
|
||||||
turn_number: number;
|
turn_number: number;
|
||||||
turn_order: string[];
|
turn_order: string[];
|
||||||
|
unstuck_triggered?: boolean;
|
||||||
|
unstuck_party_id?: string | null;
|
||||||
|
unstuck_message?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameConclusion {
|
export interface GameConclusion {
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,8 @@ export function drawPlayerPiece(
|
||||||
py: number,
|
py: number,
|
||||||
cellSize: number,
|
cellSize: number,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isCurrentTurn: boolean
|
isCurrentTurn: boolean,
|
||||||
|
showNametags: boolean = true
|
||||||
): void {
|
): void {
|
||||||
const dead = isPlayerDead(player);
|
const dead = isPlayerDead(player);
|
||||||
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
|
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
|
||||||
|
|
@ -447,8 +448,12 @@ export function drawPlayerPiece(
|
||||||
const roleIcon = dead ? '🪦' : isLeader ? '👑' : isTroll ? '👹' : pieceType === 'knight' ? '⚔️' : '🪓';
|
const roleIcon = dead ? '🪦' : isLeader ? '👑' : isTroll ? '👹' : pieceType === 'knight' ? '⚔️' : '🪓';
|
||||||
const hpStr = player.health !== undefined ? ` ❤️${Number(player.health.toFixed(1))}` : '';
|
const hpStr = player.health !== undefined ? ` ❤️${Number(player.health.toFixed(1))}` : '';
|
||||||
const text = dead
|
const text = dead
|
||||||
? `🪦 ${player.name} [DEAD]`
|
? showNametags
|
||||||
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`;
|
? `🪦 ${player.name} [DEAD]`
|
||||||
|
: '🪦 [DEAD]'
|
||||||
|
: showNametags
|
||||||
|
? `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`
|
||||||
|
: `⚡${player.strength.toFixed(1)}${hpStr}`;
|
||||||
const textMetrics = ctx.measureText(text);
|
const textMetrics = ctx.measureText(text);
|
||||||
const bgWidth = textMetrics.width + 12;
|
const bgWidth = textMetrics.width + 12;
|
||||||
const bgHeight = 16;
|
const bgHeight = 16;
|
||||||
|
|
@ -475,7 +480,8 @@ export function drawWizardPiece(
|
||||||
wizard: { x: number; y: number; name: string; strength: number; color?: string },
|
wizard: { x: number; y: number; name: string; strength: number; color?: string },
|
||||||
px: number,
|
px: number,
|
||||||
py: number,
|
py: number,
|
||||||
cellSize: number
|
cellSize: number,
|
||||||
|
showNametags: boolean = true
|
||||||
): void {
|
): void {
|
||||||
const color = wizard.color || '#A855F7';
|
const color = wizard.color || '#A855F7';
|
||||||
const spriteCanvas = getSpriteCanvas('wizard', color, false);
|
const spriteCanvas = getSpriteCanvas('wizard', color, false);
|
||||||
|
|
@ -518,7 +524,9 @@ export function drawWizardPiece(
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
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 textMetrics = ctx.measureText(text);
|
||||||
const bgWidth = textMetrics.width + 12;
|
const bgWidth = textMetrics.width + 12;
|
||||||
const bgHeight = 16;
|
const bgHeight = 16;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
[metadata]
|
[metadata]
|
||||||
version = 1.5
|
version = 1.6
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue