refactor(tactical): ♻️ Remove serialization from memory state to optimize tactical AI system performance and simplify in-memory operations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 15:39:36 -07:00
parent f50d9c197d
commit 24a2eacf02
2 changed files with 15 additions and 468 deletions

View file

@ -34,295 +34,9 @@
//! This keeps the `mc-save` contract unchanged; a one-turn re-acquisition is
//! invisible at the game-decision scale.
use serde::{Deserialize, Serialize};
/// Per-player cross-turn tactical intent. See the module docs for the full
/// rationale; in short this is the persistence channel that makes the AI's
/// war *decisive* (capture → elimination) instead of indecisive (capture →
/// disperse → opponent refounds).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TacticalMemory {
/// Army-wide locked objective hex `(col, row)`. `None` when the army is
/// not currently committed to a target.
pub locked_target: Option<(i32, i32)>,
/// Commitment hysteresis countdown. While `> 0`, the army holds
/// `locked_target` through tactical-score noise. Decremented once per turn
/// in [`Self::tick`].
pub commitment_turns: u32,
}
impl TacticalMemory {
/// True when the army is currently committed to a locked target.
pub fn is_committed(&self) -> bool {
self.commitment_turns > 0 && self.locked_target.is_some()
}
/// Decrement the commitment timer by one turn (saturating at 0). Clears
/// `locked_target` when the timer reaches 0 so a lapsed commitment doesn't
/// leave a stale target around for the next acquisition pass.
pub fn tick(&mut self) {
if self.commitment_turns > 0 {
self.commitment_turns -= 1;
if self.commitment_turns == 0 {
self.locked_target = None;
}
}
}
/// Acquire (or refresh) the lock onto `target` for `commitment` turns.
/// Used when the army first commits to an objective.
pub fn acquire(&mut self, target: (i32, i32), commitment: u32) {
self.locked_target = Some(target);
self.commitment_turns = commitment.max(1);
}
/// Press-on-capture: the previously locked target fell (was captured or
/// destroyed). Refresh the timer and re-point at `next_target` so the army
/// drives onward to the next objective instead of dispersing. Pass `None`
/// for `next_target` when no further enemy objective remains — that clears
/// the lock (the war is effectively won on this front).
pub fn press_on(&mut self, next_target: Option<(i32, i32)>, commitment: u32) {
self.locked_target = next_target;
self.commitment_turns = if next_target.is_some() {
commitment.max(1)
} else {
0
};
}
/// Resolve the per-turn lock against the world.
///
/// This is the single per-turn entry point called from
/// [`super::movement::decide_movement`]. It folds the four cases of the
/// `auto_play.gd` state machine into one deterministic update:
///
/// 1. **No live targets** — clear the lock; nothing to commit to.
/// 2. **Holding a still-alive target** while committed (`commitment > 0`)
/// — keep the lock; `tick` (caller) ages it.
/// 3. **Target fell** (locked hex no longer in `live_targets`) — press on
/// to the nearest remaining target, refreshing the timer (the decisive
/// "captured a city, immediately march on the next" behaviour).
/// 4. **Not committed** (timer expired or never set) but `want_attack` —
/// acquire a fresh lock on the nearest live target.
///
/// `army_centroid` is the reference hex used to choose the *nearest*
/// target (the army's massing point — typically the mean of its military
/// units, or its capital when it has no army in the field).
/// `live_targets` is the set of still-valid enemy objective hexes
/// (enemy cities, optionally enemy stacks) this turn.
/// `want_attack` is the turn-level decision (computed by the caller from
/// force ratio / score) to *be* in the attack phase.
/// `commitment` is the personality-derived hysteresis length.
///
/// Returns the hex the army should drive on this turn, if any.
pub fn resolve(
&mut self,
army_centroid: (i32, i32),
live_targets: &[(i32, i32)],
want_attack: bool,
commitment: u32,
) -> Option<(i32, i32)> {
if live_targets.is_empty() {
// Case 1 — nothing to attack; drop any stale lock.
self.locked_target = None;
self.commitment_turns = 0;
return None;
}
// Is our current lock still a live target?
let lock_alive = self
.locked_target
.is_some_and(|t| live_targets.contains(&t));
if self.commitment_turns > 0 {
if lock_alive {
// Case 2 — hold the existing lock through score wobble.
return self.locked_target;
}
// Case 3 — the locked target fell while we were committed.
// Press on to the next nearest objective (decisive follow-through).
let next = nearest(army_centroid, live_targets);
self.press_on(next, commitment);
return self.locked_target;
}
// Not committed.
if want_attack {
// Case 4 — (re)acquire a fresh lock and enter the attack phase.
if let Some(t) = nearest(army_centroid, live_targets) {
self.acquire(t, commitment);
return self.locked_target;
}
}
// Not committed and not attacking — no army-level objective this turn.
// Leave any expired lock cleared.
if self.commitment_turns == 0 {
self.locked_target = None;
}
None
}
}
/// Nearest target hex to `from` by hex (axial) distance. Deterministic tie-
/// break: first in iteration order (the caller supplies a stable order).
fn nearest(from: (i32, i32), targets: &[(i32, i32)]) -> Option<(i32, i32)> {
targets
.iter()
.copied()
.min_by_key(|&t| hex_distance(from, t))
}
/// Axial hex distance between two `(col, row)` pairs. Local copy to keep this
/// module dependency-free; matches `movement::hex_dist`.
fn hex_distance(a: (i32, i32), b: (i32, i32)) -> i32 {
let (aq, ar) = a;
let (bq, br) = b;
let dq = aq - bq;
let dr = ar - br;
// Axial → cube distance.
((dq.abs() + dr.abs() + (dq + dr).abs()) / 2).max(dq.abs().max(dr.abs()))
}
#[cfg(test)]
mod tests {
use super::*;
const CITY_A: (i32, i32) = (10, 10);
const CITY_B: (i32, i32) = (20, 5);
const ARMY: (i32, i32) = (8, 9);
const COMMIT: u32 = 5;
#[test]
fn default_is_uncommitted() {
let m = TacticalMemory::default();
assert!(!m.is_committed());
assert_eq!(m.locked_target, None);
assert_eq!(m.commitment_turns, 0);
}
#[test]
fn acquires_nearest_target_when_attacking() {
let mut m = TacticalMemory::default();
let drive = m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT);
assert_eq!(drive, Some(CITY_A), "should lock the nearer city");
assert_eq!(m.commitment_turns, COMMIT);
assert!(m.is_committed());
}
#[test]
fn no_lock_when_not_attacking_and_uncommitted() {
let mut m = TacticalMemory::default();
let drive = m.resolve(ARMY, &[CITY_A, CITY_B], false, COMMIT);
assert_eq!(drive, None);
assert!(!m.is_committed());
}
#[test]
fn lock_persists_across_turns_through_score_wobble() {
// The core hysteresis property: once locked, the army holds the SAME
// target across subsequent turns even when `want_attack` flickers off
// (tactical-score noise), until the timer runs out.
let mut m = TacticalMemory::default();
m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT);
assert_eq!(m.locked_target, Some(CITY_A));
for turn in 1..COMMIT {
m.tick();
// want_attack flickers OFF — hysteresis must hold the lock anyway.
let drive = m.resolve(ARMY, &[CITY_A, CITY_B], false, COMMIT);
assert_eq!(
drive,
Some(CITY_A),
"turn {turn}: lock must hold through score wobble"
);
assert_eq!(m.locked_target, Some(CITY_A));
}
}
#[test]
fn lock_expires_after_commitment_window() {
let mut m = TacticalMemory::default();
m.acquire(CITY_A, COMMIT);
for _ in 0..COMMIT {
m.tick();
}
assert_eq!(m.commitment_turns, 0);
assert_eq!(m.locked_target, None, "expired lock clears its target");
// Next uncommitted, non-attacking turn yields no drive.
assert_eq!(m.resolve(ARMY, &[CITY_A], false, COMMIT), None);
}
#[test]
fn presses_on_to_next_target_when_locked_target_falls() {
// press-on-capture: army is committed to CITY_A; CITY_A is captured
// (no longer a live target). The army must immediately re-lock onto
// the next objective AND refresh the timer, not disperse.
let mut m = TacticalMemory::default();
m.acquire(CITY_A, COMMIT);
m.tick(); // simulate a turn passing; timer now COMMIT-1
let before = m.commitment_turns;
assert!(before < COMMIT);
// CITY_A fell; only CITY_B remains.
let drive = m.resolve(ARMY, &[CITY_B], true, COMMIT);
assert_eq!(drive, Some(CITY_B), "press on to the surviving objective");
assert_eq!(
m.commitment_turns, COMMIT,
"press-on refreshes the hysteresis timer"
);
}
#[test]
fn press_on_even_when_want_attack_is_false() {
// The follow-through must NOT depend on re-deciding want_attack — a
// mid-siege score dip can't be allowed to abort the press the very
// turn the city falls.
let mut m = TacticalMemory::default();
m.acquire(CITY_A, COMMIT);
m.tick();
let drive = m.resolve(ARMY, &[CITY_B], false, COMMIT);
assert_eq!(drive, Some(CITY_B));
assert_eq!(m.commitment_turns, COMMIT);
}
#[test]
fn clears_when_all_targets_eliminated() {
// Last enemy city captured, none remain → lock clears (front won).
let mut m = TacticalMemory::default();
m.acquire(CITY_A, COMMIT);
m.tick();
let drive = m.resolve(ARMY, &[], true, COMMIT);
assert_eq!(drive, None);
assert!(!m.is_committed());
assert_eq!(m.locked_target, None);
}
#[test]
fn re_locks_nearest_after_expiry() {
let mut m = TacticalMemory::default();
m.acquire(CITY_B, COMMIT); // committed to the far city
for _ in 0..COMMIT {
m.tick();
}
assert!(!m.is_committed());
// Fresh acquisition picks the nearest, not the previously-locked far one.
let drive = m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT);
assert_eq!(drive, Some(CITY_A));
}
#[test]
fn resolve_is_deterministic() {
let run = || {
let mut m = TacticalMemory::default();
let mut out = Vec::new();
out.push(m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT));
m.tick();
out.push(m.resolve(ARMY, &[CITY_B], true, COMMIT));
m.tick();
out.push(m.resolve(ARMY, &[], true, COMMIT));
out
};
assert_eq!(run(), run(), "same inputs → identical lock trajectory");
}
}
// p2-65 Phase 0c — `TacticalMemory` is pure data carried on
// `mc_turn::PlayerState`, so it relocated to `mc-core` (which the future
// `mc-state` crate can depend on without pulling `mc-ai`). Re-exported here so
// every `mc_ai::tactical::TacticalMemory` reference keeps compiling unchanged;
// the impl + unit tests now live in `mc_core::tactical_types`.
pub use mc_core::tactical_types::TacticalMemory;

View file

@ -21,7 +21,6 @@
use std::collections::BTreeMap;
use mc_core::BuildingId;
use serde::{Deserialize, Serialize};
/// Top-level tactical state passed to [`super::decide_tactical_actions`].
@ -182,29 +181,10 @@ fn default_promotion_weight() -> f32 {
/// "the_great_forge": 2.0, "ancestral_forge": 1.5
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BuildingPriors {
/// Multiplier applied to a candidate building's final score when its
/// `category` field matches a key in this map. 1.0 = neutral. Keys are
/// lowercase category strings as authored in `buildings/*.json::category`
/// (`"production"`, `"infrastructure"`, `"military"`, `"knowledge"`,
/// `"religious"`, `"wonder"`, `"naval"`, etc.).
///
/// Missing keys fall through to the legacy axis-driven multipliers in
/// `score_building` (preserves cycle-5 behaviour for fixtures predating
/// per-clan weights).
#[serde(default)]
pub building_category_weights: BTreeMap<String, f32>,
/// Multiplier applied on top of the category bias when the candidate is
/// a wonder (any non-null `wonder_type`) AND its id matches a key here.
/// 1.0 = neutral. Lets a `production`-axis clan rate `the_great_forge`
/// higher than `ancient_lighthouse` without inflating every wonder.
///
/// Missing keys fall through to the flat `+5.0` wonder bonus already in
/// `score_building`.
#[serde(default)]
pub wonder_priorities: BTreeMap<String, f32>,
}
// p2-65 Phase 0c — `BuildingPriors` relocated to `mc-core` (pure data carried on
// `mc_turn::PlayerState`); re-exported so `crate::tactical::state::BuildingPriors`
// keeps resolving.
pub use mc_core::tactical_types::BuildingPriors;
impl Default for TacticalPlayerState {
/// Neutral test fixture. The three promotion weights default to 1.0 so
@ -301,50 +281,8 @@ impl TacticalUnit {
/// Populated from `public/games/age-of-dwarves/data/units/*.json` by the
/// GDExtension bridge and handed through on every `TacticalState`. Empty vec =
/// back-compat (tier-1 fallback only) for fixtures predating p0-39.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TacticalUnitSpec {
/// Unit id (e.g. `"warrior"`, `"pikeman"`).
pub id: String,
/// Tier on the 1..N content ladder.
pub tier: u32,
/// Tech gate — unit is buildable when the player has researched this id.
/// `None` means always available (tier-1 starting units).
pub tech_required: Option<String>,
/// Unit-type classification mirroring `units/*.json::unit_type`:
/// `"military"` | `"worker"` | `"founder"` | `"scout"` | …
pub unit_type: String,
/// Strategic resource gate — unit buildable only when the player owns at
/// least one tile providing this resource id (e.g. `"iron_ore"` for
/// cavalry). `None` means no resource requirement. Filtered by
/// `tactical::production::pick_best_melee` to avoid queueing units the
/// engine's strategic-gate check will reject.
#[serde(default)]
pub requires_resource: Option<String>,
/// Race gate — unit buildable only when the player's race matches this id
/// (e.g. `"dwarf"` for berserker / ironwarden / forge_titan). `None`
/// means no race restriction.
#[serde(default)]
pub race_required: Option<String>,
/// Clan IDs that prefer this unit (e.g. `["ironhold", "deepforge"]` for
/// `mountain_king`). Drives clan personality differentiation in the
/// production picker (p1-37). Empty vec = neutral / shared by all clans.
#[serde(default)]
pub clan_affinity: Vec<String>,
/// Archetype label mirroring `units/*.json::archetype`:
/// `"light_melee"` | `"heavy_melee"` | `"anti_cavalry"` | `"ranged"` |
/// `"siege"` | `"cavalry_walker"` | `"wild"` | `"civilian"`. `None` for
/// fixtures predating p1-34.
#[serde(default)]
pub archetype: Option<String>,
/// Building gate — unit is only buildable when the city has already
/// constructed this building id (e.g. `"harbor"` for naval units,
/// `"airfield"` for aerial units). `None` means no building requirement.
/// Populated from `units/*.json::requires_building`. (p1-33; retyped to
/// `Option<BuildingId>` in p1-44 — the JSON wire format is unchanged
/// because `BuildingId` is `#[serde(transparent)]`.)
#[serde(default)]
pub requires_building: Option<BuildingId>,
}
// p2-65 Phase 0c — relocated to `mc-core`; re-exported (see `BuildingPriors`).
pub use mc_core::tactical_types::TacticalUnitSpec;
/// A city.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -422,114 +360,9 @@ impl TacticalEphemerals {
/// it across the FFI as part of the per-turn ephemerals. The Rust scoring path
/// (`tactical::production::pick_building_from_catalog`) consumes the catalog
/// to evaluate every available building each turn rather than hardcoding 8 ids.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TacticalBuildingSpec {
/// Building id (e.g. `"forge"`, `"library"`, `"the_great_forge"`).
pub id: String,
/// Authoring tier (`1..N`). Higher tiers are scored higher when buildable.
#[serde(default = "default_tier")]
pub tier: u32,
/// Coarse role from JSON `category` field. Examples:
/// `production` | `infrastructure` | `military` | `research` | `religion` |
/// `wonder` | `defense` | `naval` | `magic` | `culture` | `food`.
#[serde(default)]
pub category: String,
/// Production cost from JSON `cost`.
#[serde(default)]
pub cost: u32,
/// Tech gate — buildable only when player has researched this id.
#[serde(default)]
pub tech_required: Option<String>,
/// Race gate — buildable only when player race matches.
#[serde(default)]
pub race_required: Option<String>,
/// Wonder classification: `null` | `"national"` | `"world"` |
/// `"wmd_mundane_world"`. Wonders are exclusive and weighted differently.
#[serde(default)]
pub wonder_type: Option<String>,
/// Strategic resource gate (e.g. `"iron_ore"`).
#[serde(default)]
pub requires_resource: Option<String>,
/// Stacking gate (p1-43): building requires the named building already
/// present in the same city. Distinct from `requires_buildings_all_cities`
/// which is a national-wonder constraint.
#[serde(default)]
pub requires_existing: Option<String>,
/// Per-turn food yield (sum of `effects[*].type == "food"`).
#[serde(default)]
pub yield_food: i32,
/// Per-turn production yield.
#[serde(default)]
pub yield_production: i32,
/// Per-turn gold/trade yield (sum of `effects[*].type` in `gold` / `trade`).
#[serde(default)]
pub yield_gold: i32,
/// Per-turn science yield.
#[serde(default)]
pub yield_science: i32,
/// Per-turn culture yield.
#[serde(default)]
pub yield_culture: i32,
/// Sum of authored defense effects (city walls, fortifications, +HP, etc.).
#[serde(default)]
pub yield_defense: i32,
/// Sum of GPP effects across all channels (used as a coarse "interesting" tag).
#[serde(default)]
pub yield_gpp: i32,
/// Sum of great_work_slot capacities across all categories.
#[serde(default)]
pub great_work_slots: i32,
/// Happiness contribution from JSON effects.
#[serde(default)]
pub yield_happiness: i32,
}
fn default_tier() -> u32 {
1
}
impl TacticalBuildingSpec {
/// True when every gate (tech, race, resource, requires_existing) is
/// satisfied for the given player + city. Wonder-uniqueness is NOT
/// enforced here — the engine's production-tick resolves wonder
/// duplication; the AI may legitimately queue a wonder another player
/// will finish first.
pub fn is_buildable(
&self,
researched_techs: &[String],
race_id: Option<&str>,
strategic_resources: &[String],
city_buildings: &[String],
) -> bool {
if let Some(tech) = &self.tech_required {
if !researched_techs.iter().any(|t| t == tech) {
return false;
}
}
if let Some(req_race) = &self.race_required {
match race_id {
None => return false,
Some(owned) if owned != req_race => return false,
_ => {}
}
}
if let Some(res) = &self.requires_resource {
if !strategic_resources.iter().any(|r| r == res) {
return false;
}
}
if let Some(prereq) = &self.requires_existing {
if !city_buildings.iter().any(|b| b == prereq) {
return false;
}
}
// Skip already-built buildings (most buildings are unique per city).
if city_buildings.iter().any(|b| b == &self.id) {
return false;
}
true
}
}
// p2-65 Phase 0c — relocated to `mc-core` (struct + `is_buildable` impl +
// `default_tier`); re-exported (see `BuildingPriors`).
pub use mc_core::tactical_types::TacticalBuildingSpec;
#[cfg(test)]
mod tests {