Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development.
Something went wrong. Try again.
34 kB · 985 lines
Rust
at commit 774beb1f
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986//! The renderer-agnostic simulation core.//!//! `Sim` owns all game state and advances it one fixed tick at a time via//! [`Sim::advance`]. Frontends decide how fast wall-clock time maps to ticks//! (and whether time is paused), render the state, and translate input into//! the command methods below. Nothing in this module may read the wall clock//! or perform I/O other than through the save module.//!//! Time model: continuous real-time-with-pause. There is no build/defend//! phase split — the economy, schemes, heat, and raids all run on one clock.//! Raids are consequences: heat (raised by schemes and violence) drives a//! countdown toward the next agency raid, and notoriety scales its strength.
use crate::combat::CombatSystem;use crate::entities::{self, Agent, Henchman, Minion, MinionClass, Player, generate_agent_wave};use crate::map::GameMap;use crate::save::SaveState;use crate::tiles::{self, TileType};
/// Ticks between economy updates (income, research, derived stats).pub const ECONOMY_INTERVAL: u64 = 20;/// Ticks between slow updates (scheme progress, heat decay).pub const SLOW_INTERVAL: u64 = 40;/// Below this heat, agencies aren't interested and the raid countdown pauses.pub const RAID_HEAT_THRESHOLD: i32 = 10;/// When the countdown reaches this, the player gets a warning message.pub const RAID_WARNING_TICKS: i32 = 80;/// Raid countdown bounds (in ticks).pub const RAID_COUNTDOWN_BASE: i32 = 900;pub const RAID_COUNTDOWN_MIN: i32 = 180;/// Raid strength cap (equivalent to the old max wave).pub const RAID_STRENGTH_CAP: i32 = 12;
/// An in-progress agency raid on the lair.#[derive(Debug, Clone)]pub struct Raid { /// How many raids the lair has seen, 1-based (stored in `player.wave`). pub number: i32, /// Combat turns elapsed since the raid began. pub turn: i32, /// Announcement countdown — combat holds for a few ticks so the player /// sees the alert. pub announce: i32, /// Effective wave strength used to generate the attackers. pub strength: i32,}
/// High-level threat picture for frontends to display.#[derive(Debug, Clone, PartialEq)]pub enum Threat { /// Heat is below the agency-interest threshold; no raid is coming. Cold, /// Agencies are closing in; raid expected in roughly this many ticks. Watched { ticks_left: i32 }, /// A raid is in progress. Raid { number: i32, turn: i32 },}
pub struct Sim { pub map: GameMap, pub player: Player, pub minions: Vec<Minion>, pub agents: Vec<Agent>, pub henchmen: Vec<Henchman>, pub combat: CombatSystem, /// Simulation ticks elapsed since the game began. pub tick: u64, pub raid: Option<Raid>, /// Ticks until the next raid (counts down while heat is above threshold). pub next_raid_in: i32, pub game_over: bool, /// Cached from the last derived-stats pass (per powered facility). research_rate: i32, heat_cooling_rate: i32, /// Event messages for frontends to drain and display. log: Vec<String>,}
impl Sim { pub fn new() -> Self { let map = GameMap::new(80, 40); let throne = map.throne_pos().unwrap_or((40, 20)); let mut player = Player::new(throne.0, throne.1); player.update_powers();
let mut minions = Vec::new(); for (dx, dy) in [(1, 0), (-1, 0), (0, 1)] { let (mx, my) = (throne.0 + dx, throne.1 + dy); if map.is_walkable(mx, my) { minions.push(Minion::new(mx, my, MinionClass::Worker, 0, 0)); } }
let henchmen = Henchman::starting_roster(throne.0, throne.1);
let mut sim = Self { map, player, minions, agents: Vec::new(), henchmen, combat: CombatSystem::new(), tick: 0, raid: None, next_raid_in: RAID_COUNTDOWN_BASE, game_over: false, research_rate: 0, heat_cooling_rate: 0, log: Vec::new(), }; sim.recompute_derived(); sim.schedule_next_raid();
// Staff any guard posts present at start. let posts = sim.map.tiles_of_type(TileType::GuardPost); for &(gx, gy) in &posts { sim.spawn_minion_at_guard_post(gx, gy); }
// Player-facing strings stay ASCII: Bevy's default font has no em dash. sim.push_log("The lair is operational. Time flows - SPACE pauses it."); sim.push_log("Run schemes (m) for gold and infamy. Heat draws the agencies."); sim }
// ── Logging ────────────────────────────────────────────────────────────
fn push_log(&mut self, msg: impl Into<String>) { self.log.push(msg.into()); }
/// Take all pending log messages (frontends call this once per frame). pub fn drain_log(&mut self) -> Vec<String> { std::mem::take(&mut self.log) }
// ── Clock ──────────────────────────────────────────────────────────────
/// Advance the simulation by one tick. The heartbeat of the game. pub fn advance(&mut self) { if self.game_over { return; } self.tick += 1;
if self.raid.is_some() { self.combat_step(); } else { self.threat_tick(); if self.tick.is_multiple_of(ECONOMY_INTERVAL) { // Power cooldowns recover during peacetime at economy cadence; // during raids they tick per combat turn as before. self.player.tick_power_cooldowns(); } }
if self.tick.is_multiple_of(ECONOMY_INTERVAL) { self.economy_tick(); } if self.tick.is_multiple_of(SLOW_INTERVAL) { self.slow_tick(); } }
pub fn raid_active(&self) -> bool { self.raid.is_some() }
pub fn threat(&self) -> Threat { if let Some(raid) = &self.raid { Threat::Raid { number: raid.number, turn: raid.turn, } } else if self.player.heat < RAID_HEAT_THRESHOLD { Threat::Cold } else { Threat::Watched { ticks_left: self.next_raid_in.max(0), } } }
// ── Economy ────────────────────────────────────────────────────────────
/// Recompute power grid and derived stats. Cheap; also called by /// frontends after build actions so the sidebar reflects changes at once. pub fn recompute_derived(&mut self) { let cores = self.map.tiles_of_type(TileType::PowerCore); self.map.update_power(&cores);
let mut power_gen = 0i32; let mut power_use = 0i32; let mut research_gen = 0i32; let mut cooling = 0i32;
for _ in &cores { power_gen += TileType::PowerCore.power_gen(self.player.power_core_mk2) + self.player.power_bonus; } for y in 0..self.map.height { for x in 0..self.map.width { let tile = self.map.get_tile(x, y); if self.map.powered.contains(&(x, y)) { power_use += tile.power_usage(); research_gen += tile.research_gen(); cooling += tile.heat_reduction(); } } }
self.player.power_cap = power_gen; self.player.power = (power_gen - power_use).max(0); self.research_rate = research_gen; self.heat_cooling_rate = cooling;
let barracks = self.map.tiles_of_type(TileType::Barracks).len() as i32; self.player.minion_cap = 3 + barracks * 2; }
/// Accrue income and research. Runs every `ECONOMY_INTERVAL` ticks. fn economy_tick(&mut self) { self.recompute_derived();
let mut research_gen = self.research_rate + self.player.research_bonus; for minion in &self.minions { research_gen += minion.research_bonus(); } self.player.research += research_gen;
let workers = self .minions .iter() .filter(|m| m.entity.alive && m.class == MinionClass::Worker) .count() as i32; let vaults = self.map.tiles_of_type(TileType::Vault).len() as i32; let income = workers * (3 + vaults); self.player.gold += income; self.player.gold_income = income; }
/// Scheme progress and heat decay. Runs every `SLOW_INTERVAL` ticks. fn slow_tick(&mut self) { let cooling = 1 + self.heat_cooling_rate + self.player.heat_reduction_bonus; self.player.reduce_heat(cooling); self.tick_schemes(); }
// ── Threat & raids ─────────────────────────────────────────────────────
fn schedule_next_raid(&mut self) { let pressure = self.player.heat * 6 + self.player.notoriety; self.next_raid_in = (RAID_COUNTDOWN_BASE - pressure).max(RAID_COUNTDOWN_MIN); }
fn threat_tick(&mut self) { if self.player.heat < RAID_HEAT_THRESHOLD { return; } // Hotter lairs draw raids in faster. let step = 1 + self.player.heat / 50; let before = self.next_raid_in; self.next_raid_in -= step; if before > RAID_WARNING_TICKS && self.next_raid_in <= RAID_WARNING_TICKS { self.push_log("Intel intercepted: agency forces are mobilizing against the lair!"); } if self.next_raid_in <= 0 { self.start_raid(); } }
fn start_raid(&mut self) { self.player.wave += 1; let strength = (1 + self.player.notoriety / 25 + self.player.heat / 30).min(RAID_STRENGTH_CAP);
let entries = self.map.entry_positions(); let throne = self.map.throne_pos().unwrap_or((40, 20)); let mut agents = generate_agent_wave(strength, self.player.heat, &entries);
let positions: Vec<(i32, i32)> = agents.iter().map(|a| (a.entity.x, a.entity.y)).collect(); for (i, agent) in agents.iter_mut().enumerate() { let target = agent.preferred_target(&self.map, throne); let avoid: std::collections::HashSet<(i32, i32)> = positions .iter() .enumerate() .filter(|(j, _)| *j != i) .map(|(_, pos)| *pos) .collect(); agent.calculate_path(&self.map, target, &avoid); }
let count = agents.iter().filter(|a| a.entity.alive).count(); self.agents = agents; self.raid = Some(Raid { number: self.player.wave, turn: 0, announce: 5, strength, });
self.push_log(format!( "=== RAID {} - AGENCY FORCES INBOUND! ===", self.player.wave )); self.push_log(format!("{count} agents are breaching the lair!"));
// Staff empty guard posts. let posts = self.map.tiles_of_type(TileType::GuardPost); for &(gx, gy) in &posts { let staffed = self .minions .iter() .any(|m| m.entity.alive && m.home_x == gx && m.home_y == gy); if !staffed { self.spawn_minion_at_guard_post(gx, gy); } } }
/// One combat turn of an active raid. fn combat_step(&mut self) { let Some(raid) = &mut self.raid else { return }; if raid.announce > 0 { raid.announce -= 1; return; } raid.turn += 1; let raid_turn = raid.turn;
let throne = self.map.throne_pos().unwrap_or((40, 20));
// Mid-raid events. if let Some(event_type) = crate::events::check_for_event(self.player.wave, raid_turn) { let entries = self.map.entry_positions(); let event = crate::events::process_event( event_type, &mut self.player, &mut self.agents, &mut self.minions, &mut self.map, &entries, ); self.push_log(format!("EVENT: {}", event.name)); self.push_log(event.description.clone()); }
// Agent turn. let throne_reached = self.combat.process_agent_turn( &mut self.map, &mut self.player, &mut self.agents, &mut self.minions, &mut self.henchmen, throne, ); self.collect_combat_log(); for event in &self.combat.kill_events.clone() { self.player.gold += event.gold_value; self.player.kills += 1; self.player.score += event.gold_value; self.player.add_notoriety(match event.agent_type { entities::AgentType::Investigator => 2, entities::AgentType::Thief => 3, entities::AgentType::Saboteur => 5, entities::AgentType::Soldier => 4, entities::AgentType::SuperAgent => 15, }); } for event in &self.combat.heat_events.clone() { self.player.add_heat(event.heat_change); self.push_log(format!("Heat +{} ({})", event.heat_change, event.reason)); }
if throne_reached { self.player.entity.take_damage(15); self.push_log("The Supervillain takes 15 damage!"); if !self.player.entity.alive { self.push_log("The Supervillain has fallen. The lair is lost."); self.game_over = true; return; } }
// Minion turn. self.combat .process_minion_turn(&self.map, &mut self.minions, &mut self.agents); self.collect_combat_log(); self.apply_kill_rewards();
// Henchmen turn. self.combat.process_henchmen_turn( &self.map, &mut self.henchmen, &mut self.agents, &mut self.minions, ); self.collect_combat_log(); self.apply_kill_rewards();
// Facility effects. self.combat .process_technician_repairs(&mut self.map, &mut self.minions); self.combat .process_medbay_healing(&self.map, &mut self.minions); self.combat .process_training_room(&self.map, &mut self.minions);
self.combat .cleanup_dead(&mut self.minions, &mut self.agents, &mut self.henchmen);
self.player.tick_power_cooldowns();
if self.combat.all_agents_dead(&self.agents) { self.end_raid_repelled(); } }
fn end_raid_repelled(&mut self) { let Some(raid) = self.raid.take() else { return }; self.push_log(format!("=== RAID {} REPELLED! ===", raid.number));
let bonus = 20 + raid.strength * 10; self.player.gold += bonus; self.push_log(format!("Salvage from the raiders: +{bonus} gold"));
self.player.add_notoriety(raid.strength * 2); // Surviving a raid resolves some agency attention. self.player.reduce_heat(10);
let combo = self.combat.combo_bonus(); if combo > 0 { self.player.gold += combo; self.push_log(format!("Trap combo bonus: +{combo} gold!")); }
self.player.entity.heal(10); self.schedule_next_raid(); }
fn collect_combat_log(&mut self) { let msgs = self.combat.log.clone(); for msg in msgs { self.push_log(msg); } }
fn apply_kill_rewards(&mut self) { for event in &self.combat.kill_events.clone() { self.player.gold += event.gold_value; self.player.kills += 1; self.player.score += event.gold_value; } }
// ── Commands (frontend-invoked) ────────────────────────────────────────
/// Move the Supervillain one step, if walkable. pub fn move_player(&mut self, dx: i32, dy: i32) { let nx = self.player.entity.x + dx; let ny = self.player.entity.y + dy; if self.map.is_walkable(nx, ny) { self.player.entity.x = nx; self.player.entity.y = ny; } }
pub fn spawn_minion_at_guard_post(&mut self, x: i32, y: i32) { let alive = self.minions.iter().filter(|m| m.entity.alive).count() as i32; if alive >= self.player.minion_cap { self.push_log(format!( "Minion cap reached! ({alive}/{})", self.player.minion_cap )); return; } self.minions.push(Minion::new( x, y, MinionClass::Guard, self.player.minion_damage_bonus, self.player.minion_hp_bonus, )); self.push_log("Minion deployed at guard post!"); }
/// Hire the minion class at `class_index` (index into `MinionClass::all()`). pub fn hire_minion(&mut self, class_index: usize) { let classes = MinionClass::all(); if class_index >= classes.len() { return; } let class = classes[class_index];
if !self.player.unlocked.contains(class.unlock_key()) { self.push_log(format!("{} not researched yet!", class.name())); return; } let cost = class.hire_cost(); if self.player.gold < cost { self.push_log(format!("Need {} gold to hire {}!", cost, class.name())); return; } let alive = self.minions.iter().filter(|m| m.entity.alive).count() as i32; if alive >= self.player.minion_cap { self.push_log(format!( "Minion cap reached! ({alive}/{})", self.player.minion_cap )); return; }
let throne = self.map.throne_pos().unwrap_or((40, 20)); for dist in 1..5 { for (dx, dy) in [(0, -dist), (0, dist), (-dist, 0), (dist, 0)] { let (mx, my) = (throne.0 + dx, throne.1 + dy); if self.map.is_walkable(mx, my) { self.player.gold -= cost; self.minions.push(Minion::new( mx, my, class, self.player.minion_damage_bonus, self.player.minion_hp_bonus, )); self.push_log(format!("Hired {} for {} gold!", class.name(), cost)); return; } } } self.push_log("No room near throne to place minion!"); }
/// Launch the scheme at `index` (index into `tiles::available_schemes()`). /// Committed minions leave the lair for the scheme's duration cost. pub fn launch_scheme(&mut self, index: usize) -> bool { let schemes = tiles::available_schemes(); if index >= schemes.len() { return false; } let scheme = &schemes[index];
let alive = self.minions.iter().filter(|m| m.entity.alive).count() as i32; if alive < scheme.minion_cost { self.push_log(format!( "Need {} minions for this scheme!", scheme.minion_cost )); return false; }
// Send workers first, then anyone. let mut removed = 0; self.minions.retain(|m| { if removed >= scheme.minion_cost { return true; } if m.entity.alive && m.class == MinionClass::Worker { removed += 1; false } else { true } }); if removed < scheme.minion_cost { self.minions.retain(|m| { if removed >= scheme.minion_cost { return true; } if m.entity.alive { removed += 1; false } else { true } }); }
self.player.active_schemes.push(entities::ActiveScheme { name: scheme.name.to_string(), turns_remaining: scheme.duration_turns, gold_reward: scheme.gold_reward, heat_cost: scheme.heat_cost, notoriety_reward: scheme.notoriety_reward, minion_cost: scheme.minion_cost, loot: scheme.loot.map(|s| s.to_string()), });
self.push_log(format!("Launched scheme: {}!", scheme.name)); self.push_log(format!( " {} minions deployed for {} turns", scheme.minion_cost, scheme.duration_turns )); true }
/// Progress active schemes by one scheme-turn; pay out completed ones. fn tick_schemes(&mut self) { let mut completed = Vec::new(); for scheme in &mut self.player.active_schemes { scheme.turns_remaining -= 1; if scheme.turns_remaining <= 0 { completed.push(scheme.clone()); } } self.player.active_schemes.retain(|s| s.turns_remaining > 0);
for scheme in &completed { self.player.gold += scheme.gold_reward; self.player.add_heat(scheme.heat_cost); self.player.add_notoriety(scheme.notoriety_reward); self.push_log(format!("Scheme complete: {}!", scheme.name)); self.push_log(format!( " +{} gold, +{} notoriety, +{} heat", scheme.gold_reward, scheme.notoriety_reward, scheme.heat_cost )); if let Some(loot_name) = &scheme.loot { self.player.loot.push(loot_name.clone()); self.push_log(format!(" Acquired: {loot_name}!")); self.apply_loot_bonus(loot_name); } self.player.update_powers(); } }
fn apply_loot_bonus(&mut self, loot_name: &str) { let items = tiles::loot_items(); let Some(item) = items.iter().find(|l| l.name == loot_name) else { return; }; use tiles::LootBonus; match item.bonus_type { LootBonus::MinionDamage => { self.player.minion_damage_bonus += item.bonus_value; for minion in &mut self.minions { minion.entity.damage += item.bonus_value; } self.push_log(format!(" Minion damage +{}!", item.bonus_value)); } LootBonus::MinionHP => { self.player.minion_hp_bonus += item.bonus_value; for minion in &mut self.minions { minion.entity.max_hp += item.bonus_value; minion.entity.hp += item.bonus_value; } self.push_log(format!(" Minion HP +{}!", item.bonus_value)); } LootBonus::TrapDamage => { self.player.trap_damage_bonus += item.bonus_value; self.push_log(format!(" Trap damage +{}!", item.bonus_value)); } LootBonus::HeatReduction => { self.player.heat_reduction_bonus += item.bonus_value; self.push_log(format!(" Heat reduction +{}!", item.bonus_value)); } LootBonus::ResearchBonus => { self.player.research_bonus += item.bonus_value; self.push_log(format!(" Research bonus +{}!", item.bonus_value)); } LootBonus::PowerBonus => { self.player.power_bonus += item.bonus_value; self.push_log(format!(" Power bonus +{}!", item.bonus_value)); } } }
/// Buy the research unlock at `index` (index into `tiles::research_unlocks()`). pub fn buy_research(&mut self, index: usize) { let unlocks = tiles::research_unlocks(); if index >= unlocks.len() { return; } let (key, cost, name) = unlocks[index]; if self.player.unlocked.contains(key) { self.push_log("Already researched!"); return; } if self.player.research < cost { self.push_log(format!( "Not enough research points! Need {}, have {}", cost, self.player.research )); return; } self.player.research -= cost; self.player.unlocked.insert(key.to_string());
match key { "power_core_mk2" => self.player.power_core_mk2 = true, "minion_armor" => { for minion in &mut self.minions { minion.armor += 5; minion.entity.max_hp += 15; minion.entity.hp = minion.entity.max_hp; } self.player.minion_hp_bonus += 15; } "minion_weapons" => { for minion in &mut self.minions { minion.entity.damage += 5; } self.player.minion_damage_bonus += 5; } _ => {} }
self.push_log(format!("Researched: {name}!")); self.player.update_powers(); }
/// Activate the superpower at `index` in the player's power list. pub fn activate_power(&mut self, index: usize) { if index >= self.player.powers.len() { return; } if !self.player.powers[index].is_ready() { self.push_log(format!( "{} is on cooldown ({}t)", self.player.powers[index].name, self.player.powers[index].current_cooldown )); return; }
let power = self.player.powers[index].clone(); let messages = self.combat.activate_power( &power, &mut self.player, &mut self.agents, &mut self.minions, &self.map, ); self.player.powers[index].use_power(); for msg in messages { self.push_log(msg); } self.apply_kill_rewards(); }
// ── Save / load ────────────────────────────────────────────────────────
pub fn create_save_state(&self) -> SaveState { let mut map_tiles = Vec::new(); for y in 0..self.map.height { for x in 0..self.map.width { map_tiles.push(self.map.get_tile(x, y)); } } SaveState { player: self.player.clone(), minions: self.minions.clone(), henchmen: self.henchmen.clone(), map_width: self.map.width, map_height: self.map.height, map_tiles, map_powered: self.map.powered.clone(), map_throne: self.map.throne_pos().unwrap_or((40, 20)), map_entries: self.map.entry_positions(), wave: self.player.wave, phase: if self.game_over { "GameOver" } else { "Running" } .to_string(), sim_tick: self.tick, next_raid_in: self.next_raid_in, } }
pub fn apply_save_state(&mut self, state: SaveState) { self.player = state.player; self.minions = state.minions; self.henchmen = state.henchmen; self.game_over = state.phase == "GameOver"; self.map = GameMap::from_tiles( state.map_width, state.map_height, state.map_tiles, state.map_powered, state.map_throne, state.map_entries, ); // Raids in progress are not persisted; the raiders withdraw on load. self.agents = Vec::new(); self.raid = None; self.tick = state.sim_tick; if state.next_raid_in > 0 { self.next_raid_in = state.next_raid_in; } else { self.schedule_next_raid(); } self.recompute_derived(); }}
impl Default for Sim { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*;
/// Advance n ticks. fn run(sim: &mut Sim, n: u64) { for _ in 0..n { sim.advance(); } }
#[test] fn new_sim_starts_at_peace() { let sim = Sim::new(); assert_eq!(sim.tick, 0); assert!(!sim.raid_active()); assert!(!sim.game_over); assert_eq!(sim.threat(), Threat::Cold); }
#[test] fn economy_accrues_on_the_clock() { let mut sim = Sim::new(); let gold = sim.player.gold; // Workers exist at start, so an economy tick must produce income. run(&mut sim, ECONOMY_INTERVAL); assert!(sim.player.gold > gold, "workers should generate gold"); assert!(sim.player.gold_income > 0); }
#[test] fn no_economy_between_intervals() { let mut sim = Sim::new(); run(&mut sim, ECONOMY_INTERVAL); // land on an accrual tick let gold = sim.player.gold; run(&mut sim, ECONOMY_INTERVAL - 1); assert_eq!(sim.player.gold, gold, "income only lands on the interval"); }
#[test] fn low_heat_means_no_raid_countdown() { let mut sim = Sim::new(); sim.player.heat = 0; let before = sim.next_raid_in; run(&mut sim, 100); assert_eq!(sim.next_raid_in, before, "cold lair should not be hunted"); assert!(!sim.raid_active()); }
#[test] fn heat_drives_raid_countdown_and_spawn() { let mut sim = Sim::new(); sim.player.heat = 100; sim.player.notoriety = 50; sim.next_raid_in = 5; run(&mut sim, 5); assert!( sim.raid_active(), "countdown at high heat must trigger a raid" ); assert!(!sim.agents.is_empty(), "raid should spawn agents"); assert_eq!(sim.player.wave, 1); }
#[test] fn hotter_lair_counts_down_faster() { let mut sim = Sim::new(); sim.player.heat = 100; // step = 1 + 100/50 = 3 sim.next_raid_in = 300; run(&mut sim, 10); assert_eq!(sim.next_raid_in, 270);
let mut cool = Sim::new(); cool.player.heat = 20; // step = 1 cool.next_raid_in = 300; run(&mut cool, 10); assert_eq!(cool.next_raid_in, 290); }
#[test] fn repelling_a_raid_reschedules_the_next() { let mut sim = Sim::new(); sim.player.heat = 60; sim.next_raid_in = 1; run(&mut sim, 1); assert!(sim.raid_active());
// Slaughter the raiders off-screen. for agent in &mut sim.agents { agent.entity.alive = false; agent.entity.hp = 0; } // Let the announcement pass and one combat turn resolve. run(&mut sim, 10); assert!(!sim.raid_active(), "raid should end when all agents die"); assert!( sim.next_raid_in >= RAID_COUNTDOWN_MIN, "next raid rescheduled" ); }
#[test] fn schemes_tick_on_the_clock_and_pay_out() { let mut sim = Sim::new(); let gold = sim.player.gold; let heat = sim.player.heat; assert!(sim.launch_scheme(0), "starting minions can run scheme 0"); let schemes = tiles::available_schemes(); let duration = schemes[0].duration_turns as u64;
run(&mut sim, SLOW_INTERVAL * duration); assert!( sim.player.active_schemes.is_empty(), "scheme should complete" ); assert!( sim.player.gold >= gold + schemes[0].gold_reward, "scheme gold should be paid" ); assert!(sim.player.heat > heat, "schemes generate heat"); assert!(sim.player.notoriety > 0, "schemes generate notoriety"); }
#[test] fn launching_a_scheme_removes_minions() { let mut sim = Sim::new(); let before = sim.minions.len(); assert!(sim.launch_scheme(0)); let cost = tiles::available_schemes()[0].minion_cost as usize; assert_eq!(sim.minions.len(), before - cost); }
#[test] fn scheme_fails_without_minions() { let mut sim = Sim::new(); sim.minions.clear(); assert!(!sim.launch_scheme(0)); assert!(sim.player.active_schemes.is_empty()); }
#[test] fn heat_decays_over_time() { let mut sim = Sim::new(); sim.player.heat = 50; run(&mut sim, SLOW_INTERVAL * 3); assert!( sim.player.heat < 50, "heat should decay on the slow cadence" ); }
#[test] fn game_over_freezes_the_clock() { let mut sim = Sim::new(); sim.game_over = true; let tick = sim.tick; run(&mut sim, 10); assert_eq!(sim.tick, tick); }
#[test] fn save_roundtrip_preserves_clock_and_threat() { let mut sim = Sim::new(); sim.player.heat = 42; run(&mut sim, 137); let state = sim.create_save_state();
let mut restored = Sim::new(); restored.apply_save_state(state); assert_eq!(restored.tick, sim.tick); assert_eq!(restored.next_raid_in, sim.next_raid_in); assert_eq!(restored.player.heat, sim.player.heat); assert!(!restored.raid_active(), "raids are not persisted"); }
#[test] fn threat_reporting_matches_state() { let mut sim = Sim::new(); assert_eq!(sim.threat(), Threat::Cold); sim.player.heat = 30; sim.next_raid_in = 500; assert_eq!(sim.threat(), Threat::Watched { ticks_left: 500 }); }}