From 36c839268a5d09dc5f60899514386efda3bf70b0 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 25 Apr 2026 17:08:05 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20update=20clan=20personality=20research=20diverge?= =?UTF-8?q?nce=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p0-02-clan-personalities.md | 23 +++++++- .../p1-22-mcts-wall-clock-budget.md | 14 +++-- src/game/engine/scenes/tests/auto_play.gd | 57 +++++++++++++++++-- .../engine/src/modules/ai/ai_turn_bridge.gd | 6 ++ src/simulator/crates/mc-ai/src/evaluator.rs | 47 +++++++++++---- .../mc-ai/tests/gpu_tree_integration.rs | 10 ++-- .../crates/mc-ai/tests/mcts_basic.rs | 6 +- tools/export-single.sh | 15 +++++ tools/huge-map-5clan.sh | 5 ++ 9 files changed, 152 insertions(+), 31 deletions(-) diff --git a/.project/objectives/p0-02-clan-personalities.md b/.project/objectives/p0-02-clan-personalities.md index 88e3887a..129c5ef3 100644 --- a/.project/objectives/p0-02-clan-personalities.md +++ b/.project/objectives/p0-02-clan-personalities.md @@ -5,7 +5,7 @@ priority: p0 status: partial scope: game1 owner: warcouncil -updated_at: 2026-04-19 +updated_at: 2026-04-25 evidence: - public/games/age-of-dwarves/data/ai_personalities.json - .local/iter/apricot-20260418_08*/ # 5-clan re-runs on p0-25-instrumented binary @@ -94,11 +94,28 @@ Note: ablated TTV drops (not rises) because most games hit T300 stalemate when t **Victory-balance gate**: all 5 clans win ≥6/9–9/10 in their pinned matchup — PASSED (every clan dominant when pinned). -**Era-divergence gate**: ≥1 era delta between production/expansion-divergent pairs — NOT MET. Production-axis mean (ironhold+deepforge) = 3.0; trade-axis mean (goldvein+runesmith) = 3.0; delta = 0. Root cause updated (post-pacing fix): games now run to T192 median (p0-01 evidence), but clan personalities do not yet drive distinct research sequences — all clans research the same techs in roughly the same priority, so `tier_peak` converges regardless of personality. Fixing this requires either personality-specific research weights or a research-axis differentiation outside warcouncil scope. +**Era-divergence gate**: ≥1 era delta between production/expansion-divergent pairs — NOT MET (as of 2026-04-19). Root cause confirmed: `auto_play.gd::_pick_research` was hardcoded military-priority with no personality input. Fix landed 2026-04-25 — see "Post-reframe evidence v3" below. + +## Post-reframe evidence v3 (2026-04-25, research personality wiring) + +**Root cause fix**: `auto_play.gd::_pick_research` previously applied a flat `×2` for `pillar == "military"` with no per-clan variation, so all five clans converged on the same research order. Two code paths updated: + +1. `src/game/engine/scenes/tests/auto_play.gd::_pick_research` — now reads `DataLoader.get_ai_personality(clan_id)` per player, normalises the 6 raw axes (1–10 → [0,1]) via the new `_norm_axis` static helper, and computes a per-pillar multiplier (range 1.0–2.0) for all six actual pillar names in `techs/*.json` (`military`, `metallurgy`, `agriculture`, `civics`, `scholarship`, `ecology`). The hardcoded `×2 military` is gone. + +2. `src/simulator/crates/mc-ai/src/evaluator.rs::score_tech` — corrected stale pillar names (`engineering`, `warfare`, `growth`, `commerce`, `trade`, `construction`, `production` — none of which exist in the actual data) to the real pillar set, and switched from blended `StrategicWeights::economy` / `aggression` to per-axis weights read from `AiPlayerState::strategic_axes`. Build: `cargo build -p mc-ai --lib --locked` clean; `cargo test -p mc-ai --lib --locked` 184/184 pass. `pick_tech` is not yet wired to GDExtension (no caller outside tests) — wiring is tracked in p0-26. + +**Expected research differentiation per clan (pillar → axis mapping):** +- `blackhammer` (aggression=9): `military` multiplier ≈ 2.0 → rushes `war`, `tracking`, `combined_arms` etc. +- `ironhold` (production=9): `metallurgy` multiplier ≈ 2.0 → prioritises `steelworking`, `runelore`, `high_smithing` +- `deepforge` (production=8): `metallurgy` multiplier ≈ 1.78 + `ecology` blend → tall-empire smithing + land techs +- `goldvein` (wealth=9, trade=9): `civics` multiplier ≈ 1.7, `scholarship` multiplier ≈ 1.5 → income/knowledge techs +- `runesmith` (balanced): all multipliers ≈ 1.3–1.5 → adaptive order based on game state + +Status: code landed; batch validation pending. Next step: re-run 5-clan batch under p0-25 instrumentation to measure `tier_peak` divergence. ## Remaining to reach done -1. **Waiting on p0-01 balance tune** — era-divergence gate cannot be evaluated until games routinely reach tier 6+. After p0-01 lands its pacing fix, re-run the 5-clan batch and cite `tier_peak_med` delta between ironhold/deepforge (low production) and goldvein/runesmith (high production) pairs. +1. **5-clan batch re-run** under p0-25 instrumentation (tier_peak available); demonstrate ≥10% tier_peak delta between contrasting clan pairs (goldvein vs ironhold; runesmith vs blackhammer). Run: `ssh apricot tools/matchup-grid.sh` or `tools/huge-map-5clan.sh` with `AI_PIN_PERSONALITY=` per slot. 2. **6-axis ablation re-run** on the tuned binary with `tier_peak_med` deltas for expansion/production/grudge_persistence. The pre-reframe ablation (2026-04-17) already confirmed all 6 axes live under the legacy metric; this is confirmation under the reframed gate. ## Depends on diff --git a/.project/objectives/p1-22-mcts-wall-clock-budget.md b/.project/objectives/p1-22-mcts-wall-clock-budget.md index 65edc234..9289b5a6 100644 --- a/.project/objectives/p1-22-mcts-wall-clock-budget.md +++ b/.project/objectives/p1-22-mcts-wall-clock-budget.md @@ -2,13 +2,19 @@ id: p1-22 title: MCTS per-decision wall-clock budget — bound per-turn cost on huge maps priority: p1 -status: missing +status: partial scope: game1 owner: warcouncil updated_at: 2026-04-25 evidence: - .local/iter/huge-map-5clan-20260425_115416/ (3/10 victories at PARALLEL=2 SAFETY_TIMEOUT=3600s; perf-bound seeds time out) - - src/simulator/crates/mc-ai/src/mcts_tree.rs (no current wall-clock or iteration cap per decision) + - src/simulator/crates/mc-ai/src/mcts_tree.rs:301 (simulate_parallel budget_ms: Option — breaks collection loop when elapsed >= budget) + - src/simulator/crates/mc-ai/src/mcts_tree.rs:382 (iterate_gpu_batched budget_ms: Option — breaks batch-collection loop when elapsed >= budget) + - src/simulator/crates/mc-ai/src/mcts_tree.rs:641 (simulate_parallel_respects_wall_clock_budget test — 1_000_000 rollout target exits in <500ms with budget_ms=Some(50), >0 visits confirmed) + - src/simulator/api-gdext/src/ai.rs:36 (GdMcTreeController.budget_ms field, default 0=unbounded) + - src/simulator/api-gdext/src/ai.rs:111 (set_budget_ms(ms: i64) #[func], plumbed into both choose_action and choose_action_with_stats) + - src/game/engine/src/modules/ai/ai_turn_bridge.gd:71 (reads MCTS_DECISION_BUDGET_MS env, calls ctrl.set_budget_ms, logs when active) + - tools/huge-map-5clan.sh:44 (: "${MCTS_DECISION_BUDGET_MS:=2000}" default; line ~99 MCTS_DECISION_BUDGET_MS=2000 passed to autoplay-batch.sh) --- ## Summary @@ -19,8 +25,8 @@ This is engineering work, not test calibration: the AI is ALWAYS faster when it ## Acceptance -- ❌ `mc-ai` exposes a per-decision wall-clock budget (e.g. `MCTS_DECISION_BUDGET_MS=2000`) that caps the iteration loop in `mcts_tree::iterate*` once `now() - start >= budget_ms`. Default off; opt-in via env var. -- ❌ `huge-map-5clan.sh` sets `MCTS_DECISION_BUDGET_MS=2000` so each AI decision is bounded — predictable per-turn cost regardless of state complexity. +- ✓ `mc-ai` exposes a per-decision wall-clock budget (e.g. `MCTS_DECISION_BUDGET_MS=2000`) that caps the iteration loop in `mcts_tree::simulate_parallel` and `mcts_tree::iterate_gpu_batched` once `now() - start >= budget_ms`. Default off (0 = unbounded); opt-in via env var. Evidence: `src/simulator/crates/mc-ai/src/mcts_tree.rs:301,382`; unit test `simulate_parallel_respects_wall_clock_budget` passes at line 641. +- ✓ `huge-map-5clan.sh` sets `MCTS_DECISION_BUDGET_MS=2000` so each AI decision is bounded — predictable per-turn cost regardless of state complexity. Evidence: `tools/huge-map-5clan.sh` lines 44 and ~99. - ❌ Re-run `huge-map-5clan` 10-seed batch with the budget — verify ≥5/10 victories and ≥2 distinct winners. With `~5s/turn × 5 players × 500 turns = 12500s = 3.5hr` per game at PARALLEL=2 and 3600s safety_timeout, all seeds should reach T500 (≥9/10 victories expected). - ❌ p0-22's `ultimate_stress: PASS` gate (now followup-tracked here) flips ✓ once this lands. diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 0db3daff..118a1a0b 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -1181,12 +1181,48 @@ func _play_turn() -> void: func _pick_research(player: RefCounted) -> void: - ## Score available techs: base = 1000/cost; military pillar ×2; + ## Score available techs: base = 1000/cost; per-pillar personality multiplier; ## unlocks tier≥4 unit adds ×3; prerequisite of high-value tech adds ×1.5. - ## The prereq-chain boost ensures steelworking (→combined_arms→ironwarden) - ## gets researched ~50 turns earlier than cheapest-first ordering. + ## Personality axes drive per-pillar multipliers so clan research orders diverge: + ## military → aggression (blackhammer rushes military techs) + ## metallurgy → production (ironhold/deepforge prioritise smithing) + ## agriculture → expansion (blackhammer/runesmith expand aggressively) + ## civics → wealth + trade_willingness (goldvein) + ## scholarship → wealth + production blend (goldvein science income) + ## ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire) + ## + ## Research scoring belongs in mc-ai::ScoringEvaluator::pick_tech (Rail-1). + ## This test-harness path reads axes inline; wiring through GdAiController + ## requires the tactical bridge to emit research actions (tracked in p0-26). var all_techs: Array = DataLoader.get_all_techs() + # Load clan personality axes (1..=10). Defaults to 5 (neutral) if clan + # is unset so vanilla scoring degrades gracefully to neutral multipliers. + var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "") + var axes: Dictionary = {} + if not clan_id.is_empty(): + var personality: Dictionary = DataLoader.get_ai_personality(clan_id) + if personality != null and not personality.is_empty(): + axes = personality.get("strategic_axes", {}) + + # Normalise raw 1..=10 axis values to [0, 1] (neutral 5 → 0.44). + var agg: float = _norm_axis(axes, "aggression") + var prod: float = _norm_axis(axes, "production") + var wlth: float = _norm_axis(axes, "wealth") + var trd: float = _norm_axis(axes, "trade_willingness") + var exp: float = _norm_axis(axes, "expansion") + + # Per-pillar multiplier derived from clan axes (range 1.0..=2.0). + # Base of 1.0 ensures clans with low axes still research every pillar. + var pillar_mult: Dictionary = { + "military": 1.0 + agg, + "metallurgy": 1.0 + prod, + "agriculture": 1.0 + exp * 0.8, + "civics": 1.0 + (wlth + trd) / 2.0 * 0.7, + "scholarship": 1.0 + (wlth + prod) / 2.0 * 0.6, + "ecology": 1.0 + (exp + prod) / 2.0 * 0.5, + } + # Pass 1: compute raw score for every tech (ignoring availability). var raw_score: Dictionary = {} for tech: Dictionary in all_techs: @@ -1195,8 +1231,9 @@ func _pick_research(player: RefCounted) -> void: continue var cost: int = maxi(int(tech.get("cost", 1)), 1) var sc: float = 1000.0 / float(cost) - if str(tech.get("pillar", "")) == "military": - sc *= 2.0 + # Apply personality-driven pillar multiplier (replaces hardcoded x2 for military). + var pillar: String = str(tech.get("pillar", "")) + sc *= float(pillar_mult.get(pillar, 1.0)) for uid: Variant in tech.get("unlocks", {}).get("units", []): var udata: Dictionary = DataLoader.get_unit(str(uid)) if int(udata.get("tier", 1)) >= 4: @@ -1204,7 +1241,7 @@ func _pick_research(player: RefCounted) -> void: break raw_score[tid] = sc - # Pass 2: prerequisites of any tech scoring ≥ 20 get a 1.5× boost. + # Pass 2: prerequisites of any tech scoring >= 20 get a 1.5x boost. var prereq_mult: Dictionary = {} for tech: Dictionary in all_techs: var tid: String = str(tech.get("id", "")) @@ -1240,6 +1277,14 @@ func _pick_research(player: RefCounted) -> void: print(" Researching: %s (score %.1f)" % [best_id, best_score]) +static func _norm_axis(axes: Dictionary, key: String) -> float: + ## Normalise a 1..=10 raw personality axis value to [0, 1]. + ## Returns 0.44 for neutral (5), 0.0 for minimum (1), 1.0 for maximum (10). + ## Missing keys default to 5 (neutral). + var raw: float = float(axes.get(key, 5)) + return (clampf(raw, 1.0, 10.0) - 1.0) / 9.0 + + func _score_site(pos: Vector2i, game_map: RefCounted) -> float: ## Score a hex as a city site. Food*2 + production*1.5 + resources. var tile: Resource = game_map.get_tile(pos) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index 66478610..3be57da9 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -70,6 +70,12 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void: ctrl.set_rollout_depth(MCTS_ROLLOUT_DEPTH) ctrl.set_gpu_enabled(OS.get_environment("AI_GPU_ROLLOUT") in ["1", "true", "TRUE", "True"]) ctrl.set_priors_enabled(OS.get_environment("AI_MCTS_PRIORS") in ["1", "true", "TRUE", "True"]) + var budget_ms_env: String = OS.get_environment("MCTS_DECISION_BUDGET_MS") + if not budget_ms_env.is_empty() and budget_ms_env.is_valid_int(): + var budget_ms_val: int = int(budget_ms_env) + if budget_ms_val > 0: + ctrl.set_budget_ms(budget_ms_val) + print("AiTurnBridge: MCTS_DECISION_BUDGET_MS=%d ms active (p1-22)" % budget_ms_val) var data_dir: String = ProjectSettings.globalize_path( "res://public/games/age-of-dwarves/data" ) diff --git a/src/simulator/crates/mc-ai/src/evaluator.rs b/src/simulator/crates/mc-ai/src/evaluator.rs index 207493c1..f24090fc 100644 --- a/src/simulator/crates/mc-ai/src/evaluator.rs +++ b/src/simulator/crates/mc-ai/src/evaluator.rs @@ -753,19 +753,46 @@ fn score_tech( score += tech.unlocks_buildings.len() as f32 * 0.2; score += tech.unlocks_units.len() as f32 * 0.15; - // Strategic fit by pillar + // Extract raw per-axis weights from state (1..=10 scale, default 5 = neutral). + // These may come from the clan personality JSON (1–10) or race axes (-10..+10). + // Normalise both to [0, 1] by clamping to [1, 10] then dividing by 9. + let norm_axis = |key: &str| -> f32 { + let raw = *state.strategic_axes.get(key).unwrap_or(&5) as f32; + (raw.clamp(1.0, 10.0) - 1.0) / 9.0 + }; + let aggression = norm_axis("aggression"); + let production_axis = norm_axis("production"); + let wealth_axis = norm_axis("wealth"); + let trade_axis = norm_axis("trade_willingness"); + let expansion = norm_axis("expansion"); + + // Strategic fit by actual pillar names in techs/*.json. + // + // Mapping: + // military → aggression (blackhammer: agg=9 → ~0.89) + // metallurgy → production (ironhold: prod=9 → ~0.89; deepforge: prod=8 → ~0.78) + // agriculture → expansion (blackhammer: exp=6; runesmith: exp=6) + // civics → wealth + trade_willingness blend (goldvein) + // scholarship → wealth (goldvein) + production (ironhold/deepforge) + // ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire) score += match tech.pillar.as_str() { - "metallurgy" | "engineering" => strategic.economy * 0.6, - "military" | "warfare" => strategic.aggression * 0.6, - "agriculture" | "growth" => strategic.economy * 0.5, - "commerce" | "trade" => strategic.economy * 0.4, - "construction" | "production" => strategic.economy * 0.5, - _ => 0.2, + "military" => aggression * 0.7, + "metallurgy" => production_axis * 0.7, + "agriculture" => expansion * 0.5, + "civics" => (wealth_axis + trade_axis) / 2.0 * 0.6, + "scholarship" => (wealth_axis + production_axis) / 2.0 * 0.5, + "ecology" => (expansion + production_axis) / 2.0 * 0.4, + _ => 0.2, }; - // Economic emergency: commerce techs urgent when broke - if state.gold < 0 && matches!(tech.pillar.as_str(), "commerce" | "trade") { - score += 0.5; + // Economic emergency: civics/scholarship give commerce-adjacent income boost + if state.gold < 0 && matches!(tech.pillar.as_str(), "civics" | "scholarship") { + score += 0.4; + } + + // Aggression bonus for military techs when threat is high + if state.threat_level > 0.5 && tech.pillar == "military" { + score += strategic.aggression * 0.3; } score diff --git a/src/simulator/crates/mc-ai/tests/gpu_tree_integration.rs b/src/simulator/crates/mc-ai/tests/gpu_tree_integration.rs index a5a54173..283f71f5 100644 --- a/src/simulator/crates/mc-ai/tests/gpu_tree_integration.rs +++ b/src/simulator/crates/mc-ai/tests/gpu_tree_integration.rs @@ -91,7 +91,7 @@ fn iterate_gpu_batched_exercises_gpu_path_when_adapter_available() { let mut rolled_out = 0_usize; let mut batch_idx: u64 = 0; while rolled_out < TOTAL_ROLLOUTS { - let n = tree.iterate_gpu_batched(BATCH_SIZE, 1000 + batch_idx); + let n = tree.iterate_gpu_batched(BATCH_SIZE, 1000 + batch_idx, None); if n == 0 { break; } @@ -143,7 +143,7 @@ fn iterate_gpu_batched_cpu_fallback_without_context() { let mut rolled_out = 0_usize; let mut batch_idx: u64 = 0; while rolled_out < TOTAL_ROLLOUTS { - let n = tree.iterate_gpu_batched(BATCH_SIZE, 2000 + batch_idx); + let n = tree.iterate_gpu_batched(BATCH_SIZE, 2000 + batch_idx, None); if n == 0 { break; } @@ -179,8 +179,8 @@ fn iterate_gpu_batched_is_seed_deterministic() { let mut tree_b = Tree::new(root_b).with_gpu_context(ctx); for i in 0..2 { - tree_a.iterate_gpu_batched(BATCH_SIZE, 7000 + i as u64); - tree_b.iterate_gpu_batched(BATCH_SIZE, 7000 + i as u64); + tree_a.iterate_gpu_batched(BATCH_SIZE, 7000 + i as u64, None); + tree_b.iterate_gpu_batched(BATCH_SIZE, 7000 + i as u64, None); } assert_eq!( @@ -200,7 +200,7 @@ fn iterate_gpu_batched_is_seed_deterministic() { fn iterate_gpu_batched_zero_batch_is_noop() { let ctx = GpuContext::shared(); let mut tree = Tree::new(make_root_state()).with_gpu_context(ctx); - let n = tree.iterate_gpu_batched(0, 42); + let n = tree.iterate_gpu_batched(0, 42, None); assert_eq!(n, 0); assert_eq!(tree.root().visits, 0); assert_eq!(tree.gpu_batch_count, 0); diff --git a/src/simulator/crates/mc-ai/tests/mcts_basic.rs b/src/simulator/crates/mc-ai/tests/mcts_basic.rs index 96c1906e..74e96848 100644 --- a/src/simulator/crates/mc-ai/tests/mcts_basic.rs +++ b/src/simulator/crates/mc-ai/tests/mcts_basic.rs @@ -80,7 +80,7 @@ fn terminal_state_has_no_legal_actions() { #[test] fn simulate_parallel_visits_match_n_rollouts() { let mut tree = Tree::new(ToyState { depth: 3, branching: 4 }); - tree.simulate_parallel(200, 42, Tree::::default_rollout); + tree.simulate_parallel(200, 42, Tree::::default_rollout, None); // All 200 rollouts must be backpropagated: root visits == 200. assert_eq!(tree.root().visits, 200); // Stubbed rollout returns 0.5 → wins == 0.5 * visits. @@ -91,7 +91,7 @@ fn simulate_parallel_visits_match_n_rollouts() { fn simulate_parallel_deterministic_across_runs() { let make = || { let mut tree = Tree::new(ToyState { depth: 3, branching: 4 }); - tree.simulate_parallel(500, 99, Tree::::default_rollout); + tree.simulate_parallel(500, 99, Tree::::default_rollout, None); (tree.root().visits, tree.nodes.len()) }; let (v1, n1) = make(); @@ -118,7 +118,7 @@ fn parallel_faster_than_serial_for_large_n() { let parallel_ms = { let mut tree = Tree::new(ToyState { depth: 3, branching: 4 }); let t = Instant::now(); - tree.simulate_parallel(N, 1, Tree::::default_rollout); + tree.simulate_parallel(N, 1, Tree::::default_rollout, None); t.elapsed().as_millis() }; diff --git a/tools/export-single.sh b/tools/export-single.sh index a0c2335c..0fcdf49d 100755 --- a/tools/export-single.sh +++ b/tools/export-single.sh @@ -213,6 +213,21 @@ if $godot_cmd --headless --path "$export_game_dir" "$flag" "$preset_name" "$out_ if [ -e "$out_path" ]; then size="$(du -h "$out_path" 2>/dev/null | cut -f1)" echo -e "${GREEN} ✓ Exported $artifact ($size)${NC}" + # ── Relocate GDExtension .so into the addon subdir for Linux ─── + # Godot's Linux export drops `lib*.so` next to the binary, but the + # .gdextension references `res://engine/addons/magic_civ_physics/...` + # which the runtime resolves to that exact relative path next to the + # executable. Without the move, the library is never loaded and + # Gd* classes never register. + if [ "$platform" = "linux" ]; then + so_root="$out_dir/libmagic_civ_physics.x86_64.so" + so_addon_dir="$out_dir/engine/addons/magic_civ_physics" + if [ -f "$so_root" ]; then + mkdir -p "$so_addon_dir" + mv "$so_root" "$so_addon_dir/" + echo -e "${DIM} · relocated .so → engine/addons/magic_civ_physics/${NC}" + fi + fi cleanup_staging exit 0 else diff --git a/tools/huge-map-5clan.sh b/tools/huge-map-5clan.sh index 885e8bef..553d0b98 100755 --- a/tools/huge-map-5clan.sh +++ b/tools/huge-map-5clan.sh @@ -41,6 +41,10 @@ DIM='\033[2m'; NC='\033[0m' # (128×80, 12-player) is stretch-goal; switch to # MAP_SIZE=huge once POD's MAX_PLAYERS=4 limit is # lifted and the game supports >8 AI slots. +# p1-22: bound MCTS per-decision wall-clock cost. 2000 ms caps each AI +# decision so slow seeds finish in ~5s/turn × 5 players × 500 turns ≈ 3.5 hr +# per game — well within the 3600s safety timeout. +: "${MCTS_DECISION_BUDGET_MS:=2000}" for arg in "$@"; do case "$arg" in @@ -90,6 +94,7 @@ MARKER="$PARENT/completion.marker" MAP_SIZE="$MAP_SIZE" \ NUM_PLAYERS="$NUM_PLAYERS" \ PARALLEL="$PARALLEL" \ +MCTS_DECISION_BUDGET_MS=2000 \ AI_USE_MCTS=true \ AI_PIN_PERSONALITY_P0=ironhold \ AI_PIN_PERSONALITY_P1=blackhammer \