From a9b8e23ae71d15fb52b84d125e58fd82d0b4c78f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 25 Apr 2026 02:13:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20per-slot=20personality=20pinning=20via=20env?= =?UTF-8?q?=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- scripts/autoplay/run_ap3.sh | 9 ++++++++ src/game/engine/scenes/tests/auto_play.gd | 8 +++++++ .../src/modules/ai/personality_assigner.gd | 21 +++++++++++++++++-- tools/checklist-report.py | 16 ++++++++++++++ tools/matchup-grid.sh | 18 +++++++++------- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/scripts/autoplay/run_ap3.sh b/scripts/autoplay/run_ap3.sh index 35f63e95..57c55308 100755 --- a/scripts/autoplay/run_ap3.sh +++ b/scripts/autoplay/run_ap3.sh @@ -49,6 +49,15 @@ fi if [ -n "${AI_PIN_PERSONALITY:-}" ]; then FLATPAK_ENVS+=("--env=AI_PIN_PERSONALITY=$AI_PIN_PERSONALITY") fi +# Per-slot pin overrides — matchup-grid.sh sets these to populate clan_id on +# both player slots (incl. the human slot) so meta.json player_clans has every +# clan and matchup_balance verdict can attribute all wins. +for i in 0 1 2 3 4 5 6 7; do + var="AI_PIN_PERSONALITY_P${i}" + if [ -n "${!var:-}" ]; then + FLATPAK_ENVS+=("--env=${var}=${!var}") + fi +done GODOT_ARGS=("--path" "." "--rendering-method" "gl_compatibility") WESTON_PID="" diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 803be9ec..ddd58b15 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -2495,6 +2495,14 @@ func _append_turn_stats(outcome: String) -> void: for p: Variant in GameState.players: if int(p.index) == _victory_winner: winner_personality = str(p.get("clan_id") if p.get("clan_id") != null else "") + # Defensive fallback: if clan_id was never assigned (e.g. human + # slot in a legacy matchup-grid run without per-slot pinning), + # fall back to the AI_PIN_PERSONALITY_P{index} env var so the + # downstream verdict can still attribute the win. + if winner_personality.is_empty(): + winner_personality = OS.get_environment( + "AI_PIN_PERSONALITY_P%d" % _victory_winner + ) break # p0-34: prefer prologue.display_turn() during the -1 → 0 → 1 cold-open so # the first lines of turn_stats.jsonl carry the prologue turn sequence diff --git a/src/game/engine/src/modules/ai/personality_assigner.gd b/src/game/engine/src/modules/ai/personality_assigner.gd index 572b85ce..4b0264ba 100644 --- a/src/game/engine/src/modules/ai/personality_assigner.gd +++ b/src/game/engine/src/modules/ai/personality_assigner.gd @@ -8,11 +8,25 @@ extends RefCounted static func assign(player: RefCounted, rng: RandomNumberGenerator) -> void: - if player == null or player.is_human: + if player == null: return var clans: Dictionary = DataLoader.get_data("ai_personalities") if clans.is_empty(): return + + # Per-slot pin: AI_PIN_PERSONALITY_P{index} forces a specific clan into a + # specific player slot. Overrides the is_human guard so matchup-grid and + # arena harnesses can deterministically populate every slot's clan_id. + # Without this, the meta.json player_clans map under-counts non-pinned + # slots and matchup_balance verdict mis-attributes wins. + var slot_pin: String = OS.get_environment("AI_PIN_PERSONALITY_P%d" % int(player.index)) + if not slot_pin.is_empty() and clans.has(slot_pin): + _apply_clan(player, clans[slot_pin], slot_pin) + return + + if player.is_human: + return + var ids: Array = clans.keys() ids.sort() # AI_PIN_PERSONALITY= forces all AI players to a specific clan for @@ -23,7 +37,10 @@ static func assign(player: RefCounted, rng: RandomNumberGenerator) -> void: chosen_id = pin else: chosen_id = ids[rng.randi() % ids.size()] - var pick: Dictionary = clans.get(chosen_id, {}) + _apply_clan(player, clans[chosen_id], chosen_id) + + +static func _apply_clan(player: RefCounted, pick: Dictionary, chosen_id: String) -> void: var axes: Dictionary = pick.get("strategic_axes", {}) if axes.is_empty(): return diff --git a/tools/checklist-report.py b/tools/checklist-report.py index fb83706a..b03af413 100755 --- a/tools/checklist-report.py +++ b/tools/checklist-report.py @@ -69,6 +69,22 @@ def _collect(gd: Path) -> dict: player_clans = {str(k): str(v) for k, v in raw.items() if v} except (OSError, json.JSONDecodeError): pass + # Defensive fallback for legacy matchup-grid runs (pre per-slot pinning): + # if any player slot has empty clan_id, derive it from the parent dir name + # `/_vs_/as_/game_*`. The pinned clan was + # historically placed on slot 1, the other on slot 0. + parent = gd.parent + pair_root = parent.parent + if parent.name.startswith("as_") and "_vs_" in pair_root.name: + pinned_clan = parent.name[len("as_"):] + pair_clans = pair_root.name.split("_vs_") + if len(pair_clans) == 2 and pinned_clan in pair_clans: + other_clan = pair_clans[0] if pair_clans[1] == pinned_clan else pair_clans[1] + # Legacy: pinned on slot 1, "other" on slot 0 + if "0" not in player_clans: + player_clans["0"] = other_clan + if "1" not in player_clans: + player_clans["1"] = pinned_clan return { "turns": final.get("turn", 0), "outcome": final.get("outcome", "?"), "winner_personality": final.get("winner_personality", ""), diff --git a/tools/matchup-grid.sh b/tools/matchup-grid.sh index 05476de4..5e38234a 100755 --- a/tools/matchup-grid.sh +++ b/tools/matchup-grid.sh @@ -92,24 +92,28 @@ for pair in "${PAIRS[@]}"; do # pairs, which keeps determinism-compare usable later. offset=$((SEED_BASE + pair_idx * 100)) - # Half the games: clan_a on slot 1 (AI opponent). Other half: clan_b. - # This keeps positional fairness — the "who's AI vs who's heuristic" - # question doesn't bias the grid. + # Per-slot pinning: clan_a in slot 0, clan_b in slot 1 for one half; + # swap positions for the other half to remove positional bias. + # AI_PIN_PERSONALITY_P{N} (added with personality_assigner.gd per-slot + # support) overrides the is_human guard so BOTH players' clan_id is set + # in meta.json — matchup_balance verdict can attribute every win. half=$((COUNT / 2)) second_half=$((COUNT - half)) echo -e "${YELLOW}[${pair_idx}/${#PAIRS[@]}]${NC} $pair (seeds $((offset + 1))..$((offset + COUNT)))" - # Batch with clan_a as AI - AI_PIN_PERSONALITY="$clan_a" \ + # Batch with clan_a in slot 0, clan_b in slot 1 + AI_PIN_PERSONALITY_P0="$clan_a" \ + AI_PIN_PERSONALITY_P1="$clan_b" \ SEED_OFFSET=$offset \ PARALLEL=$PARALLEL \ bash "$REPO_ROOT/tools/autoplay-batch.sh" "$half" "$TURN_LIMIT" \ "$pair_dir/as_${clan_a}" > "$pair_dir/as_${clan_a}.log" 2>&1 a_rc=$? - # Batch with clan_b as AI - AI_PIN_PERSONALITY="$clan_b" \ + # Batch with clan_b in slot 0, clan_a in slot 1 (positional swap) + AI_PIN_PERSONALITY_P0="$clan_b" \ + AI_PIN_PERSONALITY_P1="$clan_a" \ SEED_OFFSET=$((offset + half)) \ PARALLEL=$PARALLEL \ bash "$REPO_ROOT/tools/autoplay-batch.sh" "$second_half" "$TURN_LIMIT" \