diff --git a/.gitignore b/.gitignore index fc828ea..c210aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,15 @@ Thumbs.db # Logs *.log +logs/ + +# Runtime artifacts / caches +static/ +api/ +image_cache/ +data/rag_chroma/ +.claude/ +dependency-reduced-pom.xml + +# Precon decks (generated / imported at runtime) +precon-decks/ diff --git a/fix_paths.py b/fix_paths.py new file mode 100644 index 0000000..1d4db6e --- /dev/null +++ b/fix_paths.py @@ -0,0 +1,42 @@ +with open('ml/eval/forge_evaluator.py', 'rb') as f: + data = f.read() + +# Fix 1: absolute JAR path in subprocess call +data = data.replace( + b'"--forge-jar", self.cfg.forge_jar,', + b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),' +) + +# Fix 2: resolve forge_work_dir to absolute path too +data = data.replace( + b'"--forge-dir", self.cfg.forge_work_dir,', + b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),' +) + +# Fix 3: change default forge_work_dir +data = data.replace( + b'forge_work_dir: str = "forge"', + b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"' +) + +with open('ml/eval/forge_evaluator.py', 'wb') as f: + f.write(data) + +import py_compile +py_compile.compile('ml/eval/forge_evaluator.py', doraise=True) +print('forge_evaluator.py - Syntax OK') + +# Fix eval_policy.py too +with open('scripts/eval_policy.py', 'rb') as f: + data = f.read() + +data = data.replace( + b'"--forge-dir", default="forge"', + b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"' +) + +with open('scripts/eval_policy.py', 'wb') as f: + f.write(data) + +py_compile.compile('scripts/eval_policy.py', doraise=True) +print('eval_policy.py - Syntax OK') diff --git a/ml/eval/forge_evaluator_FIXED.py b/ml/eval/forge_evaluator_FIXED.py new file mode 100644 index 0000000..80a5129 --- /dev/null +++ b/ml/eval/forge_evaluator_FIXED.py @@ -0,0 +1,548 @@ +""" +Commander AI Lab — Forge Evaluator (Issue #153) +═════════════════════════════════════════════════ +Reusable eval harness that: + 1. Starts the policy inference server pointing at a given checkpoint + 2. Launches Forge games via MultiThreadBatchRunner with the policy + server wired in as the AI decision-maker + 3. Collects per-game metrics and writes a JSON results file + +Metrics collected per game: + - won : bool + - turns : int + - life_final : int (winning seat final life) + - life_delta : int (winner life - loser life at game end) + - cmd_damage : int (commander damage dealt by policy seat) + - illegal_acts : int (actions rejected by Forge — should be 0) + - entropy_mean : float (mean policy entropy across decision steps) + - inference_ms : float (mean per-step inference latency) + +Usage: + from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig + cfg = EvalConfig( + checkpoint_path="ml/models/checkpoints/forge-trained/final.pt", + forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar", + deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"], + num_games=200, + num_threads=4, + run_id="forge-trained", + results_dir="results", + ) + evaluator = ForgeEvaluator(cfg) + summary = evaluator.run() + print(summary) +""" + +import json +import logging +import os +import sys +import time +import uuid +import subprocess +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Dict, List, Optional + +import numpy as np + +project_root = str(Path(__file__).parent.parent.parent) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +logger = logging.getLogger("ml.eval.forge_evaluator") + + +# ───────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────── + +@dataclass +class EvalConfig: + """All parameters needed for a single evaluation run.""" + + # Model + checkpoint_path: str = "ml/models/checkpoints/best_policy.pt" + embeddings_dir: str = "embeddings" + device: str = None # None = auto-detect + + # Forge + forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" + forge_work_dir: str = "forge" + java_path: str = "java" + deck_files: List[str] = field(default_factory=list) + deck_names: List[str] = field(default_factory=list) + policy_server_url: str = "http://localhost:8091" + clock_seconds: int = 120 + + # Run + num_games: int = 200 + num_threads: int = 4 + seed: Optional[int] = None + run_id: str = "" + results_dir: str = "results" + + # Inference + greedy: bool = True + temperature: float = 1.0 + + def __post_init__(self): + if not self.run_id: + self.run_id = str(uuid.uuid4())[:8] + + +# ───────────────────────────────────────────── +# Per-game result +# ───────────────────────────────────────────── + +@dataclass +class GameRecord: + game_index: int + won: bool + turns: int + life_final_policy: int # life total of policy seat at game end + life_final_opponent: int + life_delta: int # policy life - opponent life + commander_damage_dealt: int + illegal_actions: int + entropy_mean: float # mean per-step policy entropy + inference_ms_mean: float # mean per-step inference latency + decision_steps: int # number of decision points in this game + error: str = "" + + +# ───────────────────────────────────────────── +# Eval summary +# ───────────────────────────────────────────── + +@dataclass +class EvalSummary: + run_id: str + checkpoint_path: str + num_games: int + wins: int + losses: int + win_rate: float + win_rate_ci95_low: float + win_rate_ci95_high: float + avg_turns: float + avg_life_delta: float + avg_commander_damage: float + avg_illegal_actions: float + avg_entropy: float + avg_inference_ms: float + total_decision_steps: int + elapsed_wall_s: float + games_per_sec: float + error_count: int + result_file: str + + def __str__(self) -> str: + return ( + f"EvalSummary run={self.run_id}\n" + f" checkpoint : {self.checkpoint_path}\n" + f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n" + f" win rate : {self.win_rate:.1%} " + f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n" + f" avg turns : {self.avg_turns:.1f}\n" + f" avg life Δ : {self.avg_life_delta:+.1f}\n" + f" avg cmd dmg: {self.avg_commander_damage:.1f}\n" + f" illegal : {self.avg_illegal_actions:.2f}/game\n" + f" entropy : {self.avg_entropy:.4f}\n" + f" latency : {self.avg_inference_ms:.1f} ms/step\n" + f" throughput : {self.games_per_sec:.2f} games/sec\n" + f" result file: {self.result_file}\n" + ) + + +# ───────────────────────────────────────────── +# Main evaluator +# ───────────────────────────────────────────── + +class ForgeEvaluator: + """ + Orchestrates a full eval run: + 1. Spin up PolicyInferenceService on cfg.policy_server_url port + 2. Run cfg.num_games Forge games via MultiThreadBatchRunner + 3. Parse Forge JSONL output for per-game metrics + 4. Write results to {results_dir}/eval-{run_id}.json + """ + + def __init__(self, cfg: EvalConfig): + self.cfg = cfg + self._records: List[GameRecord] = [] + self._result_path = os.path.join( + cfg.results_dir, f"eval-{cfg.run_id}.json" + ) + + # ── Public entry point ─────────────────── + + def run(self) -> EvalSummary: + """Run the full evaluation. Returns an EvalSummary.""" + t0 = time.time() + os.makedirs(self.cfg.results_dir, exist_ok=True) + + logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games) + logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path) + + # Load policy service + service = self._load_policy_service() + + # Run games — either via real Forge or synthetic fallback + forge_available = ( + self.cfg.forge_jar + and Path(self.cfg.forge_jar).exists() + and len(self.cfg.deck_files) >= 3 + ) + + if forge_available: + self._records = self._run_forge_games(service) + else: + logger.warning( + "[Eval %s] Forge JAR or decks not found — running synthetic eval.", + self.cfg.run_id + ) + self._records = self._run_synthetic_eval(service) + + elapsed = time.time() - t0 + summary = self._build_summary(elapsed) + self._write_results(summary) + logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary) + return summary + + # ── Policy service setup ───────────────── + + def _load_policy_service(self): + try: + from ml.serving.policy_server import PolicyInferenceService + svc = PolicyInferenceService( + checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent), + embeddings_dir=self.cfg.embeddings_dir, + device=self.cfg.device, + ) + svc.load() + ok = svc.reload(self.cfg.checkpoint_path) + if not ok: + logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable") + return svc + except Exception as e: + logger.error("[Eval] Could not load PolicyInferenceService: %s", e) + return None + + # ── Real Forge game runner ─────────────── + + def _run_forge_games(self, service) -> List[GameRecord]: + """Launch LabCli Java subprocess with --ml-log, then parse JSONL output.""" + logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games) + deck_args = [] + for i, df in enumerate(self.cfg.deck_files[:4]): + deck_args.extend([f"--deck{i+1}", Path(df).stem]) + # Resolve Java 17 + java_exe = self.cfg.java_path + for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe", + r"C:\Program Files\Java\jdk-17\bin\java.exe"]: + if Path(c).exists(): + java_exe = c + break + lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar") + if not Path(lab_jar).exists(): + logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar) + return self._run_synthetic_eval(service) + cmd = [ + java_exe, "-jar", lab_jar, + "--forge-jar", self.cfg.forge_jar, + "--forge-dir", self.cfg.forge_work_dir, + *deck_args, + "--games", str(self.cfg.num_games), + "--threads", str(self.cfg.num_threads), + "--clock", str(self.cfg.clock_seconds), + "--ml-log", + "--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"), + ] + if self.cfg.seed is not None: + cmd.extend(["--seed", str(self.cfg.seed)]) + logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd)) + try: + result = subprocess.run(cmd, capture_output=True, text=True, + timeout=self.cfg.clock_seconds * self.cfg.num_games + 300) + if result.returncode != 0: + logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500]) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e) + return self._run_synthetic_eval(service) + return self._parse_batch_json(service) + + def _parse_batch_json(self, service) -> List[GameRecord]: + """Parse the batch JSON output from LabCli Java subprocess.""" + batch_path = os.path.join( + self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json" + ) + if not Path(batch_path).exists(): + logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path) + return self._run_synthetic_eval(service) + with open(batch_path) as f: + batch = json.load(f) + games_list = batch.get("games", []) + if not games_list: + logger.warning("[Eval] No games in batch JSON — falling back to synthetic") + return self._run_synthetic_eval(service) + records = [] + for idx, g in enumerate(games_list): + won = g.get("winningSeat", -1) == 0 + turns = g.get("totalTurns", 0) + pr = g.get("playerResults", []) + lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40 + lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40 + records.append(GameRecord( + game_index=idx, + won=won, + turns=turns, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=0, + illegal_actions=0, + entropy_mean=0.0, + inference_ms_mean=0.0, + decision_steps=turns, + )) + if len(records) >= self.cfg.num_games: + break + logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records)) + return records + + def _run_forge_via_subprocess(self, service) -> List[GameRecord]: + """Parse JSONL files produced by a separately-launched Forge batch.""" + records = [] + results_path = Path(self.cfg.results_dir) + pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl" + + all_files = sorted(results_path.glob(pattern)) + if not all_files: + logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern) + return self._run_synthetic_eval(service) + + from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode + games_seen: Dict[str, list] = {} + for f in all_files: + for snap in parse_forge_jsonl(f): + gid = snap.get("game_id", "g0") + games_seen.setdefault(gid, []).append(snap) + + for idx, (gid, snaps) in enumerate(games_seen.items()): + records.append(self._snaps_to_record(idx, gid, snaps, service)) + if len(records) >= self.cfg.num_games: + break + + return records + + def _java_results_to_records(self, java_results, service) -> List[GameRecord]: + """Convert Java GameResult objects to GameRecord.""" + records = [] + for jr in java_results: + try: + won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy + records.append(GameRecord( + game_index=getattr(jr, "gameIndex", len(records)), + won=won, + turns=getattr(jr, "turns", 0), + life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40, + life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40, + life_delta=0, + commander_damage_dealt=0, + illegal_actions=getattr(jr, "illegalActions", 0), + entropy_mean=0.0, + inference_ms_mean=0.0, + decision_steps=0, + )) + except Exception as e: + logger.warning("[Eval] Skipping malformed GameResult: %s", e) + return records + + # ── Synthetic fallback ─────────────────── + + def _run_synthetic_eval(self, service) -> List[GameRecord]: + """Run synthetic games using self_play engine (no Forge required). + + Used when Forge JAR is missing or deck files aren't configured. + Metrics like illegal_actions and commander_damage are 0 in this mode. + """ + import warnings + warnings.filterwarnings("ignore", category=DeprecationWarning) + + records = [] + try: + from ml.training.self_play import ( + create_random_initial_state, apply_action, advance_turn, + encode_state_simple, HeuristicPolicy + ) + from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION + import torch + import torch.nn.functional as F + except ImportError as e: + logger.error("[Eval] Cannot import self_play: %s", e) + return [] + + opponent = HeuristicPolicy() + agent_has_model = service is not None and service._loaded + + for game_idx in range(self.cfg.num_games): + state = create_random_initial_state() + entropies, latencies = [], [] + steps = 0 + + while not state.game_over and steps < 60: + sv = encode_state_simple(state, 0, "midrange") + t0 = time.time() + + if agent_has_model: + snap = state.to_snapshot() + result = service.predict( + snap, playstyle="midrange", + greedy=self.cfg.greedy, + temperature=self.cfg.temperature, + ) + action_idx = result.get("action_index", 7) + probs_map = result.get("probabilities", {}) + probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS + else: + action_idx = np.random.randint(NUM_ACTIONS) + probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS + + latencies.append((time.time() - t0) * 1000) + p = np.clip(probs, 1e-9, 1.0) + p /= p.sum() + entropies.append(float(-np.sum(p * np.log(p)))) + + state = apply_action(state, action_idx, 0) + if not state.game_over: + opp_sv = encode_state_simple(state, 1, "midrange") + opp_a, _ = opponent.select_action(opp_sv) + state = apply_action(state, opp_a, 1) + if not state.game_over: + state = advance_turn(state) + steps += 1 + + won = state.winner == 0 + lp = state.players[0].life_total + lo = state.players[1].life_total + records.append(GameRecord( + game_index=game_idx, + won=won, + turns=state.turn, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=0, + illegal_actions=0, + entropy_mean=float(np.mean(entropies)) if entropies else 0.0, + inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0, + decision_steps=steps, + )) + + return records + + # ── JSONL snapshot → GameRecord ────────── + + def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord: + entropies, latencies = [], [] + won = False + turns = len(snaps) + cmd_dmg = 0 + illegal = 0 + lp, lo = 40, 40 + + for snap in snaps: + if "outcome" in snap and snap == snaps[-1]: + won = snap["outcome"].get("winner_seat", -1) == 0 + lp = snap["outcome"].get("life_remaining", 40) + lo = snap["outcome"].get("opp_life_remaining", 0) + cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0) + + if service and service._loaded and "state_vector" in snap: + t0 = time.time() + result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy) + latencies.append((time.time() - t0) * 1000) + probs_map = result.get("probabilities", {}) + if probs_map: + p = np.clip(list(probs_map.values()), 1e-9, 1.0) + p /= p.sum() + entropies.append(float(-np.sum(p * np.log(p)))) + + return GameRecord( + game_index=idx, + won=won, + turns=turns, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=cmd_dmg, + illegal_actions=illegal, + entropy_mean=float(np.mean(entropies)) if entropies else 0.0, + inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0, + decision_steps=len(snaps), + ) + + # ── Summary builder ────────────────────── + + def _build_summary(self, elapsed: float) -> EvalSummary: + n = len(self._records) + if n == 0: + return EvalSummary( + run_id=self.cfg.run_id, + checkpoint_path=self.cfg.checkpoint_path, + num_games=0, wins=0, losses=0, + win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0, + avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0, + avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0, + total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0, + error_count=0, result_file=self._result_path, + ) + + wins = sum(1 for r in self._records if r.won) + losses = n - wins + wr = wins / n + + # Wilson score 95% CI + z = 1.96 + denom = 1 + z**2 / n + centre = (wr + z**2 / (2 * n)) / denom + margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom + ci_lo = max(0.0, centre - margin) + ci_hi = min(1.0, centre + margin) + + errors = sum(1 for r in self._records if r.error) + + return EvalSummary( + run_id=self.cfg.run_id, + checkpoint_path=self.cfg.checkpoint_path, + num_games=n, + wins=wins, + losses=losses, + win_rate=wr, + win_rate_ci95_low=ci_lo, + win_rate_ci95_high=ci_hi, + avg_turns=float(np.mean([r.turns for r in self._records])), + avg_life_delta=float(np.mean([r.life_delta for r in self._records])), + avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])), + avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])), + avg_entropy=float(np.mean([r.entropy_mean for r in self._records])), + avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])), + total_decision_steps=sum(r.decision_steps for r in self._records), + elapsed_wall_s=elapsed, + games_per_sec=n / max(elapsed, 0.001), + error_count=errors, + result_file=self._result_path, + ) + + # ── Result writer ──────────────────────── + + def _write_results(self, summary: EvalSummary) -> None: + payload = { + "summary": asdict(summary), + "games": [asdict(r) for r in self._records], + } + os.makedirs(self.cfg.results_dir, exist_ok=True) + with open(self._result_path, "w") as f: + json.dump(payload, f, indent=2) + logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path) diff --git a/patch_eval.py b/patch_eval.py new file mode 100644 index 0000000..7754244 --- /dev/null +++ b/patch_eval.py @@ -0,0 +1,68 @@ +import re + +with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f: + content = f.read() + +# Fix 1: >= 2 to >= 3 +content = content.replace('deck_files) >= 2', 'deck_files) >= 3') + +# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json +content = content.replace( + 'return self._run_forge_via_subprocess(service)', + 'return self._parse_batch_json(service)' +) + +# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess +new_method = ''' + def _parse_batch_json(self, service) -> List[GameRecord]: + """Parse the batch JSON output from LabCli Java subprocess.""" + batch_path = os.path.join( + self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json" + ) + if not Path(batch_path).exists(): + logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path) + return self._run_synthetic_eval(service) + with open(batch_path) as f: + batch = json.load(f) + games_list = batch.get("games", []) + if not games_list: + logger.warning("[Eval] No games in batch JSON -- falling back to synthetic") + return self._run_synthetic_eval(service) + records = [] + for idx, g in enumerate(games_list): + won = g.get("winningSeat", -1) == 0 + turns = g.get("totalTurns", 0) + pr = g.get("playerResults", []) + lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40 + lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40 + records.append(GameRecord( + game_index=idx, + won=won, + turns=turns, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=0, + illegal_actions=0, + entropy_mean=0.0, + inference_ms_mean=0.0, + decision_steps=turns, + )) + if len(records) >= self.cfg.num_games: + break + logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records)) + return records + +''' + +content = content.replace( + ' def _run_forge_via_subprocess(', + new_method + ' def _run_forge_via_subprocess(' +) + +with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f: + f.write(content) + +import py_compile +py_compile.compile('ml/eval/forge_evaluator.py', doraise=True) +print('Syntax OK - patch applied') diff --git a/patch_phase1.py b/patch_phase1.py new file mode 100644 index 0000000..12d0a3d --- /dev/null +++ b/patch_phase1.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Phase 1 patch: no-op mana pool display functions in lan-client/ui.js +Run from anywhere — edit UI_JS_PATH to point at your file. +""" + +import re + +UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js" + +with open(UI_JS_PATH, "r", encoding="utf-8") as f: + content = f.read() + +original_size = len(content) +changes = [] + +# ── Change 1: no-op renderManaPool ─────────────────────────────────────────── +old1 = re.search( + r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}', + content, re.DOTALL +) +if old1: + content = content[:old1.start()] + \ + "function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \ + content[old1.end():] + changes.append("renderManaPool -> stub") +else: + print("WARNING: renderManaPool pattern not matched — skipping") + +# ── Change 2: no-op showManaPool ───────────────────────────────────────────── +old2 = re.search( + r'function showManaPool\(\) \{.*?\}', + content, re.DOTALL +) +if old2: + content = content[:old2.start()] + \ + "function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \ + content[old2.end():] + changes.append("showManaPool -> stub") +else: + print("WARNING: showManaPool pattern not matched — skipping") + +# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ───────────── +mana_row = ( + " +\n '