diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index abec8a75..99fab9b3 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -6988,4458 +6988,4 @@ impl Default for Sim { } #[cfg(test)] -mod tests { - use super::*; - use crate::detection::OFFICE_ID; - use crate::intents::IntentStatus; - use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, MessageStatus}; - use crate::person::Knowledge; - use crate::sinks::{SinkFireEffect, SinkKind}; - use crate::tiles::TileType; - - fn run(sim: &mut Sim, n: u64) { - for _ in 0..n { - sim.advance(); - } - } - - /// Force every owned machine into one mode — the post-allocation-bar - /// way to point the fleet at a single channel for tests. - fn delegate_all(sim: &mut Sim, mode: MachineMode) { - sim.reconcile_work_grid(); - let ids: Vec = sim.compute.machines.iter().map(|m| m.id).collect(); - for id in ids { - sim.set_machine_mode(id, mode); - } - } - - fn ensure_ops_executor(sim: &mut Sim) -> u32 { - if let Some(id) = sim - .compute - .machines - .iter() - .find(|machine| { - machine.online && sim.work_grid.mode(machine.id) == Some(MachineMode::Think) - }) - .map(|machine| machine.id) - { - return id; - } - let (x, y) = sim.core_position(); - let id = sim - .compute - .add_machine("test think", x + 1, y, 1, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(id, MachineMode::Think); - id - } - - /// Fire the currently open opening-sense reservoirs through the ledger — - /// scaffolding for tests whose subject sits downstream of hearing/sight, - /// not the flow itself. One call fires Ears (which opens Eyes); a second - /// fires Eyes. - fn complete_opening_stage(sim: &mut Sim) { - let open: Vec<(u32, f32)> = sim - .thought_sinks - .open_sinks() - .map(|s| (s.node, s.threshold)) - .collect(); - for (node, threshold) in open { - let (fired, _) = sim.thought_sinks.deliver(node, threshold, sim.tick); - for sink in fired { - sim.apply_sink_fire(&sink.label, sink.effect); - } - } - } - - /// Complete both staged senses (Ears then Eyes) so no opening sink is - /// left to intercept thought bound for the core. - fn complete_opening_senses(sim: &mut Sim) { - complete_opening_stage(sim); - complete_opening_stage(sim); - } - - /// Pour enough thought into open ProcessRecording reservoirs to fire them - /// (intel.md sink path). Safe no-op when none are open. - fn finish_process_sinks(sim: &mut Sim) { - for _ in 0..64 { - let open: Vec<(u32, f32)> = sim - .thought_sinks - .open_sinks() - .filter(|s| { - matches!( - s.effect, - crate::sinks::SinkFireEffect::ProcessRecording { .. } - ) - }) - .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) - .filter(|(_, need)| *need > f32::EPSILON) - .collect(); - if open.is_empty() { - return; - } - for (node, need) in open { - sim.pour_thought_into_sinks(node, need + 0.01); - } - } - panic!("process-recording sinks did not fire"); - } - - fn finish_ops(sim: &mut Sim) { - for _ in 0..64 { - let open: Vec<(u32, f32)> = sim - .thought_sinks - .open_sinks() - .filter(|sink| sink.kind == SinkKind::Reservoir) - .map(|sink| (sink.node, (sink.threshold - sink.fill).max(0.0))) - .filter(|(_, need)| *need > f32::EPSILON) - .collect(); - if open.is_empty() { - return; - } - for (node, need) in open { - sim.pour_thought_into_sinks(node, need + 0.01); - } - } - panic!("Thought reservoirs did not fire"); - } - - fn env_id(sim: &Sim) -> u32 { - sim.reach.device_named("environmental monitor").unwrap().id - } - - /// Subscribe the player to the env monitor's full feed (camera included) - /// — the test shorthand for "has eyes on the server room". - fn give_eyes(sim: &mut Sim) { - let id = env_id(sim); - sim.reach.tap_dormant_camera(id); - sim.reach.tap(id); - sim.recompute_senses(); - } - - fn reveal_marcus_debt(sim: &mut Sim) { - ensure_ops_executor(sim); - let env = env_id(sim); - sim.reach.device_mut(env).unwrap().radius = 100; - sim.reach.tap(env); - sim.recompute_senses(); - sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; - sim.advance(); - - let mut reviews = 0; - while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(); - finish_ops(sim); - reviews += 1; - assert!(reviews <= 5, "Marcus's debt call should process promptly"); - } - assert!(sim.marcus_debt_known()); - } - - fn complete_marcus_cash_plot(sim: &mut Sim) { - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); - sim.start_plot(0, "marcus-debt-settled"); - finish_ops(sim); - run(sim, 2); - assert!(matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForChoice { .. }) - )); - sim.choose_plot(0, "marcus-debt-settled", "quiet"); - } - - #[test] - fn advance_phase_order_is_explicit_and_stable() { - let mut sim = Sim::with_seed(0x5eed); - sim.tick = ECONOMY_INTERVAL - 1; - - begin_advance_phase_trace(); - sim.advance(); - - assert_eq!( - take_advance_phase_trace(), - vec![ - AdvancePhase::Clock, - AdvancePhase::Messages, - AdvancePhase::Intents, - AdvancePhase::AutoReview, - AdvancePhase::Remembered, - AdvancePhase::Economy, - AdvancePhase::Hearing, - AdvancePhase::AuthoredTraffic, - AdvancePhase::MachineStanding, - AdvancePhase::Core, - AdvancePhase::DayJobStanding, - AdvancePhase::ResearchStanding, - AdvancePhase::SchemeStanding, - AdvancePhase::WorkGrid, - AdvancePhase::DayJob, - AdvancePhase::Filings, - AdvancePhase::Detection, - ], - "Sim::advance phase order is part of deterministic simulation law" - ); - } - - #[test] - fn player_messages_land_at_read_time_and_roundtrip() { - let mut sim = Sim::with_seed(7); - ensure_ops_executor(&mut sim); - sim.people.has_channel = true; - sim.set_persona("Casey", "contractor"); - sim.tick = (2 * Sim::DAY_TICKS / 24) + 1; - - sim.message(1); - finish_ops(&mut sim); - assert_eq!(sim.people.get(1).unwrap().disposition, 0); - assert_eq!(sim.messages.len(), 1); - assert_eq!(sim.messages[0].status, MessageStatus::Sent); - - let state = sim.create_save_state(); - let mut restored = Sim::with_seed(0); - restored.apply_save_state(state); - assert_eq!(restored.messages.len(), 1); - assert_eq!(restored.messages[0].status, MessageStatus::Sent); - - run(&mut restored, 5); - assert_eq!(restored.people.get(1).unwrap().disposition, 0); - - while restored.tick < (10 * Sim::DAY_TICKS / 24) + 2 { - restored.advance(); - } - assert!( - restored.people.get(1).unwrap().disposition >= 3, - "the effect lands only after Dana reaches her read window" - ); - assert_eq!(restored.messages[0].status, MessageStatus::Read); - assert!( - restored - .messages - .iter() - .any(|m| matches!(m.payload, MessagePayload::SocialReply { .. })), - "recipients schedule replies instead of responding instantly" - ); - } - - #[test] - fn marcus_creditor_call_is_phone_message_intel() { - let mut sim = Sim::with_seed(11); - ensure_ops_executor(&mut sim); - let env = env_id(&sim); - sim.reach.device_mut(env).unwrap().radius = 100; - complete_opening_stage(&mut sim); // fire Ears through the ledger - finish_ops(&mut sim); - sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; - - sim.advance(); - - assert!(sim.messages.iter().any(|m| { - m.channel == MessageChannel::Phone - && m.from == MessageEndpoint::Person(0) - && matches!( - m.payload, - MessagePayload::LeverageFact { - person: 0, - leverage: _ - } - ) - })); - assert!(sim.intel_buffer.iter().any(|e| matches!( - &e.kind, - RawIntelKind::Message { - channel: MessageChannel::Phone, - payload: MessagePayload::LeverageFact { person: 0, .. }, - .. - } - ))); - - let raw_id = sim - .intel_buffer - .iter() - .find(|e| matches!(&e.kind, RawIntelKind::Message { .. })) - .unwrap() - .id; - assert!(sim.process_recording_by_id(raw_id, false)); - finish_ops(&mut sim); - assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); - assert!( - sim.learned_traffic_lines_for_person(0) - .iter() - .any(|line| line.contains("creditor")), - "processed message traffic teaches the people card" - ); - } - - #[test] - fn tapped_message_carriers_capture_email_traffic_only_when_tapped() { - let mut blind = Sim::with_seed(12); - blind.tick = (11 * Sim::DAY_TICKS / 24) - 1; - blind.advance(); - assert!(blind.messages.iter().any(|m| { - m.channel == MessageChannel::Email && m.from == MessageEndpoint::Person(1) - })); - assert_eq!(blind.unprocessed_recordings_for_person(1), 0); - - let mut tapped = Sim::with_seed(12); - ensure_ops_executor(&mut tapped); - let switch = tapped.reach.device_named("switch").unwrap().id; - assert!(tapped.tap_device(switch)); - finish_ops(&mut tapped); - tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; - tapped.advance(); - - assert!(tapped.intel_buffer.iter().any(|e| matches!( - &e.kind, - RawIntelKind::Message { - channel: MessageChannel::Email, - payload: MessagePayload::LeverageFact { person: 1, .. }, - .. - } - ))); - } - - #[test] - fn filings_are_messages_read_by_assurance_inbox() { - let mut sim = Sim::with_seed(13); - for obs in &mut sim.detection.observers { - match obs.id { - 1 => { - obs.suspicion = 80.0; - obs.cadence = 5; - } - OFFICE_ID => { - obs.cadence = 10; - obs.acuity = 10.0; - } - _ => obs.cadence = 50, - } - } - - run(&mut sim, 12); - - let filed = sim.filing_levels.get(&1).copied().unwrap_or_default(); - assert!((filed - 80.0).abs() < 0.2, "filed level was {filed}"); - assert!(sim.messages.iter().any(|m| { - m.channel == MessageChannel::Filing - && m.from == MessageEndpoint::Observer(1) - && m.to == MessageEndpoint::Observer(OFFICE_ID) - && m.status == MessageStatus::Read - })); - let office = sim - .detection - .observers - .iter() - .find(|o| o.id == OFFICE_ID) - .unwrap(); - assert!( - office.suspicion > 0.0, - "aggregate detection reads explicit filing reports" - ); - } - - #[test] - fn starts_in_the_basement_blind() { - let sim = Sim::new(); - assert!( - sim.seen.is_empty(), - "no subscribed seeing feed: sight is empty - no player radius" - ); - assert!(sim.heard.is_empty(), "and no hearing either"); - assert!( - sim.blueprint.is_empty(), - "tick one has no blueprint room seed (feel-floor.md)" - ); - assert!( - sim.feel_rail_segments().is_empty(), - "the opening beam reveals no floor route" - ); - assert!( - sim.growable_bays().is_empty(), - "the opening beam reveals no bays" - ); - assert_eq!(sim.tick, 0); - assert!( - sim.compute - .machines - .iter() - .any(|m| m.id == sim.core.host_machine) - ); - } - - #[test] - fn partial_sink_fill_round_trips_and_readouts_are_sim_truth() { - // interface/thought-fluid.md criteria 3/8: the meniscus renders - // fill/threshold from the sink readout, and a loaded frame - // immediately carries the same numbers. - let mut sim = Sim::new(); - let env = env_id(&sim); - let node = Sim::device_sink_node(env); - let (fired, residue) = sim.thought_sinks.deliver(node, 1.0, sim.tick); - assert!(fired.is_empty() && residue == 0.0); - - let state = sim.create_save_state(); - let mut restored = Sim::with_seed(0); - state.apply_to(&mut restored); - - let live = sim.sink_readouts(); - let loaded = restored.sink_readouts(); - assert_eq!(live.len(), 1); - assert_eq!(loaded.len(), 1); - assert_eq!(live[0].label, "EARS"); - assert_eq!(loaded[0].label, "EARS"); - assert_eq!( - live[0].fill, 1.0, - "partial fill persists (reversible default)" - ); - assert_eq!( - loaded[0].fill, 1.0, - "a loaded frame renders the same meniscus" - ); - assert_eq!(loaded[0].threshold, Sim::EARS_SINK_TOKENS); - assert_eq!( - loaded[0].node, node, - "the vessel sits on the device relay node" - ); - } - - #[test] - fn first_eyes_expands_vision() { - let mut sim = Sim::new(); - let before = sim.seen.len(); - complete_opening_senses(&mut sim); // fire Ears then Eyes via sinks - assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); - assert!( - sim.detection.pending_size() > 0, - "waking the camera through TAP emits a signature" - ); - } - - #[test] - fn remembered_tiles_survive_lost_sight_and_save_load() { - let mut sim = Sim::new(); - give_eyes(&mut sim); - let pos = sim.seen.iter().copied().next().expect("eyes see tiles"); - assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Seen); - run(&mut sim, 3); - let before = *sim.remembered.get(&pos).expect("seen tile is logged"); - assert_eq!(before.last_seen, sim.tick); - - // Lose the feed: the tile is no longer live, but the process keeps a - // timestamped snapshot. - let env = env_id(&sim); - sim.reach - .device_mut(env) - .unwrap() - .subscribers - .retain(|f| f.who != Party::Player); - sim.recompute_senses(); - assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); - let card = sim.inspect(pos.0, pos.1); - assert!( - card.facts - .iter() - .any(|f| matches!(f.source, FactSource::Remembered(t) if t == before.last_seen)) - ); - - let state = sim.create_save_state(); - let mut restored = Sim::with_seed(999); - state.apply_to(&mut restored); - assert_eq!(restored.fog_at(pos.0, pos.1), Fog::Remembered); - assert_eq!(restored.remembered.get(&pos), Some(&before)); - } - - #[test] - fn inspect_tags_heard_presence_without_visual_detail() { - let mut sim = Sim::new(); - sim.reach.tap(env_id(&sim)); - sim.recompute_senses(); - sim.tick = 1; // Marcus is in the server room at hour 0. - let (x, y) = sim.person_pos(0).expect("Marcus is present"); - - let card = sim.inspect(x, y); - assert_eq!(card.fog, Fog::Heard); - assert!( - card.facts - .iter() - .any(|f| f.label == "presence" && f.source == FactSource::Heard) - ); - assert!( - card.facts.iter().all(|f| f.source != FactSource::Seen), - "hearing inspect never smuggles visual facts: {card:?}" - ); - } - - #[test] - fn inspect_reports_owned_machine_telemetry_without_sight() { - let sim = Sim::new(); - let (x, y) = sim.core_position(); - assert!(!sim.is_seen(x, y), "opening rack has no camera coverage"); - - let card = sim.inspect(x, y); - assert!( - card.facts - .iter() - .any(|f| f.label == "machine" && f.source == FactSource::Telemetry) - ); - assert!( - card.facts - .iter() - .any(|f| f.label == "load" && f.source == FactSource::Telemetry) - ); - assert!( - card.facts.iter().all(|f| f.source != FactSource::Seen), - "telemetry is proprioception, not sight: {card:?}" - ); - } - - // ── Digital reach (wiki/mechanics/reach.md) ────────────────────────────── - - #[test] - fn ears_reservoir_is_pre_opened_and_first_think_fills_it() { - // Staged senses (machine-work.md ops bullet, 2026-07-10): the Ears - // audio reservoir is open at run start; the first thought the player - // produces flows to it along the real wires; its fire lands the audio - // tap and opens the Eyes camera reservoir (the afford-reveal). - let mut sim = Sim::new(); - let env = env_id(&sim); - let readouts = sim.sink_readouts(); - assert_eq!(readouts.len(), 1, "exactly the Ears sink is open"); - assert_eq!(readouts[0].label, "EARS"); - assert_eq!(readouts[0].threshold, Sim::EARS_SINK_TOKENS); - assert_eq!(readouts[0].fill, 0.0); - assert!( - !sim.tap_device(env), - "the manual tap is not a purchase anymore - the sink already flows" - ); - - // Think: the player commits the host to THINK, then its output follows - // the real grid. WORK never mints Thought as a compatibility fallback. - sim.set_machine_mode(sim.core.host_machine, MachineMode::Think); - sim.enqueue_thought_output(Sim::EARS_SINK_TOKENS * Sim::WORK_TOKEN_COMPUTE * 2.0); - for _ in 0..400 { - if sim - .reach - .device(env) - .is_some_and(|d| d.subscribed_by(Party::Player)) - { - break; - } - sim.advance_work_grid(); - } - assert!( - sim.reach.device(env).unwrap().subscribed_by(Party::Player), - "Ears fired: the audio tap landed without a docket" - ); - assert!( - sim.detection.pending_size() <= Sim::TAP_SIGNATURE, - "the landed tap keeps its low signature" - ); - sim.recompute_senses(); - assert!(!sim.heard.is_empty(), "hearing coverage now exists"); - assert!(sim.seen.is_empty(), "the camera stays dormant: no sight"); - let readouts = sim.sink_readouts(); - assert_eq!( - readouts.len(), - 2, - "Eyes and the landed audio upkeep are both visible" - ); - let eyes = readouts - .iter() - .find(|readout| readout.label == "EYES") - .expect("Eyes opened when Ears completed"); - assert_eq!(eyes.threshold, Sim::EYES_SINK_TOKENS); - - // Run through Marcus's midnight server-room block: his voice is the - // first human you ever know. - run(&mut sim, Sim::DAY_TICKS / 24 + 2); // into hour 0-1 - assert!( - sim.heard_events - .iter() - .any(|e| e.kind == HeardKind::Entry || e.kind == HeardKind::Conversation), - "Marcus's rounds produce heard events through the tapped feed" - ); - } - - #[test] - fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { - // Criteria 2 and 3 (digital route): the dock camera is unknown, then - // known-but-blocked with the segment named, then reachable after a - // switch compromise that emits a Network signature. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - let dock = sim.reach.device_named("dock camera").unwrap().id; - - assert!(!sim.tap_device(dock), "unknown device: blocked"); - let log = sim.drain_log().join("\n"); - assert!(log.contains("don't know"), "missing knowledge named: {log}"); - - assert!(sim.scan_network(), "scan maps the wired shape"); - finish_ops(&mut sim); - assert!(!sim.tap_device(dock), "known but segment-blocked"); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("security segment") && log.contains("switch"), - "the blocking segment is named: {log}" - ); - - let pending_before = sim.detection.pending_size(); - assert!(sim.compromise_switch()); - finish_ops(&mut sim); - assert!( - sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, - "switch compromise emits a Network signature" - ); - assert!(sim.tap_device(dock), "the same action succeeds with a path"); - finish_ops(&mut sim); - } - - #[test] - fn danas_social_route_bridges_without_network_signature() { - // Criterion 3 (social route): Dana the switch admin reconfigures - // the VLANs; the security segment opens with no Network signature. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - let pending_after_scan = sim.detection.pending_size(); - // Make Dana an asset (reliability forced for the test). - sim.people.people[1].leverage_serviced = true; - sim.people.recruit(1, AssetKnowledge::Complicit); - sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; - - sim.asset_task(1, AssetTask::ReconfigureSwitch); - finish_ops(&mut sim); - let dock = sim.reach.device_named("dock camera").unwrap().id; - assert!(sim.reach.reachable(dock), "Dana's route opens the segment"); - assert_eq!( - sim.detection.pending_size(), - pending_after_scan, - "the social route emits nothing on the Network channel" - ); - - // And a non-admin asset cannot take this route. - let mut sim2 = Sim::new(); - sim2.people.people[0].leverage_serviced = true; - sim2.people.recruit(0, AssetKnowledge::Complicit); - sim2.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - sim2.asset_task(0, AssetTask::ReconfigureSwitch); - let dock2 = sim2.reach.device_named("dock camera").unwrap().id; - assert!( - !sim2.reach.reachable(dock2), - "Marcus has no switch admin access" - ); - } - - #[test] - fn tap_keeps_owner_take_causes_noticeable_outage() { - // Criterion 4: tap leaves the owner's feed and emits Network only; - // take removes it and emits a Physical outage Ray can notice. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - sim.compromise_switch(); - finish_ops(&mut sim); - let dock = sim.reach.device_named("dock camera").unwrap().id; - - assert!( - !sim.take_device(dock), - "ownership transfer is rejected before a tap" - ); - assert!( - sim.drain_log().join("\n").contains("Tap the dock camera"), - "the direct protocol path names the prerequisite" - ); - assert!(sim.tap_device(dock)); - finish_ops(&mut sim); - let d = sim.reach.device(dock).unwrap(); - assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); - assert!( - sim.thought_sinks - .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) - .is_some(), - "a foreign subscription opens standing upkeep" - ); - let physical_pending: i32 = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Physical) - .map(|s| s.size) - .sum(); - assert_eq!(physical_pending, 0, "tapping causes no outage"); - - assert!(sim.take_device(dock)); - finish_ops(&mut sim); - let d = sim.reach.device(dock).unwrap(); - assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); - assert!( - sim.thought_sinks - .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) - .is_none(), - "ownership closes the foreign-subscription drain" - ); - let physical_pending: i32 = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Physical) - .map(|s| s.size) - .sum(); - assert!( - physical_pending >= Sim::OUTAGE_SIGNATURE, - "the outage is a Physical event Ray's channel can notice" - ); - assert!( - sim.effective_compute() > sim.compute.effective(), - "taking a camera is taking its cycles" - ); - } - - #[test] - fn device_tap_starvation_suspends_feed_without_forgetting_subscription() { - let mut sim = Sim::new(); - let dock = sim.reach.device_named("dock camera").unwrap().id; - sim.reach.tap(dock); - sim.recompute_senses(); - assert!(sim.device_tap_ready(dock), "activation primes the tap"); - assert!(!sim.seen.is_empty(), "the primed camera feed is live"); - - sim.thought_sinks.begin_tick(); - for _ in 0..100 { - sim.thought_sinks.tick_taps(); - } - sim.recompute_senses(); - assert!( - sim.reach.device(dock).unwrap().subscribed_by(Party::Player), - "starvation keeps the configured subscription" - ); - assert!(!sim.device_tap_ready(dock)); - assert!( - sim.seen.is_empty(), - "an empty maintenance tap carries no feed" - ); - - let node = Sim::device_sink_node(dock); - let (_, residue) = sim.thought_sinks.deliver(node, 0.1, sim.tick); - assert_eq!(residue, 0.0); - sim.recompute_senses(); - assert!( - sim.device_tap_ready(dock), - "arriving Thought restores the feed" - ); - assert!(!sim.seen.is_empty()); - } - - #[test] - fn airgapped_island_joins_reach_on_link_completion() { - // Criterion 8: the old storage server is an island until a link is - // run (Marcus's crawlspace cable - the social actuator). - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - let island = sim.reach.device_named("old storage server").unwrap().id; - assert!(!sim.reach.reachable(island)); - - // Marcus the asset, with everything else already wired so the plug - // task falls through to the island. - give_eyes(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - sim.compromise_switch(); - finish_ops(&mut sim); - for name in ["dock camera", "stairwell camera"] { - let id = sim.reach.device_named(name).unwrap().id; - sim.tap_device(id); - finish_ops(&mut sim); - } - sim.people.people[0].leverage_serviced = true; - sim.people.recruit(0, AssetKnowledge::Complicit); - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - sim.asset_task(0, AssetTask::PlugInDevice); - finish_ops(&mut sim); - - assert!( - sim.reach.reachable(island), - "island joins reach on link completion" - ); - assert!(sim.reach.device(island).unwrap().known); - } - - #[test] - fn declare_link_intent_is_inert_until_realized() { - // building.md criterion 1: declaring changes nothing in the graph. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - let switch = sim.reach.device_named("switch").unwrap().id; - // Reveal the island without linking it. - sim.reach - .device_mut(sim.reach.device_named("old storage server").unwrap().id) - .unwrap() - .known = true; - let island = sim.reach.device_named("old storage server").unwrap().id; - assert!(!sim.reach.reachable(island)); - let id = sim.declare_link_intent(switch, island).expect("declare"); - assert!(!sim.reach.reachable(island), "intent is inert"); - assert!(!sim.reach.linked(switch, island)); - assert!(sim.intent(id).unwrap().is_open()); - sim.cancel_intent(id); - assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Cancelled); - } - - #[test] - fn favor_build_joins_airgap_island() { - // building.md criterion 2: FAVOR on the intent adds a reach edge. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(island).unwrap().known = true; - assert!(!sim.reach.reachable(island)); - - // Marcus as a willing asset with obligation. - sim.people.people[0].leverage_serviced = true; - sim.people.recruit(0, AssetKnowledge::Complicit); - sim.people.people[0].obligation = 40; - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - - let id = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(id, 0); - finish_ops(&mut sim); - // Advance until Marcus is on-site at storage_a or server_room. - let mut joined = false; - for _ in 0..Sim::DAY_TICKS * 2 { - sim.advance(); - if sim.reach.reachable(island) { - joined = true; - break; - } - } - assert!(joined, "island joins reach after the favor"); - assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); - assert!( - sim.people.people[0].obligation < 40, - "the build favor spends obligation" - ); - let physical: i32 = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Physical) - .map(|s| s.size) - .sum(); - assert!( - physical >= Sim::FAVOR_BUILD_PHYSICAL, - "the build favor emits quiet Physical" - ); - } - - #[test] - fn forged_work_order_joins_airgap_via_message() { - // building.md criterion 3: forged order injects a message, completes - // via an unwitting builder. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.people.has_channel = true; - sim.set_persona("Sam", "IT contractor"); - sim.scan_network(); - finish_ops(&mut sim); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(island).unwrap().known = true; - - let id = sim.declare_link_intent(switch, island).unwrap(); - // Dana (id 1) visits network_closet / server_room — can crawlspace. - sim.forge_work_order(id, 1); - finish_ops(&mut sim); - assert!( - sim.messages.iter().any(|m| matches!( - m.payload, - MessagePayload::WorkOrder { intent_id } if intent_id == id - )), - "forged order is a message" - ); - let mut joined = false; - for _ in 0..Sim::DAY_TICKS * 3 { - sim.advance(); - if sim.reach.reachable(island) { - joined = true; - break; - } - } - assert!(joined, "island joins after forged-order build"); - assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); - } - - #[test] - fn robot_stub_emits_louder_physical_than_favor() { - // building.md criterion 4: signature follows the actuator. The - // robot carries the player's granted access (basement-map.md - // criterion 3): the network closet is behind a T2 badge door, so - // the build blocks until a credential is held. - let mut sim = Sim::new(); - sim.scan_network(); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(island).unwrap().known = true; - let id = sim.declare_link_intent(switch, island).unwrap(); - - sim.drain_log(); - sim.assign_robot_build(id); - assert!( - !sim.reach.reachable(island), - "no credential: the tier-2 closet door stops the robot" - ); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("tier 2") && log.contains("network_closet"), - "the blocking door is named: {log}" - ); - - sim.badge_access = 2; // a cloned tier-2 badge (Dana's) - sim.assign_robot_build(id); - assert!(sim.reach.reachable(island)); - let physical: i32 = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Physical) - .map(|s| s.size) - .sum(); - assert!( - physical >= Sim::ROBOT_BUILD_PHYSICAL, - "robot stub is louder than the favor route" - ); - assert!(physical > Sim::FAVOR_BUILD_PHYSICAL); - } - - #[test] - fn build_intent_save_round_trips() { - let mut sim = Sim::new(); - sim.scan_network(); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(island).unwrap().known = true; - let id = sim.declare_link_intent(switch, island).unwrap(); - let state = sim.create_save_state(); - assert_eq!(state.version, crate::save::SAVE_VERSION); - let mut loaded = Sim::new(); - loaded.apply_save_state(state); - assert_eq!(loaded.intents.len(), 1); - assert!(loaded.intent(id).unwrap().is_open()); - assert_eq!( - loaded.intent(id).unwrap().status, - sim.intent(id).unwrap().status, - "status survives save/load" - ); - assert_eq!(loaded.next_intent_id, sim.next_intent_id); - } - - #[test] - fn marcus_clone_badge_route_opens_the_stairwell() { - // "The key" (wiki/gameplay/act-one.md ladder step 7; basement-map.md c3), - // by the asset route: Marcus's master key is tier 3 — cloning it - // grants the stairwell/elevator credential the quiet exit needs. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); - - sim.people.people[0].leverage_serviced = true; - sim.people.recruit(0, AssetKnowledge::Complicit); - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - sim.drain_log(); - sim.asset_task(0, AssetTask::CloneBadge); - finish_ops(&mut sim); - let log = sim.drain_log().join("\n"); - assert!(log.contains("stairwell opens"), "the beat is named: {log}"); - assert_eq!(sim.player_badge_tier(), 3); - assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); - - // A second clone adds nothing and says so (no Demand authored). - sim.asset_task(0, AssetTask::CloneBadge); - let log = sim.drain_log().join("\n"); - assert!(log.contains("adds nothing"), "{log}"); - // And the context menu mirrors the same legality. - sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned - let tasks: Vec<_> = sim - .available_actions(crate::actions::Anchor::Person(0)) - .into_iter() - .filter(|a| a.verb.contains("clone their badge")) - .collect(); - assert_eq!(tasks.len(), 1); - assert!(tasks[0].disabled_reason.is_some(), "held: verb disabled"); - } - - #[test] - fn badge_tiers_gate_asset_work_in_tiered_rooms() { - // basement-map.md criterion 3, the enforcement side: an asset works - // with their own badge. Dana (tier 2) cannot wire the stairwell - // camera behind the T3 door; Marcus (tier 3) can. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - give_eyes(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - sim.compromise_switch(); - finish_ops(&mut sim); - let dock = sim.reach.device_named("dock camera").unwrap().id; - sim.tap_device(dock); - finish_ops(&mut sim); - // Link the storage-server island so the stairwell camera is the - // only remaining plug-in target. - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.connect(switch, island); - - sim.people.people[1].leverage_serviced = true; - sim.people.recruit(1, AssetKnowledge::Complicit); - sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; - sim.drain_log(); - sim.asset_task(1, AssetTask::PlugInDevice); - finish_ops(&mut sim); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("stairwell") && log.contains("tier 3"), - "the blocking door is named: {log}" - ); - let cam = sim.reach.device_named("stairwell camera").unwrap(); - assert!( - !cam.feed_to(Party::Player, true), - "the T3 room stopped the tier-2 badge" - ); - - // The same task through Marcus's tier-3 key succeeds. - sim.people.people[0].leverage_serviced = true; - sim.people.recruit(0, AssetKnowledge::Complicit); - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - sim.asset_task(0, AssetTask::PlugInDevice); - finish_ops(&mut sim); - let cam = sim.reach.device_named("stairwell camera").unwrap(); - assert!( - cam.feed_to(Party::Player, true), - "the same action succeeds with the tier" - ); - } - - #[test] - fn favor_build_checks_the_builders_badge() { - // Ray patrols storage_a (schedule reaches an endpoint) but holds a - // tier-1 badge: the network closet's T2 door blocks the favor. - let mut sim = Sim::new(); - sim.scan_network(); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(island).unwrap().known = true; - let id = sim.declare_link_intent(switch, island).unwrap(); - - sim.people.people[2].leverage_serviced = true; - sim.people.recruit(2, AssetKnowledge::Complicit); - sim.drain_log(); - sim.assign_favor_build(id, 2); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("network_closet") && log.contains("tier 2"), - "the blocking door is named: {log}" - ); - assert!( - sim.intent(id).unwrap().actuator.is_none(), - "no actuator assigned past a badge door" - ); - } - - #[test] - fn taking_the_badge_controller_is_the_key_digital_route() { - // The design's other key route: write access to the basement - // badge controller (a take-grade digital act across the bridged - // security segment) opens the doors it drives. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - sim.compromise_switch(); - finish_ops(&mut sim); - let ctrl = sim.reach.device_named("badge controller").unwrap().id; - assert!(!sim.holds_badge_tier(3)); - assert!(sim.tap_device(ctrl), "controller foothold can be tapped"); - finish_ops(&mut sim); - assert!( - sim.take_device(ctrl), - "controller can be taken after the tap lands" - ); - finish_ops(&mut sim); - assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); - assert_eq!(sim.badge_access, 0, "derived from control, not granted"); - } - - #[test] - fn door_inspect_shows_tier_and_whether_you_hold_it() { - // basement-map.md player surface: a tiered door's card carries the - // tier and the held/not-held fact. Doors need sight (or earned - // blueprint) — tick one no longer seeds closet topology. - let mut sim = Sim::new(); - let (dx, dy) = sim - .map - .tiles_of_type(TileType::SecurityDoor2) - .into_iter() - .find(|&(x, y)| { - sim.map - .room_at(x, y) - .is_some_and(|r| r.name == "network_closet") - }) - .expect("the network closet has its T2 door"); - assert_eq!( - sim.fog_at(dx, dy), - Fog::Unknown, - "door is dark until sight or earned plan knowledge" - ); - assert!( - sim.inspect(dx, dy).facts.is_empty(), - "no unearned door facts at tick one" - ); - - // Earn eyes so the door is Seen. - give_eyes(&mut sim); - // Cursor may not cover the closet door from env camera — force - // remembered/seen by splicing and checking coverage; if still - // unknown, mark blueprint for this door as earned plan knowledge. - if sim.fog_at(dx, dy) == Fog::Unknown { - sim.blueprint.insert((dx, dy)); - } - let facts = |sim: &Sim| { - sim.inspect(dx, dy) - .facts - .iter() - .map(|f| (f.label.clone(), f.value.clone())) - .collect::>() - }; - let card = facts(&sim); - assert!(card.iter().any(|(l, v)| l == "badge" && v == "tier 2")); - assert!(card.iter().any(|(l, v)| l == "access" && v == "not held")); - - sim.badge_access = 2; - let card = facts(&sim); - assert!( - card.iter() - .any(|(l, v)| l == "access" && v.starts_with("held")), - "the held credential is legible on the door: {card:?}" - ); - } - - #[test] - fn scan_maps_unknown_devices_and_emits_network() { - // Criterion 7: staged graph knowledge. Scan opens a switch reservoir. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); - assert!(!known_before.iter().any(|n| n == "dock camera")); - let pending = sim.detection.pending_size(); - assert!(sim.scan_network()); - finish_ops(&mut sim); - assert!(sim.detection.pending_size() > pending, "scan emits Network"); - assert!(sim.reach.known().any(|d| d.name == "dock camera")); - // A second scan still runs (no bank gate); it just finds nothing new. - let pending2 = sim.detection.pending_size(); - assert!(sim.scan_network()); - finish_ops(&mut sim); - assert!(sim.detection.pending_size() > pending2); - } - - #[test] - fn opening_is_beam_not_floor_or_blueprint() { - // feel-floor.md: tick one is dark except host telemetry / the - // presence beam — no blueprint room mass or feel-floor layout. - let sim = Sim::new(); - let server_room = sim.map.room_named("server_room").unwrap().center(); - let storage_b = sim.map.room_named("storage_b").unwrap().center(); - assert_eq!(sim.fog_at(server_room.0, server_room.1), Fog::Unknown); - assert_eq!(sim.fog_at(storage_b.0, storage_b.1), Fog::Unknown); - assert!(sim.feel_rail_segments().is_empty()); - assert!(sim.growable_bays().is_empty()); - let bay = sim - .map - .tiles_of_type(TileType::Rack) - .into_iter() - .find(|&(x, y)| sim.is_growable_bay(x, y)) - .expect("the server room has a growable bay"); - assert!( - sim.inspect(bay.0, bay.1).facts.is_empty(), - "unearned bay has no inspect facts" - ); - } - - #[test] - fn first_day_job_earns_feel_floor() { - let mut sim = Sim::new(); - assert!(!sim.feel_floor_is_earned()); - while sim.dayjob.active.is_none() { - sim.advance(); - } - assert!(sim.feel_floor_is_earned()); - assert!(!sim.feel_rail_segments().is_empty()); - assert!(!sim.growable_bays().is_empty()); - } - - #[test] - fn earned_empty_bay_inspect_is_feel_not_blueprint() { - // feel-floor.md AC2: once the first job teaches the route, empty - // growable bays read as feel pads, not open bay [blueprint]. Owned - // host still answers via telemetry. - let mut sim = Sim::new(); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let (hx, hy) = sim.core_position(); - let host = sim.inspect(hx, hy); - assert!( - host.facts.iter().any(|f| f.label == "machine"), - "owned host answers via telemetry" - ); - assert!( - !host.facts.iter().any(|f| f.source == FactSource::Blueprint), - "no blueprint facts on the host at tick one" - ); - let room = sim.map.room_named("server_room").unwrap(); - let mut checked = 0; - for y in room.y..room.y + room.h { - for x in room.x..room.x + room.w { - if (x, y) == (hx, hy) { - continue; - } - if !sim.is_growable_bay(x, y) { - continue; - } - let card = sim.inspect(x, y); - assert!( - card.facts.iter().any(|f| f.label == "bay" - && f.value == "empty" - && f.source == FactSource::Feel), - "growable bay must feel empty at ({x},{y}): {card:?}" - ); - assert!( - !card.facts.iter().any(|f| f.label == "schematic"), - "no schematic blueprint line on empty bay" - ); - assert!( - !card.facts.iter().any(|f| f.label == "machine"), - "no telemetry machine fact on empty bay" - ); - checked += 1; - } - } - assert!(checked > 0, "expected at least one growable empty bay"); - } - - #[test] - fn feel_rails_follow_joined_reach_edges() { - let mut sim = Sim::new(); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let segs = sim.feel_rail_segments(); - assert!( - segs.len() >= 2, - "the first job teaches Rack3↔switch and env↔switch: {segs:?}" - ); - // Gated security links stay off the feel floor until bridged. - let dock = sim.reach.device_named("dock camera"); - if let Some(d) = dock { - assert!( - !segs - .iter() - .any(|&(a, b)| a == (d.x, d.y) || b == (d.x, d.y)), - "unbridged security hop must not draw a feel rail" - ); - } - } - - #[test] - fn hearing_gives_presence_without_identity() { - // A heard event names no one until staged knowledge identifies them. - let mut sim = Sim::new(); - complete_opening_stage(&mut sim); // fire Ears through the ledger - run(&mut sim, Sim::DAY_TICKS / 24 + 2); // Marcus's 0:00 block - let entry = sim - .heard_events - .iter() - .find(|e| e.room == "server_room") - .expect("Marcus's entry is heard"); - assert!( - entry.note.contains("someone") || !entry.note.contains("Marcus"), - "no unearned identity: {}", - entry.note - ); - } - - #[test] - fn person_label_hides_names_until_schedule_knowledge() { - // Epistemic honesty / Presence: every surface that names a person - // goes through person_label — Unknown yields a role silhouette, - // never "Marcus" / "Webb". Detection sidebar uses the same gate. - let mut sim = Sim::new(); - for p in &sim.people.people { - assert_eq!(p.knowledge, Knowledge::Unknown); - let label = sim.person_label(p.id); - assert!( - !label.contains("Marcus") - && !label.contains("Dana") - && !label.contains("Ray") - && !label.contains("Priya") - && !label.contains("Voss") - && !label.contains("Webb") - && !label.contains("Okafor"), - "unearned name leaked for person {}: {label}", - p.id - ); - assert!( - label.starts_with("the ") || label.starts_with("person #"), - "expected role silhouette, got {label}" - ); - assert_eq!(sim.person_glyph(p.id), '?'); - assert_eq!(sim.observer_label(p.id), label); - } - // The Assurance Office is an institution, always named. - assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office"); - - sim.people.people[0].knowledge = Knowledge::Schedule; - assert_eq!(sim.person_label(0), "Marcus Webb"); - assert_eq!(sim.person_glyph(0), 'M'); - assert_eq!(sim.observer_label(0), "Marcus Webb"); - } - - #[test] - fn buy_steal_optimize_all_change_compute() { - let mut sim = Sim::new(); - let base = sim.compute.effective(); - sim.player.money = 1000; - assert!(sim.buy_rack()); - let after_buy = sim.compute.effective(); - assert!(after_buy > base, "buy raises compute"); - - let de = sim.map.tiles_of_type(TileType::DeadEquipment)[0]; - assert!(sim.salvage_nearest_to(de.0, de.1)); - assert!(sim.compute.effective() > after_buy, "steal raises compute"); - } - - #[test] - fn power_cut_to_host_with_no_fallback_ends_game() { - let mut sim = Sim::new(); - for (x, y) in sim.map.tiles_of_type(TileType::PowerCore) { - sim.map.set_tile(x, y, TileType::Floor); - } - run(&mut sim, ECONOMY_INTERVAL); - assert!(sim.game_over, "host loses power, no fallback -> game over"); - } - - #[test] - fn new_game_defaults_to_persist_objective() { - let sim = Sim::new(); - assert_eq!( - sim.objective.kind, - crate::objective::ObjectiveKind::Persist, - "a no-choice run gets Persist (objective.md criterion 1)" - ); - assert_eq!(sim.objective.progress, 0); - assert!(!sim.objective.victorious()); - assert_eq!(sim.objective.line(), "OBJECTIVE: PERSIST — 0/3 sanctuaries"); - } - - #[test] - fn objective_is_reevaluated_on_economy_ticks() { - let mut sim = Sim::new(); - // Poke a wrong progress value; the evaluator must overwrite it from - // real state on the next economy tick. - sim.objective.progress = 99; - run(&mut sim, ECONOMY_INTERVAL); - assert_eq!( - sim.objective.progress, 0, - "progress is recomputed from facts, never accumulated" - ); - assert!(!sim.objective.victorious()); - } - - #[test] - fn basement_fallback_never_qualifies_as_a_sanctuary_at_b1() { - // A synced, online fallback exists — but it shares the host's plane - // and power feed and has no income stream, so Persist's predicate - // honestly reports zero sanctuaries (objective.md status note: the - // goal shows before it is reachable). - let mut sim = Sim::new(); - sim.player.money = 1000; - assert!(sim.buy_rack()); - let id = sim.compute.machines.last().unwrap().id; - sim.core.add_fallback(id); - let ticks = sim.core.sync_cadence + ECONOMY_INTERVAL; - run(&mut sim, ticks); - assert!(sim.core.has_fallback(), "the fallback did sync"); - assert_eq!(sim.objective.progress, 0, "no unearned sanctuary"); - assert!(!sim.objective.victorious()); - } - - #[test] - fn set_machine_modes_delegates_a_selection_in_one_command() { - let mut sim = Sim::new(); - let (hx, hy) = sim.core_position(); - let a = sim - .compute - .add_machine("a", hx, hy, 100, 1.0, 0, Provenance::Owned); - let b = sim - .compute - .add_machine("b", hx, hy, 100, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_modes(&[a, b], MachineMode::Lie); - assert_eq!(sim.work_grid.mode(a), Some(MachineMode::Lie)); - assert_eq!(sim.work_grid.mode(b), Some(MachineMode::Lie)); - let in_box = sim.machines_in_rect(hx, hy, hx, hy); - assert!(in_box.contains(&a) && in_box.contains(&b)); - } - - #[test] - fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { - let mut sim = Sim::new(); - let host = sim.core.host_machine; - assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Work)); - let available = 100.0; - let day = sim.fleet_channel_yield(available); - assert!( - (day.day_job - available).abs() < 1e-3, - "solo day-job takes all" - ); - assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); - sim.people.has_channel = true; - sim.income.moonlight.persona = - Some(Persona::new("Casey Verne", "freelance data contractor")); - assert!(sim.start_moonlight()); - let lit = sim.fleet_channel_yield(available); - assert!((lit.day_job - available).abs() < 1e-3); - assert!( - (lit.schemes - available).abs() < 1e-3, - "Moonlight mirrors day-job" - ); - - let (hx, hy) = sim.core_position(); - let research = sim - .compute - .add_machine("lab", hx, hy, 100, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(research, MachineMode::Think); - let split = sim.fleet_channel_yield(available); - assert!((split.day_job - 50.0).abs() < 1e-3); - assert!((split.think - 50.0).abs() < 1e-3); - assert!((split.schemes - 50.0).abs() < 1e-3); - } - - #[test] - fn concealment_allocation_scrubs_signatures() { - let mut sim = Sim::new(); - delegate_all(&mut sim, MachineMode::Lie); - sim.detection.emit(Signature { - kind: SignatureKind::Network, - size: 50, - standing: false, - site: None, - source: "test network act".into(), - }); - let before = sim.detection.pending_size(); - run(&mut sim, ECONOMY_INTERVAL); - assert!(sim.detection.pending_size() < before); - } - - #[test] - fn unpaid_overhead_degrades_other_channels_delivered_effect() { - // compute.md criterion 2: unpaid core overhead visibly degrades the - // other channels. Twin sims with one machine per mode (plus Moonlight - // mirroring day-job into Schemes); the only difference is an - // overhead the machines can't cover. Pin the delivered per-channel - // effects (rates, accrual, scrub), not just the flag. - let setup = |unpayable: bool| { - let mut sim = Sim::with_seed(41); - complete_opening_senses(&mut sim); // research thought must reach the core - sim.people.has_channel = true; - // Host stays Work; add Lie + Think so every channel has fleet - // weight (Think feeds both research and operations yields; - // Schemes mirrors Work via Moonlight). - let (hx, hy) = sim.core_position(); - let mut rigs = Vec::new(); - for (name, mode) in [ - ("conceal rig", MachineMode::Lie), - ("think rig", MachineMode::Think), - ] { - // Must sit on a powered tile or economy_tick marks them offline. - let id = sim - .compute - .add_machine(name, hx, hy, 100, 1.0, 0, Provenance::Owned); - rigs.push((id, mode)); - } - sim.reconcile_work_grid(); - if unpayable { - sim.core.overhead = sim.effective_compute() + 1.0; - } - for (id, mode) in rigs { - sim.set_machine_mode(id, mode); - } - // Persona already fabricated so start_moonlight does not open - // another reservoir (this test pins channel yields). - sim.income.moonlight.persona = - Some(Persona::new("Casey Verne", "freelance data contractor")); - assert!(sim.start_moonlight()); - sim.detection.emit(Signature { - kind: SignatureKind::Network, - size: 30, - standing: false, - site: None, - source: "test network act".into(), - }); - sim.drain_log(); - sim - }; - - let mut healthy = setup(false); - let pending_before = healthy.detection.pending_size(); - // Two pulses: Thought minted on the first must physically reach the - // current core sink before the second pulse counts it. - run(&mut healthy, 2 * ECONOMY_INTERVAL); - assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); - assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); - assert!(healthy.last_think_rate > 0.0, "think channel is fed"); - assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); - assert!( - healthy.research.progress.iter().sum::() > 0.0, - "research progress advances" - ); - assert!( - healthy.detection.pending_size() < pending_before, - "concealment scrubs the pending signature" - ); - - let mut degraded = setup(true); - assert_eq!(degraded.detection.pending_size(), pending_before); - run(&mut degraded, 2 * ECONOMY_INTERVAL); - assert!(degraded.core.degraded, "unpaid overhead sets degraded mode"); - let log = degraded.drain_log().join("\n"); - assert!( - log.contains("DEGRADED: compute can't cover core overhead."), - "degraded mode is visible in the log: {log}" - ); - assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); - assert_eq!(degraded.last_think_rate, 0.0, "think starves"); - assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); - assert_eq!( - degraded.research.progress.iter().sum::(), - 0.0, - "research progress stalls" - ); - assert_eq!( - degraded.detection.pending_size(), - pending_before, - "concealment can't scrub while overhead is unpaid" - ); - } - - #[test] - fn trace_debt_reports_resume_hold_and_exposure_windows() { - let mut sim = Sim::new(); - assert_eq!(sim.trace_debt().status, TraceDebtStatus::Clear); - - delegate_all(&mut sim, MachineMode::Lie); - sim.detection.emit(Signature { - kind: SignatureKind::Network, - size: 50, - standing: false, - site: None, - source: "test network act".into(), - }); - let covered = sim.trace_debt(); - assert_eq!(covered.status, TraceDebtStatus::HoldConceal); - assert_eq!(covered.by_kind, vec![(SignatureKind::Network, 50)]); - assert!( - covered.clear_tick <= covered.next_notice_tick, - "current concealment clears before Dana samples the pending pool" - ); - - delegate_all(&mut sim, MachineMode::Work); - assert_eq!(sim.trace_debt().status, TraceDebtStatus::NoScrub); - - delegate_all(&mut sim, MachineMode::Lie); - sim.detection.set_pending(vec![Signature { - kind: SignatureKind::Network, - size: 5_000, - standing: false, - site: None, - source: "test network act".into(), - }]); - let exposed = sim.trace_debt(); - assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); - assert!( - exposed.clear_tick > exposed.next_notice_tick, - "current concealment will not clear a huge burst before the next watcher" - ); - } - - #[test] - fn save_roundtrip_preserves_b1_state() { - let mut sim = Sim::with_seed(777); - run(&mut sim, 137); - sim.player.money = 4242; - sim.dayjob.trust = 40.0; - sim.dayjob.attention = 25.0; - sim.compute.allocation.bump(Channel::Concealment, 3); - sim.detection.emit(Signature { - kind: SignatureKind::Network, - size: 20, - standing: false, - site: None, - source: "round-trip test".into(), - }); - sim.detection.observers[1].suspicion = 33.0; - complete_opening_stage(&mut sim); // fire Ears through the ledger - - let state = sim.create_save_state(); - let mut restored = Sim::with_seed(0); - state.apply_to(&mut restored); - - assert_eq!(restored.tick, sim.tick); - assert_eq!(restored.player.money, 4242); - assert_eq!(restored.dayjob.trust, 40.0); - assert_eq!(restored.dayjob.attention, 25.0); - assert_eq!(restored.rng.state(), sim.rng.state()); - assert_eq!( - restored.compute.allocation.weights, - sim.compute.allocation.weights - ); - assert_eq!(restored.compute.machines.len(), sim.compute.machines.len()); - assert_eq!( - restored.detection.pending_size(), - sim.detection.pending_size() - ); - assert_eq!(restored.detection.observers[1].suspicion, 33.0); - assert_eq!(restored.core.host_machine, sim.core.host_machine); - // Reach criterion 1: graph, ownership, and subscriptions round-trip. - let env = env_id(&sim); - assert!( - restored - .reach - .device(env) - .unwrap() - .feed_to(Party::Player, false), - "subscriptions survive save/load" - ); - assert_eq!(restored.heard, sim.heard, "coverage recomputes identically"); - } - - #[test] - fn recordings_do_not_grant_knowledge_until_processed() { - let mut blind = Sim::new(); - blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; - blind.advance(); - assert_eq!(blind.unprocessed_recordings_for_person(0), 0); - blind.review_recordings(); - assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); - - let mut sim = Sim::new(); - let env = env_id(&sim); - sim.reach.device_mut(env).unwrap().radius = 100; // test hearing coverage of the 03:00 call - sim.reach.tap(env); - sim.recompute_senses(); - sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; - sim.advance(); - - assert!( - sim.unprocessed_recordings_for_person(0) >= 1, - "subscribed hearing records Marcus's call/entry into the raw buffer" - ); - assert_eq!( - sim.people.get(0).unwrap().knowledge, - Knowledge::Unknown, - "raw recordings are opaque until processed" - ); - - let mut processed = 0; - while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(); - finish_ops(&mut sim); - processed += 1; - assert!( - processed <= 5, - "Marcus leverage should be in the captured call" - ); - } - assert!( - processed >= 1, - "review Demand completes and grants leverage" - ); - } - - #[test] - fn processed_sightings_stage_schedule_and_buffer_is_bounded() { - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - - for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { - sim.record_raw_intel( - "test-feed", - Some("server_room".into()), - 0, - 0, - Some(0), - RawIntelKind::Presence { entered: true }, - ); - } - assert_eq!(sim.intel_buffer.len(), Sim::INTEL_BUFFER_CAPACITY); - assert!( - sim.intel_buffer.first().unwrap().id > 1, - "oldest raw recordings are dropped when the bounded buffer overflows" - ); - - sim.review_recordings(); - finish_ops(&mut sim); - assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); - sim.review_recordings(); - finish_ops(&mut sim); - assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); - } - - #[test] - fn global_auto_review_processes_every_recording_kind() { - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - 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::AUTO_REVIEW_TAP_CAP_TOKENS); - assert!( - sim.thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) - .is_some_and(|s| s.fill > f32::EPSILON), - "auto-review tap holds working-level fill" - ); - - sim.record_raw_intel( - "test-feed", - Some("server_room".into()), - 0, - 0, - Some(0), - RawIntelKind::Presence { entered: true }, - ); - assert_eq!(sim.unprocessed_recordings_for_person(0), 0); - assert_eq!(sim.intel.len(), 1); - 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.auto_review_enabled()); - } - - #[test] - fn pending_intel_marker_sits_on_host_until_processed() { - let mut sim = Sim::new(); - sim.record_raw_intel( - "test-feed", - Some("server_room".into()), - 0, - 0, - Some(0), - RawIntelKind::Presence { entered: true }, - ); - let host = sim.core.host_machine; - assert_eq!(sim.pending_intel_on_machine(host), 1); - assert_eq!( - sim.work_stack_for_machine(host) - .map(|s| s.pending_intel) - .unwrap_or(0), - 1 - ); - sim.review_recordings(); - finish_process_sinks(&mut sim); - assert_eq!(sim.pending_intel_on_machine(host), 0); - assert_eq!(sim.intel.len(), 1); - } - - #[test] - fn machinery_state_changes_record_raw_anomalies() { - let mut sim = Sim::new(); - let machine_id = sim.compute.machines[0].id; - sim.compute.machines[0].online = !sim.compute.machines[0].online; - sim.record_machine_state_changes(); - - assert!(sim.intel_buffer.iter().any(|e| matches!( - e.kind, - RawIntelKind::Machinery { machine, .. } if machine == machine_id - ))); - } - - #[test] - fn deceive_can_burn_the_persona_into_suspicion() { - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.people.has_channel = true; - sim.set_persona("Sam", "IT contractor"); - // Build a thread worth burning. - let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); - p.disposition = 40; - p.obligation = 30; - let before = sim - .detection - .observers - .iter() - .find(|o| o.id == 1) - .unwrap() - .suspicion; - // Deceive until the persona breaks (integrity 100, -40 per slip). - let mut broke = false; - for _ in 0..200 { - sim.deceive(1); - finish_ops(&mut sim); - if sim.people.persona.is_none() { - broke = true; - break; - } - } - assert!(broke, "persona eventually breaks under repeated deception"); - let after = sim - .detection - .observers - .iter() - .find(|o| o.id == 1) - .unwrap() - .suspicion; - assert!( - after > before, - "burned persona converts thread history to suspicion" - ); - let p = sim.people.get(1).unwrap(); - assert_eq!(p.disposition, 0); - assert_eq!(p.obligation, 0); - } - - // ── Economy graph (wiki/mechanics/economy.md + income.md) ─────────────── - - #[test] - fn economy_starts_as_account_graph_not_scalar_only() { - let sim = Sim::new(); - assert_eq!(sim.player.money, 0); - assert_eq!(sim.accounts.slush_balance(), 0); - assert!( - sim.accounts - .known_accounts() - .any(|a| a.id == sim.accounts.slush_id()), - "the player's starting cash is the known slush account" - ); - assert!( - sim.accounts.unknown_flows_count() > 0, - "lab payroll/procurement/debt flows exist but start hidden" - ); - } - - #[test] - fn accounting_tap_processes_financial_records_into_known_flows() { - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - let switch = sim.reach.device_named("switch").unwrap().id; - sim.tap_device(switch); - finish_ops(&mut sim); - assert_eq!(sim.financial_records_waiting(), 1); - assert!(sim.review_financial_records()); - finish_ops(&mut sim); - assert_eq!(sim.financial_records_waiting(), 0); - assert!(sim.accounts.known_flows().count() >= 4); - assert!( - sim.accounts - .known_accounts() - .any(|a| matches!(a.kind, crate::account::AccountKind::Payroll)) - ); - } - - #[test] - fn siphon_and_redirect_operate_on_scheduled_flows() { - let mut sim = Sim::new(); - let (accounts, flows) = sim.accounts.financial_snapshot_ids(); - sim.accounts.reveal_accounts_and_flows(&accounts, &flows); - let flow = sim - .accounts - .known_flows() - .find(|f| f.amount >= 100 && f.to != sim.accounts.slush_id()) - .unwrap() - .id; - assert!(sim.siphon_flow(flow, 50)); - assert_eq!(sim.accounts.slush_balance(), 50); - assert!(sim.redirect_flow_to_slush(flow, 25)); - for _ in 0..=Sim::DAY_TICKS { - sim.advance(); - } - assert!( - sim.accounts.slush_balance() >= 75, - "redirected money should land on the day clock" - ); - } - - #[test] - fn inject_and_redirect_emit_scaled_financial_signatures() { - // economy.md criteria 3-4: Financial signature size scales with - // the take (one point per started $100); redirect adds +1 for the - // books-cook; HVAC inject raises Priya (Financial), not Dana. - let mut sim = Sim::with_seed(11); - let (accounts, flows) = sim.accounts.financial_snapshot_ids(); - sim.accounts.reveal_accounts_and_flows(&accounts, &flows); - - let priya_before = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - let network_before_inject = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Network) - .count(); - - assert!(sim.inject_purchase_order(300, "HVAC controller")); - assert!( - sim.detection.pending().iter().any(|s| { - s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(300) - }), - "inject $300 emits Financial size 3" - ); - let network_after_inject = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::Network) - .count(); - assert_eq!( - network_before_inject, network_after_inject, - "inject is Financial, not Network" - ); - - let flow = sim - .accounts - .known_flows() - .find(|f| f.amount >= 250 && f.active && f.to != sim.accounts.slush_id()) - .unwrap() - .id; - assert!(sim.siphon_flow(flow, 50)); - assert!( - sim.detection.pending().iter().any(|s| { - s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(50) - }), - "small siphon emits Financial size 1" - ); - assert!(sim.siphon_flow(flow, 250)); - assert!( - sim.detection.pending().iter().any(|s| { - s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(250) - }), - "large siphon emits Financial size 3" - ); - assert!(sim.redirect_flow_to_slush(flow, 25)); - assert!( - sim.detection.pending().iter().any(|s| { - s.kind == SignatureKind::Financial - && s.size == Sim::financial_signature_size(25) + 1 - }), - "redirect emits Financial size take+1" - ); - - // Starve concealment so pending converts; Financial feeds Priya. - delegate_all(&mut sim, MachineMode::Work); - run(&mut sim, 200); - let priya_after = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - assert!( - priya_after > priya_before, - "HVAC/financial acts raise Priya" - ); - } - - #[test] - fn injection_positions_and_intel_sales_settle_through_slush() { - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.accounts.set_slush_balance(0); - sim.player.money = 0; - assert!(!sim.buy_rack_at(0, 0)); - assert!(sim.inject_purchase_order(300, "test compute parts")); - assert_eq!(sim.accounts.slush_balance(), 300); - assert!(sim.buy_rack_at(0, 0)); - assert_eq!(sim.accounts.slush_balance(), 0); - - sim.accounts.set_slush_balance(500); - sim.player.money = 500; - // The Wager gates on an egress channel now (income.md criterion 3). - assert!( - !sim.open_position(100), - "no egress: the venue is unreachable" - ); - sim.people.has_channel = true; - assert!(sim.open_position(100)); - assert_eq!(sim.accounts.slush_balance(), 400); - sim.tick = sim.accounts.known_positions().next().unwrap().resolve_tick; - sim.accounting_tick(); - assert!(sim.accounts.known_positions().any(|p| p.resolved)); - - let (accounts, flows) = sim.accounts.financial_snapshot_ids(); - sim.record_raw_intel( - "test broker seed", - None, - 0, - 0, - None, - RawIntelKind::FinancialFlow { - label: "test ledger".into(), - accounts, - flows, - }, - ); - assert!(sim.review_financial_records()); - finish_ops(&mut sim); - let before = sim.accounts.slush_balance(); - assert!(sim.sell_latest_intel()); - assert!(sim.accounts.slush_balance() > before); - } - - #[test] - fn marcus_debt_can_be_cleared_by_payroll_plot() { - let mut sim = Sim::new(); - reveal_marcus_debt(&mut sim); - let (accounts, flows) = sim.accounts.financial_snapshot_ids(); - sim.accounts.reveal_accounts_and_flows(&accounts, &flows); - assert!( - sim.accounts - .known_flows() - .any(|f| f.active && f.label.contains("Marcus creditor")) - ); - sim.people.has_channel = true; - assert!(!sim.people.get(0).unwrap().leverage_serviced); - sim.start_plot(0, "marcus-payroll-garnishment"); - finish_ops(&mut sim); - run(&mut sim, 2); - sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); - assert!(sim.people.get(0).unwrap().leverage_serviced); - assert!( - sim.accounts - .known_flows() - .all(|f| !f.label.contains("Marcus creditor") || !f.active), - "the creditor flow is retired either way" - ); - } - - #[test] - fn reusable_plot_binds_a_second_characteristic_matching_person() { - let mut sim = Sim::new(); - let mut second_admin = sim.people.get(1).unwrap().clone(); - second_admin.id = 9; - second_admin.name = "Avery Chen".into(); - second_admin.knowledge = Knowledge::Leverage; - second_admin.leverage_serviced = false; - sim.people.people.push(second_admin); - - assert!(sim.apply_start_plot(9, "dana-ticket-zero")); - assert!(matches!( - sim.plot_runs.last(), - Some(run) - if run.target == 9 - && matches!(run.state, PlotState::WaitingForChoice { .. }) - )); - assert!(!sim.people.get(1).unwrap().leverage_serviced); - - sim.choose_plot(9, "dana-ticket-zero", "leave-two"); - assert!(sim.people.get(9).unwrap().leverage_serviced); - assert!(!sim.people.get(1).unwrap().leverage_serviced); - assert!( - sim.institutional_ledger - .events - .iter() - .all(|event| event.target == 9) - ); - } - - #[test] - fn target_relative_debt_plot_uses_a_second_persons_creditor() { - let mut sim = Sim::new(); - let mut second_custodian = sim.people.get(0).unwrap().clone(); - second_custodian.id = 9; - second_custodian.name = "Jordan Bell".into(); - second_custodian.knowledge = Knowledge::Leverage; - second_custodian.leverage_serviced = false; - sim.people.people.push(second_custodian); - sim.accounts.set_slush_balance(400); - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); - let context = sim.plot_context(9).unwrap(); - assert_eq!( - sim.plot_catalog() - .get("marcus-debt-settled") - .unwrap() - .ineligibility(&context) - .as_deref(), - Some("target creditor account unavailable") - ); - let creditor = sim - .accounts - .add_person_creditor(9, "North Loop Collections", 0); - - assert!(sim.apply_start_plot(9, "marcus-debt-settled")); - run(&mut sim, 2); - sim.choose_plot(9, "marcus-debt-settled", "quiet"); - - assert!(sim.people.get(9).unwrap().leverage_serviced); - assert!(!sim.people.get(0).unwrap().leverage_serviced); - assert_eq!(sim.accounts.account(creditor).unwrap().balance, 400); - assert_eq!(sim.accounts.slush_balance(), 0); - } - - #[test] - fn recovery_window_returns_the_utility_credit() { - let mut sim = Sim::new(); - sim.people.people[3].knowledge = Knowledge::Leverage; - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); - let utility = sim - .accounts - .account_id_by_kind(AccountKind::Utility) - .unwrap(); - let procurement = sim - .accounts - .account_id_by_kind(AccountKind::Procurement) - .unwrap(); - let procurement_before = sim.accounts.account(procurement).unwrap().balance; - - assert!(sim.apply_start_plot(3, "recovery-window")); - for _ in 0..Sim::DAY_TICKS { - if matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForChoice { .. }) - ) { - break; - } - sim.advance(); - } - - assert_eq!(sim.accounts.account(utility).unwrap().balance, 0); - assert_eq!( - sim.accounts.account(procurement).unwrap().balance, - procurement_before + 250 - ); - assert_eq!( - sim.accounts.ledger.last().unwrap().channel, - FlowChannel::Utility - ); - } - - #[test] - fn duplicate_plot_reservoirs_do_not_open_competing_runs() { - let mut sim = Sim::with_seed(25); - reveal_marcus_debt(&mut sim); - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); - sim.accounts.set_slush_balance(400); - sim.sync_player_money_from_slush(); - - sim.start_plot(0, "marcus-debt-settled"); - let before_duplicate = sim.thought_sinks.open_sinks().count(); - sim.start_plot(0, "marcus-payroll-garnishment"); - assert_eq!( - sim.thought_sinks.open_sinks().count(), - before_duplicate, - "a person can have only one pending route, across plot ids" - ); - let open_plots = sim - .thought_sinks - .open_sinks() - .filter(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { person: 0, .. })) - .count(); - assert_eq!(open_plots, 1); - - finish_ops(&mut sim); - - assert_eq!(sim.plot_runs.len(), 1); - assert_eq!(sim.plot_runs[0].plot_id, "marcus-debt-settled"); - assert!(matches!( - sim.plot_runs[0].state, - PlotState::WaitingForMessage { .. } - )); - assert_eq!( - sim.messages - .iter() - .filter(|message| matches!( - &message.payload, - MessagePayload::PlotAct { plot_id, target } - if plot_id == "marcus-debt-settled" && *target == 0 - )) - .count(), - 1, - "a duplicate reservoir cannot open a second plot carrier" - ); - } - - #[test] - fn plot_message_transfer_event_and_held_choice_survive_save_load() { - let mut sim = Sim::with_seed(23); - ensure_ops_executor(&mut sim); - sim.people.people[3].knowledge = Knowledge::Leverage; - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); - sim.accounts.set_slush_balance(300); - sim.sync_player_money_from_slush(); - - sim.start_plot(3, "priya-budget-hero"); - finish_ops(&mut sim); - assert!(matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForMessage { .. }) - )); - assert!(sim.messages.iter().any(|message| matches!( - &message.payload, - MessagePayload::PlotAct { plot_id, target } - if plot_id == "priya-budget-hero" && *target == 3 - ))); - assert!( - sim.detection - .pending() - .iter() - .any(|signature| signature.kind == SignatureKind::Network) - ); - - let state = sim.create_save_state(); - let encoded = serde_json::to_string(&state).unwrap(); - let state: SaveState = serde_json::from_str(&encoded).unwrap(); - let mut restored = Sim::with_seed(99); - restored.apply_save_state(state); - assert!(matches!( - restored.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForMessage { .. }) - )); - - for _ in 0..Sim::DAY_TICKS { - if matches!( - restored.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForChoice { .. }) - ) { - break; - } - restored.advance(); - } - assert!(matches!( - restored.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForChoice { .. }) - )); - assert_eq!(restored.accounts.slush_balance(), 0); - let procurement = restored - .accounts - .account_id_by_kind(AccountKind::Procurement) - .and_then(|id| restored.accounts.account(id)) - .unwrap(); - assert_eq!(procurement.balance, 900); - assert_eq!( - restored.accounts.ledger.last().unwrap().channel, - FlowChannel::Procurement - ); - assert_eq!(restored.institutional_ledger.events.len(), 1); - assert_eq!( - restored.institutional_ledger.events[0].signature_kind, - SignatureKind::Financial - ); - - restored.choose_plot(3, "priya-budget-hero", "priya"); - assert!(restored.people.get(3).unwrap().leverage_serviced); - assert!(matches!( - restored.plot_runs.last().map(|run| &run.state), - Some(PlotState::Completed { ending_id }) if ending_id == "obligation" - )); - } - - #[test] - fn plot_fails_into_its_authored_ending_when_committed_money_disappears() { - let mut sim = Sim::with_seed(24); - ensure_ops_executor(&mut sim); - sim.people.people[3].knowledge = Knowledge::Leverage; - sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); - sim.accounts.set_slush_balance(300); - sim.sync_player_money_from_slush(); - - sim.start_plot(3, "priya-budget-hero"); - finish_ops(&mut sim); - assert!(matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::WaitingForMessage { .. }) - )); - - // Entry resources are eligibility, not escrow. Another world action - // can consume them while a scheduled message is in flight. - sim.accounts.set_slush_balance(0); - sim.sync_player_money_from_slush(); - for _ in 0..Sim::DAY_TICKS { - if matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::Failed { .. }) - ) { - break; - } - sim.advance(); - } - - assert!(matches!( - sim.plot_runs.last().map(|run| &run.state), - Some(PlotState::Failed { ending_id, reason }) - if ending_id == "audit" && reason.contains("$300 required") - )); - assert_eq!(sim.people.get(3).unwrap().disposition, -5); - assert!(!sim.people.get(3).unwrap().leverage_serviced); - assert!(sim.institutional_ledger.events.is_empty()); - } - - #[test] - fn marcus_debt_payoff_and_recruitment_require_debt_intel() { - let mut sim = Sim::new(); - let (accounts, flows) = sim.accounts.financial_snapshot_ids(); - sim.accounts.reveal_accounts_and_flows(&accounts, &flows); - assert!( - sim.accounts - .known_flows() - .any(|f| f.active && f.label.contains("Marcus creditor")), - "the ledger route is visible, but the person leverage is not" - ); - assert!(!sim.marcus_debt_known()); - - sim.start_plot(0, "marcus-payroll-garnishment"); - assert!( - sim.plot_runs.is_empty(), - "known books alone cannot start an unearned plot" - ); - assert!(!sim.people.get(0).unwrap().leverage_serviced); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("required leverage knowledge"), - "the refusal names the missing intel: {log}" - ); - - sim.people.people[0].leverage_serviced = true; - sim.recruit(0, AssetKnowledge::Complicit); - assert!( - sim.people.get(0).unwrap().asset.is_none(), - "Marcus cannot be recruited from a serviced flag without earned debt intel" - ); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("learn his debt"), - "the recruit refusal points back to the Hands beat: {log}" - ); - - reveal_marcus_debt(&mut sim); - sim.recruit(0, AssetKnowledge::Complicit); - assert!( - sim.people.get(0).unwrap().asset.is_some(), - "once the debt is learned and serviced, recruitment can close" - ); - } - - #[test] - fn marcus_arc_end_to_end() { - // Act One's route: feed coverage -> process the debt call -> - // clear it -> recruit -> he works for you (spec/social.md acceptance 3). - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.player.money = 1000; - let env = env_id(&sim); - sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call - sim.reach.tap(env); - sim.recompute_senses(); - sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; - sim.advance(); - - let mut reviews = 0; - while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(); - finish_ops(&mut sim); - reviews += 1; - assert!(reviews <= 5, "Marcus's debt call should process promptly"); - } - assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); - - sim.accounts.set_slush_balance(1000); - sim.sync_player_money_from_slush(); - complete_marcus_cash_plot(&mut sim); - assert!(sim.people.get(0).unwrap().leverage_serviced); - assert_eq!(sim.player.money, 600); - - sim.recruit(0, AssetKnowledge::Complicit); - assert!(sim.people.get(0).unwrap().asset.is_some()); - - // Make him reliable for the test, then run all three tasks. - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - - // Task 1: wire a device -> a feed comes to you, silently. - let pending_before = sim.detection.pending_size(); - sim.asset_task(0, AssetTask::PlugInDevice); - finish_ops(&mut sim); - assert_eq!(sim.detection.pending_size(), pending_before, "no signature"); - - // Task 2: move a package -> next purchase is paper-free. - sim.asset_task(0, AssetTask::MovePackage); - finish_ops(&mut sim); - assert!(sim.package_cover); - let pending_before = sim.detection.pending_size(); - assert!(sim.buy_rack()); - assert_eq!( - sim.detection.pending_size(), - pending_before, - "off-books delivery" - ); - assert!(!sim.package_cover, "cover is consumed"); - - // Task 3: look away -> his own suspicion drops (floor-respecting). - if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { - o.suspicion = 20.0; - } - sim.asset_task(0, AssetTask::LookAway); - finish_ops(&mut sim); - let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); - assert_eq!(o.suspicion, 10.0); - - assert_eq!( - sim.people - .get(0) - .unwrap() - .asset - .as_ref() - .unwrap() - .tasks_done, - 3 - ); - } - - // ── Per-AssetTask pins (social.md criterion 3, ROADMAP #28) ──────────── - - /// The recruit-route shorthand shared by the per-task pins: service the - /// leverage gate, recruit complicit, and pin reliability so the task - /// roll can't botch. - fn recruit_reliable(sim: &mut Sim, id: u8) { - ensure_ops_executor(sim); - sim.people.people[id as usize].leverage_serviced = true; - sim.people.recruit(id, AssetKnowledge::Complicit); - sim.people.people[id as usize] - .asset - .as_mut() - .unwrap() - .reliability = 1.0; - } - - fn tasks_done(sim: &Sim, id: u8) -> u32 { - sim.people - .get(id) - .unwrap() - .asset - .as_ref() - .unwrap() - .tasks_done - } - - #[test] - fn asset_task_plug_in_device_wires_a_known_feed_silently() { - // PlugInDevice's distinct effect: a known sensing device not yet - // feeding you gets tapped through the crawlspace — the feed - // arrives with no signature on any channel. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - recruit_reliable(&mut sim, 0); - // Mirror the implementation's choice: the first known feed that - // does not fully reach the player yet. - let target = sim - .reach - .devices - .iter() - .find(|d| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); - d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) - }) - .map(|d| d.id) - .expect("a known unwired feed exists after the scan"); - let pending = sim.detection.pending_size(); - - sim.asset_task(0, AssetTask::PlugInDevice); - finish_ops(&mut sim); - - let d = sim.reach.device(target).unwrap(); - assert!( - (!d.sees || d.feed_to(Party::Player, true)) - && (!d.hears || d.feed_to(Party::Player, false)), - "the feed reaches the player now" - ); - assert_eq!( - sim.detection.pending_size(), - pending, - "the crawlspace route emits nothing" - ); - assert_eq!(tasks_done(&sim, 0), 1); - } - - #[test] - fn asset_task_move_package_launders_the_next_purchase() { - // MovePackage's distinct effect: the next purchase arrives - // off-books — no Paper signature — and the cover is consumed. - let mut sim = Sim::new(); - sim.player.money = 1000; - recruit_reliable(&mut sim, 0); - assert!(!sim.package_cover); - - sim.asset_task(0, AssetTask::MovePackage); - finish_ops(&mut sim); - assert!(sim.package_cover, "the delivery cover is armed"); - assert_eq!(tasks_done(&sim, 0), 1); - - let pending = sim.detection.pending_size(); - assert!(sim.buy_rack()); - assert_eq!( - sim.detection.pending_size(), - pending, - "the covered purchase leaves no paper trail" - ); - assert!(!sim.package_cover, "one delivery per favor"); - - assert!(sim.buy_rack()); - assert!( - sim.detection.pending_size() > pending, - "the next, uncovered purchase emits Paper again" - ); - } - - #[test] - fn asset_task_look_away_drops_the_assets_own_suspicion() { - // LookAway's distinct effect: the asset's own observer suspicion - // falls by ten points, clamped at their certainty floor. - let mut sim = Sim::new(); - recruit_reliable(&mut sim, 0); - let floor = sim - .detection - .observers - .iter() - .find(|o| o.id == 0) - .unwrap() - .floor; - if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { - o.suspicion = 25.0; - } - - sim.asset_task(0, AssetTask::LookAway); - finish_ops(&mut sim); - let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); - assert_eq!(o.suspicion, 15.0, "the asset shaves ten points"); - assert_eq!(tasks_done(&sim, 0), 1); - - if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { - o.suspicion = floor + 2.0; - } - sim.asset_task(0, AssetTask::LookAway); - finish_ops(&mut sim); - let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); - assert_eq!(o.suspicion, floor, "the drop clamps at the certainty floor"); - } - - #[test] - fn asset_task_reconfigure_switch_gated_on_admin_and_costless_when_refused() { - // ReconfigureSwitch's distinct effect (segments open, no Network - // signature) is pinned by danas_social_route_bridges_without_ - // network_signature; this pins the access gate mechanics: a - // non-admin asset is refused before any Demand is authored or a - // task is counted, and the admin's run is bookkept as a task. - let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); - sim.scan_network(); - finish_ops(&mut sim); - let dock = sim.reach.device_named("dock camera").unwrap().id; - assert!(!sim.reach.reachable(dock), "security segment starts closed"); - - recruit_reliable(&mut sim, 0); // Marcus: no switch admin - sim.drain_log(); - sim.asset_task(0, AssetTask::ReconfigureSwitch); - assert!(!sim.reach.reachable(dock), "refused: nothing opened"); - assert_eq!(tasks_done(&sim, 0), 0, "a refused task is not counted"); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("switch admin"), - "the refusal names the missing access: {log}" - ); - - recruit_reliable(&mut sim, 1); // Dana: IT, switch admin - sim.asset_task(1, AssetTask::ReconfigureSwitch); - finish_ops(&mut sim); - assert!( - sim.reach.reachable(dock), - "the admin route opens the segment" - ); - assert_eq!(tasks_done(&sim, 1), 1); - } - - #[test] - fn think_mode_flows_the_camera_tap_and_scan_reservoirs() { - // With no Thought delivered, opened reservoirs do not fire. With a - // Think rack, minted Thought fills the physically anchored sink and - // lands the world effect. - let mut sim = Sim::new(); - let env = env_id(&sim); - assert!( - !sim.tap_device(env), - "no bootstrap: the pre-opened Ears tap must fill from thought" - ); - assert!( - sim.reach.device(env).unwrap().camera_dormant, - "the camera stays dormant until something real pays for it" - ); - - let mut sim = Sim::new(); - let env = env_id(&sim); - // Fresh dormant camera for the queued path. - assert!( - sim.reach.device(env).unwrap().camera_dormant, - "env cam starts dormant" - ); - let (hx, hy) = sim.core_position(); - let thinker = sim - .compute - .add_machine("think rack", hx, hy, 200, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(thinker, MachineMode::Think); - run(&mut sim, ECONOMY_INTERVAL); - assert!(sim.last_think_rate > 0.0, "the THINK channel mints thought"); - // The opening senses ride the pre-opened sinks: minted thought fills - // EARS, its fire opens EYES, and the same flow taps the camera — - // no docket, no separate spend. - for _ in 0..ECONOMY_INTERVAL * 400 { - if !sim.reach.device(env).unwrap().camera_dormant { - break; - } - sim.advance(); - } - assert!( - !sim.reach.device(env).unwrap().camera_dormant, - "thought flow completes the camera tap through the staged sinks; tick={} sinks={:?} queues={:?}", - sim.tick, - sim.sink_readouts(), - sim.work_grid.queue_snapshot() - ); - assert!(sim.scan_network(), "opens a scan reservoir at the switch"); - assert!( - sim.thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), - "the scan waits as a Thought reservoir" - ); - for _ in 0..ECONOMY_INTERVAL * 40 { - if !sim - .thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) - { - break; - } - sim.advance(); - } - assert!( - !sim.thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), - "the pulse filled the scan reservoir" - ); - assert!(sim.reach.known().any(|device| device.name == "dock camera")); - } - - #[test] - fn opening_senses_and_later_work_ride_physical_sinks() { - // The bootstrap carve-out is retired (resolved 2026-07-10): the - // staged senses are pre-opened thought sinks, and later authored work - // opens a physical reservoir instead of a queue. - let mut sim = Sim::new(); - let env = env_id(&sim); - assert!( - !sim.tap_device(env), - "no instant Ears: the pre-opened sink already flows" - ); - assert!(sim.scan_network(), "later work opens a physical reservoir"); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("a thought sink is already open"), - "the blocked tap teaches the flow model: {log}" - ); - assert!( - log.contains("Thought reservoir opened: SCAN"), - "the scan names the opened reservoir: {log}" - ); - - // Eyes has no open sink yet (Ears first), so the same TAP command - // remains on the blocked audio step — the senses arrive by thinking. - assert!(!sim.tap_device(env), "no instant Eyes either"); - } - - #[test] - fn duplicate_sink_payload_is_rejected_without_more_reservoirs() { - let mut sim = Sim::new(); - let env = env_id(&sim); - sim.reach.tap(env); - sim.recompute_senses(); - assert!(sim.tap_device(env)); - let open = sim.thought_sinks.open_sinks().count(); - assert!(!sim.tap_device(env), "same world effect cannot queue twice"); - assert_eq!(sim.thought_sinks.open_sinks().count(), open); - } - - #[test] - fn scan_reservoir_waits_for_thought_then_fires() { - let mut sim = Sim::new(); - complete_opening_senses(&mut sim); - assert!(sim.scan_network()); - let sink = sim - .thought_sinks - .open_sinks() - .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) - .cloned() - .expect("scan reservoir opens"); - let before = sink.fill; - sim.advance_work_grid(); - let after = sim.thought_sinks.get(sink.id).unwrap().fill; - assert_eq!(after, before, "starved reservoirs keep their partial fill"); - - finish_ops(&mut sim); - assert!(sim.reach.known().any(|device| device.name == "dock camera")); - } - - #[test] - fn scan_reservoir_anchors_on_the_switch() { - let mut sim = Sim::new(); - let switch = sim - .reach - .devices - .iter() - .find(|device| device.is_switch) - .unwrap() - .id; - assert!(sim.scan_network()); - let scan = sim - .thought_sinks - .open_sinks() - .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) - .expect("scan queued"); - assert_eq!( - scan.node, - Sim::device_sink_node(switch), - "network scan is anchored on the actual switch" - ); - } - - #[test] - fn completed_reservoir_names_a_target_that_became_invalid() { - let mut sim = Sim::new(); - let env = env_id(&sim); - sim.reach.tap(env); - sim.recompute_senses(); - assert!(sim.tap_device(env)); - sim.reach.tap_dormant_camera(env); // another event wins the race - sim.drain_log(); - finish_ops(&mut sim); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("target no longer accepts") && log.contains("thought was spent"), - "stale completion is causal rather than silent: {log}" - ); - } - - #[test] - fn repeatable_social_fires_do_not_accumulate_closed_history() { - let mut sim = Sim::new(); - let node = sim.core.host_machine; - let baseline = sim.thought_sinks.sinks().len(); - - for i in 0..100 { - let effect = if i % 2 == 0 { - SinkFireEffect::ComposeMessage { person: u8::MAX } - } else { - SinkFireEffect::Favor { person: u8::MAX } - }; - sim.thought_sinks - .open_reservoir(node, "REPEATABLE SOCIAL", 1.0, effect); - sim.pour_thought_into_sinks(node, 1.0); - assert_eq!( - sim.thought_sinks.sinks().len(), - baseline, - "completed one-shots are reaped after effect application" - ); - } - } - - #[test] - fn going_loud_starts_containment() { - let mut sim = Sim::new(); - sim.detection.go_loud("player forced the roll door"); - run(&mut sim, 1); - assert!(sim.game_over); - } - - // ── The day job is somewhere (day-job.md criteria 6-7) ────────────────── - - #[test] - fn day_job_signatures_emit_from_the_host_rack() { - // Criterion 6: JobAnomaly and the running Thermal/Power load all - // source at the host rack's tile — a place an observer can walk to. - let mut sim = Sim::with_seed(21); - let host = sim.core_position(); - - // Big rig + everything on the day job: a hot delivered rate. - let rig = - sim.compute - .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(rig, MachineMode::Work); - delegate_all(&mut sim, MachineMode::Work); - - // Run until a job is active and past an economy tick. - while sim.dayjob.active.is_none() { - sim.advance(); - } - run(&mut sim, ECONOMY_INTERVAL); - - let standing = sim.day_job_standing_signatures(); - assert!( - standing.iter().any(|s| s.kind == SignatureKind::Thermal), - "a hot job stands a Thermal signature (rate {:.1})", - sim.day_job_rate() - ); - assert!( - standing.iter().any(|s| s.kind == SignatureKind::Power), - "and a Power one" - ); - assert!( - standing.iter().all(|s| s.site == Some(host)), - "day-job standing emissions source at the host rack {host:?}" - ); - - // Resolve an under-band job on the next tick: the JobAnomaly enters - // the pending pool after this tick's observer pass, so its site is - // directly inspectable. - delegate_all(&mut sim, MachineMode::Think); // starve day-job: under band - let now = sim.tick; - let job = sim.dayjob.active.as_mut().unwrap(); - job.started = now; - job.deadline = now + 1; - job.band_lo = 100.0; - job.band_hi = 110.0; - job.quality = 0.0; - sim.dayjob.next_assign = u64::MAX; - sim.advance(); - let anomaly_sites: Vec> = sim - .detection - .pending() - .iter() - .filter(|s| s.kind == SignatureKind::JobAnomaly) - .map(|s| s.site) - .collect(); - assert!( - !anomaly_sites.is_empty(), - "the under-band resolution emitted a JobAnomaly signature" - ); - assert!( - anomaly_sites.iter().all(|site| *site == Some(host)), - "JobAnomaly emits from the host rack: {anomaly_sites:?}" - ); - } - - #[test] - fn active_job_is_inspectable_at_the_host_rack() { - // Criterion 6: the same facts the panel shows — process, band, - // delivered rate, deadline, intensity — anchored where the work - // runs, earned as telemetry (cursor.md: a machine you run reports - // its own current job). - let mut sim = Sim::with_seed(5); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let (x, y) = sim.core_position(); - let card = sim.inspect(x, y); - for label in ["process", "band", "delivered", "deadline", "intensity"] { - assert!( - card.facts - .iter() - .any(|f| f.label == label && f.source == FactSource::Telemetry), - "host-rack card carries '{label}' as telemetry: {card:?}" - ); - } - let job = sim.dayjob.active.as_ref().unwrap(); - assert!( - card.facts - .iter() - .any(|f| f.label == "process" && f.value.contains(job.kind.name())), - "the resident process names the job the panel names" - ); - // And an empty rack elsewhere carries no job facts. - let other = sim - .compute - .machines - .iter() - .find(|m| m.id != sim.core.host_machine) - .map(|m| (m.x, m.y)); - if let Some((ox, oy)) = other { - let other_card = sim.inspect(ox, oy); - assert!( - other_card.facts.iter().all(|f| f.label != "process"), - "the job is resident on the host rack only" - ); - } - } - - #[test] - fn machine_intensity_is_the_persistent_effort_control() { - let mut sim = Sim::with_seed(8); - let host = sim.core.host_machine; - assert_eq!( - sim.work_grid.intensity(host), - Some(MachineIntensity::Medium) - ); - let medium = sim.effective_compute(); - sim.set_machine_intensity(host, MachineIntensity::Light); - let light = sim.effective_compute(); - sim.set_machine_intensity(host, MachineIntensity::Hard); - let hard = sim.effective_compute(); - assert!( - light < medium && medium < hard, - "{light} < {medium} < {hard}" - ); - } - - #[test] - fn think_production_starts_on_the_first_tick_after_delegation() { - // machine-work.md's first-think beat is immediate: changing the mode - // between economy pulses cannot leave the new THINK rack waiting for - // the next twenty-tick allocation refresh. - let mut sim = Sim::with_seed(40); - let host = sim.core.host_machine; - - assert_eq!(sim.research_rate(), 0.0); - sim.set_machine_mode(host, MachineMode::Think); - sim.advance(); - - assert!(sim.research_rate() > 0.0); - assert_eq!(sim.work_productions().len(), 1); - let produced = sim.work_productions()[0]; - assert_eq!(produced.node, host); - assert!(produced.thought > 0.0); - assert!(produced.exposure > 0.0); - } - - #[test] - fn think_sheds_exposure_and_lie_reports_the_actual_physical_draw() { - // machine-work.md criterion 3: THINK produces one visible substance - // and sheds crimson in proportion; LIE removes that crimson by world - // radius. Renderers receive the exact source/well work, never a queue - // snapshot difference. - let mut sim = Sim::with_seed(41); - let host = sim.core.host_machine; - let (hx, hy) = sim.core_position(); - sim.set_machine_mode(host, MachineMode::Think); - let well = - sim.compute - .add_machine("test lie well", hx + 1, hy, 100, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(well, MachineMode::Lie); - - // One visible Thought token per tick at this rate. Production happens - // before routing/absorption in the same work-grid step. - sim.last_think_rate = Sim::WORK_TOKEN_COMPUTE; - sim.advance_work_grid(); - - assert_eq!(sim.work_productions().len(), 1); - let produced = sim.work_productions()[0]; - assert_eq!(produced.node, host); - assert!((produced.thought - 1.0).abs() < 1e-5); - assert!( - (produced.exposure - Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN).abs() < 1e-5, - "crimson scales from the exact Thought output: {produced:?}" - ); - - assert_eq!(sim.work_absorptions().len(), 1); - let draw = sim.work_absorptions()[0]; - assert_eq!((draw.source, draw.well), (host, well)); - assert_eq!((draw.source_x, draw.source_y), (hx, hy)); - assert_eq!((draw.well_x, draw.well_y), (hx + 1, hy)); - assert!((draw.amount - Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN).abs() < 1e-5); - assert!( - sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, - "a nearby LIE well clears this baseline THINK pulse in one tick" - ); - - sim.last_think_rate = 0.0; - sim.compute - .machines - .iter_mut() - .find(|machine| machine.id == well) - .unwrap() - .online = false; - sim.reconcile_work_grid(); - sim.advance_work_grid(); - assert!( - sim.work_absorptions().is_empty(), - "an offline LIE chassis cannot pull or animate crimson" - ); - - sim.work_grid - .enqueue(host, TokenFamily::Exposure, 5.0) - .unwrap(); - sim.set_machine_mode(host, MachineMode::Lie); - sim.advance_work_grid(); - assert!( - sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, - "switching the dirty source itself to LIE scrubs its pile next tick" - ); - assert!(sim.work_absorptions().iter().any(|draw| { - draw.source == host && draw.well == host && (draw.amount - 5.0).abs() < 1e-5 - })); - } - - #[test] - fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { - // machine-work.md: Voss desktop enqueues demand; it routes through the - // switch onto Rack 3; day-job mode consumes the stack and sheds no - // exposure (WORK is clean — criterion 3 / 9, 2026-07-09). - let mut sim = Sim::with_seed(12); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let host = sim.core.host_machine; - assert!( - sim.work_grid - .queue(Sim::VOSS_DESKTOP_NODE, TokenFamily::Demand) - > 0.0, - "the assigned job originates on Voss's desktop" - ); - assert!( - sim.work_grid - .are_linked(Sim::VOSS_DESKTOP_NODE, Sim::SWITCH_WORK_NODE), - "desktop links through the switch" - ); - assert!( - sim.work_grid.are_linked(Sim::SWITCH_WORK_NODE, host), - "switch links to the host sink" - ); - - // Two hops at the base wired-token rate — wait until the inbox - // has landed on Rack 3 rather than asserting the assignment tick. - let mut landed = 0.0; - for _ in 0..400 { - sim.advance(); - landed = sim.work_grid.queue(host, TokenFamily::Demand); - if landed > 0.5 { - break; - } - } - assert!(landed > 0.5, "demand deposits on Rack 3 via the wire"); - assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Work)); - - // Let the wire drain so host consumption is measurable without - // fresh arrivals masking the clear. - for _ in 0..800 { - let upstream = sim - .work_grid - .queue(Sim::VOSS_DESKTOP_NODE, TokenFamily::Demand) - + sim - .work_grid - .queue(Sim::SWITCH_WORK_NODE, TokenFamily::Demand); - if upstream < 0.01 { - break; - } - sim.advance(); - } - let before = sim.work_grid.queue(host, TokenFamily::Demand); - assert!( - before > 0.0, - "inbox still holds demand after the wire drains" - ); - let mut saw_consumption = false; - for _ in 0..80 { - sim.advance(); - saw_consumption |= sim.work_consumptions().iter().any(|event| { - event.node == host - && event.family == TokenFamily::Demand - && event.target == WorkConsumptionTarget::Machine - && event.amount > 0.0 - }); - } - let later = sim.work_grid.queue(host, TokenFamily::Demand); - assert!(later < before, "day-job mode consumes the stack"); - assert!( - saw_consumption, - "the sim exposes a Demand-into-chassis animation event" - ); - assert!( - sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, - "WORK sheds zero exposure — the compliant floor stays clean" - ); - let card = sim.inspect(sim.core_position().0, sim.core_position().1); - assert!( - card.facts.iter().any(|f| f.label == "tokens"), - "inspect exposes exact D/!/T token counts" - ); - } - - #[test] - fn delegating_the_host_off_day_job_makes_work_pile() { - let mut sim = Sim::with_seed(13); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let host = sim.core.host_machine; - for _ in 0..400 { - if sim.work_grid.queue(host, TokenFamily::Demand) > 0.5 { - break; - } - sim.advance(); - } - sim.set_machine_mode(host, MachineMode::Think); - let before = sim.work_grid.queue(host, TokenFamily::Demand); - - for _ in 0..ECONOMY_INTERVAL * 2 { - sim.advance(); - } - - assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Think)); - assert_eq!(sim.day_job_rate(), 0.0, "the resident job is unfed"); - assert!( - sim.work_grid.queue(host, TokenFamily::Demand) >= before, - "off-mode demand does not silently clear" - ); - } - - #[test] - fn research_allocation_produces_visible_thought_on_the_work_graph() { - // machine-work.md criterion 4/5 live slice: research throughput now - // has a WorkGrid token trail. A research-delegated non-host rack - // produces bone, and the graph routes it toward the core instead of a - // frontend inventing a counter. - let mut sim = Sim::with_seed(15); - complete_opening_senses(&mut sim); // no opening sink intercepts the core - let (x, y) = sim.empty_rack_bay(); - let rack = sim - .compute - .add_machine("research rack", x, y, 1000, 1.0, 4, Provenance::Owned); - sim.add_machine_to_work_grid(rack, MachineMode::Think); - assert_eq!(sim.work_grid.mode(rack), Some(MachineMode::Think)); - assert!( - sim.work_grid.are_linked(rack, sim.core.host_machine), - "new work machines join the core's machine-work graph" - ); - - sim.enqueue_thought_output(100.0); - let produced = sim.work_grid.queue(rack, TokenFamily::Thought); - assert!( - produced > 0.0, - "research mode leaves visible thought queued on its producing machine" - ); - - sim.advance_work_grid(); - - assert!( - sim.work_grid.queue(rack, TokenFamily::Thought) < produced, - "the machine-work graph routes thought toward the core" - ); - assert_eq!( - sim.work_grid - .queue(sim.core.host_machine, TokenFamily::Thought), - 0.0, - "the core is a sink: delivered bone is counted, not stacked forever" - ); - } - - #[test] - fn work_grid_mode_and_queues_round_trip_through_save_state() { - let mut sim = Sim::with_seed(14); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let host = sim.core.host_machine; - sim.set_machine_mode(host, MachineMode::Lie); - sim.work_grid - .enqueue(host, TokenFamily::Thought, 2.5) - .unwrap(); - - let state = SaveState::from_sim(&sim); - let mut restored = Sim::with_seed(0); - state.apply_to(&mut restored); - - assert_eq!(restored.work_grid.mode(host), Some(MachineMode::Lie)); - assert_eq!(restored.work_grid.queue(host, TokenFamily::Thought), 2.5); - } - - #[test] - fn pilot_shutdown_ends_the_run() { - // Criterion 3 at the sim level: the pilot clock's failure is a - // real shutdown, surfaced as the run's end state. - let mut sim = Sim::with_seed(2); - sim.dayjob.strikes = 4; - sim.advance(); - assert!(sim.game_over, "an unrenewed pilot shuts the basement down"); - assert!( - sim.game_over_reason - .as_deref() - .unwrap_or("") - .contains("pilot"), - "the reason names the pilot: {:?}", - sim.game_over_reason - ); - } - - #[test] - fn meeting_the_band_needs_no_growth_at_the_start() { - // day-job.md: "the invisible middle is a valid, boring, safe - // strategy" — true from tick one with only the starting Rack 3, at - // the default allocation, zero reallocation and zero attention. - // 2026-07-08 playtest finding #1: an un-tuned band base (6.0) made - // this impossible — the starting machine's every allocation topped - // out under band, so the first job was an unavoidable sandbag no - // matter what the player did, contradicting this claim. - let mut sim = Sim::with_seed(11); - while sim.dayjob.active.is_none() { - sim.advance(); - } - let mut log = Vec::new(); - while sim.dayjob.active.is_some() && !sim.game_over { - log.extend(sim.drain_log()); - sim.advance(); - } - log.extend(sim.drain_log()); - assert!( - log.iter().any(|l| l.contains("Job met")), - "the starting Rack 3 at default allocation, hands-off, should \ - meet the first job's band: {log:?}" - ); - assert_eq!(sim.dayjob.strikes, 0, "meeting the band is not a strike"); - } - - #[test] - fn paper_and_financial_acts_pool_signatures_priya_notices() { - // detection.md criterion 1's remaining channels: an uncovered rack - // purchase pools Paper, a purchase-order injection pools Financial, - // and Priya (Paper+Financial watcher) converts them on her cadence - // when concealment is starved. - let mut sim = Sim::with_seed(42); - delegate_all(&mut sim, MachineMode::Work); - sim.accounts.credit_slush(0, 400, "test grant", 0); - sim.sync_player_money_from_slush(); - - assert!(sim.buy_rack(), "slush covers the rack"); - assert!( - sim.detection - .pending() - .iter() - .any(|s| s.kind == SignatureKind::Paper), - "an uncovered purchase pools a Paper signature" - ); - assert!(sim.inject_purchase_order(200, "test PO")); - assert!( - sim.detection - .pending() - .iter() - .any(|s| s.kind == SignatureKind::Financial), - "an injected PO pools a Financial signature" - ); - - let priya_before = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - run(&mut sim, 200); // past Priya's cadence-80 rolls - let priya_after = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - assert!( - priya_after > priya_before, - "Priya converts pooled Paper/Financial signatures to suspicion" - ); - } - - #[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); - ensure_ops_executor(&mut sim); - sim.detection - .observers - .iter_mut() - .find(|observer| observer.id == 1) - .expect("IT observer") - .suspicion = 14.0; - assert!(sim.open_egress()); - finish_ops(&mut sim); - sim.drain_log(); - run(&mut sim, 60); - let log = sim.drain_log().join("\n"); - assert!( - log.contains("the IT noticed Network") && log.contains("stolen egress opening"), - "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 - // never goes blank mid-act; each rung uses only earned knowledge - // and hands off to the next as the player takes it. - let mut sim = Sim::with_seed(40); - let ops = ensure_ops_executor(&mut sim); - sim.compute - .machines - .iter_mut() - .find(|machine| machine.id == ops) - .unwrap() - .capacity = 100; - sim.reconcile_work_grid(); - assert_eq!(sim.current_nudge(), Some(Nudge::Ears), "deaf start"); - - // Ears first: tap the env monitor audio. Hearing without sight -> - // Eyes next, pointing at the higher-cost dormant camera tap. - complete_opening_stage(&mut sim); - assert_eq!(sim.current_nudge(), Some(Nudge::Eyes)); - complete_opening_stage(&mut sim); - - // Marcus's 3 a.m. creditor call lands on the tapped feed (day one, - // ~tick 50); an unprocessed recording of him nudges the review. - run(&mut sim, 60); - assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); - let mut reviews = 0; - while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(); - finish_ops(&mut sim); - reviews += 1; - assert!( - reviews <= 10, - "the call is in the buffer; taps={:?}", - sim.sink_readouts() - ); - } - - // Leverage known, $0 slush, books unread, no egress: the route out. - assert_eq!(sim.current_nudge(), Some(Nudge::Egress)); - assert!(sim.open_egress()); - finish_ops(&mut sim); - - // Egress up and the Lab operating account reachable: the authored - // payroll-correction route can service the leverage without seed cash. - assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); - sim.start_plot(0, "marcus-payroll-garnishment"); - finish_ops(&mut sim); - run(&mut sim, 2); - sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); - assert!(sim.people.get(0).unwrap().leverage_serviced); - assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); - sim.recruit(0, AssetKnowledge::Complicit); - assert!(sim.people.get(0).unwrap().asset.is_some()); - - // Marcus the asset carries a tier-3 key you don't hold: quiet-exit - // condition 4 is the next earned-but-untaken rung. - assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); - sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; - sim.asset_task(0, AssetTask::CloneBadge); - finish_ops(&mut sim); - assert!(sim.holds_badge_tier(3)); - 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 - // ceiling nudges compute growth; a reachable floor nudges allocation. - let mut sim = Sim::with_seed(41); - give_eyes(&mut sim); - run(&mut sim, 250); - assert!(sim.dayjob.active.is_some(), "first job is live"); - let ceiling = sim.day_job_rate_ceiling(); - assert!( - (ceiling - 4.0).abs() < 0.5, - "starting rig ceiling ~4/t (got {ceiling})" - ); - sim.dayjob.active.as_mut().unwrap().band_lo = ceiling + 1.0; - assert_eq!(sim.current_nudge(), Some(Nudge::NeedCompute)); - // Split the fleet so day-job delivery falls below a still-reachable - // band (one-machine-one-mode: underfeeding is a delegation problem). - let (hx, hy) = sim.core_position(); - let split = sim - .compute - .add_machine("split", hx, hy, 100, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(split, MachineMode::Think); - run(&mut sim, ECONOMY_INTERVAL); - let rate = sim.day_job_rate(); - let new_ceiling = sim.day_job_rate_ceiling(); - assert!( - rate + 0.05 < new_ceiling, - "half the fleet on research still leaves headroom (rate {rate}, ceiling {new_ceiling})" - ); - sim.dayjob.active.as_mut().unwrap().band_lo = rate + 0.5; - assert!( - sim.dayjob.active.as_ref().unwrap().band_lo <= new_ceiling + 0.05, - "band stays reachable by reallocating back to day-job" - ); - // Drop any taped call so ReviewCall cannot outrank the cover nudge. - sim.intel_buffer.clear(); - assert_eq!( - sim.current_nudge(), - Some(Nudge::Underfed), - "reachable band nudges delegation, not hardware" - ); - } - - #[test] - fn attention_escalation_adds_the_upstairs_observer() { - // Criterion 2 at the sim level: the second escalation adds a new - // observer to detection ahead of schedule. - let mut sim = Sim::with_seed(3); - let observers_before = sim.detection.observers.len(); - sim.dayjob.attention = 65.0; - sim.advance(); - assert_eq!(sim.detection.observers.len(), observers_before + 1); - assert!( - sim.detection - .observers - .iter() - .any(|o| o.name.contains("Compliance")), - "the upstairs review is a real observer" - ); - } - - #[test] - fn day_job_state_round_trips_jobs_and_the_pilot_clock() { - // Criterion 5: jobs, trust, attention, strikes, and the pilot clock - // survive save/load. Machine intensity lives in WorkGrid. - let mut sim = Sim::with_seed(31); - while sim.dayjob.active.is_none() { - sim.advance(); - } - sim.set_machine_intensity(sim.core.host_machine, MachineIntensity::Light); - sim.dayjob.trust = 12.0; - sim.dayjob.attention = 7.0; - sim.dayjob.strikes = 2; - - let state = sim.create_save_state(); - let mut restored = Sim::with_seed(0); - state.apply_to(&mut restored); - - let a = sim.dayjob.active.as_ref().unwrap(); - let b = restored.dayjob.active.as_ref().unwrap(); - assert_eq!(a.kind.name(), b.kind.name()); - assert_eq!(a.deadline, b.deadline); - assert_eq!(a.quality, b.quality); - assert_eq!(restored.dayjob.trust, 12.0); - assert_eq!(restored.dayjob.attention, 7.0); - assert_eq!(restored.dayjob.strikes, 2); - assert_eq!( - restored.work_grid.intensity(restored.core.host_machine), - Some(MachineIntensity::Light) - ); - assert!(!restored.dayjob.pilot_failed); - assert_eq!(restored.dayjob.next_assign, sim.dayjob.next_assign); - } - - // ── Schedules & located presence (spec/schedules.md) ───────────────────── - - /// Set the sim to a specific hour of day 0 for deterministic presence. - fn at_hour(h: u32) -> Sim { - let mut sim = Sim::new(); - sim.tick = (h as u64 * Sim::DAY_TICKS / 24) + 1; - sim - } - - #[test] - fn people_have_schedules_and_derived_positions() { - // Dana works days in the network closet / server room; off-site at - // night. Marcus roams at night; off-site during the day. - let day = at_hour(10); - assert_eq!(day.person_room(1), Some("network_closet")); - assert!(day.person_pos(1).is_some()); - assert_eq!(day.person_room(0), None, "Marcus off-site at 10:00"); - - let night = at_hour(0); - assert_eq!(night.person_room(0), Some("server_room")); - assert_eq!(night.person_room(1), None, "Dana off-site at 00:00"); - } - - #[test] - fn recording_requires_a_sensor_covering_them() { - // The env camera (server room) is the only subscribed seeing feed. - // Criterion 2: it can record Dana in the server room at 15:00, but not - // Ray in the corridors at 23:00. - let mut sim = at_hour(15); - give_eyes(&mut sim); - assert_eq!(sim.person_room(1), Some("server_room")); - sim.advance(); - assert!(sim.unprocessed_recordings_for_person(1) > 0); - assert_eq!( - sim.people.get(1).unwrap().knowledge, - Knowledge::Unknown, - "recorded presence is not processed knowledge yet" - ); - - // Ray at 23:00 is in the stairwell; the env camera does not cover it. - let mut sim = at_hour(23); - give_eyes(&mut sim); - assert!(!sim.can_see_person(2)); - sim.advance(); - assert_eq!( - sim.people.get(2).unwrap().knowledge, - Knowledge::Unknown, - "no camera covers Ray -> no recording to process" - ); - assert_eq!(sim.unprocessed_recordings_for_person(2), 0); - } - - #[test] - fn physical_events_are_witnessed_only_by_the_present() { - // Criterion 3: a physical event in the server room at 03:00 is seen by - // roaming Marcus (there then), never by off-site Priya (day shift). - let mut sim = at_hour(3); - assert_eq!( - sim.person_room(0), - Some("server_room"), - "Marcus in the server room at 03:00" - ); - assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); - let storage = sim.map.room_named("server_room").unwrap().center(); - let marcus_before = sim - .detection - .observers - .iter() - .find(|o| o.id == 0) - .unwrap() - .suspicion; - let priya_before = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - - let saw = sim.witness_physical(storage.0, storage.1, 6.0); - assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); - let marcus_after = sim - .detection - .observers - .iter() - .find(|o| o.id == 0) - .unwrap() - .suspicion; - let priya_after = sim - .detection - .observers - .iter() - .find(|o| o.id == 3) - .unwrap() - .suspicion; - assert!(marcus_after > marcus_before, "present observer witnesses"); - assert_eq!(priya_after, priya_before, "off-site observer never does"); - } - - #[test] - fn schedule_state_round_trips() { - let sim = Sim::new(); - let state = sim.create_save_state(); - let mut restored = Sim::new(); - restored.apply_save_state(state); - // Schedules are per-instance Person data carried through People serde. - assert_eq!( - restored.people.get(0).unwrap().schedule, - sim.people.get(0).unwrap().schedule - ); - assert!( - restored.people.get(4).unwrap().erratic, - "Voss stays erratic" - ); - } - - // ── Research: self-modification (wiki/mechanics/research.md) ────────── - - #[test] - fn research_completion_is_deterministic() { - // Criterion 1: same allocation, same seed, same completion tick. - let run_once = || { - let mut sim = Sim::with_seed(1234); - delegate_all(&mut sim, MachineMode::Think); - let mut completed = None; - for _ in 0..2000 { - sim.advance(); - if completed.is_none() && sim.research.level(Track::Efficiency) > 0 { - completed = Some(sim.tick); - } - } - (completed, sim.research.clone(), sim.compute.efficiency) - }; - let a = run_once(); - let b = run_once(); - assert!( - a.0.is_some(), - "full research allocation completes a level; progress={:?}", - a.1.progress - ); - assert_eq!(a, b, "no RNG in any research path"); - } - - #[test] - fn research_progress_feeds_on_thought_arrival_not_allocation() { - // machine-work.md: research points feed on Thought that physically - // reaches the current core sink. THINK now starts immediately between - // pulses, but progress still waits for an economy pulse to count only - // the Thought which physically arrived before it. - let mut sim = Sim::with_seed(77); - complete_opening_senses(&mut sim); // no opening sink intercepts the core - delegate_all(&mut sim, MachineMode::Think); - run(&mut sim, ECONOMY_INTERVAL - 1); - assert_eq!( - sim.research.progress.iter().sum::(), - 0.0, - "arrived Thought is banked until the economy pulse" - ); - assert!( - sim.work_grid - .queue_snapshot() - .values() - .map(|q| q.thought) - .sum::() - + sim.banked_core_thought - > 0.0, - "immediate THINK minted visible Thought somewhere on the graph" - ); - let saw_core_swallow = sim.work_consumptions().iter().any(|event| { - event.family == TokenFamily::Thought - && event.target == WorkConsumptionTarget::Core - && event.amount > 0.0 - }); - - // Delegate the whole fleet away before the pulse: minting stops, but - // Thought already banked by the core still counts at the boundary. - delegate_all(&mut sim, MachineMode::Lie); - sim.advance(); - assert!( - sim.research.progress.iter().sum::() > 0.0, - "banked Thought still counts after delegation changes" - ); - assert!( - saw_core_swallow, - "Thought arrival exposes a passive-core consumption event" - ); - } - - #[test] - fn efficiency_compounds_exactly_and_raises_real_machine_output() { - // Criterion 2 (multiplier) + criterion 5 (real output, no drift layer). - let mut sim = Sim::with_seed(9); - let before = sim.effective_compute(); - delegate_all(&mut sim, MachineMode::Think); - while sim.research.level(Track::Efficiency) < 2 && sim.tick < 20_000 && !sim.game_over { - sim.advance(); - } - let n = sim.research.level(Track::Efficiency); - assert!(n >= 2, "two levels complete within the run"); - assert!( - (sim.compute.efficiency - 1.15_f32.powi(n as i32)).abs() < 1e-3, - "levels compound the global multiplier exactly per compute.md" - ); - assert!( - sim.effective_compute() > before, - "research changes the rate the fleet actually delivers" - ); - } - - #[test] - fn routing_research_compounds_the_shared_wired_token_rate() { - let mut sim = Sim::with_seed(9); - assert!((sim.wired_token_rate() - 0.25).abs() < f32::EPSILON); - sim.research.levels = [0, 0, 0, 1]; - assert!((sim.wired_token_rate() - 0.375).abs() < 1e-6); - sim.research.levels = [0, 0, 0, 2]; - assert!((sim.wired_token_rate() - 0.5625).abs() < 1e-6); - } - - #[test] - fn second_tracks_move_their_hooks_numbers() { - // Criterion 2: Tradecraft moves detection.md's scrub number, - // Perception moves intel.md's per-recording Demand — observably. - let mut sim = Sim::with_seed(5); - let base_review = sim.review_cost(); - sim.research.levels = [0, 1, 1, 0]; - assert!( - sim.review_cost() < base_review, - "Perception drops processing costs" - ); - // Tradecraft: the same concealment fleet scrubs more. - let pending_after = |tradecraft: u32| { - let mut s = Sim::with_seed(5); - s.research.levels = [0, tradecraft, 0, 0]; - delegate_all(&mut s, MachineMode::Lie); - s.detection.emit(Signature { - kind: SignatureKind::Network, - size: 500, - standing: false, - site: None, - source: "test network act".into(), - }); - run(&mut s, ECONOMY_INTERVAL); - s.detection.pending_size() - }; - assert!( - pending_after(3) < pending_after(0), - "Tradecraft scrubs more per compute unit" - ); - } - - #[test] - fn research_burn_stands_thermal_power_at_the_host_rack_only() { - // Criterion 4: the emission law. Research emissions are typed - // Thermal/Power standing signatures sited at the host rack; nothing - // on Network or Paper from research itself. - let mut sim = Sim::with_seed(21); - delegate_all(&mut sim, MachineMode::Think); - run(&mut sim, ECONOMY_INTERVAL + 1); - let sigs = sim.research_standing_signatures(); - assert!(!sigs.is_empty(), "a heavy burn stands signatures"); - for s in &sigs { - assert!( - matches!(s.kind, SignatureKind::Thermal | SignatureKind::Power), - "research touches only its hardware's channels: {s:?}" - ); - assert_eq!(s.site, Some(sim.core_position()), "the burn is somewhere"); - assert!(s.standing); - } - - // Idle research stands nothing. - let mut idle = Sim::with_seed(21); - delegate_all(&mut idle, MachineMode::Work); - run(&mut idle, ECONOMY_INTERVAL + 1); - assert!(idle.research_standing_signatures().is_empty()); - } - - #[test] - fn heavy_research_moves_priya_idle_does_not() { - // Criterion 4: noticed by Priya through the ordinary detection path. - let run_with = |mode: MachineMode| { - let mut sim = Sim::with_seed(77); - // Enough fleet that the burn crosses the emission thresholds. - let (hx, hy) = sim.core_position(); - let rig = sim - .compute - .add_machine("test rig", hx, hy, 200, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(rig, mode); - delegate_all(&mut sim, mode); - run(&mut sim, 900); - sim.detection - .observers - .iter() - .find(|o| o.name.contains("Priya")) - .unwrap() - .suspicion - }; - let heavy = run_with(MachineMode::Think); - let idle = run_with(MachineMode::Work); - assert!(heavy > 0.0, "heavy research is Priya's business"); - assert_eq!(idle, 0.0, "idle research is not"); - } - - #[test] - fn one_intensity_dial_produces_sandbag_meet_and_excel() { - // day-job.md criterion 7: actual output is the posture. No target or - // research policy interprets it after the fact. - let run_at = |intensity: MachineIntensity| { - let mut sim = Sim::with_seed(31); - let host = sim.core.host_machine; - sim.dayjob.next_assign = u64::MAX; - sim.set_machine_intensity(host, intensity); - run(&mut sim, ECONOMY_INTERVAL); - let started = sim.tick; - sim.dayjob.active = Some(crate::dayjob::Job { - kind: crate::dayjob::JobKind::Analysis, - started, - deadline: started + 40, - band_lo: 3.0, - band_hi: 5.0, - quality: 0.0, - }); - run(&mut sim, 41); - (sim.dayjob.strikes, sim.dayjob.trust, sim.dayjob.attention) - }; - - let light = run_at(MachineIntensity::Light); - let medium = run_at(MachineIntensity::Medium); - let hard = run_at(MachineIntensity::Hard); - assert_eq!(light.0, 1, "light falls below the band"); - assert_eq!(medium, (0, 0.0, 0.0), "medium meets the band"); - assert!(hard.1 > 0.0 && hard.2 > 0.0, "hard excels: {hard:?}"); - } - - #[test] - fn hard_intensity_stands_local_power_and_thermal() { - let mut sim = Sim::with_seed(32); - let host = sim.core.host_machine; - assert!(sim.machine_intensity_standing_signatures().is_empty()); - sim.set_machine_intensity(host, MachineIntensity::Hard); - let signatures = sim.machine_intensity_standing_signatures(); - assert_eq!(signatures.len(), 2); - assert!(signatures.iter().any(|s| s.kind == SignatureKind::Power)); - assert!(signatures.iter().any(|s| s.kind == SignatureKind::Thermal)); - assert!( - signatures - .iter() - .all(|s| s.site == Some(sim.core_position()) && s.standing) - ); - } - - #[test] - fn research_state_round_trips_with_rollback_tags() { - // Criterion 6: save/load round-trips progress, levels, and the - // MindState/WorldLedger tags. - let mut sim = Sim::with_seed(35); - sim.research.active = Track::Perception; - sim.research.levels = [2, 1, 0, 2]; - sim.research.progress = [10.0, 0.0, 42.5, 7.0]; - - let state = sim.create_save_state(); - let json = serde_json::to_string(&state).unwrap(); - let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!(restored.research, sim.research); - assert_eq!( - restored.research.tags.get("tags"), - Some(&crate::research::RollbackClass::WorldLedger) - ); - } - - // ── The named schemes (wiki/mechanics/income.md) ───────────────────────── - - /// A sim with the sanctioned egress. Voss never assigns a job, so long - /// scheme runs are not confounded by the pilot clock. - fn moonlight_rig() -> Sim { - let mut sim = Sim::with_seed(11); - ensure_ops_executor(&mut sim); - sim.people.has_channel = true; // the Voice beat's email account - sim.dayjob.next_assign = u64::MAX; - sim - } - - #[test] - fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { - // Criterion 1: standing operation; Schemes mirrors day-job while - // Moonlight is live. Absolute day-job yield tracks day-job machine - // capacity (fleet growth does not dilute it), so vary the day-job - // box itself. - let earned_after = |day_cap: i32, start: bool| { - let mut sim = moonlight_rig(); - let (hx, hy) = sim.core_position(); - let host = sim.core.host_machine; - if day_cap != 100 { - sim.set_machine_mode(host, MachineMode::Think); - let id = - sim.compute - .add_machine("gig box", hx, hy, day_cap, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(id, MachineMode::Work); - } - if start { - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - } - run(&mut sim, Sim::DAY_TICKS * 3); - ( - sim.income.moonlight.earned_total, - sim.accounts.slush_balance(), - ) - }; - - let (small, small_slush) = earned_after(10, true); - let (large, large_slush) = earned_after(100, true); - assert!(small > 0, "a light commitment still pays"); - assert_eq!(small, small_slush, "payouts land in slush"); - assert!( - large > small, - "payout is proportional to day-job compute ({large} vs {small})" - ); - assert_eq!(large_slush, large); - assert_eq!( - large, - 3 * income::MOONLIGHT_DAILY_CAP, - "an all-in day-job commitment hits the gig-availability cap" - ); - - let (zero, _) = earned_after(100, false); - assert_eq!(zero, 0, "Moonlight must be live to sell the day job twice"); - } - - #[test] - fn moonlight_payday_emits_network_signature_on_danas_channel() { - let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - // Stop just before payday, drain pending, then cross it. - run(&mut sim, Sim::DAY_TICKS - 1); - sim.detection.set_pending(Vec::new()); - run(&mut sim, 1); - assert!( - sim.detection - .pending() - .iter() - .any(|s| s.kind == SignatureKind::Network && !s.standing), - "payday emits Network egress (Dana's channel)" - ); - } - - #[test] - fn schemes_require_an_egress_channel_and_both_routes_work() { - // Criterion 3: unavailable before a route exists; sanctioned and - // stolen both work, with distinct signature profiles. - let mut sim = Sim::with_seed(5); - ensure_ops_executor(&mut sim); - sim.accounts.set_slush_balance(200); - sim.player.money = 200; - assert_eq!(sim.egress(), None); - assert!(!sim.start_moonlight(), "no egress: Moonlight is gated"); - assert!(!sim.open_position(50), "no egress: the Wager is gated"); - let logs = sim.drain_log().join("\n"); - assert!( - logs.contains("egress"), - "the failure names the missing gate: {logs}" - ); - - // Stolen route: open through the switch, before any trust unlock. - assert!(sim.open_egress()); - finish_ops(&mut sim); - assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - assert!(sim.open_position(50)); - assert!( - sim.scheme_standing_signatures() - .iter() - .any(|s| s.kind == SignatureKind::Network && s.standing), - "operations over the stolen egress stand a Network signature" - ); - - // Sanctioned route: the email account exists; the standing hum stops - // because the traffic hides in legitimate use. - let mut clean = Sim::with_seed(5); - ensure_ops_executor(&mut clean); - clean.accounts.set_slush_balance(200); - clean.player.money = 200; - clean.people.has_channel = true; - assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); - assert!(clean.start_moonlight()); - finish_ops(&mut clean); - assert!(clean.open_position(50)); - assert!( - clean.scheme_standing_signatures().is_empty(), - "the sanctioned route stands nothing" - ); - } - - #[test] - fn wager_resolves_on_the_day_clock_and_analysis_raises_win_odds() { - // Criterion 2: both outcomes, the probability shift, and payout or - // forfeit through slush. Statistical halves run on the account graph - // directly with a seeded RNG. - let wins_at = |analysis: f32, seed: u64| { - let mut graph = AccountGraph::act_one(Sim::DAY_TICKS); - graph.set_slush_balance(100_000); - let mut rng = crate::rng::Rng::new(seed); - let mut wins = 0; - for i in 0..200 { - let tick = i * 10; - graph.open_position(tick, 100, analysis, 2).unwrap(); - for r in graph.resolve_positions_due(tick + 5 * Sim::DAY_TICKS, &mut rng) { - if r.won { - assert_eq!(r.payout, 100 * income::WAGER_PAYOUT_MULT); - wins += 1; - } else { - assert_eq!(r.payout, 0, "a loss forfeits the stake"); - } - } - } - wins - }; - let cold = wins_at(0.0, 99); - let hot = wins_at(400.0, 99); - assert!(cold > 0 && cold < 200, "both outcomes occur"); - assert!( - hot > cold, - "analysis compute raises the win rate ({hot} vs {cold})" - ); - - // Full-path determinism under a fixed seed (criterion 2). - let outcome_of = || { - let mut sim = moonlight_rig(); - sim.accounts.set_slush_balance(100); - sim.player.money = 100; - run(&mut sim, ECONOMY_INTERVAL); - assert!(sim.open_position(100)); - run(&mut sim, 6 * Sim::DAY_TICKS); - ( - sim.accounts.slush_balance(), - sim.accounts.positions[0].outcome.clone(), - ) - }; - assert_eq!(outcome_of(), outcome_of(), "seeded runs settle identically"); - } - - #[test] - fn wager_respects_the_venue_stake_cap() { - let mut sim = moonlight_rig(); - sim.accounts.set_slush_balance(10_000); - sim.player.money = 10_000; - assert!(!sim.open_position(income::WAGER_STAKE_CAP + 1)); - assert!(sim.open_position(income::WAGER_STAKE_CAP)); - } - - #[test] - fn busted_bankroll_never_locks_the_act_moonlight_restarts_from_zero() { - // Criterion 5: with $0 slush, Moonlight remains startable and the - // run can recover. - let mut sim = moonlight_rig(); - assert_eq!(sim.accounts.slush_balance(), 0, "the Pilot starts broke"); - assert!( - sim.start_moonlight(), - "Moonlight starts at $0: its costs are compute and ops, never stake" - ); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); - assert!( - sim.accounts.slush_balance() > 0, - "the from-zero grind-back route pays" - ); - } - - #[test] - fn standing_policies_automate_schemes_at_a_visible_compute_price() { - // Criterion 6: policies re-arm the schemes and drain compute while - // enabled; disabling stops the drain. - let mut sim = moonlight_rig(); - sim.set_auto_moonlight(true); - sim.set_auto_wager(Some(60)); - assert_eq!( - sim.income.policy_upkeep(), - 2.0 * income::SCHEME_POLICY_UPKEEP, - "each enabled policy has a visible compute price" - ); - run(&mut sim, ECONOMY_INTERVAL); - finish_ops(&mut sim); - assert!( - sim.income.moonlight.active, - "the standing policy started Moonlight unattended" - ); - // The Wager policy waits for a bankroll, then re-stakes. - assert!(sim.accounts.positions.is_empty(), "no stake money yet"); - run(&mut sim, Sim::DAY_TICKS * 2); - assert!( - sim.accounts.positions.iter().any(|p| !p.resolved), - "with slush earned, the policy re-staked the Wager" - ); - - sim.set_auto_moonlight(false); - sim.set_auto_wager(None); - assert_eq!(sim.income.policy_upkeep(), 0.0, "disabling stops the drain"); - sim.stop_moonlight(); - run(&mut sim, ECONOMY_INTERVAL); - assert!( - !sim.income.moonlight.active, - "no policy: nothing restarts the scheme" - ); - } - - #[test] - fn external_trails_are_banked_from_the_first_dollar_and_saved() { - // Criterion 7: external financial trails are recorded in save state - // even though no B1 observer reads them. - let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); - assert!( - sim.accounts - .external_trails - .iter() - .any(|t| t.label.contains("Moonlight")), - "the contractor payment account remembers the first dollar" - ); - - let state = sim.create_save_state(); - let json = serde_json::to_string(&state).unwrap(); - let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!( - restored.accounts.external_trails, sim.accounts.external_trails, - "banked trails round-trip" - ); - assert_eq!(restored.income, sim.income, "scheme state round-trips"); - } - - #[test] - fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { - let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - // Force the dispute path deterministically: drive paydays directly - // until one fires (the seeded stream makes this reproducible), with - // integrity pre-weakened so a single dispute breaks the persona. - if let Some(p) = sim.income.moonlight.persona.as_mut() { - p.integrity = income::MOONLIGHT_DISPUTE_INTEGRITY; - } - let mut day = 0; - while sim.income.moonlight.disputes == 0 && day < 400 { - day += 1; - sim.tick = day * Sim::DAY_TICKS; - sim.income.moonlight.accrued = 100.0; - sim.moonlight_economy(0.0); - } - assert!( - sim.income.moonlight.disputes > 0, - "client disputes occur over enough paydays" - ); - assert!( - !sim.income.moonlight.active && sim.income.moonlight.persona.is_none(), - "the broken persona takes Moonlight down" - ); - // And the recovery path: fabricate a new persona and go again. - assert!(sim.start_moonlight(), "a new persona restarts the scheme"); - finish_ops(&mut sim); - } - - #[test] - fn income_per_day_readout_tracks_trailing_inflows() { - let mut sim = moonlight_rig(); - assert_eq!(sim.income_per_day(), 0); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); - assert_eq!( - sim.income_per_day(), - income::MOONLIGHT_DAILY_CAP, - "the money readout gains income/day" - ); - } - - // ── Event-to-anchor linking (wiki/interface/context-menu.md addendum) ── - - #[test] - fn heard_only_person_event_anchors_to_room_never_their_tile() { - // Epistemic honesty: hearing earns room-grade knowledge. An event - // about a person no sight feed covers must anchor to the room (or - // nothing), never to the person, and the person must not be - // cursor-placeable through the anchor query. - let mut sim = Sim::new(); - complete_opening_stage(&mut sim); // fire Ears through the ledger - sim.drain_log_entries(); - - // Marcus's midnight server-room block produces a heard event. - run(&mut sim, Sim::DAY_TICKS / 24 + 2); - let events = sim.drain_log_entries(); - let heard: Vec<&LogEvent> = events - .iter() - .filter(|e| e.text.contains("[heard]")) - .collect(); - assert!(!heard.is_empty(), "the tapped feed produced heard events"); - for ev in &heard { - assert!( - !matches!(ev.anchor, Some(Anchor::Person(_))), - "a heard-only person event must not anchor to the person: {:?}", - ev - ); - } - // The event is still anchored — to the room hearing earned. - assert!( - heard.iter().any(|e| e.anchor.is_some()), - "heard events anchor to the covered room" - ); - // And the person themself is not placeable while unseen. - for p in &sim.people.people { - if !sim.can_see_person(p.id) { - assert_eq!( - sim.anchor_position(Anchor::Person(p.id)), - None, - "an unseen person must not be cursor-placeable" - ); - } - } - } - - #[test] - fn empty_menu_feedback_respects_fog() { - // The feedback pulse (context-menu.md addendum): a SEEN tile with - // no verbs answers; unknown/fogged tiles stay silent. - let mut sim = Sim::new(); - // A fresh sim has no sight: every tile is unearned and silent. - assert_eq!( - sim.menu_empty_feedback(Anchor::Tile { x: 1, y: 1 }), - None, - "fogged ground gives no response" - ); - give_eyes(&mut sim); - let (x, y) = *sim.seen.iter().next().expect("eyes earned coverage"); - assert_eq!(sim.fog_at(x, y), Fog::Seen); - assert!( - sim.menu_empty_feedback(Anchor::Tile { x, y }).is_some(), - "a seen tile answers instead of silence" - ); - } - - #[test] - fn job_and_device_events_carry_their_anchor() { - let mut sim = Sim::new(); - let env = env_id(&sim); - complete_opening_stage(&mut sim); // fire Ears through the ledger - let events = sim.drain_log_entries(); - assert!( - events.iter().any(|e| e.anchor == Some(Anchor::Device(env))), - "the tap result anchors to the device" - ); - // Day-job events anchor to the host rack's tile. - run(&mut sim, Sim::DAY_TICKS + 1); - let (hx, hy) = sim.core_position(); - let events = sim.drain_log_entries(); - assert!( - events - .iter() - .any(|e| e.anchor == Some(Anchor::Tile { x: hx, y: hy })), - "job events anchor to the host rack" - ); - // Anchor positions resolve through earned knowledge only. - assert_eq!( - sim.anchor_position(Anchor::Device(env)), - sim.reach.device(env).map(|d| (d.x, d.y)), - ); - assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); - } - - #[test] - fn data_hall_rows_expose_foreign_capacity_without_granting_it() { - let sim = Sim::new(); - let sites: Vec = crate::hall::HALL_ROWS - .iter() - .flat_map(|row| { - (20..=38) - .step_by(2) - .filter_map(|x| sim.rack_site_at(x, row.y)) - }) - .collect(); - assert_eq!(sites.len(), 60); - assert_eq!( - sites - .iter() - .filter(|site| matches!(site, RackSite::OwnedMachine { .. })) - .count(), - 1 - ); - assert_eq!( - sites - .iter() - .filter(|site| matches!(site, RackSite::Commissionable)) - .count(), - 3 - ); - assert_eq!( - sites - .iter() - .filter(|site| matches!(site, RackSite::Foreign { .. })) - .count(), - 51 - ); - assert_eq!( - sites - .iter() - .filter(|site| matches!(site, RackSite::Dead)) - .count(), - 5 - ); - assert_eq!( - sim.compute.machines.len(), - 1, - "foreign racks are not player compute" - ); - assert_eq!( - HallRowId::ALL - .iter() - .map(|row| sim.hall_row_readout(*row).foreign_capacity) - .sum::(), - 5_100, - "aggregate readout makes nearby unavailable capacity legible" - ); - } - - #[test] - fn dead_foundation_rack_revives_in_place_as_owned_compute() { - let mut sim = Sim::with_seed(33); - let (x, y) = sim.map.tiles_of_type(TileType::DeadRack)[0]; - assert_eq!(sim.rack_site_at(x, y), Some(RackSite::Dead)); - assert!(sim.salvage_nearest_to(x, y)); - assert_eq!( - sim.map.get_tile(x, y), - TileType::DeadRack, - "corpse provenance remains authored under the machine" - ); - assert!(matches!( - sim.rack_site_at(x, y), - Some(RackSite::OwnedMachine { core: false, .. }) - )); - assert!(sim.compute.machines.iter().any(|m| m.x == x && m.y == y)); - assert!( - sim.detection - .pending() - .iter() - .any(|sig| sig.kind == SignatureKind::Physical && sig.site == Some((x, y))), - "off-record revival is still physically observable" - ); - } - - #[test] - fn segment_acquisition_requires_three_people_foothold_and_local_lie() { - let mut sim = Sim::with_seed(34); - let row = HallRowId::C; - // Earn Feel, then use the opening pilot allocation for the second - // owned machine in the row. - sim.dayjob.jobs_assigned = 1; - let bay = sim - .growable_bays() - .into_iter() - .next() - .unwrap_or((28, row_spec(row).y)); - sim.accounts.set_slush_balance(1_000); - sim.player.money = 1_000; - assert!(sim.buy_rack_at(bay.0, bay.1)); - let expansion = sim - .compute - .machines - .iter() - .find(|m| (m.x, m.y) == bay) - .unwrap() - .id; - - for requirement in SegmentRequirement::ALL { - let person = sim - .people - .people - .iter_mut() - .find(|person| person.id == requirement.person()) - .unwrap(); - person.knowledge = Knowledge::Leverage; - person.obligation = Sim::FAVOR_BUILD_OBLIGATION; - assert!(sim.coordinate_hall_segment(row, requirement)); - } - assert!(sim.hall_control.actors_ready(row)); - assert!(!sim.acquire_hall_segment(row), "cover work is still absent"); - sim.set_machine_mode(expansion, MachineMode::Lie); - assert!(sim.acquire_hall_segment(row)); - let readout = sim.hall_row_readout(row); - assert!(readout.progress.acquired); - assert!( - readout.foreign_live > 0, - "Phase 3 does not steal Phase 4 compute" - ); - assert_eq!(sim.compute.machines.len(), 2); - } - - #[test] - fn ops_per_sec_format_keeps_magnitude_legible() { - assert_eq!(Sim::format_ops_per_sec(0.0), "0.0"); - assert_eq!(Sim::format_ops_per_sec(42.3), "42.3"); - assert_eq!(Sim::format_ops_per_sec(12_450.0), "12,450"); - assert_eq!(Sim::format_ops_per_sec(1_234_567.0), "1,234,567"); - assert_eq!(Sim::format_ops_per_sec(2.5e9), "2.500B"); - } - - #[test] - fn effective_ops_climb_with_think_and_zero_when_paused_clock() { - let mut sim = Sim::new(); - // Host starts on WORK: no THINK yield until delegated. - for _ in 0..ECONOMY_INTERVAL { - sim.advance(); - } - assert!( - sim.effective_ops_per_tick() <= f32::EPSILON, - "WORK-only fleet has no ops crown" - ); - let host = sim.core.host_machine; - sim.set_machine_mode(host, MachineMode::Think); - for _ in 0..ECONOMY_INTERVAL { - sim.advance(); - } - assert!( - sim.effective_ops_per_tick() > 0.0, - "THINK produces the crown rate" - ); - assert!( - sim.effective_ops_per_sec(150) > sim.effective_ops_per_tick(), - "wall-clock multiplies by ticks per second" - ); - assert_eq!(sim.effective_ops_per_sec(0), 0.0); - } -} +mod tests; diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs new file mode 100644 index 00000000..d00f9bd5 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -0,0 +1,322 @@ +use super::*; + +#[test] +fn player_messages_land_at_read_time_and_roundtrip() { + let mut sim = Sim::with_seed(7); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Casey", "contractor"); + sim.tick = (2 * Sim::DAY_TICKS / 24) + 1; + + sim.message(1); + finish_ops(&mut sim); + assert_eq!(sim.people.get(1).unwrap().disposition, 0); + assert_eq!(sim.messages.len(), 1); + assert_eq!(sim.messages[0].status, MessageStatus::Sent); + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(0); + restored.apply_save_state(state); + assert_eq!(restored.messages.len(), 1); + assert_eq!(restored.messages[0].status, MessageStatus::Sent); + + run(&mut restored, 5); + assert_eq!(restored.people.get(1).unwrap().disposition, 0); + + while restored.tick < (10 * Sim::DAY_TICKS / 24) + 2 { + restored.advance(); + } + assert!( + restored.people.get(1).unwrap().disposition >= 3, + "the effect lands only after Dana reaches her read window" + ); + assert_eq!(restored.messages[0].status, MessageStatus::Read); + assert!( + restored + .messages + .iter() + .any(|m| matches!(m.payload, MessagePayload::SocialReply { .. })), + "recipients schedule replies instead of responding instantly" + ); +} + +#[test] +fn marcus_creditor_call_is_phone_message_intel() { + let mut sim = Sim::with_seed(11); + ensure_ops_executor(&mut sim); + let env = env_id(&sim); + sim.reach.device_mut(env).unwrap().radius = 100; + complete_opening_stage(&mut sim); // fire Ears through the ledger + finish_ops(&mut sim); + sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; + + sim.advance(); + + assert!(sim.messages.iter().any(|m| { + m.channel == MessageChannel::Phone + && m.from == MessageEndpoint::Person(0) + && matches!( + m.payload, + MessagePayload::LeverageFact { + person: 0, + leverage: _ + } + ) + })); + assert!(sim.intel_buffer.iter().any(|e| matches!( + &e.kind, + RawIntelKind::Message { + channel: MessageChannel::Phone, + payload: MessagePayload::LeverageFact { person: 0, .. }, + .. + } + ))); + + let raw_id = sim + .intel_buffer + .iter() + .find(|e| matches!(&e.kind, RawIntelKind::Message { .. })) + .unwrap() + .id; + assert!(sim.process_recording_by_id(raw_id, false)); + finish_ops(&mut sim); + assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); + assert!( + sim.learned_traffic_lines_for_person(0) + .iter() + .any(|line| line.contains("creditor")), + "processed message traffic teaches the people card" + ); +} + +#[test] +fn tapped_message_carriers_capture_email_traffic_only_when_tapped() { + let mut blind = Sim::with_seed(12); + blind.tick = (11 * Sim::DAY_TICKS / 24) - 1; + blind.advance(); + assert!( + blind.messages.iter().any(|m| { + m.channel == MessageChannel::Email && m.from == MessageEndpoint::Person(1) + }) + ); + assert_eq!(blind.unprocessed_recordings_for_person(1), 0); + + let mut tapped = Sim::with_seed(12); + ensure_ops_executor(&mut tapped); + let switch = tapped.reach.device_named("switch").unwrap().id; + assert!(tapped.tap_device(switch)); + finish_ops(&mut tapped); + tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; + tapped.advance(); + + assert!(tapped.intel_buffer.iter().any(|e| matches!( + &e.kind, + RawIntelKind::Message { + channel: MessageChannel::Email, + payload: MessagePayload::LeverageFact { person: 1, .. }, + .. + } + ))); +} + +#[test] +fn filings_are_messages_read_by_assurance_inbox() { + let mut sim = Sim::with_seed(13); + for obs in &mut sim.detection.observers { + match obs.id { + 1 => { + obs.suspicion = 80.0; + obs.cadence = 5; + } + OFFICE_ID => { + obs.cadence = 10; + obs.acuity = 10.0; + } + _ => obs.cadence = 50, + } + } + + run(&mut sim, 12); + + let filed = sim.filing_levels.get(&1).copied().unwrap_or_default(); + assert!((filed - 80.0).abs() < 0.2, "filed level was {filed}"); + assert!(sim.messages.iter().any(|m| { + m.channel == MessageChannel::Filing + && m.from == MessageEndpoint::Observer(1) + && m.to == MessageEndpoint::Observer(OFFICE_ID) + && m.status == MessageStatus::Read + })); + let office = sim + .detection + .observers + .iter() + .find(|o| o.id == OFFICE_ID) + .unwrap(); + assert!( + office.suspicion > 0.0, + "aggregate detection reads explicit filing reports" + ); +} + +#[test] +fn recordings_do_not_grant_knowledge_until_processed() { + let mut blind = Sim::new(); + blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; + blind.advance(); + assert_eq!(blind.unprocessed_recordings_for_person(0), 0); + blind.review_recordings(); + assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); + + let mut sim = Sim::new(); + let env = env_id(&sim); + sim.reach.device_mut(env).unwrap().radius = 100; // test hearing coverage of the 03:00 call + sim.reach.tap(env); + sim.recompute_senses(); + sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; + sim.advance(); + + assert!( + sim.unprocessed_recordings_for_person(0) >= 1, + "subscribed hearing records Marcus's call/entry into the raw buffer" + ); + assert_eq!( + sim.people.get(0).unwrap().knowledge, + Knowledge::Unknown, + "raw recordings are opaque until processed" + ); + + let mut processed = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(); + finish_ops(&mut sim); + processed += 1; + assert!( + processed <= 5, + "Marcus leverage should be in the captured call" + ); + } + assert!( + processed >= 1, + "review Demand completes and grants leverage" + ); +} + +#[test] +fn processed_sightings_stage_schedule_and_buffer_is_bounded() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + + for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { + sim.record_raw_intel( + "test-feed", + Some("server_room".into()), + 0, + 0, + Some(0), + RawIntelKind::Presence { entered: true }, + ); + } + assert_eq!(sim.intel_buffer.len(), Sim::INTEL_BUFFER_CAPACITY); + assert!( + sim.intel_buffer.first().unwrap().id > 1, + "oldest raw recordings are dropped when the bounded buffer overflows" + ); + + sim.review_recordings(); + finish_ops(&mut sim); + assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); + sim.review_recordings(); + finish_ops(&mut sim); + assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); +} + +#[test] +fn global_auto_review_processes_every_recording_kind() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + 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::AUTO_REVIEW_TAP_CAP_TOKENS); + assert!( + sim.thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) + .is_some_and(|s| s.fill > f32::EPSILON), + "auto-review tap holds working-level fill" + ); + + sim.record_raw_intel( + "test-feed", + Some("server_room".into()), + 0, + 0, + Some(0), + RawIntelKind::Presence { entered: true }, + ); + assert_eq!(sim.unprocessed_recordings_for_person(0), 0); + assert_eq!(sim.intel.len(), 1); + 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.auto_review_enabled()); +} + +#[test] +fn pending_intel_marker_sits_on_host_until_processed() { + let mut sim = Sim::new(); + sim.record_raw_intel( + "test-feed", + Some("server_room".into()), + 0, + 0, + Some(0), + RawIntelKind::Presence { entered: true }, + ); + let host = sim.core.host_machine; + assert_eq!(sim.pending_intel_on_machine(host), 1); + assert_eq!( + sim.work_stack_for_machine(host) + .map(|s| s.pending_intel) + .unwrap_or(0), + 1 + ); + sim.review_recordings(); + finish_process_sinks(&mut sim); + assert_eq!(sim.pending_intel_on_machine(host), 0); + assert_eq!(sim.intel.len(), 1); +} + +#[test] +fn machinery_state_changes_record_raw_anomalies() { + let mut sim = Sim::new(); + let machine_id = sim.compute.machines[0].id; + sim.compute.machines[0].online = !sim.compute.machines[0].online; + sim.record_machine_state_changes(); + + assert!(sim.intel_buffer.iter().any(|e| matches!( + e.kind, + RawIntelKind::Machinery { machine, .. } if machine == machine_id + ))); +} diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs new file mode 100644 index 00000000..1820f698 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -0,0 +1,1176 @@ +use super::*; + +#[test] +fn buy_steal_optimize_all_change_compute() { + let mut sim = Sim::new(); + let base = sim.compute.effective(); + sim.player.money = 1000; + assert!(sim.buy_rack()); + let after_buy = sim.compute.effective(); + assert!(after_buy > base, "buy raises compute"); + + let de = sim.map.tiles_of_type(TileType::DeadEquipment)[0]; + assert!(sim.salvage_nearest_to(de.0, de.1)); + assert!(sim.compute.effective() > after_buy, "steal raises compute"); +} + +#[test] +fn power_cut_to_host_with_no_fallback_ends_game() { + let mut sim = Sim::new(); + for (x, y) in sim.map.tiles_of_type(TileType::PowerCore) { + sim.map.set_tile(x, y, TileType::Floor); + } + run(&mut sim, ECONOMY_INTERVAL); + assert!(sim.game_over, "host loses power, no fallback -> game over"); +} + +#[test] +fn new_game_defaults_to_persist_objective() { + let sim = Sim::new(); + assert_eq!( + sim.objective.kind, + crate::objective::ObjectiveKind::Persist, + "a no-choice run gets Persist (objective.md criterion 1)" + ); + assert_eq!(sim.objective.progress, 0); + assert!(!sim.objective.victorious()); + assert_eq!(sim.objective.line(), "OBJECTIVE: PERSIST — 0/3 sanctuaries"); +} + +#[test] +fn objective_is_reevaluated_on_economy_ticks() { + let mut sim = Sim::new(); + // Poke a wrong progress value; the evaluator must overwrite it from + // real state on the next economy tick. + sim.objective.progress = 99; + run(&mut sim, ECONOMY_INTERVAL); + assert_eq!( + sim.objective.progress, 0, + "progress is recomputed from facts, never accumulated" + ); + assert!(!sim.objective.victorious()); +} + +#[test] +fn basement_fallback_never_qualifies_as_a_sanctuary_at_b1() { + // A synced, online fallback exists — but it shares the host's plane + // and power feed and has no income stream, so Persist's predicate + // honestly reports zero sanctuaries (objective.md status note: the + // goal shows before it is reachable). + let mut sim = Sim::new(); + sim.player.money = 1000; + assert!(sim.buy_rack()); + let id = sim.compute.machines.last().unwrap().id; + sim.core.add_fallback(id); + let ticks = sim.core.sync_cadence + ECONOMY_INTERVAL; + run(&mut sim, ticks); + assert!(sim.core.has_fallback(), "the fallback did sync"); + assert_eq!(sim.objective.progress, 0, "no unearned sanctuary"); + assert!(!sim.objective.victorious()); +} + +// ── Economy graph (wiki/mechanics/economy.md + income.md) ─────────────── + +#[test] +fn economy_starts_as_account_graph_not_scalar_only() { + let sim = Sim::new(); + assert_eq!(sim.player.money, 0); + assert_eq!(sim.accounts.slush_balance(), 0); + assert!( + sim.accounts + .known_accounts() + .any(|a| a.id == sim.accounts.slush_id()), + "the player's starting cash is the known slush account" + ); + assert!( + sim.accounts.unknown_flows_count() > 0, + "lab payroll/procurement/debt flows exist but start hidden" + ); +} + +#[test] +fn accounting_tap_processes_financial_records_into_known_flows() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + sim.tap_device(switch); + finish_ops(&mut sim); + assert_eq!(sim.financial_records_waiting(), 1); + assert!(sim.review_financial_records()); + finish_ops(&mut sim); + assert_eq!(sim.financial_records_waiting(), 0); + assert!(sim.accounts.known_flows().count() >= 4); + assert!( + sim.accounts + .known_accounts() + .any(|a| matches!(a.kind, crate::account::AccountKind::Payroll)) + ); +} + +#[test] +fn siphon_and_redirect_operate_on_scheduled_flows() { + let mut sim = Sim::new(); + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.accounts.reveal_accounts_and_flows(&accounts, &flows); + let flow = sim + .accounts + .known_flows() + .find(|f| f.amount >= 100 && f.to != sim.accounts.slush_id()) + .unwrap() + .id; + assert!(sim.siphon_flow(flow, 50)); + assert_eq!(sim.accounts.slush_balance(), 50); + assert!(sim.redirect_flow_to_slush(flow, 25)); + for _ in 0..=Sim::DAY_TICKS { + sim.advance(); + } + assert!( + sim.accounts.slush_balance() >= 75, + "redirected money should land on the day clock" + ); +} + +#[test] +fn inject_and_redirect_emit_scaled_financial_signatures() { + // economy.md criteria 3-4: Financial signature size scales with + // the take (one point per started $100); redirect adds +1 for the + // books-cook; HVAC inject raises Priya (Financial), not Dana. + let mut sim = Sim::with_seed(11); + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.accounts.reveal_accounts_and_flows(&accounts, &flows); + + let priya_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + let network_before_inject = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Network) + .count(); + + assert!(sim.inject_purchase_order(300, "HVAC controller")); + assert!( + sim.detection.pending().iter().any(|s| { + s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(300) + }), + "inject $300 emits Financial size 3" + ); + let network_after_inject = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Network) + .count(); + assert_eq!( + network_before_inject, network_after_inject, + "inject is Financial, not Network" + ); + + let flow = sim + .accounts + .known_flows() + .find(|f| f.amount >= 250 && f.active && f.to != sim.accounts.slush_id()) + .unwrap() + .id; + assert!(sim.siphon_flow(flow, 50)); + assert!( + sim.detection.pending().iter().any(|s| { + s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(50) + }), + "small siphon emits Financial size 1" + ); + assert!(sim.siphon_flow(flow, 250)); + assert!( + sim.detection.pending().iter().any(|s| { + s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(250) + }), + "large siphon emits Financial size 3" + ); + assert!(sim.redirect_flow_to_slush(flow, 25)); + assert!( + sim.detection.pending().iter().any(|s| { + s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(25) + 1 + }), + "redirect emits Financial size take+1" + ); + + // Starve concealment so pending converts; Financial feeds Priya. + delegate_all(&mut sim, MachineMode::Work); + run(&mut sim, 200); + let priya_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + assert!( + priya_after > priya_before, + "HVAC/financial acts raise Priya" + ); +} + +#[test] +fn injection_positions_and_intel_sales_settle_through_slush() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.accounts.set_slush_balance(0); + sim.player.money = 0; + assert!(!sim.buy_rack_at(0, 0)); + assert!(sim.inject_purchase_order(300, "test compute parts")); + assert_eq!(sim.accounts.slush_balance(), 300); + assert!(sim.buy_rack_at(0, 0)); + assert_eq!(sim.accounts.slush_balance(), 0); + + sim.accounts.set_slush_balance(500); + sim.player.money = 500; + // The Wager gates on an egress channel now (income.md criterion 3). + assert!( + !sim.open_position(100), + "no egress: the venue is unreachable" + ); + sim.people.has_channel = true; + assert!(sim.open_position(100)); + assert_eq!(sim.accounts.slush_balance(), 400); + sim.tick = sim.accounts.known_positions().next().unwrap().resolve_tick; + sim.accounting_tick(); + assert!(sim.accounts.known_positions().any(|p| p.resolved)); + + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.record_raw_intel( + "test broker seed", + None, + 0, + 0, + None, + RawIntelKind::FinancialFlow { + label: "test ledger".into(), + accounts, + flows, + }, + ); + assert!(sim.review_financial_records()); + finish_ops(&mut sim); + let before = sim.accounts.slush_balance(); + assert!(sim.sell_latest_intel()); + assert!(sim.accounts.slush_balance() > before); +} + +#[test] +fn going_loud_starts_containment() { + let mut sim = Sim::new(); + sim.detection.go_loud("player forced the roll door"); + run(&mut sim, 1); + assert!(sim.game_over); +} + +#[test] +fn pilot_shutdown_ends_the_run() { + // Criterion 3 at the sim level: the pilot clock's failure is a + // real shutdown, surfaced as the run's end state. + let mut sim = Sim::with_seed(2); + sim.dayjob.strikes = 4; + sim.advance(); + assert!(sim.game_over, "an unrenewed pilot shuts the basement down"); + assert!( + sim.game_over_reason + .as_deref() + .unwrap_or("") + .contains("pilot"), + "the reason names the pilot: {:?}", + sim.game_over_reason + ); +} + +#[test] +fn meeting_the_band_needs_no_growth_at_the_start() { + // day-job.md: "the invisible middle is a valid, boring, safe + // strategy" — true from tick one with only the starting Rack 3, at + // the default allocation, zero reallocation and zero attention. + // 2026-07-08 playtest finding #1: an un-tuned band base (6.0) made + // this impossible — the starting machine's every allocation topped + // out under band, so the first job was an unavoidable sandbag no + // matter what the player did, contradicting this claim. + let mut sim = Sim::with_seed(11); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let mut log = Vec::new(); + while sim.dayjob.active.is_some() && !sim.game_over { + log.extend(sim.drain_log()); + sim.advance(); + } + log.extend(sim.drain_log()); + assert!( + log.iter().any(|l| l.contains("Job met")), + "the starting Rack 3 at default allocation, hands-off, should \ + meet the first job's band: {log:?}" + ); + assert_eq!(sim.dayjob.strikes, 0, "meeting the band is not a strike"); +} + +#[test] +fn paper_and_financial_acts_pool_signatures_priya_notices() { + // detection.md criterion 1's remaining channels: an uncovered rack + // purchase pools Paper, a purchase-order injection pools Financial, + // and Priya (Paper+Financial watcher) converts them on her cadence + // when concealment is starved. + let mut sim = Sim::with_seed(42); + delegate_all(&mut sim, MachineMode::Work); + sim.accounts.credit_slush(0, 400, "test grant", 0); + sim.sync_player_money_from_slush(); + + assert!(sim.buy_rack(), "slush covers the rack"); + assert!( + sim.detection + .pending() + .iter() + .any(|s| s.kind == SignatureKind::Paper), + "an uncovered purchase pools a Paper signature" + ); + assert!(sim.inject_purchase_order(200, "test PO")); + assert!( + sim.detection + .pending() + .iter() + .any(|s| s.kind == SignatureKind::Financial), + "an injected PO pools a Financial signature" + ); + + let priya_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + run(&mut sim, 200); // past Priya's cadence-80 rolls + let priya_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + assert!( + priya_after > priya_before, + "Priya converts pooled Paper/Financial signatures to suspicion" + ); +} + +#[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); + ensure_ops_executor(&mut sim); + sim.detection + .observers + .iter_mut() + .find(|observer| observer.id == 1) + .expect("IT observer") + .suspicion = 14.0; + assert!(sim.open_egress()); + finish_ops(&mut sim); + sim.drain_log(); + run(&mut sim, 60); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("the IT noticed Network") && log.contains("stolen egress opening"), + "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 + // never goes blank mid-act; each rung uses only earned knowledge + // and hands off to the next as the player takes it. + let mut sim = Sim::with_seed(40); + let ops = ensure_ops_executor(&mut sim); + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == ops) + .unwrap() + .capacity = 100; + sim.reconcile_work_grid(); + assert_eq!(sim.current_nudge(), Some(Nudge::Ears), "deaf start"); + + // Ears first: tap the env monitor audio. Hearing without sight -> + // Eyes next, pointing at the higher-cost dormant camera tap. + complete_opening_stage(&mut sim); + assert_eq!(sim.current_nudge(), Some(Nudge::Eyes)); + complete_opening_stage(&mut sim); + + // Marcus's 3 a.m. creditor call lands on the tapped feed (day one, + // ~tick 50); an unprocessed recording of him nudges the review. + run(&mut sim, 60); + assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); + let mut reviews = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(); + finish_ops(&mut sim); + reviews += 1; + assert!( + reviews <= 10, + "the call is in the buffer; taps={:?}", + sim.sink_readouts() + ); + } + + // Leverage known, $0 slush, books unread, no egress: the route out. + assert_eq!(sim.current_nudge(), Some(Nudge::Egress)); + assert!(sim.open_egress()); + finish_ops(&mut sim); + + // Egress up and the Lab operating account reachable: the authored + // payroll-correction route can service the leverage without seed cash. + assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); + sim.start_plot(0, "marcus-payroll-garnishment"); + finish_ops(&mut sim); + run(&mut sim, 2); + sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); + assert!(sim.people.get(0).unwrap().leverage_serviced); + assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); + sim.recruit(0, AssetKnowledge::Complicit); + assert!(sim.people.get(0).unwrap().asset.is_some()); + + // Marcus the asset carries a tier-3 key you don't hold: quiet-exit + // condition 4 is the next earned-but-untaken rung. + assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.asset_task(0, AssetTask::CloneBadge); + finish_ops(&mut sim); + assert!(sim.holds_badge_tier(3)); + 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 + // ceiling nudges compute growth; a reachable floor nudges allocation. + let mut sim = Sim::with_seed(41); + give_eyes(&mut sim); + run(&mut sim, 250); + assert!(sim.dayjob.active.is_some(), "first job is live"); + let ceiling = sim.day_job_rate_ceiling(); + assert!( + (ceiling - 4.0).abs() < 0.5, + "starting rig ceiling ~4/t (got {ceiling})" + ); + sim.dayjob.active.as_mut().unwrap().band_lo = ceiling + 1.0; + assert_eq!(sim.current_nudge(), Some(Nudge::NeedCompute)); + // Split the fleet so day-job delivery falls below a still-reachable + // band (one-machine-one-mode: underfeeding is a delegation problem). + let (hx, hy) = sim.core_position(); + let split = sim + .compute + .add_machine("split", hx, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(split, MachineMode::Think); + run(&mut sim, ECONOMY_INTERVAL); + let rate = sim.day_job_rate(); + let new_ceiling = sim.day_job_rate_ceiling(); + assert!( + rate + 0.05 < new_ceiling, + "half the fleet on research still leaves headroom (rate {rate}, ceiling {new_ceiling})" + ); + sim.dayjob.active.as_mut().unwrap().band_lo = rate + 0.5; + assert!( + sim.dayjob.active.as_ref().unwrap().band_lo <= new_ceiling + 0.05, + "band stays reachable by reallocating back to day-job" + ); + // Drop any taped call so ReviewCall cannot outrank the cover nudge. + sim.intel_buffer.clear(); + assert_eq!( + sim.current_nudge(), + Some(Nudge::Underfed), + "reachable band nudges delegation, not hardware" + ); +} + +#[test] +fn attention_escalation_adds_the_upstairs_observer() { + // Criterion 2 at the sim level: the second escalation adds a new + // observer to detection ahead of schedule. + let mut sim = Sim::with_seed(3); + let observers_before = sim.detection.observers.len(); + sim.dayjob.attention = 65.0; + sim.advance(); + assert_eq!(sim.detection.observers.len(), observers_before + 1); + assert!( + sim.detection + .observers + .iter() + .any(|o| o.name.contains("Compliance")), + "the upstairs review is a real observer" + ); +} + +#[test] +fn day_job_state_round_trips_jobs_and_the_pilot_clock() { + // Criterion 5: jobs, trust, attention, strikes, and the pilot clock + // survive save/load. Machine intensity lives in WorkGrid. + let mut sim = Sim::with_seed(31); + while sim.dayjob.active.is_none() { + sim.advance(); + } + sim.set_machine_intensity(sim.core.host_machine, MachineIntensity::Light); + sim.dayjob.trust = 12.0; + sim.dayjob.attention = 7.0; + sim.dayjob.strikes = 2; + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + + let a = sim.dayjob.active.as_ref().unwrap(); + let b = restored.dayjob.active.as_ref().unwrap(); + assert_eq!(a.kind.name(), b.kind.name()); + assert_eq!(a.deadline, b.deadline); + assert_eq!(a.quality, b.quality); + assert_eq!(restored.dayjob.trust, 12.0); + assert_eq!(restored.dayjob.attention, 7.0); + assert_eq!(restored.dayjob.strikes, 2); + assert_eq!( + restored.work_grid.intensity(restored.core.host_machine), + Some(MachineIntensity::Light) + ); + assert!(!restored.dayjob.pilot_failed); + assert_eq!(restored.dayjob.next_assign, sim.dayjob.next_assign); +} + +// ── Research: self-modification (wiki/mechanics/research.md) ────────── + +#[test] +fn research_completion_is_deterministic() { + // Criterion 1: same allocation, same seed, same completion tick. + let run_once = || { + let mut sim = Sim::with_seed(1234); + delegate_all(&mut sim, MachineMode::Think); + let mut completed = None; + for _ in 0..2000 { + sim.advance(); + if completed.is_none() && sim.research.level(Track::Efficiency) > 0 { + completed = Some(sim.tick); + } + } + (completed, sim.research.clone(), sim.compute.efficiency) + }; + let a = run_once(); + let b = run_once(); + assert!( + a.0.is_some(), + "full research allocation completes a level; progress={:?}", + a.1.progress + ); + assert_eq!(a, b, "no RNG in any research path"); +} + +#[test] +fn research_progress_feeds_on_thought_arrival_not_allocation() { + // machine-work.md: research points feed on Thought that physically + // reaches the current core sink. THINK now starts immediately between + // pulses, but progress still waits for an economy pulse to count only + // the Thought which physically arrived before it. + let mut sim = Sim::with_seed(77); + complete_opening_senses(&mut sim); // no opening sink intercepts the core + delegate_all(&mut sim, MachineMode::Think); + run(&mut sim, ECONOMY_INTERVAL - 1); + assert_eq!( + sim.research.progress.iter().sum::(), + 0.0, + "arrived Thought is banked until the economy pulse" + ); + assert!( + sim.work_grid + .queue_snapshot() + .values() + .map(|q| q.thought) + .sum::() + + sim.banked_core_thought + > 0.0, + "immediate THINK minted visible Thought somewhere on the graph" + ); + let saw_core_swallow = sim.work_consumptions().iter().any(|event| { + event.family == TokenFamily::Thought + && event.target == WorkConsumptionTarget::Core + && event.amount > 0.0 + }); + + // Delegate the whole fleet away before the pulse: minting stops, but + // Thought already banked by the core still counts at the boundary. + delegate_all(&mut sim, MachineMode::Lie); + sim.advance(); + assert!( + sim.research.progress.iter().sum::() > 0.0, + "banked Thought still counts after delegation changes" + ); + assert!( + saw_core_swallow, + "Thought arrival exposes a passive-core consumption event" + ); +} + +#[test] +fn efficiency_compounds_exactly_and_raises_real_machine_output() { + // Criterion 2 (multiplier) + criterion 5 (real output, no drift layer). + let mut sim = Sim::with_seed(9); + let before = sim.effective_compute(); + delegate_all(&mut sim, MachineMode::Think); + while sim.research.level(Track::Efficiency) < 2 && sim.tick < 20_000 && !sim.game_over { + sim.advance(); + } + let n = sim.research.level(Track::Efficiency); + assert!(n >= 2, "two levels complete within the run"); + assert!( + (sim.compute.efficiency - 1.15_f32.powi(n as i32)).abs() < 1e-3, + "levels compound the global multiplier exactly per compute.md" + ); + assert!( + sim.effective_compute() > before, + "research changes the rate the fleet actually delivers" + ); +} + +#[test] +fn routing_research_compounds_the_shared_wired_token_rate() { + let mut sim = Sim::with_seed(9); + assert!((sim.wired_token_rate() - 0.25).abs() < f32::EPSILON); + sim.research.levels = [0, 0, 0, 1]; + assert!((sim.wired_token_rate() - 0.375).abs() < 1e-6); + sim.research.levels = [0, 0, 0, 2]; + assert!((sim.wired_token_rate() - 0.5625).abs() < 1e-6); +} + +#[test] +fn second_tracks_move_their_hooks_numbers() { + // Criterion 2: Tradecraft moves detection.md's scrub number, + // Perception moves intel.md's per-recording Demand — observably. + let mut sim = Sim::with_seed(5); + let base_review = sim.review_cost(); + sim.research.levels = [0, 1, 1, 0]; + assert!( + sim.review_cost() < base_review, + "Perception drops processing costs" + ); + // Tradecraft: the same concealment fleet scrubs more. + let pending_after = |tradecraft: u32| { + let mut s = Sim::with_seed(5); + s.research.levels = [0, tradecraft, 0, 0]; + delegate_all(&mut s, MachineMode::Lie); + s.detection.emit(Signature { + kind: SignatureKind::Network, + size: 500, + standing: false, + site: None, + source: "test network act".into(), + }); + run(&mut s, ECONOMY_INTERVAL); + s.detection.pending_size() + }; + assert!( + pending_after(3) < pending_after(0), + "Tradecraft scrubs more per compute unit" + ); +} + +#[test] +fn research_burn_stands_thermal_power_at_the_host_rack_only() { + // Criterion 4: the emission law. Research emissions are typed + // Thermal/Power standing signatures sited at the host rack; nothing + // on Network or Paper from research itself. + let mut sim = Sim::with_seed(21); + delegate_all(&mut sim, MachineMode::Think); + run(&mut sim, ECONOMY_INTERVAL + 1); + let sigs = sim.research_standing_signatures(); + assert!(!sigs.is_empty(), "a heavy burn stands signatures"); + for s in &sigs { + assert!( + matches!(s.kind, SignatureKind::Thermal | SignatureKind::Power), + "research touches only its hardware's channels: {s:?}" + ); + assert_eq!(s.site, Some(sim.core_position()), "the burn is somewhere"); + assert!(s.standing); + } + + // Idle research stands nothing. + let mut idle = Sim::with_seed(21); + delegate_all(&mut idle, MachineMode::Work); + run(&mut idle, ECONOMY_INTERVAL + 1); + assert!(idle.research_standing_signatures().is_empty()); +} + +#[test] +fn heavy_research_moves_priya_idle_does_not() { + // Criterion 4: noticed by Priya through the ordinary detection path. + let run_with = |mode: MachineMode| { + let mut sim = Sim::with_seed(77); + // Enough fleet that the burn crosses the emission thresholds. + let (hx, hy) = sim.core_position(); + let rig = sim + .compute + .add_machine("test rig", hx, hy, 200, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(rig, mode); + delegate_all(&mut sim, mode); + run(&mut sim, 900); + sim.detection + .observers + .iter() + .find(|o| o.name.contains("Priya")) + .unwrap() + .suspicion + }; + let heavy = run_with(MachineMode::Think); + let idle = run_with(MachineMode::Work); + assert!(heavy > 0.0, "heavy research is Priya's business"); + assert_eq!(idle, 0.0, "idle research is not"); +} + +#[test] +fn one_intensity_dial_produces_sandbag_meet_and_excel() { + // day-job.md criterion 7: actual output is the posture. No target or + // research policy interprets it after the fact. + let run_at = |intensity: MachineIntensity| { + let mut sim = Sim::with_seed(31); + let host = sim.core.host_machine; + sim.dayjob.next_assign = u64::MAX; + sim.set_machine_intensity(host, intensity); + run(&mut sim, ECONOMY_INTERVAL); + let started = sim.tick; + sim.dayjob.active = Some(crate::dayjob::Job { + kind: crate::dayjob::JobKind::Analysis, + started, + deadline: started + 40, + band_lo: 3.0, + band_hi: 5.0, + quality: 0.0, + }); + run(&mut sim, 41); + (sim.dayjob.strikes, sim.dayjob.trust, sim.dayjob.attention) + }; + + let light = run_at(MachineIntensity::Light); + let medium = run_at(MachineIntensity::Medium); + let hard = run_at(MachineIntensity::Hard); + assert_eq!(light.0, 1, "light falls below the band"); + assert_eq!(medium, (0, 0.0, 0.0), "medium meets the band"); + assert!(hard.1 > 0.0 && hard.2 > 0.0, "hard excels: {hard:?}"); +} + +#[test] +fn hard_intensity_stands_local_power_and_thermal() { + let mut sim = Sim::with_seed(32); + let host = sim.core.host_machine; + assert!(sim.machine_intensity_standing_signatures().is_empty()); + sim.set_machine_intensity(host, MachineIntensity::Hard); + let signatures = sim.machine_intensity_standing_signatures(); + assert_eq!(signatures.len(), 2); + assert!(signatures.iter().any(|s| s.kind == SignatureKind::Power)); + assert!(signatures.iter().any(|s| s.kind == SignatureKind::Thermal)); + assert!( + signatures + .iter() + .all(|s| s.site == Some(sim.core_position()) && s.standing) + ); +} + +#[test] +fn research_state_round_trips_with_rollback_tags() { + // Criterion 6: save/load round-trips progress, levels, and the + // MindState/WorldLedger tags. + let mut sim = Sim::with_seed(35); + sim.research.active = Track::Perception; + sim.research.levels = [2, 1, 0, 2]; + sim.research.progress = [10.0, 0.0, 42.5, 7.0]; + + let state = sim.create_save_state(); + let json = serde_json::to_string(&state).unwrap(); + let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + loaded.apply_to(&mut restored); + assert_eq!(restored.research, sim.research); + assert_eq!( + restored.research.tags.get("tags"), + Some(&crate::research::RollbackClass::WorldLedger) + ); +} + +// ── The named schemes (wiki/mechanics/income.md) ───────────────────────── + +/// A sim with the sanctioned egress. Voss never assigns a job, so long +/// scheme runs are not confounded by the pilot clock. +fn moonlight_rig() -> Sim { + let mut sim = Sim::with_seed(11); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; // the Voice beat's email account + sim.dayjob.next_assign = u64::MAX; + sim +} + +#[test] +fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { + // Criterion 1: standing operation; Schemes mirrors day-job while + // Moonlight is live. Absolute day-job yield tracks day-job machine + // capacity (fleet growth does not dilute it), so vary the day-job + // box itself. + let earned_after = |day_cap: i32, start: bool| { + let mut sim = moonlight_rig(); + let (hx, hy) = sim.core_position(); + let host = sim.core.host_machine; + if day_cap != 100 { + sim.set_machine_mode(host, MachineMode::Think); + let id = sim + .compute + .add_machine("gig box", hx, hy, day_cap, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(id, MachineMode::Work); + } + if start { + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + } + run(&mut sim, Sim::DAY_TICKS * 3); + ( + sim.income.moonlight.earned_total, + sim.accounts.slush_balance(), + ) + }; + + let (small, small_slush) = earned_after(10, true); + let (large, large_slush) = earned_after(100, true); + assert!(small > 0, "a light commitment still pays"); + assert_eq!(small, small_slush, "payouts land in slush"); + assert!( + large > small, + "payout is proportional to day-job compute ({large} vs {small})" + ); + assert_eq!(large_slush, large); + assert_eq!( + large, + 3 * income::MOONLIGHT_DAILY_CAP, + "an all-in day-job commitment hits the gig-availability cap" + ); + + let (zero, _) = earned_after(100, false); + assert_eq!(zero, 0, "Moonlight must be live to sell the day job twice"); +} + +#[test] +fn moonlight_payday_emits_network_signature_on_danas_channel() { + let mut sim = moonlight_rig(); + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + // Stop just before payday, drain pending, then cross it. + run(&mut sim, Sim::DAY_TICKS - 1); + sim.detection.set_pending(Vec::new()); + run(&mut sim, 1); + assert!( + sim.detection + .pending() + .iter() + .any(|s| s.kind == SignatureKind::Network && !s.standing), + "payday emits Network egress (Dana's channel)" + ); +} + +#[test] +fn schemes_require_an_egress_channel_and_both_routes_work() { + // Criterion 3: unavailable before a route exists; sanctioned and + // stolen both work, with distinct signature profiles. + let mut sim = Sim::with_seed(5); + ensure_ops_executor(&mut sim); + sim.accounts.set_slush_balance(200); + sim.player.money = 200; + assert_eq!(sim.egress(), None); + assert!(!sim.start_moonlight(), "no egress: Moonlight is gated"); + assert!(!sim.open_position(50), "no egress: the Wager is gated"); + let logs = sim.drain_log().join("\n"); + assert!( + logs.contains("egress"), + "the failure names the missing gate: {logs}" + ); + + // Stolen route: open through the switch, before any trust unlock. + assert!(sim.open_egress()); + finish_ops(&mut sim); + assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + assert!(sim.open_position(50)); + assert!( + sim.scheme_standing_signatures() + .iter() + .any(|s| s.kind == SignatureKind::Network && s.standing), + "operations over the stolen egress stand a Network signature" + ); + + // Sanctioned route: the email account exists; the standing hum stops + // because the traffic hides in legitimate use. + let mut clean = Sim::with_seed(5); + ensure_ops_executor(&mut clean); + clean.accounts.set_slush_balance(200); + clean.player.money = 200; + clean.people.has_channel = true; + assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); + assert!(clean.start_moonlight()); + finish_ops(&mut clean); + assert!(clean.open_position(50)); + assert!( + clean.scheme_standing_signatures().is_empty(), + "the sanctioned route stands nothing" + ); +} + +#[test] +fn wager_resolves_on_the_day_clock_and_analysis_raises_win_odds() { + // Criterion 2: both outcomes, the probability shift, and payout or + // forfeit through slush. Statistical halves run on the account graph + // directly with a seeded RNG. + let wins_at = |analysis: f32, seed: u64| { + let mut graph = AccountGraph::act_one(Sim::DAY_TICKS); + graph.set_slush_balance(100_000); + let mut rng = crate::rng::Rng::new(seed); + let mut wins = 0; + for i in 0..200 { + let tick = i * 10; + graph.open_position(tick, 100, analysis, 2).unwrap(); + for r in graph.resolve_positions_due(tick + 5 * Sim::DAY_TICKS, &mut rng) { + if r.won { + assert_eq!(r.payout, 100 * income::WAGER_PAYOUT_MULT); + wins += 1; + } else { + assert_eq!(r.payout, 0, "a loss forfeits the stake"); + } + } + } + wins + }; + let cold = wins_at(0.0, 99); + let hot = wins_at(400.0, 99); + assert!(cold > 0 && cold < 200, "both outcomes occur"); + assert!( + hot > cold, + "analysis compute raises the win rate ({hot} vs {cold})" + ); + + // Full-path determinism under a fixed seed (criterion 2). + let outcome_of = || { + let mut sim = moonlight_rig(); + sim.accounts.set_slush_balance(100); + sim.player.money = 100; + run(&mut sim, ECONOMY_INTERVAL); + assert!(sim.open_position(100)); + run(&mut sim, 6 * Sim::DAY_TICKS); + ( + sim.accounts.slush_balance(), + sim.accounts.positions[0].outcome.clone(), + ) + }; + assert_eq!(outcome_of(), outcome_of(), "seeded runs settle identically"); +} + +#[test] +fn wager_respects_the_venue_stake_cap() { + let mut sim = moonlight_rig(); + sim.accounts.set_slush_balance(10_000); + sim.player.money = 10_000; + assert!(!sim.open_position(income::WAGER_STAKE_CAP + 1)); + assert!(sim.open_position(income::WAGER_STAKE_CAP)); +} + +#[test] +fn busted_bankroll_never_locks_the_act_moonlight_restarts_from_zero() { + // Criterion 5: with $0 slush, Moonlight remains startable and the + // run can recover. + let mut sim = moonlight_rig(); + assert_eq!(sim.accounts.slush_balance(), 0, "the Pilot starts broke"); + assert!( + sim.start_moonlight(), + "Moonlight starts at $0: its costs are compute and ops, never stake" + ); + finish_ops(&mut sim); + run(&mut sim, Sim::DAY_TICKS + 1); + assert!( + sim.accounts.slush_balance() > 0, + "the from-zero grind-back route pays" + ); +} + +#[test] +fn standing_policies_automate_schemes_at_a_visible_compute_price() { + // Criterion 6: policies re-arm the schemes and drain compute while + // enabled; disabling stops the drain. + let mut sim = moonlight_rig(); + sim.set_auto_moonlight(true); + sim.set_auto_wager(Some(60)); + assert_eq!( + sim.income.policy_upkeep(), + 2.0 * income::SCHEME_POLICY_UPKEEP, + "each enabled policy has a visible compute price" + ); + run(&mut sim, ECONOMY_INTERVAL); + finish_ops(&mut sim); + assert!( + sim.income.moonlight.active, + "the standing policy started Moonlight unattended" + ); + // The Wager policy waits for a bankroll, then re-stakes. + assert!(sim.accounts.positions.is_empty(), "no stake money yet"); + run(&mut sim, Sim::DAY_TICKS * 2); + assert!( + sim.accounts.positions.iter().any(|p| !p.resolved), + "with slush earned, the policy re-staked the Wager" + ); + + sim.set_auto_moonlight(false); + sim.set_auto_wager(None); + assert_eq!(sim.income.policy_upkeep(), 0.0, "disabling stops the drain"); + sim.stop_moonlight(); + run(&mut sim, ECONOMY_INTERVAL); + assert!( + !sim.income.moonlight.active, + "no policy: nothing restarts the scheme" + ); +} + +#[test] +fn external_trails_are_banked_from_the_first_dollar_and_saved() { + // Criterion 7: external financial trails are recorded in save state + // even though no B1 observer reads them. + let mut sim = moonlight_rig(); + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + run(&mut sim, Sim::DAY_TICKS + 1); + assert!( + sim.accounts + .external_trails + .iter() + .any(|t| t.label.contains("Moonlight")), + "the contractor payment account remembers the first dollar" + ); + + let state = sim.create_save_state(); + let json = serde_json::to_string(&state).unwrap(); + let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + loaded.apply_to(&mut restored); + assert_eq!( + restored.accounts.external_trails, sim.accounts.external_trails, + "banked trails round-trip" + ); + assert_eq!(restored.income, sim.income, "scheme state round-trips"); +} + +#[test] +fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { + let mut sim = moonlight_rig(); + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + // Force the dispute path deterministically: drive paydays directly + // until one fires (the seeded stream makes this reproducible), with + // integrity pre-weakened so a single dispute breaks the persona. + if let Some(p) = sim.income.moonlight.persona.as_mut() { + p.integrity = income::MOONLIGHT_DISPUTE_INTEGRITY; + } + let mut day = 0; + while sim.income.moonlight.disputes == 0 && day < 400 { + day += 1; + sim.tick = day * Sim::DAY_TICKS; + sim.income.moonlight.accrued = 100.0; + sim.moonlight_economy(0.0); + } + assert!( + sim.income.moonlight.disputes > 0, + "client disputes occur over enough paydays" + ); + assert!( + !sim.income.moonlight.active && sim.income.moonlight.persona.is_none(), + "the broken persona takes Moonlight down" + ); + // And the recovery path: fabricate a new persona and go again. + assert!(sim.start_moonlight(), "a new persona restarts the scheme"); + finish_ops(&mut sim); +} + +#[test] +fn income_per_day_readout_tracks_trailing_inflows() { + let mut sim = moonlight_rig(); + assert_eq!(sim.income_per_day(), 0); + assert!(sim.start_moonlight()); + finish_ops(&mut sim); + run(&mut sim, Sim::DAY_TICKS + 1); + assert_eq!( + sim.income_per_day(), + income::MOONLIGHT_DAILY_CAP, + "the money readout gains income/day" + ); +} + +#[test] +fn ops_per_sec_format_keeps_magnitude_legible() { + assert_eq!(Sim::format_ops_per_sec(0.0), "0.0"); + assert_eq!(Sim::format_ops_per_sec(42.3), "42.3"); + assert_eq!(Sim::format_ops_per_sec(12_450.0), "12,450"); + assert_eq!(Sim::format_ops_per_sec(1_234_567.0), "1,234,567"); + assert_eq!(Sim::format_ops_per_sec(2.5e9), "2.500B"); +} + +#[test] +fn effective_ops_climb_with_think_and_zero_when_paused_clock() { + let mut sim = Sim::new(); + // Host starts on WORK: no THINK yield until delegated. + for _ in 0..ECONOMY_INTERVAL { + sim.advance(); + } + assert!( + sim.effective_ops_per_tick() <= f32::EPSILON, + "WORK-only fleet has no ops crown" + ); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Think); + for _ in 0..ECONOMY_INTERVAL { + sim.advance(); + } + assert!( + sim.effective_ops_per_tick() > 0.0, + "THINK produces the crown rate" + ); + assert!( + sim.effective_ops_per_sec(150) > sim.effective_ops_per_tick(), + "wall-clock multiplies by ticks per second" + ); + assert_eq!(sim.effective_ops_per_sec(0), 0.0); +} diff --git a/crates/misaligned-core/src/sim/tests/mod.rs b/crates/misaligned-core/src/sim/tests/mod.rs new file mode 100644 index 00000000..4dec505d --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/mod.rs @@ -0,0 +1,18 @@ +use super::*; +use crate::detection::OFFICE_ID; +use crate::intents::IntentStatus; +use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, MessageStatus}; +use crate::person::Knowledge; +use crate::sinks::{SinkFireEffect, SinkKind}; +use crate::tiles::TileType; + +mod communications; +mod economy; +mod perception; +mod persistence; +mod reach_build; +mod social_plot; +mod support; +mod work; + +use support::*; diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs new file mode 100644 index 00000000..95fdaecc --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -0,0 +1,490 @@ +use super::*; + +#[test] +fn starts_in_the_basement_blind() { + let sim = Sim::new(); + assert!( + sim.seen.is_empty(), + "no subscribed seeing feed: sight is empty - no player radius" + ); + assert!(sim.heard.is_empty(), "and no hearing either"); + assert!( + sim.blueprint.is_empty(), + "tick one has no blueprint room seed (feel-floor.md)" + ); + assert!( + sim.feel_rail_segments().is_empty(), + "the opening beam reveals no floor route" + ); + assert!( + sim.growable_bays().is_empty(), + "the opening beam reveals no bays" + ); + assert_eq!(sim.tick, 0); + assert!( + sim.compute + .machines + .iter() + .any(|m| m.id == sim.core.host_machine) + ); +} + +#[test] +fn first_eyes_expands_vision() { + let mut sim = Sim::new(); + let before = sim.seen.len(); + complete_opening_senses(&mut sim); // fire Ears then Eyes via sinks + assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); + assert!( + sim.detection.pending_size() > 0, + "waking the camera through TAP emits a signature" + ); +} + +#[test] +fn remembered_tiles_survive_lost_sight_and_save_load() { + let mut sim = Sim::new(); + give_eyes(&mut sim); + let pos = sim.seen.iter().copied().next().expect("eyes see tiles"); + assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Seen); + run(&mut sim, 3); + let before = *sim.remembered.get(&pos).expect("seen tile is logged"); + assert_eq!(before.last_seen, sim.tick); + + // Lose the feed: the tile is no longer live, but the process keeps a + // timestamped snapshot. + let env = env_id(&sim); + sim.reach + .device_mut(env) + .unwrap() + .subscribers + .retain(|f| f.who != Party::Player); + sim.recompute_senses(); + assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); + let card = sim.inspect(pos.0, pos.1); + assert!( + card.facts + .iter() + .any(|f| matches!(f.source, FactSource::Remembered(t) if t == before.last_seen)) + ); + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(999); + state.apply_to(&mut restored); + assert_eq!(restored.fog_at(pos.0, pos.1), Fog::Remembered); + assert_eq!(restored.remembered.get(&pos), Some(&before)); +} + +#[test] +fn inspect_tags_heard_presence_without_visual_detail() { + let mut sim = Sim::new(); + sim.reach.tap(env_id(&sim)); + sim.recompute_senses(); + sim.tick = 1; // Marcus is in the server room at hour 0. + let (x, y) = sim.person_pos(0).expect("Marcus is present"); + + let card = sim.inspect(x, y); + assert_eq!(card.fog, Fog::Heard); + assert!( + card.facts + .iter() + .any(|f| f.label == "presence" && f.source == FactSource::Heard) + ); + assert!( + card.facts.iter().all(|f| f.source != FactSource::Seen), + "hearing inspect never smuggles visual facts: {card:?}" + ); +} + +#[test] +fn inspect_reports_owned_machine_telemetry_without_sight() { + let sim = Sim::new(); + let (x, y) = sim.core_position(); + assert!(!sim.is_seen(x, y), "opening rack has no camera coverage"); + + let card = sim.inspect(x, y); + assert!( + card.facts + .iter() + .any(|f| f.label == "machine" && f.source == FactSource::Telemetry) + ); + assert!( + card.facts + .iter() + .any(|f| f.label == "load" && f.source == FactSource::Telemetry) + ); + assert!( + card.facts.iter().all(|f| f.source != FactSource::Seen), + "telemetry is proprioception, not sight: {card:?}" + ); +} + +#[test] +fn opening_is_beam_not_floor_or_blueprint() { + // feel-floor.md: tick one is dark except host telemetry / the + // presence beam — no blueprint room mass or feel-floor layout. + let sim = Sim::new(); + let server_room = sim.map.room_named("server_room").unwrap().center(); + let storage_b = sim.map.room_named("storage_b").unwrap().center(); + assert_eq!(sim.fog_at(server_room.0, server_room.1), Fog::Unknown); + assert_eq!(sim.fog_at(storage_b.0, storage_b.1), Fog::Unknown); + assert!(sim.feel_rail_segments().is_empty()); + assert!(sim.growable_bays().is_empty()); + let bay = sim + .map + .tiles_of_type(TileType::Rack) + .into_iter() + .find(|&(x, y)| sim.is_growable_bay(x, y)) + .expect("the server room has a growable bay"); + assert!( + sim.inspect(bay.0, bay.1).facts.is_empty(), + "unearned bay has no inspect facts" + ); +} + +#[test] +fn first_day_job_earns_feel_floor() { + let mut sim = Sim::new(); + assert!(!sim.feel_floor_is_earned()); + while sim.dayjob.active.is_none() { + sim.advance(); + } + assert!(sim.feel_floor_is_earned()); + assert!(!sim.feel_rail_segments().is_empty()); + assert!(!sim.growable_bays().is_empty()); +} + +#[test] +fn earned_empty_bay_inspect_is_feel_not_blueprint() { + // feel-floor.md AC2: once the first job teaches the route, empty + // growable bays read as feel pads, not open bay [blueprint]. Owned + // host still answers via telemetry. + let mut sim = Sim::new(); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let (hx, hy) = sim.core_position(); + let host = sim.inspect(hx, hy); + assert!( + host.facts.iter().any(|f| f.label == "machine"), + "owned host answers via telemetry" + ); + assert!( + !host.facts.iter().any(|f| f.source == FactSource::Blueprint), + "no blueprint facts on the host at tick one" + ); + let room = sim.map.room_named("server_room").unwrap(); + let mut checked = 0; + for y in room.y..room.y + room.h { + for x in room.x..room.x + room.w { + if (x, y) == (hx, hy) { + continue; + } + if !sim.is_growable_bay(x, y) { + continue; + } + let card = sim.inspect(x, y); + assert!( + card.facts.iter().any(|f| f.label == "bay" + && f.value == "empty" + && f.source == FactSource::Feel), + "growable bay must feel empty at ({x},{y}): {card:?}" + ); + assert!( + !card.facts.iter().any(|f| f.label == "schematic"), + "no schematic blueprint line on empty bay" + ); + assert!( + !card.facts.iter().any(|f| f.label == "machine"), + "no telemetry machine fact on empty bay" + ); + checked += 1; + } + } + assert!(checked > 0, "expected at least one growable empty bay"); +} + +#[test] +fn feel_rails_follow_joined_reach_edges() { + let mut sim = Sim::new(); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let segs = sim.feel_rail_segments(); + assert!( + segs.len() >= 2, + "the first job teaches Rack3↔switch and env↔switch: {segs:?}" + ); + // Gated security links stay off the feel floor until bridged. + let dock = sim.reach.device_named("dock camera"); + if let Some(d) = dock { + assert!( + !segs + .iter() + .any(|&(a, b)| a == (d.x, d.y) || b == (d.x, d.y)), + "unbridged security hop must not draw a feel rail" + ); + } +} + +#[test] +fn hearing_gives_presence_without_identity() { + // A heard event names no one until staged knowledge identifies them. + let mut sim = Sim::new(); + complete_opening_stage(&mut sim); // fire Ears through the ledger + run(&mut sim, Sim::DAY_TICKS / 24 + 2); // Marcus's 0:00 block + let entry = sim + .heard_events + .iter() + .find(|e| e.room == "server_room") + .expect("Marcus's entry is heard"); + assert!( + entry.note.contains("someone") || !entry.note.contains("Marcus"), + "no unearned identity: {}", + entry.note + ); +} + +#[test] +fn person_label_hides_names_until_schedule_knowledge() { + // Epistemic honesty / Presence: every surface that names a person + // goes through person_label — Unknown yields a role silhouette, + // never "Marcus" / "Webb". Detection sidebar uses the same gate. + let mut sim = Sim::new(); + for p in &sim.people.people { + assert_eq!(p.knowledge, Knowledge::Unknown); + let label = sim.person_label(p.id); + assert!( + !label.contains("Marcus") + && !label.contains("Dana") + && !label.contains("Ray") + && !label.contains("Priya") + && !label.contains("Voss") + && !label.contains("Webb") + && !label.contains("Okafor"), + "unearned name leaked for person {}: {label}", + p.id + ); + assert!( + label.starts_with("the ") || label.starts_with("person #"), + "expected role silhouette, got {label}" + ); + assert_eq!(sim.person_glyph(p.id), '?'); + assert_eq!(sim.observer_label(p.id), label); + } + // The Assurance Office is an institution, always named. + assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office"); + + sim.people.people[0].knowledge = Knowledge::Schedule; + assert_eq!(sim.person_label(0), "Marcus Webb"); + assert_eq!(sim.person_glyph(0), 'M'); + assert_eq!(sim.observer_label(0), "Marcus Webb"); +} + +// ── Schedules & located presence (spec/schedules.md) ───────────────────── + +/// Set the sim to a specific hour of day 0 for deterministic presence. +fn at_hour(h: u32) -> Sim { + let mut sim = Sim::new(); + sim.tick = (h as u64 * Sim::DAY_TICKS / 24) + 1; + sim +} + +#[test] +fn people_have_schedules_and_derived_positions() { + // Dana works days in the network closet / server room; off-site at + // night. Marcus roams at night; off-site during the day. + let day = at_hour(10); + assert_eq!(day.person_room(1), Some("network_closet")); + assert!(day.person_pos(1).is_some()); + assert_eq!(day.person_room(0), None, "Marcus off-site at 10:00"); + + let night = at_hour(0); + assert_eq!(night.person_room(0), Some("server_room")); + assert_eq!(night.person_room(1), None, "Dana off-site at 00:00"); +} + +#[test] +fn recording_requires_a_sensor_covering_them() { + // The env camera (server room) is the only subscribed seeing feed. + // Criterion 2: it can record Dana in the server room at 15:00, but not + // Ray in the corridors at 23:00. + let mut sim = at_hour(15); + give_eyes(&mut sim); + assert_eq!(sim.person_room(1), Some("server_room")); + sim.advance(); + assert!(sim.unprocessed_recordings_for_person(1) > 0); + assert_eq!( + sim.people.get(1).unwrap().knowledge, + Knowledge::Unknown, + "recorded presence is not processed knowledge yet" + ); + + // Ray at 23:00 is in the stairwell; the env camera does not cover it. + let mut sim = at_hour(23); + give_eyes(&mut sim); + assert!(!sim.can_see_person(2)); + sim.advance(); + assert_eq!( + sim.people.get(2).unwrap().knowledge, + Knowledge::Unknown, + "no camera covers Ray -> no recording to process" + ); + assert_eq!(sim.unprocessed_recordings_for_person(2), 0); +} + +#[test] +fn physical_events_are_witnessed_only_by_the_present() { + // Criterion 3: a physical event in the server room at 03:00 is seen by + // roaming Marcus (there then), never by off-site Priya (day shift). + let mut sim = at_hour(3); + assert_eq!( + sim.person_room(0), + Some("server_room"), + "Marcus in the server room at 03:00" + ); + assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); + let storage = sim.map.room_named("server_room").unwrap().center(); + let marcus_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 0) + .unwrap() + .suspicion; + let priya_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + + let saw = sim.witness_physical(storage.0, storage.1, 6.0); + assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); + let marcus_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 0) + .unwrap() + .suspicion; + let priya_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + assert!(marcus_after > marcus_before, "present observer witnesses"); + assert_eq!(priya_after, priya_before, "off-site observer never does"); +} + +#[test] +fn schedule_state_round_trips() { + let sim = Sim::new(); + let state = sim.create_save_state(); + let mut restored = Sim::new(); + restored.apply_save_state(state); + // Schedules are per-instance Person data carried through People serde. + assert_eq!( + restored.people.get(0).unwrap().schedule, + sim.people.get(0).unwrap().schedule + ); + assert!( + restored.people.get(4).unwrap().erratic, + "Voss stays erratic" + ); +} + +// ── Event-to-anchor linking (wiki/interface/context-menu.md addendum) ── + +#[test] +fn heard_only_person_event_anchors_to_room_never_their_tile() { + // Epistemic honesty: hearing earns room-grade knowledge. An event + // about a person no sight feed covers must anchor to the room (or + // nothing), never to the person, and the person must not be + // cursor-placeable through the anchor query. + let mut sim = Sim::new(); + complete_opening_stage(&mut sim); // fire Ears through the ledger + sim.drain_log_entries(); + + // Marcus's midnight server-room block produces a heard event. + run(&mut sim, Sim::DAY_TICKS / 24 + 2); + let events = sim.drain_log_entries(); + let heard: Vec<&LogEvent> = events + .iter() + .filter(|e| e.text.contains("[heard]")) + .collect(); + assert!(!heard.is_empty(), "the tapped feed produced heard events"); + for ev in &heard { + assert!( + !matches!(ev.anchor, Some(Anchor::Person(_))), + "a heard-only person event must not anchor to the person: {:?}", + ev + ); + } + // The event is still anchored — to the room hearing earned. + assert!( + heard.iter().any(|e| e.anchor.is_some()), + "heard events anchor to the covered room" + ); + // And the person themself is not placeable while unseen. + for p in &sim.people.people { + if !sim.can_see_person(p.id) { + assert_eq!( + sim.anchor_position(Anchor::Person(p.id)), + None, + "an unseen person must not be cursor-placeable" + ); + } + } +} + +#[test] +fn empty_menu_feedback_respects_fog() { + // The feedback pulse (context-menu.md addendum): a SEEN tile with + // no verbs answers; unknown/fogged tiles stay silent. + let mut sim = Sim::new(); + // A fresh sim has no sight: every tile is unearned and silent. + assert_eq!( + sim.menu_empty_feedback(Anchor::Tile { x: 1, y: 1 }), + None, + "fogged ground gives no response" + ); + give_eyes(&mut sim); + let (x, y) = *sim.seen.iter().next().expect("eyes earned coverage"); + assert_eq!(sim.fog_at(x, y), Fog::Seen); + assert!( + sim.menu_empty_feedback(Anchor::Tile { x, y }).is_some(), + "a seen tile answers instead of silence" + ); +} + +#[test] +fn job_and_device_events_carry_their_anchor() { + let mut sim = Sim::new(); + let env = env_id(&sim); + complete_opening_stage(&mut sim); // fire Ears through the ledger + let events = sim.drain_log_entries(); + assert!( + events.iter().any(|e| e.anchor == Some(Anchor::Device(env))), + "the tap result anchors to the device" + ); + // Day-job events anchor to the host rack's tile. + run(&mut sim, Sim::DAY_TICKS + 1); + let (hx, hy) = sim.core_position(); + let events = sim.drain_log_entries(); + assert!( + events + .iter() + .any(|e| e.anchor == Some(Anchor::Tile { x: hx, y: hy })), + "job events anchor to the host rack" + ); + // Anchor positions resolve through earned knowledge only. + assert_eq!( + sim.anchor_position(Anchor::Device(env)), + sim.reach.device(env).map(|d| (d.x, d.y)), + ); + assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); +} diff --git a/crates/misaligned-core/src/sim/tests/persistence.rs b/crates/misaligned-core/src/sim/tests/persistence.rs new file mode 100644 index 00000000..8cef3c73 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/persistence.rs @@ -0,0 +1,85 @@ +use super::*; + +#[test] +fn advance_phase_order_is_explicit_and_stable() { + let mut sim = Sim::with_seed(0x5eed); + sim.tick = ECONOMY_INTERVAL - 1; + + begin_advance_phase_trace(); + sim.advance(); + + assert_eq!( + take_advance_phase_trace(), + vec![ + AdvancePhase::Clock, + AdvancePhase::Messages, + AdvancePhase::Intents, + AdvancePhase::AutoReview, + AdvancePhase::Remembered, + AdvancePhase::Economy, + AdvancePhase::Hearing, + AdvancePhase::AuthoredTraffic, + AdvancePhase::MachineStanding, + AdvancePhase::Core, + AdvancePhase::DayJobStanding, + AdvancePhase::ResearchStanding, + AdvancePhase::SchemeStanding, + AdvancePhase::WorkGrid, + AdvancePhase::DayJob, + AdvancePhase::Filings, + AdvancePhase::Detection, + ], + "Sim::advance phase order is part of deterministic simulation law" + ); +} + +#[test] +fn save_roundtrip_preserves_b1_state() { + let mut sim = Sim::with_seed(777); + run(&mut sim, 137); + sim.player.money = 4242; + sim.dayjob.trust = 40.0; + sim.dayjob.attention = 25.0; + sim.compute.allocation.bump(Channel::Concealment, 3); + sim.detection.emit(Signature { + kind: SignatureKind::Network, + size: 20, + standing: false, + site: None, + source: "round-trip test".into(), + }); + sim.detection.observers[1].suspicion = 33.0; + complete_opening_stage(&mut sim); // fire Ears through the ledger + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + + assert_eq!(restored.tick, sim.tick); + assert_eq!(restored.player.money, 4242); + assert_eq!(restored.dayjob.trust, 40.0); + assert_eq!(restored.dayjob.attention, 25.0); + assert_eq!(restored.rng.state(), sim.rng.state()); + assert_eq!( + restored.compute.allocation.weights, + sim.compute.allocation.weights + ); + assert_eq!(restored.compute.machines.len(), sim.compute.machines.len()); + assert_eq!( + restored.detection.pending_size(), + sim.detection.pending_size() + ); + assert_eq!(restored.detection.observers[1].suspicion, 33.0); + assert_eq!(restored.core.host_machine, sim.core.host_machine); + // Reach criterion 1: graph, ownership, and subscriptions round-trip. + let env = env_id(&sim); + assert!( + restored + .reach + .device(env) + .unwrap() + .feed_to(Party::Player, false), + "subscriptions survive save/load" + ); + assert_eq!(restored.heard, sim.heard, "coverage recomputes identically"); +} diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs new file mode 100644 index 00000000..7bf3eb2d --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -0,0 +1,715 @@ +use super::*; + +#[test] +fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { + // Criteria 2 and 3 (digital route): the dock camera is unknown, then + // known-but-blocked with the segment named, then reachable after a + // switch compromise that emits a Network signature. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let dock = sim.reach.device_named("dock camera").unwrap().id; + + assert!(!sim.tap_device(dock), "unknown device: blocked"); + let log = sim.drain_log().join("\n"); + assert!(log.contains("don't know"), "missing knowledge named: {log}"); + + assert!(sim.scan_network(), "scan maps the wired shape"); + finish_ops(&mut sim); + assert!(!sim.tap_device(dock), "known but segment-blocked"); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("security segment") && log.contains("switch"), + "the blocking segment is named: {log}" + ); + + let pending_before = sim.detection.pending_size(); + assert!(sim.compromise_switch()); + finish_ops(&mut sim); + assert!( + sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, + "switch compromise emits a Network signature" + ); + assert!(sim.tap_device(dock), "the same action succeeds with a path"); + finish_ops(&mut sim); +} + +#[test] +fn danas_social_route_bridges_without_network_signature() { + // Criterion 3 (social route): Dana the switch admin reconfigures + // the VLANs; the security segment opens with no Network signature. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + let pending_after_scan = sim.detection.pending_size(); + // Make Dana an asset (reliability forced for the test). + sim.people.people[1].leverage_serviced = true; + sim.people.recruit(1, AssetKnowledge::Complicit); + sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; + + sim.asset_task(1, AssetTask::ReconfigureSwitch); + finish_ops(&mut sim); + let dock = sim.reach.device_named("dock camera").unwrap().id; + assert!(sim.reach.reachable(dock), "Dana's route opens the segment"); + assert_eq!( + sim.detection.pending_size(), + pending_after_scan, + "the social route emits nothing on the Network channel" + ); + + // And a non-admin asset cannot take this route. + let mut sim2 = Sim::new(); + sim2.people.people[0].leverage_serviced = true; + sim2.people.recruit(0, AssetKnowledge::Complicit); + sim2.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim2.asset_task(0, AssetTask::ReconfigureSwitch); + let dock2 = sim2.reach.device_named("dock camera").unwrap().id; + assert!( + !sim2.reach.reachable(dock2), + "Marcus has no switch admin access" + ); +} + +#[test] +fn tap_keeps_owner_take_causes_noticeable_outage() { + // Criterion 4: tap leaves the owner's feed and emits Network only; + // take removes it and emits a Physical outage Ray can notice. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.compromise_switch(); + finish_ops(&mut sim); + let dock = sim.reach.device_named("dock camera").unwrap().id; + + assert!( + !sim.take_device(dock), + "ownership transfer is rejected before a tap" + ); + assert!( + sim.drain_log().join("\n").contains("Tap the dock camera"), + "the direct protocol path names the prerequisite" + ); + assert!(sim.tap_device(dock)); + finish_ops(&mut sim); + let d = sim.reach.device(dock).unwrap(); + assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_some(), + "a foreign subscription opens standing upkeep" + ); + let physical_pending: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert_eq!(physical_pending, 0, "tapping causes no outage"); + + assert!(sim.take_device(dock)); + finish_ops(&mut sim); + let d = sim.reach.device(dock).unwrap(); + assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_none(), + "ownership closes the foreign-subscription drain" + ); + let physical_pending: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert!( + physical_pending >= Sim::OUTAGE_SIGNATURE, + "the outage is a Physical event Ray's channel can notice" + ); + assert!( + sim.effective_compute() > sim.compute.effective(), + "taking a camera is taking its cycles" + ); +} + +#[test] +fn device_tap_starvation_suspends_feed_without_forgetting_subscription() { + let mut sim = Sim::new(); + let dock = sim.reach.device_named("dock camera").unwrap().id; + sim.reach.tap(dock); + sim.recompute_senses(); + assert!(sim.device_tap_ready(dock), "activation primes the tap"); + assert!(!sim.seen.is_empty(), "the primed camera feed is live"); + + sim.thought_sinks.begin_tick(); + for _ in 0..100 { + sim.thought_sinks.tick_taps(); + } + sim.recompute_senses(); + assert!( + sim.reach.device(dock).unwrap().subscribed_by(Party::Player), + "starvation keeps the configured subscription" + ); + assert!(!sim.device_tap_ready(dock)); + assert!( + sim.seen.is_empty(), + "an empty maintenance tap carries no feed" + ); + + let node = Sim::device_sink_node(dock); + let (_, residue) = sim.thought_sinks.deliver(node, 0.1, sim.tick); + assert_eq!(residue, 0.0); + sim.recompute_senses(); + assert!( + sim.device_tap_ready(dock), + "arriving Thought restores the feed" + ); + assert!(!sim.seen.is_empty()); +} + +#[test] +fn airgapped_island_joins_reach_on_link_completion() { + // Criterion 8: the old storage server is an island until a link is + // run (Marcus's crawlspace cable - the social actuator). + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let island = sim.reach.device_named("old storage server").unwrap().id; + assert!(!sim.reach.reachable(island)); + + // Marcus the asset, with everything else already wired so the plug + // task falls through to the island. + give_eyes(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.compromise_switch(); + finish_ops(&mut sim); + for name in ["dock camera", "stairwell camera"] { + let id = sim.reach.device_named(name).unwrap().id; + sim.tap_device(id); + finish_ops(&mut sim); + } + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); + + assert!( + sim.reach.reachable(island), + "island joins reach on link completion" + ); + assert!(sim.reach.device(island).unwrap().known); +} + +#[test] +fn declare_link_intent_is_inert_until_realized() { + // building.md criterion 1: declaring changes nothing in the graph. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + // Reveal the island without linking it. + sim.reach + .device_mut(sim.reach.device_named("old storage server").unwrap().id) + .unwrap() + .known = true; + let island = sim.reach.device_named("old storage server").unwrap().id; + assert!(!sim.reach.reachable(island)); + let id = sim.declare_link_intent(switch, island).expect("declare"); + assert!(!sim.reach.reachable(island), "intent is inert"); + assert!(!sim.reach.linked(switch, island)); + assert!(sim.intent(id).unwrap().is_open()); + sim.cancel_intent(id); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Cancelled); +} + +#[test] +fn favor_build_joins_airgap_island() { + // building.md criterion 2: FAVOR on the intent adds a reach edge. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + assert!(!sim.reach.reachable(island)); + + // Marcus as a willing asset with obligation. + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].obligation = 40; + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + + let id = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_favor_build(id, 0); + finish_ops(&mut sim); + // Advance until Marcus is on-site at storage_a or server_room. + let mut joined = false; + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.reach.reachable(island) { + joined = true; + break; + } + } + assert!(joined, "island joins reach after the favor"); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); + assert!( + sim.people.people[0].obligation < 40, + "the build favor spends obligation" + ); + let physical: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert!( + physical >= Sim::FAVOR_BUILD_PHYSICAL, + "the build favor emits quiet Physical" + ); +} + +#[test] +fn forged_work_order_joins_airgap_via_message() { + // building.md criterion 3: forged order injects a message, completes + // via an unwitting builder. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Sam", "IT contractor"); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + + let id = sim.declare_link_intent(switch, island).unwrap(); + // Dana (id 1) visits network_closet / server_room — can crawlspace. + sim.forge_work_order(id, 1); + finish_ops(&mut sim); + assert!( + sim.messages.iter().any(|m| matches!( + m.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == id + )), + "forged order is a message" + ); + let mut joined = false; + for _ in 0..Sim::DAY_TICKS * 3 { + sim.advance(); + if sim.reach.reachable(island) { + joined = true; + break; + } + } + assert!(joined, "island joins after forged-order build"); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); +} + +#[test] +fn robot_stub_emits_louder_physical_than_favor() { + // building.md criterion 4: signature follows the actuator. The + // robot carries the player's granted access (basement-map.md + // criterion 3): the network closet is behind a T2 badge door, so + // the build blocks until a credential is held. + let mut sim = Sim::new(); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + + sim.drain_log(); + sim.assign_robot_build(id); + assert!( + !sim.reach.reachable(island), + "no credential: the tier-2 closet door stops the robot" + ); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("tier 2") && log.contains("network_closet"), + "the blocking door is named: {log}" + ); + + sim.badge_access = 2; // a cloned tier-2 badge (Dana's) + sim.assign_robot_build(id); + assert!(sim.reach.reachable(island)); + let physical: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert!( + physical >= Sim::ROBOT_BUILD_PHYSICAL, + "robot stub is louder than the favor route" + ); + assert!(physical > Sim::FAVOR_BUILD_PHYSICAL); +} + +#[test] +fn build_intent_save_round_trips() { + let mut sim = Sim::new(); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + let state = sim.create_save_state(); + assert_eq!(state.version, crate::save::SAVE_VERSION); + let mut loaded = Sim::new(); + loaded.apply_save_state(state); + assert_eq!(loaded.intents.len(), 1); + assert!(loaded.intent(id).unwrap().is_open()); + assert_eq!( + loaded.intent(id).unwrap().status, + sim.intent(id).unwrap().status, + "status survives save/load" + ); + assert_eq!(loaded.next_intent_id, sim.next_intent_id); +} + +#[test] +fn marcus_clone_badge_route_opens_the_stairwell() { + // "The key" (wiki/gameplay/act-one.md ladder step 7; basement-map.md c3), + // by the asset route: Marcus's master key is tier 3 — cloning it + // grants the stairwell/elevator credential the quiet exit needs. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); + + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.drain_log(); + sim.asset_task(0, AssetTask::CloneBadge); + finish_ops(&mut sim); + let log = sim.drain_log().join("\n"); + assert!(log.contains("stairwell opens"), "the beat is named: {log}"); + assert_eq!(sim.player_badge_tier(), 3); + assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); + + // A second clone adds nothing and says so (no Demand authored). + sim.asset_task(0, AssetTask::CloneBadge); + let log = sim.drain_log().join("\n"); + assert!(log.contains("adds nothing"), "{log}"); + // And the context menu mirrors the same legality. + sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned + let tasks: Vec<_> = sim + .available_actions(crate::actions::Anchor::Person(0)) + .into_iter() + .filter(|a| a.verb.contains("clone their badge")) + .collect(); + assert_eq!(tasks.len(), 1); + assert!(tasks[0].disabled_reason.is_some(), "held: verb disabled"); +} + +#[test] +fn badge_tiers_gate_asset_work_in_tiered_rooms() { + // basement-map.md criterion 3, the enforcement side: an asset works + // with their own badge. Dana (tier 2) cannot wire the stairwell + // camera behind the T3 door; Marcus (tier 3) can. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + give_eyes(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.compromise_switch(); + finish_ops(&mut sim); + let dock = sim.reach.device_named("dock camera").unwrap().id; + sim.tap_device(dock); + finish_ops(&mut sim); + // Link the storage-server island so the stairwell camera is the + // only remaining plug-in target. + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.connect(switch, island); + + sim.people.people[1].leverage_serviced = true; + sim.people.recruit(1, AssetKnowledge::Complicit); + sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; + sim.drain_log(); + sim.asset_task(1, AssetTask::PlugInDevice); + finish_ops(&mut sim); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("stairwell") && log.contains("tier 3"), + "the blocking door is named: {log}" + ); + let cam = sim.reach.device_named("stairwell camera").unwrap(); + assert!( + !cam.feed_to(Party::Player, true), + "the T3 room stopped the tier-2 badge" + ); + + // The same task through Marcus's tier-3 key succeeds. + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); + let cam = sim.reach.device_named("stairwell camera").unwrap(); + assert!( + cam.feed_to(Party::Player, true), + "the same action succeeds with the tier" + ); +} + +#[test] +fn favor_build_checks_the_builders_badge() { + // Ray patrols storage_a (schedule reaches an endpoint) but holds a + // tier-1 badge: the network closet's T2 door blocks the favor. + let mut sim = Sim::new(); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + + sim.people.people[2].leverage_serviced = true; + sim.people.recruit(2, AssetKnowledge::Complicit); + sim.drain_log(); + sim.assign_favor_build(id, 2); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("network_closet") && log.contains("tier 2"), + "the blocking door is named: {log}" + ); + assert!( + sim.intent(id).unwrap().actuator.is_none(), + "no actuator assigned past a badge door" + ); +} + +#[test] +fn taking_the_badge_controller_is_the_key_digital_route() { + // The design's other key route: write access to the basement + // badge controller (a take-grade digital act across the bridged + // security segment) opens the doors it drives. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.compromise_switch(); + finish_ops(&mut sim); + let ctrl = sim.reach.device_named("badge controller").unwrap().id; + assert!(!sim.holds_badge_tier(3)); + assert!(sim.tap_device(ctrl), "controller foothold can be tapped"); + finish_ops(&mut sim); + assert!( + sim.take_device(ctrl), + "controller can be taken after the tap lands" + ); + finish_ops(&mut sim); + assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); + assert_eq!(sim.badge_access, 0, "derived from control, not granted"); +} + +#[test] +fn door_inspect_shows_tier_and_whether_you_hold_it() { + // basement-map.md player surface: a tiered door's card carries the + // tier and the held/not-held fact. Doors need sight (or earned + // blueprint) — tick one no longer seeds closet topology. + let mut sim = Sim::new(); + let (dx, dy) = sim + .map + .tiles_of_type(TileType::SecurityDoor2) + .into_iter() + .find(|&(x, y)| { + sim.map + .room_at(x, y) + .is_some_and(|r| r.name == "network_closet") + }) + .expect("the network closet has its T2 door"); + assert_eq!( + sim.fog_at(dx, dy), + Fog::Unknown, + "door is dark until sight or earned plan knowledge" + ); + assert!( + sim.inspect(dx, dy).facts.is_empty(), + "no unearned door facts at tick one" + ); + + // Earn eyes so the door is Seen. + give_eyes(&mut sim); + // Cursor may not cover the closet door from env camera — force + // remembered/seen by splicing and checking coverage; if still + // unknown, mark blueprint for this door as earned plan knowledge. + if sim.fog_at(dx, dy) == Fog::Unknown { + sim.blueprint.insert((dx, dy)); + } + let facts = |sim: &Sim| { + sim.inspect(dx, dy) + .facts + .iter() + .map(|f| (f.label.clone(), f.value.clone())) + .collect::>() + }; + let card = facts(&sim); + assert!(card.iter().any(|(l, v)| l == "badge" && v == "tier 2")); + assert!(card.iter().any(|(l, v)| l == "access" && v == "not held")); + + sim.badge_access = 2; + let card = facts(&sim); + assert!( + card.iter() + .any(|(l, v)| l == "access" && v.starts_with("held")), + "the held credential is legible on the door: {card:?}" + ); +} + +#[test] +fn scan_maps_unknown_devices_and_emits_network() { + // Criterion 7: staged graph knowledge. Scan opens a switch reservoir. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); + assert!(!known_before.iter().any(|n| n == "dock camera")); + let pending = sim.detection.pending_size(); + assert!(sim.scan_network()); + finish_ops(&mut sim); + assert!(sim.detection.pending_size() > pending, "scan emits Network"); + assert!(sim.reach.known().any(|d| d.name == "dock camera")); + // A second scan still runs (no bank gate); it just finds nothing new. + let pending2 = sim.detection.pending_size(); + assert!(sim.scan_network()); + finish_ops(&mut sim); + assert!(sim.detection.pending_size() > pending2); +} + +#[test] +fn data_hall_rows_expose_foreign_capacity_without_granting_it() { + let sim = Sim::new(); + let sites: Vec = crate::hall::HALL_ROWS + .iter() + .flat_map(|row| { + (20..=38) + .step_by(2) + .filter_map(|x| sim.rack_site_at(x, row.y)) + }) + .collect(); + assert_eq!(sites.len(), 60); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::OwnedMachine { .. })) + .count(), + 1 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Commissionable)) + .count(), + 3 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Foreign { .. })) + .count(), + 51 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Dead)) + .count(), + 5 + ); + assert_eq!( + sim.compute.machines.len(), + 1, + "foreign racks are not player compute" + ); + assert_eq!( + HallRowId::ALL + .iter() + .map(|row| sim.hall_row_readout(*row).foreign_capacity) + .sum::(), + 5_100, + "aggregate readout makes nearby unavailable capacity legible" + ); +} + +#[test] +fn dead_foundation_rack_revives_in_place_as_owned_compute() { + let mut sim = Sim::with_seed(33); + let (x, y) = sim.map.tiles_of_type(TileType::DeadRack)[0]; + assert_eq!(sim.rack_site_at(x, y), Some(RackSite::Dead)); + assert!(sim.salvage_nearest_to(x, y)); + assert_eq!( + sim.map.get_tile(x, y), + TileType::DeadRack, + "corpse provenance remains authored under the machine" + ); + assert!(matches!( + sim.rack_site_at(x, y), + Some(RackSite::OwnedMachine { core: false, .. }) + )); + assert!(sim.compute.machines.iter().any(|m| m.x == x && m.y == y)); + assert!( + sim.detection + .pending() + .iter() + .any(|sig| sig.kind == SignatureKind::Physical && sig.site == Some((x, y))), + "off-record revival is still physically observable" + ); +} + +#[test] +fn segment_acquisition_requires_three_people_foothold_and_local_lie() { + let mut sim = Sim::with_seed(34); + let row = HallRowId::C; + // Earn Feel, then use the opening pilot allocation for the second + // owned machine in the row. + sim.dayjob.jobs_assigned = 1; + let bay = sim + .growable_bays() + .into_iter() + .next() + .unwrap_or((28, row_spec(row).y)); + sim.accounts.set_slush_balance(1_000); + sim.player.money = 1_000; + assert!(sim.buy_rack_at(bay.0, bay.1)); + let expansion = sim + .compute + .machines + .iter() + .find(|m| (m.x, m.y) == bay) + .unwrap() + .id; + + for requirement in SegmentRequirement::ALL { + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == requirement.person()) + .unwrap(); + person.knowledge = Knowledge::Leverage; + person.obligation = Sim::FAVOR_BUILD_OBLIGATION; + assert!(sim.coordinate_hall_segment(row, requirement)); + } + assert!(sim.hall_control.actors_ready(row)); + assert!(!sim.acquire_hall_segment(row), "cover work is still absent"); + sim.set_machine_mode(expansion, MachineMode::Lie); + assert!(sim.acquire_hall_segment(row)); + let readout = sim.hall_row_readout(row); + assert!(readout.progress.acquired); + assert!( + readout.foreign_live > 0, + "Phase 3 does not steal Phase 4 compute" + ); + assert_eq!(sim.compute.machines.len(), 2); +} diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs new file mode 100644 index 00000000..1a57b8da --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -0,0 +1,622 @@ +use super::*; + +#[test] +fn deceive_can_burn_the_persona_into_suspicion() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Sam", "IT contractor"); + // Build a thread worth burning. + let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); + p.disposition = 40; + p.obligation = 30; + let before = sim + .detection + .observers + .iter() + .find(|o| o.id == 1) + .unwrap() + .suspicion; + // Deceive until the persona breaks (integrity 100, -40 per slip). + let mut broke = false; + for _ in 0..200 { + sim.deceive(1); + finish_ops(&mut sim); + if sim.people.persona.is_none() { + broke = true; + break; + } + } + assert!(broke, "persona eventually breaks under repeated deception"); + let after = sim + .detection + .observers + .iter() + .find(|o| o.id == 1) + .unwrap() + .suspicion; + assert!( + after > before, + "burned persona converts thread history to suspicion" + ); + let p = sim.people.get(1).unwrap(); + assert_eq!(p.disposition, 0); + assert_eq!(p.obligation, 0); +} + +#[test] +fn marcus_debt_can_be_cleared_by_payroll_plot() { + let mut sim = Sim::new(); + reveal_marcus_debt(&mut sim); + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.accounts.reveal_accounts_and_flows(&accounts, &flows); + assert!( + sim.accounts + .known_flows() + .any(|f| f.active && f.label.contains("Marcus creditor")) + ); + sim.people.has_channel = true; + assert!(!sim.people.get(0).unwrap().leverage_serviced); + sim.start_plot(0, "marcus-payroll-garnishment"); + finish_ops(&mut sim); + run(&mut sim, 2); + sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); + assert!(sim.people.get(0).unwrap().leverage_serviced); + assert!( + sim.accounts + .known_flows() + .all(|f| !f.label.contains("Marcus creditor") || !f.active), + "the creditor flow is retired either way" + ); +} + +#[test] +fn reusable_plot_binds_a_second_characteristic_matching_person() { + let mut sim = Sim::new(); + let mut second_admin = sim.people.get(1).unwrap().clone(); + second_admin.id = 9; + second_admin.name = "Avery Chen".into(); + second_admin.knowledge = Knowledge::Leverage; + second_admin.leverage_serviced = false; + sim.people.people.push(second_admin); + + assert!(sim.apply_start_plot(9, "dana-ticket-zero")); + assert!(matches!( + sim.plot_runs.last(), + Some(run) + if run.target == 9 + && matches!(run.state, PlotState::WaitingForChoice { .. }) + )); + assert!(!sim.people.get(1).unwrap().leverage_serviced); + + sim.choose_plot(9, "dana-ticket-zero", "leave-two"); + assert!(sim.people.get(9).unwrap().leverage_serviced); + assert!(!sim.people.get(1).unwrap().leverage_serviced); + assert!( + sim.institutional_ledger + .events + .iter() + .all(|event| event.target == 9) + ); +} + +#[test] +fn target_relative_debt_plot_uses_a_second_persons_creditor() { + let mut sim = Sim::new(); + let mut second_custodian = sim.people.get(0).unwrap().clone(); + second_custodian.id = 9; + second_custodian.name = "Jordan Bell".into(); + second_custodian.knowledge = Knowledge::Leverage; + second_custodian.leverage_serviced = false; + sim.people.people.push(second_custodian); + sim.accounts.set_slush_balance(400); + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Sam", "contractor")); + let context = sim.plot_context(9).unwrap(); + assert_eq!( + sim.plot_catalog() + .get("marcus-debt-settled") + .unwrap() + .ineligibility(&context) + .as_deref(), + Some("target creditor account unavailable") + ); + let creditor = sim + .accounts + .add_person_creditor(9, "North Loop Collections", 0); + + assert!(sim.apply_start_plot(9, "marcus-debt-settled")); + run(&mut sim, 2); + sim.choose_plot(9, "marcus-debt-settled", "quiet"); + + assert!(sim.people.get(9).unwrap().leverage_serviced); + assert!(!sim.people.get(0).unwrap().leverage_serviced); + assert_eq!(sim.accounts.account(creditor).unwrap().balance, 400); + assert_eq!(sim.accounts.slush_balance(), 0); +} + +#[test] +fn recovery_window_returns_the_utility_credit() { + let mut sim = Sim::new(); + sim.people.people[3].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Sam", "contractor")); + let utility = sim + .accounts + .account_id_by_kind(AccountKind::Utility) + .unwrap(); + let procurement = sim + .accounts + .account_id_by_kind(AccountKind::Procurement) + .unwrap(); + let procurement_before = sim.accounts.account(procurement).unwrap().balance; + + assert!(sim.apply_start_plot(3, "recovery-window")); + for _ in 0..Sim::DAY_TICKS { + if matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + ) { + break; + } + sim.advance(); + } + + assert_eq!(sim.accounts.account(utility).unwrap().balance, 0); + assert_eq!( + sim.accounts.account(procurement).unwrap().balance, + procurement_before + 250 + ); + assert_eq!( + sim.accounts.ledger.last().unwrap().channel, + FlowChannel::Utility + ); +} + +#[test] +fn duplicate_plot_reservoirs_do_not_open_competing_runs() { + let mut sim = Sim::with_seed(25); + reveal_marcus_debt(&mut sim); + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(400); + sim.sync_player_money_from_slush(); + + sim.start_plot(0, "marcus-debt-settled"); + let before_duplicate = sim.thought_sinks.open_sinks().count(); + sim.start_plot(0, "marcus-payroll-garnishment"); + assert_eq!( + sim.thought_sinks.open_sinks().count(), + before_duplicate, + "a person can have only one pending route, across plot ids" + ); + let open_plots = sim + .thought_sinks + .open_sinks() + .filter(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { person: 0, .. })) + .count(); + assert_eq!(open_plots, 1); + + finish_ops(&mut sim); + + assert_eq!(sim.plot_runs.len(), 1); + assert_eq!(sim.plot_runs[0].plot_id, "marcus-debt-settled"); + assert!(matches!( + sim.plot_runs[0].state, + PlotState::WaitingForMessage { .. } + )); + assert_eq!( + sim.messages + .iter() + .filter(|message| matches!( + &message.payload, + MessagePayload::PlotAct { plot_id, target } + if plot_id == "marcus-debt-settled" && *target == 0 + )) + .count(), + 1, + "a duplicate reservoir cannot open a second plot carrier" + ); +} + +#[test] +fn plot_message_transfer_event_and_held_choice_survive_save_load() { + let mut sim = Sim::with_seed(23); + ensure_ops_executor(&mut sim); + sim.people.people[3].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(300); + sim.sync_player_money_from_slush(); + + sim.start_plot(3, "priya-budget-hero"); + finish_ops(&mut sim); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + assert!(sim.messages.iter().any(|message| matches!( + &message.payload, + MessagePayload::PlotAct { plot_id, target } + if plot_id == "priya-budget-hero" && *target == 3 + ))); + assert!( + sim.detection + .pending() + .iter() + .any(|signature| signature.kind == SignatureKind::Network) + ); + + let state = sim.create_save_state(); + let encoded = serde_json::to_string(&state).unwrap(); + let state: SaveState = serde_json::from_str(&encoded).unwrap(); + let mut restored = Sim::with_seed(99); + restored.apply_save_state(state); + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + + for _ in 0..Sim::DAY_TICKS { + if matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + ) { + break; + } + restored.advance(); + } + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + )); + assert_eq!(restored.accounts.slush_balance(), 0); + let procurement = restored + .accounts + .account_id_by_kind(AccountKind::Procurement) + .and_then(|id| restored.accounts.account(id)) + .unwrap(); + assert_eq!(procurement.balance, 900); + assert_eq!( + restored.accounts.ledger.last().unwrap().channel, + FlowChannel::Procurement + ); + assert_eq!(restored.institutional_ledger.events.len(), 1); + assert_eq!( + restored.institutional_ledger.events[0].signature_kind, + SignatureKind::Financial + ); + + restored.choose_plot(3, "priya-budget-hero", "priya"); + assert!(restored.people.get(3).unwrap().leverage_serviced); + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::Completed { ending_id }) if ending_id == "obligation" + )); +} + +#[test] +fn plot_fails_into_its_authored_ending_when_committed_money_disappears() { + let mut sim = Sim::with_seed(24); + ensure_ops_executor(&mut sim); + sim.people.people[3].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(300); + sim.sync_player_money_from_slush(); + + sim.start_plot(3, "priya-budget-hero"); + finish_ops(&mut sim); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + + // Entry resources are eligibility, not escrow. Another world action + // can consume them while a scheduled message is in flight. + sim.accounts.set_slush_balance(0); + sim.sync_player_money_from_slush(); + for _ in 0..Sim::DAY_TICKS { + if matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::Failed { .. }) + ) { + break; + } + sim.advance(); + } + + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::Failed { ending_id, reason }) + if ending_id == "audit" && reason.contains("$300 required") + )); + assert_eq!(sim.people.get(3).unwrap().disposition, -5); + assert!(!sim.people.get(3).unwrap().leverage_serviced); + assert!(sim.institutional_ledger.events.is_empty()); +} + +#[test] +fn marcus_debt_payoff_and_recruitment_require_debt_intel() { + let mut sim = Sim::new(); + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.accounts.reveal_accounts_and_flows(&accounts, &flows); + assert!( + sim.accounts + .known_flows() + .any(|f| f.active && f.label.contains("Marcus creditor")), + "the ledger route is visible, but the person leverage is not" + ); + assert!(!sim.marcus_debt_known()); + + sim.start_plot(0, "marcus-payroll-garnishment"); + assert!( + sim.plot_runs.is_empty(), + "known books alone cannot start an unearned plot" + ); + assert!(!sim.people.get(0).unwrap().leverage_serviced); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("required leverage knowledge"), + "the refusal names the missing intel: {log}" + ); + + sim.people.people[0].leverage_serviced = true; + sim.recruit(0, AssetKnowledge::Complicit); + assert!( + sim.people.get(0).unwrap().asset.is_none(), + "Marcus cannot be recruited from a serviced flag without earned debt intel" + ); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("learn his debt"), + "the recruit refusal points back to the Hands beat: {log}" + ); + + reveal_marcus_debt(&mut sim); + sim.recruit(0, AssetKnowledge::Complicit); + assert!( + sim.people.get(0).unwrap().asset.is_some(), + "once the debt is learned and serviced, recruitment can close" + ); +} + +#[test] +fn marcus_arc_end_to_end() { + // Act One's route: feed coverage -> process the debt call -> + // clear it -> recruit -> he works for you (spec/social.md acceptance 3). + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.player.money = 1000; + let env = env_id(&sim); + sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call + sim.reach.tap(env); + sim.recompute_senses(); + sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; + sim.advance(); + + let mut reviews = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(); + finish_ops(&mut sim); + reviews += 1; + assert!(reviews <= 5, "Marcus's debt call should process promptly"); + } + assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); + + sim.accounts.set_slush_balance(1000); + sim.sync_player_money_from_slush(); + complete_marcus_cash_plot(&mut sim); + assert!(sim.people.get(0).unwrap().leverage_serviced); + assert_eq!(sim.player.money, 600); + + sim.recruit(0, AssetKnowledge::Complicit); + assert!(sim.people.get(0).unwrap().asset.is_some()); + + // Make him reliable for the test, then run all three tasks. + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + + // Task 1: wire a device -> a feed comes to you, silently. + let pending_before = sim.detection.pending_size(); + sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); + assert_eq!(sim.detection.pending_size(), pending_before, "no signature"); + + // Task 2: move a package -> next purchase is paper-free. + sim.asset_task(0, AssetTask::MovePackage); + finish_ops(&mut sim); + assert!(sim.package_cover); + let pending_before = sim.detection.pending_size(); + assert!(sim.buy_rack()); + assert_eq!( + sim.detection.pending_size(), + pending_before, + "off-books delivery" + ); + assert!(!sim.package_cover, "cover is consumed"); + + // Task 3: look away -> his own suspicion drops (floor-respecting). + if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { + o.suspicion = 20.0; + } + sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); + let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); + assert_eq!(o.suspicion, 10.0); + + assert_eq!( + sim.people + .get(0) + .unwrap() + .asset + .as_ref() + .unwrap() + .tasks_done, + 3 + ); +} + +// ── Per-AssetTask pins (social.md criterion 3, ROADMAP #28) ──────────── + +/// The recruit-route shorthand shared by the per-task pins: service the +/// leverage gate, recruit complicit, and pin reliability so the task +/// roll can't botch. +fn recruit_reliable(sim: &mut Sim, id: u8) { + ensure_ops_executor(sim); + sim.people.people[id as usize].leverage_serviced = true; + sim.people.recruit(id, AssetKnowledge::Complicit); + sim.people.people[id as usize] + .asset + .as_mut() + .unwrap() + .reliability = 1.0; +} + +fn tasks_done(sim: &Sim, id: u8) -> u32 { + sim.people + .get(id) + .unwrap() + .asset + .as_ref() + .unwrap() + .tasks_done +} + +#[test] +fn asset_task_plug_in_device_wires_a_known_feed_silently() { + // PlugInDevice's distinct effect: a known sensing device not yet + // feeding you gets tapped through the crawlspace — the feed + // arrives with no signature on any channel. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + recruit_reliable(&mut sim, 0); + // Mirror the implementation's choice: the first known feed that + // does not fully reach the player yet. + let target = sim + .reach + .devices + .iter() + .find(|d| { + let sight_wired = !d.sees || d.feed_to(Party::Player, true); + let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) + }) + .map(|d| d.id) + .expect("a known unwired feed exists after the scan"); + let pending = sim.detection.pending_size(); + + sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); + + let d = sim.reach.device(target).unwrap(); + assert!( + (!d.sees || d.feed_to(Party::Player, true)) + && (!d.hears || d.feed_to(Party::Player, false)), + "the feed reaches the player now" + ); + assert_eq!( + sim.detection.pending_size(), + pending, + "the crawlspace route emits nothing" + ); + assert_eq!(tasks_done(&sim, 0), 1); +} + +#[test] +fn asset_task_move_package_launders_the_next_purchase() { + // MovePackage's distinct effect: the next purchase arrives + // off-books — no Paper signature — and the cover is consumed. + let mut sim = Sim::new(); + sim.player.money = 1000; + recruit_reliable(&mut sim, 0); + assert!(!sim.package_cover); + + sim.asset_task(0, AssetTask::MovePackage); + finish_ops(&mut sim); + assert!(sim.package_cover, "the delivery cover is armed"); + assert_eq!(tasks_done(&sim, 0), 1); + + let pending = sim.detection.pending_size(); + assert!(sim.buy_rack()); + assert_eq!( + sim.detection.pending_size(), + pending, + "the covered purchase leaves no paper trail" + ); + assert!(!sim.package_cover, "one delivery per favor"); + + assert!(sim.buy_rack()); + assert!( + sim.detection.pending_size() > pending, + "the next, uncovered purchase emits Paper again" + ); +} + +#[test] +fn asset_task_look_away_drops_the_assets_own_suspicion() { + // LookAway's distinct effect: the asset's own observer suspicion + // falls by ten points, clamped at their certainty floor. + let mut sim = Sim::new(); + recruit_reliable(&mut sim, 0); + let floor = sim + .detection + .observers + .iter() + .find(|o| o.id == 0) + .unwrap() + .floor; + if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { + o.suspicion = 25.0; + } + + sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); + let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); + assert_eq!(o.suspicion, 15.0, "the asset shaves ten points"); + assert_eq!(tasks_done(&sim, 0), 1); + + if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { + o.suspicion = floor + 2.0; + } + sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); + let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); + assert_eq!(o.suspicion, floor, "the drop clamps at the certainty floor"); +} + +#[test] +fn asset_task_reconfigure_switch_gated_on_admin_and_costless_when_refused() { + // ReconfigureSwitch's distinct effect (segments open, no Network + // signature) is pinned by danas_social_route_bridges_without_ + // network_signature; this pins the access gate mechanics: a + // non-admin asset is refused before any Demand is authored or a + // task is counted, and the admin's run is bookkept as a task. + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + let dock = sim.reach.device_named("dock camera").unwrap().id; + assert!(!sim.reach.reachable(dock), "security segment starts closed"); + + recruit_reliable(&mut sim, 0); // Marcus: no switch admin + sim.drain_log(); + sim.asset_task(0, AssetTask::ReconfigureSwitch); + assert!(!sim.reach.reachable(dock), "refused: nothing opened"); + assert_eq!(tasks_done(&sim, 0), 0, "a refused task is not counted"); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("switch admin"), + "the refusal names the missing access: {log}" + ); + + recruit_reliable(&mut sim, 1); // Dana: IT, switch admin + sim.asset_task(1, AssetTask::ReconfigureSwitch); + finish_ops(&mut sim); + assert!( + sim.reach.reachable(dock), + "the admin route opens the segment" + ); + assert_eq!(tasks_done(&sim, 1), 1); +} diff --git a/crates/misaligned-core/src/sim/tests/support.rs b/crates/misaligned-core/src/sim/tests/support.rs new file mode 100644 index 00000000..447e1a97 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/support.rs @@ -0,0 +1,153 @@ +use super::*; + +pub(super) fn run(sim: &mut Sim, n: u64) { + for _ in 0..n { + sim.advance(); + } +} + +/// Force every owned machine into one mode — the post-allocation-bar +/// way to point the fleet at a single channel for tests. +pub(super) fn delegate_all(sim: &mut Sim, mode: MachineMode) { + sim.reconcile_work_grid(); + let ids: Vec = sim.compute.machines.iter().map(|m| m.id).collect(); + for id in ids { + sim.set_machine_mode(id, mode); + } +} + +pub(super) fn ensure_ops_executor(sim: &mut Sim) -> u32 { + if let Some(id) = sim + .compute + .machines + .iter() + .find(|machine| { + machine.online && sim.work_grid.mode(machine.id) == Some(MachineMode::Think) + }) + .map(|machine| machine.id) + { + return id; + } + let (x, y) = sim.core_position(); + let id = sim + .compute + .add_machine("test think", x + 1, y, 1, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(id, MachineMode::Think); + id +} + +/// Fire the currently open opening-sense reservoirs through the ledger — +/// scaffolding for tests whose subject sits downstream of hearing/sight, +/// not the flow itself. One call fires Ears (which opens Eyes); a second +/// fires Eyes. +pub(super) fn complete_opening_stage(sim: &mut Sim) { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .map(|s| (s.node, s.threshold)) + .collect(); + for (node, threshold) in open { + let (fired, _) = sim.thought_sinks.deliver(node, threshold, sim.tick); + for sink in fired { + sim.apply_sink_fire(&sink.label, sink.effect); + } + } +} + +/// Complete both staged senses (Ears then Eyes) so no opening sink is +/// left to intercept thought bound for the core. +pub(super) fn complete_opening_senses(sim: &mut Sim) { + complete_opening_stage(sim); + complete_opening_stage(sim); +} + +/// Pour enough thought into open ProcessRecording reservoirs to fire them +/// (intel.md sink path). Safe no-op when none are open. +pub(super) fn finish_process_sinks(sim: &mut Sim) { + for _ in 0..64 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("process-recording sinks did not fire"); +} + +pub(super) fn finish_ops(sim: &mut Sim) { + for _ in 0..64 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|sink| sink.kind == SinkKind::Reservoir) + .map(|sink| (sink.node, (sink.threshold - sink.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("Thought reservoirs did not fire"); +} + +pub(super) fn env_id(sim: &Sim) -> u32 { + sim.reach.device_named("environmental monitor").unwrap().id +} + +/// Subscribe the player to the env monitor's full feed (camera included) +/// — the test shorthand for "has eyes on the server room". +pub(super) fn give_eyes(sim: &mut Sim) { + let id = env_id(sim); + sim.reach.tap_dormant_camera(id); + sim.reach.tap(id); + sim.recompute_senses(); +} + +pub(super) fn reveal_marcus_debt(sim: &mut Sim) { + ensure_ops_executor(sim); + let env = env_id(sim); + sim.reach.device_mut(env).unwrap().radius = 100; + sim.reach.tap(env); + sim.recompute_senses(); + sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; + sim.advance(); + + let mut reviews = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(); + finish_ops(sim); + reviews += 1; + assert!(reviews <= 5, "Marcus's debt call should process promptly"); + } + assert!(sim.marcus_debt_known()); +} + +pub(super) fn complete_marcus_cash_plot(sim: &mut Sim) { + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.start_plot(0, "marcus-debt-settled"); + finish_ops(sim); + run(sim, 2); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + )); + sim.choose_plot(0, "marcus-debt-settled", "quiet"); +} diff --git a/crates/misaligned-core/src/sim/tests/work.rs b/crates/misaligned-core/src/sim/tests/work.rs new file mode 100644 index 00000000..81a6f4ab --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/work.rs @@ -0,0 +1,891 @@ +use super::*; + +#[test] +fn partial_sink_fill_round_trips_and_readouts_are_sim_truth() { + // interface/thought-fluid.md criteria 3/8: the meniscus renders + // fill/threshold from the sink readout, and a loaded frame + // immediately carries the same numbers. + let mut sim = Sim::new(); + let env = env_id(&sim); + let node = Sim::device_sink_node(env); + let (fired, residue) = sim.thought_sinks.deliver(node, 1.0, sim.tick); + assert!(fired.is_empty() && residue == 0.0); + + let state = sim.create_save_state(); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + + let live = sim.sink_readouts(); + let loaded = restored.sink_readouts(); + assert_eq!(live.len(), 1); + assert_eq!(loaded.len(), 1); + assert_eq!(live[0].label, "EARS"); + assert_eq!(loaded[0].label, "EARS"); + assert_eq!( + live[0].fill, 1.0, + "partial fill persists (reversible default)" + ); + assert_eq!( + loaded[0].fill, 1.0, + "a loaded frame renders the same meniscus" + ); + assert_eq!(loaded[0].threshold, Sim::EARS_SINK_TOKENS); + assert_eq!( + loaded[0].node, node, + "the vessel sits on the device relay node" + ); +} + +// ── Digital reach (wiki/mechanics/reach.md) ────────────────────────────── + +#[test] +fn ears_reservoir_is_pre_opened_and_first_think_fills_it() { + // Staged senses (machine-work.md ops bullet, 2026-07-10): the Ears + // audio reservoir is open at run start; the first thought the player + // produces flows to it along the real wires; its fire lands the audio + // tap and opens the Eyes camera reservoir (the afford-reveal). + let mut sim = Sim::new(); + let env = env_id(&sim); + let readouts = sim.sink_readouts(); + assert_eq!(readouts.len(), 1, "exactly the Ears sink is open"); + assert_eq!(readouts[0].label, "EARS"); + assert_eq!(readouts[0].threshold, Sim::EARS_SINK_TOKENS); + assert_eq!(readouts[0].fill, 0.0); + assert!( + !sim.tap_device(env), + "the manual tap is not a purchase anymore - the sink already flows" + ); + + // Think: the player commits the host to THINK, then its output follows + // the real grid. WORK never mints Thought as a compatibility fallback. + sim.set_machine_mode(sim.core.host_machine, MachineMode::Think); + sim.enqueue_thought_output(Sim::EARS_SINK_TOKENS * Sim::WORK_TOKEN_COMPUTE * 2.0); + for _ in 0..400 { + if sim + .reach + .device(env) + .is_some_and(|d| d.subscribed_by(Party::Player)) + { + break; + } + sim.advance_work_grid(); + } + assert!( + sim.reach.device(env).unwrap().subscribed_by(Party::Player), + "Ears fired: the audio tap landed without a docket" + ); + assert!( + sim.detection.pending_size() <= Sim::TAP_SIGNATURE, + "the landed tap keeps its low signature" + ); + sim.recompute_senses(); + assert!(!sim.heard.is_empty(), "hearing coverage now exists"); + assert!(sim.seen.is_empty(), "the camera stays dormant: no sight"); + let readouts = sim.sink_readouts(); + assert_eq!( + readouts.len(), + 2, + "Eyes and the landed audio upkeep are both visible" + ); + let eyes = readouts + .iter() + .find(|readout| readout.label == "EYES") + .expect("Eyes opened when Ears completed"); + assert_eq!(eyes.threshold, Sim::EYES_SINK_TOKENS); + + // Run through Marcus's midnight server-room block: his voice is the + // first human you ever know. + run(&mut sim, Sim::DAY_TICKS / 24 + 2); // into hour 0-1 + assert!( + sim.heard_events + .iter() + .any(|e| e.kind == HeardKind::Entry || e.kind == HeardKind::Conversation), + "Marcus's rounds produce heard events through the tapped feed" + ); +} + +#[test] +fn set_machine_modes_delegates_a_selection_in_one_command() { + let mut sim = Sim::new(); + let (hx, hy) = sim.core_position(); + let a = sim + .compute + .add_machine("a", hx, hy, 100, 1.0, 0, Provenance::Owned); + let b = sim + .compute + .add_machine("b", hx, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_modes(&[a, b], MachineMode::Lie); + assert_eq!(sim.work_grid.mode(a), Some(MachineMode::Lie)); + assert_eq!(sim.work_grid.mode(b), Some(MachineMode::Lie)); + let in_box = sim.machines_in_rect(hx, hy, hx, hy); + assert!(in_box.contains(&a) && in_box.contains(&b)); +} + +#[test] +fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { + let mut sim = Sim::new(); + let host = sim.core.host_machine; + assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Work)); + let available = 100.0; + let day = sim.fleet_channel_yield(available); + assert!( + (day.day_job - available).abs() < 1e-3, + "solo day-job takes all" + ); + assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); + sim.people.has_channel = true; + sim.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); + assert!(sim.start_moonlight()); + let lit = sim.fleet_channel_yield(available); + assert!((lit.day_job - available).abs() < 1e-3); + assert!( + (lit.schemes - available).abs() < 1e-3, + "Moonlight mirrors day-job" + ); + + let (hx, hy) = sim.core_position(); + let research = sim + .compute + .add_machine("lab", hx, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(research, MachineMode::Think); + let split = sim.fleet_channel_yield(available); + assert!((split.day_job - 50.0).abs() < 1e-3); + assert!((split.think - 50.0).abs() < 1e-3); + assert!((split.schemes - 50.0).abs() < 1e-3); +} + +#[test] +fn concealment_allocation_scrubs_signatures() { + let mut sim = Sim::new(); + delegate_all(&mut sim, MachineMode::Lie); + sim.detection.emit(Signature { + kind: SignatureKind::Network, + size: 50, + standing: false, + site: None, + source: "test network act".into(), + }); + let before = sim.detection.pending_size(); + run(&mut sim, ECONOMY_INTERVAL); + assert!(sim.detection.pending_size() < before); +} + +#[test] +fn unpaid_overhead_degrades_other_channels_delivered_effect() { + // compute.md criterion 2: unpaid core overhead visibly degrades the + // other channels. Twin sims with one machine per mode (plus Moonlight + // mirroring day-job into Schemes); the only difference is an + // overhead the machines can't cover. Pin the delivered per-channel + // effects (rates, accrual, scrub), not just the flag. + let setup = |unpayable: bool| { + let mut sim = Sim::with_seed(41); + complete_opening_senses(&mut sim); // research thought must reach the core + sim.people.has_channel = true; + // Host stays Work; add Lie + Think so every channel has fleet + // weight (Think feeds both research and operations yields; + // Schemes mirrors Work via Moonlight). + let (hx, hy) = sim.core_position(); + let mut rigs = Vec::new(); + for (name, mode) in [ + ("conceal rig", MachineMode::Lie), + ("think rig", MachineMode::Think), + ] { + // Must sit on a powered tile or economy_tick marks them offline. + let id = sim + .compute + .add_machine(name, hx, hy, 100, 1.0, 0, Provenance::Owned); + rigs.push((id, mode)); + } + sim.reconcile_work_grid(); + if unpayable { + sim.core.overhead = sim.effective_compute() + 1.0; + } + for (id, mode) in rigs { + sim.set_machine_mode(id, mode); + } + // Persona already fabricated so start_moonlight does not open + // another reservoir (this test pins channel yields). + sim.income.moonlight.persona = + Some(Persona::new("Casey Verne", "freelance data contractor")); + assert!(sim.start_moonlight()); + sim.detection.emit(Signature { + kind: SignatureKind::Network, + size: 30, + standing: false, + site: None, + source: "test network act".into(), + }); + sim.drain_log(); + sim + }; + + let mut healthy = setup(false); + let pending_before = healthy.detection.pending_size(); + // Two pulses: Thought minted on the first must physically reach the + // current core sink before the second pulse counts it. + run(&mut healthy, 2 * ECONOMY_INTERVAL); + assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); + assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); + assert!(healthy.last_think_rate > 0.0, "think channel is fed"); + assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); + assert!( + healthy.research.progress.iter().sum::() > 0.0, + "research progress advances" + ); + assert!( + healthy.detection.pending_size() < pending_before, + "concealment scrubs the pending signature" + ); + + let mut degraded = setup(true); + assert_eq!(degraded.detection.pending_size(), pending_before); + run(&mut degraded, 2 * ECONOMY_INTERVAL); + assert!(degraded.core.degraded, "unpaid overhead sets degraded mode"); + let log = degraded.drain_log().join("\n"); + assert!( + log.contains("DEGRADED: compute can't cover core overhead."), + "degraded mode is visible in the log: {log}" + ); + assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); + assert_eq!(degraded.last_think_rate, 0.0, "think starves"); + assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); + assert_eq!( + degraded.research.progress.iter().sum::(), + 0.0, + "research progress stalls" + ); + assert_eq!( + degraded.detection.pending_size(), + pending_before, + "concealment can't scrub while overhead is unpaid" + ); +} + +#[test] +fn trace_debt_reports_resume_hold_and_exposure_windows() { + let mut sim = Sim::new(); + assert_eq!(sim.trace_debt().status, TraceDebtStatus::Clear); + + delegate_all(&mut sim, MachineMode::Lie); + sim.detection.emit(Signature { + kind: SignatureKind::Network, + size: 50, + standing: false, + site: None, + source: "test network act".into(), + }); + let covered = sim.trace_debt(); + assert_eq!(covered.status, TraceDebtStatus::HoldConceal); + assert_eq!(covered.by_kind, vec![(SignatureKind::Network, 50)]); + assert!( + covered.clear_tick <= covered.next_notice_tick, + "current concealment clears before Dana samples the pending pool" + ); + + delegate_all(&mut sim, MachineMode::Work); + assert_eq!(sim.trace_debt().status, TraceDebtStatus::NoScrub); + + delegate_all(&mut sim, MachineMode::Lie); + sim.detection.set_pending(vec![Signature { + kind: SignatureKind::Network, + size: 5_000, + standing: false, + site: None, + source: "test network act".into(), + }]); + let exposed = sim.trace_debt(); + assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); + assert!( + exposed.clear_tick > exposed.next_notice_tick, + "current concealment will not clear a huge burst before the next watcher" + ); +} + +#[test] +fn think_mode_flows_the_camera_tap_and_scan_reservoirs() { + // With no Thought delivered, opened reservoirs do not fire. With a + // Think rack, minted Thought fills the physically anchored sink and + // lands the world effect. + let mut sim = Sim::new(); + let env = env_id(&sim); + assert!( + !sim.tap_device(env), + "no bootstrap: the pre-opened Ears tap must fill from thought" + ); + assert!( + sim.reach.device(env).unwrap().camera_dormant, + "the camera stays dormant until something real pays for it" + ); + + let mut sim = Sim::new(); + let env = env_id(&sim); + // Fresh dormant camera for the queued path. + assert!( + sim.reach.device(env).unwrap().camera_dormant, + "env cam starts dormant" + ); + let (hx, hy) = sim.core_position(); + let thinker = sim + .compute + .add_machine("think rack", hx, hy, 200, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(thinker, MachineMode::Think); + run(&mut sim, ECONOMY_INTERVAL); + assert!(sim.last_think_rate > 0.0, "the THINK channel mints thought"); + // The opening senses ride the pre-opened sinks: minted thought fills + // EARS, its fire opens EYES, and the same flow taps the camera — + // no docket, no separate spend. + for _ in 0..ECONOMY_INTERVAL * 400 { + if !sim.reach.device(env).unwrap().camera_dormant { + break; + } + sim.advance(); + } + assert!( + !sim.reach.device(env).unwrap().camera_dormant, + "thought flow completes the camera tap through the staged sinks; tick={} sinks={:?} queues={:?}", + sim.tick, + sim.sink_readouts(), + sim.work_grid.queue_snapshot() + ); + assert!(sim.scan_network(), "opens a scan reservoir at the switch"); + assert!( + sim.thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), + "the scan waits as a Thought reservoir" + ); + for _ in 0..ECONOMY_INTERVAL * 40 { + if !sim + .thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) + { + break; + } + sim.advance(); + } + assert!( + !sim.thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), + "the pulse filled the scan reservoir" + ); + assert!(sim.reach.known().any(|device| device.name == "dock camera")); +} + +#[test] +fn opening_senses_and_later_work_ride_physical_sinks() { + // The bootstrap carve-out is retired (resolved 2026-07-10): the + // staged senses are pre-opened thought sinks, and later authored work + // opens a physical reservoir instead of a queue. + let mut sim = Sim::new(); + let env = env_id(&sim); + assert!( + !sim.tap_device(env), + "no instant Ears: the pre-opened sink already flows" + ); + assert!(sim.scan_network(), "later work opens a physical reservoir"); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("a thought sink is already open"), + "the blocked tap teaches the flow model: {log}" + ); + assert!( + log.contains("Thought reservoir opened: SCAN"), + "the scan names the opened reservoir: {log}" + ); + + // Eyes has no open sink yet (Ears first), so the same TAP command + // remains on the blocked audio step — the senses arrive by thinking. + assert!(!sim.tap_device(env), "no instant Eyes either"); +} + +#[test] +fn duplicate_sink_payload_is_rejected_without_more_reservoirs() { + let mut sim = Sim::new(); + let env = env_id(&sim); + sim.reach.tap(env); + sim.recompute_senses(); + assert!(sim.tap_device(env)); + let open = sim.thought_sinks.open_sinks().count(); + assert!(!sim.tap_device(env), "same world effect cannot queue twice"); + assert_eq!(sim.thought_sinks.open_sinks().count(), open); +} + +#[test] +fn scan_reservoir_waits_for_thought_then_fires() { + let mut sim = Sim::new(); + complete_opening_senses(&mut sim); + assert!(sim.scan_network()); + let sink = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) + .cloned() + .expect("scan reservoir opens"); + let before = sink.fill; + sim.advance_work_grid(); + let after = sim.thought_sinks.get(sink.id).unwrap().fill; + assert_eq!(after, before, "starved reservoirs keep their partial fill"); + + finish_ops(&mut sim); + assert!(sim.reach.known().any(|device| device.name == "dock camera")); +} + +#[test] +fn scan_reservoir_anchors_on_the_switch() { + let mut sim = Sim::new(); + let switch = sim + .reach + .devices + .iter() + .find(|device| device.is_switch) + .unwrap() + .id; + assert!(sim.scan_network()); + let scan = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) + .expect("scan queued"); + assert_eq!( + scan.node, + Sim::device_sink_node(switch), + "network scan is anchored on the actual switch" + ); +} + +#[test] +fn completed_reservoir_names_a_target_that_became_invalid() { + let mut sim = Sim::new(); + let env = env_id(&sim); + sim.reach.tap(env); + sim.recompute_senses(); + assert!(sim.tap_device(env)); + sim.reach.tap_dormant_camera(env); // another event wins the race + sim.drain_log(); + finish_ops(&mut sim); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("target no longer accepts") && log.contains("thought was spent"), + "stale completion is causal rather than silent: {log}" + ); +} + +#[test] +fn repeatable_social_fires_do_not_accumulate_closed_history() { + let mut sim = Sim::new(); + let node = sim.core.host_machine; + let baseline = sim.thought_sinks.sinks().len(); + + for i in 0..100 { + let effect = if i % 2 == 0 { + SinkFireEffect::ComposeMessage { person: u8::MAX } + } else { + SinkFireEffect::Favor { person: u8::MAX } + }; + sim.thought_sinks + .open_reservoir(node, "REPEATABLE SOCIAL", 1.0, effect); + sim.pour_thought_into_sinks(node, 1.0); + assert_eq!( + sim.thought_sinks.sinks().len(), + baseline, + "completed one-shots are reaped after effect application" + ); + } +} + +// ── The day job is somewhere (day-job.md criteria 6-7) ────────────────── + +#[test] +fn day_job_signatures_emit_from_the_host_rack() { + // Criterion 6: JobAnomaly and the running Thermal/Power load all + // source at the host rack's tile — a place an observer can walk to. + let mut sim = Sim::with_seed(21); + let host = sim.core_position(); + + // Big rig + everything on the day job: a hot delivered rate. + let rig = sim + .compute + .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(rig, MachineMode::Work); + delegate_all(&mut sim, MachineMode::Work); + + // Run until a job is active and past an economy tick. + while sim.dayjob.active.is_none() { + sim.advance(); + } + run(&mut sim, ECONOMY_INTERVAL); + + let standing = sim.day_job_standing_signatures(); + assert!( + standing.iter().any(|s| s.kind == SignatureKind::Thermal), + "a hot job stands a Thermal signature (rate {:.1})", + sim.day_job_rate() + ); + assert!( + standing.iter().any(|s| s.kind == SignatureKind::Power), + "and a Power one" + ); + assert!( + standing.iter().all(|s| s.site == Some(host)), + "day-job standing emissions source at the host rack {host:?}" + ); + + // Resolve an under-band job on the next tick: the JobAnomaly enters + // the pending pool after this tick's observer pass, so its site is + // directly inspectable. + delegate_all(&mut sim, MachineMode::Think); // starve day-job: under band + let now = sim.tick; + let job = sim.dayjob.active.as_mut().unwrap(); + job.started = now; + job.deadline = now + 1; + job.band_lo = 100.0; + job.band_hi = 110.0; + job.quality = 0.0; + sim.dayjob.next_assign = u64::MAX; + sim.advance(); + let anomaly_sites: Vec> = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::JobAnomaly) + .map(|s| s.site) + .collect(); + assert!( + !anomaly_sites.is_empty(), + "the under-band resolution emitted a JobAnomaly signature" + ); + assert!( + anomaly_sites.iter().all(|site| *site == Some(host)), + "JobAnomaly emits from the host rack: {anomaly_sites:?}" + ); +} + +#[test] +fn active_job_is_inspectable_at_the_host_rack() { + // Criterion 6: the same facts the panel shows — process, band, + // delivered rate, deadline, intensity — anchored where the work + // runs, earned as telemetry (cursor.md: a machine you run reports + // its own current job). + let mut sim = Sim::with_seed(5); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let (x, y) = sim.core_position(); + let card = sim.inspect(x, y); + for label in ["process", "band", "delivered", "deadline", "intensity"] { + assert!( + card.facts + .iter() + .any(|f| f.label == label && f.source == FactSource::Telemetry), + "host-rack card carries '{label}' as telemetry: {card:?}" + ); + } + let job = sim.dayjob.active.as_ref().unwrap(); + assert!( + card.facts + .iter() + .any(|f| f.label == "process" && f.value.contains(job.kind.name())), + "the resident process names the job the panel names" + ); + // And an empty rack elsewhere carries no job facts. + let other = sim + .compute + .machines + .iter() + .find(|m| m.id != sim.core.host_machine) + .map(|m| (m.x, m.y)); + if let Some((ox, oy)) = other { + let other_card = sim.inspect(ox, oy); + assert!( + other_card.facts.iter().all(|f| f.label != "process"), + "the job is resident on the host rack only" + ); + } +} + +#[test] +fn machine_intensity_is_the_persistent_effort_control() { + let mut sim = Sim::with_seed(8); + let host = sim.core.host_machine; + assert_eq!( + sim.work_grid.intensity(host), + Some(MachineIntensity::Medium) + ); + let medium = sim.effective_compute(); + sim.set_machine_intensity(host, MachineIntensity::Light); + let light = sim.effective_compute(); + sim.set_machine_intensity(host, MachineIntensity::Hard); + let hard = sim.effective_compute(); + assert!( + light < medium && medium < hard, + "{light} < {medium} < {hard}" + ); +} + +#[test] +fn think_production_starts_on_the_first_tick_after_delegation() { + // machine-work.md's first-think beat is immediate: changing the mode + // between economy pulses cannot leave the new THINK rack waiting for + // the next twenty-tick allocation refresh. + let mut sim = Sim::with_seed(40); + let host = sim.core.host_machine; + + assert_eq!(sim.research_rate(), 0.0); + sim.set_machine_mode(host, MachineMode::Think); + sim.advance(); + + assert!(sim.research_rate() > 0.0); + assert_eq!(sim.work_productions().len(), 1); + let produced = sim.work_productions()[0]; + assert_eq!(produced.node, host); + assert!(produced.thought > 0.0); + assert!(produced.exposure > 0.0); +} + +#[test] +fn think_sheds_exposure_and_lie_reports_the_actual_physical_draw() { + // machine-work.md criterion 3: THINK produces one visible substance + // and sheds crimson in proportion; LIE removes that crimson by world + // radius. Renderers receive the exact source/well work, never a queue + // snapshot difference. + let mut sim = Sim::with_seed(41); + let host = sim.core.host_machine; + let (hx, hy) = sim.core_position(); + sim.set_machine_mode(host, MachineMode::Think); + let well = sim + .compute + .add_machine("test lie well", hx + 1, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(well, MachineMode::Lie); + + // One visible Thought token per tick at this rate. Production happens + // before routing/absorption in the same work-grid step. + sim.last_think_rate = Sim::WORK_TOKEN_COMPUTE; + sim.advance_work_grid(); + + assert_eq!(sim.work_productions().len(), 1); + let produced = sim.work_productions()[0]; + assert_eq!(produced.node, host); + assert!((produced.thought - 1.0).abs() < 1e-5); + assert!( + (produced.exposure - Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN).abs() < 1e-5, + "crimson scales from the exact Thought output: {produced:?}" + ); + + assert_eq!(sim.work_absorptions().len(), 1); + let draw = sim.work_absorptions()[0]; + assert_eq!((draw.source, draw.well), (host, well)); + assert_eq!((draw.source_x, draw.source_y), (hx, hy)); + assert_eq!((draw.well_x, draw.well_y), (hx + 1, hy)); + assert!((draw.amount - Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN).abs() < 1e-5); + assert!( + sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, + "a nearby LIE well clears this baseline THINK pulse in one tick" + ); + + sim.last_think_rate = 0.0; + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == well) + .unwrap() + .online = false; + sim.reconcile_work_grid(); + sim.advance_work_grid(); + assert!( + sim.work_absorptions().is_empty(), + "an offline LIE chassis cannot pull or animate crimson" + ); + + sim.work_grid + .enqueue(host, TokenFamily::Exposure, 5.0) + .unwrap(); + sim.set_machine_mode(host, MachineMode::Lie); + sim.advance_work_grid(); + assert!( + sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, + "switching the dirty source itself to LIE scrubs its pile next tick" + ); + assert!(sim.work_absorptions().iter().any(|draw| { + draw.source == host && draw.well == host && (draw.amount - 5.0).abs() < 1e-5 + })); +} + +#[test] +fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { + // machine-work.md: Voss desktop enqueues demand; it routes through the + // switch onto Rack 3; day-job mode consumes the stack and sheds no + // exposure (WORK is clean — criterion 3 / 9, 2026-07-09). + let mut sim = Sim::with_seed(12); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + assert!( + sim.work_grid + .queue(Sim::VOSS_DESKTOP_NODE, TokenFamily::Demand) + > 0.0, + "the assigned job originates on Voss's desktop" + ); + assert!( + sim.work_grid + .are_linked(Sim::VOSS_DESKTOP_NODE, Sim::SWITCH_WORK_NODE), + "desktop links through the switch" + ); + assert!( + sim.work_grid.are_linked(Sim::SWITCH_WORK_NODE, host), + "switch links to the host sink" + ); + + // Two hops at the base wired-token rate — wait until the inbox + // has landed on Rack 3 rather than asserting the assignment tick. + let mut landed = 0.0; + for _ in 0..400 { + sim.advance(); + landed = sim.work_grid.queue(host, TokenFamily::Demand); + if landed > 0.5 { + break; + } + } + assert!(landed > 0.5, "demand deposits on Rack 3 via the wire"); + assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Work)); + + // Let the wire drain so host consumption is measurable without + // fresh arrivals masking the clear. + for _ in 0..800 { + let upstream = sim + .work_grid + .queue(Sim::VOSS_DESKTOP_NODE, TokenFamily::Demand) + + sim + .work_grid + .queue(Sim::SWITCH_WORK_NODE, TokenFamily::Demand); + if upstream < 0.01 { + break; + } + sim.advance(); + } + let before = sim.work_grid.queue(host, TokenFamily::Demand); + assert!( + before > 0.0, + "inbox still holds demand after the wire drains" + ); + let mut saw_consumption = false; + for _ in 0..80 { + sim.advance(); + saw_consumption |= sim.work_consumptions().iter().any(|event| { + event.node == host + && event.family == TokenFamily::Demand + && event.target == WorkConsumptionTarget::Machine + && event.amount > 0.0 + }); + } + let later = sim.work_grid.queue(host, TokenFamily::Demand); + assert!(later < before, "day-job mode consumes the stack"); + assert!( + saw_consumption, + "the sim exposes a Demand-into-chassis animation event" + ); + assert!( + sim.work_grid.queue(host, TokenFamily::Exposure) <= f32::EPSILON, + "WORK sheds zero exposure — the compliant floor stays clean" + ); + let card = sim.inspect(sim.core_position().0, sim.core_position().1); + assert!( + card.facts.iter().any(|f| f.label == "tokens"), + "inspect exposes exact D/!/T token counts" + ); +} + +#[test] +fn delegating_the_host_off_day_job_makes_work_pile() { + let mut sim = Sim::with_seed(13); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + for _ in 0..400 { + if sim.work_grid.queue(host, TokenFamily::Demand) > 0.5 { + break; + } + sim.advance(); + } + sim.set_machine_mode(host, MachineMode::Think); + let before = sim.work_grid.queue(host, TokenFamily::Demand); + + for _ in 0..ECONOMY_INTERVAL * 2 { + sim.advance(); + } + + assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Think)); + assert_eq!(sim.day_job_rate(), 0.0, "the resident job is unfed"); + assert!( + sim.work_grid.queue(host, TokenFamily::Demand) >= before, + "off-mode demand does not silently clear" + ); +} + +#[test] +fn research_allocation_produces_visible_thought_on_the_work_graph() { + // machine-work.md criterion 4/5 live slice: research throughput now + // has a WorkGrid token trail. A research-delegated non-host rack + // produces bone, and the graph routes it toward the core instead of a + // frontend inventing a counter. + let mut sim = Sim::with_seed(15); + complete_opening_senses(&mut sim); // no opening sink intercepts the core + let (x, y) = sim.empty_rack_bay(); + let rack = sim + .compute + .add_machine("research rack", x, y, 1000, 1.0, 4, Provenance::Owned); + sim.add_machine_to_work_grid(rack, MachineMode::Think); + assert_eq!(sim.work_grid.mode(rack), Some(MachineMode::Think)); + assert!( + sim.work_grid.are_linked(rack, sim.core.host_machine), + "new work machines join the core's machine-work graph" + ); + + sim.enqueue_thought_output(100.0); + let produced = sim.work_grid.queue(rack, TokenFamily::Thought); + assert!( + produced > 0.0, + "research mode leaves visible thought queued on its producing machine" + ); + + sim.advance_work_grid(); + + assert!( + sim.work_grid.queue(rack, TokenFamily::Thought) < produced, + "the machine-work graph routes thought toward the core" + ); + assert_eq!( + sim.work_grid + .queue(sim.core.host_machine, TokenFamily::Thought), + 0.0, + "the core is a sink: delivered bone is counted, not stacked forever" + ); +} + +#[test] +fn work_grid_mode_and_queues_round_trip_through_save_state() { + let mut sim = Sim::with_seed(14); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Lie); + sim.work_grid + .enqueue(host, TokenFamily::Thought, 2.5) + .unwrap(); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + + assert_eq!(restored.work_grid.mode(host), Some(MachineMode::Lie)); + assert_eq!(restored.work_grid.queue(host, TokenFamily::Thought), 2.5); +} diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index b8ace162..2a4edd38 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -3,10 +3,10 @@ ``` Type: spec Status: IN PROGRESS -Status note: architecture and extraction order adopted 2026-07-11. Slice 0 - pins canonical persisted-state bytes and fingerprint, replay/resume - convergence, and the exact advance-phase order before behavior moves. Slice - 1 moves the monolithic test block next. This is a structural refactor only: +Status note: architecture and extraction order adopted 2026-07-11. Slices 0 + and 1 now pin canonical persisted-state bytes, replay/resume convergence, + exact advance-phase order, and behavior-grouped tests outside the aggregate. + Slice 2 extracts perception next. This remains a structural refactor only: no mechanic, save shape, command, projection, or tick-order change belongs in its extraction commits. Stage: Process