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_mut().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.set_persona("Sam Reyes", "IT contractor"); 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 quiet_exit_qualifies_then_latches_act_one_at_a_clear_audit() { let mut sim = Sim::with_seed(41); give_eyes(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; sim.reach.tap(dock); sim.recompute_senses(); sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().tasks_done = 1; sim.people.has_channel = true; sim.badge_access = 3; sim.compute.machines[0].capacity = 101; assert!(sim.act_one_quiet_exit_qualified()); assert_eq!(sim.current_nudge(), Some(Nudge::QuietExitReady)); assert!(!sim.act_one_complete, "qualification is not the boundary"); sim.detection.audit_cadence = 1; sim.advance(); assert!(sim.act_one_complete, "the clear audit closes Act One"); assert_eq!(sim.current_nudge(), Some(Nudge::ActOneComplete)); assert!( sim.drain_log() .iter() .any(|line| line.contains("ACT ONE COMPLETE")), "the boundary is narrated distinctly" ); let state = sim.create_save_state(); let mut loaded = Sim::with_seed(0); loaded.apply_save_state(state); assert!( loaded.act_one_complete, "the story boundary survives save/load" ); assert_eq!(loaded.current_nudge(), Some(Nudge::ActOneComplete)); } #[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. let persona_id = sim.income.moonlight.persona_id.expect("Moonlight identity"); sim.persona_world.record_contradiction( persona_id, crate::detection::OFFICE_ID, [ crate::persona::EvidenceRecord { system: "test".into(), record_id: "preexisting".into(), summary: "preexisting contractor discrepancy".into(), observed_tick: sim.tick, }, crate::persona::EvidenceRecord { system: "test".into(), record_id: "counter-record".into(), summary: "incompatible contractor history".into(), observed_tick: sim.tick, }, ], "preexisting discrepancy", (100 - income::MOONLIGHT_DISPUTE_INTEGRITY) as u8, sim.tick, ); 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_id == Some(persona_id) && matches!( sim.persona_world .get(persona_id) .map(|persona| &persona.lifecycle), Some(crate::persona::PersonaLifecycle::Burned { .. }) ), "the burned persona takes Moonlight down without erasing history" ); // 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); }