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