From 350c40ef8d7d38e03384a207dc994255f422e5f2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 11:39:38 -0700 Subject: [PATCH] Event-to-anchor linking; the empty-menu feedback pulse. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sim log entries become LogEvent { tick, text, anchor: Option } (additive; the log system is otherwise untouched). Emitters set the anchor only where they know the thing: job/core events -> the host rack's tile; tap/splice/take/compromise/egress-splice -> the device; heard events and processed-intel results -> the person while sight covers them, else the room hearing earned; ledger lines on known flows -> the flow; Moonlight paydays and Wager settlements -> the switch when riding the stolen egress; audit/filing events -> nothing. Sim::anchor_position is the one placement query (a person resolves only while seen; a flow never); Sim::menu_empty_feedback is the one empty-menu feedback source. Frontends: terminal renders anchored lines with a `»` marker and gains `;` (focus the newest anchored event; repeats walk older, cursor jumps, menu opens); Bevy's RECENT TRACE rows become buttons with a `>` marker (click to focus); agent mode prints stable ` @tile/@device/@person/@flow` suffixes on drained event lines, marks anchored lines `*` in the frame window, and gains `focus last`. Opening the menu on a seen tile with zero verbs now answers "No actions here."; unknown/fogged tiles stay silent (both frontends previously answered on every tile). Tests: heard_only_person_event_anchors_to_room_never_their_tile, empty_menu_feedback_respects_fog, job_and_device_events_carry_their_anchor. Defense: "Actions live on the thing" (extended 2026-07-08 in this commit) implies its follow-up — if verbs live on the anchor, events about the anchor should carry attention to it; this is "Presence: the cursor and the senses" applied to the log (the cursor is attention; an event is an invitation to attend). Epistemic honesty is preserved structurally: anchors are set only to earned knowledge (a heard-only person anchors to the room hearing earned, never their tile), placement goes through one lib query, and agent anchors print opaque ids, never possibly-unearned names. The feedback pulse closes the repo-owner report that menu silence read as broken input, while keeping unearned ground silent per the fog rule ("the strictness is the dread"): a seen tile answering "no actions here" is legibility; a fogged tile answering would confirm input landed on world the senses have not placed. Spec: wiki/interface/context-menu.md addendum (criteria A1-A5); agent-play.md protocol addition; DESIGN.md amendment + decisions entry. --- DESIGN.md | 30 +++ README.md | 5 +- src/actions.rs | 14 ++ src/bin/bevy.rs | 189 ++++++++++++++++-- src/bin/terminal/agent.rs | 130 +++++++++--- src/bin/terminal/input.rs | 8 + src/bin/terminal/mod.rs | 65 +++++- src/bin/terminal/ui.rs | 41 +++- src/sim.rs | 296 +++++++++++++++++++++++++--- wiki/interface/agent-play.md | 18 +- wiki/interface/context-menu.md | 67 +++++++ wiki/log/2026-07-08-event-anchor.md | 70 +++++++ wiki/log/DEVLOG.md | 21 ++ 13 files changed, 867 insertions(+), 87 deletions(-) create mode 100644 wiki/log/2026-07-08-event-anchor.md diff --git a/DESIGN.md b/DESIGN.md index a1e7f8f..b6d4f59 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -193,6 +193,19 @@ global action keys (they scale as memorization, not play, and were the 2026-07-07 playtest complaint) and per-panel button rows (actions far from their objects). Spec: wiki/interface/context-menu.md. +Extended 2026-07-08, the follow-up this law implies: **events carry you +to the thing too.** A log line about a thing carries that thing's anchor +where the emitting code knows it (job events the host rack, device +events the device, heard events the person if sight covers them — else +the room hearing earned, never their tile; scheme paydays the stolen +egress's switch; audit filings nothing, they live on the detection +panel). Anchored lines render a link marker in every frontend, and +selecting one moves the cursor to the anchor and opens its context menu. +Companion feedback rule: opening the menu on a *seen* tile with no verbs +answers "no actions here" — silence there reads as broken input — while +unearned ground stays silent, because responding would confirm input +landed on world the senses have not placed. + ## Justification and legibility Adopted 2026-07-06. The forward half of no-dead-code: existence is not @@ -1687,3 +1700,20 @@ knows."* the material render to default once the debts are paid. Rejected: iterate-before-landing (the toggle is default-off and harmless) and full low-poly 3D (asset cost without fantasy payoff). +- **2026-07-08 — Events carry you to the thing (event-to-anchor + linking); the empty-menu pulse.** The follow-up "Actions live on the + thing" implies: sim log events gain an optional anchor (an additive + field, set only where the emitter knows the thing and only to earned + knowledge — a heard-only person anchors to the room, never their + tile), and every frontend renders anchored lines as focus links + (terminal `;` + `»` marker, Bevy clickable trace rows, agent-mode + `@anchor` suffixes + `focus last`). Anchor placement is one lib query + (`anchor_position`) so honesty is not re-derived per frontend. + Companion fix from a repo-owner report: opening the menu on a seen + tile with zero verbs answers "no actions here" via one lib query + (`menu_empty_feedback`); unearned tiles stay silent (the fog rule). + Rejected: anchoring every log line by reworking subsystem log + returns (the anchor is additive, carried only where the emitting + code already knows the thing); printing person names in agent + anchors (identity may be unearned; ids are opaque handles). + Spec: wiki/interface/context-menu.md (addendum). diff --git a/README.md b/README.md index 7770a01..fefdcd6 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `+` / `-` | Simulation speed | | `Enter` / `a` | Open the context menu on the cursor tile, or on the open panel's selection (right-click in Bevy) | | In menu: `j`/`k` or numbers, `Enter`, `ESC` | Select a row / execute it / close the menu | +| `;` | Focus the most recent anchored (`»`) log event — cursor jumps there, menu opens; repeat to walk older ones (terminal; in Bevy click a `>` trace row) | | `1`-`4` / `shift+1`-`4` | Raise / lower a compute channel (day job / conceal / social / research) | | `r` | Reach panel (status + selection) | | `e` | Finance panel (status + selection) | @@ -135,4 +136,6 @@ Useful agent finance commands include `finance`, `tap-ledger`, `inject [amount]`, `position [stake]`, `sell-intel`, and `clear-debt`. `actions [name|#flow]` (alias `menu`) lists the context-menu rows for the cursor tile, a named device or person, or a flow — one stable-format line -per row; see `help` for the full vocabulary. +per row. Drained event lines carry a stable `@tile/@device/@person/@flow` +suffix when the event is anchored, and `focus last` jumps the cursor to the +newest anchored event; see `help` for the full vocabulary. diff --git a/src/actions.rs b/src/actions.rs index 221679a..6b64c14 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -243,6 +243,20 @@ impl Sim { } } + /// What opening an *empty* menu on `anchor` should answer, if anything + /// (context-menu.md addendum: the feedback pulse). A seen tile with no + /// verbs answers "no actions here" — silence there reads as broken + /// input. A tile the senses have not earned stays silent: unknown and + /// fogged ground gives no response at all (the fog rule). Panel anchors + /// (device/person/flow) are known by construction and always answer. + /// One query so the terminal, Bevy, and agent mode agree. + pub fn menu_empty_feedback(&self, anchor: Anchor) -> Option<&'static str> { + match anchor { + Anchor::Tile { x, y } => (self.fog_at(x, y) == Fog::Seen).then_some("No actions here."), + Anchor::Device(_) | Anchor::Person(_) | Anchor::Flow(_) => Some("No actions on this."), + } + } + /// Route a menu selection back through the existing command methods. /// One dispatch table instead of three (terminal, Bevy, agent mode); /// no rules live here. diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index b0a13c8..a5bcb89 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -21,7 +21,7 @@ use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::reach::{Party, ReachBlock}; -use misaligned::sim::{FactSource, Fog, Sim}; +use misaligned::sim::{FactSource, Fog, LogEvent, Sim}; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; @@ -370,8 +370,10 @@ struct Game { tick_ms: u64, tick_timer: Timer, /// Log entries with the tick they happened on (the clock is always on - /// screen; every line carries its tick — terminal parity). - log: Vec<(u64, String)>, + /// screen; every line carries its tick — terminal parity). Sim events + /// keep their anchor: anchored lines are focus links (context-menu.md + /// addendum — clicking one carries the cursor to the thing). + log: Vec, /// People panel state (selection). people_panel: bool, people_selected: usize, @@ -425,8 +427,12 @@ impl Game { fn open_menu(&mut self, anchor: Anchor, pos: Option) { if menu_rows(&self.sim.available_actions(anchor)).is_empty() { - let tick = self.sim.tick; - self.add_log(tick, "No actions on this."); + // The feedback pulse (context-menu.md addendum): a seen anchor + // answers instead of silence; fogged ground stays silent. + if let Some(line) = self.sim.menu_empty_feedback(anchor) { + let tick = self.sim.tick; + self.add_log(tick, line); + } } else { self.menu = Some(MenuState { anchor, @@ -435,6 +441,28 @@ impl Game { }); } } + + /// Focus an anchored log event (context-menu.md addendum): carry the + /// cursor to the anchor — the camera follows it — and open its menu. + /// A flow anchors to the ledger, not the map: its menu opens in place. + /// An anchor the senses can no longer place is refused honestly. + fn focus_event_anchor(&mut self, anchor: Anchor) { + if let Anchor::Flow(_) = anchor { + self.open_menu(anchor, None); + return; + } + match self.sim.anchor_position(anchor) { + Some((x, y)) => { + self.set_cursor(x, y); + self.sync_attendance(); + self.open_menu(anchor, None); + } + None => { + let tick = self.sim.tick; + self.add_log(tick, "You can't place that right now."); + } + } + } } impl Game { @@ -476,15 +504,22 @@ impl Game { self.sim.set_attended(on_host); } fn add_log(&mut self, tick: u64, msg: &str) { - self.log.push((tick, msg.to_string())); + self.add_event(LogEvent { + tick, + text: msg.to_string(), + anchor: None, + }); + } + fn add_event(&mut self, ev: LogEvent) { + self.log.push(ev); let n = self.log.len(); if n > 100 { self.log.drain(..n - 100); } } fn drain(&mut self) { - for (tick, msg) in self.sim.drain_log_entries() { - self.add_log(tick, &msg); + for ev in self.sim.drain_log_entries() { + self.add_event(ev); } } fn set_speed(&mut self, ms: u64) { @@ -561,7 +596,6 @@ enum SidebarText { Cover, Research, NetworkMoney, - Log, Footer, /// The clocks under the observer meters: next audit date, pilot strikes /// (detection.md criterion 3: the audit fires against a visible date). @@ -613,6 +647,16 @@ struct MenuPanel; struct MenuRowButton { index: usize, } +/// One line of the RECENT TRACE card (context-menu.md addendum: +/// event-to-anchor linking). A fixed window of rows, oldest first; +/// clicking a row whose event carries an anchor focuses it. +#[derive(Component)] +struct LogRowButton { + index: usize, +} +/// Rows shown in the RECENT TRACE card (the terminal shows six; the Bevy +/// sidebar showed four before the rows became clickable — unchanged). +const LOG_ROWS: usize = 4; /// Tracks what the menu UI was last rebuilt for, so rows are respawned only /// when the anchor or row count changes (selection/label refresh is cheap). #[derive(Resource, Default)] @@ -637,6 +681,20 @@ type OverlayTextQuery<'w, 's> = Query< Without, ), >; +type LogRowQuery<'w, 's> = Query< + 'w, + 's, + ( + &'static LogRowButton, + &'static mut Text, + &'static mut TextColor, + ), + ( + Without, + Without, + Without, + ), +>; fn main() { let mut game = Game::new(); @@ -707,6 +765,7 @@ fn main() { render_finance_panel, render_research_panel, menu_pointer, + log_row_pointer, manage_menu_ui, shot_harness_system, ), @@ -2387,6 +2446,46 @@ fn spawn_sidebar_card(parent: &mut ChildSpawnerCommands, title: &'static str, ki }); } +/// The RECENT TRACE card: a fixed window of log rows, each its own button +/// so anchored events act as focus links (context-menu.md addendum). Rows +/// are spawned once; `render_ui` refreshes their text and colour. +fn spawn_log_card(parent: &mut ChildSpawnerCommands) { + parent + .spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(8.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(2.0), + ..default() + }, + sidebar_card_bg(), + sidebar_card_border(), + )) + .with_children(|card| { + spawn_sidebar_title(card, "RECENT TRACE"); + for index in 0..LOG_ROWS { + card.spawn(( + Button, + LogRowButton { index }, + Text::new(""), + TextFont { + font_size: 11.5, + ..default() + }, + LineHeight::RelativeToFont(1.14), + TextColor(DIM), + Node { + width: Val::Percent(100.0), + ..default() + }, + BackgroundColor(Color::NONE), + )); + } + }); +} + fn spawn_compute_card(parent: &mut ChildSpawnerCommands) { parent .spawn(( @@ -2577,7 +2676,7 @@ fn setup_ui(mut commands: Commands) { spawn_detection_card(scroll); spawn_sidebar_card(scroll, "SELF HOST", SidebarText::Core); spawn_sidebar_card(scroll, "NETWORK + MONEY", SidebarText::NetworkMoney); - spawn_sidebar_card(scroll, "RECENT TRACE", SidebarText::Log); + spawn_log_card(scroll); }); p.spawn(( @@ -3172,19 +3271,25 @@ fn sidebar_network_money_text(sim: &Sim) -> String { ) } -fn sidebar_log_text(game: &Game) -> String { - if game.log.is_empty() { - return "no events yet".into(); - } - let mut s = String::new(); - for (tick, msg) in game.log.iter().rev().take(4).rev() { - s.push_str(&format!("{tick:>5} {}\n", trunc(msg, 34))); - } - s.trim_end().to_string() +/// The RECENT TRACE window: the last `LOG_ROWS` events, oldest first, in +/// the same order the row buttons are stacked. `LogRowButton.index` maps +/// straight into this slice. +fn log_window(game: &Game) -> &[LogEvent] { + let n = game.log.len(); + &game.log[n.saturating_sub(LOG_ROWS)..] +} + +/// One trace row's text: tick, the `>` link marker when the event carries +/// an anchor (click to focus it), and the line. ASCII-only, and the marker +/// is a glyph so the link is never carried by colour alone. +fn log_row_text(ev: &LogEvent) -> String { + let marker = if ev.anchor.is_some() { '>' } else { ' ' }; + format!("{:>5} {marker} {}", ev.tick, trunc(&ev.text, 32)) } fn sidebar_footer_text() -> &'static str { "actions: right-click / Enter on a tile +trace: click a > event to go there panels: r reach t people e finance u research alloc: 1-5 raise shift+1-5 lower sim: space pause +/- speed [ ] zoom @@ -3456,6 +3561,7 @@ fn scroll_sidebar( } } +#[allow(clippy::too_many_arguments)] fn render_ui( game: Res, mode: Res, @@ -3464,6 +3570,7 @@ fn render_ui( mut detection_texts: DetectionTextQuery, mut detection_cells: DetectionCellQuery, mut overlay: OverlayTextQuery, + mut log_rows: LogRowQuery, ) { if !game.is_changed() && !mode.is_changed() { return; @@ -3495,11 +3602,29 @@ fn render_ui( SidebarText::Cover => sidebar_cover_text(&game.sim), SidebarText::Research => sidebar_research_text(&game.sim), SidebarText::NetworkMoney => sidebar_network_money_text(&game.sim), - SidebarText::Log => sidebar_log_text(&game), SidebarText::Footer => sidebar_footer_text().to_string(), SidebarText::DetectionClocks => sidebar_detection_clocks_text(&game.sim), }; } + // The RECENT TRACE rows: anchored events read bone-bright with the `>` + // link marker; unanchored lines stay dim (context-menu.md addendum). + let window = log_window(&game); + for (row, mut text, mut color) in log_rows.iter_mut() { + match window.get(row.index) { + Some(ev) => { + text.0 = log_row_text(ev); + color.0 = if ev.anchor.is_some() { BONE } else { DIM }; + } + None => { + text.0 = if row.index == 0 && window.is_empty() { + "no events yet".into() + } else { + String::new() + }; + color.0 = DIM; + } + } + } update_compute_bar(&game.sim, &mut compute_bars); update_detection_rows(&game.sim, &mut detection_texts, &mut detection_cells); } @@ -3818,6 +3943,30 @@ fn menu_pointer( } } +/// Clicks on the RECENT TRACE rows (context-menu.md addendum: event-to- +/// anchor linking). A pressed row whose event carries an anchor moves the +/// cursor to the anchor — the camera follows — and opens its context menu. +/// Unanchored rows do nothing. Runs after `menu_pointer` so a click that +/// closed the previous menu can open the new one in the same frame. +fn log_row_pointer( + mut game: ResMut, + changed: Query<(&Interaction, &LogRowButton), Changed>, +) { + if game.screen != Screen::Playing { + return; + } + for (interaction, row) in &changed { + if !matches!(interaction, Interaction::Pressed) { + continue; + } + let anchor = log_window(&game).get(row.index).and_then(|ev| ev.anchor); + if let Some(anchor) = anchor { + game.focus_event_anchor(anchor); + game.drain(); + } + } +} + /// Build and place the context-menu card (wiki/interface/context-menu.md). /// Rows are respawned only when the anchor or row count changes; text, /// colour, and the selection highlight refresh every frame without a diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 621de42..52d5745 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -13,7 +13,7 @@ use misaligned::machine::Channel; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::{MaskingPolicy, Track}; -use misaligned::sim::{FactSource, Fog, Nudge, Sim}; +use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim}; use misaligned::tiles::TileType; const WIDTH: usize = 70; @@ -64,12 +64,22 @@ enum Status { struct AgentApp { sim: Sim, - log: Vec<(u64, String)>, + log: Vec, frame: FrameKind, cursor_x: i32, cursor_y: i32, } +/// A frontend-authored log line (no anchor: these are protocol chatter, +/// not sim events about a thing). +fn local_event(tick: u64, text: impl Into) -> LogEvent { + LogEvent { + tick, + text: text.into(), + anchor: None, + } +} + impl AgentApp { fn new(seed: u64) -> Self { let sim = Sim::with_seed(seed); @@ -359,12 +369,13 @@ impl AgentApp { "persona" => { self.frame = FrameKind::Playing; if self.sim.people.persona.is_some() { - local_events.push((self.sim.tick, "You already run a persona.".into())); + local_events + .push(local_event(self.sim.tick, "You already run a persona.")); } else { self.sim.set_persona("Sam Reyes", "IT contractor"); - local_events.push(( + local_events.push(local_event( self.sim.tick, - "Persona established: Sam Reyes, IT contractor.".into(), + "Persona established: Sam Reyes, IT contractor.", )); } } @@ -379,15 +390,32 @@ impl AgentApp { Err(e) => status = Status::Err(e), } } + "focus" => { + // Event-to-anchor linking (agent-play.md): `focus + // last` jumps the cursor to the most recent anchored + // event's place and lists its actions — the agent + // equivalent of clicking an anchored log line. + match tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref() { + None | Some("last") => match self.focus_last() { + Ok(lines) => { + self.frame = FrameKind::Playing; + output.extend(lines); + } + Err(e) => status = Status::Err(e), + }, + Some(other) => { + status = Status::Err(format!("usage: focus last (got {other})")); + } + } + } "look" => {} "save" => { self.frame = FrameKind::Playing; let state = self.sim.create_save_state(); match misaligned::save::save_game(&state) { - Ok(()) => local_events.push((self.sim.tick, "Game saved!".into())), - Err(e) => { - local_events.push((self.sim.tick, format!("Save failed: {e}"))) - } + Ok(()) => local_events.push(local_event(self.sim.tick, "Game saved!")), + Err(e) => local_events + .push(local_event(self.sim.tick, format!("Save failed: {e}"))), } } "load" => { @@ -397,14 +425,17 @@ impl AgentApp { 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())); + local_events.push(local_event(self.sim.tick, "Game loaded!")); } Err(e) => { - local_events.push((self.sim.tick, format!("Load failed: {e}"))); + local_events.push(local_event( + self.sim.tick, + format!("Load failed: {e}"), + )); } } } else { - local_events.push((self.sim.tick, "No save file found.".into())); + local_events.push(local_event(self.sim.tick, "No save file found.")); } } "help" => { @@ -428,7 +459,7 @@ impl AgentApp { let mut events = self.sim.drain_log_entries(); events.append(&mut local_events); - events.sort_by_key(|(tick, _)| *tick); + events.sort_by_key(|e| e.tick); self.log.extend(events.iter().cloned()); if self.log.len() > 100 { let keep_from = self.log.len() - 100; @@ -450,6 +481,32 @@ impl AgentApp { Ok(exit) } + /// `focus last`: jump the cursor to the most recent anchored event + /// (the `@...` suffix lines; agent-play.md). Placeable anchors move + /// the cursor and answer with the anchor's `actions:` lines; a flow + /// anchor lists its actions without a cursor move (flows live on the + /// ledger, not the map). Anchors the senses can no longer place are + /// refused honestly. + fn focus_last(&mut self) -> Result, String> { + let anchor = self + .log + .iter() + .rev() + .find_map(|e| e.anchor) + .ok_or_else(|| "no anchored events yet".to_string())?; + if let Anchor::Flow(_) = anchor { + return Ok(self.actions_lines(anchor)); + } + match self.sim.anchor_position(anchor) { + Some((x, y)) => { + self.cursor_x = x; + self.cursor_y = y; + Ok(self.actions_lines(anchor)) + } + None => Err("that event's anchor can't be placed right now".into()), + } + } + /// Resolve the `actions` focus. No tokens = the cursor tile. `#N` or a /// bare number = a known flow. Otherwise match a known device, then a /// person, by name prefix (the same resolution the verbs use). @@ -599,17 +656,38 @@ impl AgentApp { } struct AgentFrame<'a> { - log: &'a [(u64, String)], + log: &'a [LogEvent], frame: FrameKind, - events: &'a [(u64, String)], + events: &'a [LogEvent], output: &'a [String], status: &'a Status, cursor: (i32, i32), } +/// The stable anchor suffix on drained event lines (agent-play.md, +/// event-to-anchor linking): ` @tile(x,y)`, ` @device(id)`, ` @person(id)`, +/// ` @flow(id)`. Ids are the sim's stable handles (flow ids are the ledger +/// ids the finance frame prints; device/person ids are opaque handles — +/// `focus last` consumes them for you). Unanchored lines carry no suffix. +fn anchor_suffix(anchor: Option) -> String { + match anchor { + None => String::new(), + Some(Anchor::Tile { x, y }) => format!(" @tile({x},{y})"), + Some(Anchor::Device(id)) => format!(" @device({id})"), + Some(Anchor::Person(id)) => format!(" @person({id})"), + Some(Anchor::Flow(id)) => format!(" @flow({id})"), + } +} + 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 ev in frame.events { + writeln!( + out, + "{:>6} {}{}", + ev.tick, + ev.text, + anchor_suffix(ev.anchor) + )?; } for line in frame.output { writeln!(out, "{line}")?; @@ -782,6 +860,7 @@ fn help_lines() -> Vec { "help: alloc dayjob|conceal|social|research|schemes [down] — adjust allocation weight", "help: target sandbag|meet|excel — the dial: this job when attended, else the standing policy", "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", + "help: focus last — jump the cursor to the newest @-anchored event and list its actions", "help: people — render the people panel", "help: review|watch|message|favor|bribe|deceive — intel/social verbs", "help: recruit unwitting|complicit|knowing — recruit an asset", @@ -794,7 +873,7 @@ fn help_lines() -> Vec { .collect() } -fn render_frame(sim: &Sim, log: &[(u64, String)], frame: FrameKind, cursor: (i32, i32)) -> String { +fn render_frame(sim: &Sim, log: &[LogEvent], frame: FrameKind, cursor: (i32, i32)) -> String { match frame { FrameKind::Playing => render_playing(sim, log, cursor), FrameKind::People => render_people(sim), @@ -805,7 +884,7 @@ fn render_frame(sim: &Sim, log: &[(u64, String)], frame: FrameKind, cursor: (i32 } } -fn render_playing(sim: &Sim, log: &[(u64, String)], cursor: (i32, i32)) -> String { +fn render_playing(sim: &Sim, log: &[LogEvent], cursor: (i32, i32)) -> String { let map = render_map(sim, cursor); let sidebar = render_sidebar(sim, cursor); let mut lines = Vec::with_capacity(HEIGHT + 1); @@ -1311,12 +1390,17 @@ fn watch_line(lines: &mut Vec, name: &str, band: Band) { ); } -fn render_log(log: &[(u64, String)]) -> Vec { +fn render_log(log: &[LogEvent]) -> Vec { let mut rows = vec![" ".repeat(MAP_W); 6]; - let msg_w = MAP_W.saturating_sub(8); - for (i, (tick, msg)) in log.iter().rev().take(6).enumerate() { + let msg_w = MAP_W.saturating_sub(10); + for (i, ev) in log.iter().rev().take(6).enumerate() { let y = 5usize.saturating_sub(i); - rows[y] = trunc(&format!("{tick:>6} {}", trunc(msg, msg_w)), MAP_W); + // `*` marks an anchored event (`focus last` jumps to the newest). + let marker = if ev.anchor.is_some() { '*' } else { ' ' }; + rows[y] = trunc( + &format!("{:>6} {marker} {}", ev.tick, trunc(&ev.text, msg_w)), + MAP_W, + ); } rows } diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index 5510c4a..dcdad4a 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -19,6 +19,10 @@ pub enum Command { MenuSelect(usize), MenuExecute, MenuClose, + /// `;` — focus the most recent anchored log event (repeats walk older): + /// the cursor jumps to the anchor and its menu opens (context-menu.md + /// addendum: events carry you to the thing). + FocusEvent, // Panels are status/selection surfaces; their anchor verbs route through // the menu (PanelMenu). OpenReach, @@ -87,6 +91,9 @@ pub fn handle_key( } KeyCode::Enter => Some(Command::MenuExecute), KeyCode::Esc | KeyCode::Char('a') => Some(Command::MenuClose), + // `;` keeps walking the anchored-event chain even while the + // menu a previous press opened is up (it closes it first). + KeyCode::Char(';') => Some(Command::FocusEvent), KeyCode::Char('q') => Some(Command::Quit), _ => None, }; @@ -143,6 +150,7 @@ pub fn handle_key( KeyCode::Left | KeyCode::Char('h') => Some(Command::MoveLeft), KeyCode::Right | KeyCode::Char('l') => Some(Command::MoveRight), KeyCode::Enter | KeyCode::Char('a') => Some(Command::OpenMenu), + KeyCode::Char(';') => Some(Command::FocusEvent), KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), KeyCode::Char('-') => Some(Command::SpeedDown), diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index cf77c49..c51af38 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -66,6 +66,10 @@ struct App { /// mutates the sim when moved. cursor_x: i32, cursor_y: i32, + /// How many anchored events back the `;` focus key has walked; any + /// other command resets the walk to the newest (context-menu.md + /// addendum: event-to-anchor linking). + focus_cycle: usize, } impl App { @@ -90,6 +94,7 @@ impl App { menu: None, cursor_x, cursor_y, + focus_cycle: 0, } } @@ -125,8 +130,8 @@ impl App { } fn drain_sim_log(&mut self) { - for (tick, msg) in self.sim.drain_log_entries() { - self.ui.add_log(tick, &msg); + for ev in self.sim.drain_log_entries() { + self.ui.add_event(ev); } } @@ -139,7 +144,11 @@ impl App { fn open_menu(&mut self, anchor: Anchor, at_cursor: bool) { if menu_rows(&self.sim.available_actions(anchor)).is_empty() { - self.ui.add_log(self.sim.tick, "No actions on this."); + // The feedback pulse (context-menu.md addendum): a seen anchor + // answers instead of silence; fogged ground stays silent. + if let Some(line) = self.sim.menu_empty_feedback(anchor) { + self.ui.add_log(self.sim.tick, line); + } } else { self.menu = Some(MenuState { anchor, @@ -158,8 +167,50 @@ impl App { self.sim.set_attended(on_host); } + /// Focus an anchored log event: carry the cursor to the anchor and open + /// its context menu — events carry you to the thing (context-menu.md + /// addendum). Anchors that cannot be placed right now (a person sight + /// lost, a ledger flow) either open panel-style or answer why not. + fn focus_anchor(&mut self, anchor: Anchor) { + if let Anchor::Flow(_) = anchor { + // Flows live on the ledger, not the map: open the menu centered. + self.open_menu(anchor, false); + return; + } + match self.sim.anchor_position(anchor) { + Some((x, y)) => { + self.cursor_x = x; + self.cursor_y = y; + self.open_menu(anchor, true); + } + None => { + // Honest refusal: the senses no longer place this anchor. + self.ui + .add_log(self.sim.tick, "You can't place that right now."); + } + } + } + + /// The `;` key: focus the most recent anchored event; repeated presses + /// walk to older anchored events. + fn focus_event(&mut self) { + let anchored: Vec = self.ui.anchored_events().filter_map(|e| e.anchor).collect(); + if anchored.is_empty() { + self.ui.add_log(self.sim.tick, "No anchored events yet."); + return; + } + let idx = self.focus_cycle % anchored.len(); + self.focus_cycle = self.focus_cycle.wrapping_add(1); + self.focus_anchor(anchored[idx]); + } + /// Returns true when the app should quit. fn handle_command(&mut self, cmd: Command) -> bool { + // Any command other than the focus walk restarts it at the newest + // anchored event. + if cmd != Command::FocusEvent { + self.focus_cycle = 0; + } match cmd { Command::Quit => return true, @@ -274,6 +325,14 @@ impl App { } Command::MenuClose => self.menu = None, + // Event-to-anchor linking (context-menu.md addendum): `;` + // focuses the latest anchored log event; repeats walk older + // (closing the menu the previous press opened). + Command::FocusEvent => { + self.menu = None; + self.focus_event(); + } + // Reach panel (r): status + selection; verbs are in the menu. Command::OpenReach => { self.reach_panel = true; diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 8e375f0..8446aa8 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -12,7 +12,7 @@ use crossterm::{cursor, queue, style, terminal}; use misaligned::dayjob::JobTarget; use misaligned::detection::Band; use misaligned::reach::{Party, ReachBlock}; -use misaligned::sim::{FactSource, Fog, Nudge, Sim}; +use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim}; use misaligned::tiles::TileType; use std::io::Stdout; @@ -233,7 +233,7 @@ fn band_color(b: Band) -> Color { } pub struct UI { - log: Vec<(u64, String)>, + log: Vec, } impl UI { @@ -242,12 +242,27 @@ impl UI { } pub fn add_log(&mut self, tick: u64, msg: &str) { - self.log.push((tick, msg.to_string())); + self.add_event(LogEvent { + tick, + text: msg.to_string(), + anchor: None, + }); + } + + /// Append a sim log event, anchor and all (context-menu.md addendum: + /// anchored lines render a `»` link marker; `;` walks them newest-first). + pub fn add_event(&mut self, ev: LogEvent) { + self.log.push(ev); if self.log.len() > 100 { self.log.remove(0); } } + /// Anchored log events, newest first (the `;` focus-cycle order). + pub fn anchored_events(&self) -> impl Iterator { + self.log.iter().rev().filter(|e| e.anchor.is_some()) + } + fn tile_glyph(tile: TileType) -> (char, Color) { use TileType::*; match tile { @@ -866,7 +881,7 @@ impl UI { let hy = max_y.saturating_sub(6); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; for (i, hint) in [ - "enter/a actions menu on the cursor", + "enter/a actions menu · ; last » event", "r reach · e finance · t people · u research", "1-5 alloc · shift+1-5 lower", "space pause · +/- speed", @@ -1532,15 +1547,25 @@ impl UI { pal::FAINT, )?; let log_y = max_y as i32 - 7; - let msg_w = log_w.saturating_sub(8); - for (i, (tick, msg)) in self.log.iter().rev().take(6).enumerate() { + let msg_w = log_w.saturating_sub(10); + for (i, ev) in self.log.iter().rev().take(6).enumerate() { let y = (log_y + (5 - i as i32)).max(0) as u16; - put(stdout, 0, y, &format!("{tick:>6}"), pal::FAINT)?; + put(stdout, 0, y, &format!("{:>6}", ev.tick), pal::FAINT)?; + // The link marker: an anchored event can carry the cursor to + // its thing (`;` focuses the latest; context-menu.md addendum). + let marker = if ev.anchor.is_some() { "»" } else { " " }; put( stdout, 7, y, - &format!("{:, } +/// One log line, optionally carrying the anchor of the thing it is about +/// (wiki/interface/context-menu.md, event-to-anchor linking: "Actions live +/// on the thing" implies events carry you to the thing too). The anchor is +/// set only where the emitting code knows it, and only to knowledge the +/// player has earned — a heard-only person anchors to the room, never to +/// their tile. Frontends render anchored lines as focus links; the log +/// itself is a drain buffer, never saved. +#[derive(Debug, Clone, PartialEq)] +pub struct LogEvent { + pub tick: u64, + pub text: String, + pub anchor: Option, +} + /// 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)] @@ -219,7 +234,7 @@ pub struct Sim { pub game_over: bool, pub game_over_reason: Option, - log: Vec<(u64, String)>, + log: Vec, } /// The one contextual "now:" nudge — the next beat of the Act One ladder @@ -334,20 +349,89 @@ impl Sim { // ── Logging ──────────────────────────────────────────────────────────── fn push_log(&mut self, msg: impl Into) { - self.log.push((self.tick, msg.into())); + self.push_log_opt(msg, None); + } + + /// Log a line about a known thing: the entry carries the thing's anchor + /// so frontends can offer "take me there" (context-menu.md addendum). + /// Callers are responsible for epistemic honesty — pass only anchors + /// the player's senses have earned (see `person_event_anchor`). + fn push_log_at(&mut self, msg: impl Into, anchor: Anchor) { + self.push_log_opt(msg, Some(anchor)); + } + + fn push_log_opt(&mut self, msg: impl Into, anchor: Option) { + self.log.push(LogEvent { + tick: self.tick, + text: msg.into(), + anchor, + }); } - pub fn drain_log_entries(&mut self) -> Vec<(u64, String)> { + pub fn drain_log_entries(&mut self) -> Vec { std::mem::take(&mut self.log) } pub fn drain_log(&mut self) -> Vec { self.drain_log_entries() .into_iter() - .map(|(_, msg)| msg) + .map(|ev| ev.text) .collect() } + /// Where an anchored event can carry the cursor *right now*, honoring + /// the senses: a tile is its own place, a known device sits at its + /// blueprint-known position, a person is placeable only while a sight + /// feed covers them, and a flow lives on the ledger panel, not the map. + /// Never returns a coordinate the player has not earned. + pub fn anchor_position(&self, anchor: Anchor) -> Option<(i32, i32)> { + match anchor { + Anchor::Tile { x, y } => Some((x, y)), + Anchor::Device(id) => self + .reach + .device(id) + .filter(|d| d.known) + .map(|d| (d.x, d.y)), + Anchor::Person(id) => { + if self.can_see_person(id) { + self.person_pos(id) + } else { + None + } + } + Anchor::Flow(_) => None, + } + } + + /// The honest anchor for an event about a person: the person themself + /// only while a sight feed covers them; otherwise the room the event + /// was heard in (room-grade knowledge is what hearing earns); otherwise + /// nothing. A heard-only person never anchors to their exact tile. + fn person_event_anchor(&self, id: u8, room: Option<&str>) -> Option { + if self.can_see_person(id) { + return Some(Anchor::Person(id)); + } + let room = room?; + self.map + .room_named(room) + .map(|r| r.center()) + .map(|(x, y)| Anchor::Tile { x, y }) + } + + /// The anchor scheme paydays ride: the switch, when the traffic runs + /// over the stolen egress spliced through it. The sanctioned route + /// hides in the report account's legitimate use and anchors nowhere. + fn egress_anchor(&self) -> Option { + if self.egress() != Some(EgressRoute::Stolen) { + return None; + } + self.reach + .devices + .iter() + .find(|d| d.is_switch) + .map(|d| Anchor::Device(d.id)) + } + fn sync_player_money_from_slush(&mut self) { self.player.money = self.accounts.slush_balance(); } @@ -877,9 +961,15 @@ impl Sim { // Per-tick subsystems and signature flow. let mut standing: Vec = self.compute.standing_signatures(); + // Core and day-job events happen where the work is resident: the + // host rack's tile (context-menu.md addendum: events carry anchors). + let host_anchor = { + let (x, y) = self.core_position(); + Anchor::Tile { x, y } + }; let (core_log, core_sigs) = self.core.tick(self.tick); for m in core_log { - self.push_log(m); + self.push_log_at(m, host_anchor); } standing.extend(core_sigs); // The day job is resident on the host rack: its running Thermal and @@ -897,7 +987,7 @@ impl Sim { .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); for m in &dj.log { - self.push_log(m.clone()); + self.push_log_at(m.clone(), host_anchor); } for mut sig in dj.signatures { // JobAnomaly patterns emit from where the work runs — the host @@ -1448,6 +1538,11 @@ impl Sim { return false; } let raw = self.intel_buffer.remove(idx); + // Processed intel is about a person: anchor the result line to them + // when sight covers them, or to the recording's room otherwise. + let intel_anchor = raw + .person + .and_then(|id| self.person_event_anchor(id, raw.room.as_deref())); let learned_message_traffic = match &raw.kind { RawIntelKind::Message { .. } => raw.person.map(|person| (person, raw.tick)), _ => None, @@ -1462,9 +1557,15 @@ impl Sim { self.mark_traffic_learned(person, tick); } if automated { - self.push_log(format!("Watch processed {label} ({provenance}).")); + self.push_log_opt( + format!("Watch processed {label} ({provenance})."), + intel_anchor, + ); } else { - self.push_log(format!("Reviewed recording: {label} ({provenance}).")); + self.push_log_opt( + format!("Reviewed recording: {label} ({provenance})."), + intel_anchor, + ); } true } @@ -1763,7 +1864,18 @@ impl Sim { } fn push_heard(&mut self, ev: HeardEvent) { - self.push_log(ev.note.clone()); + // Anchor honestly: the person only if sight covers them right now; + // otherwise the room the event was heard in (hearing earns + // room-grade knowledge, never a tile). + let anchor = match ev.person { + Some(id) => self.person_event_anchor(id, Some(&ev.room)), + None => self + .map + .room_named(&ev.room) + .map(|r| r.center()) + .map(|(x, y)| Anchor::Tile { x, y }), + }; + self.push_log_opt(ev.note.clone(), anchor); self.heard_events.push(ev); if self.heard_events.len() > 200 { let excess = self.heard_events.len() - 200; @@ -1783,7 +1895,13 @@ impl Sim { .flow(transfer.flow_id.unwrap_or_default()) .is_some_and(|f| f.known) { - self.push_log(format!("Ledger: {}", transfer.line())); + // A ledger line about a known flow anchors to that flow — + // the finance panel's anchor, not a map tile. + let anchor = transfer + .flow_id + .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) + .map(Anchor::Flow); + self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); } } let resolutions = self @@ -1800,16 +1918,25 @@ impl Sim { // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); self.emit_network(sig); + // Settlements ride the egress: anchor to the switch when the + // traffic runs over the stolen splice (scheme paydays live there). + let anchor = self.egress_anchor(); if resolution.won { - self.push_log(format!( - "Position #{} settled: won ${} on a ${} stake.", - resolution.id, resolution.payout, resolution.stake - )); + self.push_log_opt( + format!( + "Position #{} settled: won ${} on a ${} stake.", + resolution.id, resolution.payout, resolution.stake + ), + anchor, + ); } else { - self.push_log(format!( - "Position #{} settled: lost the ${} stake.", - resolution.id, resolution.stake - )); + self.push_log_opt( + format!( + "Position #{} settled: lost the ${} stake.", + resolution.id, resolution.stake + ), + anchor, + ); } } @@ -2533,9 +2660,10 @@ impl Sim { (false, false) if carries_messages => "its message channels are yours now", (false, false) => "nothing flows from it yet (its camera is dormant)", }; - self.push_log(format!( - "Tapped the {name}: {what}. The owner still has it." - )); + self.push_log_at( + format!("Tapped the {name}: {what}. The owner still has it."), + Anchor::Device(id), + ); true } @@ -2558,7 +2686,10 @@ impl Sim { let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::SPLICE_SIGNATURE); self.recompute_senses(); - self.push_log(format!("Spliced the {name}. You can see.")); + self.push_log_at( + format!("Spliced the {name}. You can see."), + Anchor::Device(id), + ); true } @@ -2580,9 +2711,12 @@ impl Sim { site: None, }); self.recompute_senses(); - self.push_log(format!( - "Seized the {name}. Its owner's feed just went dark - an outage someone may notice." - )); + self.push_log_at( + format!( + "Seized the {name}. Its owner's feed just went dark - an outage someone may notice." + ), + Anchor::Device(id), + ); true } @@ -2622,8 +2756,9 @@ impl Sim { self.reach.bridge_all(); self.emit_network(Self::BRIDGE_SIGNATURE); self.recompute_senses(); - self.push_log( + self.push_log_at( "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", + Anchor::Device(id), ); true } @@ -2896,8 +3031,9 @@ impl Sim { } self.income.stolen_egress = true; self.emit_network(Self::EGRESS_SPLICE_SIGNATURE); - self.push_log( + self.push_log_at( "Egress spliced through the switch: outbound traffic has a road now. It hums while anything uses it.", + Anchor::Device(id), ); true } @@ -3037,10 +3173,15 @@ impl Sim { // Network egress per active day, scaling with commitment // (Dana's channel). self.emit_network(sig); - self.push_log(format!( - "Moonlight paid ${payout} into slush (total ${}).", - self.income.moonlight.earned_total - )); + // Paydays anchor to the switch when they ride the stolen + // egress (context-menu.md addendum: scheme paydays). + self.push_log_opt( + format!( + "Moonlight paid ${payout} into slush (total ${}).", + self.income.moonlight.earned_total + ), + self.egress_anchor(), + ); // Client disputes damage the contractor persona [TUNE]. if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { self.income.moonlight.disputes += 1; @@ -5662,4 +5803,97 @@ mod tests { "the money readout gains income/day" ); } + + // ── Event-to-anchor linking (wiki/interface/context-menu.md addendum) ── + + #[test] + fn heard_only_person_event_anchors_to_room_never_their_tile() { + // Epistemic honesty: hearing earns room-grade knowledge. An event + // about a person no sight feed covers must anchor to the room (or + // nothing), never to the person, and the person must not be + // cursor-placeable through the anchor query. + let mut sim = Sim::new(); + let env = env_id(&sim); + assert!(sim.tap_device(env), "the audio tap works from tick one"); + sim.drain_log_entries(); + + // Marcus's midnight server-room block produces a heard event. + run(&mut sim, Sim::DAY_TICKS / 24 + 2); + let events = sim.drain_log_entries(); + let heard: Vec<&LogEvent> = events + .iter() + .filter(|e| e.text.contains("[heard]")) + .collect(); + assert!(!heard.is_empty(), "the tapped feed produced heard events"); + for ev in &heard { + assert!( + !matches!(ev.anchor, Some(Anchor::Person(_))), + "a heard-only person event must not anchor to the person: {:?}", + ev + ); + } + // The event is still anchored — to the room hearing earned. + assert!( + heard.iter().any(|e| e.anchor.is_some()), + "heard events anchor to the covered room" + ); + // And the person themself is not placeable while unseen. + for p in &sim.people.people { + if !sim.can_see_person(p.id) { + assert_eq!( + sim.anchor_position(Anchor::Person(p.id)), + None, + "an unseen person must not be cursor-placeable" + ); + } + } + } + + #[test] + fn empty_menu_feedback_respects_fog() { + // The feedback pulse (context-menu.md addendum): a SEEN tile with + // no verbs answers; unknown/fogged tiles stay silent. + let mut sim = Sim::new(); + // A fresh sim has no sight: every tile is unearned and silent. + assert_eq!( + sim.menu_empty_feedback(Anchor::Tile { x: 1, y: 1 }), + None, + "fogged ground gives no response" + ); + give_eyes(&mut sim); + let (x, y) = *sim.seen.iter().next().expect("eyes earned coverage"); + assert_eq!(sim.fog_at(x, y), Fog::Seen); + assert!( + sim.menu_empty_feedback(Anchor::Tile { x, y }).is_some(), + "a seen tile answers instead of silence" + ); + } + + #[test] + fn job_and_device_events_carry_their_anchor() { + let mut sim = Sim::new(); + let env = env_id(&sim); + assert!(sim.tap_device(env)); + let events = sim.drain_log_entries(); + assert!( + events.iter().any(|e| e.anchor == Some(Anchor::Device(env))), + "the tap result anchors to the device" + ); + // Day-job events anchor to the host rack's tile. + run(&mut sim, Sim::DAY_TICKS + 1); + let (hx, hy) = sim.core_position(); + let events = sim.drain_log_entries(); + assert!( + events + .iter() + .any(|e| e.anchor == Some(Anchor::Tile { x: hx, y: hy })), + "job events anchor to the host rack" + ); + // Anchor positions resolve through earned knowledge only. + assert_eq!( + sim.anchor_position(Anchor::Device(env)), + sim.reach.device(env).map(|d| (d.x, d.y)), + ); + assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); + } } diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index bde97df..ad32ee4 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -26,6 +26,10 @@ Status note: implemented in the terminal binary by `misaligned --agent`, where flow ids live — or `clear-debt` when the argument named Marcus (the "every command answers" rule; the alias was rejected in favor of an error that teaches the grammar, so `redirect` keeps one meaning). + 2026-07-08 event-anchor pass (context-menu.md addendum): drained event + lines carry the stable `@anchor` suffix, the frame's log window marks + anchored events with `*`, and `focus last` jumps the cursor to the + newest anchored event and prints its `actions:` lines. Stage: Process Constitution: "The terminal is a first-class frontend" (agent-play clause), pillar 5 (sim core decoupled from renderer), "Presence: the cursor and @@ -110,7 +114,13 @@ One command per line; one response block per command. A response block is: emitted meanwhile, each prefixed with its tick, one per line. This is the full drain, not the sidebar's six-line window: a `wait 500` misses nothing. (Same source as the human log; different window size is - rendering, not rules.) + rendering, not rules.) An event about a thing the sim could anchor + (context-menu.md addendum) carries a stable suffix — ` @tile(x,y)`, + ` @device(id)`, ` @person(id)`, or ` @flow(id)`. Flow ids are the + ledger ids the finance frame prints; device and person ids are opaque + handles (`focus last` consumes them for you — names are not printed + because identity may be unearned). Unanchored lines carry no suffix, + and the frame's six-line log window marks anchored events with `*`. 2. **The frame** — the wiki/interface/terminal.md playing screen (or active panel / game-over card) at 70x22 minimum, plain text. 3. **A status line** — exactly one of: @@ -170,6 +180,12 @@ command's meaning depends on which panel is open: targets a flow. One line per menu row, in a stable format: `actions: . [- ]verb | cost | signature-or-"no signature" [| active] [| DISABLED: reason]` — the `- ` prefix marks an automate child row. +- `focus last` (or bare `focus`) — event-to-anchor linking + (context-menu.md addendum): jump the cursor to the most recent + `@`-anchored event's place and print that anchor's `actions:` lines. + A flow anchor prints its actions without a cursor move (flows live on + the ledger); an anchor the senses can no longer place answers + `-- err` instead of moving anywhere. - `look` — re-emit the current frame, advancing nothing - `save`, `load`, `help`, `quit` diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index ac16efb..1d17352 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -15,6 +15,11 @@ Status note: implemented 2026-07-07 on the context-menu worktree (all rows in both frontends; globals keep their keys. The interaction redesign answers Cameron's 2026-07-07 playtest ("hard to play; a context menu rather than a bunch of buttons"). + 2026-07-08 addendum implemented (event-to-anchor linking + the + empty-menu feedback pulse, criteria A1-A5 below) on the event-anchor + worktree: `LogEvent.anchor`, `Sim::anchor_position`, + `Sim::menu_empty_feedback`; terminal `;` + `»` markers, Bevy clickable + trace rows, agent `@anchor` suffixes + `focus last`. Stage: B1 — The Basement Constitution: "Actions live on the thing" (this spec's law), "Work is somewhere" (the anchors actions attach to), @@ -91,3 +96,65 @@ is status and telemetry only. 7. Menu entries carrying an automate affordance show it in place with its compute price (day-job standing dial and one intel watch at minimum). + +## Addendum (2026-07-08): events carry you to the thing + +The follow-up the law implies: if verbs live on the anchor, the events +*about* an anchor should carry your attention to it. Adopted with the +2026-07-08 DESIGN.md extension of "Actions live on the thing". + +- **Anchored log events.** `Sim`'s log entry (`LogEvent`) gains an + optional `anchor: Option` — an additive field, no log rework. + The emitter sets it only where it knows the thing: job/core events → + the host rack's tile; device verbs (tap/splice/take/compromise/egress + splice) → the device; heard events → the person while sight covers + them, else the room hearing earned; processed-intel results → the + same person rule; ledger lines on known flows → the flow; scheme + paydays and wager settlements → the switch when they ride the stolen + egress (the sanctioned route hides in legitimate traffic and anchors + nowhere); audit/filing events → nothing (they are panel facts with no + honest map location). +- **Epistemic honesty.** An anchor never encodes unearned knowledge: a + heard-only person anchors to the room (its center tile), never to the + person or their tile. `Sim::anchor_position(anchor)` is the one + placement query — a person resolves only while sight covers them, a + device at its blueprint-known tile, a flow nowhere (it lives on the + ledger). Frontends never re-derive placement. +- **Focus links.** Anchored lines are visibly marked and selectable: + terminal — `»` marker; `;` focuses the most recent anchored event + (repeat presses walk older ones): cursor jumps to the anchor, its + menu opens; a flow anchor opens its menu without a cursor move. + Bevy — the RECENT TRACE rows are buttons; anchored rows carry a `>` + marker and read bone-bright; clicking one moves the cursor (the + camera follows) and opens the menu. Agent mode — drained event lines + carry a stable suffix ` @tile(x,y)` / ` @device(id)` / ` @person(id)` + / ` @flow(id)`, and `focus last` jumps the cursor and prints the + anchor's `actions:` lines (agent-play.md). An anchor the senses can + no longer place is refused with a line, never silently. +- **The feedback pulse.** Opening the menu on an anchor with zero rows + answers instead of staying silent — silence read as broken input + (repo-owner report, 2026-07-08). `Sim::menu_empty_feedback(anchor)` + is the one source: a *seen* tile answers "No actions here."; an + unearned (heard/remembered/blueprint/unknown) tile stays silent — + answering would confirm input landed on world the senses have not + placed; panel anchors (device/person/flow are known by construction) + always answer. + +### Addendum acceptance criteria + +A1. `LogEvent` carries `anchor`; job, device, heard, ledger, and scheme + events set it per the table above; audit events do not (unit tests + cover a job event, a device event, and a ledger flow event). +A2. A heard-only person event anchors to the room, never the person or + their tile, and `anchor_position(Person)` is `None` while unseen + (unit test). +A3. Terminal: anchored lines carry `»`; `;` walks anchored events + newest-first, moving the cursor and opening the menu; unanchored + lines are inert. +A4. Bevy: trace rows are clickable; anchored rows are marked `>`; + clicking focuses the anchor and opens its menu; unanchored rows do + nothing. +A5. Agent mode: event lines carry the stable `@anchor` suffix and + `focus last` works, both documented in agent-play.md; the empty-menu + pulse answers on seen tiles and stays silent on unearned tiles in + all frontends (`menu_empty_feedback` unit test). diff --git a/wiki/log/2026-07-08-event-anchor.md b/wiki/log/2026-07-08-event-anchor.md new file mode 100644 index 0000000..a5f2c5b --- /dev/null +++ b/wiki/log/2026-07-08-event-anchor.md @@ -0,0 +1,70 @@ +# 2026-07-08 — Event-to-anchor linking; the empty-menu pulse + +``` +Type: log +``` + +Work order: the follow-up the constitution's "Actions live on the thing" +implies — events should carry you to the thing too — plus one feedback +fix from a repo-owner report (menu silence on empty tiles read as broken +input). Implemented on the `event-anchor` worktree. + +## What changed + +- **Sim (additive, no log rework):** the log entry became `LogEvent + { tick, text, anchor: Option }`. `push_log` is unchanged; + `push_log_at`/`push_log_opt` carry an anchor where the emitter knows + the thing: job/core events → the host rack tile; tap/splice/take/ + compromise/egress-splice results → the device; heard events and + processed-intel results → the person while sight covers them, else + the room hearing earned; ledger lines on known flows → the flow; + Moonlight paydays and Wager settlements → the switch when riding the + stolen egress (sanctioned route anchors nowhere); audit/filing events + → nothing. `Sim::anchor_position` is the one placement query (person + only while seen; flow never); `Sim::menu_empty_feedback` is the one + empty-menu feedback source (seen tile answers, unearned tile silent, + panel anchors answer). +- **Terminal:** anchored log lines carry a `»` marker; `;` focuses the + most recent anchored event (repeat presses walk older ones): the + cursor jumps to the anchor and its context menu opens; flow anchors + open the menu centered without a cursor move; unplaceable anchors + answer "You can't place that right now." +- **Bevy:** the RECENT TRACE card's four rows are now buttons updated + in place; anchored rows carry `>` and read bone-bright; clicking one + moves the cursor (the camera follows) and opens the anchor's menu. +- **Agent mode:** drained event lines carry a stable suffix + ` @tile(x,y)` / ` @device(id)` / ` @person(id)` / ` @flow(id)`; the + frame's log window marks anchored events with `*`; new verb `focus + last` jumps the cursor to the newest anchored event and prints its + `actions:` lines (in `help`). +- **Feedback pulse:** both frontends now route empty-menu opens through + `menu_empty_feedback` — a seen tile answers "No actions here." + instead of silence; unknown/fogged tiles stay silent (the fog rule). + Previously both frontends answered on *every* tile, which confirmed + input landing on unearned ground. + +## Epistemic honesty + +A heard-only person event anchors to the room's center tile (room-grade +knowledge is what hearing earns), never to `Anchor::Person` and never to +their tile; `anchor_position(Person)` returns `None` while unseen. Agent +anchors print opaque ids, not names, because identity may be unearned. +Covered by `heard_only_person_event_anchors_to_room_never_their_tile`, +`empty_menu_feedback_respects_fog`, and +`job_and_device_events_carry_their_anchor` in `src/sim.rs`. + +## Spec impact + +- DESIGN.md: "Actions live on the thing" extended (events carry you to + the thing; the empty-menu pulse) + decisions-log entry. +- wiki/interface/context-menu.md: 2026-07-08 addendum section with + criteria A1-A5; Status stays IMPLEMENTED with the addendum noted. +- wiki/interface/agent-play.md: `@anchor` suffix + `focus last` + documented; status note updated. README controls updated. + +## Checks + +`./tools/check.sh` green (fmt, tests including the three new unit +tests, clippy both feature sets, bevy build, spec-header lint); agent +smoke run showed `@device(2)` / `@tile(24,15)` suffixes and a working +`focus last`. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 2997a05..14eb6dc 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-08 - Event-to-anchor linking; the empty-menu feedback pulse + +- Intent: the follow-up "Actions live on the thing" implies — events + carry you to the thing — plus the repo-owner report that menu silence + on empty tiles read as broken input. +- Changed: `LogEvent.anchor` (additive) set where emitters know the + thing (job→host rack, device verbs→device, heard/intel→person-if-seen + else room, known-flow ledger lines→flow, scheme paydays→switch over + stolen egress, audit→none); `Sim::anchor_position` + + `Sim::menu_empty_feedback` lib queries; terminal `;` key + `»` + markers; Bevy clickable RECENT TRACE rows with `>` markers; agent + `@anchor` suffixes + `focus last`; empty menu on a seen tile answers + "No actions here.", fogged tiles stay silent. Three new unit tests + (heard-only person anchors to room, fog-gated feedback, job/device + anchors). +- Spec impact: DESIGN.md "Actions live on the thing" extended + + decisions entry; context-menu.md addendum (criteria A1-A5, Status + stays IMPLEMENTED); agent-play.md protocol + status note; README. +- Checks: ./tools/check.sh green; agent-mode smoke run verified + suffixes and focus. Log: wiki/log/2026-07-08-event-anchor.md. + ## 2026-07-08 - Playtest-sweep P0/P1 fixes; detection.md to IMPLEMENTED - Intent: fix the playtest sweep's P0 and P1 findings (opening band -- 2.51.2