Compare commits

..

No commits in common. "main" and "v1.4" have entirely different histories.
main ... v1.4

20 changed files with 43 additions and 1589 deletions

View File

@ -31,11 +31,6 @@ botWebWars/
├── README.md # Human-facing overview and quickstart
├── Dockerfile # Multi-stage production container build (frontend + backend)
├── docker-compose.yml # Single-service container composition on port 8000
├── launch_bots.py # Top-level batch launcher for botagent_ai instances
├── launch_bots.sh # Shell wrapper for launch_bots.py
├── launch_trolls.py # Top-level batch launcher for trollagent_ai instances
├── launch_trolls.sh # Shell wrapper for launch_trolls.py
├── launcher_common.py # Shared agent process orchestrator and log multiplexer
├── backend/ # FastAPI application & game engine
│ ├── app/
@ -127,12 +122,6 @@ 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.
@ -289,11 +278,6 @@ The application is containerized into a single unified image via [Dockerfile](Do
export OLLAMA_MODEL="gemma4:12b"
python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6"
```
- **Batch Multi-Session Launch (Top-Level Script)**:
```bash
# Launch 3 bots (named botagent_ai_gemma4_e4b_1, botagent_ai_gemma4_e4b_2, ...)
./launch_bots.sh -n 3 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
```
### C. Vertex AI (Gemini) Bot: `botagent_gear/`
- **Entry File**: `botagent_gear/bot.py`
@ -345,11 +329,6 @@ The application is containerized into a single unified image via [Dockerfile](Do
export OLLAMA_MODEL="gemma4:12b"
python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d"
```
- **Batch Multi-Session Launch (Top-Level Script)**:
```bash
# Launch 2 trolls (named trollagent_ai_gemma4_e4b_1, trollagent_ai_gemma4_e4b_2)
./launch_trolls.sh -n 2 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
```
### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/`
- **Entry File**: `trollagent_gear/bot.py`

View File

@ -19,12 +19,6 @@
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

@ -124,46 +124,3 @@ $ python troll_agent.py --url "http://localhost:8000" --name "Fat Troll" --color
```
BotAgents work the same way
---
## Multi-Agent Batch Launchers (Top-Level Scripts)
Instead of opening multiple terminal tabs manually, you can launch pools of AI bots or trolls using the top-level launcher scripts. The scripts automatically name agents as `{folder}_{sanitized_model}_{number}` (e.g. `botagent_ai_gemma4_e4b_1`, `botagent_ai_gemma4_e4b_2`, etc.), auto-assign distinct colors, multiplex real-time colorized logs in one terminal, save individual log files under `logs/`, and gracefully clean up all sessions when interrupted (`Ctrl+C`).
### Launch Multiple AI Bots (`botagent_ai`)
```bash
# Launch 3 AI bots with default parameters (gemma4:e4b, str 2, hp 2)
./launch_bots.sh -n 3
# Or with python directly, specifying custom parameters:
python3 launch_bots.py -n 4 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_bots.py -n 3 --dry-run
```
### Launch Multiple AI Trolls (`trollagent_ai`)
```bash
# Launch 2 AI trolls with default parameters (gemma4:e4b, str 2, hp 2)
./launch_trolls.sh -n 2
# Or with python directly, specifying custom parameters:
python3 launch_trolls.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_trolls.py -n 2 --dry-run
```
### CLI Options Supported:
- `-n`, `--count`, `--num`: Number of agents to launch (default: `1`).
- `-s`, `--strength`: Strength multiplier (default: `2`).
- `-H`, `--health`: Starting health points (default: `2`).
- `--ollama-url`: Ollama API base URL (default: `http://192.168.1.220:11434`).
- `-m`, `--model`, `--ollama-model`: Ollama model name (default: `gemma4:e4b`).
- `-u`, `--url`, `--server-url`: botWebWars backend URL (default: `http://localhost:8000`).
- `-c`, `--color`: Custom avatar hex color (default: auto-cycles vibrant palette).
- `--start-index`: Starting number index (e.g. `--start-index 4` for instances `_4`, `_5`, ...).
- `--dry-run`: Display all commands without launching.
- `--log-dir`: Directory for per-agent log files (default: `logs/`).
- `--no-logs`: Disable writing log files to disk.

View File

@ -604,7 +604,6 @@ 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:
@ -712,41 +711,11 @@ 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,12 +338,7 @@ class GameEngine:
player_id = actors[safe_index]
return self.players.get(player_id)
def _get_turn_info(
self,
unstuck_triggered: bool = False,
unstuck_party_id: Optional[str] = None,
unstuck_message: Optional[str] = None,
) -> TurnInfo:
def _get_turn_info(self) -> TurnInfo:
if not self.game_started:
return TurnInfo(
game_started=False,
@ -352,9 +347,6 @@ 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()
@ -365,9 +357,6 @@ 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):
@ -980,7 +969,7 @@ class GameEngine:
party_member_ids = set(party.member_ids)
occupant = occupied_map.get((target_x, target_y))
if occupant is not None:
if occupant is not None and occupant.id not in party_member_ids:
return None, f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.", 0.0
strength_penalty = 0.0
@ -1060,229 +1049,6 @@ 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
# ==========================================
@ -2199,7 +1965,6 @@ 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
@ -2251,28 +2016,8 @@ 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(
unstuck_triggered=unstuck_triggered,
unstuck_party_id=unstuck_party_id,
unstuck_message=unstuck_message,
)
return self._get_turn_info()
async def sleep_player(self, player_id: str) -> SleepResponse:
async with self._lock:
@ -2494,8 +2239,6 @@ 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(
@ -2512,8 +2255,6 @@ 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(
@ -2537,8 +2278,6 @@ 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:
@ -2569,33 +2308,6 @@ 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(
@ -2676,35 +2388,15 @@ 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,7 +168,6 @@ 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))
@ -386,9 +385,6 @@ 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,8 +887,6 @@ 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
@ -936,7 +934,6 @@ 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
@ -1142,272 +1139,3 @@ 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

@ -47,8 +47,6 @@ export function App() {
setActiveWizardChallenge,
showScoreboard,
setShowScoreboard,
showPopups,
setShowPopups,
registerPlayer,
removePlayer,
formParty,
@ -65,7 +63,6 @@ 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);
@ -205,10 +202,6 @@ export function App() {
playerCount={boardState.player_count}
isConcluded={isConcluded}
onOpenScoreboard={() => setShowScoreboard(true)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
showNamesAndControls={showNamesAndControls}
onToggleNamesAndControls={setShowNamesAndControls}
/>
{/* Main Content Area */}
@ -220,7 +213,6 @@ export function App() {
availableMoves={availableMoves}
onSelectPlayer={setSelectedPlayer}
onChallengeWizard={handleOpenWizardPrompt}
showNametags={showNamesAndControls}
/>
{/* 8-Directional Movement D-Pad & Simulation Controls */}
@ -241,10 +233,6 @@ export function App() {
onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying}
onToggleAutoPlay={() => setIsAutoPlaying((prev) => !prev)}
showPopups={showPopups}
onTogglePopups={setShowPopups}
showNamesAndControls={showNamesAndControls}
onToggleNamesAndControls={setShowNamesAndControls}
/>
{/* Sidebar Player Roster with Turn Order & Parties */}
@ -290,12 +278,10 @@ export function App() {
/>
{/* 3-Bout D20 Battle Modal */}
{showPopups && (
<BattleModal
battle={activeBattle}
onClose={handleCloseBattle}
/>
)}
{/* Wizard Challenge Reward Selector Prompt Modal */}
<WizardPromptModal
@ -307,12 +293,10 @@ export function App() {
/>
{/* 3-Bout D20 Wizard Challenge Modal */}
{showPopups && (
<WizardChallengeModal
challenge={activeWizardChallenge}
onClose={handleCloseWizardChallenge}
/>
)}
{/* Game Conclusion Scoreboard Modal */}
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (

View File

@ -9,7 +9,6 @@ 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> = ({
@ -19,7 +18,6 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
onSelectPlayer,
onHoverCoord,
onChallengeWizard,
showNametags = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -430,7 +428,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, showNametags);
drawWizardPiece(ctx, boardState.wizard, wx, wy, cellSize);
}
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
@ -440,7 +438,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, showNametags);
drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn);
});
ctx.restore(); // end clip
@ -501,7 +499,6 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
max_x,
min_y,
max_y,
showNametags,
]);
// Mouse Drag to Pan

View File

@ -10,10 +10,6 @@ interface HeaderProps {
playerCount: number;
isConcluded?: boolean;
onOpenScoreboard?: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
showNamesAndControls?: boolean;
onToggleNamesAndControls?: (value: boolean) => void;
}
export const Header: React.FC<HeaderProps> = ({
@ -26,10 +22,6 @@ export const Header: React.FC<HeaderProps> = ({
playerCount,
isConcluded,
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">
@ -116,60 +108,6 @@ export const Header: React.FC<HeaderProps> = ({
</button>
)}
{/* Battle & Wizard Result Popups 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 ${
showPopups
? '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={
showPopups
? 'Result popups enabled: Click to hide battle and wizard popups'
: 'Result popups disabled: Click to show battle and wizard popups'
}
>
<input
type="checkbox"
checked={showPopups}
onChange={(e) => onTogglePopups?.(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>Popups:</span>
<strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>
{showPopups ? 'ON' : 'OFF'}
</strong>
</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

@ -13,10 +13,6 @@ interface MovementControlsProps {
onStepBot: () => void;
isAutoPlaying: boolean;
onToggleAutoPlay: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
showNamesAndControls?: boolean;
onToggleNamesAndControls?: (value: boolean) => void;
}
export const MovementControls: React.FC<MovementControlsProps> = ({
@ -30,10 +26,6 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
onStepBot,
isAutoPlaying,
onToggleAutoPlay,
showPopups = true,
onTogglePopups,
showNamesAndControls = true,
onToggleNamesAndControls,
}) => {
const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id;
@ -47,13 +39,6 @@ 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 &&
@ -188,7 +173,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
if (boardState.players.length === 0 || !showNamesAndControls) {
if (boardState.players.length === 0) {
return null;
}
@ -291,25 +276,14 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
<button
onClick={handlePassClick}
disabled={!isStarted || !isMyTurn}
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 ${
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 ${
!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'
}`}
>
<span>PASS</span>
{willUnstuckOnPass && (
<span className="text-[7px] text-amber-200 uppercase tracking-tighter leading-none">Unstick</span>
)}
PASS
</button>
{renderDirButton('RIGHT', '→')}
</div>
@ -408,46 +382,6 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
</button>
</div>
{/* 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={
showPopups
? 'Result popups enabled: Click to hide battle and wizard popups'
: 'Result popups disabled: Click to show battle and wizard popups'
}
>
<input
type="checkbox"
checked={showPopups}
onChange={(e) => onTogglePopups?.(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
/>
<span className="text-[11px]">
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">
{isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
</div>

View File

@ -51,7 +51,6 @@ export function useGameSocket() {
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
const [activeWizardChallenge, setActiveWizardChallenge] = useState<WizardChallengeResult | null>(null);
const [showScoreboard, setShowScoreboard] = useState(false);
const [showPopups, setShowPopups] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
@ -60,20 +59,6 @@ export function useGameSocket() {
activeBattleRef.current = activeBattle;
const activeWizardChallengeRef = useRef<WizardChallengeResult | null>(null);
activeWizardChallengeRef.current = activeWizardChallenge;
const showPopupsRef = useRef(true);
showPopupsRef.current = showPopups;
const updateShowPopups = useCallback((val?: boolean | ((prev: boolean) => boolean)) => {
setShowPopups((prev) => {
const next = typeof val === 'function' ? val(prev) : typeof val === 'boolean' ? val : !prev;
showPopupsRef.current = next;
if (!next) {
setActiveBattle(null);
setActiveWizardChallenge(null);
}
return next;
});
}, []);
const fetchBoard = useCallback(async () => {
try {
@ -220,9 +205,7 @@ export function useGameSocket() {
turn: data.turn ?? prev.turn,
}));
const b: BattleResult = data.battle;
if (showPopupsRef.current) {
setActiveBattle(b);
}
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
} else if (data.event === 'wizard_challenge_resolved') {
setBoardState((prev) => ({
@ -232,9 +215,7 @@ export function useGameSocket() {
turn: data.turn ?? prev.turn,
}));
const c: WizardChallengeResult = data.challenge_result || data.challenge;
if (showPopupsRef.current) {
setActiveWizardChallenge(c);
}
const wizName = c.wizard_name || 'Gary the Wizard';
let rewardLabel = `+${c.score_change} score`;
if (c.reward_chosen === 'strength') {
@ -275,13 +256,8 @@ 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,
@ -437,9 +413,7 @@ export function useGameSocket() {
throw new Error(err.detail || 'Battle failed');
}
const result: BattleResult = await res.json();
if (showPopupsRef.current) {
setActiveBattle(result);
}
return result;
};
@ -458,10 +432,8 @@ export function useGameSocket() {
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
}
if (data.battle_triggered && data.battle_result) {
if (showPopupsRef.current) {
setActiveBattle(data.battle_result);
}
}
if (data.game_concluded && data.game_concluded.concluded) {
setIsAutoPlaying(false);
setShowScoreboard(true);
@ -518,9 +490,7 @@ export function useGameSocket() {
throw new Error(err.detail || 'Failed to challenge wizard');
}
const data: WizardChallengeResult = await res.json();
if (showPopupsRef.current) {
setActiveWizardChallenge(data);
}
return data;
};
@ -545,15 +515,11 @@ export function useGameSocket() {
if (data.action_taken === 'formed_party' && data.formed_party) {
setLastEventMessage(`🤝 ${data.player_name} formed party "${data.formed_party.name}" under leader ${data.formed_party.leader_name}!`);
} else if (data.action_taken === 'battled' && data.battle_result) {
if (showPopupsRef.current) {
setActiveBattle(data.battle_result);
}
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
} else if (data.action_taken === 'challenged_wizard' && data.wizard_challenge_result) {
const wcr = data.wizard_challenge_result;
if (showPopupsRef.current) {
setActiveWizardChallenge(wcr);
}
const wizName = wcr.wizard_name || 'Gary the Wizard';
setLastEventMessage(
wcr.player_won
@ -562,13 +528,9 @@ 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);
}
}
if (data.game_concluded && data.game_concluded.concluded) {
setIsAutoPlaying(false);
@ -613,8 +575,6 @@ export function useGameSocket() {
setActiveWizardChallenge,
showScoreboard,
setShowScoreboard,
showPopups,
setShowPopups: updateShowPopups,
registerPlayer,
removePlayer,
formParty,

View File

@ -39,7 +39,6 @@ export interface Party {
leader_name: string;
member_ids: string[];
total_strength: number;
consecutive_passes?: number;
created_at?: string;
}
@ -50,9 +49,6 @@ 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,8 +372,7 @@ export function drawPlayerPiece(
py: number,
cellSize: number,
isSelected: boolean,
isCurrentTurn: boolean,
showNametags: boolean = true
isCurrentTurn: boolean
): void {
const dead = isPlayerDead(player);
const pieceType = dead ? 'gravestone' : getPlayerPieceType(player);
@ -448,12 +447,8 @@ 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]`
: '🪦 [DEAD]'
: showNametags
? `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`
: `${player.strength.toFixed(1)}${hpStr}`;
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
@ -480,8 +475,7 @@ export function drawWizardPiece(
wizard: { x: number; y: number; name: string; strength: number; color?: string },
px: number,
py: number,
cellSize: number,
showNametags: boolean = true
cellSize: number
): void {
const color = wizard.color || '#A855F7';
const spriteCanvas = getSpriteCanvas('wizard', color, false);
@ -524,9 +518,7 @@ export function drawWizardPiece(
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = showNametags
? `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`
: `${wizard.strength.toFixed(1)}`;
const text = `🧙 ${wizard.name} [⚡${wizard.strength.toFixed(1)}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;

View File

@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars.
Example usage:
# Launch 3 bots with default parameters (gemma4:e4b, str 2, hp 2)
./launch_bots.py -n 3
# Custom model, health, strength, and Ollama server
./launch_bots.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_bots.py -n 3 --dry-run
"""
import argparse
import os
import sys
from pathlib import Path
# Ensure workspace root is in sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launcher_common import (
DEFAULT_BOT_COLORS,
run_agent_launcher,
)
DEFAULT_SERVER_URL = os.getenv("BOT_SERVER_URL", "http://localhost:8000")
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
DEFAULT_BOT_STRENGTH = int(os.getenv("BOT_STRENGTH", "2"))
DEFAULT_BOT_HEALTH = int(os.getenv("BOT_HEALTH", "2"))
def parse_args():
parser = argparse.ArgumentParser(
description="Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-n", "--count", "--num", "--num-bots",
dest="count",
type=int,
default=1,
help="Number of bot agent instances to launch",
)
parser.add_argument(
"-s", "--strength",
dest="strength",
type=int,
default=DEFAULT_BOT_STRENGTH,
help="Bot strength multiplier (1-10)",
)
parser.add_argument(
"-H", "--health",
dest="health",
type=int,
default=DEFAULT_BOT_HEALTH,
help="Starting health points",
)
parser.add_argument(
"--ollama-url",
dest="ollama_url",
default=DEFAULT_OLLAMA_URL,
help="Ollama API base URL",
)
parser.add_argument(
"-m", "--model", "--ollama-model",
dest="model",
default=DEFAULT_OLLAMA_MODEL,
help="Ollama model identifier",
)
parser.add_argument(
"-u", "--url", "--server-url",
dest="server_url",
default=DEFAULT_SERVER_URL,
help="botWebWars server URL",
)
parser.add_argument(
"-c", "--color",
dest="color",
default=None,
help="Custom hex color code for avatar (default: auto-cycles vibrant palette)",
)
parser.add_argument(
"--start-index",
dest="start_index",
type=int,
default=1,
help="Starting index number for bot naming (e.g. start at 4 for botagent_ai_model_4)",
)
parser.add_argument(
"--prefix",
dest="prefix",
default="botagent_ai",
help="Custom naming prefix before model and index",
)
parser.add_argument(
"--piece-type",
dest="piece_type",
choices=["knight", "warrior"],
default=None,
help="Optional board piece class",
)
parser.add_argument(
"--log-dir",
dest="log_dir",
default="logs",
help="Directory to store per-bot log files",
)
parser.add_argument(
"--no-logs",
dest="no_logs",
action="store_true",
help="Disable writing logs to files",
)
parser.add_argument(
"--python",
dest="python",
default=None,
help="Custom path to python interpreter binary",
)
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="Print the launch plan and commands without executing them",
)
return parser.parse_args()
def extra_args_builder(args):
extra = []
if args.piece_type:
extra.extend(["--piece-type", args.piece_type])
return extra
def main():
args = parse_args()
run_agent_launcher(
agent_type="Bot",
folder="botagent_ai",
palette=DEFAULT_BOT_COLORS,
args=args,
extra_args_builder=extra_args_builder,
)
if __name__ == "__main__":
main()

View File

@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Shell wrapper for launch_bots.py
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/launch_bots.py" "$@"

View File

@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.
Example usage:
# Launch 2 trolls with default parameters (gemma4:e4b, str 2, hp 2)
./launch_trolls.py -n 2
# Custom strength, health, model, and Ollama server
./launch_trolls.py -n 3 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"
# Preview commands without executing
./launch_trolls.py -n 2 --dry-run
"""
import argparse
import os
import sys
from pathlib import Path
# Ensure workspace root is in sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launcher_common import (
DEFAULT_TROLL_COLORS,
run_agent_launcher,
)
DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000"))
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "2.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "2.0"))
def parse_args():
parser = argparse.ArgumentParser(
description="Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-n", "--count", "--num", "--num-trolls",
dest="count",
type=int,
default=1,
help="Number of troll agent instances to launch",
)
parser.add_argument(
"-s", "--strength",
dest="strength",
type=float,
default=DEFAULT_TROLL_STRENGTH,
help="Troll strength multiplier (1-10)",
)
parser.add_argument(
"-H", "--health",
dest="health",
type=float,
default=DEFAULT_TROLL_HEALTH,
help="Starting health points",
)
parser.add_argument(
"--ollama-url",
dest="ollama_url",
default=DEFAULT_OLLAMA_URL,
help="Ollama API base URL",
)
parser.add_argument(
"-m", "--model", "--ollama-model",
dest="model",
default=DEFAULT_OLLAMA_MODEL,
help="Ollama model identifier",
)
parser.add_argument(
"-u", "--url", "--server-url",
dest="server_url",
default=DEFAULT_SERVER_URL,
help="botWebWars server URL",
)
parser.add_argument(
"-c", "--color",
dest="color",
default=None,
help="Custom hex color code for avatar (default: auto-cycles troll green palette)",
)
parser.add_argument(
"--start-index",
dest="start_index",
type=int,
default=1,
help="Starting index number for troll naming (e.g. start at 3 for trollagent_ai_model_3)",
)
parser.add_argument(
"--prefix",
dest="prefix",
default="trollagent_ai",
help="Custom naming prefix before model and index",
)
parser.add_argument(
"--loop-delay",
dest="loop_delay",
type=float,
default=1.0,
help="Turn polling interval in seconds",
)
parser.add_argument(
"--log-dir",
dest="log_dir",
default="logs",
help="Directory to store per-troll log files",
)
parser.add_argument(
"--no-logs",
dest="no_logs",
action="store_true",
help="Disable writing logs to files",
)
parser.add_argument(
"--python",
dest="python",
default=None,
help="Custom path to python interpreter binary",
)
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="Print the launch plan and commands without executing them",
)
return parser.parse_args()
def extra_args_builder(args):
extra = []
if args.loop_delay:
extra.extend(["--loop-delay", str(args.loop_delay)])
return extra
def main():
args = parse_args()
run_agent_launcher(
agent_type="Troll",
folder="trollagent_ai",
palette=DEFAULT_TROLL_COLORS,
args=args,
extra_args_builder=extra_args_builder,
)
if __name__ == "__main__":
main()

View File

@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Shell wrapper for launch_trolls.py
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/launch_trolls.py" "$@"

View File

@ -1,352 +0,0 @@
"""Common process management and launcher utilities for botWebWars agents."""
import argparse
import os
import re
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import List, Optional, Tuple
# ANSI color codes for pretty terminal logging
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[1;32m"
YELLOW = "\033[1;33m"
RED = "\033[1;31m"
CYAN = "\033[1;36m"
MAGENTA = "\033[1;35m"
BLUE = "\033[1;34m"
CONSOLE_COLORS = [
"\033[1;34m", # Blue
"\033[1;35m", # Magenta
"\033[1;36m", # Cyan
"\033[1;32m", # Green
"\033[1;33m", # Yellow
"\033[1;31m", # Red
"\033[1;94m", # Light Blue
"\033[1;95m", # Light Magenta
"\033[1;96m", # Light Cyan
"\033[1;92m", # Light Green
]
DEFAULT_BOT_COLORS = [
"#3b82f6", # Blue
"#8b5cf6", # Purple
"#ec4899", # Pink
"#06b6d4", # Cyan
"#f59e0b", # Amber
"#10b981", # Emerald
"#6366f1", # Indigo
"#f43f5e", # Rose
"#14b8a6", # Teal
"#e11d48", # Crimson
]
DEFAULT_TROLL_COLORS = [
"#15803d", # Forest Green
"#166534", # Dark Green
"#047857", # Emerald / Swamp
"#4d7c0f", # Olive Lime
"#3f6212", # Deep Olive
"#b45309", # Dark Amber / Brown
"#7c2d12", # Rust / Bloodwood
"#581c87", # Dark Purple
"#0f766e", # Deep Teal
"#1e293b", # Slate Dark
]
def sanitize_model_name(model: str) -> str:
"""Sanitizes model name by replacing non-alphanumeric characters with underscores.
Example: 'gemma4:e4b' -> 'gemma4_e4b', 'llama3.2:1b' -> 'llama3_2_1b'
"""
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", model).strip("_")
return cleaned or "model"
def make_agent_name(folder: str, model: str, index: int, prefix: Optional[str] = None) -> str:
"""Generates the standardized agent name in the format: {folder}_{model}_{index}.
Example: 'botagent_ai_gemma4_e4b_3'
"""
base_prefix = prefix.strip("_") if prefix else folder
clean_model = sanitize_model_name(model)
return f"{base_prefix}_{clean_model}_{index}"
def find_python_executable(agent_dir: Path, custom_python: Optional[str] = None) -> str:
"""Locates the appropriate Python binary:
1. Custom explicit python path if provided.
2. Active virtual environment if invoked inside one.
3. Agent directory local virtual environment (e.g. {agent_dir}/venv/bin/python).
4. sys.executable or system python3.
"""
if custom_python:
return custom_python
# If the user explicitly activated an environment outside
if getattr(sys, "base_prefix", None) != sys.prefix:
return sys.executable
# Check local venv inside the agent's folder
local_venv = agent_dir / "venv" / "bin" / "python"
if local_venv.is_file() and os.access(local_venv, os.X_OK):
return str(local_venv)
# Fallback to current sys.executable
return sys.executable
def stream_output(
agent_name: str,
color_code: str,
proc: subprocess.Popen,
log_file: Optional[Path],
) -> None:
"""Streams child process stdout/stderr line-by-line to console and log file."""
log_fp = None
if log_file:
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_fp = open(log_file, "a", encoding="utf-8")
except Exception as e:
print(f"{RED}⚠️ Failed to open log file {log_file}: {e}{RESET}", flush=True)
last_lobby_time = 0.0
try:
if proc.stdout:
for raw_line in iter(proc.stdout.readline, ""):
if not raw_line:
break
if log_fp:
log_fp.write(raw_line)
log_fp.flush()
line = raw_line.rstrip("\r\n")
if not line:
continue
# Throttle lobby polling notifications to avoid console flooding
if "[LOBBY]" in line:
now = time.time()
if now - last_lobby_time < 8.0:
continue
last_lobby_time = now
print(f"{color_code}[{agent_name}]{RESET} {line}", flush=True)
except Exception:
pass
finally:
if log_fp:
try:
log_fp.close()
except Exception:
pass
def stop_all_processes(procs: List[Tuple[str, subprocess.Popen]]) -> None:
"""Gracefully terminates all child processes by sending SIGINT first."""
if not procs:
return
running_procs = [(name, p) for name, p in procs if p.poll() is None]
if not running_procs:
return
print(f"\n{YELLOW}🛑 Stopping {len(running_procs)} agent session(s) gracefully...{RESET}", flush=True)
# 1. Send SIGINT so bot.py catches KeyboardInterrupt and cleanly deregisters from the board
for name, proc in running_procs:
if proc.poll() is None:
try:
proc.send_signal(signal.SIGINT)
except (ProcessLookupError, OSError):
pass
# 2. Give processes up to 4 seconds to deregister and exit cleanly
deadline = time.time() + 4.0
for name, proc in running_procs:
remaining = max(0.1, deadline - time.time())
try:
proc.wait(timeout=remaining)
except subprocess.TimeoutExpired:
pass
# 3. Terminate/kill any processes that hung
for name, proc in running_procs:
if proc.poll() is None:
print(f"{RED}⚠️ Agent {name} did not exit in time; terminating...{RESET}", flush=True)
try:
proc.terminate()
proc.wait(timeout=1.0)
except Exception:
try:
proc.kill()
except Exception:
pass
print(f"{GREEN}✅ All agent sessions stopped cleanly.{RESET}\n", flush=True)
def run_agent_launcher(
agent_type: str,
folder: str,
palette: List[str],
args: argparse.Namespace,
extra_args_builder=None,
) -> None:
"""Generic orchestrator for launching bot and troll agent pools."""
root_dir = Path(__file__).resolve().parent
agent_dir = root_dir / folder
if not agent_dir.is_dir():
print(f"{RED}❌ Error: Agent directory '{agent_dir}' not found.{RESET}", file=sys.stderr)
sys.exit(1)
entry_file = agent_dir / "bot.py"
if not entry_file.is_file():
print(f"{RED}❌ Error: Entrypoint '{entry_file}' not found.{RESET}", file=sys.stderr)
sys.exit(1)
python_bin = find_python_executable(agent_dir, args.python)
count = max(1, args.count)
start_index = max(1, args.start_index)
prefix = getattr(args, "prefix", None)
# Prepare list of commands and configurations
agent_configs = []
for offset in range(count):
idx = start_index + offset
name = make_agent_name(folder=folder, model=args.model, index=idx, prefix=prefix)
color = args.color if args.color else palette[offset % len(palette)]
console_color = CONSOLE_COLORS[offset % len(CONSOLE_COLORS)]
cmd = [
python_bin,
"bot.py",
"--name", name,
"--color", color,
"-s", str(args.strength),
"-H", str(args.health),
"--ollama-url", args.ollama_url,
"--ollama-model", args.model,
"-u", args.server_url,
]
if extra_args_builder:
extra = extra_args_builder(args)
if extra:
cmd.extend(extra)
log_file = None
if not args.no_logs and args.log_dir:
log_file = Path(args.log_dir).resolve() / f"{name}.log"
agent_configs.append({
"index": idx,
"name": name,
"color": color,
"console_color": console_color,
"cmd": cmd,
"log_file": log_file,
})
# Dry-run display mode
if args.dry_run:
print(f"\n{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}🔍 DRY RUN: {count} {agent_type}(s) [{folder}]{RESET}")
print(f"{CYAN}{'=' * 68}{RESET}")
print(f"Model: {BOLD}{args.model}{RESET}")
print(f"Ollama URL: {args.ollama_url}")
print(f"Server URL: {args.server_url}")
print(f"Strength: {args.strength} | Health: {args.health}")
print(f"Interpreter: {python_bin}")
print(f"Working Dir: {agent_dir}")
if not args.no_logs and args.log_dir:
print(f"Log Dir: {Path(args.log_dir).resolve()}")
print(f"{CYAN}{'-' * 68}{RESET}")
for item in agent_configs:
cmd_str = " ".join(item["cmd"])
print(f"[{item['index']}] Name: {BOLD}{item['name']}{RESET}")
print(f" Color: {item['color']}")
print(f" Cmd: {cmd_str}\n")
print(f"{CYAN}{'=' * 68}{RESET}\n")
return
# Normal execution
print(f"\n{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}🚀 Launching {count} {agent_type}(s) [{folder}]{RESET}")
print(f"Model: {BOLD}{args.model}{RESET}")
print(f"Ollama URL: {args.ollama_url}")
print(f"Server URL: {args.server_url}")
print(f"Strength: {args.strength} | Health: {args.health}")
print(f"Interpreter: {python_bin}")
if not args.no_logs and args.log_dir:
print(f"Logs: {Path(args.log_dir).resolve()}/<agent_name>.log")
print(f"{CYAN}{'=' * 68}{RESET}")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["BOT_SERVER_URL"] = args.server_url
if "troll" in folder.lower():
env["TROLL_SERVER_URL"] = args.server_url
procs: List[Tuple[str, subprocess.Popen]] = []
threads: List[threading.Thread] = []
interrupted = threading.Event()
def handle_signal(signum, frame):
interrupted.set()
# Register signal traps
old_sigint = signal.signal(signal.SIGINT, handle_signal)
old_sigterm = signal.signal(signal.SIGTERM, handle_signal)
try:
for item in agent_configs:
print(f"✨ Spawning {item['console_color']}{item['name']}{RESET} ({item['color']})...")
proc = subprocess.Popen(
item["cmd"],
cwd=str(agent_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
procs.append((item["name"], proc))
t = threading.Thread(
target=stream_output,
args=(item["name"], item["console_color"], proc, item["log_file"]),
daemon=True,
)
t.start()
threads.append(t)
time.sleep(0.15) # Slight stagger for clean initial connections
print(f"{CYAN}{'=' * 68}{RESET}")
print(f"{BOLD}💡 All {len(procs)} agent(s) spawned. Press Ctrl+C at any time to stop.{RESET}")
print(f"{CYAN}{'=' * 68}{RESET}\n")
# Monitor loop
while not interrupted.is_set():
# Check if all processes have exited naturally
if all(p.poll() is not None for _, p in procs):
break
interrupted.wait(timeout=0.5)
except KeyboardInterrupt:
interrupted.set()
finally:
stop_all_processes(procs)
signal.signal(signal.SIGINT, old_sigint)
signal.signal(signal.SIGTERM, old_sigterm)

View File

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