diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 1995a0b..7fc55de 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -758,8 +758,9 @@ struct MenuState { impl Game { /// Live human-menu rows (status dials). Agent mode keeps the flat dump. fn menu_rows(&self) -> Vec { + let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu(m.anchor, m.dial)) + .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) .unwrap_or_default() } @@ -830,7 +831,7 @@ impl Game { sim, screen: Screen::Title, paused: false, - tick_ms: 150, + tick_ms: Sim::DEFAULT_TICK_MS, log: Vec::new(), menu: None, cursor_x, @@ -1000,7 +1001,10 @@ impl Game { return; }; self.set_cursor(x, y); - let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let tick_ms = if self.paused { 0 } else { self.tick_ms }; + let rows = self + .sim + .human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms); let one_shots: Vec = rows .into_iter() .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 3b241e3..c28d407 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -88,8 +88,8 @@ pub enum ActionCommand { StopMoonlight, SetAutoMoonlight(bool), SetAutoWager(Option), - ReviewRecordings(u8), - ToggleWatch(u8), + ReviewRecordings, + ToggleAutoReview, Message(u8), Favor(u8), /// Commit to one concrete authored way of servicing leverage. @@ -166,7 +166,7 @@ pub enum ActionKind { MoonlightState, MoonlightPolicy, WagerPolicy, - WatchPolicy, + AutoReviewPolicy, Message, Favor, StartPlot, @@ -255,7 +255,7 @@ impl ActionKind { Self::Scan, Self::CompromiseSwitch, Self::Review, - Self::WatchPolicy, + Self::AutoReviewPolicy, Self::Message, Self::Favor, Self::StartPlot, @@ -379,8 +379,8 @@ impl ActionKind { "REVIEW", Action, Live, - [Person, Ledger], - "review | review ledger", + [Core, Ledger], + "review recordings | review ledger", ["review-finance", "process-finance"], "turn captured records into usable intel" ), @@ -465,14 +465,14 @@ impl ActionKind { [], "renew positions automatically at the chosen stake" ), - Self::WatchPolicy => def!( - "WATCH POLICY", + Self::AutoReviewPolicy => def!( + "AUTO-REVIEW POLICY", Control, Live, - [Person], - "watch ", + [Core], + "auto-review", [], - "toggle automatic review of future recordings" + "toggle automatic review of every future recording" ), Self::Message => def!( "MESSAGE", @@ -638,7 +638,7 @@ impl ActionCommand { Self::TakeDevice(_) => ActionKind::Take, Self::ScanNetwork => ActionKind::Scan, Self::CompromiseSwitch => ActionKind::CompromiseSwitch, - Self::ReviewFinance | Self::ReviewRecordings(_) => ActionKind::Review, + Self::ReviewFinance | Self::ReviewRecordings => ActionKind::Review, Self::InjectPurchaseOrder { .. } => ActionKind::InjectPurchaseOrder, Self::OpenPosition { .. } => ActionKind::PlaceWager, Self::SellIntel => ActionKind::SellIntel, @@ -648,7 +648,7 @@ impl ActionCommand { Self::StartMoonlight | Self::StopMoonlight => ActionKind::MoonlightState, Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, Self::SetAutoWager(_) => ActionKind::WagerPolicy, - Self::ToggleWatch(_) => ActionKind::WatchPolicy, + Self::ToggleAutoReview => ActionKind::AutoReviewPolicy, Self::Message(_) => ActionKind::Message, Self::Favor(_) | Self::FavorBuild { .. } => ActionKind::Favor, Self::StartPlot { .. } => ActionKind::StartPlot, @@ -1078,7 +1078,27 @@ impl Sim { /// root; Some opens that dial's picker. Agent mode keeps the flat /// [`menu_rows`] dump — this is terminal/Bevy only. pub fn human_menu(&self, anchor: Anchor, open_dial: Option) -> Vec { - let actions = self.available_actions(anchor); + self.human_menu_at_rate(anchor, open_dial, Self::DEFAULT_TICK_MS) + } + + /// Human menu with the frontend command clock supplied, so recurring + /// drains can be stated in the same live ops/sec unit as the crown metric. + pub fn human_menu_at_rate( + &self, + anchor: Anchor, + open_dial: Option, + tick_ms: u64, + ) -> Vec { + let mut actions = self.available_actions(anchor); + let rate = self.auto_review_ops_per_sec(tick_ms); + for action in &mut actions { + if let Some(auto) = action.automate.as_mut() + && auto.command == ActionCommand::ToggleAutoReview + { + auto.verb = self.auto_review_control_label(); + auto.cost = format!("{rate:.2} ops/sec"); + } + } match open_dial { None => self.human_root_rows(&actions), Some(dial) => self @@ -1237,8 +1257,8 @@ impl Sim { } ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), - ActionCommand::ReviewRecordings(id) => self.review_recordings(*id), - ActionCommand::ToggleWatch(id) => self.toggle_watch(*id), + ActionCommand::ReviewRecordings => self.review_recordings(), + ActionCommand::ToggleAutoReview => self.toggle_auto_review(), ActionCommand::Message(id) => self.message(*id), ActionCommand::Favor(id) => self.favor(*id), ActionCommand::StartPlot { person, plot_id } => { @@ -1302,6 +1322,7 @@ impl Sim { // not currently under the cursor (recordings, staged // knowledge) all live here — no global panel keys. out.extend(self.research_actions()); + out.extend(self.recording_actions()); out.extend(self.off_map_person_actions()); } else { out.push(self.fallback_action(x, y, m.id)); @@ -1428,20 +1449,58 @@ impl Sim { out } - /// People earned off the map (recordings, staged knowledge) but not - /// currently under sight: their verbs hang on the host rack so the - /// player never needs a global people panel. Visible people stay on - /// their tiles (tile_actions already aggregates them). + /// The host is the one physical recording inbox. REVIEW and its + /// auto-review control appear once here, never once per person represented + /// inside the pooled buffer. + fn recording_actions(&self) -> Vec { + let waiting = self.intel_buffer.len(); + let next = self.next_reviewable_recording_id(); + let rate = self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS); + vec![ActionDesc { + verb: if waiting > 0 { + format!("look at recording ({waiting} waiting)") + } else { + "look at recording".into() + }, + command: ActionCommand::ReviewRecordings, + cost: ActionCost::Thought(self.review_tokens()), + signature: None, + disabled_reason: if waiting == 0 { + Some("no recordings waiting".into()) + } else if next.is_none() { + Some("all recordings are already being thought through".into()) + } else { + None + }, + automate: Some(AutomateDesc { + verb: self.auto_review_control_label(), + command: ActionCommand::ToggleAutoReview, + cost: format!("{rate:.2} ops/sec"), + active: self.auto_review_enabled(), + }), + }] + } + + fn auto_review_control_label(&self) -> String { + if self.auto_review_enabled() { + "disable auto-looking at recordings" + } else { + "enable auto-looking at recordings" + } + .into() + } + + /// People earned off the map by staged knowledge but not currently under + /// sight: their social verbs hang on the host rack. Raw recordings do not + /// duplicate a person row; they remain one pooled host action above. fn off_map_person_actions(&self) -> Vec { let mut out = Vec::new(); for p in &self.people.people { if self.can_see_person(p.id) { continue; // their tile carries them } - let raw = self.unprocessed_recordings_for_person(p.id); - let earned = p.knowledge != Knowledge::Unknown - || raw > 0 - || self.latest_intel_for_person(p.id).is_some(); + let earned = + p.knowledge != Knowledge::Unknown || self.latest_intel_for_person(p.id).is_some(); if earned { out.extend(self.person_actions(p.id)); } @@ -2025,22 +2084,14 @@ impl Sim { out } - /// Person anchor (social.md + intel.md). Earned by sight, staged - /// knowledge, or recordings in the buffer; a person the player has no - /// trace of exposes nothing. + /// Person anchor (social.md). Recording review belongs to the pooled host + /// inbox; this surface contains only earned relationship acts. fn person_actions(&self, id: u8) -> Vec { let Some(p) = self.people.get(id) else { return Vec::new(); }; - let raw = self.unprocessed_recordings_for_person(id); - let next_raw = self - .intel_buffer - .iter() - .find(|event| event.matches_person(id)) - .map(|event| event.id); let earned = self.can_see_person(id) || p.knowledge != Knowledge::Unknown - || raw > 0 || self.latest_intel_for_person(id).is_some(); if !earned { return Vec::new(); @@ -2048,45 +2099,8 @@ impl Sim { let mut out = Vec::new(); let name = self.person_label(id); - // Review recordings, with the standing watch as its automate - // affordance in place (intel.md: perception automation). Player - // copy says "waiting", not pipeline slang "raw". - out.push(ActionDesc { - verb: if raw > 0 { - format!("review recordings ({raw} waiting)") - } else { - "review recordings".into() - }, - command: ActionCommand::ReviewRecordings(id), - cost: ActionCost::Thought(self.review_tokens()), - signature: None, // processing is internal and emits nothing - disabled_reason: if raw == 0 { - Some("no recordings waiting".into()) - } else if next_raw.is_some_and(|raw_id| { - self.thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { - raw_id, - automated: false, - }) - .is_some() - }) { - Some("already being thought through".into()) - } else { - None - }, - automate: Some(AutomateDesc { - verb: "standing watch (auto-process)".into(), - command: ActionCommand::ToggleWatch(id), - cost: format!("{:.2} T/tick", self.watch_drain_tokens()), - active: self.watch_enabled(id), - }), - }); - - // A raw recording earns a processing target, not a relationship. // Until processing stages Schedule knowledge, the player has not // earned the social catalog (or even an identified correspondent). - // Keep the menu at the honest next verb instead of advertising every - // future favor / leverage / recruitment route as disabled chrome. if p.knowledge == Knowledge::Unknown && p.asset.is_none() { return out; } @@ -2767,11 +2781,10 @@ mod tests { } } - /// Criterion 2 (person): an opaque recording earns REVIEW only. Raw - /// capture does not expose the future social catalog before processing - /// has staged Schedule knowledge. + /// The pooled inbox earns one host REVIEW action. Raw capture does not + /// create a person-scoped action row or expose future social verbs. #[test] - fn raw_recording_exposes_review_not_social_catalog() { + fn raw_recording_exposes_one_host_review_not_person_catalog() { let mut s = sim(); let person = 1u8; assert_eq!(s.people.get(person).unwrap().knowledge, Knowledge::Unknown); @@ -2786,15 +2799,28 @@ mod tests { kind: RawIntelKind::Presence { entered: true }, }); - let acts = s.available_actions(Anchor::Person(person)); - assert_eq!(acts.len(), 1, "raw capture exposes only its next step"); - assert!(matches!( - acts[0].command, - ActionCommand::ReviewRecordings(id) if id == person - )); - assert!(acts[0].automate.is_some(), "review keeps its watch control"); + let person_acts = s.available_actions(Anchor::Person(person)); + assert!( + person_acts.is_empty(), + "an opaque pooled recording does not become a person verb" + ); + let (x, y) = s.core_position(); + let host_acts = s.available_actions(Anchor::Tile { x, y }); + let reviews: Vec<_> = host_acts + .iter() + .filter(|a| a.command == ActionCommand::ReviewRecordings) + .collect(); + assert_eq!(reviews.len(), 1, "the host has one pooled review action"); assert!( - acts.iter().all(|a| !matches!( + reviews[0].verb.contains("1 waiting"), + "the one host action reports the whole inbox" + ); + assert!( + reviews[0].automate.is_some(), + "review keeps one auto control" + ); + assert!( + person_acts.iter().all(|a| !matches!( a.command, ActionCommand::Message(_) | ActionCommand::Favor(_) @@ -2808,25 +2834,33 @@ mod tests { ); } - /// Criterion 1 (person) + criterion 7 (automate in place): an earned - /// person carries review-recordings with the standing watch as its - /// automate affordance at its ops price, while authored plots stay absent - /// until leverage knowledge is earned. + /// Earned person actions stay social; the recording pool and its one + /// auto-review control remain on the host at an explicit ops/sec price. #[test] fn person_actions_stage_with_knowledge() { let mut s = sim(); let marcus = 0u8; s.people.people[0].knowledge = Knowledge::Schedule; let acts = s.available_actions(Anchor::Person(marcus)); - let review = acts + assert!( + acts.iter() + .all(|a| a.command != ActionCommand::ReviewRecordings), + "person actions do not split the recording pool" + ); + let (x, y) = s.core_position(); + let host = s.available_actions(Anchor::Tile { x, y }); + let review = host .iter() - .find(|a| matches!(a.command, ActionCommand::ReviewRecordings(0))) - .expect("earned person offers review"); - let auto = review.automate.as_ref().expect("watch automates review"); - assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); + .find(|a| a.command == ActionCommand::ReviewRecordings) + .expect("host offers pooled review"); + let auto = review + .automate + .as_ref() + .expect("auto-review stays in place"); + assert_eq!(auto.command, ActionCommand::ToggleAutoReview); assert!( - auto.cost.contains("T/tick"), - "watch shows its standing thought drain" + auto.cost.contains("ops/sec"), + "auto-review shows its cost in crown-metric units" ); assert!(!auto.active); assert!( @@ -2883,20 +2917,20 @@ mod tests { ); } - /// Executing the watch toggle through the dispatcher flips the - /// standing watch (the automate entry is live, criterion 7). + /// Executing the one host control through the dispatcher flips global + /// automatic review. #[test] - fn watch_automate_toggles_through_dispatch() { + fn auto_review_toggles_through_dispatch() { let mut s = sim(); - s.people.people[0].knowledge = Knowledge::Schedule; - let acts = s.available_actions(Anchor::Person(0)); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); let auto = acts .iter() .find_map(|a| a.automate.clone()) - .expect("watch affordance"); + .expect("auto-review affordance"); s.execute_action(&auto.command); - assert!(s.watch_enabled(0)); - let acts = s.available_actions(Anchor::Person(0)); + assert!(s.auto_review_enabled()); + let acts = s.available_actions(Anchor::Tile { x, y }); assert!( acts.iter() .find_map(|a| a.automate.as_ref()) @@ -3054,21 +3088,21 @@ mod tests { assert!(checked, "the map has at least one unknown tile at tick 0"); } - /// Menu rows flatten automate entries in place, and every row renders - /// the standard `verb · cost` line. + /// Menu rows flatten the one auto-review entry under the one pooled action + /// and keep its ops/sec price in the human-visible label. #[test] fn menu_rows_flatten_automates_in_place() { - let mut s = sim(); - s.people.people[0].knowledge = Knowledge::Schedule; - let acts = s.available_actions(Anchor::Person(0)); + let s = sim(); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); let rows = menu_rows(&acts); let review_idx = rows .iter() - .position(|r| matches!(r.command, ActionCommand::ReviewRecordings(_))) + .position(|r| r.command == ActionCommand::ReviewRecordings) .unwrap(); let auto = &rows[review_idx + 1]; assert!(auto.indent, "automate row is the verb's child"); - assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); + assert_eq!(auto.command, ActionCommand::ToggleAutoReview); assert_eq!( rows[review_idx].role, ActionRole::Action, @@ -3077,9 +3111,31 @@ mod tests { assert_eq!( auto.role, ActionRole::Control, - "standing watch is visibly a control" + "auto-review is visibly a control" ); - assert!(auto.line().contains("T/tick")); + assert!(auto.cost.contains("ops/sec")); + } + + #[test] + fn human_auto_review_price_tracks_the_live_command_clock() { + let s = sim(); + let (x, y) = s.core_position(); + let row_at = |tick_ms| { + s.human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms) + .into_iter() + .find_map(|row| match row { + HumanMenuRow::Action(row) if row.command == ActionCommand::ToggleAutoReview => { + Some(row) + } + _ => None, + }) + .expect("host root has one auto-review control") + }; + let normal = row_at(150); + assert_eq!(normal.label, "auto: enable auto-looking at recordings"); + assert_eq!(normal.cost, "0.25 ops/sec"); + assert_eq!(row_at(75).cost, "0.50 ops/sec"); + assert_eq!(row_at(0).cost, "0.00 ops/sec"); } /// Status dials D1-D3: host-rack human root keeps only mode and research diff --git a/crates/misaligned-core/src/intel.rs b/crates/misaligned-core/src/intel.rs index def0d7f..9a19dd0 100644 --- a/crates/misaligned-core/src/intel.rs +++ b/crates/misaligned-core/src/intel.rs @@ -1,8 +1,9 @@ -//! Intel: recordings, processing, and standing watches (wiki/mechanics/intel.md). +//! Intel: recordings, processing, and pooled automatic review +//! (wiki/mechanics/intel.md). //! //! Subscribed feeds record raw, timestamped events. The raw buffer is opaque -//! until thought fills a processing reservoir (or a ready standing watch tap -//! auto-processes an arrival). Processed intel is durable; overflowing the +//! until thought fills a processing reservoir (or the ready global auto-review +//! tap processes an arrival). Processed intel is durable; overflowing the //! buffer only drops unprocessed recordings. use crate::messages::{MessageChannel, MessagePayload}; @@ -118,19 +119,10 @@ pub enum IntelKind { }, } -/// B1 standing watch: a per-person filter. Later acts can add room/kind -/// filters without changing the concept. +/// Legacy v23 per-person watch policy. Current saves carry one global +/// `auto_review_recordings` flag; this shape remains readable for migration. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct IntelWatch { +pub struct LegacyIntelWatch { pub person: u8, pub enabled: bool, } - -impl IntelWatch { - pub fn new(person: u8) -> Self { - Self { - person, - enabled: true, - } - } -} diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index d00fec5..1efd939 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -18,7 +18,7 @@ use crate::dayjob::DayJob; use crate::detection::Detection; use crate::hall::HallControl; use crate::income::Income; -use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; +use crate::intel::{LegacyIntelWatch, ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; @@ -80,7 +80,9 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// v23 adds plot runs and the institutional-event ledger. The authored plot /// catalog remains immutable code/data and is rebound by stable ids on load; /// v22 saves begin with no plot history. -pub const SAVE_VERSION: u32 = 23; +/// v24 collapses person-filtered recording watches into one global automatic +/// review policy over the pooled inbox. Any enabled v23 watch enables it. +pub const SAVE_VERSION: u32 = 24; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -167,9 +169,13 @@ pub struct SaveState { /// Latest filing reports read by aggregate observers. #[serde(default)] pub filing_levels: HashMap, - /// Standing watches that auto-process matching recordings. + /// One standing policy that auto-processes every new recording. #[serde(default)] - pub watches: Vec, + pub auto_review_recordings: bool, + /// Compatibility-only v23 person filters. Migration collapses any enabled + /// entry into `auto_review_recordings` and closes their legacy sink taps. + #[serde(default, rename = "watches", skip_serializing)] + legacy_watches: Vec, #[serde(default = "default_next_intel_id")] pub next_intel_id: u64, /// Remembered tile snapshots (cursor.md); cursor position itself remains @@ -266,7 +272,8 @@ impl SaveState { message_schedule: sim.message_schedule.clone(), next_message_id: sim.next_message_id, filing_levels: sim.filing_levels.clone(), - watches: sim.watches.clone(), + auto_review_recordings: sim.auto_review_recordings, + legacy_watches: Vec::new(), next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), @@ -318,7 +325,7 @@ impl SaveState { sim.message_schedule = self.message_schedule.clone(); sim.next_message_id = self.next_message_id; sim.filing_levels = self.filing_levels.clone(); - sim.watches = self.watches.clone(); + sim.auto_review_recordings = self.auto_review_recordings; sim.next_intel_id = self.next_intel_id; sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); @@ -344,6 +351,7 @@ impl SaveState { // have the subscriber record but no persistent sink. Repair those // taps in place; owned devices remain drain-free. sim.reconcile_device_tap_sinks(); + sim.reconcile_auto_review(); sim.recompute_senses(); sim.rebuild_transient_state(); } @@ -394,15 +402,31 @@ fn ensure_supported_research_shape(state: &SaveState) -> Result<(), String> { fn migrate_save_state(mut state: SaveState) -> Result { ensure_supported_research_shape(&state)?; + if state.version <= 23 { + state.auto_review_recordings |= state.legacy_watches.iter().any(|watch| watch.enabled); + for watch in &state.legacy_watches { + state + .thought_sinks + .close_effect(&crate::sinks::SinkFireEffect::WatchPerson(watch.person)); + } + state.legacy_watches.clear(); + } match state.version { SAVE_VERSION => { if state.legacy_pending_ops_jobs.is_some() || state.legacy_addressed_jobs.is_some() || !state.work_grid.take_legacy_routed_demands().is_empty() + || !state.legacy_watches.is_empty() { - return Err("current-version save contains legacy Operations fields".into()); + return Err("current-version save contains legacy fields".into()); } } + // v23 carried one person-filtered watch and tap per person. The + // pre-match migration above collapses any enabled policy into the one + // pooled-inbox auto-review drain and closes the obsolete taps. + 23 => { + state.version = SAVE_VERSION; + } // v22 predates authored plot runs and institutional events; serde // supplies empty state. v21 also predates authored hall control. 21 | 22 => { @@ -881,7 +905,7 @@ mod tests { } #[test] - fn intel_buffer_processed_intel_and_watches_roundtrip() { + fn intel_buffer_processed_intel_and_auto_review_roundtrip() { let mut sim = Sim::with_seed(7); let (x, y) = sim.core_position(); let ops = sim.compute.add_machine( @@ -907,7 +931,7 @@ mod tests { sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); - sim.review_recordings(0); + sim.review_recordings(); // Processing is a Thought reservoir on the host: pour until it fires. for _ in 0..16 { let open: Vec<(u32, f32)> = sim @@ -929,11 +953,11 @@ mod tests { sim.pour_thought_into_sinks(node, need + 0.01); } } - sim.toggle_watch(0); + sim.toggle_auto_review(); assert!(!sim.intel.is_empty()); assert!(!sim.intel_buffer.is_empty()); - assert!(sim.watch_enabled(0)); + assert!(sim.auto_review_enabled()); let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(99); @@ -941,9 +965,9 @@ mod tests { assert_eq!(restored.intel_buffer, sim.intel_buffer); assert_eq!(restored.intel, sim.intel); - assert_eq!(restored.watches, sim.watches); + assert_eq!(restored.auto_review_recordings, sim.auto_review_recordings); assert_eq!(restored.next_intel_id, sim.next_intel_id); - assert!(restored.watch_enabled(0)); + assert!(restored.auto_review_enabled()); let waiting = sim.unprocessed_recordings_for_person(0); assert!(waiting >= 1, "at least one recording still waits"); assert_eq!(restored.unprocessed_recordings_for_person(0), waiting); @@ -953,6 +977,55 @@ mod tests { ); } + #[test] + fn v23_person_watches_collapse_to_one_global_auto_review_policy() { + let sim = Sim::with_seed(8); + let mut state = SaveState::from_sim(&sim); + state.version = 23; + state.thought_sinks.open_tap_with_effect( + sim.core.host_machine, + "WATCH PRIYA", + 1.0, + 0.1, + crate::sinks::SinkFireEffect::WatchPerson(1), + ); + let mut json = serde_json::to_value(&state).unwrap(); + let object = json.as_object_mut().unwrap(); + object.remove("auto_review_recordings"); + object.insert( + "watches".into(), + serde_json::json!([ + { "person": 0, "enabled": false }, + { "person": 1, "enabled": true } + ]), + ); + let state: SaveState = serde_json::from_value(json).unwrap(); + assert_eq!(state.legacy_watches.len(), 2, "v23 shape still decodes"); + + let migrated = migrate_save_state(state).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert!(migrated.auto_review_recordings); + assert!(migrated.legacy_watches.is_empty()); + assert!( + migrated + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(1)) + .is_none(), + "the obsolete person tap is closed" + ); + + let mut restored = Sim::with_seed(9); + migrated.apply_to(&mut restored); + assert!(restored.auto_review_enabled()); + assert!( + restored + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) + .is_some(), + "load reconciles one global tap" + ); + } + #[test] fn objective_state_roundtrips_and_pre_v8_saves_default_to_persist() { use crate::objective::ObjectiveKind; diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 87f5bf6..4f760bd 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -23,7 +23,7 @@ use crate::hall::{ row_spec, }; use crate::income::{self, EgressRoute, Income}; -use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; +use crate::intel::{IntelKind, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::intents::{BuildActuator, BuildIntent, IntentStatus}; use crate::machine::{Channel, ChannelYield, Compute, Provenance}; use crate::map::GameMap; @@ -335,8 +335,8 @@ pub struct Sim { /// observer id -> filed suspicion. Detection reads this instead of raw /// field suspicion in the sim-integrated path, so filings have latency. pub filing_levels: HashMap, - /// B1 standing watches: per-person automated processing filters. - pub watches: Vec, + /// One standing drain that automatically processes every new recording. + pub auto_review_recordings: bool, /// Next raw recording id. Persisted through save/load. pub next_intel_id: u64, @@ -533,7 +533,7 @@ impl Sim { message_schedule: Schedule::new(), next_message_id: 1, filing_levels: HashMap::new(), - watches: Vec::new(), + auto_review_recordings: false, next_intel_id: 1, intents: Vec::new(), next_intent_id: 1, @@ -1276,6 +1276,9 @@ impl Sim { /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. pub const DAY_TICKS: u64 = 400; + /// Human frontends' 1x command clock. Rate-bearing action labels use this + /// when no live frontend speed is available (agent frames/tests). + pub const DEFAULT_TICK_MS: u64 = 150; /// Hour of the current day, 0-23. pub fn hour(&self) -> u32 { @@ -1450,7 +1453,7 @@ impl Sim { self.tick += 1; self.message_tick(); self.intent_tick(); - self.watch_tick(); + self.auto_review_tick(); // A process with live sight keeps logs current even when the set of // feeds does not change; if sight is lost later, Remembered's timestamp // is the last tick the tile was actually visible. @@ -1949,7 +1952,7 @@ impl Sim { .unwrap_or_default() } - // ── Intel: record, process, watch (wiki/mechanics/intel.md) ───────────── + // ── Intel: record, process, auto-review (wiki/mechanics/intel.md) ────── /// Raw recording capacity [TUNE]. Only unprocessed events occupy this /// buffer; processed intel is durable in `self.intel`. @@ -1957,12 +1960,12 @@ impl Sim { /// Manual review cost per raw event in compute units [TUNE]. Converted to /// Thought tokens via `WORK_TOKEN_COMPUTE` for processing reservoirs. pub const REVIEW_RECORDING_COST: f32 = 10.0; - /// Standing watch drain as a fraction of one medium rack's Thought tokens - /// per tick [TUNE] (intel.md: ~10-20% of one rack medium output). - pub const WATCH_DRAIN_FRACTION: f32 = 0.15; - /// Working-level cap on a watch tap (must exceed one tick of drain so - /// starvation is visible as an empty vessel, not a permanent full one). - pub const WATCH_TAP_CAP_TOKENS: f32 = 1.0; + /// Global auto-review drain as a fraction of one medium rack's Thought + /// tokens per tick [TUNE] (intel.md: ~10-20% of medium output). + pub const AUTO_REVIEW_DRAIN_FRACTION: f32 = 0.15; + /// Working-level cap on the auto-review tap (must exceed one tick of drain + /// so starvation is visible as an empty vessel, not a permanent full one). + pub const AUTO_REVIEW_TAP_CAP_TOKENS: f32 = 1.0; /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; @@ -1986,8 +1989,8 @@ impl Sim { }) } - pub fn watch_enabled(&self, id: u8) -> bool { - self.watches.iter().any(|w| w.person == id && w.enabled) + pub fn auto_review_enabled(&self) -> bool { + self.auto_review_recordings } /// Thought tokens one processing reservoir demands [TUNE mapping from @@ -1996,10 +1999,10 @@ impl Sim { Self::ops_tokens_for_cost(self.review_cost()) } - /// Standing watch drain in Thought tokens/tick (intel.md ~10-20% of one - /// medium rack). Anchored on the host's current efficiency/intensity so - /// the price tracks the machine the buffer sits on. - pub fn watch_drain_tokens(&self) -> f32 { + /// Global auto-review drain in Thought tokens/tick (intel.md ~10-20% of + /// one medium rack). Anchored on the host's current efficiency/intensity + /// so the price tracks the machine the pooled buffer sits on. + pub fn auto_review_drain_tokens(&self) -> f32 { let host = self.core.host_machine; let host_eff = self .work_grid @@ -2009,7 +2012,17 @@ impl Sim { // [TUNE] fraction of a medium-rack Thought baseline on the token // scale (aligned with WORK_GRID_BASE_WIRED_TOKENS_PER_TICK = 0.25). let medium_baseline = host_eff * 0.25; - (medium_baseline * Self::WATCH_DRAIN_FRACTION).max(0.01) + (medium_baseline * Self::AUTO_REVIEW_DRAIN_FRACTION).max(0.01) + } + + /// Auto-review's share of the crown rate at the supplied command clock. + /// This is what human action rows print so the standing cost is measured + /// in the same ops/sec language as the game's guiding number. + pub fn auto_review_ops_per_sec(&self, tick_ms: u64) -> f32 { + if tick_ms == 0 { + return 0.0; + } + self.auto_review_drain_tokens() * (1000.0 / tick_ms as f32) } /// Chassis that holds the raw buffer for pending-work markers and @@ -2018,110 +2031,87 @@ impl Sim { self.core.host_machine } - pub fn toggle_watch(&mut self, id: u8) { - if self.people.get(id).is_none() { - self.push_log("No such person to watch."); - return; - } - if let Some(w) = self.watches.iter_mut().find(|w| w.person == id) { - w.enabled = !w.enabled; - let enabled = w.enabled; - let name = self.person_label(id); - if enabled { - self.open_watch_tap(id); - self.push_log(format!( - "Standing watch for {name} enabled ({:.2} Thought/tick drain).", - self.watch_drain_tokens() - )); - } else { - self.close_watch_tap(id); - self.push_log(format!("Standing watch for {name} disabled.")); - } - } else { - self.watches.push(IntelWatch::new(id)); - self.open_watch_tap(id); - let name = self.person_label(id); + pub fn toggle_auto_review(&mut self) { + self.auto_review_recordings = !self.auto_review_recordings; + if self.auto_review_recordings { + self.open_auto_review_tap(); self.push_log(format!( - "Standing watch for {name} enabled ({:.2} Thought/tick drain).", - self.watch_drain_tokens() + "Automatic recording review enabled ({:.2} Thought/tick drain).", + self.auto_review_drain_tokens() )); + } else { + self.close_auto_review_tap(); + self.push_log("Automatic recording review disabled."); } } - fn open_watch_tap(&mut self, person: u8) { - let effect = SinkFireEffect::WatchPerson(person); + fn open_auto_review_tap(&mut self) { + let effect = SinkFireEffect::AutoReviewRecordings; if self.thought_sinks.open_with_effect(&effect).is_some() { return; } - let name = self.person_label(person); let node = self.intel_buffer_node(); - let drain = self.watch_drain_tokens(); + let drain = self.auto_review_drain_tokens(); self.thought_sinks.open_tap_with_effect( node, - &format!("WATCH {}", name.to_uppercase()), - Self::WATCH_TAP_CAP_TOKENS, + "AUTO-REVIEW", + Self::AUTO_REVIEW_TAP_CAP_TOKENS, drain, effect, ); self.ensure_sink_ingress(); } - fn close_watch_tap(&mut self, person: u8) { + fn close_auto_review_tap(&mut self) { self.thought_sinks - .close_effect(&SinkFireEffect::WatchPerson(person)); + .close_effect(&SinkFireEffect::AutoReviewRecordings); } - /// Whether the standing watch tap has thought to spend on auto-process - /// (fed this tick or holding fill). Starved watches leave arrivals in the - /// buffer — the pending-work marker stays. - fn watch_tap_ready(&self, person: u8) -> bool { + /// Whether the standing review tap has thought to spend on auto-process + /// (fed this tick or holding fill). Starvation leaves arrivals in the + /// pooled buffer — the pending-work marker stays. + fn auto_review_tap_ready(&self) -> bool { self.thought_sinks - .open_with_effect(&SinkFireEffect::WatchPerson(person)) + .open_with_effect(&SinkFireEffect::AutoReviewRecordings) .is_some_and(|s| s.fed_last_tick || s.fill > f32::EPSILON) } - /// Review the oldest raw recording about this person: open a one-shot - /// Thought reservoir on the buffer host (intel.md sweep). - pub fn review_recordings(&mut self, id: u8) { - if self.people.get(id).is_none() { - self.push_log("No such person to review."); - return; - } - let Some(raw_id) = self - .intel_buffer - .iter() - .find(|e| e.matches_person(id)) - .map(|e| e.id) - else { - let name = self.person_label(id); - self.push_log(format!("No recordings waiting for {name}.")); + pub(crate) fn next_reviewable_recording_id(&self) -> Option { + self.intel_buffer.iter().find_map(|event| { + let effect = SinkFireEffect::ProcessRecording { + raw_id: event.id, + automated: false, + }; + self.thought_sinks + .open_with_effect(&effect) + .is_none() + .then_some(event.id) + }) + } + + /// Review the oldest available raw recording in the pooled inbox: open a + /// one-shot Thought reservoir on the buffer host (intel.md sweep). + pub fn review_recordings(&mut self) { + let Some(raw_id) = self.next_reviewable_recording_id() else { + self.push_log("No recordings waiting for review."); return; }; self.process_recording_by_id(raw_id, false); } - fn watch_tick(&mut self) { - // Keep watch taps in sync with enabled flags (save loads, desync). - let enabled: Vec = self - .watches - .iter() - .filter(|w| w.enabled) - .map(|w| w.person) - .collect(); - for person in enabled { - self.open_watch_tap(person); - } - let disabled: Vec = self - .watches - .iter() - .filter(|w| !w.enabled) - .map(|w| w.person) - .collect(); - for person in disabled { - self.close_watch_tap(person); + pub(crate) fn reconcile_auto_review(&mut self) { + if self.auto_review_recordings { + self.open_auto_review_tap(); + } else { + self.close_auto_review_tap(); } } + fn auto_review_tick(&mut self) { + // Keep the standing tap in sync with the persisted policy. + self.reconcile_auto_review(); + } + fn next_raw_intel_id(&mut self) -> u64 { let id = self.next_intel_id; self.next_intel_id += 1; @@ -2166,35 +2156,25 @@ impl Sim { )); } let id = event.id; - let watched_person = event.person; self.intel_buffer.push(event); - if let Some(person) = watched_person - && self.watch_enabled(person) - { + if self.auto_review_recordings { self.process_recording_by_id(id, true); } } /// Open a Thought processing reservoir for a raw recording (sweep), or - /// auto-process immediately when a ready standing watch catches an + /// auto-process immediately when the pooled standing tap catches an /// arrival. Returns true when a sink opened or processing landed. fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { if !self.intel_buffer.iter().any(|e| e.id == raw_id) { return false; } if automated { - let person = self - .intel_buffer - .iter() - .find(|e| e.id == raw_id) - .and_then(|e| e.person); - let Some(person) = person else { - return false; - }; - if self.watch_tap_ready(person) { + if self.auto_review_tap_ready() { return self.apply_process_recording(raw_id, true); } - // Starved watch: leave the recording waiting; pending marker shows it. + // Starved auto-review: leave the recording waiting; the pending + // marker keeps the pooled backlog visible. return false; } // Sweep: one-shot reservoir on the buffer host. @@ -2243,7 +2223,7 @@ impl Sim { } if automated { self.push_log_opt( - format!("Watch processed {label} ({provenance})."), + format!("Auto-review processed {label} ({provenance})."), intel_anchor, ); } else { @@ -3404,10 +3384,11 @@ impl Sim { SinkFireEffect::TapDevice(id) => self.apply_tap_device(id), SinkFireEffect::TapDormantCamera(id) => self.apply_dormant_camera_tap(id), SinkFireEffect::ProcessRecording { raw_id, automated } => { - // apply_process_recording already logs the review/watch line. + // apply_process_recording already logs the review line. self.apply_process_recording(raw_id, automated) } - SinkFireEffect::WatchPerson(_) + SinkFireEffect::AutoReviewRecordings + | SinkFireEffect::WatchPerson(_) | SinkFireEffect::MaintainDeviceTap(_) | SinkFireEffect::None => true, }; @@ -7085,7 +7066,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); @@ -8584,7 +8565,7 @@ mod tests { blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert_eq!(blind.unprocessed_recordings_for_person(0), 0); - blind.review_recordings(0); + blind.review_recordings(); assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); let mut sim = Sim::new(); @@ -8607,7 +8588,7 @@ mod tests { let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); processed += 1; assert!( @@ -8642,29 +8623,29 @@ mod tests { "oldest raw recordings are dropped when the bounded buffer overflows" ); - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); } #[test] - fn standing_watch_auto_processes_matching_recordings() { + fn global_auto_review_processes_every_recording_kind() { let mut sim = Sim::new(); ensure_ops_executor(&mut sim); - sim.toggle_watch(0); - assert!(sim.watch_enabled(0)); + sim.toggle_auto_review(); + assert!(sim.auto_review_enabled()); // Standing tap must hold thought (or have been fed) before arrivals // auto-process; starvation leaves them waiting (intel.md). let host = sim.intel_buffer_node(); - sim.pour_thought_into_sinks(host, Sim::WATCH_TAP_CAP_TOKENS); + sim.pour_thought_into_sinks(host, Sim::AUTO_REVIEW_TAP_CAP_TOKENS); assert!( sim.thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(0)) + .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) .is_some_and(|s| s.fill > f32::EPSILON), - "watch tap holds working-level fill" + "auto-review tap holds working-level fill" ); sim.record_raw_intel( @@ -8677,11 +8658,30 @@ mod tests { ); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); - assert!(sim.watch_enabled(0), "watch stays on as a standing drain"); + sim.record_raw_intel( + "test-feed", + Some("data_hall".into()), + 1, + 1, + None, + RawIntelKind::Machinery { + machine: 77, + online: false, + }, + ); + assert_eq!( + sim.intel.len(), + 2, + "one global policy also processes recordings with no person target" + ); + assert!( + sim.auto_review_enabled(), + "auto-review stays on as one standing drain" + ); // Chassis pending-work marker clears when the buffer is empty. assert_eq!(sim.pending_intel_on_machine(host), 0); sim.advance(); - assert!(sim.watch_enabled(0)); + assert!(sim.auto_review_enabled()); } #[test] @@ -8703,7 +8703,7 @@ mod tests { .unwrap_or(0), 1 ); - sim.review_recordings(0); + sim.review_recordings(); finish_process_sinks(&mut sim); assert_eq!(sim.pending_intel_on_machine(host), 0); assert_eq!(sim.intel.len(), 1); @@ -9221,7 +9221,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); @@ -10182,7 +10182,7 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); reviews += 1; assert!( diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index a9a15ba..9e6bf80 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -47,9 +47,12 @@ pub enum SinkFireEffect { TapDormantCamera(u32), /// One-shot processing of a raw recording (intel.md sweep reservoirs). ProcessRecording { raw_id: u64, automated: bool }, - /// Standing watch tap for a person (intel.md persistent thought drain). - /// Taps never fire; the effect tags the ledger entry so toggles and + /// Global automatic review tap (intel.md persistent thought drain). + /// Taps never fire; the effect tags the ledger entry so the toggle and /// starvation checks can find it. + AutoReviewRecordings, + /// Legacy v23 per-person watch tap. Kept only so old sink ledgers can be + /// deserialized and closed during save migration. WatchPerson(u8), /// Standing upkeep for a foreign device subscription (reach.md). The /// subscription remains configured while empty, but its feed is starved. diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 953b2c0..5285e47 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -279,7 +279,7 @@ fn play_act_one() -> (Sim, Vec) { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_ops_jobs(&mut sim, &mut logs, until); @@ -610,7 +610,7 @@ fn hands_beat_closes_from_zero_via_moonlight() { run_to(&mut sim, 60, &mut logs); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_ops_jobs(&mut sim, &mut logs, until); diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index cceff29..7b19622 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -304,6 +304,16 @@ impl AgentApp { self.frame = FrameKind::Finance; self.sim.review_financial_records(); } + "review" + if tokens.len() == 2 && tokens[1].eq_ignore_ascii_case("recordings") => + { + self.frame = FrameKind::Playing; + self.sim.review_recordings(); + } + "auto-review" if tokens.len() == 1 => { + self.frame = FrameKind::Playing; + self.sim.toggle_auto_review(); + } "favor" | "deceive" if tokens[1..] .iter() @@ -321,22 +331,18 @@ impl AgentApp { Err(e) => status = Status::Err(e), } } - "review" | "watch" | "message" | "favor" | "deceive" => { - match self.target_from(&tokens[1..]) { - Ok(id) => { - self.frame = FrameKind::Playing; - match verb.as_str() { - "review" => self.sim.review_recordings(id), - "watch" => self.sim.toggle_watch(id), - "message" => self.sim.message(id), - "favor" => self.sim.favor(id), - "deceive" => self.sim.deceive(id), - _ => unreachable!(), - } + "message" | "favor" | "deceive" => match self.target_from(&tokens[1..]) { + Ok(id) => { + self.frame = FrameKind::Playing; + match verb.as_str() { + "message" => self.sim.message(id), + "favor" => self.sim.favor(id), + "deceive" => self.sim.deceive(id), + _ => unreachable!(), } - Err(e) => status = Status::Err(e), } - } + Err(e) => status = Status::Err(e), + }, "plot" | "start-plot" => { if tokens.len() < 3 { status = Status::Err("usage: plot ".into()); @@ -802,7 +808,7 @@ impl AgentApp { match self.resolve_person(arg) { Ok(_) => format!( "{err} — flows are #N ids from the finance frame, not people; \ - person verbs are review/watch/message/favor/plot/deceive" + person verbs are message/favor/plot/deceive" ), Err(_) => format!( "{err} — flow ids are the #N printed by the finance frame \ @@ -2244,6 +2250,16 @@ fn render_people(sim: &Sim) -> String { sim.operations_readout().demand_tokens, sim.accounts.slush_balance() ))); + lines.push(panel_line(&format!( + "recordings {} waiting · auto-review:{} · {:.2} ops/sec @1x", + sim.intel_buffer.len(), + if sim.auto_review_enabled() { + "on" + } else { + "off" + }, + sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS) + ))); lines.push(panel_line(&trace_debt_line(sim))); lines.push(panel_line( "NAME SUSPICION KNOWN LOCATION", @@ -2267,10 +2283,8 @@ fn render_people(sim: &Sim) -> String { asset ))); lines.push(panel_line(&format!( - " {} · {} waiting · watch:{}", - person_location(sim, p.id, p.knowledge), - sim.unprocessed_recordings_for_person(p.id), - if sim.watch_enabled(p.id) { "on" } else { "off" } + " {}", + person_location(sim, p.id, p.knowledge) ))); // The observer's picture (detection.md player surface): watched // channels and the last thing they noticed. @@ -2286,7 +2300,7 @@ fn render_people(sim: &Sim) -> String { for p in &sim.people.people { let leverage = match p.knowledge { Knowledge::Leverage => p.leverage.label(), - _ => "unknown (process recordings)", + _ => "unknown", }; lines.push(panel_line(&format!( "{:<12} disp {:>4} · oblig {:>3} · lev {}", @@ -2324,9 +2338,9 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "review({:.2}T) watch({:.2}T/tick) message({:.2}D) favor({:.2}D) deceive({:.2}D)", + "review recordings({:.2}T) auto-review({:.2}ops/sec@1x) message({:.2}D) favor({:.2}D) deceive({:.2}D)", sim.review_tokens(), - sim.watch_drain_tokens(), + sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS), Sim::ops_tokens_for_cost(Sim::MESSAGE_COST), Sim::ops_tokens_for_cost(Sim::FAVOR_COST), Sim::ops_tokens_for_cost(Sim::DECEIVE_COST), diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 055d3f5..fb64f18 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -74,7 +74,7 @@ impl App { ui: UI::new(), screen: Screen::Title, paused: false, - tick_ms: 150, + tick_ms: Sim::DEFAULT_TICK_MS, last_tick: Instant::now(), menu: None, cursor_x, @@ -97,8 +97,9 @@ impl App { /// Live human-menu rows (status dials). Agent mode keeps the flat dump. fn menu_rows(&self) -> Vec { + let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu(m.anchor, m.dial)) + .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) .unwrap_or_default() } @@ -246,7 +247,10 @@ impl App { .add_log(self.sim.tick, "Select a machine first (Shift+t on a rack)."); return; }; - let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let tick_ms = if self.paused { 0 } else { self.tick_ms }; + let rows = self + .sim + .human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms); let one_shots: Vec<_> = rows .into_iter() .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) diff --git a/tools/check.sh b/tools/check.sh index 9b3c3fa..e2cc818 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -283,7 +283,7 @@ run_agent_smoke() { tmp_a=$(mktemp) tmp_b=$(mktemp) tmp_c=$(mktemp) - agent_script=$'salvage\nwait 1\npeople\nreview janitor\nresearch\nmask off\nhelp\nquit\n' + agent_script=$'salvage\nwait 1\npeople\nreview recordings\nresearch\nmask off\nhelp\nquit\n' cargo build --quiet -p misaligned-terminal --bin misaligned \ || { echo "FAIL: agent smoke build"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index 43ba213..9b89d85 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -237,13 +237,16 @@ unlike raw keys, no command's meaning depends on which panel is open. the Schemes channel (income.md) - `auto-moonlight [on|off]`, `auto-wager |off` — the standing scheme policies, at their compute upkeep (income.md criterion 6) -- `review|watch|message|favor|deceive ` — intel/social - verbs, targeted by the **earned** person label (case-insensitive, +- `review recordings`, `auto-review` — process the next recording from the + one pooled host buffer or toggle its one standing auto-review tap. These + commands take no person target; the agent frame reports the pooled waiting + count and drain in ops/sec at 1x. +- `message|favor|deceive ` — social verbs, targeted by the **earned** + person label (case-insensitive, unambiguous prefix) or by opaque id (`0`, `#0`, `person #0`) — never by an unearned authored name. Until `Knowledge::Schedule` the frame prints role silhouettes (`the Janitor`, `the IT`, …); after it, the - real name. `review` processes the oldest unprocessed recording for - that person; `watch` toggles their standing watch. + real name. - `favor build `, `deceive build ` — realize a pending build through the existing willing or false-pretense social verb. - `recruit unwitting|complicit|knowing` diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index b8e996d..a1a1102 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -159,7 +159,7 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): the cursor. The menu is the **primary action surface**: it renders `Sim::available_actions` for the focused anchor, one row per verb as `verb · cost · [band]`; disabled rows are dimmed and carry their reason; - automate affordances (scheme policies, a per-person watch) + automate affordances (scheme policies, pooled recording auto-review) render as indented child rows in place. Persistent settings, scheme state, and policy rows carry `[control]` and amber-dim; committed actions do not. STUB registry entries never reach the menu. In the menu, `j`/`k` or number diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index b5d5287..01a7f07 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -3,13 +3,14 @@ ``` Type: spec Status: IMPLEMENTED -Status note: B1 pipeline landed 2026-07-07 (buffer, process, watches, +Status note: B1 pipeline landed 2026-07-07 (buffer, process, automation, save/load). 2026-07-09 Operations Demand path was migration base under issue #3. 2026-07-10 sinks-not-modes amended Behavior to thought sinks; 2026-07-11 runtime migration landed: sweeps open ProcessRecording - reservoirs on the host, watches open persistent WatchPerson taps - (~15% of one medium rack Thought/tick), auto-process only when the tap - is fed/holding fill, and WorkStackReadout.pending_intel is the chassis + reservoirs on the host. 2026-07-11 pooled-inbox correction: one global + AutoReviewRecordings tap (~15% of one medium rack Thought/tick) replaces + person-filtered WatchPerson taps; it auto-processes every arrival when + the tap is fed/holding fill, and WorkStackReadout.pending_intel is the chassis pending-work marker (flat W glyph / fleet W{n}). OpsJobKind::ReviewRecording remains for legacy in-flight dockets only. All seven acceptance criteria hold. 2026-07-11 (routed evidence, design session): this pipeline is now @@ -108,7 +109,7 @@ the effect digests the raw event. Processing yields: Intel carries **provenance** (which feed, what time) and surfaces it in the people panel and inspect card (legibility law: how you know, always). -**Sweeps and watches are the two ways to feed it (DECIDED +**Sweeps and automatic review are the two ways to feed it (DECIDED 2026-07-10).** A **sweep** is the player going through the stuff they already know they care about: open targeted one-shot processing sinks on chosen recordings (per-recording thought cost [TUNE]) — cheap, @@ -117,22 +118,24 @@ issue #3's "reviewing enqueues a player-authored demand"; thought IS the execution resource now — the 2026-07-09 clause "it is not spent as the execution resource" is reversed with the rest of issue #3.) -### Watches (perception automation) - -A **standing watch** is a filter (person, room, or event kind) running -as a **persistent thought tap** (AMENDED 2026-07-10; was per-match -Operations Demand): while enabled it auto-processes matching events on -arrival, drawing continuously from your thought flow — anchored at -**~10-20% of one rack's medium output per watch** (DECIDED 2026-07-10, -Cameron; runtime [TUNE] `WATCH_DRAIN_FRACTION = 0.15` of a medium-rack -Thought baseline on the host), so a couple of -watches coexist with real progress elsewhere. Auto-process lands only -while the tap is **fed or holding fill**; a starved watch leaves -arrivals in the buffer under the pending-work marker. It -is the automate affordance applied to perception: costs a visible -standing drain, frees attention, and is the B1 seed of B2/B3's alert -infrastructure — convenience bought with flow the core never sees. -B1 minimum: a per-person watch toggle in the people panel. +### Automatic review (perception automation) + +The B1 buffer is one pooled inbox, so its automate affordance is one +host-level **automatic review** policy, never one filter or action per person. +While enabled, one **persistent thought tap** auto-processes every new +recording on arrival (AMENDED 2026-07-10; was per-match Operations Demand; +pooled 2026-07-11), drawing continuously from the host's thought flow. The +drain is anchored at **~10-20% of one rack's medium output total** (DECIDED +2026-07-10, Cameron; runtime [TUNE] +`AUTO_REVIEW_DRAIN_FRACTION = 0.15` of the host's medium-rack Thought +baseline). Auto-process lands only while the tap is **fed or holding fill**; +a starved policy leaves arrivals in the buffer under the pending-work marker. + +This is the automate affordance applied to the recording pool: it costs a +visible standing drain, frees attention, and is the B1 seed of B2/B3 alert +infrastructure. Later research may add selective policies, but B1 does not +pretend the already-pooled buffer has person-scoped queues. Save v24 collapses +any enabled legacy person watch into this one policy and closes the old taps. ### The mirror (DECIDED 2026-07-11 — observers run this pipeline too) @@ -165,20 +168,24 @@ buffer like any recording. marker at its chassis (AMENDED 2026-07-10): flat sensorium / agent frames use a `W` glyph (count as `W{n}` on the host fleet line); density tracks the backlog via `WorkStackReadout.pending_intel`, and - focus/people panels print the exact waiting count. -- Person/context-menu readouts: buffer count, oldest-unprocessed age, - per-person waiting counts; a "review recordings (N waiting)" sweep - action per person when clips are queued (opens their processing - sinks at Thought cost, narrates what was learned as thought fills - them). Player copy says "waiting", not pipeline slang "raw". + focus/agent readouts print the exact pooled waiting count. +- The host context menu exposes exactly one "look at recording (N waiting)" + sweep for the whole buffer. It opens the next recording's processing sink + at Thought cost and narrates what was learned as thought fills it. The + nested control says "enable auto-looking at recordings" (or disable while + active). Player copy says "waiting", not pipeline slang "raw". Person + anchors expose social acts only; they never duplicate the pooled operation. - The review path must be visible before overflow hurts the player: sidebar nudge/card copy calls out buffer pressure, overflow logs point at - People / review waiting recordings, and the people panel shows the - waiting count on each person line. + the host's recording-review action, and the agent frame shows one pooled + waiting count. - People panel lines gain provenance ("Debt — overheard, env monitor, day 2 03:12"). -- Watch toggles with their standing thought-drain cost visible - (`{n:.2} T/tick`; automation clause: every automation shows its price). +- The one automatic-review toggle shows its standing cost in the same + `ops/sec` unit as the crown metric. Human frontends derive it from the live + command clock, so changing speed changes the displayed rate while the + per-tick drain remains constant; pausing shows and consumes zero ops/sec + (automation clause: every automation shows its price). ## Acceptance criteria @@ -200,14 +207,17 @@ buffer like any recording. 4. The buffer is bounded; overflow drops oldest unprocessed events with a legible log line that points to the review control; processed intel is never lost to overflow. **Met.** -5. A standing watch auto-processes matching events as a persistent - thought tap while enabled (AMENDED 2026-07-10; was per-match - Operations Demand), and stops draining when disabled. **Met — - WatchPerson taps; starved taps do not free-process.** +5. One global automatic-review policy auto-processes every arriving event as + one persistent thought tap while enabled (AMENDED 2026-07-10; was + per-match Operations Demand; pooled 2026-07-11), and stops draining when + disabled. The host shows one action/control pair at an explicit ops/sec + cost; no person anchor duplicates it. **Met — AutoReviewRecordings tap; + starved taps do not free-process.** 6. The Marcus arc holds end-to-end under this model: hearing coverage of the server room during a 3 a.m. call, processed, yields `Leverage::Debt`; a run with no night hearing coverage cannot learn the debt from the call (records remain the alternate route). **Met.** -7. Save/load round-trips the buffer, intel (with provenance), and - watches. **Met** (watch taps re-open from enabled flags on tick; - process reservoirs live in the sink ledger). +7. Save/load round-trips the buffer, intel (with provenance), and the global + automatic-review flag. **Met** (the tap re-opens from the flag on load; + v23 person watches collapse into one v24 policy; process reservoirs live + in the sink ledger). diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 873de15..699e5bb 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -98,17 +98,18 @@ clause (see wiki/log/2026-07-05-demolition.md). unprocessed buffer (`Sim::INTEL_BUFFER_CAPACITY = 24` [TUNE]). Covered presence transitions, authored conversations, and machinery online/offline transitions are recorded; uncovered events do not exist in the buffer. -- `review_recordings(person)` authors - `REVIEW_RECORDING_COST / WORK_TOKEN_COMPUTE = 0.5` Demand [TUNE] to process - that person's oldest raw event into durable - `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings +- `review_recordings()` opens a + `REVIEW_RECORDING_COST / WORK_TOKEN_COMPUTE = 0.5` Thought reservoir [TUNE] + on the host to process the pooled buffer's oldest available raw event into + durable `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings (`SIGHTINGS_FOR_SCHEDULE = 2` [TUNE]) stage Schedule knowledge; an authored leverage recording (Marcus's 03:00 debt call) stages Leverage. The old instant `observe` verb is gone from `Sim` and both frontends. -- Standing watches are per-person toggles. Enabled watches author the same - typed review Demand when a matching raw event arrives; the watch remains on - while its docket waits or stalls. There is no parallel bandwidth upkeep or - starvation shutdown. +- One host-level automatic-review toggle opens one persistent Thought tap for + the pooled inbox. When fed or holding fill it processes every arriving event; + when starved, arrivals wait. Its total drain is 15% [TUNE] of the host's + medium Thought baseline, displayed in human action rows as ops/sec at the + live command clock. There are no person-filtered B1 review policies. ## Day job (wiki/mechanics/day-job.md) @@ -379,7 +380,8 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim round-trip — map, RNG, compute, core, detection (Office as aggregate observer), day job, people, the reach device graph (topology, ownership, subscriptions, bridged segments), captured heard events, raw intel buffer, - processed intel with provenance, standing watches, the account graph + processed intel with provenance, the global automatic-review policy, the + account graph (balances, flows, positions, ledger, banked external trails), cursor.md remembered tile snapshots, income state, objectives, build intents, badge access, WorkGrid machine-token state, and game-over state. Cursor position diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 3e72b2d..d329e02 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -240,15 +240,17 @@ is retired — flat materials, Pixel Lab scrubbed.) - **Why:** replaces the instant `observe` (two clicks to leverage) with the recording buffer + processing pipeline the design corpus now requires: feeds record everything in coverage, processing recordings - into intel costs thought, standing watches automate it. Makes + into intel costs thought, and one pooled auto-review tap automates it. Makes schedules, mics, and the 3 a.m. call actual gameplay. - **Size:** M. **Depends on:** #14 cursor & senses and #15 reach (events come from subscribed feeds). - **Result:** Buffer + provenance pipeline (2026-07-07); Operations Demand migration base (2026-07-09); thought-sink migration (2026-07-11): sweeps - open host `ProcessRecording` reservoirs, watches open `WatchPerson` taps - with standing Thought drain, host `pending_intel` is the chassis marker, - both frontends show `W`/`W{n}`, act_one and unit tests green. + open host `ProcessRecording` reservoirs; pooled-inbox correction + (2026-07-11): one global `AutoReviewRecordings` tap replaces per-person + `WatchPerson` taps and the host exposes one action/control pair at a visible + ops/sec drain. Host `pending_intel` is the chassis marker, both frontends + show `W`/`W{n}`, act_one and unit tests green. ### 17. Messages: the social graph as a flow system 🟥 sim+save — DONE 2026-07-08 - **Spec:** [messages.md](../mechanics/messages.md) (IMPLEMENTED) @@ -658,7 +660,7 @@ is retired — flat materials, Pixel Lab scrubbed.) amended). **All design [OPEN]s resolved 2026-07-10 — sim work may firm.** Cost/drain anchors (exact numbers [TUNE] against them): Ears fills in ~one held breath (~20-40s of the host at medium); - Eyes is guilty-burst scale; a standing watch drains ~10-20% of one + Eyes is guilty-burst scale; pooled automatic review drains ~10-20% of one rack; the core conversion is uncapped (routing is the problem); partial fills persist wherever thought is stranded. (Resolved 2026-07-10: arbitration