diff --git a/experiment_runner.py b/experiment_runner.py index a6b184f..4786aae 100644 --- a/experiment_runner.py +++ b/experiment_runner.py @@ -21,6 +21,7 @@ import subprocess import sys import textwrap import time +import uuid import multiprocessing as mp from datetime import datetime from pathlib import Path @@ -326,6 +327,36 @@ def _mk_run_dir(exp_dir: Path, idx: int) -> Path: return run_dir +def _make_game_ids_unique(run_dirs: Iterable[Path]) -> None: + """ + Ensures every lmvsgame.json in *run_dirs* carries a distinct `"id"`. + If a duplicate is found we overwrite it with a fresh 16-char UUID + **after** the game has finished but **before** the analysis phase. + """ + seen: set[str] = set() + + for run_dir in run_dirs: + json_path = run_dir / "lmvsgame.json" + if not json_path.exists(): + continue # should not happen, but be tolerant + + try: + meta = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: + continue # invalid JSON → leave unchanged + + gid = str(meta.get("id", "")).strip() + if not gid: + continue # no id field → nothing to fix + + if gid in seen: # duplicate → replace + meta["id"] = uuid.uuid4().hex[:16] + json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8") + gid = meta["id"] + + seen.add(gid) + + def _dump_seed(seed: int, run_dir: Path) -> None: seed_file = run_dir / "seed.txt" if not seed_file.exists(): @@ -507,6 +538,11 @@ def main() -> None: json.dump([res._asdict() for res in runs_meta], fh, indent=2, default=str) log.info("Run summary written → %s", summary_path) + # ------------------------------------------------------------------ + # De-duplicate game IDs (critical-state runs reuse the snapshot ID) + # ------------------------------------------------------------------ + _make_game_ids_unique([r.run_dir for r in runs_meta]) + # ------------------------------------------------------------------ # # Post-analysis pipeline # # ------------------------------------------------------------------ # diff --git a/experiment_runner/analysis/statistical_game_analysis.py b/experiment_runner/analysis/statistical_game_analysis.py index 6332623..5ec0ca4 100644 --- a/experiment_runner/analysis/statistical_game_analysis.py +++ b/experiment_runner/analysis/statistical_game_analysis.py @@ -220,7 +220,17 @@ def _plot_relationships_per_game( jitter_step = 0.04 # vertical gap between stacked points for game_id, game_df in all_phase.groupby("game_id", sort=False): - # dense phase ordering (0 … n-1) + # ── make sure rel_dict exists ─────────────────────────────── + if "rel_dict" not in game_df.columns: + game_df = game_df.copy() + game_df["rel_dict"] = game_df["relationships"].apply(_parse_relationships) + + # ── NEW: discard rows with no relationship info ──────────── + game_df = game_df[game_df["rel_dict"].apply(bool)] + if game_df.empty: # nothing left to plot + continue + + # ── dense phase ordering (0 … n-1) on the surviving phases ─ phase_labels = sorted(game_df["game_phase"].unique(), key=_phase_sort_key) phase_to_x = {ph: idx for idx, ph in enumerate(phase_labels)} fig_w = max(8, len(phase_labels) * 0.1 + 4) @@ -335,7 +345,14 @@ def _plot_relationships_per_game( plt.xticks(list(phase_to_x.values()), phase_labels, rotation=90, fontsize=8) margin = 0.1 plt.ylim(y_min - margin, y_max + margin) - plt.ylabel("Relationship value (−2 … +2)") + + # ── custom y-tick labels ──────────────────────────────────── + plt.yticks( + [-2, -1, 0, 1, 2], + ["Enemy", "Unfriendly", "Neutral", "Friendly", "Ally"], + ) + + plt.ylabel("Relationship value") plt.xlabel("Game phase") plt.title(f"{focal} Relationships – {run_label}") plt.legend(ncol=3, fontsize=8) @@ -347,7 +364,6 @@ def _plot_relationships_per_game( - def _plot_phase_level( all_phase: pd.DataFrame, plot_dir: Path,