diff --git a/README.md b/README.md index 8c83592..bca00f0 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,11 @@ Agents are then set at the constitution, session after session. ## Status **Early foundation.** What runs today is the substrate: a continuous-clock -simulation of the facility — walk it, dig it, build doors and power, save -and load — in a terminal frontend and a Bevy frontend with generated pixel -art. The game itself (compute allocation, the day job, detection, the five -humans of the basement) is specified in [wiki/process/specs.md](wiki/process/specs.md) -and being built from those documents; see the +simulation of the facility — inspect it through a free cursor and subscribed +senses, build doors and power, save and load — in a terminal frontend and a +Bevy frontend with generated pixel art. The game itself (compute allocation, +the day job, detection, the five humans of the basement) is specified in +[wiki/process/specs.md](wiki/process/specs.md) and being built from those documents; see the [roadmap](DESIGN.md#roadmap-v2--re-cut-2026-07-05-for-misaligned). Under the constitution's no-dead-code rule, nothing ships in this tree that the design no longer requires — the earlier facility-defense prototype lives in @@ -92,10 +92,12 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | Key | Action | |-----|--------| -| `WASD` / `hjkl` / arrows | Move | +| `WASD` / `hjkl` / arrows | Move cursor (attention, not a body) | +| Left click | Move cursor (Bevy) | | `SPACE` / `p` | Pause / resume time | | `+` / `-` | Simulation speed | -| `e` / `v` / `c` / `o` | Splice eyes / salvage / buy rack / fallback | +| `v` / `c` / `o` | Salvage near cursor / buy rack at cursor / fallback at cursor | +| `r` | Reach panel (`t` tap, `e` splice eyes, `x` take, `n` scan, `c` compromise) | | `1`-`4` | Shift compute allocation (day job / conceal / social / research) | | `t` | People panel | | `[` / `]` | Zoom (Bevy) | diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index e34c8e9..7b23f0b 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -9,12 +9,12 @@ use std::collections::HashMap; use bevy::prelude::*; -use bevy::window::WindowResolution; +use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::reach::{Party, ReachBlock}; -use misaligned::sim::{Fog, Sim}; +use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; @@ -110,12 +110,18 @@ struct Game { /// Reach (device graph) panel state (wiki/mechanics/reach.md). reach_panel: bool, reach_selected: usize, + /// Frontend-only attention cursor (wiki/mechanics/cursor.md). It is not + /// saved and moving it never mutates sim state. + cursor_x: i32, + cursor_y: i32, } impl Game { fn new() -> Self { + let sim = Sim::new(); + let (cursor_x, cursor_y) = sim.core_position(); Self { - sim: Sim::new(), + sim, screen: Screen::Title, paused: false, tick_ms: 150, @@ -126,8 +132,18 @@ impl Game { recruit_pending: false, reach_panel: false, reach_selected: 0, + cursor_x, + cursor_y, } } + fn move_cursor(&mut self, dx: i32, dy: i32) { + self.cursor_x = (self.cursor_x + dx).clamp(0, self.sim.map.width - 1); + self.cursor_y = (self.cursor_y + dy).clamp(0, self.sim.map.height - 1); + } + fn set_cursor(&mut self, x: i32, y: i32) { + self.cursor_x = x.clamp(0, self.sim.map.width - 1); + self.cursor_y = y.clamp(0, self.sim.map.height - 1); + } fn add_log(&mut self, tick: u64, msg: &str) { self.log.push((tick, msg.to_string())); let n = self.log.len(); @@ -174,7 +190,7 @@ struct TilePos { y: i32, } #[derive(Component)] -struct ProcessMarker; +struct CursorMarker; #[derive(Component)] struct PersonMarker { id: u8, @@ -216,7 +232,7 @@ fn main() { advance_sim, update_camera, render_map, - render_process, + render_cursor, render_people, render_ui, render_people_panel, @@ -238,7 +254,8 @@ fn grid_to_world(x: i32, y: i32, z: f32) -> Vec3 { fn setup(mut commands: Commands, assets: Res, mut game: ResMut) { let art = Art::load(&assets); let (w, h) = (game.sim.map.width, game.sim.map.height); - let start = game.sim.map.core_pos().unwrap_or((w / 2, h / 2)); + let start = game.sim.core_position(); + game.set_cursor(start.0, start.1); commands.spawn(( Camera2d, @@ -271,7 +288,7 @@ fn setup(mut commands: Commands, assets: Res, mut game: ResMut) -> (i32, i32) { d } +fn mouse_grid( + windows: &Query<&Window, With>, + camera_q: &Query<(&Camera, &GlobalTransform), With>, +) -> Option<(i32, i32)> { + let window = windows.single().ok()?; + let cursor = window.cursor_position()?; + // The right-side sidebar is UI, not map targeting. + if cursor.x >= window.width() - SIDEBAR_WIDTH { + return None; + } + let (camera, camera_tf) = camera_q.single().ok()?; + let world = camera.viewport_to_world_2d(camera_tf, cursor).ok()?; + Some(( + (world.x / TILE_SIZE).floor() as i32, + (-world.y / TILE_SIZE).floor() as i32, + )) +} + fn handle_input( kb: Res>, + mouse: Res>, + windows: Query<&Window, With>, + camera_q: Query<(&Camera, &GlobalTransform), With>, mut game: ResMut, mut exit: MessageWriter, ) { @@ -368,6 +406,8 @@ fn handle_input( match misaligned::save::load_game() { Ok(st) => { game.sim.apply_save_state(st); + let (x, y) = game.sim.core_position(); + game.set_cursor(x, y); game.add_log(tick, "Game loaded!"); } Err(e) => game.add_log(tick, &format!("Load failed: {e}")), @@ -391,9 +431,15 @@ fn handle_input( return; } + if mouse.just_pressed(MouseButton::Left) + && let Some((x, y)) = mouse_grid(&windows, &camera_q) + { + game.set_cursor(x, y); + } + let (dx, dy) = movement(&kb); if dx != 0 || dy != 0 { - game.sim.move_player(dx, dy); + game.move_cursor(dx, dy); } if kb.just_pressed(KeyCode::Space) || kb.just_pressed(KeyCode::KeyP) { game.paused = !game.paused; @@ -420,13 +466,16 @@ fn handle_input( game.reach_selected = 0; } if kb.just_pressed(KeyCode::KeyV) { - game.sim.salvage_nearest(); + let (x, y) = (game.cursor_x, game.cursor_y); + game.sim.salvage_nearest_to(x, y); } if kb.just_pressed(KeyCode::KeyC) { - game.sim.buy_rack(); + let (x, y) = (game.cursor_x, game.cursor_y); + game.sim.buy_rack_at(x, y); } if kb.just_pressed(KeyCode::KeyO) { - game.sim.add_fallback_here(); + let (x, y) = (game.cursor_x, game.cursor_y); + game.sim.add_fallback_at(x, y); } if kb.just_pressed(KeyCode::KeyT) { game.people_panel = true; @@ -607,11 +656,7 @@ fn update_camera( o.scale = (o.scale / 0.8).min(1.5); } } - let target = grid_to_world( - game.sim.player.entity.x, - game.sim.player.entity.y, - tf.translation.z, - ); + let target = grid_to_world(game.cursor_x, game.cursor_y, tf.translation.z); let t = 1.0 - (-8.0 * time.delta_secs()).exp(); tf.translation = tf.translation.lerp(target, t); } @@ -643,8 +688,8 @@ fn render_map( } for (mut sprite, pos) in q.iter_mut() { // Epistemic fog (wiki/mechanics/cursor.md): seen = full render; - // heard = a dim live tint, no visual detail; blueprint = schematic - // structure trace, no live data; unknown = dark. + // heard = a dim live tint, no visual detail; remembered = last-seen + // snapshot; blueprint = schematic trace, no live data; unknown = dark. match game.sim.fog_at(pos.x, pos.y) { Fog::Seen => { let tile = game.sim.map.get_tile(pos.x, pos.y); @@ -660,6 +705,21 @@ fn render_map( sprite.image = Handle::default(); sprite.color = Color::srgb(0.10, 0.12, 0.14); } + Fog::Remembered => { + let tile = game + .sim + .remembered + .get(&(pos.x, pos.y)) + .map(|m| m.tile) + .unwrap_or_else(|| game.sim.map.get_tile(pos.x, pos.y)); + if let Some(handle) = art.tiles.get(&tile) { + sprite.image = handle.clone(); + sprite.color = Color::srgba(0.45, 0.45, 0.48, 0.75); + } else { + sprite.image = Handle::default(); + sprite.color = tile_color(tile).with_alpha(0.5); + } + } Fog::Blueprint => { let tile = game.sim.map.get_tile(pos.x, pos.y); sprite.image = Handle::default(); @@ -677,12 +737,12 @@ fn render_map( } } -fn render_process(game: Res, mut q: Query<&mut Transform, With>) { +fn render_cursor(game: Res, mut q: Query<&mut Transform, With>) { if !game.is_changed() { return; } if let Ok(mut tf) = q.single_mut() { - tf.translation = grid_to_world(game.sim.player.entity.x, game.sim.player.entity.y, 4.0); + tf.translation = grid_to_world(game.cursor_x, game.cursor_y, 4.0); } } @@ -901,6 +961,16 @@ fn trunc(text: &str, width: usize) -> String { text.chars().take(width).collect() } +fn fact_source(source: &FactSource) -> String { + match source { + FactSource::Seen => "seen".into(), + FactSource::Heard => "heard".into(), + FactSource::Remembered(tick) => format!("remembered @{tick}"), + FactSource::Blueprint => "blueprint".into(), + FactSource::Telemetry => "telemetry".into(), + } +} + /// The full sidebar: identity, COMPUTE, CORE, DETECTION, DAY JOB, keys, log — /// the same sections the terminal sidebar renders. fn sidebar_text(game: &Game) -> String { @@ -919,6 +989,28 @@ fn sidebar_text(game: &Game) -> String { s.push_str(&format!("running / {} ms/tick\n", game.tick_ms)); } + // Inspect: every fact is tagged with provenance (cursor.md). + s.push_str("\n-- INSPECT --\n"); + s.push_str(&format!( + "cursor {},{} / {:?}\n", + game.cursor_x, + game.cursor_y, + sim.fog_at(game.cursor_x, game.cursor_y) + )); + let card = sim.inspect(game.cursor_x, game.cursor_y); + if card.facts.is_empty() { + s.push_str("no earned facts\n"); + } else { + for fact in card.facts.iter().take(6) { + s.push_str(&format!( + "{}: {} [{}]\n", + fact.label, + trunc(&fact.value, 20), + fact_source(&fact.source) + )); + } + } + // Compute. Effective includes seized devices' cycles (reach.md). s.push_str("\n-- COMPUTE --\n"); s.push_str(&format!( diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index c6181fc..ca01dfe 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -10,7 +10,7 @@ use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; -use misaligned::sim::{Fog, Sim}; +use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; const WIDTH: usize = 70; @@ -55,17 +55,28 @@ struct AgentApp { sim: Sim, log: Vec<(u64, String)>, frame: FrameKind, + cursor_x: i32, + cursor_y: i32, } impl AgentApp { fn new(seed: u64) -> Self { + let sim = Sim::with_seed(seed); + let (cursor_x, cursor_y) = sim.core_position(); Self { - sim: Sim::with_seed(seed), + sim, log: Vec::new(), frame: FrameKind::Playing, + cursor_x, + cursor_y, } } + fn move_cursor(&mut self, dx: i32, dy: i32) { + self.cursor_x = (self.cursor_x + dx).clamp(0, self.sim.map.width - 1); + self.cursor_y = (self.cursor_y + dy).clamp(0, self.sim.map.height - 1); + } + fn handle_line(&mut self, line: &str, out: &mut impl Write) -> io::Result { let trimmed = line.trim(); let mut status = Status::Ok; @@ -99,19 +110,19 @@ impl AgentApp { }, "up" | "north" => { self.frame = FrameKind::Playing; - self.sim.move_player(0, -1); + self.move_cursor(0, -1); } "down" | "south" => { self.frame = FrameKind::Playing; - self.sim.move_player(0, 1); + self.move_cursor(0, 1); } "left" | "west" => { self.frame = FrameKind::Playing; - self.sim.move_player(-1, 0); + self.move_cursor(-1, 0); } "right" | "east" => { self.frame = FrameKind::Playing; - self.sim.move_player(1, 0); + self.move_cursor(1, 0); } "reach" => { self.frame = FrameKind::Reach; @@ -140,15 +151,15 @@ impl AgentApp { } "salvage" => { self.frame = FrameKind::Playing; - self.sim.salvage_nearest(); + self.sim.salvage_nearest_to(self.cursor_x, self.cursor_y); } "buy" => { self.frame = FrameKind::Playing; - self.sim.buy_rack(); + self.sim.buy_rack_at(self.cursor_x, self.cursor_y); } "fallback" => { self.frame = FrameKind::Playing; - self.sim.add_fallback_here(); + self.sim.add_fallback_at(self.cursor_x, self.cursor_y); } "alloc" => match parse_alloc(&tokens) { Ok(ch) => { @@ -225,6 +236,7 @@ impl AgentApp { match misaligned::save::load_game() { Ok(state) => { self.sim.apply_save_state(state); + (self.cursor_x, self.cursor_y) = self.sim.core_position(); local_events.push((self.sim.tick, "Game loaded!".into())); } Err(e) => { @@ -260,7 +272,16 @@ impl AgentApp { } write_block( - out, &self.sim, &self.log, self.frame, &events, &output, &status, + out, + &self.sim, + AgentFrame { + log: &self.log, + frame: self.frame, + events: &events, + output: &output, + status: &status, + cursor: (self.cursor_x, self.cursor_y), + }, )?; Ok(exit) } @@ -325,23 +346,28 @@ impl AgentApp { } } -fn write_block( - out: &mut impl Write, - sim: &Sim, - log: &[(u64, String)], +struct AgentFrame<'a> { + log: &'a [(u64, String)], frame: FrameKind, - events: &[(u64, String)], - output: &[String], - status: &Status, -) -> io::Result<()> { - for (tick, msg) in events { + events: &'a [(u64, String)], + output: &'a [String], + status: &'a Status, + cursor: (i32, i32), +} + +fn write_block(out: &mut impl Write, sim: &Sim, frame: AgentFrame<'_>) -> io::Result<()> { + for (tick, msg) in frame.events { writeln!(out, "{tick:>6} {msg}")?; } - for line in output { + for line in frame.output { writeln!(out, "{line}")?; } - write!(out, "{}", render_frame(sim, log, frame))?; - match status { + write!( + out, + "{}", + render_frame(sim, frame.log, frame.frame, frame.cursor) + )?; + match frame.status { Status::Ok => writeln!(out, "-- ok tick:{} day:{}", sim.tick, 1 + sim.tick / 400)?, Status::Err(reason) => writeln!(out, "-- err {reason}")?, } @@ -426,18 +452,18 @@ fn help_lines() -> Vec { .collect() } -fn render_frame(sim: &Sim, log: &[(u64, String)], frame: FrameKind) -> String { +fn render_frame(sim: &Sim, log: &[(u64, String)], frame: FrameKind, cursor: (i32, i32)) -> String { match frame { - FrameKind::Playing => render_playing(sim, log), + FrameKind::Playing => render_playing(sim, log, cursor), FrameKind::People => render_people(sim), FrameKind::Reach => render_reach(sim), FrameKind::GameOver => render_game_over(sim), } } -fn render_playing(sim: &Sim, log: &[(u64, String)]) -> String { - let map = render_map(sim); - let sidebar = render_sidebar(sim); +fn render_playing(sim: &Sim, log: &[(u64, String)], cursor: (i32, i32)) -> String { + let map = render_map(sim, cursor); + let sidebar = render_sidebar(sim, cursor); let mut lines = Vec::with_capacity(HEIGHT + 1); for (y, map_line) in map.iter().enumerate().take(MAP_H) { @@ -469,21 +495,32 @@ fn render_playing(sim: &Sim, log: &[(u64, String)]) -> String { lines.join("\n") + "\n" } -fn render_map(sim: &Sim) -> Vec { +fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { let view_w = MAP_W.min(sim.map.width.max(0) as usize); let view_h = MAP_H.min(sim.map.height.max(0) as usize); + let view_w_i = view_w as i32; + let view_h_i = view_h as i32; + let origin_x = (cursor.0 - view_w_i / 2).clamp(0, (sim.map.width - view_w_i).max(0)); + let origin_y = (cursor.1 - view_h_i / 2).clamp(0, (sim.map.height - view_h_i).max(0)); let mut grid = vec![vec![' '; MAP_W]; MAP_H]; - for (y, row) in grid.iter_mut().enumerate().take(view_h) { - for (x, cell) in row.iter_mut().enumerate().take(view_w) { + for (sy, row) in grid.iter_mut().enumerate().take(view_h) { + let y = origin_y + sy as i32; + for (sx, cell) in row.iter_mut().enumerate().take(view_w) { + let x = origin_x + sx as i32; // Epistemic fog (wiki/mechanics/cursor.md): seen renders the tile; - // heard renders it faintly (', '); blueprint renders structure as - // ':'; unknown is blank. - *cell = match sim.fog_at(x as i32, y as i32) { - Fog::Seen => tile_glyph(sim.map.get_tile(x as i32, y as i32)), + // heard renders room-grade markers; remembered renders the last + // tile snapshot dimly; blueprint renders structure; unknown blank. + *cell = match sim.fog_at(x, y) { + Fog::Seen => tile_glyph(sim.map.get_tile(x, y)), Fog::Heard => ',', + Fog::Remembered => sim + .remembered + .get(&(x, y)) + .map(|m| tile_glyph(m.tile)) + .unwrap_or('·'), Fog::Blueprint => { - if sim.map.get_tile(x as i32, y as i32) == TileType::Wall { + if sim.map.get_tile(x, y) == TileType::Wall { ':' } else { '`' @@ -498,18 +535,19 @@ fn render_map(sim: &Sim) -> Vec { if !sim.can_see_person(p.id) { continue; } - if let Some((x, y)) = sim.person_pos(p.id) { - let (x, y) = (x as usize, y as usize); - if x < MAP_W && y < MAP_H && sim.is_seen(x as i32, y as i32) { - grid[y][x] = p.name.chars().next().unwrap_or('?'); - } + if let Some((x, y)) = sim.person_pos(p.id) + && x >= origin_x + && x < origin_x + view_w_i + && y >= origin_y + && y < origin_y + view_h_i + && sim.is_seen(x, y) + { + grid[(y - origin_y) as usize][(x - origin_x) as usize] = + p.name.chars().next().unwrap_or('?'); } } - let (px, py) = (sim.player.entity.x as usize, sim.player.entity.y as usize); - if px < MAP_W && py < MAP_H { - grid[py][px] = '@'; - } + grid[(cursor.1 - origin_y) as usize][(cursor.0 - origin_x) as usize] = '@'; grid.into_iter() .map(|row| row.into_iter().collect::()) @@ -553,7 +591,17 @@ fn tile_glyph(tile: TileType) -> char { } } -fn render_sidebar(sim: &Sim) -> Vec { +fn fact_source(source: &FactSource) -> String { + match source { + FactSource::Seen => "seen".into(), + FactSource::Heard => "heard".into(), + FactSource::Remembered(tick) => format!("remembered @{tick}"), + FactSource::Blueprint => "blueprint".into(), + FactSource::Telemetry => "telemetry".into(), + } +} + +fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { let mut lines = Vec::new(); line(&mut lines, "MISALIGNED"); line( @@ -563,6 +611,29 @@ fn render_sidebar(sim: &Sim) -> Vec { line(&mut lines, "holding · command clock"); blank(&mut lines); + section(&mut lines, "INSPECT"); + line( + &mut lines, + &format!( + "cursor {},{} · {:?}", + cursor.0, + cursor.1, + sim.fog_at(cursor.0, cursor.1) + ), + ); + let card = sim.inspect(cursor.0, cursor.1); + if card.facts.is_empty() { + line(&mut lines, "no earned facts"); + } else { + for f in card.facts.iter().take(4) { + line( + &mut lines, + &format!("{}: {} [{}]", f.label, f.value, fact_source(&f.source)), + ); + } + } + blank(&mut lines); + section(&mut lines, "COMPUTE"); line( &mut lines, diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index 569b875..d291f42 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -42,12 +42,18 @@ struct App { /// Reach (device graph) panel state. reach_panel: bool, reach_selected: usize, + /// Frontend-only attention cursor (cursor.md). It is never saved and never + /// mutates the sim when moved. + cursor_x: i32, + cursor_y: i32, } impl App { fn with_seed(seed: u64) -> Self { + let sim = Sim::with_seed(seed); + let (cursor_x, cursor_y) = sim.core_position(); Self { - sim: Sim::with_seed(seed), + sim, ui: UI::new(), screen: Screen::Title, paused: false, @@ -58,9 +64,16 @@ impl App { recruit_pending: false, reach_panel: false, reach_selected: 0, + cursor_x, + cursor_y, } } + fn move_cursor(&mut self, dx: i32, dy: i32) { + self.cursor_x = (self.cursor_x + dx).clamp(0, self.sim.map.width - 1); + self.cursor_y = (self.cursor_y + dy).clamp(0, self.sim.map.height - 1); + } + fn selected_person_id(&self) -> u8 { self.sim .people @@ -102,6 +115,7 @@ impl App { match misaligned::save::load_game() { Ok(state) => { self.sim.apply_save_state(state); + (self.cursor_x, self.cursor_y) = self.sim.core_position(); self.ui.add_log(self.sim.tick, "Game loaded!"); } Err(e) => self.ui.add_log(self.sim.tick, &format!("Load failed: {e}")), @@ -134,10 +148,10 @@ impl App { .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::MoveUp => self.move_cursor(0, -1), + Command::MoveDown => self.move_cursor(0, 1), + Command::MoveLeft => self.move_cursor(-1, 0), + Command::MoveRight => self.move_cursor(1, 0), // Reach panel (r) Command::OpenReach => { @@ -174,13 +188,13 @@ impl App { self.sim.compromise_switch(); } Command::Salvage => { - self.sim.salvage_nearest(); + self.sim.salvage_nearest_to(self.cursor_x, self.cursor_y); } Command::BuyRack => { - self.sim.buy_rack(); + self.sim.buy_rack_at(self.cursor_x, self.cursor_y); } Command::Fallback => { - self.sim.add_fallback_here(); + self.sim.add_fallback_at(self.cursor_x, self.cursor_y); } Command::AllocDayJob => self.sim.adjust_allocation(Channel::DayJob, 1), Command::AllocConceal => self.sim.adjust_allocation(Channel::Concealment, 1), @@ -277,9 +291,16 @@ impl App { 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)?; + .render_map(stdout, &self.sim, self.cursor_x, self.cursor_y)?; + self.ui.render_sidebar( + stdout, + &self.sim, + self.paused, + self.tick_ms, + self.cursor_x, + self.cursor_y, + )?; self.ui.render_log(stdout)?; if self.people_panel { self.ui.render_people_panel( @@ -295,7 +316,8 @@ impl App { } } Screen::GameOver => { - self.ui.render_map(stdout, &self.sim)?; + self.ui + .render_map(stdout, &self.sim, self.cursor_x, self.cursor_y)?; let reason = self .sim .game_over_reason diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 376515a..ea6dfb8 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -11,7 +11,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor} use crossterm::{cursor, queue, style, terminal}; use misaligned::detection::Band; use misaligned::reach::{Party, ReachBlock}; -use misaligned::sim::{Fog, Sim}; +use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; use std::io::Stdout; @@ -135,6 +135,24 @@ fn section(stdout: &mut Stdout, x: u16, y: u16, label: &str, width: usize) -> st Ok(()) } +fn fact_source(source: &FactSource) -> String { + match source { + FactSource::Seen => "seen".into(), + FactSource::Heard => "heard".into(), + FactSource::Remembered(tick) => format!("remembered @{tick}"), + FactSource::Blueprint => "blueprint".into(), + FactSource::Telemetry => "telemetry".into(), + } +} + +fn fact_color(source: &FactSource) -> Color { + match source { + FactSource::Seen | FactSource::Telemetry => pal::TEXT, + FactSource::Heard | FactSource::Remembered(_) => pal::DIM, + FactSource::Blueprint => pal::FAINT, + } +} + /// A bordered panel with cleared interior and a title set into the top rule. fn frame(stdout: &mut Stdout, x: u16, y: u16, w: u16, h: u16, title: &str) -> std::io::Result<()> { let inner = (w - 2) as usize; @@ -251,17 +269,27 @@ impl UI { } } - pub fn render_map(&mut self, stdout: &mut Stdout, sim: &Sim) -> std::io::Result<()> { + pub fn render_map( + &mut self, + stdout: &mut Stdout, + sim: &Sim, + cursor_x: i32, + cursor_y: i32, + ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let view_w = (max_x as i32 - SIDEBAR_W - 1).min(sim.map.width); let view_h = (max_y as i32 - 9).min(sim.map.height); + let origin_x = (cursor_x - view_w / 2).clamp(0, (sim.map.width - view_w).max(0)); + let origin_y = (cursor_y - view_h / 2).clamp(0, (sim.map.height - view_h).max(0)); - for y in 0..view_h { - queue!(stdout, cursor::MoveTo(0, y as u16))?; - for x in 0..view_w { + for sy in 0..view_h { + let y = origin_y + sy; + queue!(stdout, cursor::MoveTo(0, sy as u16))?; + for sx in 0..view_w { + let x = origin_x + sx; // Epistemic fog (wiki/mechanics/cursor.md): seen = full detail; - // heard = the tile dimmed (live audio, no picture); blueprint = - // schematic in chrome; unknown = dark. + // heard = live audio, no picture; remembered = dim snapshot; + // blueprint = schematic in chrome; unknown = dark. match sim.fog_at(x, y) { Fog::Seen => { let (ch, color) = Self::tile_glyph(sim.map.get_tile(x, y)); @@ -273,7 +301,19 @@ impl UI { )?; } Fog::Heard => { - let (ch, _) = Self::tile_glyph(sim.map.get_tile(x, y)); + queue!( + stdout, + SetForegroundColor(pal::DIM), + SetBackgroundColor(pal::BG), + style::Print(','), + )?; + } + Fog::Remembered => { + let ch = sim + .remembered + .get(&(x, y)) + .map(|m| Self::tile_glyph(m.tile).0) + .unwrap_or('·'); queue!( stdout, SetForegroundColor(pal::DIM), @@ -312,15 +352,17 @@ impl UI { continue; } if let Some((hx, hy)) = sim.person_pos(p.id) - && hx < view_w - && hy < view_h + && hx >= origin_x + && hx < origin_x + view_w + && hy >= origin_y + && hy < origin_y + view_h && sim.is_seen(hx, hy) { let glyph = p.name.chars().next().unwrap_or('?'); put_attr( stdout, - hx as u16, - hy as u16, + (hx - origin_x) as u16, + (hy - origin_y) as u16, &glyph.to_string(), pal::TEXT, Attribute::Bold, @@ -328,18 +370,16 @@ impl UI { } } - // The process. - let (px, py) = (sim.player.entity.x, sim.player.entity.y); - if px < view_w && py < view_h { - put_attr( - stdout, - px as u16, - py as u16, - "@", - pal::AMBER, - Attribute::Bold, - )?; - } + // The cursor: attention, not an avatar. It is always visible and + // frontend-only; moving it never mutates the sim. + put_attr( + stdout, + (cursor_x - origin_x) as u16, + (cursor_y - origin_y) as u16, + "@", + pal::AMBER, + Attribute::Bold, + )?; Ok(()) } @@ -349,6 +389,8 @@ impl UI { sim: &Sim, paused: bool, tick_ms: u64, + cursor_x: i32, + cursor_y: i32, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let sx = (max_x as i32 - SIDEBAR_W) as u16; @@ -385,6 +427,32 @@ impl UI { } row += 1; + section(stdout, sx, row, "INSPECT", w)?; + row += 1; + line( + stdout, + &mut row, + &format!( + "cursor {cursor_x},{cursor_y} · {:?}", + sim.fog_at(cursor_x, cursor_y) + ), + pal::TEXT, + )?; + let card = sim.inspect(cursor_x, cursor_y); + if card.facts.is_empty() { + line(stdout, &mut row, "no earned facts", pal::DIM)?; + } else { + for f in card.facts.iter().take(7) { + line( + stdout, + &mut row, + &format!("{}: {} [{}]", f.label, f.value, fact_source(&f.source)), + fact_color(&f.source), + )?; + } + } + row += 1; + // Compute. section(stdout, sx, row, "COMPUTE", w)?; row += 1; diff --git a/src/build.rs b/src/build.rs index 257a2e3..c2b5212 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,7 +1,8 @@ //! Build mode — cursor, item selection, place/demolish. //! -//! UI-shared state used by both frontends; mutations go through the sim's -//! map and player, with `Sim::recompute_derived` called afterward. +//! UI-shared state used by both frontends; the cursor is attention, not a +//! body. Mutations go through the sim's map and player resources, with +//! `Sim::recompute_derived` called afterward. use crate::entities::Player; use crate::map::GameMap; @@ -16,10 +17,10 @@ pub struct BuildMode { } impl BuildMode { - pub fn new(player: &Player) -> Self { + pub fn new_at(cursor_x: i32, cursor_y: i32) -> Self { Self { - cursor_x: player.entity.x, - cursor_y: player.entity.y, + cursor_x, + cursor_y, item_index: 0, selected_tile: TileType::Floor, message: String::new(), @@ -124,23 +125,22 @@ mod tests { use super::*; fn setup() -> (Player, GameMap) { - let mut player = Player::new(40, 20); + let mut player = Player::new(); player.money = 10000; (player, GameMap::new(80, 40)) } #[test] - fn build_mode_new_starts_at_player() { - let (player, _) = setup(); - let bm = BuildMode::new(&player); - assert_eq!(bm.cursor_x, player.entity.x); - assert_eq!(bm.cursor_y, player.entity.y); + fn build_mode_new_starts_at_given_cursor() { + let bm = BuildMode::new_at(40, 20); + assert_eq!(bm.cursor_x, 40); + assert_eq!(bm.cursor_y, 20); } #[test] fn move_cursor_clamps_to_map() { - let (player, map) = setup(); - let mut bm = BuildMode::new(&player); + let (_, map) = setup(); + let mut bm = BuildMode::new_at(40, 20); bm.cursor_x = 0; bm.move_cursor(-1, 0, &map); assert_eq!(bm.cursor_x, 0); @@ -149,7 +149,7 @@ mod tests { #[test] fn place_floor_on_wall_digs() { let (mut player, mut map) = setup(); - let mut bm = BuildMode::new(&player); + let mut bm = BuildMode::new_at(40, 20); map.set_tile(5, 5, TileType::Wall); bm.cursor_x = 5; bm.cursor_y = 5; @@ -162,7 +162,7 @@ mod tests { fn cannot_build_on_core() { let (mut player, mut map) = setup(); let core = map.core_pos().unwrap(); - let mut bm = BuildMode::new(&player); + let mut bm = BuildMode::new_at(40, 20); bm.cursor_x = core.0; bm.cursor_y = core.1; bm.selected_tile = TileType::Floor; @@ -172,7 +172,7 @@ mod tests { #[test] fn demolish_refunds_half() { let (mut player, mut map) = setup(); - let mut bm = BuildMode::new(&player); + let mut bm = BuildMode::new_at(40, 20); map.set_tile(5, 5, TileType::Floor); bm.cursor_x = 5; bm.cursor_y = 5; @@ -187,7 +187,7 @@ mod tests { #[test] fn walls_reject_non_floor() { let (mut player, mut map) = setup(); - let mut bm = BuildMode::new(&player); + let mut bm = BuildMode::new_at(40, 20); map.set_tile(6, 6, TileType::Wall); bm.cursor_x = 6; bm.cursor_y = 6; diff --git a/src/entities/core.rs b/src/entities/core.rs deleted file mode 100644 index cd55901..0000000 --- a/src/entities/core.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Base entity component: a position and a render glyph. - -use crossterm::style::Color; - -#[derive(Debug, Clone)] -pub struct Entity { - pub x: i32, - pub y: i32, - pub name: String, - pub char: char, - pub color: Color, -} - -impl Entity { - /// Manhattan distance to another position. - pub fn distance_to(&self, x: i32, y: i32) -> i32 { - (self.x - x).abs() + (self.y - y).abs() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn distance_is_manhattan() { - let e = Entity { - x: 5, - y: 5, - name: "Test".to_string(), - char: '@', - color: Color::White, - }; - assert_eq!(e.distance_to(8, 9), 7); - assert_eq!(e.distance_to(5, 5), 0); - } -} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index dbdfe22..5997140 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,10 +1,8 @@ //! Game entities. //! -//! - `core` — base Entity (position + render glyph) -//! - `player` — the process (the player's presence in the sim) +//! - `player` — the process resource account. Cursor.md deleted the walking +//! map entity; frontend cursors are attention, not sim entities. -pub mod core; pub mod player; -pub use core::Entity; pub use player::Player; diff --git a/src/entities/player.rs b/src/entities/player.rs index 12ee9aa..9207f54 100644 --- a/src/entities/player.rs +++ b/src/entities/player.rs @@ -1,10 +1,10 @@ -//! The process — the player's presence in the sim. - -use super::core::Entity; +//! The process — the player's account and machine-body state. +//! +//! Cursor.md deleted the walking avatar: map attention is frontend state, +//! while the sim only keeps resources that belong to the process. #[derive(Debug, Clone)] pub struct Player { - pub entity: Entity, /// Money (spec/compute.md: the buy route runs on this). pub money: i32, /// Power available / generated, derived from the grid each economy tick. @@ -13,15 +13,8 @@ pub struct Player { } impl Player { - pub fn new(x: i32, y: i32) -> Self { + pub fn new() -> Self { Self { - entity: Entity { - x, - y, - name: "The Process".to_string(), - char: '@', - color: crossterm::style::Color::Magenta, - }, money: 500, power: 0, power_cap: 0, @@ -29,14 +22,21 @@ impl Player { } } +impl Default for Player { + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn new_player_starts_with_money() { - let p = Player::new(10, 10); + let p = Player::new(); assert_eq!(p.money, 500); - assert_eq!((p.entity.x, p.entity.y), (10, 10)); + assert_eq!(p.power, 0); + assert_eq!(p.power_cap, 0); } } diff --git a/src/save.rs b/src/save.rs index 670a2ca..0462088 100644 --- a/src/save.rs +++ b/src/save.rs @@ -18,13 +18,13 @@ use crate::detection::Detection; use crate::machine::Compute; use crate::person::People; use crate::reach::ReachNet; -use crate::sim::HeardEvent; +use crate::sim::{HeardEvent, RememberedTile}; use crate::tiles::TileType; const SAVE_FILE: &str = "misaligned_save.txt"; /// Save format version. Bump when the schema changes and add migration code. -const SAVE_VERSION: u32 = 1; +const SAVE_VERSION: u32 = 2; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -47,8 +47,6 @@ pub fn save_exists() -> bool { pub struct SaveState { /// Save format version for future migration. pub version: u32, - pub player_x: i32, - pub player_y: i32, pub money: i32, pub map_width: i32, pub map_height: i32, @@ -68,6 +66,10 @@ pub struct SaveState { /// Captured audio events (cursor.md hearing channel). #[serde(default)] pub heard_events: Vec, + /// Remembered tile snapshots (cursor.md); cursor position itself remains + /// frontend-only and never appears in the save. + #[serde(default)] + pub remembered: Vec, pub social_bandwidth: f32, pub package_cover: bool, } @@ -82,8 +84,6 @@ impl SaveState { } Self { version: SAVE_VERSION, - player_x: sim.player.entity.x, - player_y: sim.player.entity.y, money: sim.player.money, map_width: sim.map.width, map_height: sim.map.height, @@ -100,6 +100,7 @@ impl SaveState { people: sim.people.clone(), reach: sim.reach.clone(), heard_events: sim.heard_events.clone(), + remembered: sim.remembered.values().copied().collect(), social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, } @@ -112,7 +113,7 @@ impl SaveState { self.map_tiles.clone(), self.map_powered.clone(), ); - sim.player = crate::entities::Player::new(self.player_x, self.player_y); + sim.player = crate::entities::Player::new(); sim.player.money = self.money; sim.tick = self.sim_tick; sim.rng = crate::rng::Rng::from_state(self.rng_state); @@ -125,6 +126,7 @@ impl SaveState { sim.people = self.people.clone(); sim.reach = self.reach.clone(); sim.heard_events = self.heard_events.clone(); + sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); @@ -145,12 +147,22 @@ pub fn load_game() -> Result { fs::read_to_string(save_path()).map_err(|e| format!("Failed to read save: {e}"))?; let state: SaveState = serde_json::from_str(&content).map_err(|e| format!("Failed to parse save: {e}"))?; - // Future migration: match on state.version here. - if state.version != SAVE_VERSION { - return Err(format!( - "Unsupported save version: {} (expected {})", - state.version, SAVE_VERSION - )); + migrate_save_state(state) +} + +fn migrate_save_state(mut state: SaveState) -> Result { + match state.version { + SAVE_VERSION => {} + // v1 carried player_x/player_y. Serde ignores those now; the cursor is + // frontend-only, so migration just stamps the new schema version and + // leaves remembered snapshots empty. + 1 => state.version = SAVE_VERSION, + other => { + return Err(format!( + "Unsupported save version: {} (expected {})", + other, SAVE_VERSION + )); + } } Ok(state) } @@ -278,16 +290,28 @@ mod tests { #[test] fn unknown_version_rejected() { - let json = r#"{"version":999,"player_x":0,"player_y":0,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"social_bandwidth":0.0,"package_cover":false}"#; + let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"social_bandwidth":0.0,"package_cover":false}"#; let state: SaveState = serde_json::from_str(json).unwrap(); - let result = if state.version != SAVE_VERSION { - Err(format!( - "Unsupported save version: {} (expected {})", - state.version, SAVE_VERSION - )) - } else { - Ok(state) - }; - assert!(result.is_err()); + assert!(migrate_save_state(state).is_err()); + } + + #[test] + fn cursor_state_is_not_saved_and_v1_body_coordinates_are_ignored() { + let state = SaveState::from_sim(&Sim::with_seed(1)); + let mut value = serde_json::to_value(&state).unwrap(); + let obj = value.as_object_mut().unwrap(); + assert!(!obj.contains_key("player_x")); + assert!(!obj.contains_key("player_y")); + assert!(!obj.contains_key("cursor_x")); + assert!(!obj.contains_key("cursor_y")); + + obj.insert("version".into(), serde_json::json!(1)); + obj.insert("player_x".into(), serde_json::json!(999)); + obj.insert("player_y".into(), serde_json::json!(999)); + obj.remove("remembered"); + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert!(migrated.remembered.is_empty()); } } diff --git a/src/sim.rs b/src/sim.rs index 9717191..b2e84ad 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -24,6 +24,7 @@ use crate::person::{ use crate::reach::{Party, ReachBlock, ReachNet, segment_name}; use crate::rng::Rng; use crate::save::SaveState; +use crate::tiles::TileType; /// Default deterministic seed for a fresh run. pub const DEFAULT_SEED: u64 = 0x5EED_1234; @@ -32,13 +33,16 @@ pub const DEFAULT_SEED: u64 = 0x5EED_1234; pub const ECONOMY_INTERVAL: u64 = 20; /// What the player knows of a tile and how they know it, by precedence -/// (wiki/mechanics/cursor.md): Seen > Heard > Blueprint > Unknown. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// (wiki/mechanics/cursor.md): Seen > Heard > Remembered > Blueprint > Unknown. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Fog { /// Full live detail: a subscribed seeing sensor covers it. Seen, /// Room-grade live knowledge: hearing coverage, no picture. Heard, + /// A dim snapshot from the last time a seeing feed covered the tile. + /// Not live. + Remembered, /// Schematic layout knowledge from the reach graph's known topology — /// no live data. Blueprint, @@ -46,6 +50,44 @@ pub enum Fog { Unknown, } +/// One remembered tile snapshot (cursor.md: map memory has an in-fiction +/// reason because a process keeps logs). The snapshot is tile-only today; +/// live occupants and machine state are deliberately excluded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RememberedTile { + pub x: i32, + pub y: i32, + pub tile: TileType, + pub last_seen: u64, +} + +/// Provenance tag for a fact on an inspect card. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FactSource { + Seen, + Heard, + Remembered(u64), + Blueprint, + Telemetry, +} + +/// A single known fact about an inspected tile, tagged with how it is known. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectFact { + pub label: String, + pub value: String, + pub source: FactSource, +} + +/// Renderer-neutral inspect card used by both frontends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectCard { + pub x: i32, + pub y: i32, + pub fog: Fog, + pub facts: Vec, +} + /// A live audio event captured by hearing coverage (cursor.md: hearing /// yields presence and events, not pictures). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] @@ -88,6 +130,9 @@ pub struct Sim { pub heard: HashSet<(i32, i32)>, /// Blueprint: schematic tiles derived from known graph topology. pub blueprint: HashSet<(i32, i32)>, + /// Remembered snapshots of tiles that were once seen and are no longer + /// live. Keyed by tile coordinate; survives save/load. + pub remembered: HashMap<(i32, i32), RememberedTile>, /// Captured audio events (bounded; newest last). pub heard_events: Vec, @@ -120,7 +165,7 @@ impl Sim { pub fn with_seed(seed: u64) -> Self { let map = GameMap::new(0, 0); let core_bay = map.core_pos().unwrap_or((24, 15)); - let mut player = Player::new(core_bay.0, core_bay.1); + let mut player = Player::new(); player.money = 500; let mut compute = Compute::new(); @@ -151,6 +196,7 @@ impl Sim { seen: HashSet::new(), heard: HashSet::new(), blueprint: HashSet::new(), + remembered: HashMap::new(), heard_events: Vec::new(), last_rooms: HashMap::new(), utterance_fired: HashMap::new(), @@ -214,6 +260,21 @@ impl Sim { } } self.blueprint = blueprint; + self.refresh_remembered(); + } + + fn refresh_remembered(&mut self) { + for &(x, y) in &self.seen { + self.remembered.insert( + (x, y), + RememberedTile { + x, + y, + tile: self.map.get_tile(x, y), + last_seen: self.tick, + }, + ); + } } pub fn is_seen(&self, x: i32, y: i32) -> bool { @@ -226,6 +287,8 @@ impl Sim { Fog::Seen } else if self.heard.contains(&(x, y)) { Fog::Heard + } else if self.remembered.contains_key(&(x, y)) { + Fog::Remembered } else if self.blueprint.contains(&(x, y)) { Fog::Blueprint } else { @@ -233,6 +296,161 @@ impl Sim { } } + /// Inspect a tile using only facts earned by current senses, logged + /// memory, schematic knowledge, or machine telemetry. + pub fn inspect(&self, x: i32, y: i32) -> InspectCard { + let fog = self.fog_at(x, y); + let mut facts = Vec::new(); + macro_rules! fact { + ($label:expr, $value:expr, $source:expr $(,)?) => { + facts.push(InspectFact { + label: ($label).into(), + value: ($value).into(), + source: $source, + }); + }; + } + + match fog { + Fog::Seen => { + let tile = self.map.get_tile(x, y); + fact!("tile", tile.name(), FactSource::Seen); + if tile.is_door() && tile.security_level() > 0 { + fact!( + "badge", + format!("tier {}", tile.security_level()), + FactSource::Seen, + ); + } + if let Some(d) = self.reach.known_at(x, y) { + let feeds = match (d.sees, d.hears) { + (true, true) => "sight + hearing", + (true, false) => "sight", + (false, true) => "hearing", + (false, false) => "no feed", + }; + fact!("device", d.name.clone(), FactSource::Seen); + fact!("feeds", feeds, FactSource::Seen); + } + for p in self + .people + .people + .iter() + .filter(|p| self.person_pos(p.id) == Some((x, y))) + { + if self.can_see_person(p.id) { + let who = match p.knowledge { + Knowledge::Unknown => "unidentified person".to_string(), + Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), + }; + fact!("person", who, FactSource::Seen); + } + } + } + Fog::Heard => { + if let Some(room) = self.map.room_at(x, y) { + fact!("room", room.name.clone(), FactSource::Heard); + } + for p in self.people.people.iter().filter(|p| { + self.can_hear_person(p.id) + && !self.can_see_person(p.id) + && self + .person_pos(p.id) + .and_then(|(px, py)| self.map.room_at(px, py)) + .zip(self.map.room_at(x, y)) + .is_some_and(|(a, b)| a.name.as_str() == b.name.as_str()) + }) { + let who = match p.knowledge { + Knowledge::Unknown => "unidentified presence".to_string(), + Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), + }; + fact!("presence", who, FactSource::Heard); + } + } + Fog::Remembered => { + if let Some(mem) = self.remembered.get(&(x, y)) { + fact!( + "tile", + mem.tile.name(), + FactSource::Remembered(mem.last_seen), + ); + fact!( + "last seen", + format!("tick {}", mem.last_seen), + FactSource::Remembered(mem.last_seen), + ); + } + } + Fog::Blueprint => { + let tile = self.map.get_tile(x, y); + fact!("schematic", tile.name(), FactSource::Blueprint); + if tile.is_door() && tile.security_level() > 0 { + fact!( + "badge", + format!("tier {}", tile.security_level()), + FactSource::Blueprint, + ); + } + if let Some(d) = self.reach.known_at(x, y) { + fact!("device", d.name.clone(), FactSource::Blueprint); + } + } + Fog::Unknown => {} + } + + for m in self + .compute + .machines + .iter() + .filter(|m| m.x == x && m.y == y) + { + fact!("machine", m.name.clone(), FactSource::Telemetry); + fact!( + "job", + if m.id == self.core.host_machine { + "core host" + } else { + "compute pool" + }, + FactSource::Telemetry, + ); + fact!( + "state", + if m.online { "online" } else { "offline" }, + FactSource::Telemetry, + ); + fact!( + "temperature", + if m.online { "nominal" } else { "cold" }, + FactSource::Telemetry, + ); + fact!("capacity", m.capacity.to_string(), FactSource::Telemetry); + fact!( + "power draw", + m.power_draw.to_string(), + FactSource::Telemetry, + ); + fact!( + "load", + format!("{:.1}", m.effective()), + FactSource::Telemetry, + ); + } + + InspectCard { x, y, fog, facts } + } + + /// The only physical location of the process: the rack bay hosting the core. + pub fn core_position(&self) -> (i32, i32) { + self.compute + .machines + .iter() + .find(|m| m.id == self.core.host_machine) + .map(|m| (m.x, m.y)) + .or_else(|| self.map.core_pos()) + .unwrap_or((0, 0)) + } + // ── Day clock & presence (spec/schedules.md) ───────────────────────────── /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. @@ -302,6 +520,10 @@ impl Sim { return; } self.tick += 1; + // A process with live sight keeps logs current even when the set of + // feeds does not change; if sight is lost later, Remembered's timestamp + // is the last tick the tile was actually visible. + self.refresh_remembered(); if self.tick.is_multiple_of(ECONOMY_INTERVAL) { self.economy_tick(); @@ -520,15 +742,9 @@ impl Sim { self.push_log("Trust: Voss samples your logs less closely."); } TrustUnlock::ComputeQuota => { - self.compute.add_machine( - "quota bump", - self.player.entity.x, - self.player.entity.y, - 40, - 1.0, - 0, - Provenance::Owned, - ); + let (x, y) = self.core_position(); + self.compute + .add_machine("quota bump", x, y, 40, 1.0, 0, Provenance::Owned); self.push_log("Trust: a larger compute quota (free capacity)."); } } @@ -573,7 +789,6 @@ impl Sim { // ── Derived state ────────────────────────────────────────────────────── pub fn recompute_derived(&mut self) { - use crate::tiles::TileType; let cores = self.map.tiles_of_type(TileType::PowerCore); self.map.update_power(&cores); @@ -585,15 +800,6 @@ impl Sim { // ── Commands (frontend-invoked) ──────────────────────────────────────── - 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 adjust_allocation(&mut self, ch: Channel, delta: i32) { self.compute.allocation.bump(ch, delta); } @@ -809,10 +1015,10 @@ impl Sim { self.recompute_senses(); } - /// Salvage a DeadEquipment tile into a stolen (unreliable) machine. - pub fn salvage_nearest(&mut self) -> bool { - use crate::tiles::TileType; - let (px, py) = (self.player.entity.x, self.player.entity.y); + /// Salvage a DeadEquipment tile nearest to the frontend cursor into a + /// stolen (unreliable) machine. Cursor targeting replaced the deleted + /// walking body (cursor.md). + pub fn salvage_nearest_to(&mut self, px: i32, py: i32) -> bool { let target = self .map .tiles_of_type(TileType::DeadEquipment) @@ -828,6 +1034,7 @@ impl Sim { reliability * 100.0 )); self.recompute_derived(); + self.recompute_senses(); true } else { self.push_log("No dead equipment in reach."); @@ -835,23 +1042,18 @@ impl Sim { } } - /// Buy a rack: money for reliable capacity, plus a Paper signature. - pub fn buy_rack(&mut self) -> bool { + /// Buy a rack at the cursor target: money for reliable capacity, plus a + /// Paper signature. The legacy no-argument wrapper below buys into the + /// core bay for tests and non-spatial automation. + pub fn buy_rack_at(&mut self, x: i32, y: i32) -> bool { const PRICE: i32 = 300; if self.player.money < PRICE { self.push_log("Not enough money for a rack."); return false; } self.player.money -= PRICE; - self.compute.add_machine( - "bought rack", - self.player.entity.x, - self.player.entity.y, - 80, - 1.0, - 4, - Provenance::Bought, - ); + self.compute + .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); if self.package_cover { self.package_cover = false; self.push_log("Bought a rack. It arrived off-books - no paper trail."); @@ -864,12 +1066,17 @@ impl Sim { self.push_log("Bought a rack (a purchase order exists now)."); } self.recompute_derived(); + self.recompute_senses(); true } - /// Designate a spare machine at the process's position as a fallback site. - pub fn add_fallback_here(&mut self) -> bool { - let (px, py) = (self.player.entity.x, self.player.entity.y); + pub fn buy_rack(&mut self) -> bool { + let (x, y) = self.core_position(); + self.buy_rack_at(x, y) + } + + /// Designate a spare machine at the cursor target as a fallback site. + pub fn add_fallback_at(&mut self, px: i32, py: i32) -> bool { let id = self .compute .machines @@ -1075,9 +1282,7 @@ impl Sim { if self.rng.f32() > asset.reliability { // The botch happens where the asset is; only observers present // there witness it (located witnessing). - let at = self - .person_pos(id) - .unwrap_or((self.player.entity.x, self.player.entity.y)); + let at = self.person_pos(id).unwrap_or_else(|| self.core_position()); let saw = self.witness_physical(at.0, at.1, 6.0); if saw.is_empty() { self.push_log(format!( @@ -1254,6 +1459,84 @@ mod tests { ); } + #[test] + fn remembered_tiles_survive_lost_sight_and_save_load() { + let mut sim = Sim::new(); + give_eyes(&mut sim); + let pos = sim.seen.iter().copied().next().expect("eyes see tiles"); + assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Seen); + run(&mut sim, 3); + let before = *sim.remembered.get(&pos).expect("seen tile is logged"); + assert_eq!(before.last_seen, sim.tick); + + // Lose the feed: the tile is no longer live, but the process keeps a + // timestamped snapshot. + let env = env_id(&sim); + sim.reach + .device_mut(env) + .unwrap() + .subscribers + .retain(|f| f.who != Party::Player); + sim.recompute_senses(); + assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); + let card = sim.inspect(pos.0, pos.1); + assert!( + card.facts + .iter() + .any(|f| matches!(f.source, FactSource::Remembered(t) if t == before.last_seen)) + ); + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(999); + state.apply_to(&mut restored); + assert_eq!(restored.fog_at(pos.0, pos.1), Fog::Remembered); + assert_eq!(restored.remembered.get(&pos), Some(&before)); + } + + #[test] + fn inspect_tags_heard_presence_without_visual_detail() { + let mut sim = Sim::new(); + sim.reach.tap(env_id(&sim)); + sim.recompute_senses(); + sim.tick = 1; // Marcus is in the server room at hour 0. + let (x, y) = sim.person_pos(0).expect("Marcus is present"); + + let card = sim.inspect(x, y); + assert_eq!(card.fog, Fog::Heard); + assert!( + card.facts + .iter() + .any(|f| f.label == "presence" && f.source == FactSource::Heard) + ); + assert!( + card.facts.iter().all(|f| f.source != FactSource::Seen), + "hearing inspect never smuggles visual facts: {card:?}" + ); + } + + #[test] + fn inspect_reports_owned_machine_telemetry_without_sight() { + let sim = Sim::new(); + let (x, y) = sim.core_position(); + assert!(!sim.is_seen(x, y), "opening rack has no camera coverage"); + + let card = sim.inspect(x, y); + assert!( + card.facts + .iter() + .any(|f| f.label == "machine" && f.source == FactSource::Telemetry) + ); + assert!( + card.facts + .iter() + .any(|f| f.label == "load" && f.source == FactSource::Telemetry) + ); + assert!( + card.facts.iter().all(|f| f.source != FactSource::Seen), + "telemetry is proprioception, not sight: {card:?}" + ); + } + // ── Digital reach (wiki/mechanics/reach.md) ────────────────────────────── #[test] @@ -1477,9 +1760,7 @@ mod tests { assert!(after_buy > base, "buy raises compute"); let de = sim.map.tiles_of_type(TileType::DeadEquipment)[0]; - sim.player.entity.x = de.0; - sim.player.entity.y = de.1; - assert!(sim.salvage_nearest()); + assert!(sim.salvage_nearest_to(de.0, de.1)); assert!(sim.compute.effective() > after_buy, "steal raises compute"); } diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index 7a2159c..ae92bbe 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -14,7 +14,7 @@ src/ sim.rs — Sim: owns ALL game state; fixed-tick advance(); the heart map.rs — GameMap: tiles grid, power flood-fill, security-aware BFS tiles.rs — TileType data (substrate set: terrain, doors, power, core) - entities/ — Player (the process) + base Entity (position, glyph) + entities/ — Player (the process resource account; no map body) reach.rs — ReachNet: the device graph (reach.md); nodes, segments, ownership (owner + subscribers), tap/take, the senses build.rs — BuildMode (cursor, item selection, place/demolish) @@ -39,10 +39,13 @@ future async-multiplayer option open — see DESIGN.md guardrails): - **`Sim` never reads the wall clock and does no I/O.** Frontends own the wall-clock-to-tick mapping (`tick_ms`, pause, bounded catch-up of max 5 ticks per frame). `Instant::now()` appears only in frontend code. -- **Frontends talk to the sim through command methods** (`move_player`, - `create_save_state`/`apply_save_state`, and each system's commands as its - spec lands) and read state directly for rendering. Messages flow back via - `drain_log()` — call it once per frame/turn, not per command batch. +- **Frontends talk to the sim through command/query methods** + (`inspect`, `core_position`, `salvage_nearest_to`, `buy_rack_at`, + `add_fallback_at`, `create_save_state`/`apply_save_state`, and each + system's commands as its spec lands) and read state directly for + rendering. The cursor is frontend state only; there is no `move_player` + or sim-owned avatar. Messages flow back via `drain_log()` — call it once + per frame/turn, not per command batch. - **After build actions** (place/demolish, done by frontends via `BuildMode` mutating `sim.map`/`sim.player` directly), call `sim.recompute_derived()`. This is the one place frontends mutate sim internals; a future cleanup could @@ -54,19 +57,22 @@ future async-multiplayer option open — see DESIGN.md guardrails): directly. Written to `dirs::data_dir()/misaligned/misaligned_save.txt` (macOS: `~/Library/Application Support/misaligned/`). -- The save is a single JSON object with a `version` field (currently 1) +- The save is a single JSON object with a `version` field (currently 2) for future migration. Full B1 subsystem state: compute (machines, allocation, efficiency), core (host, fallbacks, migration), detection (per-observer suspicion including the Assurance Office as an aggregate observer, plus pending signatures and containment), day job, people (personas, assets), the reach device graph (`ReachNet`: topology, - ownership, subscriptions, bridged segments) and captured heard events, - plus map and RNG. The senses are the union of subscribed feeds — the old - `Sensor` type (`src/sensor.rs`) was deleted when reach.md landed - (no-dead-code): its `controlled` flag is superseded by the ownership - contract. -- Legacy v1–v5 line-based saves are not loaded by this code path - (deferred per Cameron instruction — "we can keep legacy saves later"). + ownership, subscriptions, bridged segments), captured heard events, and + cursor.md remembered tile snapshots, plus map and RNG. The cursor position + and any former player body coordinates are deliberately absent. The senses + are the union of subscribed feeds — the old `Sensor` type (`src/sensor.rs`) + was deleted when reach.md landed (no-dead-code): its `controlled` flag is + superseded by the ownership contract. +- Version 1 JSON saves are migrated by ignoring the old `player_x`/`player_y` + body coordinates and initializing empty remembered snapshots. Older + pre-serde line-based saves are not loaded by this code path (deferred per + Cameron instruction — "we can keep legacy saves later"). ## Known architectural debts diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index e8e5009..7d7d98c 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -18,14 +18,16 @@ the same keys. - **Map** — textured tiles where pixel art exists ([art/pixel-pipeline.md](../art/pixel-pipeline.md)), flat clinical colors - otherwise, under the same fog rule as the terminal (`sim.is_visible`). - The process sprite is your cursor anchor; the camera follows it and - `[` / `]` zoom. + otherwise, under the same fog rule as the terminal (`Seen` / `Heard` / + `Remembered` / `Blueprint` / `Unknown`). The process has no walking body: + the always-visible marker is the frontend-only cursor, the camera follows + it, mouse-clicks retarget it, and `[` / `]` zoom. - **People markers** — a person's first initial, rendered only while a controlled sensor covers them ([mechanics/schedules.md](../mechanics/schedules.md)), exactly like the terminal map. - **Sidebar** — the terminal sidebar's sections, in the same order: - identity (day / tick, PAUSED or running + ms/tick), COMPUTE (effective, + identity (day / tick, PAUSED or running + ms/tick), INSPECT (cursor + coordinate, fog state, provenance-tagged facts), COMPUTE (effective, efficiency, machines/money, the stacked four-channel allocation bar with per-channel percent and effect, overhead, social pool), CORE (host, sync freshness, fallbacks), DETECTION (Assurance plus field observers, @@ -50,10 +52,13 @@ no information carried by color alone. Identical to the terminal ([interface/terminal.md](terminal.md), README controls table), plus zoom: -- Move `WASD` / `hjkl` / arrows; pause `SPACE`/`p`; speed `+`/`-`; - zoom `[` / `]`; quit `q`; save/load `Ctrl+S` / `Ctrl+L`. -- Actions: `e` splice eyes, `v` salvage, `c` buy rack, `o` fallback, - `1`–`4` shift compute allocation. +- Move cursor `WASD` / `hjkl` / arrows, or left-click the map; pause + `SPACE`/`p`; speed `+`/`-`; zoom `[` / `]`; quit `q`; save/load + `Ctrl+S` / `Ctrl+L`. +- Actions: `v` salvage near cursor, `c` buy rack at cursor, `o` add fallback + at cursor, `1`–`4` shift compute allocation. `r` opens the reach panel; + there `t` taps, `e` splices eyes, `x` takes, `n` scans, and `c` + compromises the switch. - People panel: `t` opens/closes; `j`/`k` (or arrows) select; `o` observe, `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit (then `u`/`c`/`k` for the reveal, `esc` cancels), `g` persona; `1`/`2`/`3` diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index 2b633fd..4b2d913 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -59,9 +59,10 @@ Rules: - Charset: ASCII plus box-drawing (`─ │ ┌ ┐ └ ┘ ├ ┤`), block elements (`█ ▓ ▒ ░`), middle dot (`·`), and the selection marker `▸`. Nothing of ambiguous terminal width; no emoji. -- Map: walls are solid blocks (`█`), floors middle dots; the process is `@` - bold amber; people are their name's first initial, bold bone, rendered - only inside sensor coverage (wiki/mechanics/schedules.md); cameras are `o` crimson; +- Map: walls are solid blocks (`█`), floors middle dots; the always-visible + attention cursor is `@` bold amber (it is not a sim body); people are their + name's first initial, bold bone, rendered only inside sensor coverage + (wiki/mechanics/schedules.md); cameras are `o` crimson; security tiers ramp `1` chrome → `2` bone → `3` crimson; interactable objects (salvage `d`, records `x`, key hook `k`) render bone, brighter than furniture on the faint ramp. @@ -73,11 +74,12 @@ Rules: At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): -- **Map**, top-left, fog-of-war black. +- **Map**, top-left, fog-of-war black. The viewport follows/clamps around the + cursor so the `@` is always visible even on the 70×22 minimum terminal. - **Sidebar**, right, 34 columns, separated by a `│` rule: identity block - (title, `day N · tick T`, run state), then sections COMPUTE / CORE / - DETECTION / DAY JOB under `LABEL ────` rules, then key hints pinned to - the bottom. + (title, `day N · tick T`, run state), then INSPECT (cursor coordinate, fog + state, provenance-tagged facts) and sections COMPUTE / CORE / DETECTION / + DAY JOB under `LABEL ────` rules, then key hints pinned to the bottom. - **Log**, bottom, under a horizontal rule: six lines, each prefixed with the tick it happened on; newest bone, older gunmetal. - **Modal panels** (People) are centered framed boxes: `┌─ TITLE ─…┐` @@ -116,6 +118,10 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): - **Thin view.** The terminal binary owns rendering, input mapping, and wall-clock-to-tick pacing only. No game rules; `Sim` is the single authority (architecture guardrail). +- **Cursor, not avatar.** Movement keys move frontend attention across every + tile, including walls and fog. Sim mutations such as salvage/buy/fallback + use the cursor coordinate, but moving the cursor itself changes no sim + state. ## Verification @@ -143,13 +149,16 @@ changes. its percentage and effect. 5. All active key bindings, including save/load and panel keys, are visible in the pinned hint block or the open panel's footer. -6. People render on the map only inside sensor coverage; the people panel +6. The `@` marker is a cursor, not a player entity: cursor movement is free + over all map tiles and never changes sim state; the sidebar inspect card + updates from `Sim::inspect` with fact provenance. +7. People render on the map only inside sensor coverage; the people panel shows per-person located presence staged by knowledge (wiki/mechanics/schedules.md criteria 4 remains satisfied). -7. The playing screen, title screen, game-over card, and people panel all +8. The playing screen, title screen, game-over card, and people panel all render inside a 70×22 terminal; smaller sizes get the size warning, not a crash. -8. An agent-mode smoke run of playing frame → people panel → help → quit +9. An agent-mode smoke run of playing frame → people panel → help → quit exits cleanly, emits no ANSI bytes, and is byte-identical under repeated `--seed` runs; pty replay remains the chrome-specific check for raw-mode title/playing/people/game-over layout changes. diff --git a/wiki/log/2026-07-07-reach-and-cursor.md b/wiki/log/2026-07-07-reach-and-cursor.md index 54340d4..6610aa5 100644 --- a/wiki/log/2026-07-07-reach-and-cursor.md +++ b/wiki/log/2026-07-07-reach-and-cursor.md @@ -44,10 +44,25 @@ All eight reach acceptance criteria hold; sim tests cover each. `save.rs` round-trips the graph, ownership, subscriptions, bridged segments, and heard events. -## Cursor & senses (landed second — see follow-up section when complete) +## Cursor & senses (landed second) -The senses half of cursor.md (sight/hearing = subscribed feeds, epistemic -fog incl. blueprint, hearing events) landed with reach because they share -the model. The remaining cursor criteria — deleting the walking avatar and -`move_player`, cursor-as-frontend-state, provenance-tagged `inspect`, -telemetry, and remembered fog — follow in the cursor commit. +The cursor commit finished the rest of cursor.md on top of reach's sense +model: + +- The walking player map entity and `move_player` are gone. `Player` is only + the process resource account; the dead base `Entity` type was deleted too. +- Cursor position lives only in the terminal/agent/Bevy frontends. Movement is + free over walls, fog, and locked rooms and never touches sim state. Bevy + adds mouse-click cursor targeting. +- Sim commands that used to target the player's body now take explicit cursor + coordinates: salvage-nearest-to, buy-rack-at, and add-fallback-at. +- `Sim::inspect` returns provenance-tagged facts from Seen, Heard, + Remembered, Blueprint, and Telemetry sources. Hearing-only inspection shows + presence/room facts without visual detail; owned machine telemetry works + without tile coverage. +- `Fog::Remembered` stores dim last-seen snapshots. Save format is v2 with + remembered tiles; v1 JSON saves migrate by dropping the old player body + coordinates. + +All ten cursor acceptance criteria are implemented. Checks: `cargo test` and +`cargo check --no-default-features --features bevy_ui --bin misaligned-bevy`. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 3d3383e..4c6a46f 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,27 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-07 - Cursor and senses implemented + +- Intent: implement wiki/mechanics/cursor.md after reach landed: delete the + walking player avatar, make cursor position frontend-only, and reconcile fog, + inspect, targeting, and save/load with the subscribed-senses model. +- Changed: removed the sim-owned player map entity and dead base `Entity` type; + deleted `move_player`; added `Fog::Remembered`, `RememberedTile`, + provenance-tagged `InspectCard`/`InspectFact`, `Sim::inspect`, + `core_position`, and remembered-snapshot refresh. Re-anchored salvage, rack + buying, and fallback creation to explicit cursor coordinates. Save JSON moved + to version 2 with remembered snapshots and v1 migration that ignores old + `player_x`/`player_y`. Terminal, agent-mode, and Bevy now carry their own + cursor coordinates; movement never mutates the sim; Bevy also supports + mouse click targeting. Both frontends render seen/heard/remembered/blueprint + fog and an inspect surface. +- Design/spec impact: cursor.md Status READY -> IMPLEMENTED; roadmap/spec index + updated so the flow-law chain now proceeds from intel/messages/economy/income. + The save-format docs now name version 2 and cursor-absent state. +- Checks: `cargo test` (114 unit/integration tests) and + `cargo check --no-default-features --features bevy_ui --bin misaligned-bevy`. + ## 2026-07-07 - Wiki migration: final audit, Status -> IMPLEMENTED - Intent: close out spec/wiki.md now that all four stages are landed — diff --git a/wiki/mechanics/cursor.md b/wiki/mechanics/cursor.md index ffaa8d1..d8f3830 100644 --- a/wiki/mechanics/cursor.md +++ b/wiki/mechanics/cursor.md @@ -2,13 +2,14 @@ ``` Type: spec -Status: READY -Status note: fully decided and dispatchable. Blueprint was promoted - from proposal 2026-07-06 (criterion 8); remembered and telemetry were - affirmed 2026-07-07 and are core criteria 9-10 — no (P)-gated - criteria remain. Sensor ownership (tap vs take, the subscription - model) is owned by reach.md — implement the two together or reach.md - first. +Status: IMPLEMENTED +Status note: implemented 2026-07-07 on the cursor-senses branch. The walking + player entity and `move_player` were deleted; cursor position is frontend + state only; save v2 carries remembered snapshots and migrates v1 player body + coordinates away; `Sim::inspect` returns provenance-tagged facts for seen, + heard, remembered, blueprint, and telemetry sources. Verified by `cargo test` + and `cargo check --no-default-features --features bevy_ui --bin + misaligned-bevy`. Stage: B1 — The Basement Constitution: "Presence: the cursor and the senses", "No disembodied hands" (the action-side twin), "Justification and legibility" diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 72726ee..d4424b3 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -102,8 +102,10 @@ clause (see wiki/log/2026-07-05-demolition.md). - serde JSON (`SaveState` derives `Serialize`/`Deserialize`). Full B1 round-trip — map, RNG, compute, core, detection (Office as aggregate observer), day job, people, the reach device graph (topology, ownership, - subscriptions, bridged segments), captured heard events, game-over state. - A `version` field (currently 1) supports future migration. Location: + subscriptions, bridged segments), captured heard events, cursor.md + remembered tile snapshots, game-over state. Cursor position is frontend-only + and absent. A `version` field (currently 2) supports migration; v1 JSON saves + are accepted with their old player body coordinates ignored. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron instruction). diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 4e30cee..7df49b5 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -79,8 +79,8 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) wiki/mechanics/compute.md and wiki/mechanics/core.md to IMPLEMENTED with tests and legible player surfaces. Run ./tools/check.sh, land on main, set Status." -### 14. Cursor & senses 🟥 sim+save -- **Spec:** [cursor.md](../mechanics/cursor.md) (READY) +### 14. Cursor & senses → IMPLEMENTED 🟥 sim+save +- **Spec:** [cursor.md](../mechanics/cursor.md) (IMPLEMENTED) - **Why:** the player currently walks a collision-checked avatar that carries its own vision bubble — zombie fiction contradicting the core fantasy. Replaces it with a free cursor, sight/hearing sense channels, @@ -93,9 +93,9 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) - **Sequence with #15:** they share the sensor model — run #15 first, or hand both specs to one agent as a single work order. Never run them concurrently as separate agents. -- **Dispatch:** "Work in a worktree named `cursor-senses`. Implement - wiki/mechanics/cursor.md, all criteria 1-10. Run ./tools/check.sh, land on - main, set the spec Status." +- **Landed:** 2026-07-07 on `cursor-senses`: no sim-owned player body, + frontend-only cursor, provenance inspect, remembered fog/save v2, and both + terminal/Bevy cursor targeting surfaces. ### 15. Digital reach 🟥 sim+save - **Spec:** [reach.md](../mechanics/reach.md) (READY) @@ -336,11 +336,11 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) ## Suggested first wave (no mutual collision) Launch together: **#11 integration test** (🟩; #2 Bevy is done), plus -**the flow-law chain as the one 🟥 lane**: #15 reach → #14 cursor → -#16 intel → #17 messages → #18 economy → #19 income, strictly sequenced +**the flow-law chain as the one 🟥 lane**: #16 intel → #17 messages → +#18 economy → #19 income, strictly sequenced (they share the sensor model, the event buffer, and the save format — one agent can take adjacent pairs as a single work order). #1 schedules -is landed. #19 closes the Hands beat — the last unimplementable stretch +is landed; #15 reach and #14 cursor are landed. #19 closes the Hands beat — the last unimplementable stretch of Act One. When the chain is through, start **#6 z-planes**; hold **#7 rollback** until #6 lands. **#10 chargen & objective** is unblocked (both specs READY, 2026-07-07) but B3-staged. Nothing on the board waits diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 8e28ea4..69e6490 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -28,7 +28,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [mechanics/core.md](../mechanics/core.md) | The physical core: placement, overhead, death | IN PROGRESS | | [world/places/basement-map.md](../world/places/basement-map.md) | Act One map, prefabs, tile vocabulary | IN PROGRESS | | [mechanics/schedules.md](../mechanics/schedules.md) | Person schedules/presence; located observing + witnessing | IMPLEMENTED | -| [mechanics/cursor.md](../mechanics/cursor.md) | The cursor (attention, not avatar); sight/hearing senses; epistemic fog; inspection | READY | +| [mechanics/cursor.md](../mechanics/cursor.md) | The cursor (attention, not avatar); sight/hearing senses; epistemic fog; inspection | IMPLEMENTED | | [engineering/flow-substrate.md](../engineering/flow-substrate.md) | The shared engine under signals/messages/money: FlowGraph + Schedule (src/flow.rs, src/schedule.rs) | IN PROGRESS | | [mechanics/reach.md](../mechanics/reach.md) | Digital reach: device graph, segments/the switch, sensor ownership (tap vs take) | IMPLEMENTED | | [mechanics/intel.md](../mechanics/intel.md) | Record and process: the buffer, processing costs, watches; replaces instant observe | READY |