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:
parent
f50d9c197d
commit
24a2eacf02
2 changed files with 15 additions and 468 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue