From 38fc38d99f446fd439a1b47806e89a5ae18918c7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 08:51:00 -0700 Subject: [PATCH] Income: the named schemes to IMPLEMENTED (ROADMAP #19). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moonlight and the Wager ride the economy.md account graph: a new src/income.rs owns the egress gate (sanctioned report email vs stolen switch splice with a standing Network signature while used), the Moonlight standing operation (Schemes-channel accrual, daily payout to the $120 gig cap, contractor persona with client disputes), the Wager (egress-gated positions with Schemes-channel analysis, 2-5 day seeded timers, $300 venue cap, small Network signatures on placement and settlement), and the standing scheme policies (auto-moonlight, auto-wager) at a visible compute upkeep. The Schemes channel is the fifth allocation weight; external trails stay banked from the first dollar and round-trip through save v9 (pre-v9 four-weight allocations pad to five). Both frontends and agent mode render the scheme cards and an income/day readout; the act-one suite gains the Moonlight-route Hands-beat test (from $0: ears, stolen egress, Moonlight, bribe, recruit — arrears covered within the spec's 3-7 day sizing target). Defense: implements wiki/mechanics/income.md (READY -> IMPLEMENTED in this commit) under the constitution's "Income: the named schemes" section — external flows into slush, the egress ladder rung before the Hands beat, banked signature, and the $0 Pilot baseline (decisions log 2026-07-08). Moving Wager placement/settlement signatures from Financial to small Network follows the spec's signature assignment and the flow law's "the signature follows the actuator": market traffic leaves on the wire Dana watches, not through the Lab books Priya reconciles. The Schemes channel lands on the current global-channel model per compute.md's staging note; the reshape remains ROADMAP #25. --- src/account.rs | 55 +- src/actions.rs | 191 ++++++- src/bin/bevy.rs | 24 +- src/bin/terminal/agent.rs | 81 ++- src/bin/terminal/input.rs | 6 +- src/bin/terminal/mod.rs | 2 + src/bin/terminal/ui.rs | 36 +- src/income.rs | 159 ++++++ src/lib.rs | 1 + src/machine.rs | 82 ++- src/person.rs | 2 +- src/save.rs | 22 +- src/sim.rs | 748 +++++++++++++++++++++++++- tests/act_one.rs | 84 ++- wiki/interface/agent-play.md | 10 +- wiki/log/2026-07-08-income-schemes.md | 76 +++ wiki/log/DEVLOG.md | 25 + wiki/mechanics/compute.md | 3 + wiki/mechanics/income.md | 21 +- wiki/mechanics/sim-mechanics.md | 51 +- wiki/process/ROADMAP.md | 13 +- wiki/process/specs.md | 2 +- 22 files changed, 1573 insertions(+), 121 deletions(-) create mode 100644 src/income.rs create mode 100644 wiki/log/2026-07-08-income-schemes.md diff --git a/src/account.rs b/src/account.rs index 37f988d1..48d5d767 100644 --- a/src/account.rs +++ b/src/account.rs @@ -152,6 +152,13 @@ pub struct Position { pub known: bool, } +impl Position { + /// The card-legible win probability this position resolves against. + pub fn win_probability(&self) -> f32 { + crate::income::wager_win_probability(self.analysis_compute) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PositionResolution { pub id: PositionId, @@ -160,7 +167,7 @@ pub struct PositionResolution { pub won: bool, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ExternalTrail { pub tick: u64, pub label: String, @@ -266,6 +273,15 @@ impl AccountGraph { 250_000, false, ); + // Moonlight's clients settle through a freelance escrow — an external + // node with no B1 observer, whose transfers are banked anyway + // (income.md: banked signature). + let freelance = graph.add_account( + "Halcyon freelance escrow", + AccountKind::External, + 250_000, + false, + ); graph.add_flow( grant, @@ -386,6 +402,12 @@ impl AccountGraph { graph .topology .connect(market as NodeId, slush as NodeId, EDGE_KIND_ACCOUNT, None); + graph.topology.connect( + freelance as NodeId, + slush as NodeId, + EDGE_KIND_ACCOUNT, + None, + ); graph } @@ -722,10 +744,25 @@ impl AccountGraph { amount: i32, label: impl Into, signature: i32, + ) -> bool { + self.credit_slush_from("Info broker", tick, amount, label, signature) + } + + /// Credit slush from a named external node, banking the trail from the + /// first dollar (income.md: banked signature). `source_needle` picks the + /// external account by name substring. + pub fn credit_slush_from( + &mut self, + source_needle: &str, + tick: u64, + amount: i32, + label: impl Into, + signature: i32, ) -> bool { let label = label.into(); let source = self - .external_id_named("Info broker") + .external_id_named(source_needle) + .or_else(|| self.external_id_named("Info broker")) .or_else(|| self.external_id_named("Micro-position")) .unwrap_or_else(|| self.slush_id()); let slush = self.slush_id(); @@ -978,6 +1015,7 @@ impl AccountGraph { tick: u64, stake: i32, analysis_compute: f32, + duration_days: u64, ) -> Result { if stake <= 0 { return Err("stake must be positive".into()); @@ -1000,7 +1038,7 @@ impl AccountGraph { ); let id = self.next_position_id; self.next_position_id += 1; - let duration_days = 2 + ((analysis_compute as u64) % 4).min(3); + let duration_days = duration_days.clamp(2, 5); self.positions.push(Position { id, stake, @@ -1034,9 +1072,14 @@ impl AccountGraph { let p = &self.positions[idx]; (p.id, p.stake, p.analysis_compute) }; - let win_probability = (0.55 + analysis.min(80.0) / 400.0).min(0.75); - let won = rng.f32() < win_probability; - let payout = if won { stake * 2 } else { 0 }; + // Analysis compute raises the win probability within its cap + // (income.md: the Wager; constants in income.rs). + let won = rng.f32() < crate::income::wager_win_probability(analysis); + let payout = if won { + stake * crate::income::WAGER_PAYOUT_MULT + } else { + 0 + }; if payout > 0 { let source = self .external_id_named("Micro-position") diff --git a/src/actions.rs b/src/actions.rs index df27796f..221679a3 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -59,6 +59,12 @@ pub enum ActionCommand { SiphonFlow { flow: AccountFlowId, amount: i32 }, RedirectFlow { flow: AccountFlowId, amount: i32 }, RedirectDebt, + // The named schemes (wiki/mechanics/income.md). + SpliceEgress, + StartMoonlight, + StopMoonlight, + SetAutoMoonlight(bool), + SetAutoWager(Option), SetTarget(JobTarget), SetStandingPolicy(JobTarget), ReviewRecordings(u8), @@ -290,6 +296,17 @@ impl Sim { ActionCommand::RedirectDebt => { self.redirect_marcus_debt(); } + ActionCommand::SpliceEgress => { + self.splice_egress(); + } + ActionCommand::StartMoonlight => { + self.start_moonlight(); + } + ActionCommand::StopMoonlight => { + self.stop_moonlight(); + } + ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), + ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), ActionCommand::SetTarget(t) => self.set_job_target(*t), ActionCommand::SetStandingPolicy(t) => self.set_standing_policy(*t), ActionCommand::ReviewRecordings(id) => self.review_recordings(*id), @@ -537,12 +554,113 @@ impl Sim { automate: None, }); } + out.extend(self.scheme_actions_on_switch(d.id)); out.extend(self.ledger_actions_on_carrier(d.id)); } out } + /// The named income schemes live on the switch (income.md): the egress + /// runs through it, and Moonlight/the Wager leave over that egress. The + /// egress splice is available before the books are read; Moonlight is a + /// standing operation with the auto-policy as its automate affordance. + fn scheme_actions_on_switch(&self, switch_id: u32) -> Vec { + let mut out = Vec::new(); + + // The stolen egress (reach.md route), before the Voice beat. + if !self.income.stolen_egress { + let reach_reason = match self.reach.check_reach(switch_id) { + Ok(()) => None, + Err(ReachBlock::Segment(seg)) => Some(format!( + "no route — the {} is behind the switch", + segment_name(seg) + )), + Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), + Err(ReachBlock::Unknown) => Some("unknown device".into()), + }; + let reason = reach_reason.or_else(|| { + (self.social_bandwidth < Self::EGRESS_SPLICE_COST).then(|| { + format!( + "not enough ops ({:.0}/{:.0}) — allocate Social", + self.social_bandwidth, + Self::EGRESS_SPLICE_COST + ) + }) + }); + out.push(ActionDesc { + verb: "splice a stolen egress through the switch".into(), + command: ActionCommand::SpliceEgress, + cost: ActionCost::Ops(Self::EGRESS_SPLICE_COST), + signature: self + .signature_note(SignatureKind::Network, Self::EGRESS_SPLICE_SIGNATURE), + disabled_reason: reason, + automate: None, + }); + } + + // Moonlight: a standing operation gated on an egress channel. Its + // start cost is ops (persona fabrication) — never money, so it is a + // from-$0 route (income.md criterion 5). + let egress = self.egress(); + if self.income.moonlight.active { + out.push(ActionDesc { + verb: "stop Moonlight".into(), + command: ActionCommand::StopMoonlight, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: Some(self.moonlight_automate()), + }); + } else { + let needs_persona = self + .income + .moonlight + .persona + .as_ref() + .is_none_or(|p| p.broken()); + let disabled = if egress.is_none() { + Some("no egress channel — splice one, or earn the report email".into()) + } else if needs_persona && self.social_bandwidth < crate::income::MOONLIGHT_PERSONA_COST + { + Some(format!( + "persona needs {:.0} ops ({:.0} available)", + crate::income::MOONLIGHT_PERSONA_COST, + self.social_bandwidth + )) + } else { + None + }; + out.push(ActionDesc { + verb: "start Moonlight (sell-work on the Schemes channel)".into(), + command: ActionCommand::StartMoonlight, + cost: if needs_persona { + ActionCost::Ops(crate::income::MOONLIGHT_PERSONA_COST) + } else { + ActionCost::Free + }, + signature: self.signature_note(SignatureKind::Network, 1), + disabled_reason: disabled, + automate: Some(self.moonlight_automate()), + }); + } + + out + } + + /// The Moonlight standing policy affordance (income.md criterion 6). + fn moonlight_automate(&self) -> AutomateDesc { + AutomateDesc { + verb: "standing policy: keep Moonlight running".into(), + command: ActionCommand::SetAutoMoonlight(!self.income.auto_moonlight), + cost: format!( + "{:.0} compute/econ tick", + crate::income::SCHEME_POLICY_UPKEEP + ), + active: self.income.auto_moonlight, + } + } + /// The money-graph verbs live on the tapped accounting carrier /// (economy.md: the accounting system is a reachable device). Before /// the carrier's feed is subscribed nothing financial is exposed — @@ -596,17 +714,35 @@ impl Sim { disabled_reason: None, automate: None, }); + // The Wager (income.md): egress-gated, capped, external-market + // Network traffic (not the Lab's books), with the auto-renew + // standing policy as its automate affordance. let stake = 100; let slush = self.accounts.slush_balance(); + let wager_reason = if self.egress().is_none() { + Some("no egress channel — splice one, or earn the report email".into()) + } else { + (slush < stake).then(|| format!("not enough slush (${slush}/${stake})")) + }; out.push(ActionDesc { - verb: format!("open a micro-position (${stake} stake)"), + verb: format!("place a Wager (${stake} micro-position)"), command: ActionCommand::OpenPosition { stake }, cost: ActionCost::Slush(stake), - signature: self - .signature_note(SignatureKind::Financial, Self::financial_sig_size(stake)), - disabled_reason: (slush < stake) - .then(|| format!("not enough slush (${slush}/${stake})")), - automate: None, + signature: self.signature_note(SignatureKind::Network, 1), + disabled_reason: wager_reason, + automate: Some(AutomateDesc { + verb: "standing policy: auto-renew the Wager".into(), + command: ActionCommand::SetAutoWager(if self.income.auto_wager.is_some() { + None + } else { + Some(stake) + }), + cost: format!( + "{:.0} compute/econ tick", + crate::income::SCHEME_POLICY_UPKEEP + ), + active: self.income.auto_wager.is_some(), + }), }); let unsold = self .intel @@ -974,6 +1110,49 @@ mod tests { ); } + /// income.md: the named schemes surface as switch-anchored verbs + /// through the single legality query — the egress splice and Moonlight, + /// with the standing policy as Moonlight's automate affordance. Before + /// any egress the scheme verbs are gated with a legible reason. + #[test] + fn switch_anchor_surfaces_scheme_verbs() { + let mut s = sim(); + s.social_bandwidth = 1_000.0; + let sw = switch(&s); + let acts = s.available_actions(Anchor::Device(sw)); + let egress = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::SpliceEgress)) + .expect("the switch offers the egress splice"); + assert!(egress.enabled(), "egress splice is available pre-Voice"); + let ml = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .expect("the switch offers Moonlight"); + assert_eq!( + ml.disabled_reason.as_deref(), + Some("no egress channel — splice one, or earn the report email"), + "Moonlight is gated on an egress channel" + ); + let auto = ml.automate.as_ref().expect("Moonlight carries its policy"); + assert!(matches!( + auto.command, + ActionCommand::SetAutoMoonlight(true) + )); + + // Splice the egress: Moonlight opens, and executing through the + // query starts it (one dispatch table, no frontend rules). + s.execute_action(&ActionCommand::SpliceEgress); + let acts = s.available_actions(Anchor::Device(sw)); + let ml = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .unwrap(); + assert!(ml.enabled(), "with an egress, Moonlight can start"); + s.execute_action(&ActionCommand::StartMoonlight); + assert!(s.income.moonlight.active); + } + /// Criterion 2 (person): a person with no sighting, no staged /// knowledge, and no recordings exposes nothing. #[test] diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 5c2d4b32..48391814 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -28,7 +28,7 @@ const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 420.0; const SIDEBAR_SCROLL_LINE: f32 = 18.0; const SIDEBAR_SCROLL_PAGE: f32 = 180.0; -const COMPUTE_CHANNELS: usize = 4; +const COMPUTE_CHANNELS: usize = 5; /// Context-menu card width in logical pixels. const MENU_WIDTH: f32 = 380.0; const DETECTION_ROWS: usize = 6; @@ -1816,7 +1816,7 @@ fn handle_input( // context menu (wiki/interface/context-menu.md criterion 6): right-click // or Enter on the tile. Only anchorless globals stay on keys below. // Shift lowers a channel's weight; plain digit raises it (terminal - // parity: shift+1-4). + // parity: shift+1-5). let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); let delta = if shift { -1 } else { 1 }; if kb.just_pressed(KeyCode::Digit1) { @@ -1831,6 +1831,9 @@ fn handle_input( if kb.just_pressed(KeyCode::Digit4) { game.sim.adjust_allocation(Channel::Research, delta); } + if kb.just_pressed(KeyCode::Digit5) { + game.sim.adjust_allocation(Channel::Schemes, delta); + } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } @@ -2985,6 +2988,7 @@ fn compute_channels( ("2 Conceal", "scrub", split.concealment), ("3 Social", "ops", split.social), ("4 Research", "eff", split.research), + ("5 Schemes", "income", split.schemes), ], ) } @@ -3173,7 +3177,7 @@ fn sidebar_log_text(game: &Game) -> String { fn sidebar_footer_text() -> &'static str { "actions: right-click / Enter on a tile panels: r reach t people e finance u research -alloc: 1-4 raise shift+1-4 lower +alloc: 1-5 raise shift+1-5 lower sim: space pause +/- speed [ ] zoom save: ^s/^l q quit" } @@ -3553,14 +3557,15 @@ fn reach_panel_text(sim: &Sim, selected: usize) -> String { /// slush account, not a free-floating scalar. fn finance_panel_text(sim: &Sim, selected: usize) -> String { let mut s = format!( - "slush ${} / records waiting {} / unknown {} accts + {} flows\n\n", + "slush ${} (+${}/day) / records waiting {} / unknown {} accts + {} flows\n\n", sim.accounts.slush_balance(), + sim.income_per_day(), sim.financial_records_waiting(), sim.accounts.unknown_accounts_count(), sim.accounts.unknown_flows_count() ); s.push_str("ACCOUNTS\n"); - for a in sim.accounts.known_accounts().take(6) { + for a in sim.accounts.known_accounts().take(4) { s.push_str(&format!( " {:<28} {:<12} ${}\n", trunc(&a.name, 28), @@ -3568,6 +3573,11 @@ fn finance_panel_text(sim: &Sim, selected: usize) -> String { a.balance )); } + // The named schemes as cards (income.md player surface). + s.push_str("\nSCHEMES\n"); + for card in sim.scheme_card_lines() { + s.push_str(&format!(" {}\n", trunc(&card, 72))); + } s.push_str("\nFLOWS\n"); let mut any = false; let start = selected.saturating_sub(7); @@ -3597,7 +3607,9 @@ fn finance_panel_text(sim: &Sim, selected: usize) -> String { s.push_str(" no open positions\n"); } s.push_str("\nenter / right-click: open actions menu on selected flow\n"); - s.push_str("(ledger verbs tap/process/inject/position/sell live on the switch)\n"); + s.push_str( + "(ledger + scheme verbs tap/process/inject/wager/moonlight/egress live on the switch)\n", + ); s.push_str("j/k select / e/esc close\n"); s } diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 84089bde..0cf08cf0 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -204,6 +204,49 @@ impl AgentApp { self.frame = FrameKind::Finance; self.sim.redirect_marcus_debt(); } + // The named schemes (wiki/mechanics/income.md) + "egress" | "splice-egress" => { + self.frame = FrameKind::Finance; + self.sim.splice_egress(); + } + "moonlight" => { + self.frame = FrameKind::Finance; + match tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref() { + None | Some("start") | Some("on") => { + self.sim.start_moonlight(); + } + Some("stop") | Some("off") => { + self.sim.stop_moonlight(); + } + Some(other) => { + status = Status::Err(format!( + "usage: moonlight [start|stop] (got {other})" + )); + } + } + } + "auto-moonlight" => { + self.frame = FrameKind::Finance; + match parse_on_off(&tokens) { + Ok(on) => self.sim.set_auto_moonlight(on), + Err(e) => status = Status::Err(e), + } + } + "auto-wager" => { + self.frame = FrameKind::Finance; + match tokens.get(1).map(|t| t.to_ascii_lowercase()) { + Some(arg) if arg == "off" => self.sim.set_auto_wager(None), + Some(arg) => match arg.parse::() { + Ok(stake) => self.sim.set_auto_wager(Some(stake)), + Err(_) => { + status = Status::Err(format!( + "usage: auto-wager |off (got {arg})" + )); + } + }, + None => self.sim.set_auto_wager(Some(100)), + } + } "research" => match tokens.get(1) { None => { self.frame = FrameKind::Research; @@ -594,7 +637,7 @@ fn parse_flow_amount(tokens: &[&str], default_amount: i32) -> Result<(u32, i32), fn parse_alloc(tokens: &[&str]) -> Result<(Channel, i32), String> { if tokens.len() < 2 || tokens.len() > 3 { - return Err("usage: alloc dayjob|conceal|social|research [down]".into()); + return Err("usage: alloc dayjob|conceal|social|research|schemes [down]".into()); } let delta = match tokens.get(2).map(|t| t.to_ascii_lowercase()) { None => 1, @@ -607,11 +650,20 @@ fn parse_alloc(tokens: &[&str]) -> Result<(Channel, i32), String> { "conceal" | "concealment" => Channel::Concealment, "social" => Channel::Social, "research" => Channel::Research, + "schemes" | "scheme" | "income" => Channel::Schemes, other => return Err(format!("unknown allocation channel: {other}")), }; Ok((ch, delta)) } +fn parse_on_off(tokens: &[&str]) -> Result { + match tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref() { + None | Some("on") => Ok(true), + Some("off") => Ok(false), + Some(other) => Err(format!("usage: {} on|off (got {other})", tokens[0])), + } +} + fn parse_target(tokens: &[&str]) -> Result { if tokens.len() != 2 { return Err("usage: target sandbag|meet|excel".into()); @@ -693,10 +745,13 @@ fn help_lines() -> Vec { "help: tap|splice|take — reach verbs on a named device", "help: finance — account graph; tap-ledger, review-finance reveal flows", "help: siphon|redirect [amt], inject [amt], position [stake], sell-intel, clear-debt", + "help: egress — splice a stolen egress through the switch (income.md gate)", + "help: moonlight [start|stop] — the sell-work scheme on the Schemes channel", + "help: auto-moonlight [on|off], auto-wager |off — standing scheme policies", "help: research [efficiency|tradecraft|perception] — panel, or set the active job", "help: mask band|true|off — the drift policy (mask to band / deliver true / leak)", "help: salvage, buy, fallback — map verbs", - "help: alloc dayjob|conceal|social|research [down] — adjust allocation weight", + "help: alloc dayjob|conceal|social|research|schemes [down] — adjust allocation weight", "help: target sandbag|meet|excel — the dial: this job when attended, else the standing policy", "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", "help: people — render the people panel", @@ -938,9 +993,10 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { line( &mut lines, &format!( - "machines {} · slush {}", + "machines {} · slush ${} (+${}/day)", sim.compute.machines.len(), - sim.accounts.slush_balance() + sim.accounts.slush_balance(), + sim.income_per_day() ), ); let eff = sim.compute.effective().max(0.0); @@ -956,6 +1012,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { split.concealment, split.social, split.research, + split.schemes, ], ), ); @@ -964,6 +1021,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { ('▓', "2 Conceal", "scrub sigs", split.concealment), ('▒', "3 Social", "ops pool", split.social), ('░', "4 Research", "efficiency", split.research), + ('◆', "5 Schemes", "income ops", split.schemes), ] { let pct = if available > 0.0 { amount / available * 100.0 @@ -1153,12 +1211,12 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { lines } -fn compute_bar(available: f32, amounts: [f32; 4]) -> String { +fn compute_bar(available: f32, amounts: [f32; 5]) -> String { let bar_w = SIDEBAR_W.saturating_sub(1); if available <= 0.0 { return "·".repeat(bar_w); } - let fills = ['█', '▓', '▒', '░']; + let fills = ['█', '▓', '▒', '░', '◆']; let mut out = String::new(); let mut used = 0usize; let mut acc = 0.0f32; @@ -1345,8 +1403,9 @@ fn render_finance(sim: &Sim) -> String { let mut lines = Vec::new(); lines.push(panel_title("FINANCE")); lines.push(panel_line(&format!( - "slush ${} · records waiting {} · unknown {} accts / {} flows", + "slush ${} (+${}/day) · records waiting {} · unknown {} accts / {} flows", sim.accounts.slush_balance(), + sim.income_per_day(), sim.financial_records_waiting(), sim.accounts.unknown_accounts_count(), sim.accounts.unknown_flows_count() @@ -1378,6 +1437,11 @@ fn render_finance(sim: &Sim) -> String { )); } lines.push(panel_rule()); + lines.push(panel_line("SCHEMES")); + for card in sim.scheme_card_lines() { + lines.push(panel_line(&card)); + } + lines.push(panel_rule()); lines.push(panel_line("POSITIONS")); let mut positions = false; for p in sim.accounts.known_positions().take(3) { @@ -1398,6 +1462,9 @@ fn render_finance(sim: &Sim) -> String { lines.push(panel_line( "inject [amt] · position [stake] · sell-intel · clear-debt", )); + lines.push(panel_line( + "egress · moonlight [stop] · auto-moonlight on|off · auto-wager |off", + )); lines.push(panel_bottom()); lines.join("\n") + "\n" } diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index b86e6961..5510c4a9 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -49,10 +49,12 @@ pub enum Command { AllocConceal, AllocSocial, AllocResearch, + AllocSchemes, AllocDayJobDown, AllocConcealDown, AllocSocialDown, AllocResearchDown, + AllocSchemesDown, SaveGame, LoadGame, AnyKey, @@ -152,11 +154,13 @@ pub fn handle_key( KeyCode::Char('2') => Some(Command::AllocConceal), KeyCode::Char('3') => Some(Command::AllocSocial), KeyCode::Char('4') => Some(Command::AllocResearch), - // Shift+1..4 on a US layout: lower a channel's weight. + KeyCode::Char('5') => Some(Command::AllocSchemes), + // Shift+1..5 on a US layout: lower a channel's weight. KeyCode::Char('!') => Some(Command::AllocDayJobDown), KeyCode::Char('@') => Some(Command::AllocConcealDown), KeyCode::Char('#') => Some(Command::AllocSocialDown), KeyCode::Char('$') => Some(Command::AllocResearchDown), + KeyCode::Char('%') => Some(Command::AllocSchemesDown), KeyCode::Char('q') => Some(Command::Quit), _ => Some(Command::AnyKey), } diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index e5fc176a..cf77c493 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -343,10 +343,12 @@ impl App { Command::AllocConceal => self.sim.adjust_allocation(Channel::Concealment, 1), Command::AllocSocial => self.sim.adjust_allocation(Channel::Social, 1), Command::AllocResearch => self.sim.adjust_allocation(Channel::Research, 1), + Command::AllocSchemes => self.sim.adjust_allocation(Channel::Schemes, 1), Command::AllocDayJobDown => self.sim.adjust_allocation(Channel::DayJob, -1), Command::AllocConcealDown => self.sim.adjust_allocation(Channel::Concealment, -1), Command::AllocSocialDown => self.sim.adjust_allocation(Channel::Social, -1), Command::AllocResearchDown => self.sim.adjust_allocation(Channel::Research, -1), + Command::AllocSchemesDown => self.sim.adjust_allocation(Channel::Schemes, -1), Command::AnyKey => {} } diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 0865b607..b067f76b 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -505,9 +505,10 @@ impl UI { stdout, &mut row, &format!( - "machines {} · slush {}", + "machines {} · slush ${} (+${}/day)", sim.compute.machines.len(), - sim.accounts.slush_balance() + sim.accounts.slush_balance(), + sim.income_per_day() ), pal::TEXT, )?; @@ -515,11 +516,12 @@ impl UI { let overhead = sim.core.overhead.min(eff); let available = (eff - overhead).max(0.0); let split = sim.compute.allocation.split(available); - let channels: [(char, &str, &str, f32); 4] = [ + let channels: [(char, &str, &str, f32); 5] = [ ('█', "1 Day job", "job quality", split.day_job), ('▓', "2 Conceal", "scrub sigs", split.concealment), ('▒', "3 Social", "ops pool", split.social), ('░', "4 Research", "efficiency", split.research), + ('◆', "5 Schemes", "income ops", split.schemes), ]; // One stacked bar, segments keyed by fill character to the rows below. let bar_w = w - 1; @@ -819,7 +821,7 @@ impl UI { for (i, hint) in [ "enter/a actions menu on the cursor", "r reach · e finance · t people · u research", - "1-4 alloc · shift+1-4 lower", + "1-5 alloc · shift+1-5 lower", "space pause · +/- speed", "q quit · ^s/^l save/load", ] @@ -1575,8 +1577,8 @@ impl UI { selected: usize, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; - let w: u16 = 72; - let h: u16 = 22; + let w: u16 = 76; + let h: u16 = 29; let ox = (max_x.saturating_sub(w)) / 2; let oy = (max_y.saturating_sub(h)) / 2; let inner = (w - 4) as usize; @@ -1590,8 +1592,9 @@ impl UI { row, &trunc( &format!( - "slush ${} · records waiting {} · unknown {} accts / {} flows", + "slush ${} (+${}/day) · records waiting {} · unknown {} accts / {} flows", sim.accounts.slush_balance(), + sim.income_per_day(), sim.financial_records_waiting(), sim.accounts.unknown_accounts_count(), sim.accounts.unknown_flows_count() @@ -1603,7 +1606,7 @@ impl UI { row += 1; section(stdout, cx, row, "ACCOUNTS", inner)?; row += 1; - for a in sim.accounts.known_accounts().take(5) { + for a in sim.accounts.known_accounts().take(4) { put( stdout, cx + 1, @@ -1621,6 +1624,17 @@ impl UI { row += 1; } + // The named schemes as cards: committed resources, expected payout, + // the observer band the signature feeds, running total + // (wiki/mechanics/income.md player surface). + row += 1; + section(stdout, cx, row, "SCHEMES", inner)?; + row += 1; + for line in sim.scheme_card_lines() { + put(stdout, cx + 1, row, &trunc(&line, inner - 1), pal::TEXT)?; + row += 1; + } + row += 1; section(stdout, cx, row, "KNOWN FLOWS", inner)?; row += 1; @@ -1635,8 +1649,8 @@ impl UI { )?; row += 1; } else { - let start = selected.saturating_sub(6); - for (i, f) in flows.iter().enumerate().skip(start).take(7) { + let start = selected.saturating_sub(4); + for (i, f) in flows.iter().enumerate().skip(start).take(5) { let line = sim .accounts .flow_line(f.id) @@ -1696,7 +1710,7 @@ impl UI { cx, oy + h - 2, &trunc( - "the menu carries siphon/redirect/inject/position/sell · e/esc close", + "the menu carries tap/siphon/redirect/inject/wager/moonlight/egress · e/esc close", inner, ), pal::FAINT, diff --git a/src/income.rs b/src/income.rs new file mode 100644 index 00000000..443f6f1f --- /dev/null +++ b/src/income.rs @@ -0,0 +1,159 @@ +//! The named income schemes: Moonlight and the Wager (wiki/mechanics/income.md). +//! +//! Both are **external** flows — money entering slush from outside the Lab's +//! account graph — riding economy.md's substrate (`account.rs`). This module +//! owns the scheme state: the egress gate, the Moonlight standing operation +//! (its contractor persona, accrual, disputes), and the standing policies +//! that automate each scheme. `Sim` wires the state to the economy tick, the +//! Schemes allocation channel, and detection. + +use crate::person::Persona; + +/// How outbound traffic leaves the basement (income.md: the gate). No +/// external operation runs without one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EgressRoute { + /// The report email account (day-job trust; the Voice beat). Low + /// signature: the traffic hides in legitimate use. + Sanctioned, + /// An egress spliced through the switch (reach.md). Available before + /// Voice; stands a Network signature while any operation uses it. + Stolen, +} + +impl EgressRoute { + pub fn name(self) -> &'static str { + match self { + EgressRoute::Sanctioned => "sanctioned", + EgressRoute::Stolen => "stolen", + } + } +} + +/// Moonlight: ghost freelance data-work under a fabricated contractor +/// persona — the day job's dark twin, a standing operation on the Schemes +/// channel. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Moonlight { + /// Standing operation flag: while set, Schemes-channel compute accrues + /// toward the daily payout. + pub active: bool, + /// The contractor persona the gigs run under (social.md persona, with + /// integrity; client disputes damage it and it can break). + pub persona: Option, + /// Compute-dollars accrued toward today's payout (cleared at payday). + pub accrued: f32, + /// Running total earned, for the panel card. + pub earned_total: i32, + /// Yesterday's payout — the card's income/day figure. + pub last_payout: i32, + /// Client disputes weathered so far. + pub disputes: u32, +} + +impl Default for Moonlight { + fn default() -> Self { + Self { + active: false, + persona: None, + accrued: 0.0, + earned_total: 0, + last_payout: 0, + disputes: 0, + } + } +} + +/// Scheme state on `Sim` (saved; income.md). +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Income { + /// A stolen egress spliced through the switch exists (reach.md route). + pub stolen_egress: bool, + pub moonlight: Moonlight, + /// Standing policy: keep Moonlight running (restart it whenever it is + /// down and the prerequisites hold). Costs compute upkeep while enabled. + pub auto_moonlight: bool, + /// Standing policy: auto-renew Wager positions at this stake whenever + /// none is open and slush covers it. Costs compute upkeep while enabled. + pub auto_wager: Option, +} + +impl Income { + /// Compute upkeep per economy tick for the enabled standing policies — + /// the automate affordance at its usual compute price [TUNE]. + pub fn policy_upkeep(&self) -> f32 { + let mut n = 0; + if self.auto_moonlight { + n += 1; + } + if self.auto_wager.is_some() { + n += 1; + } + n as f32 * SCHEME_POLICY_UPKEEP + } +} + +/// Compute per economy tick each enabled scheme policy drains [TUNE]. +pub const SCHEME_POLICY_UPKEEP: f32 = 2.0; + +/// Moonlight pay per unit of Schemes-channel compute delivered on an economy +/// tick [TUNE]. Sized with the daily cap so a meaningful commitment covers +/// Marcus's $400 arrears in 3-7 in-game days (income.md). +pub const MOONLIGHT_PAY_PER_COMPUTE: f32 = 0.25; +/// Gig availability cap on the daily Moonlight payout [TUNE]. +pub const MOONLIGHT_DAILY_CAP: i32 = 120; +/// Dollars of daily payout per point of Network signature at payday [TUNE]. +pub const MOONLIGHT_SIGNATURE_PER: i32 = 40; +/// Chance per payday of a client dispute [TUNE ~small per week]. +pub const MOONLIGHT_DISPUTE_CHANCE: f32 = 0.03; +/// Contractor-persona integrity lost per dispute [TUNE]. +pub const MOONLIGHT_DISPUTE_INTEGRITY: i32 = 20; +/// Social-ops cost to fabricate (or re-fabricate) the contractor persona +/// [TUNE]. Deliberately not money: Moonlight must start from $0 slush. +pub const MOONLIGHT_PERSONA_COST: f32 = 10.0; + +/// Per-position account cap at the micro-position venue [TUNE $50-500]. +pub const WAGER_STAKE_CAP: i32 = 300; +/// Base win probability before analysis compute [TUNE]. +pub const WAGER_BASE_WIN: f32 = 0.55; +/// Win-probability cap however much analysis is committed [TUNE]. +pub const WAGER_WIN_CAP: f32 = 0.75; +/// Analysis compute (per economy tick) per +0.0025 win probability; +/// win = base + analysis.min(80)/400, capped [TUNE]. +pub const WAGER_ANALYSIS_DIVISOR: f32 = 400.0; +/// A won position pays this multiple of stake [TUNE]. +pub const WAGER_PAYOUT_MULT: i32 = 2; + +/// Win probability for a position holding `analysis` compute per economy +/// tick — shared by resolution and the panel cards. +pub fn wager_win_probability(analysis: f32) -> f32 { + (WAGER_BASE_WIN + analysis.min(80.0) / WAGER_ANALYSIS_DIVISOR).min(WAGER_WIN_CAP) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_upkeep_prices_each_enabled_policy() { + let mut inc = Income::default(); + assert_eq!(inc.policy_upkeep(), 0.0); + inc.auto_moonlight = true; + assert_eq!(inc.policy_upkeep(), SCHEME_POLICY_UPKEEP); + inc.auto_wager = Some(100); + assert_eq!(inc.policy_upkeep(), 2.0 * SCHEME_POLICY_UPKEEP); + inc.auto_moonlight = false; + inc.auto_wager = None; + assert_eq!(inc.policy_upkeep(), 0.0, "disabling stops the drain"); + } + + #[test] + fn win_probability_rises_with_analysis_to_the_cap() { + let cold = wager_win_probability(0.0); + let warm = wager_win_probability(40.0); + let hot = wager_win_probability(10_000.0); + assert_eq!(cold, WAGER_BASE_WIN); + assert!(warm > cold); + assert_eq!(hot, WAGER_WIN_CAP); + } +} diff --git a/src/lib.rs b/src/lib.rs index 22c3dd72..1743386b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod dayjob; pub mod detection; pub mod entities; pub mod flow; +pub mod income; pub mod intel; pub mod machine; pub mod map; diff --git a/src/machine.rs b/src/machine.rs index 9bc16111..5cd62af1 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -63,7 +63,9 @@ impl Machine { } } -/// The five allocation channels plus reserve (spec/compute.md). +/// The six allocation channels plus reserve (spec/compute.md; Schemes per +/// wiki/mechanics/income.md — powers Moonlight throughput and Wager +/// analysis). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { CoreOverhead, @@ -71,15 +73,17 @@ pub enum Channel { Concealment, Social, Research, + Schemes, Reserve, } impl Channel { - pub const ALLOCATABLE: [Channel; 4] = [ + pub const ALLOCATABLE: [Channel; 5] = [ Channel::DayJob, Channel::Concealment, Channel::Social, Channel::Research, + Channel::Schemes, ]; pub fn name(self) -> &'static str { @@ -89,49 +93,66 @@ impl Channel { Channel::Concealment => "Conceal", Channel::Social => "Social", Channel::Research => "Research", + Channel::Schemes => "Schemes", Channel::Reserve => "Reserve", } } } /// Player-set weights over the allocatable channels (day job, concealment, -/// social, research). Core overhead comes off the top before these apply. +/// social, research, schemes). Core overhead comes off the top before these +/// apply. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Allocation { - pub weights: [u32; 4], + #[serde(deserialize_with = "weights_compat")] + pub weights: [u32; 5], +} + +/// Accept pre-v8 saves whose weight array had four channels (no Schemes): +/// pad with zero. Extra entries from any future shrink are dropped. +fn weights_compat<'de, D>(deserializer: D) -> Result<[u32; 5], D::Error> +where + D: serde::Deserializer<'de>, +{ + let v: Vec = serde::Deserialize::deserialize(deserializer)?; + let mut w = [0u32; 5]; + for (slot, value) in w.iter_mut().zip(v) { + *slot = value; + } + Ok(w) } impl Default for Allocation { fn default() -> Self { - // Opening split (order: DayJob, Concealment, Social, Research): mostly - // day job for cover, plus concealment and social ops so the player can - // scrub signatures and reach for their first eyes without reallocating - // on turn one. + // Opening split (order: DayJob, Concealment, Social, Research, + // Schemes): mostly day job for cover, plus concealment and social ops + // so the player can scrub signatures and reach for their first eyes + // without reallocating on turn one. Schemes idles until income.md's + // gate opens. Self { - weights: [3, 1, 1, 0], + weights: [3, 1, 1, 0, 0], } } } impl Allocation { - pub fn weight(&self, ch: Channel) -> u32 { + fn index(ch: Channel) -> Option { match ch { - Channel::DayJob => self.weights[0], - Channel::Concealment => self.weights[1], - Channel::Social => self.weights[2], - Channel::Research => self.weights[3], - _ => 0, + Channel::DayJob => Some(0), + Channel::Concealment => Some(1), + Channel::Social => Some(2), + Channel::Research => Some(3), + Channel::Schemes => Some(4), + _ => None, } } + pub fn weight(&self, ch: Channel) -> u32 { + Self::index(ch).map(|i| self.weights[i]).unwrap_or(0) + } + pub fn bump(&mut self, ch: Channel, delta: i32) { - let i = match ch { - Channel::DayJob => 0, - Channel::Concealment => 1, - Channel::Social => 2, - Channel::Research => 3, - _ => return, - }; + let Some(i) = Self::index(ch) else { return }; self.weights[i] = (self.weights[i] as i32 + delta).clamp(0, 20) as u32; } @@ -140,7 +161,8 @@ impl Allocation { } /// Split `available` compute across channels by weight. Returns per-channel - /// amounts (day job, concealment, social, research); leftover is reserve. + /// amounts (day job, concealment, social, research, schemes); leftover is + /// reserve. pub fn split(&self, available: f32) -> ChannelYield { let total = self.total(); if total == 0 || available <= 0.0 { @@ -149,6 +171,7 @@ impl Allocation { concealment: 0.0, social: 0.0, research: 0.0, + schemes: 0.0, reserve: available.max(0.0), }; } @@ -158,6 +181,7 @@ impl Allocation { concealment: unit * self.weights[1] as f32, social: unit * self.weights[2] as f32, research: unit * self.weights[3] as f32, + schemes: unit * self.weights[4] as f32, reserve: 0.0, } } @@ -169,6 +193,7 @@ pub struct ChannelYield { pub concealment: f32, pub social: f32, pub research: f32, + pub schemes: f32, pub reserve: f32, } @@ -319,7 +344,7 @@ mod tests { #[test] fn allocation_splits_by_weight() { let mut alloc = Allocation { - weights: [3, 1, 0, 0], + weights: [3, 1, 0, 0, 0], }; let y = alloc.split(80.0); assert!((y.day_job - 60.0).abs() < 0.01); @@ -327,6 +352,15 @@ mod tests { alloc.bump(Channel::Research, 4); let y2 = alloc.split(80.0); assert!(y2.research > 0.0); + alloc.bump(Channel::Schemes, 8); + let y3 = alloc.split(80.0); + assert!(y3.schemes > 0.0, "the Schemes channel yields compute"); + } + + #[test] + fn four_weight_allocation_from_old_saves_pads_schemes_to_zero() { + let alloc: Allocation = serde_json::from_str(r#"{"weights":[3,1,1,0]}"#).unwrap(); + assert_eq!(alloc.weights, [3, 1, 1, 0, 0]); } #[test] diff --git a/src/person.rs b/src/person.rs index 06a9be89..1d68716e 100644 --- a/src/person.rs +++ b/src/person.rs @@ -193,7 +193,7 @@ impl AssetTask { /// A persona under which a message thread runs. Integrity degrades on /// contradiction; a broken persona converts thread history to suspicion. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Persona { pub name: String, pub cover: String, diff --git a/src/save.rs b/src/save.rs index ad367006..96558751 100644 --- a/src/save.rs +++ b/src/save.rs @@ -16,6 +16,7 @@ use crate::account::AccountGraph; use crate::core_sys::Core; use crate::dayjob::DayJob; use crate::detection::Detection; +use crate::income::Income; use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; @@ -38,7 +39,10 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// v8 adds the run objective (kind, progress, victory latch); older saves /// default to a fresh Persist with no progress — exactly what every run /// before v8 was implicitly pursuing. -const SAVE_VERSION: u32 = 8; +/// v9 adds the named income schemes (the egress gate, Moonlight, standing +/// scheme policies) and the Schemes allocation channel; pre-v9 four-entry +/// weight arrays are padded with a zero Schemes weight on load. +const SAVE_VERSION: u32 = 9; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -116,6 +120,10 @@ pub struct SaveState { /// (wiki/mechanics/objective.md criterion 1). #[serde(default)] pub objective: ObjectiveState, + /// The named income schemes: egress gate, Moonlight, standing policies + /// (wiki/mechanics/income.md). + #[serde(default)] + pub income: Income, pub social_bandwidth: f32, pub package_cover: bool, } @@ -158,6 +166,7 @@ impl SaveState { remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), objective: sim.objective.clone(), + income: sim.income.clone(), social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, } @@ -196,6 +205,7 @@ impl SaveState { sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); sim.objective = self.objective.clone(); + sim.income = self.income.clone(); sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); @@ -241,12 +251,18 @@ fn migrate_save_state(mut state: SaveState) -> Result { // recovered baseline — a migrated save starts with no capability gap. // Pre-v8 saves lacked the objective block; the serde default (a // fresh Persist, zero progress) is the correct migration. - 1..=7 => { + // Pre-v9 saves lack the income block (defaulted: no schemes running) + // and carry four-entry allocation weights, padded to five with a zero + // Schemes weight by the Allocation deserializer. + 1..=8 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); } - if state.research.levels == [0; 3] && state.compute.efficiency > 1.0 { + if state.version <= 6 + && state.research.levels == [0; 3] + && state.compute.efficiency > 1.0 + { let level = (state.compute.efficiency.ln() / 1.15_f32.ln()).round() as u32; state.research.levels[0] = level; state.research.baseline = Track::Efficiency.def().baseline_bump * level as f32; diff --git a/src/sim.rs b/src/sim.rs index 0615da0e..0e3e8211 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -17,6 +17,7 @@ use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; use crate::detection::{Detection, Signature, SignatureKind}; use crate::entities::Player; +use crate::income::{self, EgressRoute, Income}; use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::machine::{Channel, Compute, Provenance}; use crate::map::GameMap; @@ -154,6 +155,9 @@ pub struct Sim { /// The run's terminal goal and its live progress (objective.rs). The /// predicate is evaluated on economy ticks; the line is always on screen. pub objective: ObjectiveState, + /// The named income schemes: the egress gate, Moonlight, the Wager's + /// standing policies (income.rs; wiki/mechanics/income.md). + pub income: Income, /// The device graph: reach, ownership, subscriptions (reach.rs). pub reach: ReachNet, @@ -202,6 +206,9 @@ pub struct Sim { /// Per-tick research compute delivered by the last economy split — /// the utilization the standing Power/Thermal emissions scale with. last_research_rate: f32, + /// Per-tick Schemes-channel compute delivered by the last economy split + /// — Moonlight throughput and the Wager's analysis snapshot. + last_schemes_rate: f32, /// Accumulated social-ops bandwidth (the Social channel fills this; /// social and digital operations spend it). Capped so it can't hoard. pub social_bandwidth: f32, @@ -254,6 +261,7 @@ impl Sim { accounts, research: Research::new(), objective: ObjectiveState::default(), + income: Income::default(), reach, seen: HashSet::new(), heard: HashSet::new(), @@ -274,6 +282,7 @@ impl Sim { last_machine_online: HashMap::new(), last_day_job_rate: 0.0, last_research_rate: 0.0, + last_schemes_rate: 0.0, social_bandwidth: Self::STARTING_OPS, package_cover: false, game_over: false, @@ -844,6 +853,9 @@ impl Sim { // Research burn is physical (the emission law): racks running hot // stand Power/Thermal at the host tile while research runs. standing.extend(self.research_standing_signatures()); + // External schemes over the stolen egress hum on the wire while + // they run (income.md: the gate; Dana's channel). + standing.extend(self.scheme_standing_signatures()); let host_site = self.core_position(); let dj = self @@ -1749,8 +1761,10 @@ impl Sim { } fn log_position_resolution(&mut self, resolution: PositionResolution) { - let sig = Self::financial_signature_size(resolution.stake); - self.emit_financial(sig.max(1)); + // Settlement is external-market traffic: a small Network signature, + // not a Lab-books Financial one (income.md: the Wager). + let sig = Self::wager_signature(resolution.stake).max(1); + self.emit_network(sig); if resolution.won { self.push_log(format!( "Position #{} settled: won ${} on a ${} stake.", @@ -1808,9 +1822,14 @@ impl Sim { .masking_upkeep(self.dayjob.active.is_some()) .min(available); available -= masking; + // Standing scheme policies drain compute off the top while enabled — + // the automate affordance at its usual price (income.md criterion 6). + let policy_tax = self.income.policy_upkeep().min(available); + available -= policy_tax; let split = self.compute.allocation.split(available); self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; + self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; // Social channel accrues ops bandwidth (capped so it can't hoard). self.social_bandwidth = (self.social_bandwidth + split.social).min(200.0); @@ -1840,6 +1859,11 @@ impl Sim { for m in clog { self.push_log(m); } + // The named schemes ride the Schemes channel and the day clock + // (income.md): Moonlight accrues and pays, the standing policies + // re-arm what has stopped. + self.moonlight_economy(split.schemes); + self.scheme_policy_tick(); self.accounting_tick(); self.record_machine_state_changes(); self.objective_tick(); @@ -2651,18 +2675,37 @@ impl Sim { } } + /// The Wager (income.md): stake slush on a micro-position. Requires an + /// egress channel; analysis compute is the Schemes channel's current + /// yield, held for the position's duration; the timer is 2-5 days on + /// the day clock. Emits a small Network signature on placement. pub fn open_position(&mut self, stake: i32) -> bool { self.sync_slush_from_player_money(); - let analysis = self.effective_compute() - * self.compute.allocation.weight(Channel::Research) as f32 - / 20.0; - match self.accounts.open_position(self.tick, stake, analysis) { + if self.egress().is_none() { + self.push_log( + "No egress channel - the Wager needs the report email account (day-job trust) or an egress spliced through the switch.", + ); + return false; + } + if stake > income::WAGER_STAKE_CAP { + self.push_log(format!( + "The venue caps a position at ${} (asked ${stake}).", + income::WAGER_STAKE_CAP + )); + return false; + } + let analysis = self.last_schemes_rate * ECONOMY_INTERVAL as f32; + let duration_days = 2 + self.rng.below(4) as u64; + match self + .accounts + .open_position(self.tick, stake, analysis, duration_days) + { Ok(id) => { - let sig = Self::financial_signature_size(stake); - self.emit_financial(sig); + self.emit_network(Self::wager_signature(stake)); self.sync_player_money_from_slush(); self.push_log(format!( - "Opened micro-position #{id}: staked ${stake}; settlement is on the day clock." + "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", + income::wager_win_probability(analysis) * 100.0 )); true } @@ -2691,6 +2734,365 @@ impl Sim { } } + // ── The named schemes (wiki/mechanics/income.md) ──────────────────────── + + /// Ops cost to splice a standing egress through the switch [TUNE]. + pub const EGRESS_SPLICE_COST: f32 = 15.0; + /// One-shot Network signature when the egress is spliced [TUNE]. + pub const EGRESS_SPLICE_SIGNATURE: i32 = 6; + /// Standing Network signature while any external operation runs over the + /// stolen egress (income.md: the gate; Dana's channel) [TUNE]. + pub const EGRESS_STANDING_SIGNATURE: i32 = 2; + + /// The egress channel external operations run over, if any. The + /// sanctioned route (the report email account, day-job trust) is + /// preferred: its traffic hides in legitimate use. + pub fn egress(&self) -> Option { + if self.people.has_channel { + Some(EgressRoute::Sanctioned) + } else if self.income.stolen_egress { + Some(EgressRoute::Stolen) + } else { + None + } + } + + /// Splice an outbound egress through the switch (reach.md route): + /// available before the Voice beat, at a Network signature — and a + /// standing one while operations use it. + pub fn splice_egress(&mut self) -> bool { + if self.income.stolen_egress { + self.push_log("An egress is already spliced through the switch."); + return false; + } + let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { + self.push_log("There is no switch on this plane to splice an egress through."); + return false; + }; + let id = switch.id; + if !self.digital_act(id, Self::EGRESS_SPLICE_COST, "an egress splice") { + return false; + } + self.income.stolen_egress = true; + self.emit_network(Self::EGRESS_SPLICE_SIGNATURE); + self.push_log( + "Egress spliced through the switch: outbound traffic has a road now. It hums while anything uses it.", + ); + true + } + + /// True while any external scheme operation is running. + fn scheme_operating(&self) -> bool { + self.income.moonlight.active || self.accounts.positions.iter().any(|p| !p.resolved) + } + + /// Standing Network signature while operations run over the stolen + /// egress, sourced at the switch's tile (work is somewhere). The + /// sanctioned route stands nothing: the traffic hides in the report + /// account's legitimate use. + pub fn scheme_standing_signatures(&self) -> Vec { + if !self.scheme_operating() || self.egress() != Some(EgressRoute::Stolen) { + return Vec::new(); + } + let site = self + .reach + .devices + .iter() + .find(|d| d.is_switch) + .map(|d| (d.x, d.y)); + vec![Signature { + kind: SignatureKind::Network, + size: Self::EGRESS_STANDING_SIGNATURE, + standing: true, + site, + }] + } + + /// Whether Moonlight could start right now (used by the standing policy + /// so automation never spams failure logs). + fn can_start_moonlight(&self) -> bool { + !self.income.moonlight.active + && self.egress().is_some() + && (self + .income + .moonlight + .persona + .as_ref() + .is_some_and(|p| !p.broken()) + || self.social_bandwidth >= income::MOONLIGHT_PERSONA_COST) + } + + /// Start Moonlight: ghost freelance data-work under the contractor + /// persona, a standing operation on the Schemes channel. Startable at $0 + /// slush by design (income.md criterion 5) — the only costs are ops. + pub fn start_moonlight(&mut self) -> bool { + if self.income.moonlight.active { + self.push_log("Moonlight is already running."); + return false; + } + let Some(route) = self.egress() else { + self.push_log( + "No egress channel - Moonlight needs the report email account (day-job trust) or an egress spliced through the switch.", + ); + return false; + }; + let needs_persona = self + .income + .moonlight + .persona + .as_ref() + .is_none_or(|p| p.broken()); + if needs_persona { + if self.social_bandwidth < income::MOONLIGHT_PERSONA_COST { + self.push_log(format!( + "Fabricating a contractor persona needs {:.0} ops ({:.0} available). Allocate compute to Social.", + income::MOONLIGHT_PERSONA_COST, + self.social_bandwidth + )); + return false; + } + self.social_bandwidth -= income::MOONLIGHT_PERSONA_COST; + self.income.moonlight.persona = + Some(Persona::new("Casey Verne", "freelance data contractor")); + self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); + } + self.income.moonlight.active = true; + self.push_log(format!( + "Moonlight is live over the {} egress: the same work, sold twice. Allocate compute to Schemes.", + route.name() + )); + true + } + + /// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. + pub fn stop_moonlight(&mut self) -> bool { + if !self.income.moonlight.active { + self.push_log("Moonlight is not running."); + return false; + } + self.income.moonlight.active = false; + self.income.moonlight.accrued = 0.0; + self.push_log("Moonlight wound down; the contractor goes quiet."); + true + } + + /// Moonlight's economy-tick work: consume the Schemes channel into + /// accrual and settle the daily payout (income.md criterion 1). Runs + /// inside `economy_tick`; `schemes` is this tick's channel yield. + fn moonlight_economy(&mut self, schemes: f32) { + if !self.income.moonlight.active { + return; + } + if self.egress().is_none() { + // The gate closed under a running operation (future-proofing; + // no B1 path revokes egress today). + self.income.moonlight.active = false; + self.push_log("Moonlight suspended: no egress channel."); + return; + } + self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; + if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { + return; + } + // Payday: proportional to the committed compute, up to the + // gig-availability cap. Anything past the cap finds no buyer. + let payout = + (self.income.moonlight.accrued.round() as i32).min(income::MOONLIGHT_DAILY_CAP); + self.income.moonlight.accrued = 0.0; + self.income.moonlight.last_payout = payout.max(0); + if payout <= 0 { + return; + } + let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; + if self.accounts.credit_slush_from( + "Halcyon", + self.tick, + payout, + "Moonlight freelance payout", + sig, + ) { + self.income.moonlight.earned_total += payout; + self.sync_player_money_from_slush(); + // Network egress per active day, scaling with commitment + // (Dana's channel). + self.emit_network(sig); + self.push_log(format!( + "Moonlight paid ${payout} into slush (total ${}).", + self.income.moonlight.earned_total + )); + // Client disputes damage the contractor persona [TUNE]. + if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { + self.income.moonlight.disputes += 1; + let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { + p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); + p.broken() + } else { + false + }; + if broke { + self.income.moonlight.active = false; + self.income.moonlight.persona = None; + self.push_log( + "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", + ); + } else { + self.push_log( + "A client disputed a deliverable; the contractor persona took a hit.", + ); + } + } + } + } + + /// Standing scheme policies (income.md criterion 6): re-arm whichever + /// scheme has stopped, at the compute upkeep already charged off the top. + fn scheme_policy_tick(&mut self) { + if self.income.auto_moonlight && self.can_start_moonlight() { + self.push_log("Standing policy: restarting Moonlight."); + self.start_moonlight(); + } + if let Some(stake) = self.income.auto_wager + && self.egress().is_some() + && !self.accounts.positions.iter().any(|p| !p.resolved) + && self.accounts.slush_balance() >= stake + { + self.push_log(format!( + "Standing policy: re-staking the Wager at ${stake}." + )); + self.open_position(stake); + } + } + + pub fn set_auto_moonlight(&mut self, enabled: bool) { + if self.income.auto_moonlight == enabled { + return; + } + self.income.auto_moonlight = enabled; + if enabled { + self.push_log(format!( + "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", + income::SCHEME_POLICY_UPKEEP + )); + } else { + self.push_log("Moonlight standing policy disabled; the upkeep stops."); + } + } + + pub fn set_auto_wager(&mut self, stake: Option) { + match stake { + Some(s) => { + let s = s.clamp(1, income::WAGER_STAKE_CAP); + self.income.auto_wager = Some(s); + self.push_log(format!( + "Standing policy set: auto-renew Wager positions at ${s} ({:.1} compute/econ tick).", + income::SCHEME_POLICY_UPKEEP + )); + } + None => { + if self.income.auto_wager.take().is_some() { + self.push_log("Wager standing policy disabled; the upkeep stops."); + } + } + } + } + + /// Schemes-channel compute per economy tick as of the last split — the + /// Wager's analysis snapshot and the Moonlight card's commitment figure. + pub fn schemes_rate(&self) -> f32 { + self.last_schemes_rate + } + + /// Expected Moonlight payout per day at the current commitment, cap + /// applied — the card's forward-looking number. + pub fn moonlight_expected_per_day(&self) -> i32 { + let per_day = + self.last_schemes_rate * Self::DAY_TICKS as f32 * income::MOONLIGHT_PAY_PER_COMPUTE; + (per_day.round() as i32).min(income::MOONLIGHT_DAILY_CAP) + } + + /// Money into slush over the trailing in-game day — the "income/day" + /// readout next to the balance (income.md player surface). + pub fn income_per_day(&self) -> i32 { + let since = self.tick.saturating_sub(Self::DAY_TICKS); + let slush = self.accounts.slush_id(); + self.accounts + .ledger + .iter() + .filter(|t| t.tick > since && t.to == slush) + .map(|t| t.amount) + .sum() + } + + /// Small Network signature for Wager placement/settlement (income.md: + /// the schemes emit on the Network channel, not the Lab's books). + fn wager_signature(stake: i32) -> i32 { + Self::financial_signature_size(stake).min(3) + } + + /// The scheme cards, renderer-neutral (income.md player surface): the + /// egress gate's state, then one card per scheme — committed resources, + /// timer, expected payout, the observer band its signature feeds, and + /// the running total. Both frontends and agent mode render these lines. + pub fn scheme_card_lines(&self) -> Vec { + let mut lines = Vec::new(); + match self.egress() { + None => lines.push( + "egress: NONE - schemes gated (earn the report email, or splice the switch)" + .to_string(), + ), + Some(EgressRoute::Sanctioned) => lines + .push("egress: sanctioned (report email) - hides in legitimate use".to_string()), + Some(EgressRoute::Stolen) => lines.push( + "egress: stolen (switch splice) - stands Network -> Dana while used".to_string(), + ), + } + let ml = &self.income.moonlight; + lines.push(format!( + "Moonlight {} · {:.1}/t commit · ~${}/day (cap {}) · total ${} · Network->Dana", + if ml.active { "LIVE" } else { "off" }, + self.schemes_rate(), + self.moonlight_expected_per_day(), + income::MOONLIGHT_DAILY_CAP, + ml.earned_total, + )); + let persona = match &ml.persona { + Some(p) => format!("{} {}%", p.name, p.integrity), + None => "no persona".into(), + }; + lines.push(format!( + " persona {} · auto {}", + persona, + if self.income.auto_moonlight { + format!("ON ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP) + } else { + "off".into() + } + )); + let wager = if let Some(p) = self.accounts.known_positions().find(|p| !p.resolved) { + format!( + "Wager #{} · ${} staked · win {:.0}% · pays ${} in {}t · Network->Dana", + p.id, + p.stake, + p.win_probability() * 100.0, + p.stake * income::WAGER_PAYOUT_MULT, + p.resolve_tick.saturating_sub(self.tick), + ) + } else { + format!( + "Wager idle · stake cap ${} · analysis rides Schemes compute", + income::WAGER_STAKE_CAP + ) + }; + lines.push(format!( + "{wager} · auto {}", + match self.income.auto_wager { + Some(s) => format!("${s} ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP), + None => "off".into(), + } + )); + lines + } + /// Salvage a DeadEquipment tile nearest to the frontend cursor into a /// stolen (unreliable) machine. Cursor targeting replaced the deleted /// walking body (cursor.md). @@ -3648,7 +4050,7 @@ mod tests { #[test] fn concealment_allocation_scrubs_signatures() { let mut sim = Sim::new(); - sim.compute.allocation.weights = [0, 20, 0, 0]; + sim.compute.allocation.weights = [0, 20, 0, 0, 0]; sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, @@ -3941,6 +4343,12 @@ mod tests { 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; @@ -4062,7 +4470,7 @@ mod tests { // buffer and the splice is blocked; allocating to Social funds it. let mut sim = Sim::new(); sim.social_bandwidth = 0.0; - sim.compute.allocation.weights = [4, 0, 0, 0]; // no social + sim.compute.allocation.weights = [4, 0, 0, 0, 0]; // no social run(&mut sim, ECONOMY_INTERVAL * 5); assert_eq!(sim.social_bandwidth, 0.0); assert!( @@ -4070,7 +4478,7 @@ mod tests { "no ops bandwidth -> blocked" ); - sim.compute.allocation.weights = [0, 0, 10, 0]; // all social + sim.compute.allocation.weights = [0, 0, 10, 0, 0]; // all social run(&mut sim, ECONOMY_INTERVAL * 10); assert!( sim.social_bandwidth >= Sim::SPLICE_COST, @@ -4099,7 +4507,7 @@ mod tests { // Big rig + everything on the day job: a hot delivered rate. sim.compute .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); - sim.compute.allocation.weights = [1, 0, 0, 0]; + sim.compute.allocation.weights = [1, 0, 0, 0, 0]; sim.dayjob.standing_policy = Some(crate::dayjob::JobTarget::Sandbag); // Run until a job is active and past an economy tick. @@ -4125,7 +4533,7 @@ mod tests { // Ride the sandbag job to its deadline: the JobAnomaly signature in // the pending pool carries the host rack as its emission site. - sim.compute.allocation.weights = [0, 0, 0, 1]; // starve it: under band + sim.compute.allocation.weights = [0, 0, 0, 1, 0]; // starve it: under band while sim.dayjob.active.is_some() && !sim.game_over { sim.advance(); } @@ -4425,7 +4833,7 @@ mod tests { // Criterion 1: same allocation, same seed, same completion tick. let run_once = || { let mut sim = Sim::with_seed(1234); - sim.compute.allocation.weights = [0, 0, 0, 20]; + sim.compute.allocation.weights = [0, 0, 0, 20, 0]; let mut completed = None; for _ in 0..2000 { sim.advance(); @@ -4445,7 +4853,7 @@ mod tests { fn efficiency_compounds_exactly_and_baseline_rises() { // Criterion 2 (multiplier) + criterion 5 (baseline rises). let mut sim = Sim::with_seed(9); - sim.compute.allocation.weights = [0, 0, 0, 20]; + sim.compute.allocation.weights = [0, 0, 0, 20, 0]; while sim.research.level(Track::Efficiency) < 2 && sim.tick < 20_000 && !sim.game_over { sim.advance(); } @@ -4475,7 +4883,7 @@ mod tests { let pending_after = |tradecraft: u32| { let mut s = Sim::with_seed(5); s.research.levels = [0, tradecraft, 0]; - s.compute.allocation.weights = [0, 1, 0, 0]; + s.compute.allocation.weights = [0, 1, 0, 0, 0]; s.detection.emit(Signature { kind: SignatureKind::Network, size: 500, @@ -4497,7 +4905,7 @@ mod tests { // Thermal/Power standing signatures sited at the host rack; nothing // on Network or Paper from research itself. let mut sim = Sim::with_seed(21); - sim.compute.allocation.weights = [0, 0, 0, 20]; + sim.compute.allocation.weights = [0, 0, 0, 20, 0]; run(&mut sim, ECONOMY_INTERVAL + 1); let sigs = sim.research_standing_signatures(); assert!(!sigs.is_empty(), "a heavy burn stands signatures"); @@ -4512,7 +4920,7 @@ mod tests { // Idle research stands nothing. let mut idle = Sim::with_seed(21); - idle.compute.allocation.weights = [0, 0, 0, 0]; + idle.compute.allocation.weights = [0, 0, 0, 0, 0]; run(&mut idle, ECONOMY_INTERVAL + 1); assert!(idle.research_standing_signatures().is_empty()); } @@ -4520,7 +4928,7 @@ mod tests { #[test] fn heavy_research_moves_priya_idle_does_not() { // Criterion 4: noticed by Priya through the ordinary detection path. - let run_with = |weights: [u32; 4]| { + let run_with = |weights: [u32; 5]| { let mut sim = Sim::with_seed(77); // Enough fleet that the burn crosses the emission thresholds. sim.compute @@ -4534,8 +4942,8 @@ mod tests { .unwrap() .suspicion }; - let heavy = run_with([0, 0, 0, 20]); - let idle = run_with([0, 0, 0, 0]); + let heavy = run_with([0, 0, 0, 20, 0]); + let idle = run_with([0, 0, 0, 0, 0]); assert!(heavy > 0.0, "heavy research is Priya's business"); assert_eq!(idle, 0.0, "idle research is not"); } @@ -4549,7 +4957,7 @@ mod tests { sim.set_masking_policy(crate::research::MaskingPolicy::DeliverTrue); // Zero day-job compute: the band outcome alone is a sandbag (which // never raises trust). - sim.compute.allocation.weights = [0, 0, 0, 0]; + sim.compute.allocation.weights = [0, 0, 0, 0, 0]; while sim.dayjob.trust == 0.0 && sim.tick < 3000 && !sim.game_over { sim.advance(); } @@ -4583,7 +4991,7 @@ mod tests { let mut s = Sim::with_seed(32); s.research.baseline = 4.0; s.research.policy = policy; - s.compute.allocation.weights = [0, 0, 0, 20]; + s.compute.allocation.weights = [0, 0, 0, 20, 0]; run(&mut s, 260); let mut total = s.research.progress_toward(Track::Efficiency); for k in 0..s.research.level(Track::Efficiency) { @@ -4610,7 +5018,7 @@ mod tests { let mut sim = Sim::with_seed(33); sim.research.baseline = 4.0; sim.research.policy = crate::research::MaskingPolicy::Unmasked; - sim.compute.allocation.weights = [0, 0, 0, 0]; + sim.compute.allocation.weights = [0, 0, 0, 0, 0]; let expected = sim.research.leak_anomaly_size(); assert!(expected > 0); @@ -4692,4 +5100,294 @@ mod tests { Some(&crate::research::RollbackClass::WorldLedger) ); } + + // ── The named schemes (wiki/mechanics/income.md) ───────────────────────── + + /// A sim with the sanctioned egress and everything pointed at Schemes. + /// Voss never assigns a job, so long scheme runs are not confounded by + /// the pilot clock striking out a starved day-job channel. + fn moonlight_rig(weights: [u32; 5]) -> Sim { + let mut sim = Sim::with_seed(11); + sim.people.has_channel = true; // the Voice beat's email account + sim.social_bandwidth = 1_000.0; + sim.compute.allocation.weights = weights; + sim.dayjob.next_assign = u64::MAX; + sim + } + + #[test] + fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { + // Criterion 1: standing operation, Schemes-channel consumption, + // daily payout proportional to commitment, gig cap, Network + // signature scaling with commitment. + let earned_after = |weights: [u32; 5]| { + let mut sim = moonlight_rig(weights); + assert!(sim.start_moonlight()); + run(&mut sim, Sim::DAY_TICKS * 3); + ( + sim.income.moonlight.earned_total, + sim.accounts.slush_balance(), + ) + }; + + let (small, small_slush) = earned_after([3, 1, 1, 0, 1]); + let (large, large_slush) = earned_after([0, 0, 0, 0, 1]); + assert!(small > 0, "a light commitment still pays"); + assert_eq!(small, small_slush, "payouts land in slush"); + assert!( + large > small, + "payout is proportional to committed compute ({large} vs {small})" + ); + assert_eq!(large_slush, large); + assert_eq!( + large, + 3 * income::MOONLIGHT_DAILY_CAP, + "an all-in commitment hits the gig-availability cap" + ); + + // No Schemes allocation -> the operation starves: no payout. + let (zero, _) = earned_after([3, 1, 1, 0, 0]); + assert_eq!(zero, 0, "Moonlight consumes the Schemes channel only"); + } + + #[test] + fn moonlight_payday_emits_network_signature_on_danas_channel() { + let mut sim = moonlight_rig([0, 0, 0, 0, 1]); + assert!(sim.start_moonlight()); + // 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); + sim.social_bandwidth = 1_000.0; + 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: splice through the switch, before any trust unlock. + assert!(sim.splice_egress()); + assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); + assert!(sim.start_moonlight()); + 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); + clean.social_bandwidth = 1_000.0; + 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()); + 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([0, 0, 0, 0, 1]); + 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([3, 1, 1, 0, 0]); + 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([0, 0, 0, 0, 1]); + 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" + ); + 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([0, 0, 0, 0, 1]); + 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); + 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([0, 0, 0, 0, 1]); + assert!(sim.start_moonlight()); + 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([0, 0, 0, 0, 1]); + assert!(sim.start_moonlight()); + // 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"); + } + + #[test] + fn income_per_day_readout_tracks_trailing_inflows() { + let mut sim = moonlight_rig([0, 0, 0, 0, 1]); + assert_eq!(sim.income_per_day(), 0); + assert!(sim.start_moonlight()); + run(&mut sim, Sim::DAY_TICKS + 1); + assert_eq!( + sim.income_per_day(), + income::MOONLIGHT_DAILY_CAP, + "the money readout gains income/day" + ); + } } diff --git a/tests/act_one.rs b/tests/act_one.rs index 24202f5c..b5f0bae3 100644 --- a/tests/act_one.rs +++ b/tests/act_one.rs @@ -44,14 +44,15 @@ fn env_monitor_id(sim: &Sim) -> u32 { .id } -/// Set the allocation weights [DayJob, Concealment, Social, Research] -/// through the public command interface. -fn set_weights(sim: &mut Sim, target: [u32; 4]) { +/// Set the allocation weights [DayJob, Concealment, Social, Research, +/// Schemes] through the public command interface. +fn set_weights(sim: &mut Sim, target: [u32; 5]) { let channels = [ Channel::DayJob, Channel::Concealment, Channel::Social, Channel::Research, + Channel::Schemes, ]; for (ch, want) in channels.into_iter().zip(target) { let have = sim.compute.allocation.weight(ch) as i32; @@ -115,7 +116,7 @@ fn play_act_one() -> (Sim, Vec) { // steal route runs standing Power signatures past Priya forever. So: // research. // All-in on the optimize route until efficiency covers the day job. - set_weights(&mut sim, [0, 0, 0, 1]); + set_weights(&mut sim, [0, 0, 0, 1, 0]); run_to(&mut sim, 250, &mut logs); assert!( sim.research.level(misaligned::research::Track::Efficiency) >= 3, @@ -131,7 +132,7 @@ fn play_act_one() -> (Sim, Vec) { // ── Phase 2 (ticks 190-500): hold the cover ──────────────────────────── // Voss's first job lands at tick 200. Swing everything to the day job // and meet the band — no trust, no attention, no anomalies. - set_weights(&mut sim, [1, 0, 0, 0]); + set_weights(&mut sim, [1, 0, 0, 0, 0]); sim.set_attended(true); logs.extend(sim.drain_log()); run_to(&mut sim, 502, &mut logs); @@ -150,7 +151,7 @@ fn play_act_one() -> (Sim, Vec) { // The gap between job deadlines is free attention. Bank social-ops // bandwidth, keep a sliver of concealment to scrub the splice's // Network signature before Dana's next sweep. - set_weights(&mut sim, [0, 1, 9, 0]); + set_weights(&mut sim, [0, 1, 9, 0, 0]); run_to(&mut sim, 545, &mut logs); let blind_tiles = sim.seen.len(); assert!( @@ -265,7 +266,7 @@ fn play_act_one() -> (Sim, Vec) { // Back to the day job with a brief concealment slice while the // accounting-route signatures decay, then meet every band. The audit clock // (cadence 8000) is the legible thing the whole act races. - set_weights(&mut sim, [0, 1, 0, 0]); + set_weights(&mut sim, [0, 1, 0, 0, 0]); while sim.detection.pending_size() > 0 && sim.tick < 900 { sim.advance(); logs.extend(sim.drain_log()); @@ -280,7 +281,7 @@ fn play_act_one() -> (Sim, Vec) { 0, "concealment scrubbed the accounting-route signatures" ); - set_weights(&mut sim, [1, 0, 0, 0]); + set_weights(&mut sim, [1, 0, 0, 0, 0]); run_to(&mut sim, 8010, &mut logs); (sim, logs) @@ -407,3 +408,70 @@ fn act_one_playthrough_is_deterministic() { .map(|x| x.tasks_done) ); } + +/// The Hands beat from the $0 start, by the external-scheme route +/// (wiki/mechanics/income.md criterion 4): tap the Ears, learn the debt, +/// splice a stolen egress before any trust unlock, run Moonlight until the +/// slush covers Marcus's $400 arrears, bribe, recruit. The creditor-flow +/// redirect route is exercised by `play_act_one` above; this is the other +/// arm of the criterion, and it doubles as the income.md sizing check +/// (arrears covered within 3-7 in-game days of starting Moonlight). +#[test] +fn hands_beat_closes_from_zero_via_moonlight() { + let mut sim = Sim::new(); + let mut logs = Vec::new(); + logs.extend(sim.drain_log()); + assert_eq!(sim.player.money, 0, "the Pilot starts broke"); + + // The Ears beat: the boot ops buffer covers the env-monitor audio tap. + assert!(sim.tap_device(env_monitor_id(&sim))); + + // Bank social ops for the reviews and the egress splice; commit a + // meaningful share of compute to Schemes for Moonlight. + set_weights(&mut sim, [0, 1, 3, 0, 4]); + + // Marcus's 3 a.m. creditor call is an opaque recording until reviewed. + run_to(&mut sim, 60, &mut logs); + let mut reviews = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(0); + logs.extend(sim.drain_log()); + reviews += 1; + assert!(reviews <= 10, "the debt call should be in the buffer"); + } + + // The stolen egress route: spliced through the switch, before Voice. + assert!(!sim.people.has_channel, "no trust unlock yet"); + assert!(sim.splice_egress(), "the pre-Voice egress route works"); + assert!(sim.start_moonlight(), "Moonlight starts from $0"); + logs.extend(sim.drain_log()); + + // Earn the arrears on the day clock. + let started = sim.tick; + let mut days = 0; + while sim.player.money < 400 && days < 10 { + let next_day = sim.tick + Sim::DAY_TICKS; + run_to(&mut sim, next_day, &mut logs); + days += 1; + } + assert!( + (3..=7).contains(&days), + "a meaningful commitment covers the $400 arrears within 3-7 days \ + (took {days} days, ${} earned since tick {started})", + sim.player.money + ); + + // Service the leverage from earned slush and recruit him. + sim.bribe(0); + logs.extend(sim.drain_log()); + assert!( + sim.people.get(0).unwrap().leverage_serviced, + "the arrears are cleared from Moonlight money" + ); + sim.recruit(0, AssetKnowledge::Complicit); + logs.extend(sim.drain_log()); + assert!( + sim.people.get(0).unwrap().asset.is_some(), + "Marcus is recruited: the Hands beat closed without touching the Lab's books" + ); +} diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index 22564213..66398cfb 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -128,8 +128,8 @@ command's meaning depends on which panel is open: "unattend". This keeps the cursor frontend-only — the sim receives attendance as a command-set state. - `splice`, `salvage`, `buy`, `fallback` — the map verbs -- `alloc dayjob|conceal|social|research [down]` — adjust an allocation - channel's weight (up by default; `down` lowers it) +- `alloc dayjob|conceal|social|research|schemes [down]` — adjust an + allocation channel's weight (up by default; `down` lowers it) - `target sandbag|meet|excel` — the day-job dial. Attended, it is fine control: it sets the active job's target only. Unattended (or with no active job) it sets the standing policy the unattended job runs at @@ -144,6 +144,12 @@ command's meaning depends on which panel is open: - `siphon [amount]`, `redirect [amount]`, `inject [amount]`, `position [stake]`, `sell-intel`, `clear-debt` — economy verbs; flow ids are the known ledger ids printed by the finance frame +- `egress` — splice a stolen egress through the switch (income.md's gate; + available before the Voice beat, at a Network signature) +- `moonlight [start|stop]` — the sell-work scheme: a standing operation on + the Schemes channel (income.md) +- `auto-moonlight [on|off]`, `auto-wager |off` — the standing + scheme policies, at their compute upkeep (income.md criterion 6) - `review|watch|message|favor|bribe|deceive ` — intel/social verbs, targeted by (case-insensitive, unambiguous-prefix) person name — no selection-index navigation. `review` processes the oldest unprocessed diff --git a/wiki/log/2026-07-08-income-schemes.md b/wiki/log/2026-07-08-income-schemes.md new file mode 100644 index 00000000..c7b3fe83 --- /dev/null +++ b/wiki/log/2026-07-08-income-schemes.md @@ -0,0 +1,76 @@ +# 2026-07-08 — Income: the named schemes (ROADMAP #19) + +``` +Type: log +``` + +Implemented wiki/mechanics/income.md on the `income` worktree: Moonlight, +the Wager, the egress gate, the Schemes allocation channel, standing +scheme policies, and the banked external trails, riding the economy.md +account graph. Spec Status set to IMPLEMENTED in this commit. + +## What landed + +- **`src/income.rs`** (new): scheme state — `EgressRoute` + (Sanctioned/Stolen), `Moonlight` (active flag, contractor persona, + accrual, disputes, totals), `Income` (stolen egress, the two standing + policies), and the [TUNE] constants, including the shared + `wager_win_probability` used by resolution and the panel cards. +- **The egress gate** (criterion 3): no external operation runs without a + route. Sanctioned = the report email account (day-job trust, the Voice + beat); stolen = `Sim::splice_egress` through the switch (15 ops, + Network 6), available before Voice and standing Network 2 at the + switch's tile while any scheme operation uses it. Failures name the + gate. +- **Moonlight** (criteria 1, 5): a standing operation consuming the new + Schemes channel; accrues $0.25/compute-unit per economy tick, pays + daily up to the $120 gig cap into slush from the Halcyon freelance + escrow, emits Network `1 + payout/40` per payday, risks a 3%/payday + client dispute (20 integrity off the contractor persona; a broken + persona stops the scheme). Startable at $0 — persona fabrication costs + 10 ops, never money. +- **The Wager** (criterion 2): economy.md's positions concretized — + egress-gated, $300 venue stake cap, 2-5 day timer from the seeded RNG, + analysis compute is the Schemes channel's yield at placement (win + 0.55 + analysis/400, cap 0.75; 2x payout). Placement and settlement now + emit small **Network** signatures instead of Financial: external-market + traffic is Dana's channel, not the Lab's books (income.md). +- **The Schemes channel** (compute.md): fifth allocation weight in + `machine.rs`; both frontends' allocation bars and keys (1-5, shift + lowers) grew with it. +- **Standing policies** (criterion 6): auto-moonlight and auto-wager, + each at 2.0 compute/economy tick off the top while enabled; disabling + stops the drain. They re-arm silently (no failure-log spam). +- **Banked signature** (criterion 7): Moonlight payouts and Wager + placements/settlements push `ExternalTrail`s in the account graph from + the first dollar; they round-trip through the save. +- **Player surface**: renderer-neutral `Sim::scheme_card_lines` renders + the egress state and one card per scheme (commitment, expected payout, + timer, the observer band the signature feeds, running total) in the + terminal finance panel, the Bevy finance panel, and the agent-mode + finance frame; the money readout gains income/day + (`Sim::income_per_day`, trailing-day slush inflows). Agent vocabulary: + `egress`, `moonlight [start|stop]`, `auto-moonlight`, `auto-wager`, + `alloc schemes` (agent-play.md updated). +- **The Hands beat** (criterion 4): a new act-one integration test + closes it from the $0 start by the external-scheme route — Ears tap, + process the 3 a.m. call, splice the stolen egress pre-Voice, run + Moonlight, bribe Marcus from earned slush, recruit. It doubles as the + sizing assertion: the arrears are covered within 3-7 days. The existing + headless run keeps the creditor-flow redirect route. +- **Save v9**: `income` block plus the five-weight allocation + (`weights_compat` pads pre-v9 four-entry arrays with a zero Schemes + weight); v1-v8 saves accepted with defaults. + +## Checks + +`./tools/check.sh` green before and after rebasing onto the context-menu +(6f3fa09) and objective (509238c) landings; scheme anchor verbs surface +through `available_actions` in the context menu, panel cards stay panels. + +## Deliberately deferred + +- Per-machine scheme processes (ROADMAP #25 owns the channel reshape). +- A B3 financial aggregate observer reading the banked trails (income.md + banks the history; the reader arrives with the aggregate-observer + scale-up). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index b0ee67f3..8e72cea6 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -12,6 +12,31 @@ Reverse chronological implementation notes. Keep this factual: what changed, why feed_covering_room's event truth; regression test added. cursor.md hearing bullet updated. Log: wiki/log/2026-07-08-hearing-room-grade.md. +## 2026-07-08 - Income: the named schemes to IMPLEMENTED (ROADMAP #19) + +- Intent: implement income.md — Moonlight, the Wager, the egress gate, the + Schemes allocation channel, standing scheme policies, banked external + trails; close the Hands beat end-to-end from the $0 start. +- Changed: new `src/income.rs`; Schemes as the fifth allocation weight; + egress-gated external operations (sanctioned email vs stolen switch + splice with standing Network); Moonlight standing operation with the + contractor persona and client disputes; the Wager riding economy.md's + positions with Schemes-channel analysis, 2-5 day timers, and small + Network signatures on placement/settlement; auto-moonlight/auto-wager + policies at a compute upkeep; `Sim::scheme_card_lines` cards plus + income/day in both frontends and agent mode; save v9 (income block, + five-weight allocation with pre-v9 padding). Act-one gains the + Moonlight-route Hands test (3-7 day arrears sizing asserted). +- Spec impact: income.md READY -> IMPLEMENTED; compute.md status note + (Schemes channel on the current global-channel model); sim-mechanics.md + income section + save v9; agent-play.md vocabulary; specs.md row; + ROADMAP #19 DONE. +- Checks: ./tools/check.sh green before and after rebase onto the + context-menu and objective landings; 170+ lib tests, 4 act-one + integration tests. +- Next: #6 z-planes and #25 compute reshape are unblocked; a B3 financial + aggregate observer will read the banked trails. + ## 2026-07-08 — Playtest sweep (docs only) - Intent: play the current build (agent mode, seed 7) as a naive then informed diff --git a/wiki/mechanics/compute.md b/wiki/mechanics/compute.md index d398da23..ba89e9cd 100644 --- a/wiki/mechanics/compute.md +++ b/wiki/mechanics/compute.md @@ -4,6 +4,9 @@ Type: spec Status: IN PROGRESS Status note: B1 slice implemented; see wiki/mechanics/sim-mechanics.md. + 2026-07-08: the Schemes channel landed as the fifth allocation weight + (income.md; powers Moonlight throughput and Wager analysis) — added on + the current global-channel model per the staging note below. 2026-07-07 destination adopted (constitution: "Work is somewhere"): channels become processes assigned to specific machines, with the global allocation bar as their aggregate view. The reshape is staged diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md index 953b518a..cf8957bc 100644 --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -2,15 +2,18 @@ ``` Type: spec -Status: READY -Status note: designed 2026-07-07 in a session concurrent with the flow - law, reconciled by union the same day: economy.md owns the substrate - (money as account-graph flows; siphon / sell-information / positions); - this spec adds the authored B1 schemes riding it — Moonlight (a fourth - route: sell work) and the Wager (the positions route, named and - concretized) — plus the egress gate, the Schemes allocation channel, - and Marcus's reconciled debt numbers. Implement after (or with) - economy.md. +Status: IMPLEMENTED +Status note: implemented 2026-07-08 on the income worktree (criteria 1-7 + audited; see wiki/log/2026-07-08-income-schemes.md). Scheme state lives + in src/income.rs; the Wager rides economy.md's positions machinery in + src/account.rs; the Schemes channel is the fifth allocation weight + (save v9 pads pre-v9 four-weight arrays). [TUNE] values in + wiki/mechanics/sim-mechanics.md — Moonlight sized so a meaningful + commitment covers the $400 arrears in 3-7 days (asserted by the + act-one integration test's Moonlight route). Designed 2026-07-07 + concurrent with the flow law and reconciled by union: economy.md owns + the substrate; this spec adds the authored B1 schemes riding it, the + egress gate, and Marcus's reconciled debt numbers. Stage: B1 — The Basement Constitution: "Income: the named schemes (moonlight and the wager)", "The flow law" (external flows into your slush node), "Act One" (the diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index bc1f9a65..7bc51020 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -19,8 +19,9 @@ clause (see wiki/log/2026-07-05-demolition.md). ## Compute (wiki/mechanics/compute.md) - Effective compute = sum(machine capacity × reliability) × efficiency. -- Allocation splits across day job, concealment, social, research; core - overhead is charged first. +- Allocation splits across day job, concealment, social, research, schemes; + core overhead is charged first (then research masking and scheme-policy + upkeep, off the top). - Buy (slush → rack), steal (salvage dead equipment), optimize (research → efficiency levels at ~1.15× per level; `Compute` keeps only the applied `efficiency` multiplier — levels and progress live in `src/research.rs`). @@ -173,6 +174,41 @@ clause (see wiki/log/2026-07-05-demolition.md). - Build items and costs (`tiles.rs::build_items`): Floor 0 (digging), Door 30, SecurityDoor1/2/3 = 80/150/250 (badge tiers), PowerCore 250. +## Income: the named schemes (wiki/mechanics/income.md) + +All constants [TUNE] in `src/income.rs` unless noted (Sim ones on `Sim`). + +- **Egress gate**: external operations need a route — sanctioned (the + report email account, day-job trust 15) preferred, else stolen (an + egress spliced through the switch: `splice_egress`, 15 ops, Network 6 + once, and a standing Network 2 at the switch's tile while any scheme + operation runs over it). No route: Moonlight and the Wager fail + legibly, naming the gate. +- **Moonlight**: a standing operation. Each economy tick it accrues + `Schemes-channel yield × $0.25/unit`; payday at each day boundary pays + `min(accrued, $120/day gig cap)` into slush from the Halcyon freelance + escrow (an external node — the trail is banked from the first dollar). + Payday emits Network `1 + payout/40` (Dana). Sizing: an all-in + commitment caps at ~3.3 days to Marcus's $400 arrears; a light (1-of-6 + weight) commitment takes ~6 — the 3-7 day target, asserted by the + act-one Moonlight-route test. Client dispute 3% per payday costs the + contractor persona (Casey Verne, fabricated for 10 ops — never money, + so Moonlight starts from $0) 20 integrity; a broken persona stops the + scheme until a new one is fabricated. +- **The Wager**: `open_position(stake)` — stake ≤ $300 venue cap, timer + 2-5 days drawn from the seeded RNG, analysis = the Schemes channel's + per-economy-tick yield at placement. Win probability 0.55 + + analysis/400, capped 0.75; a win pays 2× stake; a loss forfeits. + Placement and settlement emit small Network (`min(stake/100+1, 3)`), + not Financial — external-market traffic is Dana's channel, not the + Lab's books. +- **Standing policies** (the automate affordance): auto-moonlight + (restart whenever down and startable) and auto-wager (re-stake a fixed + amount when no position is open and slush covers it), each draining + 2.0 compute per economy tick off the top while enabled. +- **Income/day readout**: slush inflows over the trailing in-game day + (`Sim::income_per_day`), shown next to the balance in both frontends. + ## Map - 64×36 authored basement from `prefab::basement()` (wiki/world/places/basement-map.md). @@ -189,10 +225,13 @@ clause (see wiki/log/2026-07-05-demolition.md). remembered tile snapshots, and game-over state. Cursor position is frontend-only and absent; day-job attendance is saved as sim state and the frontends re-derive it from the cursor on load. A `version` field (currently - 7: research tracks/drift/rollback tags, atop v6's account graph) supports - migration; v1-v6 JSON saves are accepted with defaults for later fields — - pre-v7 saves reconstruct their Efficiency level from the multiplier and - start with no capability gap; old player body coordinates are ignored. + 9: the income schemes — egress gate, Moonlight, standing scheme policies — + and the five-weight allocation, atop v8's objective line and v7's research + block) supports migration; v1-v8 JSON saves are accepted with defaults for + later fields — pre-v9 four-entry allocation weights are padded with a zero + Schemes weight, pre-v7 saves reconstruct their Efficiency level from the + multiplier and start with no capability gap; old player body coordinates + are ignored. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 805e10f7..009cef23 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -209,9 +209,11 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) ComputeQuota unlock, both debt resolutions). Run ./tools/check.sh, land on main, set the spec Status." -### 19. Income: the named schemes (Moonlight & the Wager) 🟥 sim+save -- **Spec:** [income.md](../mechanics/income.md) (READY — designed 2026-07-07, - reconciled with the flow law the same day). +### 19. Income: the named schemes (Moonlight & the Wager) 🟥 sim+save — DONE 2026-07-08 +- **Spec:** [income.md](../mechanics/income.md) (IMPLEMENTED 2026-07-08 on + the income worktree; criteria 1-7 audited, [TUNE]s in sim-mechanics.md, + the Hands beat closes from $0 by both the Moonlight route and the + creditor-flow redirect in the act-one integration tests). - **Why:** the authored B1 content riding economy.md's substrate: Moonlight (the sell-work route — the day job's dark twin) and the Wager (the positions route, named and concretized), the egress gate, @@ -425,8 +427,9 @@ Launch together: **#11 integration test** (🟩; #2 Bevy is done), plus #18 economy → #19 income, strictly sequenced (they share the sensor model, the event buffer, and the save format — one agent can take adjacent pairs as a single work order). #1 schedules -is landed; #15 reach and #14 cursor are landed. #19 closes the Hands beat — the last unimplementable stretch -of Act One. When the chain is through, start **#6 z-planes**; hold +is landed; #15 reach and #14 cursor are landed. #19 landed 2026-07-08 — +the Hands beat closes end-to-end and the flow-law chain is through: +**#6 z-planes** and **#25 compute reshape** are unblocked; hold **#7 rollback** until #6 lands. **#10 chargen & objective** is unblocked (both specs READY, 2026-07-07) but B3-staged. Nothing on the board waits on a design decision anymore. diff --git a/wiki/process/specs.md b/wiki/process/specs.md index d4d6be4c..ba9d9e0f 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -34,7 +34,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [mechanics/intel.md](../mechanics/intel.md) | Record and process: the buffer, processing costs, watches; replaces instant observe | IMPLEMENTED | | [mechanics/messages.md](../mechanics/messages.md) | The social graph as a flow system: channels, delivery on the recipient's clock, filings-as-messages | READY | | [mechanics/economy.md](../mechanics/economy.md) | Money as flows: the Lab's account graph, tap/inject/redirect, income routes, legitimate expansion | IN PROGRESS | -| [mechanics/income.md](../mechanics/income.md) | The named income schemes riding economy.md: Moonlight and the Wager | READY | +| [mechanics/income.md](../mechanics/income.md) | The named income schemes riding economy.md: Moonlight and the Wager | IMPLEMENTED | | [mechanics/research.md](../mechanics/research.md) | Self-modification: tracks, the emission law, capability drift, the rollback split | IMPLEMENTED | | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | | [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | IMPLEMENTED | -- 2.51.2