Compare commits
No commits in common. "main" and "v1.4" have entirely different histories.
21
AGENTS.md
21
AGENTS.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ==========================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
151
launch_bots.py
151
launch_bots.py
|
|
@ -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()
|
||||
|
|
@ -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" "$@"
|
||||
151
launch_trolls.py
151
launch_trolls.py
|
|
@ -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()
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
[metadata]
|
||||
version = 1.6
|
||||
version = 1.4
|
||||
|
|
|
|||
Loading…
Reference in New Issue