feat(mc-core): Introduce tactical memory types and expose them in lib.rs for advanced memory simulation

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

View file

@ -28,6 +28,7 @@ pub mod production_origin;
pub mod resources;
pub mod scoring_weights;
pub mod seed;
pub mod tactical_types;
pub mod tech;
pub mod units;
pub mod wonder;
@ -52,6 +53,7 @@ pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, ResourceId, Special
pub use lair::{LairCombatMode, LairId, SiegeOutcome, SiegePressure, SiegeState};
pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError};
pub use scoring_weights::{LoadError, PersonalityDef, ScoringWeights};
pub use tactical_types::{BuildingPriors, TacticalBuildingSpec, TacticalMemory, TacticalUnitSpec};
pub use player::{HexCoord, PlayerPrologue};
pub use player_presentation::PresentationPlayer;
pub use production_origin::ProductionOrigin;

View file

@ -0,0 +1,370 @@
//! Tactical AI data shapes carried on the simulation state (p2-65 Phase 0c).
//!
//! These four types were authored in `mc-ai::tactical` but are pure data
//! (no AI logic): `mc_turn::GameState`/`PlayerState` carry them as fields, so
//! they must live in a crate that the future `mc-state` crate can depend on
//! WITHOUT pulling in `mc-ai`. `mc-core` is that crate (same precedent as
//! `ScoringWeights` and `CombatBalance`, both relocated here earlier).
//!
//! `mc-ai::tactical::{memory, state}` re-export these (`pub use
//! mc_core::tactical_types::…`) so every existing `mc_ai::tactical::…`
//! reference keeps compiling unchanged. Serde shapes are byte-identical to the
//! pre-relocation definitions — the save format is invariant (the JSON never
//! encoded module paths).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::BuildingId;
/// Per-player cross-turn tactical intent (p1-29h). The persistence channel that
/// makes the AI's war *decisive* (capture → elimination) instead of indecisive
/// (capture → disperse → opponent refounds). Carried on
/// `mc_turn::PlayerState::tactical_memory` (`#[serde(skip)]` there); the
/// commitment hysteresis + army-wide target-lock that `mc-ai`'s
/// `decide_movement` mutates each turn.
#[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. The single per-turn entry
/// point called from `mc_ai::tactical::movement::decide_movement`. Folds the
/// four `auto_play.gd` state-machine cases into one deterministic update:
///
/// 1. **No live targets** — clear the lock; nothing to commit to.
/// 2. **Holding a still-alive target** while committed — keep the lock.
/// 3. **Target fell** — press on to the nearest remaining target.
/// 4. **Not committed** but `want_attack` — acquire a fresh lock.
///
/// `army_centroid` is the army's massing point; `live_targets` the set of
/// still-valid enemy objective hexes; `want_attack` the turn-level attack
/// decision; `commitment` the personality-derived hysteresis length.
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() {
self.locked_target = None;
self.commitment_turns = 0;
return None;
}
let lock_alive = self
.locked_target
.is_some_and(|t| live_targets.contains(&t));
if self.commitment_turns > 0 {
if lock_alive {
return self.locked_target;
}
let next = nearest(army_centroid, live_targets);
self.press_on(next, commitment);
return self.locked_target;
}
if want_attack {
if let Some(t) = nearest(army_centroid, live_targets) {
self.acquire(t, commitment);
return self.locked_target;
}
}
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 `mc_ai::tactical::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;
((dq.abs() + dr.abs() + (dq + dr).abs()) / 2).max(dq.abs().max(dr.abs()))
}
/// Per-clan building-scoring biases. Empty maps = neutral (fall through to the
/// legacy axis-driven multipliers in `mc_ai::tactical::production::score_building`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BuildingPriors {
/// Multiplier applied to a candidate building's final score when its
/// `category` matches a key here. 1.0 = neutral. Keys are lowercase category
/// strings from `buildings/*.json::category`.
#[serde(default)]
pub building_category_weights: BTreeMap<String, f32>,
/// Multiplier on top of the category bias when the candidate is a wonder
/// AND its id matches a key here. 1.0 = neutral.
#[serde(default)]
pub wonder_priorities: BTreeMap<String, f32>,
}
/// A buildable unit's gates + classification, flattened from `units/*.json` by
/// the GDExtension bridge and handed through on every `TacticalState`.
#[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 buildable when the player has researched this id.
pub tech_required: Option<String>,
/// Unit-type classification: `"military"` | `"worker"` | `"founder"` | …
pub unit_type: String,
/// Strategic resource gate (e.g. `"iron_ore"`). `None` = no requirement.
#[serde(default)]
pub requires_resource: Option<String>,
/// Race gate (e.g. `"dwarf"`). `None` = no restriction.
#[serde(default)]
pub race_required: Option<String>,
/// Clan IDs that prefer this unit. Empty = neutral / shared.
#[serde(default)]
pub clan_affinity: Vec<String>,
/// Archetype label mirroring `units/*.json::archetype`. `None` for fixtures
/// predating p1-34.
#[serde(default)]
pub archetype: Option<String>,
/// Building gate — unit buildable only when the city has this building.
/// `None` = no requirement. `BuildingId` is `#[serde(transparent)]` so the
/// JSON wire format is unchanged.
#[serde(default)]
pub requires_building: Option<BuildingId>,
}
/// A buildable building's gates + summed yields, flattened from `buildings/*.json`.
#[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 scored higher when buildable.
#[serde(default = "default_tier")]
pub tier: u32,
/// Coarse role from JSON `category`.
#[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"`.
#[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): requires the named building already present.
#[serde(default)]
pub requires_existing: Option<String>,
/// Per-turn food yield.
#[serde(default)]
pub yield_food: i32,
/// Per-turn production yield.
#[serde(default)]
pub yield_production: i32,
/// Per-turn gold/trade yield.
#[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.
#[serde(default)]
pub yield_defense: i32,
/// Sum of GPP effects across all channels.
#[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,
}
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.
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;
}
}
if city_buildings.iter().any(|b| b == &self.id) {
return false;
}
true
}
}
fn default_tier() -> u32 {
1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tactical_memory_acquire_hold_press_on() {
let army = (8, 9);
let a = (10, 10);
let b = (20, 5);
let mut m = TacticalMemory::default();
assert!(!m.is_committed());
assert_eq!(m.resolve(army, &[a, b], true, 5), Some(a), "locks nearer city");
assert_eq!(m.commitment_turns, 5);
// Hysteresis: hold through want_attack=false.
m.tick();
assert_eq!(m.resolve(army, &[a, b], false, 5), Some(a));
// Press-on when the locked target falls.
let drive = m.resolve(army, &[b], true, 5);
assert_eq!(drive, Some(b));
assert_eq!(m.commitment_turns, 5, "press-on refreshes timer");
// Clears when all targets gone.
assert_eq!(m.resolve(army, &[], true, 5), None);
assert!(!m.is_committed());
}
#[test]
fn building_spec_gates() {
let spec = TacticalBuildingSpec {
id: "forge".into(),
tier: 1,
category: "production".into(),
cost: 60,
tech_required: Some("bronze_working".into()),
race_required: None,
wonder_type: None,
requires_resource: None,
requires_existing: None,
yield_food: 0,
yield_production: 2,
yield_gold: 0,
yield_science: 0,
yield_culture: 0,
yield_defense: 0,
yield_gpp: 0,
great_work_slots: 0,
yield_happiness: 0,
};
assert!(!spec.is_buildable(&[], None, &[], &[]), "tech gate blocks");
assert!(spec.is_buildable(&["bronze_working".into()], None, &[], &[]));
assert!(
!spec.is_buildable(&["bronze_working".into()], None, &[], &["forge".into()]),
"already built"
);
}
#[test]
fn specs_round_trip_serde() {
let u = TacticalUnitSpec {
id: "pikeman".into(),
tier: 2,
tech_required: Some("iron_working".into()),
unit_type: "military".into(),
requires_resource: None,
race_required: None,
clan_affinity: vec!["ironhold".into()],
archetype: Some("anti_cavalry".into()),
requires_building: None,
};
let j = serde_json::to_string(&u).unwrap();
assert_eq!(serde_json::from_str::<TacticalUnitSpec>(&j).unwrap(), u);
let mut priors = BuildingPriors::default();
priors.building_category_weights.insert("production".into(), 1.5);
let j2 = serde_json::to_string(&priors).unwrap();
assert_eq!(serde_json::from_str::<BuildingPriors>(&j2).unwrap(), priors);
}
}