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:
parent
501927dc33
commit
adfecb0e96
3 changed files with 191 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue