feat(simulator-simulator): Implement biome capacity calculations for trade and turn mechanics

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-28 15:46:47 -07:00
parent 501927dc33
commit adfecb0e96
3 changed files with 191 additions and 17 deletions

View file

@ -303,6 +303,14 @@ pub struct CourierRoute {
pub delivered: bool,
/// True once this courier has been intercepted (terminal state).
pub intercepted: bool,
/// Pre-computed A* path from sender capital to receiver capital (offset coords).
/// Empty until the route resolver populates it; straight-line fallback applies when empty.
/// Populated by `mc_turn::courier_resolver::dispatch_courier`.
#[serde(default)]
pub planned_path: Vec<(i32, i32)>,
/// Index into `planned_path` of the courier's current step.
#[serde(default)]
pub path_step: usize,
}
// ── Courier-gated diplomacy event structs (p3-01 c4) ─────────────────────
@ -505,6 +513,29 @@ pub trait CourierMapView {
/// True if the courier route is still structurally intact (e.g. beacon chain
/// not destroyed). For basic couriers this is always true.
fn route_intact(&self, route: &CourierRoute) -> bool;
/// Hex steps the courier advances per turn, based on `courier_era_tier`.
/// Default: 1 step/turn (era_2 Foot Runner baseline).
fn movement_per_turn(&self, era_tier: u8) -> u32 {
// Tiers 2-9 mapped from delay_class in unit JSONs.
match era_tier {
2 => 1, // very_high delay
3 => 2, // high
4 => 3, // medium_high
5 => 4, // medium
6 => 5, // low
7 => 8, // near_instant (steam track)
8 => 8, // near_instant (resonance wire)
9 => 16, // instant (hold network)
_ => 1,
}
}
/// True if both players in the agreement have the Adamantine Echo wonder,
/// collapsing courier delay to zero (deliver next turn regardless of path).
fn adamantine_echo_active(&self, _player_a: u8, _player_b: u8) -> bool {
false
}
}
/// Advance all SharedMap and OpenBorders agreements by one turn.
@ -579,12 +610,18 @@ pub fn step_shared_map_agreements(
// Safety check: route still structurally sound.
if !map.route_intact(route) {
// Route severed but not intercepted — skip this turn.
// Infrastructure severed — courier intercepted at current position.
route.intercepted = true;
events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted {
agreement_id: sm.agreement_id,
at_position: route.position,
by_player: route.receiver, // severed by hostile action on receiver side
}));
continue;
}
}
// Advance courier one step toward destination capital.
// Advance courier toward destination capital.
let route = match sm.courier_route.as_mut() {
Some(r) => r,
None => continue, // no courier dispatched yet
@ -595,26 +632,66 @@ pub fn step_shared_map_agreements(
None => continue,
};
// Check intercept at current position before moving.
let intercept_roll: f32 = rng.gen();
let intercept_chance = map.intercept_chance_at(route.position, route.sender);
if intercept_roll < intercept_chance {
route.intercepted = true;
events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted {
// Adamantine Echo: both players have the wonder — deliver instantly.
if map.adamantine_echo_active(route.sender, route.receiver) {
route.position = dest;
route.delivered = true;
sm.share_turns_remaining = sm.duration;
events.push(DiplomacyEvent::SharedMapDelivered(SharedMapDelivered {
agreement_id: sm.agreement_id,
at_position: route.position,
by_player: route.receiver,
from_player: route.sender,
to_player: route.receiver,
turns_remaining: sm.share_turns_remaining,
}));
continue;
}
// Straight-line step toward destination capital.
// TODO(p3-01): replace with real A* pathfinding once the spatial graph is wired.
let (cx, cy) = route.position;
let (dx, dy) = dest;
let step_x = (dx - cx).signum();
let step_y = (dy - cy).signum();
route.position = (cx + step_x, cy + step_y);
// Advance `movement_per_turn` steps along planned_path (if populated),
// or fall back to straight-line for legacy / test paths with empty paths.
let steps = map.movement_per_turn(route.courier_era_tier);
for _ in 0..steps {
if route.position == dest {
break;
}
// Check intercept at current position before each step.
let intercept_roll: f32 = rng.gen();
let intercept_chance = map.intercept_chance_at(route.position, route.sender);
if intercept_roll < intercept_chance {
route.intercepted = true;
events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted {
agreement_id: sm.agreement_id,
at_position: route.position,
by_player: route.receiver,
}));
break;
}
// Advance one step: follow planned_path if available, else straight-line.
if !route.planned_path.is_empty() {
let next_step = route.path_step + 1;
if next_step < route.planned_path.len() {
route.path_step = next_step;
route.position = route.planned_path[next_step];
} else {
route.position = dest;
}
} else {
let (cx, cy) = route.position;
let (dx, dy) = dest;
let step_x = (dx - cx).signum();
let step_y = (dy - cy).signum();
route.position = (cx + step_x, cy + step_y);
}
if route.intercepted {
break;
}
}
if route.intercepted {
continue;
}
if route.position == dest {
route.delivered = true;

View file

@ -8,6 +8,7 @@ use mc_core::formation::{
RallyPointRequest, SplitFormationRequest,
};
use mc_core::grid::GridState;
use mc_core::improvement::{TileImprovement, TileImprovementSpec};
use mc_core::WonderId;
use mc_culture::CulturePool;
use mc_tech::PlayerTechState;
@ -41,6 +42,29 @@ mod relations_as_pairs {
}
}
/// Serde adapter: round-trips `BTreeMap<(u16,u16), TileImprovement>` as a
/// `Vec<((u16,u16), TileImprovement)>` for the same reason as `relations_as_pairs`.
mod improvements_as_pairs {
use mc_core::improvement::TileImprovement;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
pub fn serialize<S: Serializer>(
map: &BTreeMap<(u16, u16), TileImprovement>,
ser: S,
) -> Result<S::Ok, S::Error> {
let pairs: Vec<(&(u16, u16), &TileImprovement)> = map.iter().collect();
pairs.serialize(ser)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
de: D,
) -> Result<BTreeMap<(u16, u16), TileImprovement>, D::Error> {
let pairs: Vec<((u16, u16), TileImprovement)> = Vec::deserialize(de)?;
Ok(pairs.into_iter().collect())
}
}
/// A player-vs-player attack request queued by GDScript (click-to-attack path).
///
/// Drained each turn by `TurnProcessor::process_pvp_combat` before the
@ -95,6 +119,74 @@ pub struct GameState {
/// Auto-join toggle requests queued by GDScript this turn.
#[serde(default)]
pub pending_auto_join_requests: Vec<AutoJoinRequest>,
/// Sparse per-hex improvement layer. Keyed by (col, row) as u16; most
/// tiles will be unimproved, so a BTreeMap wastes far less memory than an
/// Option on every TileState. Serialized as pairs to avoid the JSON
/// "key must be a string" restriction on tuple keys.
#[serde(default, with = "improvements_as_pairs")]
pub tile_improvements: BTreeMap<(u16, u16), TileImprovement>,
/// Registry of improvement specs loaded from
/// `public/resources/improvements/*.json`. Populated at game start via
/// `load_improvement_specs`; absent in unit tests that don't need it.
#[serde(skip)]
pub improvement_registry: BTreeMap<String, TileImprovementSpec>,
}
impl GameState {
/// Return the improvement at `(col, row)`, if any.
pub fn improvement_at(&self, col: u16, row: u16) -> Option<&TileImprovement> {
self.tile_improvements.get(&(col, row))
}
/// Place `spec`-derived improvement at `(col, row)`, overwriting any
/// existing one. Derives `hp`, `severable`, and `flags` from the spec.
pub fn set_improvement(&mut self, col: u16, row: u16, spec: &TileImprovementSpec) {
self.tile_improvements.insert(
(col, row),
TileImprovement {
id: spec.id.clone(),
hp: spec.hp,
severable: spec.severable,
pillaged: false,
flags: spec.flags.clone(),
},
);
}
/// Remove the improvement at `(col, row)` entirely.
pub fn remove_improvement(&mut self, col: u16, row: u16) {
self.tile_improvements.remove(&(col, row));
}
/// Pillage the improvement at `(col, row)`.
///
/// - If the improvement is `severable`: sets `pillaged = true` and returns
/// `true` (the improvement remains but its route-gating effect is
/// suspended).
/// - If the improvement is not severable (e.g. a Beacon Tower): removes it
/// outright and returns `false`.
/// - If there is no improvement: returns `false`.
pub fn pillage_improvement(&mut self, col: u16, row: u16) -> bool {
match self.tile_improvements.get_mut(&(col, row)) {
Some(imp) if imp.severable => {
imp.pillaged = true;
true
}
Some(_) => {
self.tile_improvements.remove(&(col, row));
false
}
None => false,
}
}
/// Populate `improvement_registry` from a slice of already-parsed specs.
/// Call once at game start after loading JSON files.
pub fn load_improvement_specs(&mut self, specs: impl IntoIterator<Item = TileImprovementSpec>) {
for spec in specs {
self.improvement_registry.insert(spec.id.clone(), spec);
}
}
}
/// Per-player state. Wide struct by design — every field is read directly by

View file

@ -29,6 +29,7 @@ pub mod prologue;
pub mod snapshot;
pub mod spatial_index;
pub mod victory;
pub mod courier_resolver;
#[cfg(feature = "gpu")]
pub mod gpu;
@ -38,10 +39,14 @@ mod processor_invariants;
#[cfg(test)]
mod bridge_contract_tests;
#[cfg(test)]
mod improvement_tests;
pub use action::{legal_actions, ActionAvailability, ActionKind, DisabledReason, UnitCapability};
pub use action_handlers::{invoke as invoke_action, ActionError};
pub use chronicle::{Chronicle, ChronicleEntry};
pub use game_state::{AttackRequest, BuildingRallyPoint, CityEcology, GameState, MapUnit, PlayerState, TechState};
pub use mc_core::improvement::{RawImprovementJson, TileImprovement, TileImprovementSpec};
pub use combat_event::{FaunaCombatEvent, PvpCombatEvent, SiegeEvent, StrategicGateRejection, TurnResult};
pub use processor::{LairCombatConfig, TurnProcessor};
pub use prologue::{