feat(@projects/@magic-civilization): add per-slot personality pinning via env vars

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-25 02:13:01 -07:00
parent 412dbb3ebf
commit a9b8e23ae7
5 changed files with 63 additions and 9 deletions

View file

@ -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=""

View file

@ -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

View file

@ -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=<clan_id> 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

View file

@ -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
# `<root>/<clan_a>_vs_<clan_b>/as_<clan_X>/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", ""),

View file

@ -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" \