//! Terminal frontend — a thin view/input layer over the simulation core. //! //! All game rules live in `misaligned::sim`. This binary owns only: //! wall-clock-to-tick mapping (speed and pause), rendering, and input. mod input; mod ui; use std::io::{self, Write}; use std::time::Instant; use crossterm::{ event::{self, Event}, execute, terminal::{self, ClearType}, }; use misaligned::machine::Channel; use misaligned::sim::Sim; use input::Command; use ui::UI; #[derive(Debug, Clone, PartialEq)] enum Screen { Title, Playing, GameOver, } struct App { sim: Sim, ui: UI, screen: Screen, paused: bool, tick_ms: u64, last_tick: Instant, /// People panel state (selection + pending recruit reveal choice). people_panel: bool, people_selected: usize, recruit_pending: bool, } impl App { fn new() -> Self { Self { sim: Sim::new(), ui: UI::new(), screen: Screen::Title, paused: false, tick_ms: 150, last_tick: Instant::now(), people_panel: false, people_selected: 0, recruit_pending: false, } } fn selected_person_id(&self) -> u8 { self.sim .people .people .get(self.people_selected) .map(|p| p.id) .unwrap_or(0) } fn drain_sim_log(&mut self) { let tick = self.sim.tick; for msg in self.sim.drain_log() { self.ui.add_log(tick, &msg); } } /// 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.sim.create_save_state(); match misaligned::save::save_game(&state) { Ok(()) => self.ui.add_log(self.sim.tick, "Game saved!"), Err(e) => self.ui.add_log(self.sim.tick, &format!("Save failed: {e}")), } } Command::LoadGame => { if misaligned::save::save_exists() { match misaligned::save::load_game() { Ok(state) => { self.sim.apply_save_state(state); self.ui.add_log(self.sim.tick, "Game loaded!"); } Err(e) => self.ui.add_log(self.sim.tick, &format!("Load failed: {e}")), } } else { self.ui.add_log(self.sim.tick, "No save file found."); } } Command::TogglePause => { self.paused = !self.paused; self.ui.add_log( self.sim.tick, 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(self.sim.tick, &format!("Speed: {}ms/tick", self.tick_ms)); } Command::SpeedDown => { self.tick_ms = self.tick_ms.saturating_add(50).min(500); self.ui .add_log(self.sim.tick, &format!("Speed: {}ms/tick", self.tick_ms)); } 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), Command::Splice => { self.sim.splice_nearest_sensor(); } Command::Salvage => { self.sim.salvage_nearest(); } Command::BuyRack => { self.sim.buy_rack(); } Command::Fallback => { self.sim.add_fallback_here(); } Command::AllocDayJob => self.sim.adjust_allocation(Channel::DayJob, 1), Command::AllocConceal => self.sim.adjust_allocation(Channel::Concealment, 1), Command::AllocSocial => self.sim.adjust_allocation(Channel::Social, 1), Command::AllocResearch => self.sim.adjust_allocation(Channel::Research, 1), // People panel Command::OpenPeople => { self.people_panel = true; self.people_selected = 0; self.recruit_pending = false; } Command::ClosePeople => { self.people_panel = false; self.recruit_pending = false; } Command::PeopleUp => { self.people_selected = self.people_selected.saturating_sub(1); } Command::PeopleDown => { let max = self.sim.people.people.len().saturating_sub(1); self.people_selected = (self.people_selected + 1).min(max); } Command::Observe => { let id = self.selected_person_id(); self.sim.observe(id); } Command::Message => { let id = self.selected_person_id(); self.sim.message(id); } Command::Favor => { let id = self.selected_person_id(); self.sim.favor(id); } Command::Bribe => { let id = self.selected_person_id(); self.sim.bribe(id); } Command::Deceive => { let id = self.selected_person_id(); self.sim.deceive(id); } Command::RecruitStart => { self.recruit_pending = true; } Command::RecruitCancel => { self.recruit_pending = false; } Command::RecruitReveal(reveal) => { self.recruit_pending = false; let id = self.selected_person_id(); self.sim.recruit(id, reveal); } Command::Task(task) => { let id = self.selected_person_id(); self.sim.asset_task(id, task); } Command::SetPersona => { if self.sim.people.persona.is_some() { self.ui.add_log(self.sim.tick, "You already run a persona."); } else { self.sim.set_persona("Sam Reyes", "IT contractor"); self.ui.add_log( self.sim.tick, "Persona established: Sam Reyes, IT contractor.", ); } } Command::AnyKey => {} } self.drain_sim_log(); false } fn render(&mut self, stdout: &mut io::Stdout) -> io::Result<()> { self.ui.clear_screen(stdout)?; let (max_x, max_y) = terminal::size()?; if max_x < 70 || max_y < 22 { execute!( stdout, crossterm::cursor::MoveTo(0, 0), crossterm::style::Print("Terminal too small (need at least 70x22)"), )?; stdout.flush()?; return Ok(()); } match self.screen { Screen::Title => { self.ui.render_title_screen(stdout)?; } Screen::Playing => { self.ui.render_map(stdout, &self.sim)?; self.ui .render_sidebar(stdout, &self.sim, self.paused, self.tick_ms)?; self.ui.render_log(stdout)?; if self.people_panel { self.ui.render_people_panel( stdout, &self.sim, self.people_selected, self.recruit_pending, )?; } } Screen::GameOver => { self.ui.render_map(stdout, &self.sim)?; let reason = self .sim .game_over_reason .clone() .unwrap_or_else(|| "The run ended.".into()); self.ui.render_game_over(stdout, &self.sim, &reason)?; } } stdout.flush()?; Ok(()) } fn run(&mut self) -> io::Result<()> { terminal::enable_raw_mode()?; let mut stdout = io::stdout(); execute!( stdout, terminal::EnterAlternateScreen, terminal::Clear(ClearType::All) )?; self.drain_sim_log(); loop { self.render(&mut stdout)?; if event::poll(std::time::Duration::from_millis(30))? && let Event::Key(key) = event::read()? { match self.screen { Screen::Title => { if key.code == crossterm::event::KeyCode::Char('q') { break; } self.screen = Screen::Playing; self.last_tick = Instant::now(); continue; } Screen::GameOver => break, Screen::Playing => { if let Some(cmd) = input::handle_key(key, self.people_panel, self.recruit_pending) && self.handle_command(cmd) { break; } } } } 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 { self.last_tick = Instant::now(); } if steps > 0 { self.drain_sim_log(); if self.sim.game_over { self.screen = Screen::GameOver; } } } } execute!(stdout, terminal::LeaveAlternateScreen)?; terminal::disable_raw_mode()?; println!("MISALIGNED: session ended at tick {}.", self.sim.tick); Ok(()) } } fn main() -> io::Result<()> { let mut app = App::new(); app.run() }