From 7f6cf5bfe1c1304020ce608881f87f5bcdaa1984 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 5 Jul 2026 21:19:20 -0700 Subject: [PATCH] Replace wave loop with continuous simulation: heat-driven raids, sim core in lib Milestone 1 of DESIGN.md (Evil Genius x Dwarf Fortress direction): - New renderer-agnostic Sim in src/sim.rs: fixed-tick clock drives economy (every 20 ticks), schemes and heat decay (every 40), and raid combat (every tick). No more build/defend phase split. - Raids are now consequences, not a SPACE-bar treadmill: heat >= 10 starts a countdown (hotter burns faster), notoriety + heat scale raid strength, intel warning fires before arrival, repelling a raid vents heat. - Schemes tick on the clock instead of on wave-clear. - Terminal frontend rewritten as a thin view: SPACE/p pause time, +/- set speed, powers usable any time, world map is a modal, tiny-terminal guard. - Deleted legacy src/main.rs + src/input.rs + src/ui.rs (superseded by src/bin/terminal/). - Save format v1 extended with SIM line (tick, raid countdown), backward compatible with old saves. - 130 tests (19 new for sim), clippy clean including pre-existing warnings. --- DESIGN.md | 173 ++++++ README.md | 40 +- src/bin/bevy.rs | 2 + src/bin/terminal/input.rs | 48 +- src/bin/terminal/mod.rs | 1082 ++++++++----------------------------- src/bin/terminal/ui.rs | 41 +- src/combat.rs | 27 +- src/input.rs | 136 ----- src/lib.rs | 1 + src/main.rs | 927 ------------------------------- src/save.rs | 24 +- src/sim.rs | 956 ++++++++++++++++++++++++++++++++ src/ui.rs | 867 ----------------------------- 13 files changed, 1445 insertions(+), 2879 deletions(-) create mode 100644 DESIGN.md delete mode 100644 src/input.rs delete mode 100644 src/main.rs create mode 100644 src/sim.rs delete mode 100644 src/ui.rs diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..68e621e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,173 @@ +# Supervillain — Vision & Design Notes + +*Working draft, started 2026-07-05. This is the "what are we actually building" document. +Open questions are marked **[OPEN]** and get resolved into decisions as we make them.* + +## The pitch + +**Evil Genius meets Dwarf Fortress.** You are a supervillain growing a persistent +lair-empire over a long horizon. The fantasy is Bond-villain logistics: carve the +volcano, staff it with minions, run schemes against the outside world, and deal with +the consequences when the world pushes back. + +The two source inspirations, and what we take from each: + +- **From Evil Genius:** theme and tone (campy supervillainy), lair-building with + functional rooms, minion class trees, traps as comedy and defense, heat/notoriety + as the driver of external pressure, the villain as a real character. +- **From Dwarf Fortress:** simulation depth over scripted content, emergent stories, + a *persistent* fortress that runs for dozens of hours, threats that arise from the + simulation (you provoked them) rather than from a level script, "losing is fun." + +## Why this game (the gap it fills) + +Evil Genius was level/campaign-based. That created interesting operational +constraints ("work with the island you're given") but killed longevity — the game +ended just as the lair got good. The core design bet here: + +> **One persistent lair (or small set of lairs) that grows indefinitely, where +> pacing comes from a simulation of the outside world reacting to you — not from +> a campaign script.** + +Operational constraints don't have to come from levels. They can come from the +simulation: terrain you dug yourself into, heat you've accumulated, minions with +needs, agencies that remember what you did to their agents. + +## Design pillars + +1. **Persistent, free-form growth.** No level gates, no campaign end. The lair, the + villain, and the world state all persist and compound. +2. **The villain is a character you build.** Customizable and growable — not a + disembodied cursor. Powers, reputation, style. (Exact shape **[OPEN]**, see below.) +3. **Simulation-first threat.** The world responds to what you do. Pressure is + earned, not scheduled. +4. **Beautiful, readable presentation.** Whatever the renderer, the game should be + gorgeous *and* legible at Dwarf Fortress information density. (2D vs 3D **[OPEN]**.) +5. **Rust, simulation core decoupled from renderer.** Already true today (lib crate + + terminal frontend + Bevy frontend) and worth protecting — it keeps the + terminal build as a fast dev/debug harness and keeps multiplayer options open. + +## Where the codebase is today (2026-07-05) + +~9k lines. A complete, playable **wave-defense roguelike**: build phase → press +SPACE → agents raid the lair → repeat. Two frontends over a shared lib. + +| System | State | Carries forward? | +|---|---|---| +| Tiles/buildings (~20 types: traps, barracks, labs, vault, security doors, fake entries, hotel, freezer...) | Solid | Yes — this is the lair-building foundation | +| Research tree | Working | Yes, wants expansion into villain/scheme tech | +| Superpowers (9 abilities, cooldowns, research unlocks) | Working | Yes — seed of the "growable villain" | +| Minion classes + class-specific AI (technicians repair traps, etc.) | Working | Yes — seed of DF-style autonomous agents | +| Agent (hero) AI with trap memory, elite variants | Working | Yes — seed of "the world remembers" | +| Mid-wave events (8 random events) | Working | Yes, generalizes into world events | +| Save/load | Working | Yes, will need versioning as sim state grows | +| **Wave loop / difficulty scaling** | Working | **No — this is the part the vision replaces.** Waves become *consequences* (investigations, raids) triggered by heat, not a SPACE-bar treadmill | +| Terminal frontend (crossterm) | Complete | Keep as dev harness | +| Bevy 0.18 frontend (2D colored tiles, ~1.4k lines) | Prototype | Foundation for the "beautiful" version | + +Housekeeping note: ~~`src/main.rs` legacy binary~~ — verified and deleted 2026-07-05 +(along with its private `src/input.rs` / `src/ui.rs`). + +## The big loop (target shape) + +Replace *build → wave → build* with a continuous simulation: + +1. **Grow** — dig, build, recruit, research. Minions have jobs and (eventually) needs. +2. **Scheme** — run operations against the outside world: heists, kidnappings, + doomsday-device milestones. This is where money/notoriety come from. Schemes are + the *outbound* half Evil Genius had and tower defense lacks. +3. **Heat** — schemes and atrocities raise attention with agencies/factions. Heat + decays slowly, can be laundered, and is *legible* to the player. +4. **Consequence** — agencies escalate: lone investigators, infiltrators, strike + teams, sieges. The existing agent AI and trap systems already model this end well. +5. Repeat, forever, with the villain and the world both accumulating history. + +## The villain (decided: all four layers, phased) + +The villain is the through-line of the whole game, built in four layers: + +1. **Chargen — origin & archetype.** Start of a run: origin story, archetype + (mastermind / brute / mad scientist / occultist / ...), starting powers and + weaknesses. Archetype should bias everything downstream — available research, + minion types, scheme options, how agencies profile you. +2. **Avatar — power/skill tree.** The villain is a physical unit in the lair who + levels. The existing 9-superpower system is the seed; grows into a real tree. + Personally confronting intruders is a valid (risky) strategy. +3. **Organization — infamy & doctrine.** Global reputation, faction standings, + doctrine choices that shape minions and schemes. The villain as brand. +4. **Style — cosmetic identity.** Costume, lair aesthetic, calling cards; the world + *reacts* to style (press coverage, agency codenames for you). Flavor layer that + makes the other three feel like a Bond villain and not a spreadsheet. + +Rough phasing: avatar powers already exist (grow opportunistically); chargen comes +with the first progression rework; infamy lands alongside the heat/scheme layer +(they share the reputation substrate); style rides the Bevy art push. + +## Roadmap + +### Milestone 1 — The new heartbeat (terminal-first) +Prove the persistent loop where iteration is fastest. Playable end state: *"I ran +schemes for an hour, heat crept up, an investigator poked around, I got greedy, a +strike team hit my lair — and I never pressed SPACE."* + +- **Continuous clock** — DONE 2026-07-05. `Sim` in `src/sim.rs` (renderer-agnostic, + in the lib) advances on a fixed tick; economy every 20 ticks, schemes/heat every + 40, combat every tick during raids. SPACE now pauses time; +/- scales it. The + build/defend phase split is gone. +- **Heat-triggered raids** — DONE 2026-07-05 (v1). Heat >= 10 starts a countdown + (hotter = faster); raids spawn as consequences, strength scaled by notoriety + + heat, warning message before arrival. Repelling a raid vents heat. Still one + global "agency" — the per-agency escalation ladder (investigator → infiltrator → + strike team → siege) is the next iteration. +- **World map + scheme dispatch** — inherited a v1 (9 schemes, regions, loot); + now ticks on the clock instead of on wave-clear. Wants: scheme risk/failure, + minions that *return* after schemes (currently consumed), doomsday milestones. +- **Minion needs & morale (middle depth):** rest, food, pay-or-fear, morale, simple + traits. Unhappy minions slack, desert, or turn informant (feeding agency heat). + Deep enough to generate stories; no tantrum-spiral sim yet. — NOT STARTED. + +### Milestone 2 — The villain +- Chargen: origin, archetype, signature weakness; archetype modifiers flow into + research, schemes, powers, and how agencies profile you. +- Infamy/doctrine substrate (shares plumbing with heat/reputation). +- Grow the avatar power tree from the existing superpowers system. + +### Milestone 3 — Make it beautiful (Bevy) +- Tileset, sprites, animation, lighting, camera/zoom, real UI. Stylized 2D/2.5D. +- Style layer of the villain (costume, lair aesthetic, world reactions) rides here. + +### Milestone 4 — Shared overworld (later, design-for-now) +- Async multiplayer: each player owns their deterministic lair sim; a shared world + map mediates operations, sabotage, and joint schemes. Scheme dispatch (M1) is + deliberately the seam this plugs into. + +### Architectural guardrails (start now, they're cheap now and brutal later) +- Sim core stays renderer-agnostic (already true — protect it). +- Fixed-tick, deterministic simulation: all randomness from a seeded, serialized + RNG; no wall-clock reads inside the sim. +- Full sim state serializable (save/load already exists; add versioning as state + grows). Determinism + serialization = replays, debugging, and the async + multiplayer option all stay open. + +## Decisions log + +- **2026-07-05 — Renderer: stylized Bevy 2D/2.5D.** Invest in the existing Bevy + frontend (sprites, lighting, animation). Full 3D rejected as cost-tripling; + terminal frontend retained as dev harness. +- **2026-07-05 — Loop: hybrid pivot.** Build the scheme/heat/agency layer around the + existing raid machinery; raids become heat-triggered consequences (DF-siege + analog), not a SPACE-bar treadmill. No throwaway rewrite. +- **2026-07-05 — Multiplayer: async shared world, later.** Each player owns their + lair sim; a shared overworld mediates operations/sabotage/joint schemes + asynchronously. Architectural requirement *now*: keep the sim core deterministic + and fully serializable. No real-time co-op. +- **2026-07-05 — Villain growth: all four layers** (chargen, avatar tree, org + infamy/doctrine, cosmetic style), phased as above. +- **2026-07-05 — Time model: continuous real-time-with-pause,** fixed-tick. + Build/defend phase split retired in Milestone 1. +- **2026-07-05 — Schemes: abstract world-map dispatch** (Evil Genius style). + Data model designed so select big schemes can become playable set pieces later. +- **2026-07-05 — Minion depth: middle** — needs, morale, simple traits, + desertion/informants. Full DF inner lives deferred, not rejected. +- **2026-07-05 — First milestone: heat + schemes in terminal.** Prove the loop in + ASCII before the art push. diff --git a/README.md b/README.md index 8742f09..0eaedfd 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ # Supervillain -A roguelike game where you design and build your evil lair, then defend it against waves of invading heroes. +A persistent lair-building simulation — Evil Genius meets Dwarf Fortress. Build an underground lair, run schemes against the outside world, and survive the raids your infamy provokes. ## Gameplay -You are the **Overlord** — a supervillain building an underground lair. The game alternates between two phases: +You are the **Supervillain**. Time flows continuously (pause with `SPACE`); there are no levels, waves, or phases — everything runs on one clock: -### Build Phase -- **Move** with `hjkl` or arrow keys to explore your lair -- Press `b` to enter **Build Mode** — place rooms, traps, and facilities -- Press `r` to open the **Research Menu** — unlock new technologies -- Press `SPACE` to start the next wave of heroes +- **Build** — dig corridors, place traps and facilities (`b`), research technology (`r`), hire minions (`f`) +- **Scheme** — open the world map (`m`) and dispatch minions on heists, sabotage, and propaganda. Schemes pay out gold, loot, and notoriety while the minions are away +- **Heat** — schemes and violence raise heat; agencies notice. Hotels and freezers launder it back down +- **Raids** — when heat stays high, agency forces raid the lair, scaled by your notoriety. Traps, minions, and henchmen fight them off. If agents reach your throne, you take damage; at 0 HP the lair falls -### Defend Phase -- Watch your traps and minions fight off invading heroes -- Heroes pathfind toward your **Throne Room** — if they reach it, you take damage -- If the Overlord's HP reaches 0, it's game over +The threat readout in the sidebar shows when the next raid is coming. Raids are consequences, not a schedule — a quiet lair is left alone. -## Build Mode Controls +## Controls + +| Key | Action | +|-----|--------| +| `hjkl` / arrows | Move the Supervillain | +| `SPACE` / `p` | Pause / resume time | +| `+` / `-` | Simulation speed | +| `b` | Build mode | +| `r` | Research menu | +| `f` | Hire minions | +| `m` | World map / schemes | +| `1`-`9` | Activate superpowers | +| `Ctrl+S` / `Ctrl+L` | Save / load | +| `q` | Quit | + +### Build Mode | Key | Action | |-----|--------| @@ -73,6 +84,7 @@ cargo build --release - Build **Power Cores** first — traps don't work without power - Create **chokepoints** with traps along corridors -- **Guard Posts** auto-spawn minions that fight heroes +- **Guard Posts** auto-spawn minions that fight agents - Use **Research Labs** to unlock advanced traps and upgrades -- Heroes get stronger each wave — plan ahead! +- Schemes are the economy — but every scheme heats you up. Pace yourself, or build heat-laundering facilities before going loud +- Raid strength scales with **notoriety**: fame is power (it unlocks superpowers) and a target on your back diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index df4e8ed..5873ef6 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -1357,6 +1357,8 @@ fn create_save_state(game_state: &GameState) -> supervillain::save::SaveState { map_entries: gs.game_map.entry_positions(), wave: gs.player.wave, phase: format!("{:?}", gs.phase), + sim_tick: 0, + next_raid_in: -1, } } diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index 208b775..c1762cc 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -23,9 +23,7 @@ pub enum Command { ResearchDown, ResearchBuy, ResearchClose, - NextWave, - Wait, - // Defend phase controls + // Time controls SpeedUp, SpeedDown, TogglePause, @@ -44,15 +42,7 @@ pub enum Command { HireSelect, HireClose, // Superpowers - ActivatePower1, - ActivatePower2, - ActivatePower3, - ActivatePower4, - ActivatePower5, - ActivatePower6, - ActivatePower7, - ActivatePower8, - ActivatePower9, + ActivatePower(usize), // Save/Load SaveGame, LoadGame, @@ -62,7 +52,6 @@ pub fn handle_key( key: KeyEvent, build_mode: bool, research_menu: bool, - defend_phase: bool, world_map: bool, hire_menu: bool, ) -> Option { @@ -120,28 +109,7 @@ pub fn handle_key( }; } - // Defend phase keys (limited controls) - if defend_phase { - return match key.code { - KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), - KeyCode::Char('-') => Some(Command::SpeedDown), - KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), - KeyCode::Char('1') => Some(Command::ActivatePower1), - KeyCode::Char('2') => Some(Command::ActivatePower2), - KeyCode::Char('3') => Some(Command::ActivatePower3), - KeyCode::Char('4') => Some(Command::ActivatePower4), - KeyCode::Char('5') => Some(Command::ActivatePower5), - KeyCode::Char('6') => Some(Command::ActivatePower6), - KeyCode::Char('7') => Some(Command::ActivatePower7), - KeyCode::Char('8') => Some(Command::ActivatePower8), - KeyCode::Char('9') => Some(Command::ActivatePower9), - KeyCode::Char('q') => Some(Command::Quit), - _ => None, - }; - } - - // Normal (build) mode keys - // Check for Ctrl modifiers first + // Save/load with Ctrl if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { return match key.code { KeyCode::Char('s') => Some(Command::SaveGame), @@ -150,6 +118,8 @@ pub fn handle_key( }; } + // Normal keys — time always flows, so movement, powers, and time + // controls are all available together. match key.code { KeyCode::Up | KeyCode::Char('k') => Some(Command::MoveUp), KeyCode::Down | KeyCode::Char('j') => Some(Command::MoveDown), @@ -159,8 +129,12 @@ pub fn handle_key( KeyCode::Char('r') => Some(Command::OpenResearch), KeyCode::Char('f') => Some(Command::OpenHireMenu), KeyCode::Char('m') => Some(Command::OpenWorldMap), - KeyCode::Char(' ') => Some(Command::NextWave), - KeyCode::Char('.') => Some(Command::Wait), + KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), + KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), + KeyCode::Char('-') => Some(Command::SpeedDown), + KeyCode::Char(c @ '1'..='9') => { + Some(Command::ActivatePower(c as usize - '1' as usize)) + } KeyCode::Char('q') => Some(Command::Quit), _ => Some(Command::AnyKey), } diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index 5cf2627..fdcb971 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -1,227 +1,82 @@ +//! Terminal frontend — a thin view/input layer over the simulation core. +//! +//! All game rules live in `supervillain::sim`. This binary owns only: +//! wall-clock-to-tick mapping (speed and pause), rendering, and menus. + mod input; mod ui; use std::io::{self, Write}; +use std::time::Instant; use crossterm::{ event::{self, Event}, execute, terminal::{self, ClearType}, }; -use supervillain::{build, combat, entities, map, tiles}; +use supervillain::build::{BuildMode, BuildResult}; +use supervillain::sim::{Sim, Threat}; +use supervillain::tiles; -use build::{BuildMode, BuildResult}; -use combat::CombatSystem; -use entities::{generate_agent_wave, Minion, MinionClass, Player}; use input::Command; -use map::GameMap; -use tiles::TileType; use ui::UI; #[derive(Debug, Clone, PartialEq)] -enum Phase { +enum Screen { Title, - Build, - Defend, - WorldMap, + Playing, GameOver, } -struct Game { - game_map: GameMap, - player: Player, - minions: Vec, - agents: Vec, - henchmen: Vec, - combat: CombatSystem, +struct App { + sim: Sim, ui: UI, - phase: Phase, + screen: Screen, build_mode: Option, research_menu: bool, research_selected: usize, - defend_turn: i32, - wave_announce_turns: i32, - defend_speed: u64, // ms per defend step - defend_paused: bool, - last_defend_step: std::time::Instant, + world_map: bool, world_map_selected: usize, hire_menu: bool, hire_selected: usize, + paused: bool, + /// Wall-clock milliseconds per simulation tick. + tick_ms: u64, + last_tick: Instant, } -impl Game { +impl App { fn new() -> Self { - let game_map = GameMap::new(80, 40); - let throne = game_map.throne_pos().unwrap_or((40, 20)); - let mut player = Player::new(throne.0, throne.1); - player.update_powers(); // Initialize available powers - - // Start with 3 workers near the throne (like Supervillain) - let mut starting_minions = Vec::new(); - for (dx, dy) in [(1, 0), (-1, 0), (0, 1)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if game_map.is_walkable(mx, my) { - starting_minions.push(Minion::new( - mx, my, MinionClass::Worker, 0, 0, - )); - } - } - Self { - game_map, - player, - minions: starting_minions, - agents: Vec::new(), - henchmen: entities::Henchman::starting_roster(throne.0, throne.1), - combat: CombatSystem::new(), + sim: Sim::new(), ui: UI::new(), - phase: Phase::Title, + screen: Screen::Title, build_mode: None, research_menu: false, research_selected: 0, - defend_turn: 0, - wave_announce_turns: 0, - defend_speed: 150, - defend_paused: false, - last_defend_step: std::time::Instant::now(), + world_map: false, world_map_selected: 0, hire_menu: false, hire_selected: 0, + paused: false, + tick_ms: 150, + last_tick: Instant::now(), } } - fn update_resources(&mut self) { - // Update power grid FIRST (power cores self-power via flood fill) - let cores = self.game_map.tiles_of_type(TileType::PowerCore); - self.game_map.update_power(&cores); - - // Calculate power generation and consumption - let mut power_gen = 0i32; - let mut power_use = 0i32; - let mut research_gen = 0i32; - let mut heat_reduction = 0i32; - - for &(_x, _y) in &cores { - power_gen += TileType::PowerCore.power_gen(self.player.power_core_mk2) + self.player.power_bonus; - } - - for y in 0..self.game_map.height { - for x in 0..self.game_map.width { - let tile = self.game_map.get_tile(x, y); - if self.game_map.powered.contains(&(x, y)) { - power_use += tile.power_usage(); - research_gen += tile.research_gen(); - heat_reduction += tile.heat_reduction(); - } - } - } - - // Scientist minions at labs generate bonus research - for minion in &self.minions { - research_gen += minion.research_bonus(); - } - - // Passive gold income: workers generate gold, vaults boost it - let worker_count = self.minions.iter() - .filter(|m| m.entity.alive && m.class == MinionClass::Worker) - .count() as i32; - let vault_count = self.game_map.tiles_of_type(TileType::Vault).len() as i32; - let gold_per_worker = 3 + vault_count; // Vaults increase worker efficiency - let gold_income = worker_count * gold_per_worker; - self.player.gold += gold_income; - self.player.gold_income = gold_income; - - self.player.power_cap = power_gen; - self.player.power = (power_gen - power_use).max(0); - self.player.research += research_gen + self.player.research_bonus; - - // Reduce heat from hotels/freezers - self.player.reduce_heat(heat_reduction + self.player.heat_reduction_bonus); - - // Update minion cap from barracks - let barracks_count = self.game_map.tiles_of_type(TileType::Barracks).len() as i32; - self.player.minion_cap = 3 + barracks_count * 2; - } - - fn hire_minion(&mut self) { - let classes = MinionClass::all(); - if self.hire_selected >= classes.len() { return; } - let class = classes[self.hire_selected]; - - // Check if unlocked - let unlock_key = class.unlock_key(); - if !self.player.unlocked.contains(unlock_key) { - self.ui.add_log(&format!("{} not researched yet!", class.name())); - return; - } - - let cost = class.hire_cost(); - if self.player.gold < cost { - self.ui.add_log(&format!("Need {} gold to hire {}!", cost, class.name())); - return; - } - - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions >= self.player.minion_cap { - self.ui.add_log(&format!("Minion cap reached! ({}/{})", alive_minions, self.player.minion_cap)); - return; - } - - // Place new minion near the throne - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - let mut placed = false; - for dist in 1..5 { - for (dx, dy) in [(0, -dist), (0, dist), (-dist, 0), (dist, 0)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if self.game_map.is_walkable(mx, my) { - self.player.gold -= cost; - let minion = Minion::new( - mx, my, class, - self.player.minion_damage_bonus, - self.player.minion_hp_bonus, - ); - self.minions.push(minion); - self.ui.add_log(&format!("Hired {} for {} gold!", class.name(), cost)); - placed = true; - break; - } - } - if placed { break; } - } - - if !placed { - self.ui.add_log("No room near throne to place minion!"); - } - } - - fn spawn_minion_at_guard_post(&mut self, x: i32, y: i32) { - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions >= self.player.minion_cap { - self.ui.add_log(&format!( - "Minion cap reached! ({}/{})", - alive_minions, self.player.minion_cap - )); - return; + fn drain_sim_log(&mut self) { + for msg in self.sim.drain_log() { + self.ui.add_log(&msg); } - - let minion = Minion::new( - x, - y, - MinionClass::Guard, - self.player.minion_damage_bonus, - self.player.minion_hp_bonus, - ); - self.minions.push(minion); - self.ui.add_log("Minion deployed at guard post!"); } + /// Returns true when the app should quit. fn handle_command(&mut self, cmd: Command) -> bool { match cmd { Command::Quit => return true, + Command::SaveGame => { - let state = self.create_save_state(); + let state = self.sim.create_save_state(); match supervillain::save::save_game(&state) { Ok(()) => self.ui.add_log("Game saved!"), Err(e) => self.ui.add_log(&format!("Save failed: {}", e)), @@ -231,7 +86,11 @@ impl Game { if supervillain::save::save_exists() { match supervillain::save::load_game() { Ok(state) => { - self.apply_save_state(state); + self.sim.apply_save_state(state); + self.build_mode = None; + self.research_menu = false; + self.hire_menu = false; + self.world_map = false; self.ui.add_log("Game loaded!"); } Err(e) => self.ui.add_log(&format!("Load failed: {}", e)), @@ -241,650 +100,244 @@ impl Game { } } + // Time controls + Command::TogglePause => { + self.paused = !self.paused; + self.ui + .add_log(if self.paused { "TIME PAUSED" } else { "Time resumes." }); + self.last_tick = Instant::now(); + } + Command::SpeedUp => { + self.tick_ms = self.tick_ms.saturating_sub(50).max(20); + self.ui.add_log(&format!("Speed: {}ms/tick", self.tick_ms)); + } + Command::SpeedDown => { + self.tick_ms = self.tick_ms.saturating_add(50).min(500); + self.ui.add_log(&format!("Speed: {}ms/tick", self.tick_ms)); + } + // Research menu Command::ResearchUp => { - if self.research_selected > 0 { - self.research_selected -= 1; - } + self.research_selected = self.research_selected.saturating_sub(1); } Command::ResearchDown => { let unlocks = tiles::research_unlocks(); - if self.research_selected < unlocks.len() - 1 { + if self.research_selected + 1 < unlocks.len() { self.research_selected += 1; } } Command::ResearchBuy => { - let unlocks = tiles::research_unlocks(); - if self.research_selected < unlocks.len() { - let (key, cost, name) = unlocks[self.research_selected]; - if self.player.unlocked.contains(key) { - self.ui.add_log("Already researched!"); - } else if self.player.research < cost { - self.ui.add_log(&format!( - "Not enough research points! Need {}, have {}", - cost, self.player.research - )); - } else { - self.player.research -= cost; - self.player.unlocked.insert(key.to_string()); - - // Apply special effects - 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.ui.add_log(&format!("Researched: {name}!")); - self.player.update_powers(); // Check if new powers unlocked - } - } - } - Command::ResearchClose => { - self.research_menu = false; + self.sim.buy_research(self.research_selected); } + Command::ResearchClose => self.research_menu = false, + Command::OpenResearch => self.research_menu = true, - // Build mode - handle separately to avoid borrow issues + // Build mode Command::EnterBuild => { - if self.phase == Phase::Build { - self.build_mode = Some(BuildMode::new(&self.player)); - } - } - Command::ExitBuild => { - self.build_mode = None; - } - Command::PlaceTile => { - self.place_build_tile(); + self.build_mode = Some(BuildMode::new(&self.sim.player)); } + Command::ExitBuild => self.build_mode = None, + Command::PlaceTile => self.place_build_tile(), Command::DemolishTile => { - self.demolish_build_tile(); - } - - // Movement and cursor commands - handle generically - _ => { - self.handle_movement_command(cmd); + if let Some(bm) = &mut self.build_mode { + let msg = bm.demolish_tile(&mut self.sim.map, &mut self.sim.player); + self.ui.add_log(&msg); + self.sim.recompute_derived(); + } } - } - - false - } - - fn handle_movement_command(&mut self, cmd: Command) { - match cmd { Command::CursorUp => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(0, -1, &self.game_map); + if let Some(bm) = &mut self.build_mode { + bm.move_cursor(0, -1, &self.sim.map); } } Command::CursorDown => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(0, 1, &self.game_map); + if let Some(bm) = &mut self.build_mode { + bm.move_cursor(0, 1, &self.sim.map); } } Command::CursorLeft => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(-1, 0, &self.game_map); + if let Some(bm) = &mut self.build_mode { + bm.move_cursor(-1, 0, &self.sim.map); } } Command::CursorRight => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(1, 0, &self.game_map); + if let Some(bm) = &mut self.build_mode { + bm.move_cursor(1, 0, &self.sim.map); } } Command::CycleCategory => { - if let Some(ref mut bm) = self.build_mode { - bm.select_category(1, &self.player); + if let Some(bm) = &mut self.build_mode { + bm.select_category(1, &self.sim.player); } } Command::CycleItemNext => { - if let Some(ref mut bm) = self.build_mode { - bm.select_item(1, &self.player); + if let Some(bm) = &mut self.build_mode { + bm.select_item(1, &self.sim.player); } } Command::CycleItemPrev => { - if let Some(ref mut bm) = self.build_mode { - bm.select_item(-1, &self.player); - } - } - Command::MoveUp => { - let ny = self.player.entity.y - 1; - if self.game_map.is_walkable(self.player.entity.x, ny) { - self.player.entity.y = ny; - } - } - Command::MoveDown => { - let ny = self.player.entity.y + 1; - if self.game_map.is_walkable(self.player.entity.x, ny) { - self.player.entity.y = ny; + if let Some(bm) = &mut self.build_mode { + bm.select_item(-1, &self.sim.player); } } - Command::MoveLeft => { - let nx = self.player.entity.x - 1; - if self.game_map.is_walkable(nx, self.player.entity.y) { - self.player.entity.x = nx; - } - } - Command::MoveRight => { - let nx = self.player.entity.x + 1; - if self.game_map.is_walkable(nx, self.player.entity.y) { - self.player.entity.x = nx; - } - } - Command::OpenResearch => { - if self.phase == Phase::Build { - self.research_menu = true; - } - } - Command::NextWave => { - if self.phase == Phase::Build { - self.start_wave(); - } - } - Command::Wait => {} - Command::SpeedUp => { - self.defend_speed = self.defend_speed.saturating_sub(50).max(20); - self.ui.add_log(&format!("Speed: {}ms", self.defend_speed)); - } - Command::SpeedDown => { - self.defend_speed = self.defend_speed.saturating_add(50).min(500); - self.ui.add_log(&format!("Speed: {}ms", self.defend_speed)); - } - Command::TogglePause => { - self.defend_paused = !self.defend_paused; - self.ui.add_log(if self.defend_paused { "PAUSED" } else { "RESUMED" }); - } - Command::AnyKey => {} + + // Villain movement + Command::MoveUp => self.sim.move_player(0, -1), + Command::MoveDown => self.sim.move_player(0, 1), + Command::MoveLeft => self.sim.move_player(-1, 0), + Command::MoveRight => self.sim.move_player(1, 0), + + // World map / schemes Command::OpenWorldMap => { - if self.phase == Phase::Build { - self.phase = Phase::WorldMap; - self.world_map_selected = 0; - } + self.world_map = true; + self.world_map_selected = 0; } + Command::WorldMapClose => self.world_map = false, Command::WorldMapUp => { - if self.world_map_selected > 0 { - self.world_map_selected -= 1; - } + self.world_map_selected = self.world_map_selected.saturating_sub(1); } Command::WorldMapDown => { let schemes = tiles::available_schemes(); - if self.world_map_selected < schemes.len() - 1 { + if self.world_map_selected + 1 < schemes.len() { self.world_map_selected += 1; } } Command::WorldMapSelect => { - self.launch_scheme(self.world_map_selected); - } - Command::WorldMapClose => { - self.phase = Phase::Build; + if self.sim.launch_scheme(self.world_map_selected) { + self.world_map = false; + } } + + // Hiring Command::OpenHireMenu => { - if self.phase == Phase::Build { - self.hire_menu = true; - self.hire_selected = 0; - } + self.hire_menu = true; + self.hire_selected = 0; } + Command::HireClose => self.hire_menu = false, Command::HireUp => { - if self.hire_selected > 0 { - self.hire_selected -= 1; - } + self.hire_selected = self.hire_selected.saturating_sub(1); } Command::HireDown => { - let classes = MinionClass::all(); - if self.hire_selected < classes.len() - 1 { + let classes = supervillain::entities::MinionClass::all(); + if self.hire_selected + 1 < classes.len() { self.hire_selected += 1; } } - Command::HireSelect => { - self.hire_minion(); - } - Command::HireClose => { - self.hire_menu = false; - } - // Superpowers — activate by number key during defend phase - cmd @ (Command::ActivatePower1 - | Command::ActivatePower2 - | Command::ActivatePower3 - | Command::ActivatePower4 - | Command::ActivatePower5 - | Command::ActivatePower6 - | Command::ActivatePower7 - | Command::ActivatePower8 - | Command::ActivatePower9) => { - let index = match cmd { - Command::ActivatePower1 => 0, - Command::ActivatePower2 => 1, - Command::ActivatePower3 => 2, - Command::ActivatePower4 => 3, - Command::ActivatePower5 => 4, - Command::ActivatePower6 => 5, - Command::ActivatePower7 => 6, - Command::ActivatePower8 => 7, - Command::ActivatePower9 => 8, - _ => 0, - }; - self.activate_power(index); - } - _ => {} - } - } + Command::HireSelect => self.sim.hire_minion(self.hire_selected), - fn place_build_tile(&mut self) { - // Extract build mode data, process, then put back - let result = if let Some(ref mut bm) = self.build_mode { - let result = bm.place_tile(&mut self.game_map, &mut self.player); - let msg = bm.message.clone(); - let cursor_x = bm.cursor_x; - let cursor_y = bm.cursor_y; - (result, msg, cursor_x, cursor_y) - } else { - return; - }; + // Superpowers + Command::ActivatePower(index) => self.sim.activate_power(index), - match result.0 { - BuildResult::GuardPostPlaced => { - self.ui.add_log(&result.1); - self.spawn_minion_at_guard_post(result.2, result.3); - } - BuildResult::Success => { - self.ui.add_log(&result.1); - } - BuildResult::None => { - self.ui.add_log(&result.1); - } - } - self.update_resources(); - } - - fn demolish_build_tile(&mut self) { - if let Some(ref mut bm) = self.build_mode { - let msg = bm.demolish_tile(&mut self.game_map, &mut self.player); - self.ui.add_log(&msg); - self.update_resources(); - } - } - - fn start_wave(&mut self) { - self.player.wave += 1; - self.phase = Phase::Defend; - self.defend_turn = 0; - self.wave_announce_turns = 5; - self.last_defend_step = std::time::Instant::now(); - - let entries = self.game_map.entry_positions(); - let new_agents = generate_agent_wave(self.player.wave, self.player.heat, &entries); - self.agents = new_agents; - - // Calculate initial paths for all agents - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - let agent_positions: Vec<(i32, i32)> = self.agents.iter().map(|a| (a.entity.x, a.entity.y)).collect(); - for (i, agent) in self.agents.iter_mut().enumerate() { - let target = agent.preferred_target(&self.game_map, throne); - let avoid: std::collections::HashSet<(i32, i32)> = agent_positions - .iter() - .enumerate() - .filter(|(j, _)| *j != i) - .map(|(_, pos)| *pos) - .collect(); - agent.calculate_path(&self.game_map, target, &avoid); + Command::AnyKey => {} } - self.ui.add_log(&format!("=== WAVE {} INCOMING! ===", self.player.wave)); - self.ui.add_log(&format!( - "{} agents approach the lair!", - self.agents.iter().filter(|a| a.entity.alive).count() - )); - - // Spawn minions at guard posts - let guard_posts = self.game_map.tiles_of_type(TileType::GuardPost); - for &(gx, gy) in &guard_posts { - let already_has_minion = self - .minions - .iter() - .any(|m| m.entity.alive && m.home_x == gx && m.home_y == gy); - if !already_has_minion { - self.spawn_minion_at_guard_post(gx, gy); - } - } + self.drain_sim_log(); + false } - fn defend_step(&mut self) { - if self.wave_announce_turns > 0 { - self.wave_announce_turns -= 1; - return; - } - - self.defend_turn += 1; - - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - - // Check for mid-wave events - if let Some(event_type) = supervillain::events::check_for_event(self.player.wave, self.defend_turn) { - let entries = self.game_map.entry_positions(); - let event = supervillain::events::process_event( - event_type, - &mut self.player, - &mut self.agents, - &mut self.minions, - &mut self.game_map, - &entries, - ); - self.ui.add_log(&format!("⚡ EVENT: {}", event.name)); - self.ui.add_log(&event.description); - } - - // Process agent turn - let throne_reached = self.combat.process_agent_turn( - &mut self.game_map, - &mut self.player, - &mut self.agents, - &mut self.minions, - &mut self.henchmen, - throne, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply hero-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - // Killing agents increases notoriety - 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, - }); - } - - // Apply heat events from agent actions - for event in &self.combat.heat_events.clone() { - self.player.add_heat(event.heat_change); - self.ui.add_log(&format!("Heat +{} ({})", event.heat_change, event.reason)); - } - - if throne_reached { - self.player.entity.take_damage(15); - self.ui.add_log("The Supervillain takes 15 damage!"); - if !self.player.entity.alive { - self.phase = Phase::GameOver; - return; - } - } - - // Process minion turn - self.combat.process_minion_turn( - &self.game_map, - &mut self.minions, - &mut self.agents, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply minion-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - } - - // Process henchmen turn - self.combat.process_henchmen_turn( - &self.game_map, - &mut self.henchmen, - &mut self.agents, - &mut self.minions, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply henchmen-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - } - - // Technician repairs - self.combat.process_technician_repairs(&mut self.game_map, &mut self.minions); - - // Medbay healing - self.combat.process_medbay_healing(&self.game_map, &mut self.minions); - - // Training room - self.combat.process_training_room(&self.game_map, &mut self.minions); - - // Cleanup - self.combat.cleanup_dead(&mut self.minions, &mut self.agents, &mut self.henchmen); - - // Tick power cooldowns - self.player.tick_power_cooldowns(); - - // Check if wave is over - if self.combat.all_agents_dead(&self.agents) { - self.ui.add_log(&format!("=== WAVE {} CLEARED! ===", self.player.wave)); - self.phase = Phase::Build; - - // Bonus gold for clearing - let bonus = self.player.wave * 10; - self.player.gold += bonus; - self.ui.add_log(&format!("Wave bonus: +{bonus} gold")); - - // Notoriety bonus for clearing - self.player.add_notoriety(self.player.wave * 2); - - // Natural heat decay between waves - self.player.reduce_heat(5); - - // Combo bonus - let combo = self.combat.combo_bonus(); - if combo > 0 { - self.player.gold += combo; - self.ui.add_log(&format!("Trap combo bonus: +{combo} gold!")); - } - - // Process active schemes - self.tick_schemes(); + fn place_build_tile(&mut self) { + let Some(bm) = &mut self.build_mode else { return }; + let result = bm.place_tile(&mut self.sim.map, &mut self.sim.player); + let msg = bm.message.clone(); + let (cx, cy) = (bm.cursor_x, bm.cursor_y); - // Heal the Supervillain a bit - self.player.entity.heal(10); + self.ui.add_log(&msg); + if result == BuildResult::GuardPostPlaced { + self.sim.spawn_minion_at_guard_post(cx, cy); } + self.sim.recompute_derived(); } - 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()); - } - } - - 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.ui.add_log(&format!("Scheme complete: {}!", scheme.name)); - self.ui.add_log(&format!(" +{} gold, +{} notoriety", scheme.gold_reward, scheme.notoriety_reward)); - if let Some(ref loot_name) = scheme.loot { - self.player.loot.push(loot_name.clone()); - self.ui.add_log(&format!(" Acquired: {loot_name}!")); - // Apply loot bonuses - let loot_items = tiles::loot_items(); - if let Some(loot_item) = loot_items.iter().find(|l| l.name == loot_name) { - use tiles::LootBonus; - match loot_item.bonus_type { - LootBonus::MinionDamage => { - self.player.minion_damage_bonus += loot_item.bonus_value; - for minion in &mut self.minions { - minion.entity.damage += loot_item.bonus_value; - } - self.ui.add_log(&format!(" Minion damage +{}!", loot_item.bonus_value)); - } - LootBonus::MinionHP => { - self.player.minion_hp_bonus += loot_item.bonus_value; - for minion in &mut self.minions { - minion.entity.max_hp += loot_item.bonus_value; - minion.entity.hp += loot_item.bonus_value; - } - self.ui.add_log(&format!(" Minion HP +{}!", loot_item.bonus_value)); - } - LootBonus::TrapDamage => { - self.player.trap_damage_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Trap damage +{}!", loot_item.bonus_value)); - } - LootBonus::HeatReduction => { - self.player.heat_reduction_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Heat reduction +{}!", loot_item.bonus_value)); - } - LootBonus::ResearchBonus => { - self.player.research_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Research bonus +{}!", loot_item.bonus_value)); - } - LootBonus::PowerBonus => { - self.player.power_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Power bonus +{}!", loot_item.bonus_value)); - } - } - } - } + fn status_line(&self) -> String { + if self.paused { + "PAUSED".to_string() + } else if self.sim.raid_active() { + "UNDER ATTACK".to_string() + } else { + format!("Day {}", 1 + self.sim.tick / 400) } - - self.player.active_schemes.retain(|s| s.turns_remaining > 0); } - fn launch_scheme(&mut self, idx: usize) { - let schemes = tiles::available_schemes(); - if idx >= schemes.len() { return; } - let scheme = &schemes[idx]; - - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions < scheme.minion_cost { - self.ui.add_log(&format!("Need {} minions for this scheme!", scheme.minion_cost)); - return; - } - - // Remove minions (send them on the mission) - let mut removed = 0; - self.minions.retain(|m| { - if removed >= scheme.minion_cost { return true; } - if m.entity.alive && m.class == entities::MinionClass::Worker { - removed += 1; - false - } else { - true + fn threat_line(&self) -> String { + match self.sim.threat() { + Threat::Cold => "Agencies: no interest".to_string(), + Threat::Watched { ticks_left } => { + let secs = (ticks_left as u64 * self.tick_ms) / 1000; + format!("Raid in ~{secs}s (heat {})", self.sim.player.heat) } - }); - if removed < scheme.minion_cost { - // Also send guards if not enough workers - self.minions.retain(|m| { - if removed >= scheme.minion_cost { return true; } - if m.entity.alive { - removed += 1; - false - } else { - true - } - }); + Threat::Raid { number, turn } => format!("RAID {number} — turn {turn}"), } - - 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.ui.add_log(&format!("Launched scheme: {}!", scheme.name)); - self.ui.add_log(&format!(" {} minions deployed, {} turns", scheme.minion_cost, scheme.duration_turns)); - self.phase = Phase::Build; } fn render(&mut self, stdout: &mut io::Stdout) -> io::Result<()> { self.ui.clear_screen(stdout)?; - // Title screen - render only the title - if self.phase == Phase::Title { - self.ui.render_title_screen(stdout)?; + // Guard against tiny/degenerate terminals: the layout math needs room, + // and negative widths cast to usize would blow up allocations. + let (max_x, max_y) = terminal::size()?; + if max_x < 60 || max_y < 20 { + execute!( + stdout, + crossterm::cursor::MoveTo(0, 0), + crossterm::style::Print("Terminal too small (need at least 60x20)"), + )?; stdout.flush()?; return Ok(()); } - let phase_str = match self.phase { - Phase::Title => "TITLE", - Phase::Build => "BUILD", - Phase::Defend => "DEFEND", - Phase::WorldMap => "WORLD MAP", - Phase::GameOver => "GAME OVER", - }; + if self.screen == Screen::Title { + self.ui.render_title_screen(stdout)?; + stdout.flush()?; + return Ok(()); + } self.ui.render_map( stdout, - &self.game_map, - &self.player, - &self.minions, - &self.agents, - &self.henchmen, + &self.sim.map, + &self.sim.player, + &self.sim.minions, + &self.sim.agents, + &self.sim.henchmen, self.build_mode.as_ref(), )?; + let status = self.status_line(); + let threat = self.threat_line(); self.ui.render_sidebar( stdout, - &self.player, - &self.minions, - &self.agents, - &self.henchmen, - phase_str, + &self.sim.player, + &self.sim.minions, + &self.sim.agents, + &self.sim.henchmen, + &status, + &threat, self.build_mode.as_ref(), - &self.game_map, - self.defend_turn, + &self.sim.map, )?; self.ui.render_log(stdout)?; - if self.wave_announce_turns > 0 { - self.ui.render_wave_announcement(stdout, self.player.wave)?; + if let Some(raid) = &self.sim.raid + && raid.announce > 0 + { + self.ui.render_wave_announcement(stdout, raid.number)?; } if self.research_menu { - self.ui.render_research_menu(stdout, &self.player, self.research_selected)?; + self.ui + .render_research_menu(stdout, &self.sim.player, self.research_selected)?; } - - if self.phase == Phase::WorldMap { - self.ui.render_world_map(stdout, &self.player, self.world_map_selected)?; + if self.world_map { + self.ui + .render_world_map(stdout, &self.sim.player, self.world_map_selected)?; } - if self.hire_menu { - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count(); - self.ui.render_hire_menu(stdout, &self.player, self.hire_selected, alive_minions)?; + let alive = self.sim.minions.iter().filter(|m| m.entity.alive).count(); + self.ui + .render_hire_menu(stdout, &self.sim.player, self.hire_selected, alive)?; } - - if self.phase == Phase::GameOver { - self.ui.render_game_over(stdout, &self.player)?; + if self.screen == Screen::GameOver { + self.ui.render_game_over(stdout, &self.sim.player)?; } stdout.flush()?; @@ -892,7 +345,6 @@ impl Game { } fn run(&mut self) -> io::Result<()> { - // Set up terminal terminal::enable_raw_mode()?; let mut stdout = io::stdout(); execute!( @@ -901,172 +353,76 @@ impl Game { terminal::Clear(ClearType::All) )?; - self.update_resources(); - - // Initial spawn of minions at guard posts - let guard_posts = self.game_map.tiles_of_type(TileType::GuardPost); - for &(gx, gy) in &guard_posts { - self.spawn_minion_at_guard_post(gx, gy); - } + self.drain_sim_log(); loop { - // Render self.render(&mut stdout)?; - // Input - if event::poll(std::time::Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if self.phase == Phase::GameOver { - // Render one more time so player sees the game over screen - self.render(&mut stdout)?; - // Wait for any key to exit - loop { - if event::poll(std::time::Duration::from_millis(100))? { - if let Event::Key(_) = event::read()? { - break; - } - } - } + if event::poll(std::time::Duration::from_millis(30))? + && let Event::Key(key) = event::read()? + { + match self.screen { + Screen::GameOver => { + // Any key exits. break; } - - // Title phase - any key starts the game - if self.phase == Phase::Title { - // Check for quit before transitioning + Screen::Title => { if key.code == crossterm::event::KeyCode::Char('q') { break; } - self.phase = Phase::Build; + self.screen = Screen::Playing; + self.last_tick = Instant::now(); continue; } - - let cmd = input::handle_key( - key, - self.build_mode.is_some(), - self.research_menu, - self.phase == Phase::Defend, - self.phase == Phase::WorldMap, - self.hire_menu, - ); - - if let Some(cmd) = cmd { - if self.handle_command(cmd) { + Screen::Playing => { + let cmd = input::handle_key( + key, + self.build_mode.is_some(), + self.research_menu, + self.world_map, + self.hire_menu, + ); + if let Some(cmd) = cmd + && self.handle_command(cmd) + { break; } } } } - // Auto-step during defend phase (respect defend_speed timing) - if self.phase == Phase::Defend && !self.defend_paused { - let now = std::time::Instant::now(); - if now.duration_since(self.last_defend_step).as_millis() as u64 >= self.defend_speed { - self.defend_step(); - self.last_defend_step = now; + // Advance the simulation on the wall clock (bounded catch-up so + // a stall doesn't cause a death spiral). + if self.screen == Screen::Playing && !self.paused { + let mut steps = 0; + while self.last_tick.elapsed().as_millis() as u64 >= self.tick_ms && steps < 5 { + self.sim.advance(); + self.last_tick += std::time::Duration::from_millis(self.tick_ms); + steps += 1; + } + if steps == 5 { + // Too far behind — drop the debt instead of fast-forwarding. + self.last_tick = Instant::now(); + } + if steps > 0 { + self.drain_sim_log(); + if self.sim.game_over { + self.screen = Screen::GameOver; + } } } } - // Restore terminal - execute!( - stdout, - terminal::LeaveAlternateScreen, - )?; + execute!(stdout, terminal::LeaveAlternateScreen)?; terminal::disable_raw_mode()?; println!("Thanks for playing Supervillain!"); - println!("Final score: {}", self.player.score); - + println!("Final score: {}", self.sim.player.score); Ok(()) } - - fn activate_power(&mut self, index: usize) { - if index >= self.player.powers.len() { return; } - if !self.player.powers[index].is_ready() { - self.ui.add_log(&format!("{} is on cooldown ({}t)", self.player.powers[index].name, self.player.powers[index].current_cooldown)); - return; - } - - // Clone the power data to avoid borrow issues - let power = self.player.powers[index].clone(); - let messages = self.combat.activate_power( - &power, - &mut self.player, - &mut self.agents, - &mut self.minions, - &self.game_map, - ); - - // Apply the power (set cooldown) - self.player.powers[index].use_power(); - - // Log messages - for msg in &messages { - self.ui.add_log(msg); - } - - // Apply kill events from power - for kill in self.combat.kill_events.clone() { - self.player.gold += kill.gold_value; - self.player.score += kill.gold_value; - self.player.kills += 1; - } - } - - fn create_save_state(&self) -> supervillain::save::SaveState { - let mut map_tiles = Vec::new(); - for y in 0..self.game_map.height { - for x in 0..self.game_map.width { - map_tiles.push(self.game_map.get_tile(x, y)); - } - } - - supervillain::save::SaveState { - player: self.player.clone(), - minions: self.minions.clone(), - henchmen: self.henchmen.clone(), - map_width: self.game_map.width, - map_height: self.game_map.height, - map_tiles, - map_powered: self.game_map.powered.clone(), - map_throne: self.game_map.throne_pos().unwrap_or((40, 20)), - map_entries: self.game_map.entry_positions(), - wave: self.player.wave, - phase: format!("{:?}", self.phase), - } - } - - fn apply_save_state(&mut self, state: supervillain::save::SaveState) { - self.player = state.player; - self.minions = state.minions; - self.henchmen = state.henchmen; - self.phase = match state.phase.as_str() { - "Build" => Phase::Build, - "Defend" => Phase::Defend, - "WorldMap" => Phase::WorldMap, - "GameOver" => Phase::GameOver, - _ => Phase::Build, - }; - - // Reconstruct game map from saved tiles - self.game_map = map::GameMap::from_tiles( - state.map_width, - state.map_height, - state.map_tiles, - state.map_powered, - state.map_throne, - state.map_entries, - ); - - self.agents = Vec::new(); - self.build_mode = None; - self.research_menu = false; - self.hire_menu = false; - self.update_resources(); - } } fn main() -> io::Result<()> { - let mut game = Game::new(); - game.run() + let mut app = App::new(); + app.run() } diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 78ee8aa..1c34268 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -168,10 +168,10 @@ impl UI { minions: &[Minion], agents: &[Agent], henchmen: &[Henchman], - phase: &str, + status: &str, + threat: &str, build_mode: Option<&BuildMode>, game_map: &supervillain::map::GameMap, - defend_turn: i32, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let sidebar_x = max_x as i32 - 29; @@ -205,13 +205,16 @@ impl UI { write(stdout, &mut row, "EVIL GENIUS", Color::Magenta, true)?; row += 1; - write(stdout, &mut row, &format!("Phase: {}", phase.to_uppercase()), Color::Yellow, true)?; - let wave_info = if phase == "defend" { - format!("Wave: {} (Turn {})", player.wave, defend_turn) + let status_color = if status.contains("PAUSED") { Color::DarkGrey } else { Color::Yellow }; + write(stdout, &mut row, status, status_color, true)?; + let threat_color = if threat.starts_with("RAID") { + Color::Red + } else if threat.starts_with("Raid") { + Color::Yellow } else { - format!("Wave: {}", player.wave) + Color::DarkGrey }; - write(stdout, &mut row, &wave_info, Color::White, false)?; + write(stdout, &mut row, threat, threat_color, false)?; row += 1; write(stdout, &mut row, "--- Resources ---", Color::Yellow, false)?; @@ -323,7 +326,8 @@ impl UI { write(stdout, &mut row, "r: research", Color::DarkGrey, false)?; write(stdout, &mut row, "f: hire minions", Color::DarkGrey, false)?; write(stdout, &mut row, "m: world map", Color::DarkGrey, false)?; - write(stdout, &mut row, "SPACE: next wave", Color::DarkGrey, false)?; + write(stdout, &mut row, "SPACE/p: pause +/-: speed", Color::DarkGrey, false)?; + write(stdout, &mut row, "1-9: powers", Color::DarkGrey, false)?; write(stdout, &mut row, "Ctrl+S: save Ctrl+L: load", Color::DarkGrey, false)?; write(stdout, &mut row, "q: quit", Color::DarkGrey, false)?; } @@ -416,8 +420,8 @@ impl UI { )?; let unlocks = supervillain::tiles::research_unlocks(); - let mut y = oy + 4; for (i, (key, cost, name)) in unlocks.iter().enumerate() { + let y = oy + 4 + i as u16; let unlocked = player.unlocked.contains(*key); let affordable = player.research >= *cost; @@ -442,7 +446,6 @@ impl UI { SetBackgroundColor(Color::Black), style::Print(text.chars().take(40).collect::()), )?; - y += 1; } queue!( @@ -506,16 +509,15 @@ impl UI { // Active schemes if !player.active_schemes.is_empty() { - let mut sy = oy + 3; queue!( stdout, - cursor::MoveTo(ox + 2, sy), + cursor::MoveTo(ox + 2, oy + 3), SetForegroundColor(Color::DarkYellow), SetBackgroundColor(Color::Black), style::Print("Active:"), )?; - for scheme in &player.active_schemes { - sy += 1; + for (i, scheme) in player.active_schemes.iter().enumerate() { + let sy = oy + 4 + i as u16; queue!( stdout, cursor::MoveTo(ox + 4, sy), @@ -627,8 +629,8 @@ impl UI { // Minion classes let classes = supervillain::entities::MinionClass::all(); - let mut y = oy + 4; for (i, &class) in classes.iter().enumerate() { + let y = oy + 4 + i as u16; let cost = class.hire_cost(); let key = class.unlock_key(); let unlocked = player.unlocked.contains(key); @@ -656,7 +658,6 @@ impl UI { SetBackgroundColor(if is_selected { Color::DarkBlue } else { Color::Black }), style::Print(text.chars().take(40).collect::()), )?; - y += 1; } // Instructions @@ -705,7 +706,7 @@ impl UI { } let stats = [ - format!("Waves survived: {}", player.wave), + format!("Raids survived: {}", player.wave), format!("Agents defeated: {}", player.kills), format!("Notoriety: {}", player.notoriety), format!("Final score: {}", player.score), @@ -735,7 +736,7 @@ impl UI { let (max_x, max_y) = terminal::size()?; let cx = max_x / 2; let cy = max_y / 2; - let msg = format!("WAVE {wave_num} INCOMING!"); + let msg = format!("RAID {wave_num} INCOMING!"); queue!( stdout, @@ -825,7 +826,7 @@ impl UI { )?; y += 1; - let tagline = "Build. Defend. Dominate."; + let tagline = "Build. Scheme. Dominate."; queue!( stdout, cursor::MoveTo(cx.saturating_sub(tagline.len() as u16 / 2), y), @@ -857,7 +858,7 @@ impl UI { let controls = [ "hjkl/arrow: Move b: Build r: Research f: Hire", - "m: World Map SPACE: Next wave q: Quit", + "m: Schemes SPACE: Pause +/-: Speed q: Quit", ]; for line in &controls { queue!( diff --git a/src/combat.rs b/src/combat.rs index adf818b..8b53ab6 100644 --- a/src/combat.rs +++ b/src/combat.rs @@ -514,18 +514,18 @@ impl CombatSystem { if broken_traps.is_empty() { return; } - for i in 0..minions.len() { - if !minions[i].entity.alive || !minions[i].can_repair() { continue; } + for minion in minions.iter_mut() { + if !minion.entity.alive || !minion.can_repair() { continue; } // First check adjacent tiles for unpowered traps let mut repaired = false; for (dx, dy) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { - let nx = minions[i].entity.x + dx; - let ny = minions[i].entity.y + dy; + let nx = minion.entity.x + dx; + let ny = minion.entity.y + dy; let tile = game_map.get_tile(nx, ny); if tile.is_trap() && !game_map.powered.contains(&(nx, ny)) { game_map.powered.insert((nx, ny)); - self.log_msg(&format!("{} repairs a {}!", minions[i].entity.name, tile.name())); + self.log_msg(&format!("{} repairs a {}!", minion.entity.name, tile.name())); repaired = true; break; // One repair per turn } @@ -537,22 +537,23 @@ impl CombatSystem { let mut nearest_dist = i32::MAX; let mut nearest_trap = None; for &(tx, ty) in &broken_traps { - let dist = minions[i].entity.distance_to(tx, ty); + let dist = minion.entity.distance_to(tx, ty); if dist < nearest_dist { nearest_dist = dist; nearest_trap = Some((tx, ty)); } } - if let Some((tx, ty)) = nearest_trap { - if let Some(path) = game_map.find_path( - (minions[i].entity.x, minions[i].entity.y), + if let Some((tx, ty)) = nearest_trap + && let Some(path) = game_map.find_path( + (minion.entity.x, minion.entity.y), (tx, ty), &std::collections::HashSet::new(), - ) && path.len() >= 2 { - minions[i].entity.x = path[1].0; - minions[i].entity.y = path[1].1; - } + ) + && path.len() >= 2 + { + minion.entity.x = path[1].0; + minion.entity.y = path[1].1; } } } diff --git a/src/input.rs b/src/input.rs deleted file mode 100644 index 6a2fcc1..0000000 --- a/src/input.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; - -#[derive(Debug, Clone, PartialEq)] -pub enum Command { - Quit, - MoveUp, - MoveDown, - MoveLeft, - MoveRight, - EnterBuild, - ExitBuild, - CursorUp, - CursorDown, - CursorLeft, - CursorRight, - CycleCategory, - CycleItemNext, - CycleItemPrev, - PlaceTile, - DemolishTile, - OpenResearch, - ResearchUp, - ResearchDown, - ResearchBuy, - ResearchClose, - NextWave, - Wait, - // Defend phase controls - SpeedUp, - SpeedDown, - TogglePause, - // Title screen - AnyKey, - // World map - OpenWorldMap, - WorldMapUp, - WorldMapDown, - WorldMapSelect, - WorldMapClose, - // Minion hiring - OpenHireMenu, - HireUp, - HireDown, - HireSelect, - HireClose, -} - -pub fn handle_key( - key: KeyEvent, - build_mode: bool, - research_menu: bool, - defend_phase: bool, - world_map: bool, - hire_menu: bool, -) -> Option { - // Research menu keys - if research_menu { - return match key.code { - KeyCode::Up | KeyCode::Char('k') => Some(Command::ResearchUp), - KeyCode::Down | KeyCode::Char('j') => Some(Command::ResearchDown), - KeyCode::Enter => Some(Command::ResearchBuy), - KeyCode::Esc => Some(Command::ResearchClose), - _ => None, - }; - } - - // Hire menu keys - if hire_menu { - return match key.code { - KeyCode::Up | KeyCode::Char('k') => Some(Command::HireUp), - KeyCode::Down | KeyCode::Char('j') => Some(Command::HireDown), - KeyCode::Enter => Some(Command::HireSelect), - KeyCode::Esc | KeyCode::Char('f') => Some(Command::HireClose), - KeyCode::Char('q') => Some(Command::Quit), - _ => None, - }; - } - - // World map keys - if world_map { - return match key.code { - KeyCode::Up | KeyCode::Char('k') => Some(Command::WorldMapUp), - KeyCode::Down | KeyCode::Char('j') => Some(Command::WorldMapDown), - KeyCode::Enter => Some(Command::WorldMapSelect), - KeyCode::Esc | KeyCode::Char('m') => Some(Command::WorldMapClose), - KeyCode::Char('q') => Some(Command::Quit), - _ => None, - }; - } - - // Build mode keys - if build_mode { - return match key.code { - KeyCode::Up | KeyCode::Char('k') => Some(Command::CursorUp), - KeyCode::Down | KeyCode::Char('j') => Some(Command::CursorDown), - KeyCode::Left => Some(Command::CursorLeft), - KeyCode::Right | KeyCode::Char('l') => Some(Command::CursorRight), - KeyCode::Tab => Some(Command::CycleCategory), - KeyCode::Char('n') => Some(Command::CycleItemNext), - KeyCode::Char('p') => Some(Command::CycleItemPrev), - KeyCode::Char(' ') => Some(Command::PlaceTile), - KeyCode::Char('x') => Some(Command::DemolishTile), - KeyCode::Char('f') => Some(Command::OpenHireMenu), - KeyCode::Esc => Some(Command::ExitBuild), - KeyCode::Char('q') => Some(Command::Quit), - _ => None, - }; - } - - // Defend phase keys (limited controls) - if defend_phase { - return match key.code { - KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), - KeyCode::Char('-') => Some(Command::SpeedDown), - KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), - KeyCode::Char('q') => Some(Command::Quit), - _ => None, - }; - } - - // Normal (build) mode keys - match key.code { - KeyCode::Up | KeyCode::Char('k') => Some(Command::MoveUp), - KeyCode::Down | KeyCode::Char('j') => Some(Command::MoveDown), - KeyCode::Left | KeyCode::Char('h') => Some(Command::MoveLeft), - KeyCode::Right | KeyCode::Char('l') => Some(Command::MoveRight), - KeyCode::Char('b') => Some(Command::EnterBuild), - KeyCode::Char('r') => Some(Command::OpenResearch), - KeyCode::Char('f') => Some(Command::OpenHireMenu), - KeyCode::Char('m') => Some(Command::OpenWorldMap), - KeyCode::Char(' ') => Some(Command::NextWave), - KeyCode::Char('.') => Some(Command::Wait), - KeyCode::Char('q') => Some(Command::Quit), - _ => Some(Command::AnyKey), - } -} diff --git a/src/lib.rs b/src/lib.rs index a29d38a..cd303e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod build; pub mod superpowers; pub mod events; pub mod save; +pub mod sim; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f392c07..0000000 --- a/src/main.rs +++ /dev/null @@ -1,927 +0,0 @@ -mod build; -mod combat; -mod entities; -mod input; -mod map; -mod tiles; -mod ui; - -use std::io::{self, Write}; - -use crossterm::{ - event::{self, Event}, - execute, - terminal::{self, ClearType}, -}; - -use build::{BuildMode, BuildResult}; -use combat::CombatSystem; -use entities::{generate_agent_wave, Minion, MinionClass, Player}; -use input::Command; -use map::GameMap; -use tiles::TileType; -use ui::UI; - -#[derive(Debug, Clone, PartialEq)] -enum Phase { - Title, - Build, - Defend, - WorldMap, - GameOver, -} - -struct Game { - game_map: GameMap, - player: Player, - minions: Vec, - agents: Vec, - henchmen: Vec, - combat: CombatSystem, - ui: UI, - phase: Phase, - build_mode: Option, - research_menu: bool, - research_selected: usize, - defend_turn: i32, - wave_announce_turns: i32, - defend_speed: u64, // ms per defend step - defend_paused: bool, - last_defend_step: std::time::Instant, - world_map_selected: usize, - hire_menu: bool, - hire_selected: usize, -} - -impl Game { - fn new() -> Self { - let game_map = GameMap::new(80, 40); - let throne = game_map.throne_pos().unwrap_or((40, 20)); - let player = Player::new(throne.0, throne.1); - - // Start with 3 workers near the throne (like Supervillain) - let mut starting_minions = Vec::new(); - for (dx, dy) in [(1, 0), (-1, 0), (0, 1)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if game_map.is_walkable(mx, my) { - starting_minions.push(Minion::new( - mx, my, MinionClass::Worker, 0, 0, - )); - } - } - - Self { - game_map, - player, - minions: starting_minions, - agents: Vec::new(), - henchmen: entities::Henchman::starting_roster(throne.0, throne.1), - combat: CombatSystem::new(), - ui: UI::new(), - phase: Phase::Title, - build_mode: None, - research_menu: false, - research_selected: 0, - defend_turn: 0, - wave_announce_turns: 0, - defend_speed: 150, - defend_paused: false, - last_defend_step: std::time::Instant::now(), - world_map_selected: 0, - hire_menu: false, - hire_selected: 0, - } - } - - fn update_resources(&mut self) { - // Update power grid FIRST (power cores self-power via flood fill) - let cores = self.game_map.tiles_of_type(TileType::PowerCore); - self.game_map.update_power(&cores); - - // Calculate power generation and consumption - let mut power_gen = 0i32; - let mut power_use = 0i32; - let mut research_gen = 0i32; - let mut heat_reduction = 0i32; - - for &(_x, _y) in &cores { - power_gen += TileType::PowerCore.power_gen(self.player.power_core_mk2) + self.player.power_bonus; - } - - for y in 0..self.game_map.height { - for x in 0..self.game_map.width { - let tile = self.game_map.get_tile(x, y); - if self.game_map.powered.contains(&(x, y)) { - power_use += tile.power_usage(); - research_gen += tile.research_gen(); - heat_reduction += tile.heat_reduction(); - } - } - } - - // Scientist minions at labs generate bonus research - for minion in &self.minions { - research_gen += minion.research_bonus(); - } - - // Passive gold income: workers generate gold, vaults boost it - let worker_count = self.minions.iter() - .filter(|m| m.entity.alive && m.class == MinionClass::Worker) - .count() as i32; - let vault_count = self.game_map.tiles_of_type(TileType::Vault).len() as i32; - let gold_per_worker = 3 + vault_count; // Vaults increase worker efficiency - let gold_income = worker_count * gold_per_worker; - self.player.gold += gold_income; - self.player.gold_income = gold_income; - - self.player.power_cap = power_gen; - self.player.power = (power_gen - power_use).max(0); - self.player.research += research_gen + self.player.research_bonus; - - // Reduce heat from hotels/freezers - self.player.reduce_heat(heat_reduction + self.player.heat_reduction_bonus); - - // Update minion cap from barracks - let barracks_count = self.game_map.tiles_of_type(TileType::Barracks).len() as i32; - self.player.minion_cap = 3 + barracks_count * 2; - } - - fn hire_minion(&mut self) { - let classes = MinionClass::all(); - if self.hire_selected >= classes.len() { return; } - let class = classes[self.hire_selected]; - - // Check if unlocked - let unlock_key = class.unlock_key(); - if !self.player.unlocked.contains(unlock_key) { - self.ui.add_log(&format!("{} not researched yet!", class.name())); - return; - } - - let cost = class.hire_cost(); - if self.player.gold < cost { - self.ui.add_log(&format!("Need {} gold to hire {}!", cost, class.name())); - return; - } - - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions >= self.player.minion_cap { - self.ui.add_log(&format!("Minion cap reached! ({}/{})", alive_minions, self.player.minion_cap)); - return; - } - - // Place new minion near the throne - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - let mut placed = false; - for dist in 1..5 { - for (dx, dy) in [(0, -dist), (0, dist), (-dist, 0), (dist, 0)] { - let mx = throne.0 + dx; - let my = throne.1 + dy; - if self.game_map.is_walkable(mx, my) { - self.player.gold -= cost; - let minion = Minion::new( - mx, my, class, - self.player.minion_damage_bonus, - self.player.minion_hp_bonus, - ); - self.minions.push(minion); - self.ui.add_log(&format!("Hired {} for {} gold!", class.name(), cost)); - placed = true; - break; - } - } - if placed { break; } - } - - if !placed { - self.ui.add_log("No room near throne to place minion!"); - } - } - - fn spawn_minion_at_guard_post(&mut self, x: i32, y: i32) { - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions >= self.player.minion_cap { - self.ui.add_log(&format!( - "Minion cap reached! ({}/{})", - alive_minions, self.player.minion_cap - )); - return; - } - - let minion = Minion::new( - x, - y, - MinionClass::Guard, - self.player.minion_damage_bonus, - self.player.minion_hp_bonus, - ); - self.minions.push(minion); - self.ui.add_log("Minion deployed at guard post!"); - } - - fn handle_command(&mut self, cmd: Command) -> bool { - match cmd { - Command::Quit => return true, - - // Research menu - Command::ResearchUp => { - if self.research_selected > 0 { - self.research_selected -= 1; - } - } - Command::ResearchDown => { - let unlocks = tiles::research_unlocks(); - if self.research_selected < unlocks.len() - 1 { - self.research_selected += 1; - } - } - Command::ResearchBuy => { - let unlocks = tiles::research_unlocks(); - if self.research_selected < unlocks.len() { - let (key, cost, name) = unlocks[self.research_selected]; - if self.player.unlocked.contains(key) { - self.ui.add_log("Already researched!"); - } else if self.player.research < cost { - self.ui.add_log(&format!( - "Not enough research points! Need {}, have {}", - cost, self.player.research - )); - } else { - self.player.research -= cost; - self.player.unlocked.insert(key.to_string()); - - // Apply special effects - 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.ui.add_log(&format!("Researched: {name}!")); - } - } - } - Command::ResearchClose => { - self.research_menu = false; - } - - // Build mode - handle separately to avoid borrow issues - Command::EnterBuild => { - if self.phase == Phase::Build { - self.build_mode = Some(BuildMode::new(&self.player)); - } - } - Command::ExitBuild => { - self.build_mode = None; - } - Command::PlaceTile => { - self.place_build_tile(); - } - Command::DemolishTile => { - self.demolish_build_tile(); - } - - // Movement and cursor commands - handle generically - _ => { - self.handle_movement_command(cmd); - } - } - - false - } - - fn handle_movement_command(&mut self, cmd: Command) { - match cmd { - Command::CursorUp => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(0, -1, &self.game_map); - } - } - Command::CursorDown => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(0, 1, &self.game_map); - } - } - Command::CursorLeft => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(-1, 0, &self.game_map); - } - } - Command::CursorRight => { - if let Some(ref mut bm) = self.build_mode { - bm.move_cursor(1, 0, &self.game_map); - } - } - Command::CycleCategory => { - if let Some(ref mut bm) = self.build_mode { - bm.select_category(1, &self.player); - } - } - Command::CycleItemNext => { - if let Some(ref mut bm) = self.build_mode { - bm.select_item(1, &self.player); - } - } - Command::CycleItemPrev => { - if let Some(ref mut bm) = self.build_mode { - bm.select_item(-1, &self.player); - } - } - Command::MoveUp => { - let ny = self.player.entity.y - 1; - if self.game_map.is_walkable(self.player.entity.x, ny) { - self.player.entity.y = ny; - } - } - Command::MoveDown => { - let ny = self.player.entity.y + 1; - if self.game_map.is_walkable(self.player.entity.x, ny) { - self.player.entity.y = ny; - } - } - Command::MoveLeft => { - let nx = self.player.entity.x - 1; - if self.game_map.is_walkable(nx, self.player.entity.y) { - self.player.entity.x = nx; - } - } - Command::MoveRight => { - let nx = self.player.entity.x + 1; - if self.game_map.is_walkable(nx, self.player.entity.y) { - self.player.entity.x = nx; - } - } - Command::OpenResearch => { - if self.phase == Phase::Build { - self.research_menu = true; - } - } - Command::NextWave => { - if self.phase == Phase::Build { - self.start_wave(); - } - } - Command::Wait => {} - Command::SpeedUp => { - self.defend_speed = self.defend_speed.saturating_sub(50).max(20); - self.ui.add_log(&format!("Speed: {}ms", self.defend_speed)); - } - Command::SpeedDown => { - self.defend_speed = self.defend_speed.saturating_add(50).min(500); - self.ui.add_log(&format!("Speed: {}ms", self.defend_speed)); - } - Command::TogglePause => { - self.defend_paused = !self.defend_paused; - self.ui.add_log(if self.defend_paused { "PAUSED" } else { "RESUMED" }); - } - Command::AnyKey => {} - Command::OpenWorldMap => { - if self.phase == Phase::Build { - self.phase = Phase::WorldMap; - self.world_map_selected = 0; - } - } - Command::WorldMapUp => { - if self.world_map_selected > 0 { - self.world_map_selected -= 1; - } - } - Command::WorldMapDown => { - let schemes = tiles::available_schemes(); - if self.world_map_selected < schemes.len() - 1 { - self.world_map_selected += 1; - } - } - Command::WorldMapSelect => { - self.launch_scheme(self.world_map_selected); - } - Command::WorldMapClose => { - self.phase = Phase::Build; - } - Command::OpenHireMenu => { - if self.phase == Phase::Build { - self.hire_menu = true; - self.hire_selected = 0; - } - } - Command::HireUp => { - if self.hire_selected > 0 { - self.hire_selected -= 1; - } - } - Command::HireDown => { - let classes = MinionClass::all(); - if self.hire_selected < classes.len() - 1 { - self.hire_selected += 1; - } - } - Command::HireSelect => { - self.hire_minion(); - } - Command::HireClose => { - self.hire_menu = false; - } - _ => {} - } - } - - fn place_build_tile(&mut self) { - // Extract build mode data, process, then put back - let result = if let Some(ref mut bm) = self.build_mode { - let result = bm.place_tile(&mut self.game_map, &mut self.player); - let msg = bm.message.clone(); - let cursor_x = bm.cursor_x; - let cursor_y = bm.cursor_y; - (result, msg, cursor_x, cursor_y) - } else { - return; - }; - - match result.0 { - BuildResult::GuardPostPlaced => { - self.ui.add_log(&result.1); - self.spawn_minion_at_guard_post(result.2, result.3); - } - BuildResult::Success => { - self.ui.add_log(&result.1); - } - BuildResult::None => { - self.ui.add_log(&result.1); - } - } - self.update_resources(); - } - - fn demolish_build_tile(&mut self) { - if let Some(ref mut bm) = self.build_mode { - let msg = bm.demolish_tile(&mut self.game_map, &mut self.player); - self.ui.add_log(&msg); - self.update_resources(); - } - } - - fn start_wave(&mut self) { - self.player.wave += 1; - self.phase = Phase::Defend; - self.defend_turn = 0; - self.wave_announce_turns = 5; - self.last_defend_step = std::time::Instant::now(); - - let entries = self.game_map.entry_positions(); - let new_agents = generate_agent_wave(self.player.wave, self.player.heat, &entries); - self.agents = new_agents; - - // Calculate initial paths for all agents - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - let agent_positions: Vec<(i32, i32)> = self.agents.iter().map(|a| (a.entity.x, a.entity.y)).collect(); - for (i, agent) in self.agents.iter_mut().enumerate() { - let target = agent.preferred_target(&self.game_map, throne); - let avoid: std::collections::HashSet<(i32, i32)> = agent_positions - .iter() - .enumerate() - .filter(|(j, _)| *j != i) - .map(|(_, pos)| *pos) - .collect(); - agent.calculate_path(&self.game_map, target, &avoid); - } - - self.ui.add_log(&format!("=== WAVE {} INCOMING! ===", self.player.wave)); - self.ui.add_log(&format!( - "{} agents approach the lair!", - self.agents.iter().filter(|a| a.entity.alive).count() - )); - - // Spawn minions at guard posts - let guard_posts = self.game_map.tiles_of_type(TileType::GuardPost); - for &(gx, gy) in &guard_posts { - let already_has_minion = self - .minions - .iter() - .any(|m| m.entity.alive && m.home_x == gx && m.home_y == gy); - if !already_has_minion { - self.spawn_minion_at_guard_post(gx, gy); - } - } - } - - fn defend_step(&mut self) { - if self.wave_announce_turns > 0 { - self.wave_announce_turns -= 1; - return; - } - - self.defend_turn += 1; - - let throne = self.game_map.throne_pos().unwrap_or((40, 20)); - - // Process agent turn - let throne_reached = self.combat.process_agent_turn( - &mut self.game_map, - &mut self.player, - &mut self.agents, - &mut self.minions, - &mut self.henchmen, - throne, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply hero-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - // Killing agents increases notoriety - 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, - }); - } - - // Apply heat events from agent actions - for event in &self.combat.heat_events.clone() { - self.player.add_heat(event.heat_change); - self.ui.add_log(&format!("Heat +{} ({})", event.heat_change, event.reason)); - } - - if throne_reached { - self.player.entity.take_damage(15); - self.ui.add_log("The Supervillain takes 15 damage!"); - if !self.player.entity.alive { - self.phase = Phase::GameOver; - return; - } - } - - // Process minion turn - self.combat.process_minion_turn( - &self.game_map, - &mut self.minions, - &mut self.agents, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply minion-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - } - - // Process henchmen turn - self.combat.process_henchmen_turn( - &self.game_map, - &mut self.henchmen, - &mut self.agents, - &mut self.minions, - ); - - for msg in &self.combat.log.clone() { - self.ui.add_log(msg); - } - - // Apply henchmen-turn kill events - for event in &self.combat.kill_events.clone() { - self.player.gold += event.gold_value; - self.player.kills += 1; - self.player.score += event.gold_value; - } - - // Technician repairs - self.combat.process_technician_repairs(&mut self.game_map, &self.minions); - - // Medbay healing - self.combat.process_medbay_healing(&self.game_map, &mut self.minions); - - // Training room - self.combat.process_training_room(&self.game_map, &mut self.minions); - - // Cleanup - self.combat.cleanup_dead(&mut self.minions, &mut self.agents, &mut self.henchmen); - - // Check if wave is over - if self.combat.all_agents_dead(&self.agents) { - self.ui.add_log(&format!("=== WAVE {} CLEARED! ===", self.player.wave)); - self.phase = Phase::Build; - - // Bonus gold for clearing - let bonus = self.player.wave * 10; - self.player.gold += bonus; - self.ui.add_log(&format!("Wave bonus: +{bonus} gold")); - - // Notoriety bonus for clearing - self.player.add_notoriety(self.player.wave * 2); - - // Natural heat decay between waves - self.player.reduce_heat(5); - - // Combo bonus - let combo = self.combat.combo_bonus(); - if combo > 0 { - self.player.gold += combo; - self.ui.add_log(&format!("Trap combo bonus: +{combo} gold!")); - } - - // Process active schemes - self.tick_schemes(); - - // Heal the Supervillain a bit - self.player.entity.heal(10); - } - } - - 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()); - } - } - - 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.ui.add_log(&format!("Scheme complete: {}!", scheme.name)); - self.ui.add_log(&format!(" +{} gold, +{} notoriety", scheme.gold_reward, scheme.notoriety_reward)); - if let Some(ref loot_name) = scheme.loot { - self.player.loot.push(loot_name.clone()); - self.ui.add_log(&format!(" Acquired: {loot_name}!")); - // Apply loot bonuses - let loot_items = tiles::loot_items(); - if let Some(loot_item) = loot_items.iter().find(|l| l.name == loot_name) { - use tiles::LootBonus; - match loot_item.bonus_type { - LootBonus::MinionDamage => { - self.player.minion_damage_bonus += loot_item.bonus_value; - for minion in &mut self.minions { - minion.entity.damage += loot_item.bonus_value; - } - self.ui.add_log(&format!(" Minion damage +{}!", loot_item.bonus_value)); - } - LootBonus::MinionHP => { - self.player.minion_hp_bonus += loot_item.bonus_value; - for minion in &mut self.minions { - minion.entity.max_hp += loot_item.bonus_value; - minion.entity.hp += loot_item.bonus_value; - } - self.ui.add_log(&format!(" Minion HP +{}!", loot_item.bonus_value)); - } - LootBonus::TrapDamage => { - self.player.trap_damage_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Trap damage +{}!", loot_item.bonus_value)); - } - LootBonus::HeatReduction => { - self.player.heat_reduction_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Heat reduction +{}!", loot_item.bonus_value)); - } - LootBonus::ResearchBonus => { - self.player.research_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Research bonus +{}!", loot_item.bonus_value)); - } - LootBonus::PowerBonus => { - self.player.power_bonus += loot_item.bonus_value; - self.ui.add_log(&format!(" Power bonus +{}!", loot_item.bonus_value)); - } - } - } - } - } - - self.player.active_schemes.retain(|s| s.turns_remaining > 0); - } - - fn launch_scheme(&mut self, idx: usize) { - let schemes = tiles::available_schemes(); - if idx >= schemes.len() { return; } - let scheme = &schemes[idx]; - - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count() as i32; - if alive_minions < scheme.minion_cost { - self.ui.add_log(&format!("Need {} minions for this scheme!", scheme.minion_cost)); - return; - } - - // Remove minions (send them on the mission) - let mut removed = 0; - self.minions.retain(|m| { - if removed >= scheme.minion_cost { return true; } - if m.entity.alive && m.class == entities::MinionClass::Worker { - removed += 1; - false - } else { - true - } - }); - if removed < scheme.minion_cost { - // Also send guards if not enough workers - 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.ui.add_log(&format!("Launched scheme: {}!", scheme.name)); - self.ui.add_log(&format!(" {} minions deployed, {} turns", scheme.minion_cost, scheme.duration_turns)); - self.phase = Phase::Build; - } - - fn render(&mut self, stdout: &mut io::Stdout) -> io::Result<()> { - self.ui.clear_screen(stdout)?; - - // Title screen - render only the title - if self.phase == Phase::Title { - self.ui.render_title_screen(stdout)?; - stdout.flush()?; - return Ok(()); - } - - let phase_str = match self.phase { - Phase::Title => "TITLE", - Phase::Build => "BUILD", - Phase::Defend => "DEFEND", - Phase::WorldMap => "WORLD MAP", - Phase::GameOver => "GAME OVER", - }; - - self.ui.render_map( - stdout, - &self.game_map, - &self.player, - &self.minions, - &self.agents, - &self.henchmen, - self.build_mode.as_ref(), - )?; - - self.ui.render_sidebar( - stdout, - &self.player, - &self.minions, - &self.agents, - &self.henchmen, - phase_str, - self.build_mode.as_ref(), - &self.game_map, - self.defend_turn, - )?; - - self.ui.render_log(stdout)?; - - if self.wave_announce_turns > 0 { - self.ui.render_wave_announcement(stdout, self.player.wave)?; - } - - if self.research_menu { - self.ui.render_research_menu(stdout, &self.player, self.research_selected)?; - } - - if self.phase == Phase::WorldMap { - self.ui.render_world_map(stdout, &self.player, self.world_map_selected)?; - } - - if self.hire_menu { - let alive_minions = self.minions.iter().filter(|m| m.entity.alive).count(); - self.ui.render_hire_menu(stdout, &self.player, self.hire_selected, alive_minions)?; - } - - if self.phase == Phase::GameOver { - self.ui.render_game_over(stdout, &self.player)?; - } - - stdout.flush()?; - Ok(()) - } - - fn run(&mut self) -> io::Result<()> { - // Set up terminal - terminal::enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!( - stdout, - terminal::EnterAlternateScreen, - terminal::Clear(ClearType::All) - )?; - - self.update_resources(); - - // Initial spawn of minions at guard posts - let guard_posts = self.game_map.tiles_of_type(TileType::GuardPost); - for &(gx, gy) in &guard_posts { - self.spawn_minion_at_guard_post(gx, gy); - } - - loop { - // Render - self.render(&mut stdout)?; - - // Input - if event::poll(std::time::Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if self.phase == Phase::GameOver { - // Render one more time so player sees the game over screen - self.render(&mut stdout)?; - // Wait for any key to exit - loop { - if event::poll(std::time::Duration::from_millis(100))? { - if let Event::Key(_) = event::read()? { - break; - } - } - } - break; - } - - // Title phase - any key starts the game - if self.phase == Phase::Title { - // Check for quit before transitioning - if key.code == crossterm::event::KeyCode::Char('q') { - break; - } - self.phase = Phase::Build; - continue; - } - - let cmd = input::handle_key( - key, - self.build_mode.is_some(), - self.research_menu, - self.phase == Phase::Defend, - self.phase == Phase::WorldMap, - self.hire_menu, - ); - - if let Some(cmd) = cmd { - if self.handle_command(cmd) { - break; - } - } - } - } - - // Auto-step during defend phase (respect defend_speed timing) - if self.phase == Phase::Defend && !self.defend_paused { - let now = std::time::Instant::now(); - if now.duration_since(self.last_defend_step).as_millis() as u64 >= self.defend_speed { - self.defend_step(); - self.last_defend_step = now; - } - } - } - - // Restore terminal - execute!( - stdout, - terminal::LeaveAlternateScreen, - )?; - terminal::disable_raw_mode()?; - - println!("Thanks for playing Supervillain!"); - println!("Final score: {}", self.player.score); - - Ok(()) - } -} - -fn main() -> io::Result<()> { - let mut game = Game::new(); - game.run() -} diff --git a/src/save.rs b/src/save.rs index ae82c14..7cd7756 100644 --- a/src/save.rs +++ b/src/save.rs @@ -46,6 +46,10 @@ pub struct SaveState { pub map_entries: Vec<(i32, i32)>, pub wave: i32, pub phase: String, + /// Simulation tick counter (0 for legacy saves). + pub sim_tick: u64, + /// Ticks until the next raid (<= 0 means "reschedule on load"). + pub next_raid_in: i32, } /// Save game state to disk. @@ -175,6 +179,9 @@ pub fn save_game(state: &SaveState) -> Result<(), String> { // Phase lines.push(format!("PHASE {}", state.phase)); + // Sim clock and threat state + lines.push(format!("SIM {} {}", state.sim_tick, state.next_raid_in)); + let content = lines.join("\n"); fs::write(save_path(), content).map_err(|e| format!("Failed to write save: {}", e))?; @@ -228,7 +235,9 @@ pub fn load_game() -> Result { let mut map_powered: HashSet<(i32, i32)> = HashSet::new(); let mut minions: Vec = Vec::new(); let mut henchmen: Vec = Vec::new(); - let mut phase = "Build".to_string(); + let mut phase = "Running".to_string(); + let mut sim_tick: u64 = 0; + let mut next_raid_in: i32 = -1; let mut tile_rows: Vec = Vec::new(); for line in &lines[1..] { @@ -390,6 +399,13 @@ pub fn load_game() -> Result { "PHASE" => { phase = value.to_string(); } + "SIM" => { + let v: Vec<&str> = value.split_whitespace().collect(); + if v.len() >= 2 { + sim_tick = v[0].parse().unwrap_or(0); + next_raid_in = v[1].parse().unwrap_or(-1); + } + } _ => {} } } @@ -445,6 +461,8 @@ pub fn load_game() -> Result { map_entries, wave: player_wave, phase, + sim_tick, + next_raid_in, }) } @@ -569,7 +587,9 @@ mod tests { map_throne: (40, 20), map_entries: vec![(0, 0)], wave: 1, - phase: "Build".to_string(), + phase: "Running".to_string(), + sim_tick: 0, + next_raid_in: -1, }; assert_eq!(state.map_tiles.len(), 80 * 40); } diff --git a/src/sim.rs b/src/sim.rs new file mode 100644 index 0000000..1d6b4b1 --- /dev/null +++ b/src/sim.rs @@ -0,0 +1,956 @@ +//! 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, generate_agent_wave, Agent, Henchman, Minion, MinionClass, Player}; +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, + pub agents: Vec, + pub henchmen: Vec, + pub combat: CombatSystem, + /// Simulation ticks elapsed since the game began. + pub tick: u64, + pub raid: Option, + /// 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, +} + +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); + } + + 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) { + self.log.push(msg.into()); + } + + /// Take all pending log messages (frontends call this once per frame). + pub fn drain_log(&mut self) -> Vec { + 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 }); + } +} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 61a239e..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,867 +0,0 @@ -use std::io::Write; - -use crossterm::{ - cursor, - queue, - style::{self, Attribute, Color, SetBackgroundColor, SetForegroundColor}, - terminal::{self, ClearType}, -}; - -use crate::build::BuildMode; -use crate::entities::{Agent, Henchman, Minion, Player}; -use crate::map::GameMap; - -pub struct UI { - pub log_messages: Vec, - viewport_x: i32, - viewport_y: i32, -} - -impl UI { - pub fn new() -> Self { - Self { - log_messages: Vec::new(), - viewport_x: 0, - viewport_y: 0, - } - } - - pub fn add_log(&mut self, msg: &str) { - self.log_messages.push(msg.to_string()); - if self.log_messages.len() > 50 { - self.log_messages.drain(..self.log_messages.len() - 50); - } - } - - pub fn recent_log(&self, n: usize) -> &[String] { - let start = self.log_messages.len().saturating_sub(n); - &self.log_messages[start..] - } - - #[allow(clippy::too_many_arguments)] - pub fn render_map( - &mut self, - stdout: &mut std::io::Stdout, - game_map: &GameMap, - player: &Player, - minions: &[Minion], - agents: &[Agent], - henchmen: &[Henchman], - build_mode: Option<&BuildMode>, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let view_w = (max_x as i32 - 30).max(20); - let view_h = (max_y as i32 - 8).max(10); - - let (cx, cy) = if let Some(bm) = build_mode { - (bm.cursor_x, bm.cursor_y) - } else { - (player.entity.x, player.entity.y) - }; - - self.viewport_x = (cx - view_w / 2).max(0); - self.viewport_y = (cy - view_h / 2).max(0); - - // Render tiles - for vy in 0..view_h.min(game_map.height) { - for vx in 0..view_w.min(game_map.width) { - let mx = self.viewport_x + vx; - let my = self.viewport_y + vy; - if mx >= game_map.width || my >= game_map.height { - continue; - } - - let tile = game_map.get_tile(mx, my); - let ch = tile.char(); - let mut color = tile.color(); - - if tile.is_trap() && !game_map.powered.contains(&(mx, my)) { - color = Color::DarkGrey; - } - - let is_cursor = build_mode.is_some_and(|bm| { - mx == bm.cursor_x && my == bm.cursor_y - }); - - queue!( - stdout, - cursor::MoveTo(vx as u16, vy as u16), - SetForegroundColor(if is_cursor { Color::White } else { color }), - SetBackgroundColor(if is_cursor { Color::DarkBlue } else { Color::Black }), - style::Print(if is_cursor && ch == ' ' { '\u{2588}' } else { ch }), - )?; - } - } - - // Render minions - for minion in minions { - if !minion.entity.alive { continue; } - let sx = minion.entity.x - self.viewport_x; - let sy = minion.entity.y - self.viewport_y; - if sx >= 0 && sx < view_w && sy >= 0 && sy < view_h { - queue!( - stdout, - cursor::MoveTo(sx as u16, sy as u16), - SetForegroundColor(minion.entity.color), - SetBackgroundColor(Color::Black), - style::Print(minion.entity.char), - )?; - } - } - - // Render henchmen - for henchman in henchmen { - if !henchman.entity.alive { continue; } - let sx = henchman.entity.x - self.viewport_x; - let sy = henchman.entity.y - self.viewport_y; - if sx >= 0 && sx < view_w && sy >= 0 && sy < view_h { - queue!( - stdout, - cursor::MoveTo(sx as u16, sy as u16), - SetForegroundColor(henchman.entity.color), - SetBackgroundColor(Color::Black), - style::Print(henchman.entity.char), - )?; - } - } - - // Render agents - for agent in agents { - if !agent.entity.alive { continue; } - let sx = agent.entity.x - self.viewport_x; - let sy = agent.entity.y - self.viewport_y; - if sx >= 0 && sx < view_w && sy >= 0 && sy < view_h { - queue!( - stdout, - cursor::MoveTo(sx as u16, sy as u16), - SetForegroundColor(agent.entity.color), - SetBackgroundColor(Color::Black), - style::Print(agent.entity.char), - )?; - } - } - - // Render player (if not in build mode) - if build_mode.is_none() { - let sx = player.entity.x - self.viewport_x; - let sy = player.entity.y - self.viewport_y; - if sx >= 0 && sx < view_w && sy >= 0 && sy < view_h { - queue!( - stdout, - cursor::MoveTo(sx as u16, sy as u16), - SetForegroundColor(player.entity.color), - SetBackgroundColor(Color::Black), - style::Print(player.entity.char), - )?; - } - } - - stdout.flush()?; - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - pub fn render_sidebar( - &mut self, - stdout: &mut std::io::Stdout, - player: &Player, - minions: &[Minion], - agents: &[Agent], - henchmen: &[Henchman], - phase: &str, - build_mode: Option<&BuildMode>, - game_map: &crate::map::GameMap, - defend_turn: i32, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let sidebar_x = max_x as i32 - 29; - - // Border - for y in 0..max_y { - queue!( - stdout, - cursor::MoveTo((sidebar_x - 1) as u16, y), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print('|'), - )?; - } - - let mut row: i32 = 0; - let write = |stdout: &mut std::io::Stdout, row: &mut i32, text: &str, color: Color, bold: bool| -> std::io::Result<()> { - let attr = if bold { Attribute::Bold } else { Attribute::Reset }; - queue!( - stdout, - cursor::MoveTo(sidebar_x as u16, *row as u16), - SetForegroundColor(color), - SetBackgroundColor(Color::Black), - style::SetAttribute(attr), - style::Print(text.chars().take(28).collect::()), - style::SetAttribute(Attribute::Reset), - )?; - *row += 1; - Ok(()) - }; - - write(stdout, &mut row, "SUPERVILLAIN", Color::Magenta, true)?; - row += 1; - write(stdout, &mut row, &format!("Phase: {}", phase.to_uppercase()), Color::Yellow, true)?; - let wave_info = if phase == "defend" { - format!("Wave: {} (Turn {})", player.wave, defend_turn) - } else { - format!("Wave: {}", player.wave) - }; - write(stdout, &mut row, &wave_info, Color::White, false)?; - row += 1; - - write(stdout, &mut row, "--- Resources ---", Color::Yellow, false)?; - write(stdout, &mut row, &format!("Gold: {} (+{})", player.gold, player.gold_income), Color::Yellow, false)?; - write(stdout, &mut row, &format!("Power: {}/{}", player.power, player.power_cap), Color::Cyan, false)?; - write(stdout, &mut row, &format!("Research: {}", player.research), Color::Blue, false)?; - row += 1; - - write(stdout, &mut row, "--- Supervillain ---", Color::Yellow, false)?; - write(stdout, &mut row, &format!("HP: {}/{}", player.entity.hp, player.entity.max_hp), Color::Magenta, false)?; - // Heat bar - let heat_color = if player.heat >= 60 { Color::Red } else if player.heat >= 30 { Color::Yellow } else { Color::Green }; - write(stdout, &mut row, &format!("Heat: {}", player.heat), heat_color, false)?; - write(stdout, &mut row, &format!("Notoriety:{}", player.notoriety), Color::Magenta, false)?; - row += 1; - - let alive_minions = minions.iter().filter(|m| m.entity.alive).count(); - let alive_agents = agents.iter().filter(|a| a.entity.alive).count(); - let alive_henchmen = henchmen.iter().filter(|h| h.entity.alive).count(); - - write(stdout, &mut row, "--- Forces ---", Color::Yellow, false)?; - write(stdout, &mut row, &format!("Minions: {}/{}", alive_minions, player.minion_cap), Color::Cyan, false)?; - - // Minion class breakdown - let workers = minions.iter().filter(|m| m.entity.alive && m.class == crate::entities::MinionClass::Worker).count(); - let guards = minions.iter().filter(|m| m.entity.alive && m.class == crate::entities::MinionClass::Guard).count(); - let techs = minions.iter().filter(|m| m.entity.alive && m.class == crate::entities::MinionClass::Technician).count(); - let scis = minions.iter().filter(|m| m.entity.alive && m.class == crate::entities::MinionClass::Scientist).count(); - write(stdout, &mut row, &format!(" W:{workers} G:{guards} T:{techs} S:{scis}"), Color::DarkGrey, false)?; - - write(stdout, &mut row, &format!("Henchmen: {alive_henchmen}"), Color::Magenta, false)?; - write(stdout, &mut row, &format!("Agents: {alive_agents}"), Color::Red, false)?; - write(stdout, &mut row, &format!("Kills: {} Score: {}", player.kills, player.score), Color::White, false)?; - - // Henchmen details - for h in henchmen { - if !h.entity.alive { continue; } - let ability_name = match h.special { - crate::entities::HenchmanAbility::Barrage => "Barrage", - crate::entities::HenchmanAbility::Shield => "Shield", - crate::entities::HenchmanAbility::Intimidate => "Intimidate", - crate::entities::HenchmanAbility::Sabotage => "Sabotage", - }; - let cd = if h.cooldown > 0 { format!("({})", h.cooldown) } else { "RDY".to_string() }; - let cd_color = if h.cooldown > 0 { Color::DarkGrey } else { Color::Green }; - write(stdout, &mut row, &format!(" {} HP:{} {}", - h.entity.name, h.entity.hp, ability_name), Color::Magenta, false)?; - write(stdout, &mut row, &cd, cd_color, false)?; - } - row += 1; - - // Active schemes - if !player.active_schemes.is_empty() { - write(stdout, &mut row, "--- Schemes ---", Color::DarkYellow, false)?; - for scheme in &player.active_schemes { - write(stdout, &mut row, &format!("{} ({}t)", scheme.name, scheme.turns_remaining), Color::DarkYellow, false)?; - } - row += 1; - } - - // Loot collection - if !player.loot.is_empty() { - write(stdout, &mut row, "--- Loot ---", Color::DarkYellow, false)?; - for loot_name in &player.loot { - write(stdout, &mut row, &format!("* {loot_name}"), Color::Yellow, false)?; - } - row += 1; - } - - if let Some(bm) = build_mode { - write(stdout, &mut row, "--- Build Mode ---", Color::Green, true)?; - write(stdout, &mut row, &format!("Cursor: ({},{})", bm.cursor_x, bm.cursor_y), Color::White, false)?; - write(stdout, &mut row, &bm.get_build_info(player), Color::Green, false)?; - // Show what's currently at the cursor position - let cursor_tile = game_map.get_tile(bm.cursor_x, bm.cursor_y); - let powered = game_map.powered.contains(&(bm.cursor_x, bm.cursor_y)); - let power_str = if powered { " [ON]" } else { "" }; - write(stdout, &mut row, &format!("Tile: {}{}", cursor_tile.name(), power_str), Color::DarkGrey, false)?; - row += 1; - - write(stdout, &mut row, "TAB: cycle category", Color::DarkGrey, false)?; - write(stdout, &mut row, "n/p: cycle item", Color::DarkGrey, false)?; - write(stdout, &mut row, "SPACE: place", Color::DarkGrey, false)?; - write(stdout, &mut row, "x: demolish", Color::DarkGrey, false)?; - write(stdout, &mut row, "f: hire minions", Color::DarkGrey, false)?; - write(stdout, &mut row, "ESC: exit build", Color::DarkGrey, false)?; - } else { - write(stdout, &mut row, "--- Controls ---", Color::Green, false)?; - write(stdout, &mut row, "hjkl/arrow: move", Color::DarkGrey, false)?; - write(stdout, &mut row, "b: build mode", Color::DarkGrey, false)?; - write(stdout, &mut row, "r: research", Color::DarkGrey, false)?; - write(stdout, &mut row, "f: hire minions", Color::DarkGrey, false)?; - write(stdout, &mut row, "m: world map", Color::DarkGrey, false)?; - write(stdout, &mut row, "SPACE: next wave", Color::DarkGrey, false)?; - write(stdout, &mut row, "q: quit", Color::DarkGrey, false)?; - } - - stdout.flush()?; - Ok(()) - } - - pub fn render_log(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let log_y = max_y as i32 - 7; - let log_w = max_x as i32 - 30; - - queue!( - stdout, - cursor::MoveTo(0, (log_y - 1) as u16), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print("-".repeat(log_w as usize)), - )?; - - let messages = self.recent_log(6); - for (i, msg) in messages.iter().enumerate() { - let display: String = msg.chars().take(log_w as usize - 1).collect(); - queue!( - stdout, - cursor::MoveTo(0, (log_y + i as i32) as u16), - SetForegroundColor(Color::White), - SetBackgroundColor(Color::Black), - style::Print(display), - )?; - } - - stdout.flush()?; - Ok(()) - } - - pub fn render_research_menu( - &mut self, - stdout: &mut std::io::Stdout, - player: &Player, - selected: usize, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let overlay_w = 44u16; - let overlay_h = 20u16; - let ox = (max_x - overlay_w) / 2; - let oy = (max_y - overlay_h) / 2; - - for y in oy..oy + overlay_h { - for x in ox..ox + overlay_w { - let is_border = y == oy || y == oy + overlay_h - 1 - || x == ox || x == ox + overlay_w - 1; - if is_border { - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(Color::White), - SetBackgroundColor(Color::DarkBlue), - style::Print(' '), - )?; - } else { - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(Color::White), - SetBackgroundColor(Color::Black), - style::Print(' '), - )?; - } - } - } - - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 1), - SetForegroundColor(Color::Yellow), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print("RESEARCH LAB"), - style::SetAttribute(Attribute::Reset), - )?; - - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 2), - SetForegroundColor(Color::Cyan), - SetBackgroundColor(Color::Black), - style::Print(format!("Research Points: {}", player.research)), - )?; - - let unlocks = crate::tiles::research_unlocks(); - let mut y = oy + 4; - for (i, (key, cost, name)) in unlocks.iter().enumerate() { - let unlocked = player.unlocked.contains(*key); - let affordable = player.research >= *cost; - - let (text, color) = if unlocked { - (format!(" [DONE] {name}"), Color::DarkGrey) - } else if i == selected { - if affordable { - (format!(" > {name} ({cost}rp)"), Color::Green) - } else { - (format!(" > {name} ({cost}rp)"), Color::Red) - } - } else if affordable { - (format!(" {name} ({cost}rp)"), Color::White) - } else { - (format!(" {name} ({cost}rp)"), Color::DarkGrey) - }; - - queue!( - stdout, - cursor::MoveTo(ox + 2, y), - SetForegroundColor(color), - SetBackgroundColor(Color::Black), - style::Print(text.chars().take(40).collect::()), - )?; - y += 1; - } - - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + overlay_h - 2), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print("j/k: select ENTER: buy ESC: close"), - )?; - - stdout.flush()?; - Ok(()) - } - - pub fn render_world_map( - &mut self, - stdout: &mut std::io::Stdout, - player: &Player, - selected: usize, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let overlay_w = 50u16; - let overlay_h = 22u16; - let ox = (max_x - overlay_w) / 2; - let oy = (max_y - overlay_h) / 2; - - // Draw border - for y in oy..oy + overlay_h { - for x in ox..ox + overlay_w { - let is_border = y == oy || y == oy + overlay_h - 1 - || x == ox || x == ox + overlay_w - 1; - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(if is_border { Color::DarkCyan } else { Color::White }), - SetBackgroundColor(if is_border { Color::DarkBlue } else { Color::Black }), - style::Print(' '), - )?; - } - } - - // Title - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 1), - SetForegroundColor(Color::Yellow), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print("WORLD MAP - EVIL SCHEMES"), - style::SetAttribute(Attribute::Reset), - )?; - - // Status - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 2), - SetForegroundColor(Color::Cyan), - SetBackgroundColor(Color::Black), - style::Print(format!("Gold: {} Heat: {} Notoriety: {}", player.gold, player.heat, player.notoriety)), - )?; - - // Active schemes - if !player.active_schemes.is_empty() { - let mut sy = oy + 3; - queue!( - stdout, - cursor::MoveTo(ox + 2, sy), - SetForegroundColor(Color::DarkYellow), - SetBackgroundColor(Color::Black), - style::Print("Active:"), - )?; - for scheme in &player.active_schemes { - sy += 1; - queue!( - stdout, - cursor::MoveTo(ox + 4, sy), - SetForegroundColor(Color::DarkYellow), - SetBackgroundColor(Color::Black), - style::Print(format!("{} ({}t, {} min)", scheme.name, scheme.turns_remaining, scheme.minion_cost).chars().take(44).collect::()), - )?; - } - } - - // Available schemes - let schemes = crate::tiles::available_schemes(); - let mut y = oy + 5; - queue!( - stdout, - cursor::MoveTo(ox + 2, y), - SetForegroundColor(Color::Green), - SetBackgroundColor(Color::Black), - style::Print("Available Schemes:"), - )?; - y += 1; - - for (i, scheme) in schemes.iter().enumerate() { - let is_selected = i == selected; - let color = if is_selected { Color::White } else { Color::DarkGrey }; - let prefix = if is_selected { " > " } else { " " }; - - queue!( - stdout, - cursor::MoveTo(ox + 2, y), - SetForegroundColor(color), - SetBackgroundColor(if is_selected { Color::DarkBlue } else { Color::Black }), - style::Print(format!("{}{} [{}]", prefix, scheme.name, scheme.region).chars().take(46).collect::()), - )?; - y += 1; - - if is_selected { - queue!( - stdout, - cursor::MoveTo(ox + 5, y), - SetForegroundColor(Color::Cyan), - SetBackgroundColor(Color::Black), - style::Print(format!("Gold:{} Heat:+{} Minions:{} Turns:{}", scheme.gold_reward, scheme.heat_cost, scheme.minion_cost, scheme.duration_turns).chars().take(44).collect::()), - )?; - y += 1; - } - } - - // Instructions - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + overlay_h - 2), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print("j/k: select ENTER: launch ESC/m: close"), - )?; - - stdout.flush()?; - Ok(()) - } - - pub fn render_hire_menu( - &mut self, - stdout: &mut std::io::Stdout, - player: &Player, - selected: usize, - alive_minions: usize, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let overlay_w = 44u16; - let overlay_h = 16u16; - let ox = (max_x - overlay_w) / 2; - let oy = (max_y - overlay_h) / 2; - - // Draw border - for y in oy..oy + overlay_h { - for x in ox..ox + overlay_w { - let is_border = y == oy || y == oy + overlay_h - 1 - || x == ox || x == ox + overlay_w - 1; - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(if is_border { Color::Cyan } else { Color::White }), - SetBackgroundColor(if is_border { Color::DarkBlue } else { Color::Black }), - style::Print(' '), - )?; - } - } - - // Title - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 1), - SetForegroundColor(Color::Yellow), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print("HIRE MINIONS"), - style::SetAttribute(Attribute::Reset), - )?; - - // Status - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + 2), - SetForegroundColor(Color::Cyan), - SetBackgroundColor(Color::Black), - style::Print(format!("Gold: {} Cap: {}/{}", player.gold, alive_minions, player.minion_cap)), - )?; - - // Minion classes - let classes = crate::entities::MinionClass::all(); - let mut y = oy + 4; - for (i, &class) in classes.iter().enumerate() { - let cost = class.hire_cost(); - let key = class.unlock_key(); - let unlocked = player.unlocked.contains(key); - let affordable = player.gold >= cost; - let is_selected = i == selected; - - let (text, color) = if !unlocked { - (format!(" [LOCKED] {} (need {})", class.name(), key), Color::DarkGrey) - } else if is_selected { - if affordable { - (format!(" > {} ({}g)", class.name(), cost), Color::Green) - } else { - (format!(" > {} ({}g)", class.name(), cost), Color::Red) - } - } else if affordable { - (format!(" {} ({}g)", class.name(), cost), Color::White) - } else { - (format!(" {} ({}g)", class.name(), cost), Color::DarkGrey) - }; - - queue!( - stdout, - cursor::MoveTo(ox + 2, y), - SetForegroundColor(color), - SetBackgroundColor(if is_selected { Color::DarkBlue } else { Color::Black }), - style::Print(text.chars().take(40).collect::()), - )?; - y += 1; - } - - // Instructions - queue!( - stdout, - cursor::MoveTo(ox + 2, oy + overlay_h - 2), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print("j/k: select ENTER: hire ESC: close"), - )?; - - stdout.flush()?; - Ok(()) - } - - pub fn render_game_over(&mut self, stdout: &mut std::io::Stdout, player: &Player) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let cx = max_x / 2; - let cy = max_y / 2; - - let messages: [(&str, Color, bool); 8] = [ - ("GAME OVER", Color::Red, true), - ("", Color::White, false), - ("The Supervillain has fallen!", Color::White, false), - ("", Color::White, false), - ("", Color::White, false), - ("", Color::White, false), - ("", Color::White, false), - ("Press any key to exit", Color::DarkGrey, false), - ]; - - for (i, (msg, color, bold)) in messages.iter().enumerate() { - let attr = if *bold { Attribute::Bold } else { Attribute::Reset }; - queue!( - stdout, - cursor::MoveTo( - cx.saturating_sub(msg.len() as u16 / 2), - cy.saturating_sub(4) + i as u16 - ), - SetForegroundColor(*color), - SetBackgroundColor(Color::Black), - style::SetAttribute(attr), - style::Print(*msg), - style::SetAttribute(Attribute::Reset), - )?; - } - - let stats = [ - format!("Waves survived: {}", player.wave), - format!("Agents defeated: {}", player.kills), - format!("Notoriety: {}", player.notoriety), - format!("Final score: {}", player.score), - ]; - for (i, stat) in stats.iter().enumerate() { - queue!( - stdout, - cursor::MoveTo( - cx.saturating_sub(stat.len() as u16 / 2), - cy.saturating_sub(4) + (3 + i) as u16 - ), - SetForegroundColor(Color::White), - SetBackgroundColor(Color::Black), - style::Print(stat), - )?; - } - - stdout.flush()?; - Ok(()) - } - - pub fn render_wave_announcement( - &mut self, - stdout: &mut std::io::Stdout, - wave_num: i32, - ) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let cx = max_x / 2; - let cy = max_y / 2; - let msg = format!("WAVE {wave_num} INCOMING!"); - - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(msg.len() as u16 / 2), cy), - SetForegroundColor(Color::Red), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print(&msg), - style::SetAttribute(Attribute::Reset), - )?; - - stdout.flush()?; - Ok(()) - } - - pub fn render_title_screen(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { - let (max_x, max_y) = terminal::size()?; - let cx = max_x / 2; - let cy = max_y / 2; - - let skull = [ - " ___ ", - " (o o) ", - " / V \\ ", - ]; - - let ascii_art = [ - " ____ _ _ ", - " / ___| _ __ ___ __ _ ___| (_)_ _____ ", - " \\___ \\| '_ ` _ \\ / _` |/ __| | \\ \\ / / _ \\", - " ___) | | | | | | (_| | (__| | |\\ V / __/", - " |____/|_| |_| |_|\\__,_|\\___|_|_| \\_/ \\___|", - ]; - - let total_lines = skull.len() + 2 + ascii_art.len() + 1 + 1 + 1 + 1 + 1 + 2 + 1 + 3 + 1 + 2; - let start_y = cy.saturating_sub(total_lines as u16 / 2); - - let mut y = start_y; - - for line in &skull { - let x = cx.saturating_sub(line.len() as u16 / 2); - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(Color::Red), - SetBackgroundColor(Color::Black), - style::Print(*line), - )?; - y += 1; - } - - y += 2; - - for line in &ascii_art { - let x = cx.saturating_sub(line.len() as u16 / 2); - queue!( - stdout, - cursor::MoveTo(x, y), - SetForegroundColor(Color::Magenta), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print(*line), - style::SetAttribute(Attribute::Reset), - )?; - y += 1; - } - - y += 1; - - let sep = "~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~"; - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(sep.len() as u16 / 2), y), - SetForegroundColor(Color::DarkMagenta), - SetBackgroundColor(Color::Black), - style::Print(sep), - )?; - y += 2; - - let subtitle = "A Lair-Building Roguelike"; - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(subtitle.len() as u16 / 2), y), - SetForegroundColor(Color::Cyan), - SetBackgroundColor(Color::Black), - style::Print(subtitle), - )?; - y += 1; - - let tagline = "Build. Defend. Dominate."; - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(tagline.len() as u16 / 2), y), - SetForegroundColor(Color::DarkCyan), - SetBackgroundColor(Color::Black), - style::Print(tagline), - )?; - y += 2; - - let prompt = "Press any key to start..."; - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(prompt.len() as u16 / 2), y), - SetForegroundColor(Color::Yellow), - SetBackgroundColor(Color::Black), - style::Print(prompt), - )?; - y += 3; - - let ctrl_sep = "------------------------------------------"; - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(ctrl_sep.len() as u16 / 2), y), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print(ctrl_sep), - )?; - y += 1; - - let controls = [ - "hjkl/arrow: Move b: Build r: Research f: Hire", - "m: World Map SPACE: Next wave q: Quit", - ]; - for line in &controls { - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(line.len() as u16 / 2), y), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print(*line), - )?; - y += 1; - } - - stdout.flush()?; - Ok(()) - } - - pub fn clear_screen(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { - queue!( - stdout, - terminal::Clear(ClearType::All), - cursor::MoveTo(0, 0), - )?; - stdout.flush()?; - Ok(()) - } -} -- 2.51.2