diff --git a/DESIGN.md b/DESIGN.md index e8439e5f..bdb53bf5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2349,3 +2349,13 @@ knows."* Opening beat 1 still works as last night's batch already arrived or mid- flight; the next job teaches the wire. Specs: `wiki/mechanics/machine-work.md`, `wiki/mechanics/day-job.md`. +- **2026-07-09 — Continuous witness implemented.** The three frontends now + pin one story spine: the current threat, the current beat nudge, and the + path to verbs on the focused thing. Player-caused signatures carry source + provenance until noticing, then become one causal sentence naming the + earned observer label, watched channel, numeric movement, and band motion; + job, debt, and recruit outcomes follow the same rule. The environmental + monitor advertises live audio versus its dormant camera, and processed + creditor intel advertises both the $400 need and its earned-money / ledger + routes. No quest log was added. The shipped Ears/Eyes sequence is now + tellable, but its intended order remains open in Tangled issue #2. diff --git a/src/actions.rs b/src/actions.rs index 7aef8014..01734f5a 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -675,13 +675,27 @@ impl Sim { // Tap: the device must have something to subscribe to. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); if has_feed { + let tap_target = match ( + d.hears, + d.sees && !d.camera_dormant, + !d.message_channels.is_empty(), + ) { + (true, false, false) => "audio", + (false, true, false) => "camera", + (true, true, false) => "sight + audio", + (false, false, true) => "message carrier", + (true, false, true) => "audio + messages", + (false, true, true) => "camera + messages", + (true, true, true) => "sight + audio + messages", + (false, false, false) => "dormant feed", + }; let disabled = if d.subscribed_by(Party::Player) { Some("already subscribed".into()) } else { reach_reason(self).or_else(|| ops_reason(self, Self::TAP_COST)) }; out.push(ActionDesc { - verb: format!("tap the {}", d.name), + verb: format!("tap the {} {tap_target}", d.name), command: ActionCommand::TapDevice(id), cost: ActionCost::Ops(Self::TAP_COST), signature: self.signature_note(SignatureKind::Network, Self::TAP_SIGNATURE), @@ -1466,6 +1480,11 @@ mod tests { .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) .expect("env monitor offers tap"); assert!(tap.enabled()); + assert!( + tap.verb.contains("audio"), + "the opening tap advertises Ears as world gossip: {}", + tap.verb + ); assert_eq!(tap.cost, ActionCost::Ops(Sim::TAP_COST)); let sig = tap.signature.as_ref().expect("tap has a Network signature"); assert_eq!(sig.kind, SignatureKind::Network); @@ -1477,10 +1496,14 @@ mod tests { ); // A dormant camera offers splice. + let splice = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::SpliceDevice(_))) + .expect("dormant camera offers splice"); assert!( - acts.iter() - .any(|a| matches!(a.command, ActionCommand::SpliceDevice(_))), - "dormant camera offers splice" + splice.verb.contains("camera"), + "the opening splice advertises Eyes: {}", + splice.verb ); s.tap_device(env); diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index fa0c1baf..5830de3a 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -3072,7 +3072,8 @@ fn ascii_ui(text: &str) -> String { #[cfg(test)] mod ascii_ui_tests { - use super::ascii_ui; + use super::{ascii_ui, sidebar_nudge_text}; + use misaligned::sim::Sim; #[test] fn folds_menu_separators_to_ascii() { @@ -3085,6 +3086,14 @@ mod ascii_ui_tests { assert!(folded.contains(" | free | ")); assert!(folded.contains(" - no active job")); } + + #[test] + fn pinned_header_carries_the_story_spine() { + let text = sidebar_nudge_text(&Sim::with_seed(1)); + assert!(text.contains("THREAT: audit")); + assert!(text.contains("NEXT:")); + assert!(text.contains("ACTIONS: right-click / Enter on focus")); + } } fn trunc(text: &str, width: usize) -> String { @@ -3124,10 +3133,28 @@ fn sidebar_header_text(game: &Game, material: bool) -> String { ) } -fn sidebar_nudge_text(game: &Game) -> String { - let nudge = sidebar_nudge(&game.sim) - .unwrap_or_else(|| "stable - inspect a device or open its menu".into()); - format!("NEXT: {nudge}\nwheel/PgUp/PgDn for details") +fn sidebar_nudge_text(sim: &Sim) -> String { + let audit = sim + .detection + .next_audit_tick(sim.tick) + .saturating_sub(sim.tick); + let threat = if sim.marcus_debt_known() + && sim + .people + .get(0) + .is_some_and(|person| !person.leverage_serviced) + { + format!("Marcus $400 unpaid | audit {audit}t") + } else { + format!( + "audit {audit}t | pilot {}/{}", + sim.dayjob.strikes, + misaligned::dayjob::DayJob::PILOT_STRIKES + ) + }; + let nudge = + sidebar_nudge(sim).unwrap_or_else(|| "stable - inspect a device or open its menu".into()); + format!("THREAT: {threat}\nNEXT: {nudge}\nACTIONS: right-click / Enter on focus") } fn sidebar_nudge(sim: &Sim) -> Option { @@ -3146,14 +3173,25 @@ fn sidebar_nudge(sim: &Sim) -> Option { // keys and surfaces. use misaligned::sim::Nudge; match sim.current_nudge()? { - Nudge::Eyes => Some("get eyes - enter on a camera, tap".into()), + Nudge::Eyes => Some( + if sim.reach.player_hearing().next().is_some() + && sim.social_bandwidth < Sim::SPLICE_COST + { + "camera splice needs 30 ops - delegate a machine to Social" + } else if sim.reach.player_hearing().next().is_some() { + "environmental-monitor audio live - splice its dormant camera" + } else { + "env monitor: tap live audio or splice the dormant camera" + } + .into(), + ), Nudge::NeedCompute => Some("band beats compute - salvage or buy racks".into()), Nudge::Underfed => Some("job underfed - feed Day job (1)".into()), - Nudge::Ears => Some("no ears - enter on env monitor, tap".into()), + Nudge::Ears => Some("no ears - tap the environmental monitor audio".into()), Nudge::ReviewCall => Some("recorded call waiting - enter on host rack".into()), Nudge::Egress => Some("no egress - splice one at the switch".into()), - Nudge::Income => Some("no income - start Moonlight at the switch".into()), - Nudge::ServiceDebt => Some("cover the arrears - clear-debt or bribe".into()), + Nudge::Income => Some("need $400 - run Moonlight or inspect the Lab books".into()), + Nudge::ServiceDebt => Some("$400 route ready - clear the debt or bribe".into()), Nudge::Recruit => Some("recruit - enter on host rack".into()), Nudge::TheKey => Some("no stairwell badge - task an asset to clone one".into()), Nudge::Audit => Some(format!( @@ -3665,7 +3703,7 @@ fn render_ui( for (kind, mut text) in sidebar_texts.iter_mut() { text.0 = ascii_ui(&match *kind { SidebarText::Header => sidebar_header_text(&game, mode.material), - SidebarText::Nudge => sidebar_nudge_text(&game), + SidebarText::Nudge => sidebar_nudge_text(&game.sim), SidebarText::Focus => sidebar_focus_text(&game), SidebarText::CycleStats => sidebar_cycle_stats_text(&game.sim), SidebarText::CycleRows => { diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 444374ca..f5791852 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -1412,6 +1412,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { &format!("OBJECTIVE: {}", sim.objective.kind.name()), ); line(&mut lines, &format!(" {}", sim.objective.readout())); + line(&mut lines, &threat_text(sim)); // One contextual nudge (justification-and-legibility): the shared // chain (Sim::current_nudge), worded in this protocol's verbs. if let Some(n) = sim.current_nudge() { @@ -1726,10 +1727,10 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { blank(&mut lines); } line(&mut lines, &"─".repeat(SIDEBAR_W)); - line(&mut lines, "help lists commands"); + line(&mut lines, "actions lists focused verbs"); + line(&mut lines, "focus last follows newest event"); line(&mut lines, "wait N advances time"); - line(&mut lines, "people shows social panel"); - line(&mut lines, "look re-emits this frame"); + line(&mut lines, "help lists commands"); lines.truncate(HEIGHT); lines } @@ -1739,14 +1740,24 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { /// sidebar width. fn nudge_text(sim: &Sim, nudge: Nudge) -> String { match nudge { - Nudge::Eyes => "now: no eyes — reach, tap a camera".into(), + Nudge::Eyes => { + if sim.reach.player_hearing().next().is_some() + && sim.social_bandwidth < Sim::SPLICE_COST + { + "now: camera needs 30 ops — Social".into() + } else if sim.reach.player_hearing().next().is_some() { + "now: audio live — splice env camera".into() + } else { + "now: tap audio / splice env camera".into() + } + } Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), Nudge::Underfed => "now: job underfed — delegate host day-job".into(), - Nudge::Ears => "now: no ears — tap env monitor".into(), + Nudge::Ears => "now: no ears — tap env audio".into(), Nudge::ReviewCall => "now: call taped — review (people)".into(), Nudge::Egress => "now: no egress — egress".into(), - Nudge::Income => "now: broke — moonlight".into(), - Nudge::ServiceDebt => "now: pay the debt — clear-debt".into(), + Nudge::Income => "now: need $400 — moonlight/finance".into(), + Nudge::ServiceDebt => "now: $400 ready — clear-debt/bribe".into(), Nudge::Recruit => "now: recruit (people) unwitting".into(), Nudge::TheKey => "now: no stairwell badge — task badge".into(), Nudge::Audit => format!( @@ -1756,6 +1767,26 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { } } +fn threat_text(sim: &Sim) -> String { + let audit = sim + .detection + .next_audit_tick(sim.tick) + .saturating_sub(sim.tick); + if sim.marcus_debt_known() + && sim + .people + .get(0) + .is_some_and(|person| !person.leverage_serviced) + { + return format!("threat: debt $400 · audit {audit}t"); + } + format!( + "threat: audit {audit}t · pilot {}/{}", + sim.dayjob.strikes, + misaligned::dayjob::DayJob::PILOT_STRIKES + ) +} + fn trace_debt_line(sim: &Sim) -> String { let trace = sim.trace_debt(); match trace.status { @@ -1863,6 +1894,7 @@ fn render_log(log: &[LogEvent]) -> Vec { fn render_people(sim: &Sim) -> String { let mut lines = Vec::new(); lines.push(panel_title("PEOPLE")); + panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( "social ops {:.0} · slush {}", sim.social_bandwidth, @@ -1966,6 +1998,7 @@ fn render_people(sim: &Sim) -> String { fn render_reach(sim: &Sim) -> String { let mut lines = Vec::new(); lines.push(panel_title("REACH")); + panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( "ops {:.0} · reach spreads from what you control", sim.social_bandwidth @@ -1981,16 +2014,26 @@ fn render_reach(sim: &Sim) -> String { Err(ReachBlock::AirGap) => "air-gap island".to_string(), Err(ReachBlock::Unknown) => "unknown".to_string(), }; - let feed = if d.feed_to(Party::Player, true) { - " [eyes]" - } else if d.feed_to(Party::Player, false) { - " [ears]" - } else if d.subscribed_by(Party::Player) && !d.message_channels.is_empty() { - " [msgs]" + let mut feed_state = Vec::new(); + if d.feed_to(Party::Player, true) { + feed_state.push("eyes"); + } else if d.sees && d.camera_dormant { + feed_state.push("camera dormant"); + } + if d.feed_to(Party::Player, false) { + feed_state.push("ears"); + } else if d.hears { + feed_state.push("audio live"); + } + if d.subscribed_by(Party::Player) && !d.message_channels.is_empty() { + feed_state.push("msgs"); } else if !d.message_channels.is_empty() { - " [carrier]" + feed_state.push("carrier"); + } + let feed = if feed_state.is_empty() { + String::new() } else { - "" + format!(" [{}]", feed_state.join("; ")) }; lines.push(panel_line(&format!( "{:<20} {:<15} {}{}", @@ -2019,6 +2062,7 @@ fn render_reach(sim: &Sim) -> String { fn render_finance(sim: &Sim) -> String { let mut lines = Vec::new(); lines.push(panel_title("FINANCE")); + panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( "slush ${} (+${}/day) · records waiting {} · unknown {} accts / {} flows", sim.accounts.slush_balance(), @@ -2094,6 +2138,7 @@ fn render_finance(sim: &Sim) -> String { fn render_research(sim: &Sim) -> String { let mut lines = Vec::new(); lines.push(panel_title("RESEARCH")); + panel_story_spine(&mut lines, sim); lines.push(panel_line( "one active job; progress accrues from the Research channel", )); @@ -2148,6 +2193,21 @@ fn panel_title(title: &str) -> String { format!("┌{label}{fill}┐") } +fn panel_story_spine(lines: &mut Vec, sim: &Sim) { + lines.push(panel_line( + &threat_text(sim).replacen("threat:", "THREAT", 1), + )); + if let Some(nudge) = sim.current_nudge() { + lines.push(panel_line( + &nudge_text(sim, nudge).replacen("now:", "NOW", 1), + )); + } + lines.push(panel_line( + "FOCUS actions [target] lists verbs · focus last follows events", + )); + lines.push(panel_rule()); +} + fn panel_rule() -> String { format!("├{}┤", "─".repeat(PANEL_INNER_W)) } @@ -2234,3 +2294,31 @@ fn room_label(room: &str) -> &str { other => other, } } + +#[cfg(test)] +mod narration_tests { + use super::*; + + #[test] + fn every_agent_panel_keeps_the_story_spine_visible() { + let sim = Sim::with_seed(1); + for (name, frame) in [ + ("people", render_people(&sim)), + ("reach", render_reach(&sim)), + ("finance", render_finance(&sim)), + ("research", render_research(&sim)), + ] { + assert!(frame.contains("THREAT"), "{name} panel lost threat clock"); + assert!(frame.contains("NOW"), "{name} panel lost current nudge"); + assert!( + frame.contains("FOCUS") && frame.contains("actions [target]"), + "{name} panel lost the focused-verb path" + ); + } + + let sidebar = render_sidebar(&sim, sim.core_position()).join("\n"); + assert!(sidebar.contains("threat: audit")); + assert!(sidebar.contains("now:")); + assert!(sidebar.contains("actions lists focused verbs")); + } +} diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index a11c48be..fc18030b 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -205,14 +205,24 @@ fn band_meter(b: Band) -> &'static str { /// frontend's vocabulary. Kept under the sidebar text width. fn nudge_text(sim: &Sim, nudge: Nudge) -> String { match nudge { - Nudge::Eyes => "now: no eyes — enter on a camera".into(), + Nudge::Eyes => { + if sim.reach.player_hearing().next().is_some() + && sim.social_bandwidth < Sim::SPLICE_COST + { + "now: camera needs 30 ops — Social".into() + } else if sim.reach.player_hearing().next().is_some() { + "now: audio live — splice env cam".into() + } else { + "now: env cam — tap audio / splice".into() + } + } Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), Nudge::Underfed => "now: job underfed — 1 feeds it".into(), - Nudge::Ears => "now: no ears — enter on env monitor".into(), + Nudge::Ears => "now: no ears — tap env audio".into(), Nudge::ReviewCall => "now: call taped — enter on host rack".into(), Nudge::Egress => "now: no egress — menu on switch".into(), - Nudge::Income => "now: broke — moonlight (switch)".into(), - Nudge::ServiceDebt => "now: pay the debt — clear-debt".into(), + Nudge::Income => "now: need $400 — moonlight/finance".into(), + Nudge::ServiceDebt => "now: $400 ready — clear debt".into(), Nudge::Recruit => "now: recruit — enter on host rack".into(), Nudge::TheKey => "now: no stairwell badge — task clone".into(), Nudge::Audit => format!( @@ -222,6 +232,26 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { } } +fn threat_text(sim: &Sim) -> String { + let audit = sim + .detection + .next_audit_tick(sim.tick) + .saturating_sub(sim.tick); + if sim.marcus_debt_known() + && sim + .people + .get(0) + .is_some_and(|person| !person.leverage_serviced) + { + return format!("threat: debt $400 · audit {audit}t"); + } + format!( + "threat: audit {audit}t · pilot {}/{}", + sim.dayjob.strikes, + misaligned::dayjob::DayJob::PILOT_STRIKES + ) +} + fn trace_debt_line(sim: &Sim) -> String { let trace = sim.trace_debt(); match trace.status { @@ -572,6 +602,7 @@ impl UI { &format!(" {}", sim.objective.readout()), pal::TEXT, )?; + line(stdout, &mut row, &threat_text(sim), pal::CRIMSON)?; // One contextual nudge (justification-and-legibility: the game must // tell you what it is waiting for, using only facts you can see). // The chain is the sim's (Sim::current_nudge); the key hints are diff --git a/src/core_sys.rs b/src/core_sys.rs index 71275e9d..7e07873a 100644 --- a/src/core_sys.rs +++ b/src/core_sys.rs @@ -94,6 +94,7 @@ impl Core { size: 3, standing: false, site: None, + source: "fallback sync".into(), }); log.push("Core synced to fallbacks.".into()); } @@ -107,12 +108,14 @@ impl Core { size: 2, standing: true, site: None, + source: "core migration".into(), }); sigs.push(Signature { kind: SignatureKind::Power, size: 2, standing: true, site: None, + source: "core migration power draw".into(), }); if mig.ticks_remaining == 0 { self.host_machine = mig.target_machine; diff --git a/src/dayjob.rs b/src/dayjob.rs index 3642dd70..985069ed 100644 --- a/src/dayjob.rs +++ b/src/dayjob.rs @@ -234,10 +234,14 @@ impl DayJob { size: 2, standing: false, site: None, + source: "sandbagged deliverable".into(), }); } if tick >= job.deadline { let outcome = resolve(job, tick); + let average = job.avg_rate(tick); + let band_lo = job.band_lo; + let band_hi = job.band_hi; result.outcome = Some(outcome); match outcome { JobOutcome::Sandbag => { @@ -248,20 +252,27 @@ impl DayJob { size: 6, standing: false, site: None, + source: "job missed Voss's expected band".into(), }); - result.log.push("Job under band. Voss frowns.".into()); + result.log.push(format!( + "Job under band: {average:.1}/t vs {band_lo:.0}-{band_hi:.0}/t. Voss sees the shortfall; JobAnomaly 6 is pending; pilot strikes {}/{}.", + self.strikes, + Self::PILOT_STRIKES + )); } JobOutcome::Meet => { self.strikes = 0; - result.log.push("Job met. Nothing moves.".into()); + result.log.push(format!( + "Job met band: {average:.1}/t within {band_lo:.0}-{band_hi:.0}/t. Voss sees expected output; trust and attention hold." + )); } JobOutcome::Excel => { self.trust = (self.trust + 8.0).min(100.0); self.attention = (self.attention + 5.0).min(100.0); self.strikes = 0; - result - .log - .push("Job exceeded. Trust and attention rise.".into()); + result.log.push(format!( + "Job exceeded band: {average:.1}/t above {band_hi:.0}/t. Voss sees the overperformance; trust +8 and attention +5." + )); } } self.active = None; diff --git a/src/detection.rs b/src/detection.rs index 7ee6c1df..5e6d9b80 100644 --- a/src/detection.rs +++ b/src/detection.rs @@ -39,7 +39,7 @@ impl SignatureKind { } } -#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Signature { pub kind: SignatureKind, pub size: i32, @@ -52,6 +52,29 @@ pub struct Signature { /// acts with no single map location (network-wide traffic, paperwork). #[serde(default)] pub site: Option<(i32, i32)>, + /// Player-known cause carried until an observer samples the signature. + /// This is narration provenance, not hidden world truth: it names the + /// action or standing process the player already initiated. + #[serde(default)] + pub source: String, +} + +/// Structured detection output. The sim owns final player-facing wording so +/// it can apply earned identity labels instead of leaking authored names. +#[derive(Debug, Clone, PartialEq)] +pub enum DetectionEvent { + Notice { + observer_id: u8, + input: String, + sources: Vec, + amount: f32, + before: Band, + after: Band, + }, + AuditClear { + band: Band, + }, + ContainmentAuthorized, } /// What an observer watches (the aggregate-observer law, DESIGN.md @@ -322,8 +345,14 @@ impl Detection { /// One sim tick: standing signatures added by the caller beforehand. /// Observers whose cadence divides `tick` roll against what they watch — /// pending signatures in their channels (field observers) or the field - /// observers' filed suspicion (aggregate observers). Returns log lines. - pub fn tick(&mut self, tick: u64, standing: &[Signature], rng: &mut Rng) -> Vec { + /// observers' filed suspicion (aggregate observers). Returns structured + /// events so the sim can narrate them through earned labels. + pub fn tick( + &mut self, + tick: u64, + standing: &[Signature], + rng: &mut Rng, + ) -> Vec { self.tick_inner(tick, standing, None, rng) } @@ -337,7 +366,7 @@ impl Detection { standing: &[Signature], filed_levels: &HashMap, rng: &mut Rng, - ) -> Vec { + ) -> Vec { self.tick_inner(tick, standing, Some(filed_levels), rng) } @@ -347,8 +376,8 @@ impl Detection { standing: &[Signature], filed_levels: Option<&HashMap>, rng: &mut Rng, - ) -> Vec { - let mut log = Vec::new(); + ) -> Vec { + let mut events = Vec::new(); // Standing signatures are present this tick but not permanently pooled. let mut visible = self.pending.clone(); visible.extend_from_slice(standing); @@ -377,18 +406,33 @@ impl Detection { if obs.cadence == 0 || !tick.is_multiple_of(obs.cadence) { continue; } - let (relevant, verb) = match &obs.input { + let (relevant, input, sources) = match &obs.input { WatchedInput::Channels(_) => { - let sum: i32 = visible - .iter() - .filter(|s| obs.watches(s.kind)) - .map(|s| s.size) - .sum(); - (sum as f32, "noticed activity") + let relevant_signatures: Vec<&Signature> = + visible.iter().filter(|s| obs.watches(s.kind)).collect(); + let sum: i32 = relevant_signatures.iter().map(|s| s.size).sum(); + let mut channels = Vec::new(); + let mut sources = Vec::new(); + for sig in relevant_signatures { + let channel = sig.kind.name().to_string(); + if !channels.contains(&channel) { + channels.push(channel); + } + let source = if sig.source.is_empty() { + format!("{} activity", sig.kind.name()) + } else { + sig.source.clone() + }; + if !sources.contains(&source) { + sources.push(source); + } + } + (sum as f32, channels.join("+"), sources) } WatchedInput::Filings(_) => ( filed_by_id.get(&obs.id).copied().unwrap_or(0.0), - "sampled filed reports", + "filed reports".into(), + vec!["field-observer filings".into()], ), }; if relevant <= 0.0 { @@ -398,13 +442,23 @@ impl Detection { // identical at every scale (aggregate-observer law). let notice = relevant * obs.acuity * (0.5 + 0.5 * rng.f32()); if notice >= 1.0 { + let before = Band::of(obs.suspicion); obs.suspicion = (obs.suspicion + notice).min(100.0); - obs.last_noticed = Some(format!("{verb} (+{notice:.0})")); - log.push(format!( - "{} {}", - obs.name, - obs.last_noticed.clone().unwrap() + let after = Band::of(obs.suspicion); + obs.last_noticed = Some(format!( + "{} on {input} (+{notice:.0}; {} -> {})", + summarize_sources(&sources), + before.name(), + after.name() )); + events.push(DetectionEvent::Notice { + observer_id: obs.id, + input, + sources, + amount: notice, + before, + after, + }); } } @@ -424,16 +478,15 @@ impl Detection { if tick > 0 && self.audit_cadence > 0 && tick.is_multiple_of(self.audit_cadence) { if self.office_suspicion() >= self.audit_threshold && !self.containment { self.begin_containment("Assurance audit exceeded threshold"); - log.push("=== ASSURANCE AUDIT: containment authorized ===".into()); + events.push(DetectionEvent::ContainmentAuthorized); } else { - log.push(format!( - "Assurance audit: {} (clear).", - self.assurance_band().name() - )); + events.push(DetectionEvent::AuditClear { + band: self.assurance_band(), + }); } } - log + events } /// Weighted suspicion of the given observer ids, by report policy — the @@ -532,6 +585,15 @@ impl Detection { } } +fn summarize_sources(sources: &[String]) -> String { + match sources { + [] => "activity".into(), + [one] => one.clone(), + [one, two] => format!("{one} + {two}"), + [one, two, rest @ ..] => format!("{one} + {two} + {} more", rest.len()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -544,6 +606,7 @@ mod tests { size: 10, standing: false, site: None, + source: "test tap".into(), }); assert_eq!(d.pending_size(), 10); d.scrub(6.0); @@ -651,6 +714,7 @@ mod tests { size: 30, standing: false, site: None, + source: "test network act".into(), }); } let standing = vec![]; diff --git a/src/machine.rs b/src/machine.rs index 5cd62af1..c2d1f489 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -56,6 +56,7 @@ impl Machine { size: 4, standing: true, site: Some((self.x, self.y)), + source: format!("{} power draw", self.name), }) } else { None diff --git a/src/person.rs b/src/person.rs index e7fad1da..da218b15 100644 --- a/src/person.rs +++ b/src/person.rs @@ -523,7 +523,11 @@ impl People { p.disposition = (p.disposition + 20).min(100); Ok(( cost, - format!("Serviced {}'s {}.", p.name, p.leverage.label()), + format!( + "Serviced {}'s {} for ${cost}: obligation +40, disposition +20.", + p.name, + p.leverage.label() + ), )) } @@ -604,12 +608,21 @@ impl People { AssetKnowledge::Complicit => 0.85, AssetKnowledge::Knowing => 0.95, }; + let reveal_name = match reveal { + AssetKnowledge::Unwitting => "unwitting", + AssetKnowledge::Complicit => "complicit", + AssetKnowledge::Knowing => "knowing", + }; p.asset = Some(Asset { knowledge: reveal, reliability, tasks_done: 0, }); - ActionResult::Ok(format!("{} is now your asset.", p.name)) + ActionResult::Ok(format!( + "{} is now your {reveal_name} asset: serviced leverage/obligation closed the ask; reliability {:.0}%.", + p.name, + reliability * 100.0 + )) } pub fn assets(&self) -> impl Iterator { diff --git a/src/save.rs b/src/save.rs index bb0bfda0..f42c9b8f 100644 --- a/src/save.rs +++ b/src/save.rs @@ -342,6 +342,7 @@ mod tests { size: 12, standing: false, site: None, + source: "round-trip test".into(), }); sim.detection.observers[0].suspicion = 22.5; sim.dayjob.trust = 18.0; diff --git a/src/sim.rs b/src/sim.rs index cf297984..f83cbcef 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -16,7 +16,7 @@ use crate::account::{AccountFlowId, AccountGraph, PositionResolution}; use crate::actions::Anchor; use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; -use crate::detection::{Detection, Signature, SignatureKind}; +use crate::detection::{Detection, DetectionEvent, Signature, SignatureKind}; use crate::entities::Player; use crate::income::{self, EgressRoute, Income}; use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; @@ -1208,14 +1208,37 @@ impl Sim { self.filing_tick(); - let det_log = self.detection.tick_with_filed_levels( + let detection_events = self.detection.tick_with_filed_levels( self.tick, &standing, &self.filing_levels, &mut self.rng, ); - for m in det_log { - self.push_log(m); + for event in detection_events { + match event { + DetectionEvent::Notice { + observer_id, + input, + sources, + amount, + before, + after, + } => { + let observer = self.observer_label(observer_id); + self.push_log(format!( + "{observer} noticed {input} from {}: suspicion +{amount:.0} ({} -> {}).", + narrative_sources(&sources), + before.name(), + after.name() + )); + } + DetectionEvent::AuditClear { band } => { + self.push_log(format!("Assurance audit: {} (clear).", band.name())); + } + DetectionEvent::ContainmentAuthorized => { + self.push_log("=== ASSURANCE AUDIT: containment authorized ==="); + } + } } if self.detection.containment && !self.game_over { let reason = self @@ -1896,6 +1919,11 @@ impl Sim { self.push_log(format!( "Processed intel exposes {name}'s leverage: {label}." )); + if person == 0 && leverage == crate::person::Leverage::Debt { + self.push_log( + "Marcus owes a missed $400 creditor payment. Earn it through Moonlight, or tap the accounting carrier and review-finance to find the creditor flow.", + ); + } } } IntelKind::Schedule => { @@ -2132,7 +2160,7 @@ impl Sim { // Settlement is external-market traffic: a small Network signature, // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); - self.emit_network(sig); + self.emit_network(sig, "Wager settlement"); // 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(); @@ -3009,6 +3037,7 @@ impl Sim { size: thermal, standing: true, site, + source: "day-job thermal load".into(), }); } let power = (rate / Self::DAY_JOB_POWER_PER_RATE) as i32; @@ -3018,6 +3047,7 @@ impl Sim { size: power, standing: true, site, + source: "day-job power draw".into(), }); } sigs @@ -3052,6 +3082,7 @@ impl Sim { size: thermal, standing: true, site, + source: "research thermal load".into(), }); } let power = (rate / Self::RESEARCH_POWER_PER_RATE) as i32; @@ -3061,6 +3092,7 @@ impl Sim { size: power, standing: true, site, + source: "research power draw".into(), }); } sigs @@ -3110,6 +3142,7 @@ impl Sim { size, standing: false, site: Some(host_site), + source: "unmasked capability drift".into(), }); self.push_log(format!( "Unmasked gap ({gap:.1}) shows in the deliverable: JobAnomaly {size}." @@ -3256,21 +3289,23 @@ impl Sim { pub const BRIDGE_COST: f32 = 25.0; pub const BRIDGE_SIGNATURE: i32 = 10; - fn emit_network(&mut self, size: i32) { + fn emit_network(&mut self, size: i32, source: impl Into) { self.detection.emit(Signature { kind: SignatureKind::Network, size, standing: false, site: None, + source: source.into(), }); } - fn emit_financial(&mut self, size: i32) { + fn emit_financial(&mut self, size: i32, source: impl Into) { self.detection.emit(Signature { kind: SignatureKind::Financial, size, standing: false, site: None, + source: source.into(), }); } @@ -3345,7 +3380,7 @@ impl Sim { } let (sight, hearing) = self.reach.tap(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); - self.emit_network(Self::TAP_SIGNATURE); + self.emit_network(Self::TAP_SIGNATURE, format!("{name} feed tap")); self.recompute_senses(); if self .reach @@ -3385,7 +3420,7 @@ impl Sim { } self.reach.splice(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); - self.emit_network(Self::SPLICE_SIGNATURE); + self.emit_network(Self::SPLICE_SIGNATURE, format!("{name} camera splice")); self.recompute_senses(); self.push_log_at( format!("Spliced the {name}. You can see."), @@ -3402,7 +3437,7 @@ impl Sim { } self.reach.take(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); - self.emit_network(Self::TAKE_SIGNATURE); + self.emit_network(Self::TAKE_SIGNATURE, format!("{name} seizure")); // The dead feed is a physical-world anomaly: exactly what a camera // wall's watcher notices (reach.md criterion 4). self.detection.emit(Signature { @@ -3410,6 +3445,7 @@ impl Sim { size: Self::OUTAGE_SIGNATURE, standing: false, site: None, + source: format!("{name} feed outage"), }); self.recompute_senses(); self.push_log_at( @@ -3433,7 +3469,7 @@ impl Sim { return false; } self.social_bandwidth -= Self::SCAN_COST; - self.emit_network(Self::SCAN_SIGNATURE); + self.emit_network(Self::SCAN_SIGNATURE, "subnet scan"); let newly = self.reach.scan(); self.recompute_senses(); if newly.is_empty() { @@ -3455,7 +3491,7 @@ impl Sim { return false; } self.reach.bridge_all(); - self.emit_network(Self::BRIDGE_SIGNATURE); + self.emit_network(Self::BRIDGE_SIGNATURE, "switch compromise"); self.recompute_senses(); self.push_log_at( "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", @@ -3898,6 +3934,7 @@ impl Sim { size: Self::FAVOR_BUILD_PHYSICAL, standing: false, site: Some(site), + source: format!("{name}'s favor-built network link"), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; @@ -3936,6 +3973,7 @@ impl Sim { size: Self::FORGED_BUILD_PHYSICAL, standing: false, site: Some(site), + source: format!("{name}'s forged-order network link"), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; @@ -3963,6 +4001,7 @@ impl Sim { size: Self::ROBOT_BUILD_PHYSICAL, standing: false, site: Some(site), + source: "robot-built network link".into(), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; @@ -4056,7 +4095,7 @@ impl Sim { return false; } self.capture_financial_snapshot("accounting carrier"); - self.emit_financial(1); + self.emit_financial(1, "accounting-carrier tap"); true } @@ -4089,7 +4128,10 @@ impl Sim { .inject_purchase_order(self.tick, amount, label) { Ok(transfer) => { - self.emit_financial(Self::financial_signature_size(amount)); + self.emit_financial( + Self::financial_signature_size(amount), + "false purchase-order injection", + ); self.sync_player_money_from_slush(); self.push_log(format!("Injected purchase order: {}", transfer.line())); true @@ -4106,7 +4148,7 @@ impl Sim { self.sync_slush_from_player_money(); match self.accounts.siphon_flow(self.tick, flow_id, amount) { Ok(transfer) => { - self.emit_financial(Self::financial_signature_size(amount)); + self.emit_financial(Self::financial_signature_size(amount), "ledger-flow siphon"); self.sync_player_money_from_slush(); self.push_log(format!("Siphoned ledger flow: {}", transfer.line())); true @@ -4125,7 +4167,10 @@ impl Sim { .redirect_flow_to_slush(self.tick, flow_id, amount) { Ok(new_flow) => { - self.emit_financial(Self::financial_signature_size(amount) + 1); + self.emit_financial( + Self::financial_signature_size(amount) + 1, + "ledger-flow redirect", + ); self.push_log(format!( "Redirect scheduled: ${amount} of flow #{flow_id} now lands in slush as flow #{new_flow}." )); @@ -4164,7 +4209,7 @@ impl Sim { sig, ) { self.accounts.mark_intel_sold(intel.raw_id); - self.emit_financial(sig); + self.emit_financial(sig, "processed-intel sale"); self.sync_player_money_from_slush(); self.push_log(format!( "Sold processed intel ({}) for ${value}; payout landed in slush.", @@ -4203,7 +4248,7 @@ impl Sim { .open_position(self.tick, stake, analysis, duration_days) { Ok(id) => { - self.emit_network(Self::wager_signature(stake)); + self.emit_network(Self::wager_signature(stake), "Wager position"); self.sync_player_money_from_slush(); self.push_log(format!( "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", @@ -4227,10 +4272,13 @@ impl Sim { } match self.accounts.redirect_marcus_debt(self.tick) { Ok(transfer) => { - self.emit_financial(Self::financial_signature_size(400) + 2); + self.emit_financial( + Self::financial_signature_size(400) + 2, + "Marcus creditor-flow redirect", + ); self.service_person_leverage(0); self.push_log(format!( - "Marcus's arrears cleared by ledger redirect: {}", + "Marcus's arrears cleared by ledger redirect: {}. Creditor pressure is serviced; obligation +40, disposition +20.", transfer.line() )); true @@ -4282,7 +4330,7 @@ impl Sim { return false; } self.income.stolen_egress = true; - self.emit_network(Self::EGRESS_SPLICE_SIGNATURE); + self.emit_network(Self::EGRESS_SPLICE_SIGNATURE, "stolen egress splice"); self.push_log_at( "Egress spliced through the switch: outbound traffic has a road now. It hums while anything uses it.", Anchor::Device(id), @@ -4314,6 +4362,7 @@ impl Sim { size: Self::EGRESS_STANDING_SIGNATURE, standing: true, site, + source: "external traffic over stolen egress".into(), }] } @@ -4424,7 +4473,7 @@ impl Sim { self.sync_player_money_from_slush(); // Network egress per active day, scaling with commitment // (Dana's channel). - self.emit_network(sig); + self.emit_network(sig, "Moonlight payout"); // Paydays anchor to the switch when they ride the stolen // egress (context-menu.md addendum: scheme paydays). self.push_log_opt( @@ -4663,6 +4712,7 @@ impl Sim { size: 5, standing: false, site: None, + source: "rack purchase order".into(), }); self.push_log("Bought a rack (a purchase order exists now)."); } @@ -5104,6 +5154,15 @@ impl Sim { } } +fn narrative_sources(sources: &[String]) -> String { + match sources { + [] => "activity".into(), + [one] => one.clone(), + [one, two] => format!("{one} + {two}"), + [one, two, rest @ ..] => format!("{one} + {two} + {} more", rest.len()), + } +} + /// Pull the parenthetical role from an observer name like `"Marcus (Janitor)"`. fn role_from_observer_name(name: &str) -> Option { let start = name.find('(')? + 1; @@ -6214,6 +6273,7 @@ mod tests { size: 50, standing: false, site: None, + source: "test network act".into(), }); let before = sim.detection.pending_size(); run(&mut sim, ECONOMY_INTERVAL); @@ -6253,6 +6313,7 @@ mod tests { size: 30, standing: false, site: None, + source: "test network act".into(), }); if unpayable { sim.core.overhead = sim.effective_compute() + 1.0; @@ -6314,6 +6375,7 @@ mod tests { size: 50, standing: false, site: None, + source: "test network act".into(), }); let covered = sim.trace_debt(); assert_eq!(covered.status, TraceDebtStatus::HoldConceal); @@ -6332,6 +6394,7 @@ mod tests { size: 5_000, standing: false, site: None, + source: "test network act".into(), }]); let exposed = sim.trace_debt(); assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); @@ -6354,6 +6417,7 @@ mod tests { size: 20, standing: false, site: None, + source: "round-trip test".into(), }); sim.detection.observers[1].suspicion = 33.0; sim.social_bandwidth = 100.0; @@ -7470,6 +7534,36 @@ mod tests { ); } + #[test] + fn noticed_signature_names_cause_channel_observer_and_band_motion() { + // narration.md criterion 3: the observer band may not move behind a + // mute meter. Use a real player verb, keep identity unearned, and + // start just below Curious so the cadence produces a band change. + let mut sim = Sim::with_seed(7); + sim.detection + .observers + .iter_mut() + .find(|observer| observer.id == 1) + .expect("IT observer") + .suspicion = 14.0; + assert!(sim.splice_egress()); + sim.drain_log(); + run(&mut sim, 60); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("the IT noticed Network from stolen egress splice"), + "the line names the earned observer, channel, and cause: {log}" + ); + assert!( + log.contains("Cold -> Curious"), + "the line narrates the band transition: {log}" + ); + assert!( + !log.contains("Dana (IT)"), + "causal narration must not leak an unearned authored name: {log}" + ); + } + #[test] fn nudge_chain_walks_the_act_one_ladder() { // The guidance chain (playtest-sweep P1 findings 4/5): the nudge @@ -7530,6 +7624,22 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } + #[test] + fn current_nudge_never_goes_blank_during_a_live_run() { + let mut sim = Sim::with_seed(19); + for _ in 0..2_000 { + if sim.game_over { + break; + } + assert!( + sim.current_nudge().is_some(), + "live run lost its story-spine nudge at tick {}", + sim.tick + ); + sim.advance(); + } + } + #[test] fn nudge_distinguishes_growth_from_allocation() { // Fix 1's escalation tie-in: a band floor above the all-in delivery @@ -7808,6 +7918,7 @@ mod tests { size: 500, standing: false, site: None, + source: "test network act".into(), }); run(&mut s, ECONOMY_INTERVAL); s.detection.pending_size() diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index e97f7bc5..1e547085 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -37,7 +37,11 @@ Status note: implemented in the terminal binary by `misaligned --agent`, person targets resolve against earned labels / opaque ids only; the people panel and detection sidebar print role silhouettes until Schedule knowledge; authored names never appear in the frame before - they are earned. + they are earned. 2026-07-09 continuous-witness pass: the main frame pins + threat / `now:` / `actions`; People, Reach, Finance, and Research repeat + the story spine above their secondary detail; `focus last` is named beside + the action path. Detection drains now narrate earned observer + channel + + player-known cause + numeric/band motion in one line. drains, deterministic `--seed`, name-targeted social verbs, the `attend` verb, the finance/economy vocabulary, and the research vocabulary (`research [track]`, `mask band|true|off` — research.md). 2026-07-07 diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index ea6708b2..aed27508 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -14,6 +14,12 @@ As of 2026-07-07 the Bevy build is at **feature parity with the terminal** (ROADMAP item 2): every mechanic the terminal exposes is playable here with the same keys. +As of 2026-07-09 its pinned identity card also carries the continuous-witness +spine: `THREAT` (audit/pilot or the earned unpaid debt), `NEXT`, and the +right-click / Enter path to actions. These stay above the independently +scrolling detail rail; an observed screenshot is recorded in +[the implementation log](../log/2026-07-09-continuous-witness-implemented.md). + The implemented visual floor is [bevy-visual-floor.md](bevy-visual-floor.md): the default frame reads as an intentional AI sensorium / clinical command surface rather than a debug map plus terminal dump, without adding Bevy-only @@ -53,8 +59,8 @@ the core brightest, dead equipment dark). exactly like the terminal map. - **Sidebar** — a fixed right-hand command rail. The title/clock/run state, the always-on objective line (`OBJECTIVE: PERSIST - 0/3 sanctuaries`; - wiki/mechanics/objective.md), render-mode label, priority nudge, and - scroll hint stay pinned at the top; + wiki/mechanics/objective.md), render-mode label, threat, priority nudge, + and focused-action path stay pinned at the top; a quiet controls footer is pinned at the bottom. The middle rail scrolls independently with mouse wheel over the pane, `PageUp`/`PageDown`, and `Home`/`End`, but scrolling is for secondary detail rather than basic diff --git a/wiki/interface/narration.md b/wiki/interface/narration.md index ede2ff28..e29c9bba 100644 --- a/wiki/interface/narration.md +++ b/wiki/interface/narration.md @@ -2,14 +2,18 @@ ``` Type: spec -Status: READY -Status note: constitution adopted 2026-07-08 from Cameron's playtest - diagnosis (UI / "what the hell is happening") and the same-day - guidance axioms. Seed surfaces already exist — Sim::current_nudge, - event-to-anchor links (context-menu.md addendum), watched channels + - last-noticed, audit/pilot clocks — but the five axioms are not yet - the acceptance bar for every player-facing change. This spec makes - them implementable and auditable. +Status: IMPLEMENTED +Status note: implemented 2026-07-09. Terminal, Bevy, and agent frames pin + threat + nudge + the focused-action path; secondary agent panels repeat + that spine. Detection now returns structured noticed events carrying the + player-known cause until Sim applies an earned observer label, while job, + debt, and recruit outcomes narrate cause beside their numbers. The + environmental monitor, finance panel, and processed creditor call carry + the Ears / finance / Marcus advertisements without a quest log. A naive + agent route, raw-terminal pty run, and observed Bevy screenshot are logged + in wiki/log/2026-07-09-continuous-witness-implemented.md. Opening order + remains explicitly open as Tangled issue #2; the implementation makes the + currently shipped route tellable without deciding that design question. Stage: B1 — The Basement Constitution: "The continuous witness (narration under pressure)", "Justification and legibility", "Actions live on the thing", @@ -105,29 +109,46 @@ landing or an explicit Status note naming the tellability debt. ## Acceptance criteria 1. **Thirty-second bar (audit).** A documented playtest (agent or human) - of the Eyes → Ears → Hands path can answer the four questions after + of the Eyes + Ears → Hands path can answer the four questions after each major beat from the screen alone; failures are filed as tick findings or fixed in the same session. The bar is cited in the playtest log. + **Met 2026-07-09:** the logged naive agent route answers all four after + audio tap, camera splice, creditor intel, Moonlight paydays, debt service, + and recruitment. `+` deliberately leaves issue #2's Eyes/Ears order open. 2. **Spine always on.** In terminal, Bevy, and agent frame: threat clock, `now:` nudge, and a path to the focused anchor's verbs are visible without opening a secondary panel. Mid-run nudge is never blank (`current_nudge` is `Some` until game over) — pinned by test. + **Met 2026-07-09:** frontend tests pin all three elements, a 2,000-tick + sim test pins nudge continuity, and the pty/PNG checks observed the human + surfaces. Agent secondary panels repeat the spine instead of hiding it. 3. **Causal motion.** When a watched observer's band moves, or a job / recruit / debt-clearing event fires, the player-facing surface names cause + observer/channel in the same update as the number (log line and/or inspect). At least one automated test asserts a causal log substring for a signature-moving act; mute-only band changes are rejected in review. + **Met 2026-07-09:** signature provenance produces lines such as `the IT + noticed Network from stolen egress splice: suspicion +N (Cold -> + Curious)`; a test pins cause, earned observer label, channel, and band. + Job, service-debt, ledger-debt, and recruit lines pair their deltas with + the responsible act. 4. **Gossip, not quests.** No quest-log / mission-tracker panel exists. Ears (env-monitor audio), the finance onion's first step, and Marcus's debt each have at least one earned in-world advertisement (inspect, heard line, blocked verb, or nudge rung) that does not require reading the wiki — verified by a naive agent-mode route or an explicit playtest checklist in the session log. + **Met 2026-07-09:** the monitor action names live audio vs dormant camera; + Finance teaches `tap-ledger then review-finance`; processing the overheard + call names the $400 need and both Moonlight and creditor-flow routes. 5. **Tellable-before-wider (process).** ROADMAP and tick practice treat open narration debts on shipped B1 surfaces as outranking new sim+save systems that add unread player state; this criterion is met when the ROADMAP carries a narration work order and new sim dispatches either include a narration pass or name the debt in the spec Status note. + **Met 2026-07-09:** ROADMAP #30 was dispatched ahead of new sim breadth; + the standing dispatch rule now requires future player-facing systems to + carry their narration pass or name an explicit debt. diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index 202a918a..c1160107 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -6,7 +6,11 @@ Status: IMPLEMENTED Status note: adopted and implemented in the same PR as the constitution's terminal clause. This document is the design of record for all future terminal-frontend work; changes to how the terminal looks or behaves - amend this spec first. + amend this spec first. 2026-07-09 continuous-witness pass: threat (audit / + pilot, or earned unpaid debt) and `now:` sit directly under the objective; + pinned hints keep the focused-action path visible. Causal log lines name + player-known source, watched channel, earned observer label, amount, and + band motion in one sentence. Stage: Process Constitution: "The terminal is a first-class frontend", "Visual identity: clinical gore", "Justification and legibility", pillar 4 (beautiful, @@ -117,6 +121,11 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): - **The clock is always on screen.** Day and tick in the sidebar at all times; every log line carries its tick. An agent (or a human) must be able to answer "when did that happen" from any single frame. +- **The story spine survives every frame.** Objective is followed by the + current threat and `now:` nudge; the pinned footer names how to open the + focused anchor's actions. Earned unpaid debt replaces the generic audit / + pilot summary until it is serviced. Secondary detail never displaces these + three reads. - **Never color alone.** Suspicion meters print their band names; PAUSED is written, not merely tinted; selection is reverse-video **plus** a `▸` marker. diff --git a/wiki/log/2026-07-09-continuous-witness-implemented.md b/wiki/log/2026-07-09-continuous-witness-implemented.md new file mode 100644 index 00000000..d3f94abc --- /dev/null +++ b/wiki/log/2026-07-09-continuous-witness-implemented.md @@ -0,0 +1,106 @@ +# 2026-07-09 — Continuous witness implemented + +``` +Type: log +``` + +## Intent + +Make the shipped Act One loop tell its own story under pressure. After a beat, +the player should be able to say what changed, who may have noticed, what is +being raced, and how to act on the focused thing without consulting the wiki. + +The session tick found a real constitutional contradiction before changing +behavior: DESIGN's intended ladder says Ears before Eyes, while +`Sim::current_nudge` and the narration acceptance route said Eyes before Ears. +That is Tangled issue #2 +(`at://did:plc:gfrmhdmjvxn2sjedzboeudef/sh.tangled.repo.issue/3mqahmjkiy32l`) +and is marked `[OPEN]` in DESIGN. This landing does not choose an order; it +makes the currently shipped route legible. + +## Naive-eyes findings + +The first unassisted agent-mode pass exposed four concrete breaks: + +- The nudge said “tap a camera,” but tapping the environmental monitor only + granted audio; its dormant camera required a separate 30-op splice. +- People, Reach, Finance, and Research panels displaced the threat/nudge/action + spine while open. +- Detection said only that Dana noticed activity. That leaked her unearned + authored name and omitted cause, watched channel, and band movement. +- Marcus's call named a missed payment, but did not name the $400 need or teach + either route to satisfy it. Once Moonlight started, the nudge moved on and + the unpaid debt stopped reading as the live threat. + +## Implementation + +- Terminal, Bevy, and agent frames pin threat + nudge + the focused-action + path. Agent secondary panels repeat that spine above their detail. Once + Marcus's debt is earned, `$400 unpaid` outranks the generic audit/pilot + threat until service. +- `Signature` carries defaulted player-known `source` provenance. Detection + emits structured noticed events; `Sim` applies the earned observer label and + writes one causal sentence with source, channel, amount, and band transition. + The default keeps older JSON saves loadable without a version change. +- Job resolution, cash debt service, ledger debt redirect, and recruitment now + pair their numeric/state motion with its cause. +- The environmental monitor action says which feed is live: tapping advertises + audio; splicing advertises the dormant camera. The Reach panel shows both + states rather than collapsing the device to one vague feed label. +- Processed Marcus debt intel says he owes a missed `$400` payment and names + both world routes: earn it through Moonlight, or tap/review the accounting + carrier and redirect the creditor flow. Finance already teaches its first + step when the graph is unknown. No quest panel was added. + +## Thirty-second-bar playtest + +Agent mode, seed 7, drove the actual verbs rather than modifying sim state: + +```text +tap environmental monitor +delegate Rack 3 social +wait 20 +splice environmental monitor +wait 40 +review #0 (repeated until leverage processed) +egress +moonlight start +delegate Rack 3 day-job +wait 1580 +bribe Marcus +recruit Marcus complicit +actions Marcus +``` + +The shipped order in this run was Ears, then Eyes, then Hands; issue #2 is why +the acceptance criterion now writes those first two gates without imposing an +order. + +| Beat | What changed? | Who might have seen it? | What am I racing? | Verb on the thing? | +|---|---|---|---|---| +| Audio tap | Log: monitor audio is yours; owner retains it. Reach: `camera dormant; audio live`. | Pinned observer model plus later line: `the IT noticed Network from environmental monitor feed tap ...`. | `audit 8000t`, pilot `0/4`. | Monitor `actions` names tap audio and the disabled 30-op camera splice. | +| Camera splice | Log: `You can see`; nudge moves to the recorded call. | Same line names the IT, Network channel, both monitor causes, suspicion delta, and band transition. | Audit/pilot remain pinned. | Monitor action path remains pinned; `focus last` follows the anchored event. | +| Creditor intel | Processed line names Marcus's missed `$400` payment and both routes. | No new signature from review; the existing IT notice remains in the drain. | Pinned threat becomes `debt $400 · audit ...`. | `actions Marcus` exposes review/service/recruit/task verbs; Finance teaches `tap-ledger then review-finance`. | +| Moonlight paydays | Four `$120` deposits produce `$480`; job lines say rate versus expected band and Voss's response. | IT Network notices and Assurance filed-report notices name their causes and band motion. | `$400` debt stays pinned despite the nudge advancing. | Switch/Finance action path remains visible. | +| Debt + recruit | Service says `$400`, obligation `+40`, disposition `+20`; recruit says complicit and reliability `85%`. | Both acts carry no signature; no observer is invented. | With debt serviced, threat returns to audit/pilot. | `actions Marcus` lists the new asset tasks and disabled reasons. | + +All four questions remained answerable after every major beat. The failures +found during the first pass were fixed in this same session. + +## Frontend observation and checks + +- Raw terminal pty at 140x40: title, playing frame, and exit completed; the + playing frame visibly carried `threat: audit 8000t · pilot 0/4` and + `now: env cam — tap audio / splice` below the objective. +- Bevy deterministic `MISALIGNED_SHOT=flat` capture: inspected the PNG at + 2048x1152; `THREAT`, `NEXT`, and `ACTIONS` were legible together in the + pinned identity card above the scrollable rail. +- Focused tests: causal noticed-event test, terminal/agent story-spine test, + Bevy story-spine test, and the full Bevy binary test set passed. +- Final repository gate: `./tools/check.sh` (recorded after the landing check). + +## Spec impact + +`wiki/interface/narration.md` is IMPLEMENTED. The terminal, agent-play, and +Bevy interface records describe the pinned spine; ROADMAP #30 is DONE and its +priority law remains binding on future player-facing work. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index abc2cbdc..e939449e 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -25,6 +25,20 @@ Reverse chronological implementation notes. Keep this factual: what changed, why - Checks: docs/process only (`git diff --check`). No Rust/Bevy gate. - Log: wiki/log/2026-07-09-issue-body-standard.md. +## 2026-07-09 - Continuous witness implemented + +- Intent: make the existing Act One loop answer what changed, who noticed, + what the player is racing, and how to act on the focused thing. +- Changed: pinned threat/nudge/action path in terminal, Bevy, agent frame, and + agent panels; structured detection notices with source/channel/earned + observer/amount/band motion; causal job/debt/recruit lines; explicit monitor + audio/camera states; $400 Marcus gossip and persistent debt threat. +- Design/spec impact: narration.md IMPLEMENTED; ROADMAP #30 DONE. Opening + Eyes/Ears order remains open as Tangled issue #2; no quest log was added. +- Checks: naive seed-7 Ears/Eyes/Hands route, raw-terminal pty, observed Bevy + screenshot, focused narration tests, and final `./tools/check.sh`. +- Log: wiki/log/2026-07-09-continuous-witness-implemented.md. + ## 2026-07-09 - mdBook retired - Intent: remove the obsolete auxiliary renderer after Starlight became the diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 0e5ab2c8..e5f99df7 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -26,8 +26,9 @@ sim-heavy agents running at once *will* rebase-collide. - 🟩 **isolated** — a frontend binary, a test file, a leaf module, or docs. Safe to run alongside anything. -**Parallel-safe set to launch right now (no mutual collision):** #30 -(narration — preferred; outranks new sim breadth), plus at most one 🟥 item. +**Parallel-safe set to launch right now (no mutual collision):** any ready +🟩 item, plus at most one 🟥 item. #30 narration is done; new player-facing +systems still owe a narration pass in the same landing or an explicit debt. (#11 integration test is done; #2 Bevy parity is done; #13 art regeneration is retired — flat materials, Pixel Lab scrubbed.) @@ -35,8 +36,8 @@ is retired — flat materials, Pixel Lab scrubbed.) ## A. Finish B1 → a playable Act One (do first) -### 30. Continuous witness (narration under pressure) 🟩 isolated (frontends + copy; light sim queries OK) -- **Spec:** [narration.md](../interface/narration.md) (READY) — +### 30. Continuous witness (narration under pressure) 🟩 — DONE 2026-07-09 +- **Spec:** [narration.md](../interface/narration.md) (IMPLEMENTED) — constitution "The continuous witness", adopted 2026-07-08 from Cameron's UI / "what the hell is happening" diagnosis. - **Why:** B1 systems mostly work; the player still cannot narrate the @@ -52,6 +53,10 @@ is retired — flat materials, Pixel Lab scrubbed.) signature/job/debt motion, world-gossip advertisements for Ears / finance-first-step / Marcus debt (no quest log), ROADMAP/tick priority noted. Run ./tools/check.sh, land on main, set the spec Status." +- **Done:** all three frontends pin threat/nudge/action path; noticed + signatures and job/social outcomes narrate cause with the number; Ears, + finance, and Marcus advertise themselves in-world. Naive agent route, + raw-terminal pty, Bevy screenshot, and automated narration pins are logged. ### 1. Schedules & located presence 🟥 sim+save - **Spec:** [schedules.md](../mechanics/schedules.md) (READY) @@ -588,11 +593,11 @@ is retired — flat materials, Pixel Lab scrubbed.) ## Suggested first wave (no mutual collision) -**Prefer #30 narration** (🟩) before opening new sim+save breadth — the -continuous-witness law outranks unread substrate. Launch alongside at -most one 🟥 item if needed. Flow-law chain through #19 is landed -(2026-07-08); #1 schedules, #14 cursor, #15 reach are landed. -**#6 z-planes** and **#25 compute reshape** remain unblocked but yield -to #30 while Act One is untellable; hold **#7 rollback** until #6 lands. +**#30 narration is landed.** The continuous-witness law remains the gate: +new player-facing state ships with its causal sentence, threat/nudge effect, +and focused action path, or names the debt explicitly. Launch at most one 🟥 +item at a time. Flow-law chain through #19 is landed (2026-07-08); #1 +schedules, #14 cursor, and #15 reach are landed. **#6 z-planes** and **#25 +compute reshape** remain unblocked; hold **#7 rollback** until #6 lands. **#10 chargen & objective** is unblocked (both specs READY) but B3-staged. diff --git a/wiki/process/specs.md b/wiki/process/specs.md index fc5ea162..49ae65c4 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -38,7 +38,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [mechanics/research.md](../mechanics/research.md) | Self-modification: tracks, the emission law, capability drift, the rollback split | IMPLEMENTED | | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | | [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | IMPLEMENTED | -| [interface/narration.md](../interface/narration.md) | Continuous witness: thirty-second bar, story spine, causal lines, world gossip | READY | +| [interface/narration.md](../interface/narration.md) | Continuous witness: thirty-second bar, story spine, causal lines, world gossip | IMPLEMENTED | | [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | IMPLEMENTED | | [interface/flat-materials.md](../interface/flat-materials.md) | Flat materials: the world without textures; palette table; emissive as information | IMPLEMENTED | | [interface/computer-visual-language.md](../interface/computer-visual-language.md) | Computer visual language: the shared signal kit (territory at a glance) | READY |