diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index f6f3bf63..7059012c 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -14,11 +14,11 @@
| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|
-| **P0** | 27 | 7 | 3 | 0 | 0 | 37 |
+| **P0** | 27 | 8 | 2 | 0 | 0 | 37 |
| **P1** | 15 | 4 | 2 | 0 | 1 | 22 |
| **P2** | 14 | 5 | 0 | 8 | 0 | 27 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 17 | 17 |
-| **total** | **56** | **16** | **5** | **8** | **18** | **103** |
+| **total** | **56** | **17** | **4** | **8** | **18** | **103** |
@@ -74,7 +74,7 @@
| [p0-33](p0-33-world-map-input-and-panel-wiring.md) | 🟡 partial | World-map input wiring — unit selection panel, city click, ESC/F10 menu, panel close | [wireguard](../team-leads/wireguard.md) | 2026-04-18 |
| [p0-34](p0-34-freepeople-tribe-founding.md) | ✅ done | Freepeople tribe-founding cinematic — turn -1 / 0 / 1 start sequence and Dwarf Tribe founder unit | [shipwright](../team-leads/shipwright.md) | 2026-04-18 |
| [p0-35](p0-35-movement-mode-ux.md) | 🟡 partial | Movement mode UX — Move button, path preview, right-click confirm, fog-aware pathing | [wireguard](../team-leads/wireguard.md) | 2026-04-18 |
-| [p0-37](p0-37-personality-emergent-tactical-thresholds.md) | 🔴 stub | Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 |
+| [p0-37](p0-37-personality-emergent-tactical-thresholds.md) | 🟡 partial | Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 |
| [p0-38](p0-38-mcts-personality-priors.md) | 🔴 stub | Inject personality-utility scores as MCTS UCB1 priors | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 |
## P1 — Ship-readiness
diff --git a/.project/objectives/p0-37-personality-emergent-tactical-thresholds.md b/.project/objectives/p0-37-personality-emergent-tactical-thresholds.md
index 7e5354ec..16537842 100644
--- a/.project/objectives/p0-37-personality-emergent-tactical-thresholds.md
+++ b/.project/objectives/p0-37-personality-emergent-tactical-thresholds.md
@@ -2,7 +2,7 @@
id: p0-37
title: Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions)
priority: p0
-status: stub
+status: partial
scope: game1
owner: warcouncil
updated_at: 2026-04-18
@@ -63,9 +63,10 @@ through the `ScoringWeights::axes` already on the decision path.
## Acceptance
-- ✗ `mc_ai::tactical::thresholds::{dominance_factor, capital_approach_hex, retreat_hp_fraction, defensive_chase_range, final_push_enemy_city_count, capital_walls_min_age_turns, dominance_gold_floor}` implemented as pure functions of `&StrategicAxes`. Each has a unit test pinning extremes (axis=0 lower-bound, axis=10 upper-bound) and mid-point (axis=5 ≈ current hardcoded value for continuity).
-- ✗ Tactical regression suite migrated: behavioral assertions replace constant-pin assertions (e.g. "blackhammer aggression=9 yields factor < goldvein aggression=4" not "factor == 1.25"). Current regression count 79 stays green.
-- ✗ Callsites updated: `movement.rs:443, 449, 780-ish, 868-ish, 906-ish, 1049-1054` + `production.rs:318, 446`. No remaining `const` references to the 7 lifted names. `cargo test -p mc-ai tactical` stays green.
+- ✓ `mc_ai::tactical::thresholds::{dominance_factor, capital_approach_hex, retreat_hp_fraction, defensive_chase_range, final_push_enemy_city_count, capital_siege_no_retreat_hp, grudge_retreat_hp_penalty, dominance_gold_floor, capital_walls_min_age_turns}` implemented as pure functions of `&BTreeMap`. Each has baseline + extremes + behavioral divergence tests. 26 threshold unit tests green in `cargo test -p mc-ai tactical::thresholds` 2026-04-18.
+- ✓ `TacticalPlayerState.strategic_axes: BTreeMap` added with `#[serde(default)]` — back-compat with fixtures predating the field.
+- ✓ Callsites updated: movement.rs (5 lifted: dominance_factor, capital_approach_hex, retreat_hp_fraction, defensive_chase_range, final_push_enemy_city_count, capital_siege_no_retreat_hp, grudge_retreat_hp_penalty) + production.rs (3 lifted: dominance_factor, dominance_gold_floor, capital_walls_min_age_turns). No remaining `const` references. `cargo test -p mc-ai` 226/226 tests green (was 227 before; -1 is the deleted constant-pin test replaced by threshold baseline tests).
+- ✓ GDExtension bridge wired: `ai_turn_bridge.gd::_player_to_dict` emits `strategic_axes` (falls back to `DataLoader.get_data("ai_personalities")[clan_id].strategic_axes` when player entity lacks the field, so legacy savegames still differentiate per-clan).
- ✗ 5-clan batch (10 seeds T300 pinned on player 1, post-thresholds binary) shows measurable per-clan emergent divergence:
- **Combats**: blackhammer (aggression=9) median ≥ 1.5× goldvein (aggression=4)
- **Median turn**: goldvein games ≥ 1.3× blackhammer (cautious commits later)
@@ -74,6 +75,8 @@ through the `ScoringWeights::axes` already on the decision path.
- ✗ No clan win-rate regression: all 5 clans still ≥ 6/10 wins on pinned position vs heuristic opponent.
- ✗ Unblock verification: p0-01 median tier_peak ≥ 5 in Normal-vs-Normal batch after this lands (partial progress on its quality gates; full ≥6 may still need further tuning).
+**Remaining**: rebuild GDExtension and run the 5-clan validation batch on apricot. Structural work complete.
+
## Non-goals
- MCTS prior injection — tracked separately as `p0-38`.
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 323c28a2..238e0565 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,10 +1,10 @@
{
- "generated_at": "2026-04-18T17:58:48Z",
+ "generated_at": "2026-04-18T18:12:56Z",
"totals": {
"done": 56,
- "partial": 16,
- "stub": 5,
"oos": 18,
+ "stub": 4,
+ "partial": 17,
"missing": 8,
"total": 103
},
@@ -363,7 +363,7 @@
"id": "p0-37",
"title": "Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions)",
"priority": "p0",
- "status": "stub",
+ "status": "partial",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-18",
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 21307181..b7eb302c 100644
--- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd
+++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd
@@ -302,6 +302,14 @@ static func _player_to_dict(p: RefCounted) -> Dictionary:
if other == null:
continue
relations.append(0 if int(other.index) == slot else -1)
+ # Personality axes for tactical::thresholds (p0-37). Emerges posture-flip,
+ # retreat, chase, siege, and final-push thresholds from clan personality
+ # instead of a flat global constant.
+ var axes: Dictionary = (
+ p.strategic_axes
+ if "strategic_axes" in p and not p.strategic_axes.is_empty()
+ else _load_clan_axes(String(p.clan_id) if "clan_id" in p else "")
+ )
return {
"index": slot,
"clan_id": (String(p.clan_id) if "clan_id" in p else ""),
@@ -309,9 +317,22 @@ static func _player_to_dict(p: RefCounted) -> Dictionary:
"happiness_pool": (int(p.happiness_pool) if "happiness_pool" in p else 0),
"units": units, "cities": cities,
"researched_techs": techs, "relations": relations,
+ "strategic_axes": axes,
}
+static func _load_clan_axes(clan_id: String) -> Dictionary:
+ if clan_id.is_empty():
+ return {}
+ var data: Dictionary = DataLoader.get_data("ai_personalities")
+ if data == null:
+ return {}
+ var entry: Dictionary = data.get(clan_id, {})
+ if entry == null:
+ return {}
+ return entry.get("strategic_axes", {})
+
+
# ── Action dispatch ──────────────────────────────────────────────────────────
diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs
index 0878c66f..6310ae30 100644
--- a/src/simulator/crates/mc-ai/src/tactical/movement.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs
@@ -25,51 +25,15 @@ use crate::mcts::XorShift64;
use super::{Action, TacticalCity, TacticalPlayerState, TacticalState, TacticalUnit};
// ── Constants ────────────────────────────────────────────────────────────
-
-/// Hex radius within which a unit bypasses stray-unit chasing to march on
-/// the enemy capital instead. Prevents armies circling field battles while
-/// the undefended capital sits intact.
-///
-/// Port of `simple_heuristic_ai.gd::CAPITAL_APPROACH_HEX` (line 47).
-pub const CAPITAL_APPROACH_HEX: i32 = 16;
-
-/// Dominance push threshold — `own_mil` must be `>=` this multiple of
-/// `enemy_mil` before the AI commits to a capital assault over holding at
-/// parity.
-///
-/// Port of `simple_heuristic_ai.gd::DOMINANCE_FACTOR` (line 40).
-pub const DOMINANCE_FACTOR: f32 = 1.25;
-
-/// Baseline HP fraction below which a wounded unit retreats toward a
-/// friendly city instead of engaging.
-///
-/// Port of `simple_heuristic_ai.gd::RETREAT_HP_FRACTION` (line 31).
-pub const RETREAT_HP_FRACTION: f32 = 0.4;
-
-/// Enemy distance cap at which a unit with `aggression == 0` will still
-/// chase a stray enemy rather than fall back.
-///
-/// Port of `simple_heuristic_ai.gd::DEFENSIVE_CHASE_RANGE` (line 32).
-pub const DEFENSIVE_CHASE_RANGE: i32 = 12;
-
-/// Enemy-city-count at or below which every unit marches directly on the
-/// surviving capital — the final-push gate.
-///
-/// Port of `simple_heuristic_ai.gd::FINAL_PUSH_ENEMY_CITY_COUNT` (line 43).
-pub const FINAL_PUSH_ENEMY_CITY_COUNT: usize = 1;
-
-/// HP fraction at/below which retreat is still blocked when the unit is
-/// adjacent (`city_dist <= 1`) to the enemy capital — prevents bleeding
-/// turns healing beside the objective.
-///
-/// Port of `simple_heuristic_ai.gd::CAPITAL_SIEGE_NO_RETREAT_HP` (line 63).
-pub const CAPITAL_SIEGE_NO_RETREAT_HP: f32 = 0.0;
-
-/// HP-fraction reduction applied to the retreat threshold for high-grudge
-/// clans. Effective threshold becomes `RETREAT_HP_FRACTION - this value`.
-///
-/// Port of `simple_heuristic_ai.gd::GRUDGE_RETREAT_HP_PENALTY` (line 85).
-pub const GRUDGE_RETREAT_HP_PENALTY: f32 = 0.15;
+//
+// Seven gameplay-threshold constants ported from `simple_heuristic_ai.gd`
+// during p0-26 have been lifted into `super::thresholds` as pure functions
+// of the player's `strategic_axes` (p0-37). See `tactical/thresholds.rs`
+// for the axis-derived replacements of `DOMINANCE_FACTOR`,
+// `CAPITAL_APPROACH_HEX`, `RETREAT_HP_FRACTION`, `DEFENSIVE_CHASE_RANGE`,
+// `FINAL_PUSH_ENEMY_CITY_COUNT`, `CAPITAL_SIEGE_NO_RETREAT_HP`, and
+// `GRUDGE_RETREAT_HP_PENALTY`. Only behavior-constant helpers used across
+// this module remain inline below.
/// `weights.threat_penalty` at or below this value activates grudge-retreat
/// suppression. The GDScript port reads `grudge_persistence >= 6` from
@@ -362,6 +326,18 @@ fn decide_military_action(
let hp_frac = unit.hp as f32 / unit.hp_max.max(1) as f32;
let nearest_enemy = nearest_enemy_unit(unit.hex, enemy_units);
+ // Personality-emergent thresholds (p0-37). Each fn reads the player's
+ // strategic_axes and returns an axis-derived value; empty axes yield the
+ // historical hardcoded constants.
+ let axes = &me.strategic_axes;
+ let dominance_factor = super::thresholds::dominance_factor(axes);
+ let capital_approach_hex = super::thresholds::capital_approach_hex(axes);
+ let retreat_hp_fraction = super::thresholds::retreat_hp_fraction(axes);
+ let defensive_chase_range = super::thresholds::defensive_chase_range(axes);
+ let final_push_enemy_city_count = super::thresholds::final_push_enemy_city_count(axes);
+ let capital_siege_no_retreat_hp = super::thresholds::capital_siege_no_retreat_hp(axes);
+ let grudge_retreat_hp_penalty = super::thresholds::grudge_retreat_hp_penalty(axes);
+
// 1. Adjacent enemy city → attack the city tile.
//
// GDScript emits `{"type": "attack", "unit_index", "target_col",
@@ -383,7 +359,7 @@ fn decide_military_action(
// 2. Final push — if the enemy is down to their last city, every unit
// marches on it regardless of stray defenders.
if !enemy_city_positions.is_empty()
- && enemy_city_positions.len() <= FINAL_PUSH_ENEMY_CITY_COUNT
+ && enemy_city_positions.len() <= final_push_enemy_city_count
{
if let Some(last_cap) = nearest_position(unit.hex, enemy_city_positions) {
return emit_move_toward(unit, enemy_units, &|n| -(hex_dist(n, last_cap) as f32));
@@ -400,12 +376,12 @@ fn decide_military_action(
let grudge_active = weights.threat_penalty <= GRUDGE_THREAT_PENALTY_CUTOFF;
let base_retreat_hp = if grudge_active {
- (RETREAT_HP_FRACTION - GRUDGE_RETREAT_HP_PENALTY).max(0.0)
+ (retreat_hp_fraction - grudge_retreat_hp_penalty).max(0.0)
} else {
- RETREAT_HP_FRACTION
+ retreat_hp_fraction
};
let retreat_hp_threshold = if city_dist <= 1 {
- CAPITAL_SIEGE_NO_RETREAT_HP
+ capital_siege_no_retreat_hp
} else {
base_retreat_hp
};
@@ -440,18 +416,18 @@ fn decide_military_action(
// a city target is closer than the nearest stray enemy, march
// on the city.
let dominant = (own_mil_count as f32)
- >= DOMINANCE_FACTOR * (enemy_mil_count.max(1) as f32)
+ >= dominance_factor * (enemy_mil_count.max(1) as f32)
&& !enemy_city_positions.is_empty()
&& city_dist <= enemy_dist;
// 7. Capital-approach bypass.
let capital_approach_bypass = !enemy_city_positions.is_empty()
- && city_dist <= CAPITAL_APPROACH_HEX;
+ && city_dist <= capital_approach_hex;
let aggression_positive = weights.military_base > ScoringWeights::default().military_base;
let should_chase = !dominant
&& !capital_approach_bypass
- && (aggression_positive || enemy_dist <= DEFENSIVE_CHASE_RANGE);
+ && (aggression_positive || enemy_dist <= defensive_chase_range);
if should_chase {
let target = enemy.hex;
@@ -600,6 +576,7 @@ mod tests {
cities,
researched_techs: Vec::new(),
relations,
+ strategic_axes: ::std::collections::BTreeMap::new(),
}
}
@@ -1041,20 +1018,10 @@ mod tests {
);
}
- // ── Constants preservation ───────────────────────────────────────────
-
- #[test]
- fn port_constants_match_gdscript_values() {
- // Fence: changing these without touching the matching GDScript
- // values silently diverges the Rust port from the reference.
- // If a constant's value needs to change, update
- // `simple_heuristic_ai.gd` in the same commit.
- assert_eq!(CAPITAL_APPROACH_HEX, 16);
- assert!((DOMINANCE_FACTOR - 1.25).abs() < f32::EPSILON);
- assert!((RETREAT_HP_FRACTION - 0.4).abs() < f32::EPSILON);
- assert_eq!(DEFENSIVE_CHASE_RANGE, 12);
- assert_eq!(FINAL_PUSH_ENEMY_CITY_COUNT, 1);
- assert!((CAPITAL_SIEGE_NO_RETREAT_HP - 0.0).abs() < f32::EPSILON);
- assert!((GRUDGE_RETREAT_HP_PENALTY - 0.15).abs() < f32::EPSILON);
- }
+ // ── Baseline continuity ──────────────────────────────────────────────
+ //
+ // Historical constant-pin assertions were superseded by the threshold
+ // module's own baseline tests (`tactical::thresholds::tests::*_baseline_matches_historical`),
+ // which verify axis=5 reproduces the original hardcoded values. Behavioral
+ // per-clan divergence tests also live alongside the thresholds module.
}
diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs
index 5c64d191..e2d1c39a 100644
--- a/src/simulator/crates/mc-ai/src/tactical/production.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/production.rs
@@ -171,6 +171,7 @@ pub(crate) fn decide_production(
own_mil,
enemy_mil_max,
threatened,
+ &player.strategic_axes,
);
out.push(Action::SetProduction {
city_id: city.id,
@@ -180,6 +181,7 @@ pub(crate) fn decide_production(
out
}
+#[allow(clippy::too_many_arguments)]
fn pick_for_city(
city: &TacticalCity,
player: &TacticalPlayerState,
@@ -188,7 +190,13 @@ fn pick_for_city(
own_mil: u32,
enemy_mil_max: u32,
threatened: bool,
+ strategic_axes: &std::collections::BTreeMap,
) -> String {
+ // Personality-emergent thresholds (p0-37).
+ let dominance_factor_t = super::thresholds::dominance_factor(strategic_axes);
+ let dominance_gold_floor_t = super::thresholds::dominance_gold_floor(strategic_axes);
+ let capital_walls_min_age_turns_t =
+ super::thresholds::capital_walls_min_age_turns(strategic_axes);
let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN {
EARLY_MIL_FLOOR
} else {
@@ -202,6 +210,7 @@ fn pick_for_city(
enemy_mil_max,
axes.production,
early_mil_floor,
+ dominance_factor_t,
);
// 1. Threat preemption (GDScript Priority 0-A).
@@ -218,7 +227,7 @@ fn pick_for_city(
&& city.is_capital
&& city_count == 1
&& own_mil >= 2
- && turn > CAPITAL_WALLS_MIN_AGE_TURNS
+ && turn > capital_walls_min_age_turns_t
&& !city.buildings.contains(&ids::WALLS.into())
{
return ids::WALLS.into();
@@ -305,6 +314,7 @@ fn classify_posture(
enemy_mil_max: u32,
production_axis: u8,
early_mil_floor: u32,
+ dominance_factor: f32,
) -> Posture {
if threatened {
return Posture::Threatened;
@@ -315,7 +325,7 @@ fn classify_posture(
if production_axis >= PRODUCTION_AXIS_BUILDING_BIAS && own_mil >= 2 {
return Posture::BuildUp;
}
- if enemy_mil_max > 0 && own_mil as f32 >= DOMINANCE_FACTOR * enemy_mil_max as f32 {
+ if enemy_mil_max > 0 && own_mil as f32 >= dominance_factor * enemy_mil_max as f32 {
return Posture::Offensive;
}
Posture::Steady
|