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:
parent
24a2eacf02
commit
ec26b905bf
2 changed files with 372 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
370
src/simulator/crates/mc-core/src/tactical_types.rs
Normal file
370
src/simulator/crates/mc-core/src/tactical_types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue