From 080974809bd823b3a798e77425877460768a3c0b Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Thu, 23 Jul 2026 06:13:53 -0700 Subject: [PATCH] Implement discrete Moonlight contracts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make freelance income compete for real WORK and retain exact mail, persona, settlement, and routed-evidence custody across save/load. πŸ‘Ύ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- CLAUDE.md | 2 +- crates/misaligned-bevy/src/operations_ui.rs | 17 +- crates/misaligned-core/src/account.rs | 33 +- crates/misaligned-core/src/actions.rs | 245 ++-- crates/misaligned-core/src/income.rs | 193 ++- crates/misaligned-core/src/messages.rs | 10 + .../src/operations_projection.rs | 222 ++-- crates/misaligned-core/src/save.rs | 1177 ++++++++++++++++- .../misaligned-core/src/sim/communications.rs | 20 + crates/misaligned-core/src/sim/economy.rs | 877 +++++++++--- crates/misaligned-core/src/sim/mod.rs | 2 +- .../misaligned-core/src/sim/tests/economy.rs | 917 ++++++++----- crates/misaligned-core/src/sim/tests/work.rs | 29 +- crates/misaligned-core/src/sim/work.rs | 61 +- crates/misaligned-core/src/sinks.rs | 2 - crates/misaligned-core/tests/act_one.rs | 25 +- crates/misaligned-terminal/src/agent.rs | 83 +- crates/misaligned-terminal/src/operations.rs | 25 +- wiki/engineering/current-build.md | 6 +- wiki/interface/action-vocabulary.md | 6 +- wiki/interface/operations-workspace.md | 20 +- wiki/log/2026-07-23-moonlight-gigs.md | 23 + wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-23.md | 17 +- wiki/mechanics/compute.md | 7 +- wiki/mechanics/income.md | 119 +- wiki/mechanics/intel.md | 14 +- wiki/mechanics/machine-work.md | 5 +- wiki/mechanics/messages.md | 16 +- wiki/mechanics/people-tokens.md | 2 +- wiki/process/ROADMAP.md | 23 +- wiki/process/specs.md | 2 +- wiki/vision/simulation-laws.md | 4 +- 33 files changed, 3248 insertions(+), 961 deletions(-) create mode 100644 wiki/log/2026-07-23-moonlight-gigs.md diff --git a/CLAUDE.md b/CLAUDE.md index 189e85d8..815458bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ afterward. `./tools/check.sh --docs|--lib|--frontend` gate. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired - machine modes, not current player assignments. Save format is currently v52; + machine modes, not current player assignments. Save format is currently v53; only the current version loads (pre-release rider 2026-07-16 β€” older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index bd61a897..99db2449 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -140,7 +140,7 @@ mod operations_workspace_tests { ); } - /// Criterion 12 (intel sale + blocked Moonlight): the Bevy rows carry + /// Criterion 12 (intel sale + Moonlight policy): the Bevy rows carry /// the same bound commands, cost/signature previews, and exact blocked /// reasons the projection binds; execution dispatches the bound row. #[test] @@ -161,19 +161,14 @@ mod operations_workspace_tests { kind: misaligned::intel::IntelKind::Leverage(misaligned::person::Leverage::Debt), }); - // The blocked Moonlight start renders its exact reason. + // The Moonlight board exposes its standing contract policy. let ops = OperationsWorkspace::open_view(OperationsView::Schemes); let rows = ops.action_rows(&sim); - let start = rows + let policy = rows .iter() - .find(|r| matches!(r.command, ActionCommand::StartMoonlight)) - .expect("the blocked start row remains visible"); - assert!( - ops_explain_text(start) - .contains("blocked: no egress channel - open one, or earn the report email") - || ops_explain_text(start) - .contains("blocked: no egress channel β€” open one, or earn the report email") - ); + .find(|r| matches!(r.command, ActionCommand::SetAutoMoonlight(true))) + .expect("the policy row remains visible"); + assert!(ops_explain_text(policy).contains("cost")); // The intel sale row binds the exact processed id and dispatches. let mut ops = OperationsWorkspace::open_view(OperationsView::Intel); diff --git a/crates/misaligned-core/src/account.rs b/crates/misaligned-core/src/account.rs index 813ee224..3297fb25 100644 --- a/crates/misaligned-core/src/account.rs +++ b/crates/misaligned-core/src/account.rs @@ -802,7 +802,8 @@ impl AccountGraph { /// 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. + /// external account by name substring. Legacy callers retain the original + /// fallback chain; exact contracts should use `credit_slush_from_exact`. pub fn credit_slush_from( &mut self, source_needle: &str, @@ -811,12 +812,40 @@ impl AccountGraph { label: impl Into, signature: i32, ) -> bool { - let label = label.into(); let source = self .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()); + self.credit_slush_from_account(source, tick, amount, label, signature) + } + + /// Credit slush only when the named external counterparty exists. This is + /// the fail-closed boundary for contracts whose settlement receipt must + /// retain one exact source rather than silently borrowing another venue. + pub fn credit_slush_from_exact( + &mut self, + source_needle: &str, + tick: u64, + amount: i32, + label: impl Into, + signature: i32, + ) -> bool { + let Some(source) = self.external_id_named(source_needle) else { + return false; + }; + self.credit_slush_from_account(source, tick, amount, label, signature) + } + + fn credit_slush_from_account( + &mut self, + source: AccountId, + tick: u64, + amount: i32, + label: impl Into, + signature: i32, + ) -> bool { + let label = label.into(); let slush = self.slush_id(); let ok = self .transfer( diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index b19005b5..02d98c28 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -94,8 +94,19 @@ pub enum ActionCommand { }, // The named schemes (wiki/mechanics/income.md). OpenEgress, - StartMoonlight, - StopMoonlight, + AcceptMoonlightGig { + gig_id: u64, + }, + DeclineMoonlightGig { + gig_id: u64, + }, + CancelMoonlightGig { + gig_id: u64, + }, + DeliverMoonlightIntel { + gig_id: u64, + raw_id: u64, + }, SetAutoMoonlight(bool), SetAutoWager(Option), ReviewRecordings, @@ -236,7 +247,7 @@ pub enum ActionKind { Siphon, Redirect, OpenEgress, - MoonlightState, + MoonlightContract, MoonlightPolicy, WagerPolicy, AutoReviewPolicy, @@ -285,6 +296,7 @@ pub enum ActionTarget { Person, Intent, Scheme, + Gig, Core, Identity, } @@ -350,7 +362,7 @@ impl ActionKind { Self::SellIntel, Self::OpenEgress, Self::PlaceWager, - Self::MoonlightState, + Self::MoonlightContract, Self::MoonlightPolicy, Self::WagerPolicy, Self::RobotBuild, @@ -362,7 +374,7 @@ impl ActionKind { use ActionRole::{Action, Control}; use ActionSupport::{Live, Stub}; use ActionTarget::{ - Core, Device, Flow, Identity, Intel, Intent, Ledger, Machine, Person, Scheme, Tile, + Core, Device, Flow, Gig, Identity, Intel, Intent, Ledger, Machine, Person, Scheme, Tile, }; macro_rules! def { @@ -516,14 +528,14 @@ impl ActionKind { ["open-egress"], "open a stolen outbound route through the switch" ), - Self::MoonlightState => def!( - "MOONLIGHT STATE", - Control, + Self::MoonlightContract => def!( + "MOONLIGHT CONTRACT", + Action, Live, - [Scheme], - "moonlight [start|stop]", + [Gig], + "accept-gig | decline-gig | cancel-gig ", [], - "run or halt the standing sell-work scheme" + "accept, decline, cancel, or deliver one exact freelance contract" ), Self::MoonlightPolicy => def!( "MOONLIGHT POLICY", @@ -744,7 +756,10 @@ impl ActionCommand { Self::SiphonFlow { .. } => ActionKind::Siphon, Self::RedirectFlow { .. } => ActionKind::Redirect, Self::OpenEgress => ActionKind::OpenEgress, - Self::StartMoonlight | Self::StopMoonlight => ActionKind::MoonlightState, + Self::AcceptMoonlightGig { .. } + | Self::DeclineMoonlightGig { .. } + | Self::CancelMoonlightGig { .. } + | Self::DeliverMoonlightIntel { .. } => ActionKind::MoonlightContract, Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, Self::SetAutoWager(_) => ActionKind::WagerPolicy, Self::ToggleAutoReview => ActionKind::AutoReviewPolicy, @@ -2155,11 +2170,17 @@ impl Sim { ActionCommand::OpenEgress => { self.open_egress(); } - ActionCommand::StartMoonlight => { - self.start_moonlight(); + ActionCommand::AcceptMoonlightGig { gig_id } => { + self.accept_moonlight_gig(*gig_id); + } + ActionCommand::DeclineMoonlightGig { gig_id } => { + self.decline_moonlight_gig(*gig_id); } - ActionCommand::StopMoonlight => { - self.stop_moonlight(); + ActionCommand::CancelMoonlightGig { gig_id } => { + self.cancel_moonlight_gig(*gig_id); + } + ActionCommand::DeliverMoonlightIntel { gig_id, raw_id } => { + self.deliver_moonlight_intel(*gig_id, *raw_id); } ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), @@ -4059,62 +4080,96 @@ impl Sim { out } - /// Moonlight's canonical rows (income.md): a standing operation gated on - /// an egress channel, with the auto-policy as its automate affordance. - /// Its semantic home is the SCHEMES card, not the switch whose egress - /// merely carries its traffic. Its start cost is ops (persona - /// fabrication) β€” never money, so it is a from-$0 route (income.md - /// criterion 5). - pub(crate) fn moonlight_actions(&self) -> Vec { - let mut out = Vec::new(); - let egress = self.egress(); - if self.income.moonlight.active { - out.push(ActionDesc { - verb: "stop Moonlight".into(), - command: ActionCommand::StopMoonlight, + /// Moonlight uses exact contract rows. The board carries only the standing + /// auto-accept control; offer/accept/decline/cancel and delivery are bound + /// to one durable gig so neither frontend can silently choose another. + pub(crate) fn moonlight_gig_actions(&self, gig_id: u64) -> Vec { + let Some(gig) = self + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == gig_id) + else { + return Vec::new(); + }; + let signature = self.signature_note(SignatureKind::Network, gig.terms.network_signature); + match gig.status { + crate::income::MoonlightGigStatus::Offered => { + let blocked = if self.tick > gig.terms.deadline_tick { + Some("this offer has already expired".into()) + } else if !self.moonlight_mail_read(gig, "offer") { + Some("Halcyon's offer has not arrived in readable mail yet".into()) + } else if self.egress().is_none() { + Some("no egress channel β€” open one, or earn the report email".into()) + } else if self.active_moonlight_persona_id() != Some(gig.persona_id) { + Some("the contractor identity addressed by this offer is not active".into()) + } else { + None + }; + vec![ + ActionDesc { + verb: "accept this Halcyon contract".into(), + command: ActionCommand::AcceptMoonlightGig { gig_id }, + cost: ActionCost::Free, + signature: signature.clone(), + disabled_reason: blocked.clone(), + automate: None, + }, + ActionDesc { + verb: "decline this Halcyon contract".into(), + command: ActionCommand::DeclineMoonlightGig { gig_id }, + cost: ActionCost::Free, + signature: None, + disabled_reason: blocked, + automate: None, + }, + ] + } + crate::income::MoonlightGigStatus::Accepted + if gig.kind == crate::income::MoonlightGigKind::ProcessedIntel => + { + let deliver_blocked = if self.tick > gig.terms.deadline_tick { + Some("this contract's deadline has already passed".into()) + } else if self.egress().is_none() { + Some("no egress channel β€” open one, or earn the report email".into()) + } else { + None + }; + self.intel + .iter() + .filter(|intel| !self.accounts.intel_sold(intel.raw_id)) + .map(|intel| ActionDesc { + verb: format!("deliver {} to Halcyon", intel.label()), + command: ActionCommand::DeliverMoonlightIntel { + gig_id, + raw_id: intel.raw_id, + }, + cost: ActionCost::Free, + signature: signature.clone(), + disabled_reason: deliver_blocked.clone(), + automate: None, + }) + .chain(std::iter::once(ActionDesc { + verb: "cancel this Halcyon contract".into(), + command: ActionCommand::CancelMoonlightGig { gig_id }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + })) + .collect() + } + crate::income::MoonlightGigStatus::Accepted + | crate::income::MoonlightGigStatus::Delivered => vec![ActionDesc { + verb: "cancel this Halcyon contract".into(), + command: ActionCommand::CancelMoonlightGig { gig_id }, cost: ActionCost::Free, signature: None, disabled_reason: None, - automate: Some(self.moonlight_automate()), - }); - } else { - let needs_persona = self.moonlight_persona().is_none(); - let disabled = if egress.is_none() { - Some("no egress channel β€” open one, or earn the report email".into()) - } else if needs_persona { - self.sink_action_blocked_reason(&SinkFireEffect::MoonlightPersona) - } else { - None - }; - out.push(ActionDesc { - verb: "start Moonlight (sell-work on the Schemes channel)".into(), - command: ActionCommand::StartMoonlight, - cost: if needs_persona { - ActionCost::Thought(Self::thought_tokens_for_cost( - 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, + automate: None, + }], + _ => Vec::new(), } } @@ -5267,14 +5322,11 @@ mod tests { ); } - /// operations-workspace.md criterion 3 + income.md: the switch keeps - /// OPEN EGRESS (an action on that route) and nothing else strategic; - /// Moonlight's rows live on the SCHEMES card builder with the standing - /// policy as the automate affordance and the exact egress reason while - /// gated. One dispatch table, no frontend rules. + /// The switch keeps only its local egress action. Moonlight's durable + /// contracts and auto-accept control live on semantic Operations targets. #[test] - fn switch_keeps_egress_and_moonlight_lives_on_its_card() { - let mut s = sim(); + fn switch_keeps_egress_and_moonlight_contracts_live_on_their_cards() { + let s = sim(); let sw = switch(&s); let acts = s.available_actions(Anchor::Device(sw)); let egress = acts @@ -5285,9 +5337,10 @@ mod tests { assert!( !acts.iter().any(|a| matches!( a.command, - ActionCommand::StartMoonlight - | ActionCommand::StopMoonlight - | ActionCommand::SetAutoMoonlight(_) + ActionCommand::AcceptMoonlightGig { .. } + | ActionCommand::DeclineMoonlightGig { .. } + | ActionCommand::CancelMoonlightGig { .. } + | ActionCommand::DeliverMoonlightIntel { .. } | ActionCommand::OpenPosition { .. } | ActionCommand::SellIntel { .. } | ActionCommand::ReviewFinance @@ -5297,36 +5350,12 @@ mod tests { )), "the switch menu carries no scheme/ledger/flow/sale rows" ); - - let ml = s - .moonlight_actions() - .into_iter() - .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) - .expect("the SCHEMES card offers Moonlight"); - assert_eq!( - ml.disabled_reason.as_deref(), - Some("no egress channel β€” open one, or earn the report email"), - "Moonlight is gated on an egress channel" + let card = s.operations_projection().schemes.remove(0); + assert!( + card.actions + .iter() + .any(|action| matches!(action.command, ActionCommand::SetAutoMoonlight(true))) ); - let auto = ml.automate.as_ref().expect("Moonlight carries its policy"); - assert!(matches!( - auto.command, - ActionCommand::SetAutoMoonlight(true) - )); - - // Open the egress: Moonlight opens, and executing through the - // query starts it (one dispatch table, no frontend rules). - s.execute_action(&ActionCommand::OpenEgress); - drain_ops(&mut s); - let ml = s - .moonlight_actions() - .into_iter() - .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) - .unwrap(); - assert!(ml.enabled(), "with an egress, Moonlight can start"); - s.execute_action(&ActionCommand::StartMoonlight); - drain_ops(&mut s); - assert!(s.income.moonlight.active); } #[test] diff --git a/crates/misaligned-core/src/income.rs b/crates/misaligned-core/src/income.rs index 4854c5b9..36cea4cf 100644 --- a/crates/misaligned-core/src/income.rs +++ b/crates/misaligned-core/src/income.rs @@ -2,10 +2,10 @@ //! //! 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. +//! owns the scheme state: the egress gate, Moonlight's durable freelance +//! contracts, and the standing policies that automate each scheme. `Sim` +//! wires that state to the economy tick, real work Demand, account settlement, +//! and routed Network evidence. /// Synthetic aggregate counterparty for the external clients who receive /// Moonlight work. This is deliberately distinct from both a Lab person and @@ -34,39 +34,143 @@ impl EgressRoute { } } -/// Moonlight: ghost freelance data-work under a fabricated contractor -/// persona β€” the day job's dark twin, a standing operation on the Schemes -/// channel. +/// The kind of delivery a Halcyon contract asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum MoonlightGigKind { + Compute, + ProcessedIntel, +} + +impl MoonlightGigKind { + pub fn label(self) -> &'static str { + match self { + Self::Compute => "data work", + Self::ProcessedIntel => "information request", + } + } +} + +/// The contract terms visible before acceptance and copied into custody when +/// accepted. A future market change cannot rewrite an accepted deal. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MoonlightGigTerms { + pub reward: i32, + pub deadline_tick: u64, + /// Discrete Demand tokens required for a compute delivery. Intel gigs + /// carry zero here and bind one processed holding instead. + pub compute_burden: u32, + pub network_signature: i32, + pub risk_profile: String, +} + +/// One durable delivery receipt. Compute gigs append a receipt for every +/// actual WORK consumption; intel gigs retain the exact processed holding. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MoonlightDeliveryReceipt { + pub tick: u64, + pub delivered_compute: u32, + pub intel_raw_id: Option, +} + +/// The exact AccountGraph and routed-evidence records that settled a gig. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MoonlightSettlementReceipt { + pub tick: u64, + pub financial_record_id: u64, + /// Exact egress device that authored the settlement traffic. + pub source_device: u32, + pub network_evidence_id: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum MoonlightGigStatus { + Offered, + Accepted, + Delivered, + Completed, + Declined, + /// Offer lapsed past its response window without acceptance. No persona + /// consequence β€” the client simply withdrew the open order. + Expired, + Cancelled, + FailedDeadline, +} + +impl MoonlightGigStatus { + pub fn label(self) -> &'static str { + match self { + Self::Offered => "offer", + Self::Accepted => "accepted", + Self::Delivered => "delivered", + Self::Completed => "paid", + Self::Declined => "declined", + Self::Expired => "expired", + Self::Cancelled => "cancelled", + Self::FailedDeadline => "missed deadline", + } + } + + pub fn active(self) -> bool { + matches!(self, Self::Accepted | Self::Delivered) + } + + /// Terminal states that never enqueue more work or accept a reply. + pub fn terminal(self) -> bool { + matches!( + self, + Self::Completed + | Self::Declined + | Self::Expired + | Self::Cancelled + | Self::FailedDeadline + ) + } +} + +/// One exact external work order. Records are append-only in practice: +/// terminal state preserves the original offer, acceptance, work receipts, +/// payment, traffic, and persona consequence rather than making cancellation +/// an eraser. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MoonlightGig { + pub id: u64, + pub kind: MoonlightGigKind, + pub terms: MoonlightGigTerms, + pub status: MoonlightGigStatus, + /// The exact public identity the client addressed. It never changes + /// after an offer has been posted. + pub persona_id: crate::persona::PersonaId, + pub offered_tick: u64, + pub accepted_tick: Option, + pub accepted_terms: Option, + pub delivered_compute: u32, + pub delivered_intel: Option, + pub delivery_receipts: Vec, + pub settlement: Option, + pub persona_consequence: Option, + /// Exact financial-mail records carrying offer, response, receipt, and + /// invoice custody for this contract. + pub mail_ids: Vec, +} + +/// Moonlight is a board of durable external work orders, not a standing +/// compute mirror. #[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 instance the gigs run under (personas.md; - /// client disputes add contradiction evidence to the external client's - /// relationship and a formal client rejection can burn it). The identity - /// itself lives in the persona world; this is the standing link. - #[serde(default)] - pub persona_id: Option, - /// Compute-dollars accrued toward today's payout (cleared at payday). - pub accrued: f32, + pub gigs: Vec, + pub next_gig_id: u64, + pub last_market_day: Option, /// 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_id: None, - accrued: 0.0, + gigs: Vec::new(), + next_gig_id: 1, + last_market_day: None, earned_total: 0, - last_payout: 0, - disputes: 0, } } } @@ -77,8 +181,8 @@ pub struct Income { /// A stolen egress opened 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. + /// Standing policy: accept matching offered Moonlight contracts whenever + /// their visible bounds are met. 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. @@ -103,21 +207,26 @@ impl Income { /// 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; +/// Maximum open offers refreshed on each market day [TUNE]. +pub const MOONLIGHT_DAILY_OFFER_CAP: usize = 2; +/// Normal compute-gig demand burden [TUNE]. +pub const MOONLIGHT_COMPUTE_BURDEN: u32 = 36; +/// Normal compute-gig fee [TUNE]. +pub const MOONLIGHT_COMPUTE_REWARD: i32 = 100; +/// Routed Network evidence size for a completed compute contract [TUNE]. +pub const MOONLIGHT_COMPUTE_NETWORK_SIGNATURE: i32 = 4; +/// Player-facing risk held by every compute offer in the current save schema. +pub const MOONLIGHT_COMPUTE_RISK_PROFILE: &str = "routine analysis leaves a traffic trail"; +/// Normal commissioned-intel fee [TUNE]. +pub const MOONLIGHT_INTEL_REWARD: i32 = 90; +/// Routed Network evidence size for a completed commissioned-intel contract [TUNE]. +pub const MOONLIGHT_INTEL_NETWORK_SIGNATURE: i32 = 3; +/// Player-facing risk held by every commissioned-intel offer in the current save schema. +pub const MOONLIGHT_INTEL_RISK_PROFILE: &str = "requested material may contradict this identity"; +/// Contract window after market posting [TUNE]. +pub const MOONLIGHT_DEADLINE_DAYS: u64 = 2; /// Observer-local contradiction severity added per Moonlight-client dispute [TUNE]. pub const MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY: i32 = 20; -/// Operations 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; diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs index eadd4f54..75692428 100644 --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -119,6 +119,15 @@ pub enum MessagePayload { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum FinancialRecord { Transfer(AccountRecord), + /// A persisted Halcyon offer, acceptance, cancellation, delivery, or + /// invoice. It is paperwork, not a fifth message channel or a money + /// movement; final payment remains an AccountGraph transfer. + MoonlightContract { + gig_id: u64, + stage: String, + reward: i32, + deadline_tick: u64, + }, PurchaseOrder { source: u32, destination: u32, @@ -132,6 +141,7 @@ impl FinancialRecord { pub fn label(&self) -> &str { match self { FinancialRecord::Transfer(record) => &record.transfer.label, + FinancialRecord::MoonlightContract { stage, .. } => stage, FinancialRecord::PurchaseOrder { label, .. } => label, } } diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 83d5ab06..f3c92afa 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -82,6 +82,8 @@ pub enum OperationsTarget { Flow(AccountFlowId), /// A named standing scheme card (income.md). Scheme(SchemeKind), + /// One exact durable Halcyon freelance contract. + MoonlightGig(u64), /// A submitted plot whose Thought reservoir has not fired yet. PlotSubmission { person: u8, plot_id: String }, /// One in-flight, held, or completed plot run (plots.md). Plot runs are @@ -299,7 +301,9 @@ impl OperationsTarget { OperationsTarget::Account(_) | OperationsTarget::Books | OperationsTarget::Flow(_) => { OperationsView::Accounts } - OperationsTarget::Scheme(_) => OperationsView::Schemes, + OperationsTarget::Scheme(_) | OperationsTarget::MoonlightGig(_) => { + OperationsView::Schemes + } OperationsTarget::PlotSubmission { .. } | OperationsTarget::ActivePlotRun { .. } | OperationsTarget::WagerPosition(_) => OperationsView::Active, @@ -335,6 +339,7 @@ impl OperationsTarget { OperationsTarget::Flow(id) => Some(format!("@flow({id})")), OperationsTarget::Scheme(SchemeKind::Moonlight) => Some("@scheme(moonlight)".into()), OperationsTarget::Scheme(SchemeKind::Wager) => Some("@scheme(wager)".into()), + OperationsTarget::MoonlightGig(id) => Some(format!("@gig({id})")), OperationsTarget::ActivePlotRun { index } => Some(format!("@run({index})")), // A submission's stable strategic home before its run exists is // the bound person; a position's is the Wager card. @@ -2090,39 +2095,127 @@ impl Sim { // ── SCHEMES ──────────────────────────────────────────────────────────── fn schemes_view(&self) -> Vec { - vec![self.moonlight_card(), self.wager_card()] + let mut objects = vec![self.moonlight_card()]; + objects.extend(self.income.moonlight.gigs.iter().filter_map(|gig| { + // Offers are ordinary financial mail first; the board does not + // project a row until that offer has been earned and read. + if gig.status == crate::income::MoonlightGigStatus::Offered + && !self.moonlight_mail_read(gig, "offer") + { + return None; + } + let terms = gig.accepted_terms.as_ref().unwrap_or(&gig.terms); + let mut facts = vec![ + format!("kind: {}", gig.kind.label()), + format!("fee: ${}", terms.reward), + format!("deadline: day {}", 1 + terms.deadline_tick / Sim::DAY_TICKS), + format!("risk: {}", terms.risk_profile), + format!( + "contractor: {}", + self.persona_world + .get(gig.persona_id) + .map(|persona| persona.name.as_str()) + .unwrap_or("missing identity") + ), + ]; + if gig.kind == crate::income::MoonlightGigKind::Compute { + facts.push(format!( + "work delivered: {}/{}", + gig.delivered_compute, terms.compute_burden + )); + } else { + facts.push(if gig.delivered_intel.is_some() { + "information delivered: bound processed material".into() + } else { + "information delivered: none yet".into() + }); + } + if gig.status == crate::income::MoonlightGigStatus::Delivered && self.egress().is_none() + { + facts.push("awaiting egress for payment and traffic".into()); + } + if let Some(consequence) = &gig.persona_consequence { + facts.push(format!("consequence: {consequence}")); + } + if let Some(settlement) = &gig.settlement { + facts.push(format!("paid: ${} to slush", terms.reward)); + facts.push(format!("traffic: Network {}", terms.network_signature)); + facts.push(format!("payment recorded at tick {}", settlement.tick)); + } + Some(OperationsObject { + learned_result: None, + consequence: None, + target: OperationsTarget::MoonlightGig(gig.id), + label: format!("Halcyon {}", gig.kind.label()), + state: match gig.status { + crate::income::MoonlightGigStatus::Offered => ObjectState::Available, + crate::income::MoonlightGigStatus::Accepted => ObjectState::Running, + crate::income::MoonlightGigStatus::Delivered => ObjectState::Pending, + crate::income::MoonlightGigStatus::Completed => ObjectState::Completed, + crate::income::MoonlightGigStatus::Declined + | crate::income::MoonlightGigStatus::Expired + | crate::income::MoonlightGigStatus::Cancelled => ObjectState::Stopped, + crate::income::MoonlightGigStatus::FailedDeadline => ObjectState::Failed, + }, + provenance: vec!["Halcyon market mail".into()], + facts, + progress: gig + .delivery_receipts + .iter() + .map(|receipt| { + if receipt.intel_raw_id.is_some() { + format!( + "delivered commissioned information at tick {}", + receipt.tick + ) + } else { + format!( + "delivered {} work at tick {}", + receipt.delivered_compute, receipt.tick + ) + } + }) + .collect(), + related: Vec::new(), + actions: self.moonlight_gig_actions(gig.id), + }) + })); + objects.push(self.wager_card()); + objects } fn moonlight_card(&self) -> OperationsObject { - let actions = self.moonlight_actions(); let m = &self.income.moonlight; let mut facts = vec![ - format!("state: {}", if m.active { "running" } else { "stopped" }), + format!("earned total: ${}", m.earned_total), format!( - "accrued: {:.1}/{}", - m.accrued, - crate::income::MOONLIGHT_DAILY_CAP + "open offers: {}", + m.gigs + .iter() + .filter(|gig| { + gig.status == crate::income::MoonlightGigStatus::Offered + && self.moonlight_mail_read(gig, "offer") + }) + .count() + ), + format!( + "active contracts: {}", + m.gigs.iter().filter(|gig| gig.status.active()).count() ), - format!("earned total: ${}", m.earned_total), - format!("last payout: ${}", m.last_payout), - format!("disputes: {}", m.disputes), ]; - match self.moonlight_persona() { + match self + .active_moonlight_persona_id() + .and_then(|id| self.persona_world.get(id)) + { Some(persona) => facts.push(format!( - "persona: {} ({}) Β· looks {} to the client", + "current contractor: {} ({}) Β· looks {} to Halcyon", persona.name, persona.archetype_label, self.persona_world .integrity_for(persona.id, crate::income::MOONLIGHT_CLIENT_ID) .label() )), - None => facts.push("persona: none".into()), - } - if self.moonlight_earning_stalled() { - facts.push( - "earning: stalled β€” no machine on WORK; Moonlight resells the day job's output" - .into(), - ); + None => facts.push("current contractor: choose an active Research persona".into()), } facts.push(egress_fact(self)); facts.push(format!( @@ -2138,16 +2231,25 @@ impl Sim { consequence: None, target: OperationsTarget::Scheme(SchemeKind::Moonlight), label: "Moonlight".into(), - state: if m.active { + state: if m.gigs.iter().any(|gig| gig.status.active()) { ObjectState::Running } else { - ObjectState::Stopped + ObjectState::Available }, - provenance: vec!["Schemes channel".into()], + // There is no separate Schemes compute channel; Moonlight is the + // external freelance board that competes for ordinary WORK. + provenance: vec!["Halcyon freelance board".into()], facts, progress: Vec::new(), related: Vec::new(), - actions, + actions: vec![ActionDesc { + verb: "standing policy: accept matching Halcyon offers".into(), + command: ActionCommand::SetAutoMoonlight(!self.income.auto_moonlight), + cost: ActionCost::StandingThought(crate::income::SCHEME_POLICY_UPKEEP), + signature: None, + disabled_reason: None, + automate: None, + }], } } @@ -2214,11 +2316,10 @@ impl Sim { out.extend(self.pending_strategic_commitments()); out.extend(self.in_flight_social_messages()); - if self.income.moonlight.active { - let mut card = self.moonlight_card(); - card.target = OperationsTarget::Scheme(SchemeKind::Moonlight); - out.push(card); - } + out.extend(self.schemes_view().into_iter().filter(|object| { + matches!(&object.target, OperationsTarget::MoonlightGig(id) + if self.income.moonlight.gigs.iter().any(|gig| gig.id == *id && gig.status.active())) + })); for position in self.accounts.known_positions() { out.push(self.active_wager_object(position)); @@ -2319,12 +2420,6 @@ impl Sim { vec![format!("person: {}", self.person_label(*person))], self.person_label(*person), ), - SinkFireEffect::MoonlightPersona => ( - OperationsTarget::Scheme(SchemeKind::Moonlight), - "establish Moonlight persona".to_string(), - vec!["Schemes channel".into()], - "Moonlight".to_string(), - ), // Plot submissions have their richer bound projection above. // Device, reach, construction, and egress work remains on the // physical thing rather than leaking into Operations. @@ -2976,54 +3071,28 @@ mod tests { ); } - /// A blocked Moonlight start row carries the exact egress reason from - /// the shared legality helper, and dispatching the bound command produces - /// the same rejection as a direct call. + /// Moonlight's board owns its policy and exact contract actions; the + /// switch retains only its egress route action. #[test] - fn blocked_moonlight_row_matches_direct_legality() { - let mut s = sim(); + fn moonlight_contract_row_matches_shared_legality() { + let s = sim(); let projection = s.operations_projection(); - let moonlight = projection + let board = projection .schemes .iter() .find(|o| matches!(o.target, OperationsTarget::Scheme(SchemeKind::Moonlight))) .unwrap(); - let start = moonlight - .actions - .iter() - .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) - .expect("Moonlight card shows the start row"); - assert_eq!( - start.disabled_reason.as_deref(), - Some("no egress channel β€” open one, or earn the report email") - ); - - let slush_before = s.accounts.slush_balance(); - s.execute_action(&start.command); assert!( - !s.income.moonlight.active, - "the blocked row does not start Moonlight" - ); - assert_eq!( - s.accounts.slush_balance(), - slush_before, - "a blocked row spends nothing" + board + .actions + .iter() + .any(|action| matches!(action.command, ActionCommand::SetAutoMoonlight(true))) ); - - // The same row the shared legality builder exposes β€” and the switch - // anchor itself no longer aggregates the scheme row (criterion 3). - let direct = s - .moonlight_actions() - .into_iter() - .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) - .unwrap(); - assert_eq!(start.disabled_reason, direct.disabled_reason); - assert_eq!(start.cost, direct.cost); let sw = switch(&s); assert!( !s.available_actions(Anchor::Device(sw)) .iter() - .any(|a| matches!(a.command, ActionCommand::StartMoonlight)), + .any(|a| matches!(a.command, ActionCommand::AcceptMoonlightGig { .. })), "the switch menu no longer carries Moonlight" ); } @@ -3823,8 +3892,8 @@ mod tests { assert!(projection.pressure(OperationsView::People).is_none()); } - /// SCHEMES keeps OPEN EGRESS off the Moonlight card (it stays on the - /// switch) and names the missing egress as the blocking reason. + /// SCHEMES keeps OPEN EGRESS off the Moonlight board (it stays on the + /// switch) and exposes only its standing policy before offers arrive. #[test] fn schemes_keeps_egress_on_switch_and_names_prerequisite() { let s = sim(); @@ -3841,15 +3910,12 @@ mod tests { .any(|a| matches!(a.command, ActionCommand::OpenEgress)), "OPEN EGRESS remains a switch action, not a scheme card row" ); - let start = moonlight + let policy = moonlight .actions .iter() - .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .find(|a| matches!(a.command, ActionCommand::SetAutoMoonlight(true))) .unwrap(); - assert_eq!( - start.disabled_reason.as_deref(), - Some("no egress channel β€” open one, or earn the report email") - ); + assert!(policy.disabled_reason.is_none()); } /// The projection is knowledge-gated: unearned people and unknown flows diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 793be5dc..20e66d85 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -42,7 +42,9 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v52 requires one-shot Paper evidence to retain its +/// Save format version. v53 persists discrete Moonlight contracts, their +/// terms, work/payment/evidence receipts, persona consequences, and bound +/// financial paperwork. v52 requires one-shot Paper evidence to retain its /// exact institutional-carrier route, observer custody, and first-hop interdiction. /// v51 requires the same exact custody for Financial evidence. v50 persists /// person incapacity and exact human-removal custody. v49 records @@ -57,7 +59,7 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 52; +pub const SAVE_VERSION: u32 = 53; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -478,6 +480,7 @@ fn validate_current_save(mut state: SaveState) -> Result { ); } let (routed_evidence_ids, mut max_evidence_id) = validate_routed_evidence(&state)?; + validate_moonlight_gigs(&state, &routed_evidence_ids)?; validate_carried_asset_tasks(&state)?; if state.process_revision != ProcessRevision::CURRENT { return Err("current-version save belongs to an unknown process revision".into()); @@ -1042,6 +1045,37 @@ fn validate_messages(state: &SaveState) -> Result<(), String> { } account_records.push(record.clone()); } + FinancialRecord::MoonlightContract { + gig_id, + stage, + reward, + deadline_tick, + } => { + let gig = state + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == *gig_id); + if message.channel != MessageChannel::Email + || stage.trim().is_empty() + || *reward <= 0 + || message + .persona_id + .is_some_and(|id| state.persona_world.get(id).is_none()) + || !accounts.is_empty() + || !flows.is_empty() + || gig.is_none_or(|gig| { + gig.terms.reward != *reward + || gig.terms.deadline_tick != *deadline_tick + }) + { + return Err(format!( + "current-version Moonlight paperwork message {} has invalid bound terms", + message.id + )); + } + } FinancialRecord::PurchaseOrder { source, destination, @@ -1346,6 +1380,770 @@ fn validate_messages(state: &SaveState) -> Result<(), String> { Ok(()) } +fn validate_moonlight_gigs( + state: &SaveState, + routed_evidence_ids: &HashSet, +) -> Result<(), String> { + use crate::account::FlowChannel; + use crate::income::MoonlightGigKind; + use crate::income::MoonlightGigStatus; + + let mut ids = HashSet::new(); + let mut mail_ids = HashSet::new(); + let mut bound_holdings = HashSet::new(); + let mut max_id = 0u64; + let mut completed_rewards = 0i32; + let mut offers_per_day = HashMap::::new(); + let day = state.sim_tick / crate::sim::Sim::DAY_TICKS; + if state + .income + .moonlight + .last_market_day + .is_some_and(|last| last > day) + { + return Err("current-version Moonlight last_market_day is in the future".into()); + } + + let stage_messages = |gig: &crate::income::MoonlightGig, stage: &str| -> Vec<&Message> { + gig.mail_ids + .iter() + .filter_map(|mail_id| { + state.messages.iter().find(|message| { + message.id == *mail_id + && matches!( + &message.payload, + MessagePayload::FinancialRecord { + record: FinancialRecord::MoonlightContract { + gig_id: message_gig_id, + stage: message_stage, + reward, + deadline_tick, + }, + accounts, + flows, + } if *message_gig_id == gig.id + && message_stage == stage + && *reward == gig.terms.reward + && *deadline_tick == gig.terms.deadline_tick + && accounts.is_empty() + && flows.is_empty() + ) + }) + }) + .collect() + }; + let exact_stage = |gig: &crate::income::MoonlightGig, stage: &str, expected: usize| { + if stage_messages(gig, stage).len() == expected { + Ok(()) + } else { + Err(format!( + "current-version Moonlight gig #{} has wrong `{stage}` mail cardinality", + gig.id + )) + } + }; + let validate_stage_shape = |gig: &crate::income::MoonlightGig, + stage: &str, + outgoing: bool| + -> Result<(), String> { + for message in stage_messages(gig, stage) { + let (expected_from, expected_to, expected_origin) = if outgoing { + ( + MessageEndpoint::Player, + MessageEndpoint::External("Halcyon".into()), + MessageOrigin::Player, + ) + } else { + ( + MessageEndpoint::External("Halcyon".into()), + MessageEndpoint::Player, + MessageOrigin::AuthoredTraffic, + ) + }; + if message.channel != MessageChannel::Email + || message.from != expected_from + || message.to != expected_to + || message.origin != expected_origin + || message.persona_id != Some(gig.persona_id) + { + return Err(format!( + "current-version Moonlight gig #{} `{stage}` mail has invalid endpoint/origin/persona shape", + gig.id + )); + } + } + Ok(()) + }; + let validate_stage_tick = + |gig: &crate::income::MoonlightGig, stage: &str, expected: u64| -> Result<(), String> { + if stage_messages(gig, stage) + .iter() + .all(|message| message.sent_tick == expected) + { + Ok(()) + } else { + Err(format!( + "current-version Moonlight gig #{} `{stage}` mail has invalid authored timing", + gig.id + )) + } + }; + let validate_stage_window = |gig: &crate::income::MoonlightGig, + stage: &str, + earliest: u64, + latest: u64| + -> Result<(), String> { + if stage_messages(gig, stage) + .iter() + .all(|message| message.sent_tick >= earliest && message.sent_tick <= latest) + { + Ok(()) + } else { + Err(format!( + "current-version Moonlight gig #{} `{stage}` mail has invalid authored timing", + gig.id + )) + } + }; + + let slush = state.accounts.slush_id(); + let halcyon = state + .accounts + .accounts + .iter() + .find(|account| { + matches!(account.kind, crate::account::AccountKind::External) + && account.name == "Halcyon freelance escrow" + }) + .map(|account| account.id); + + for gig in &state.income.moonlight.gigs { + if gig.id == 0 || !ids.insert(gig.id) { + return Err("current-version save has an invalid or duplicate Moonlight gig id".into()); + } + max_id = max_id.max(gig.id); + if gig + .mail_ids + .iter() + .any(|mail_id| !mail_ids.insert(*mail_id)) + { + return Err(format!( + "current-version Moonlight gig #{} reuses financial-mail custody", + gig.id + )); + } + let persona = state.persona_world.get(gig.persona_id); + let offer_day = gig.offered_tick / crate::sim::Sim::DAY_TICKS; + let offer_count = offers_per_day.entry(offer_day).or_default(); + *offer_count += 1; + let expected_deadline = gig + .offered_tick + .checked_add(crate::income::MOONLIGHT_DEADLINE_DAYS * crate::sim::Sim::DAY_TICKS); + let exact_terms = match gig.kind { + MoonlightGigKind::Compute => { + gig.terms.reward == crate::income::MOONLIGHT_COMPUTE_REWARD + && gig.terms.compute_burden == crate::income::MOONLIGHT_COMPUTE_BURDEN + && gig.terms.network_signature + == crate::income::MOONLIGHT_COMPUTE_NETWORK_SIGNATURE + && gig.terms.risk_profile == crate::income::MOONLIGHT_COMPUTE_RISK_PROFILE + } + MoonlightGigKind::ProcessedIntel => { + gig.terms.reward == crate::income::MOONLIGHT_INTEL_REWARD + && gig.terms.compute_burden == 0 + && gig.terms.network_signature + == crate::income::MOONLIGHT_INTEL_NETWORK_SIGNATURE + && gig.terms.risk_profile == crate::income::MOONLIGHT_INTEL_RISK_PROFILE + } + }; + if persona.is_none_or(|persona| { + persona.archetype_id != "research" + || !persona + .available_actions + .contains(&PersonaActionKind::Deceive) + }) || state + .persona_world + .relationship(crate::income::MOONLIGHT_CLIENT_ID, gig.persona_id) + .is_none_or(|relationship| !relationship.recognized) + || gig.offered_tick == 0 + || !gig.offered_tick.is_multiple_of(crate::sim::Sim::DAY_TICKS) + || gig.offered_tick > state.sim_tick + || state + .income + .moonlight + .last_market_day + .is_none_or(|last_market_day| offer_day > last_market_day) + || *offer_count > crate::income::MOONLIGHT_DAILY_OFFER_CAP + || expected_deadline != Some(gig.terms.deadline_tick) + || !exact_terms + { + return Err(format!( + "current-version Moonlight gig #{} has invalid offer custody", + gig.id + )); + } + + exact_stage(gig, "offer", 1)?; + validate_stage_shape(gig, "offer", false)?; + validate_stage_tick(gig, "offer", gig.offered_tick)?; + + let receipt_compute = gig + .delivery_receipts + .iter() + .try_fold(0u32, |total, receipt| { + total.checked_add(receipt.delivered_compute) + }) + .ok_or_else(|| { + format!( + "current-version Moonlight gig #{} overflows its delivery receipt sum", + gig.id + ) + })?; + if receipt_compute != gig.delivered_compute + || gig.delivered_compute > gig.terms.compute_burden + { + return Err(format!( + "current-version Moonlight gig #{} has invalid delivery receipt sum", + gig.id + )); + } + match gig.kind { + MoonlightGigKind::Compute => { + if gig.delivered_intel.is_some() + || gig.delivery_receipts.iter().any(|receipt| { + receipt.intel_raw_id.is_some() || receipt.delivered_compute == 0 + }) + { + return Err(format!( + "current-version Moonlight gig #{} has impossible compute delivery", + gig.id + )); + } + } + MoonlightGigKind::ProcessedIntel => { + if gig.delivered_compute != 0 + || gig + .delivery_receipts + .iter() + .any(|receipt| receipt.delivered_compute != 0) + { + return Err(format!( + "current-version Moonlight gig #{} has impossible intel delivery", + gig.id + )); + } + match gig.delivered_intel { + Some(raw_id) => { + if !bound_holdings.insert(raw_id) { + return Err(format!( + "current-version Moonlight gig #{} reuses a commissioned holding", + gig.id + )); + } + if state.intel.iter().all(|intel| intel.raw_id != raw_id) + || !state.accounts.intel_sold(raw_id) + || gig.delivery_receipts.len() != 1 + || gig.delivery_receipts[0].intel_raw_id != Some(raw_id) + { + return Err(format!( + "current-version Moonlight gig #{} has invalid intel holding custody", + gig.id + )); + } + } + None => { + if !gig.delivery_receipts.is_empty() { + return Err(format!( + "current-version Moonlight gig #{} has orphan intel receipts", + gig.id + )); + } + } + } + } + } + for receipt in &gig.delivery_receipts { + if receipt.tick > state.sim_tick + || receipt.tick > gig.terms.deadline_tick + || gig + .accepted_tick + .is_none_or(|accepted_tick| receipt.tick < accepted_tick) + { + return Err(format!( + "current-version Moonlight gig #{} has a delivery receipt outside its window", + gig.id + )); + } + } + if gig + .delivery_receipts + .windows(2) + .any(|receipts| receipts[0].tick > receipts[1].tick) + { + return Err(format!( + "current-version Moonlight gig #{} has reordered delivery receipts", + gig.id + )); + } + + let requires_acceptance = !matches!( + gig.status, + MoonlightGigStatus::Offered + | MoonlightGigStatus::Declined + | MoonlightGigStatus::Expired + ); + if requires_acceptance { + let Some(accepted_tick) = gig.accepted_tick else { + return Err(format!( + "current-version Moonlight gig #{} has invalid acceptance custody", + gig.id + )); + }; + if accepted_tick < gig.offered_tick + || accepted_tick > state.sim_tick + || accepted_tick > gig.terms.deadline_tick + || gig.accepted_terms.as_ref() != Some(&gig.terms) + { + return Err(format!( + "current-version Moonlight gig #{} has invalid acceptance custody", + gig.id + )); + } + exact_stage(gig, "acceptance", 1)?; + validate_stage_shape(gig, "acceptance", true)?; + validate_stage_tick(gig, "acceptance", accepted_tick)?; + } else if gig.accepted_tick.is_some() + || gig.accepted_terms.is_some() + || gig.delivered_compute != 0 + || gig.delivered_intel.is_some() + || !gig.delivery_receipts.is_empty() + || gig.settlement.is_some() + || gig.persona_consequence.is_some() + { + return Err(format!( + "current-version Moonlight gig #{} has stray state for {}", + gig.id, + gig.status.label() + )); + } + + if gig.status.active() && state.sim_tick > gig.terms.deadline_tick { + return Err(format!( + "current-version Moonlight gig #{} remains active past its deadline", + gig.id + )); + } + + match gig.status { + MoonlightGigStatus::Offered => { + if state.sim_tick > gig.terms.deadline_tick { + return Err(format!( + "current-version Moonlight gig #{} is still Offered past its deadline", + gig.id + )); + } + exact_stage(gig, "acceptance", 0)?; + exact_stage(gig, "declined", 0)?; + exact_stage(gig, "delivery receipt", 0)?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + if gig.mail_ids.len() != 1 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail for offered", + gig.id + )); + } + } + MoonlightGigStatus::Expired => { + if state.sim_tick <= gig.terms.deadline_tick { + return Err(format!( + "current-version Moonlight gig #{} is Expired before its deadline", + gig.id + )); + } + exact_stage(gig, "acceptance", 0)?; + exact_stage(gig, "declined", 0)?; + exact_stage(gig, "delivery receipt", 0)?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + if gig.mail_ids.len() != 1 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail for expired", + gig.id + )); + } + } + MoonlightGigStatus::Declined => { + exact_stage(gig, "acceptance", 0)?; + exact_stage(gig, "declined", 1)?; + validate_stage_shape(gig, "declined", true)?; + validate_stage_window( + gig, + "declined", + gig.offered_tick, + gig.terms.deadline_tick.min(state.sim_tick), + )?; + exact_stage(gig, "delivery receipt", 0)?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + if gig.mail_ids.len() != 2 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail for declined", + gig.id + )); + } + } + MoonlightGigStatus::Accepted => { + let finished = match gig.kind { + MoonlightGigKind::ProcessedIntel => gig.delivered_intel.is_some(), + MoonlightGigKind::Compute => { + gig.terms.compute_burden > 0 + && gig.delivered_compute >= gig.terms.compute_burden + } + }; + if finished { + return Err(format!( + "current-version Moonlight gig #{} is Accepted with finished delivery", + gig.id + )); + } + exact_stage(gig, "delivery receipt", 0)?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "declined", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + if gig.settlement.is_some() || gig.persona_consequence.is_some() { + return Err(format!( + "current-version Moonlight gig #{} has stray terminal state while Accepted", + gig.id + )); + } + if gig.mail_ids.len() != 2 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail while Accepted", + gig.id + )); + } + } + MoonlightGigStatus::Delivered => { + let finished = match gig.kind { + MoonlightGigKind::Compute => { + gig.delivered_compute == gig.terms.compute_burden + && gig.terms.compute_burden > 0 + } + MoonlightGigKind::ProcessedIntel => gig.delivered_intel.is_some(), + }; + if !finished { + return Err(format!( + "current-version Moonlight gig #{} is Delivered without finished work", + gig.id + )); + } + exact_stage(gig, "delivery receipt", 1)?; + validate_stage_shape(gig, "delivery receipt", true)?; + validate_stage_tick( + gig, + "delivery receipt", + gig.delivery_receipts + .last() + .expect("finished delivery has a receipt") + .tick, + )?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "declined", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + if gig.settlement.is_some() || gig.persona_consequence.is_some() { + return Err(format!( + "current-version unpaid Moonlight gig #{} has settlement custody", + gig.id + )); + } + if gig.mail_ids.len() != 3 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail while Delivered", + gig.id + )); + } + } + MoonlightGigStatus::Completed => { + let finished = match gig.kind { + MoonlightGigKind::Compute => { + gig.delivered_compute == gig.terms.compute_burden + && gig.terms.compute_burden > 0 + } + MoonlightGigKind::ProcessedIntel => gig.delivered_intel.is_some(), + }; + if !finished { + return Err(format!( + "current-version Moonlight gig #{} is Completed without finished work", + gig.id + )); + } + exact_stage(gig, "delivery receipt", 1)?; + validate_stage_shape(gig, "delivery receipt", true)?; + validate_stage_tick( + gig, + "delivery receipt", + gig.delivery_receipts + .last() + .expect("finished delivery has a receipt") + .tick, + )?; + exact_stage(gig, "invoice paid", 1)?; + validate_stage_shape(gig, "invoice paid", false)?; + exact_stage(gig, "declined", 0)?; + exact_stage(gig, "cancelled", 0)?; + exact_stage(gig, "deadline missed", 0)?; + let Some(settlement) = &gig.settlement else { + return Err(format!( + "current-version Moonlight gig #{} lacks settlement receipt", + gig.id + )); + }; + validate_stage_tick(gig, "invoice paid", settlement.tick)?; + if gig.mail_ids.len() != 4 { + return Err(format!( + "current-version Moonlight gig #{} has stray mail while Completed", + gig.id + )); + } + if settlement.tick > state.sim_tick + || settlement.tick < gig.accepted_tick.unwrap_or(0) + || settlement.tick > gig.terms.deadline_tick + { + return Err(format!( + "current-version Moonlight gig #{} settlement tick is out of bounds", + gig.id + )); + } + let transfer = state + .accounts + .pending_records + .iter() + .find(|record| record.id == settlement.financial_record_id) + .map(|record| &record.transfer) + .or_else(|| { + state + .messages + .iter() + .find_map(|message| match &message.payload { + MessagePayload::FinancialRecord { + record: FinancialRecord::Transfer(record), + .. + } if record.id == settlement.financial_record_id => { + Some(&record.transfer) + } + _ => None, + }) + }); + let Some(transfer) = transfer else { + return Err(format!( + "current-version Moonlight gig #{} has broken payment custody", + gig.id + )); + }; + let Some(halcyon_id) = halcyon else { + return Err(format!( + "current-version Moonlight gig #{} payment lacks Halcyon source account", + gig.id + )); + }; + if transfer.amount != gig.terms.reward + || transfer.requested != gig.terms.reward + || transfer.from != halcyon_id + || transfer.to != slush + || transfer.channel != FlowChannel::ExternalIncome + || transfer.label != format!("Halcyon gig #{}", gig.id) + || transfer.tick != settlement.tick + { + return Err(format!( + "current-version Moonlight gig #{} payment transfer is not bound to the contract", + gig.id + )); + } + if !routed_evidence_ids.contains(&settlement.network_evidence_id) { + return Err(format!( + "current-version Moonlight gig #{} names missing Network evidence", + gig.id + )); + } + let evidence = state + .detection + .routed_evidence() + .iter() + .find(|record| record.id == settlement.network_evidence_id) + .ok_or_else(|| { + format!( + "current-version Moonlight gig #{} names missing Network evidence", + gig.id + ) + })?; + if evidence.kind != crate::detection::SignatureKind::Network + || evidence.size != gig.terms.network_signature + || evidence.cause != format!("Halcyon gig #{} delivery", gig.id) + || evidence.sent_tick != settlement.tick + || evidence.source_device != settlement.source_device + || state.reach.device(settlement.source_device).is_none() + { + return Err(format!( + "current-version Moonlight gig #{} Network evidence is not bound to the contract", + gig.id + )); + } + let settlement_trails = state + .accounts + .external_trails + .iter() + .filter(|trail| { + trail.tick == settlement.tick + && trail.label == format!("Halcyon gig #{}", gig.id) + && trail.amount == gig.terms.reward + && trail.signature == gig.terms.network_signature + }) + .count(); + if settlement_trails != 1 { + return Err(format!( + "current-version Moonlight gig #{} lacks exact banked settlement trail", + gig.id + )); + } + if gig.persona_consequence.is_some() { + return Err(format!( + "current-version paid Moonlight gig #{} has dispute consequence", + gig.id + )); + } + completed_rewards = + completed_rewards + .checked_add(gig.terms.reward) + .ok_or_else(|| { + "current-version Moonlight completed rewards overflow".to_string() + })?; + } + MoonlightGigStatus::Cancelled | MoonlightGigStatus::FailedDeadline => { + let stage = if gig.status == MoonlightGigStatus::Cancelled { + "cancelled" + } else { + "deadline missed" + }; + let outgoing = gig.status == MoonlightGigStatus::Cancelled; + exact_stage(gig, stage, 1)?; + validate_stage_shape(gig, stage, outgoing)?; + exact_stage(gig, "invoice paid", 0)?; + exact_stage(gig, "declined", 0)?; + let finished = match gig.kind { + MoonlightGigKind::Compute => { + gig.delivered_compute == gig.terms.compute_burden + && gig.terms.compute_burden > 0 + } + MoonlightGigKind::ProcessedIntel => gig.delivered_intel.is_some(), + }; + exact_stage(gig, "delivery receipt", usize::from(finished))?; + if finished { + validate_stage_shape(gig, "delivery receipt", true)?; + validate_stage_tick( + gig, + "delivery receipt", + gig.delivery_receipts + .last() + .expect("finished delivery has a receipt") + .tick, + )?; + } + if gig.status == MoonlightGigStatus::Cancelled { + exact_stage(gig, "deadline missed", 0)?; + } else { + exact_stage(gig, "cancelled", 0)?; + } + if gig.settlement.is_some() || gig.persona_consequence.is_none() { + return Err(format!( + "current-version Moonlight gig #{} lacks terminal consequence custody", + gig.id + )); + } + if gig.mail_ids.len() != 3 + usize::from(finished) { + return Err(format!( + "current-version Moonlight gig #{} has stray terminal mail", + gig.id + )); + } + let terminal_tick = stage_messages(gig, stage)[0].sent_tick; + if (gig.status == MoonlightGigStatus::Cancelled + && terminal_tick > gig.terms.deadline_tick) + || (gig.status == MoonlightGigStatus::FailedDeadline + && terminal_tick <= gig.terms.deadline_tick) + { + return Err(format!( + "current-version Moonlight gig #{} has invalid terminal timing", + gig.id + )); + } + let reason = if gig.status == MoonlightGigStatus::Cancelled { + "cancelled accepted work" + } else { + "missed contract deadline" + }; + if gig.persona_consequence.as_deref() + != Some(format!("Halcyon recorded: {reason}").as_str()) + { + return Err(format!( + "current-version Moonlight gig #{} has invalid consequence summary", + gig.id + )); + } + let contradictions = state + .persona_world + .contradictions + .iter() + .filter(|record| { + record.persona_id == gig.persona_id + && record.observer == crate::income::MOONLIGHT_CLIENT_ID + && record.right.record_id + == format!("moonlight-gig:{}:{reason}", gig.id) + }) + .collect::>(); + if contradictions.len() != 1 { + return Err(format!( + "current-version Moonlight gig #{} lacks unique bound persona contradiction", + gig.id + )); + } + let contradiction = contradictions[0]; + if contradiction.cause != reason + || contradiction.severity + != crate::income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY as u8 + || contradiction.discovered_tick != terminal_tick + || contradiction.resolved_tick.is_some() + || contradiction.left.system != "moonlight-contract" + || contradiction.left.record_id != format!("moonlight-gig:{}:accepted", gig.id) + || contradiction.left.summary != "contractor accepted the work" + || contradiction.left.observed_tick != terminal_tick + || contradiction.right.system != "moonlight-client" + || contradiction.right.summary != reason + || contradiction.right.observed_tick != terminal_tick + { + return Err(format!( + "current-version Moonlight gig #{} has malformed persona contradiction", + gig.id + )); + } + } + } + } + if state.income.moonlight.next_gig_id <= max_id { + return Err("current-version save would reuse a Moonlight gig id".into()); + } + if state.income.moonlight.earned_total != completed_rewards { + return Err( + "current-version Moonlight earned_total does not equal completed rewards".into(), + ); + } + Ok(()) +} + fn validate_routed_evidence(state: &SaveState) -> Result<(HashSet, u64), String> { let mut ids = HashSet::new(); let mut max_id = 0; @@ -1939,6 +2737,70 @@ mod tests { .to_string() } + fn accepted_moonlight_sim() -> (Sim, u64) { + let mut sim = Sim::with_seed(0xA11C_E0FF); + sim.dayjob.next_assign = u64::MAX; + sim.people.has_channel = true; + let (x, y) = sim.core_position(); + let think = sim.compute.add_machine( + "save-test think", + x + 1, + y, + 1, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(think, crate::work_grid::MachineMode::Think); + sim.set_machine_mode(sim.core.host_machine, crate::work_grid::MachineMode::Work); + assert!(sim.create_persona("research")); + for _ in 0..=Sim::DAY_TICKS + 1 { + sim.advance(); + } + let id = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| { + gig.kind == crate::income::MoonlightGigKind::Compute + && gig.status == crate::income::MoonlightGigStatus::Offered + }) + .map(|gig| gig.id) + .expect("the deterministic market posts a compute offer"); + assert!(sim.accept_moonlight_gig(id)); + (sim, id) + } + + fn completed_moonlight_state() -> SaveState { + let (mut sim, id) = accepted_moonlight_sim(); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + } + assert_eq!( + sim.income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap() + .status, + crate::income::MoonlightGigStatus::Completed + ); + SaveState::from_sim(&sim) + } + + fn moonlight_validation_error(state: &SaveState) -> String { + let routed_ids = state + .detection + .routed_evidence() + .iter() + .map(|record| record.id) + .collect(); + validate_moonlight_gigs(state, &routed_ids).expect_err("malformed Moonlight state fails") + } + fn routed_filing_state(stopped: bool) -> SaveState { let mut sim = Sim::with_seed(0xF113); for observer in &mut sim.detection.observers { @@ -2188,7 +3050,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "67e3ba200829b2f9bf37c50ef5c5020510c5c821507063c82d2880ab9b9d68cb", + "519d0e34ed81070f2cc5b2ead67558a6b324f708c9247c7bbbac36d0d7dbb7ab", "intentional persisted-state changes must review and repin this baseline" ); } @@ -3126,6 +3988,315 @@ mod tests { assert_eq!(restored.thought_sinks, sim.thought_sinks); } + #[test] + fn current_save_rejects_reused_moonlight_gig_identity() { + let sim = Sim::with_seed(53); + let mut state = SaveState::from_sim(&sim); + state.income.moonlight.next_gig_id = 0; + let json = serde_json::to_string(&state).unwrap(); + let error = parse_save(&json).expect_err("current contract ids fail closed"); + assert!( + error.contains("reuse a Moonlight gig id"), + "unexpected validation error: {error}" + ); + } + + #[test] + fn current_save_pins_moonlight_offer_terms_cadence_and_persona_binding() { + let (sim, gig_id) = accepted_moonlight_sim(); + let state = SaveState::from_sim(&sim); + parse_save(&serde_json::to_string(&state).unwrap()) + .expect("an authored accepted contract roundtrips"); + + let mut inflated_terms = state.clone(); + let gig = inflated_terms + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == gig_id) + .unwrap(); + gig.terms.reward += 1; + gig.accepted_terms = Some(gig.terms.clone()); + for message in &mut inflated_terms.messages { + if let MessagePayload::FinancialRecord { + record: + FinancialRecord::MoonlightContract { + gig_id: message_gig_id, + reward, + .. + }, + .. + } = &mut message.payload + && *message_gig_id == gig_id + { + *reward += 1; + } + } + assert!(moonlight_validation_error(&inflated_terms).contains("invalid offer custody")); + + let mut off_cadence = state.clone(); + let gig = off_cadence + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == gig_id) + .unwrap(); + gig.offered_tick += 1; + gig.terms.deadline_tick += 1; + gig.accepted_terms = Some(gig.terms.clone()); + for message in &mut off_cadence.messages { + if let MessagePayload::FinancialRecord { + record: + FinancialRecord::MoonlightContract { + gig_id: message_gig_id, + stage, + deadline_tick, + .. + }, + .. + } = &mut message.payload + && *message_gig_id == gig_id + { + *deadline_tick += 1; + if stage == "offer" { + message.sent_tick += 1; + } + } + } + assert!(moonlight_validation_error(&off_cadence).contains("invalid offer custody")); + + let mut third_offer_same_day = state.clone(); + let mut third_gig = third_offer_same_day + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.status == crate::income::MoonlightGigStatus::Offered) + .cloned() + .expect("the deterministic market retains its second offer"); + let template_mail_id = third_gig.mail_ids[0]; + let mut third_mail = third_offer_same_day + .messages + .iter() + .find(|message| message.id == template_mail_id) + .cloned() + .unwrap(); + let new_gig_id = third_offer_same_day.income.moonlight.next_gig_id; + third_offer_same_day.income.moonlight.next_gig_id += 1; + let new_mail_id = third_offer_same_day + .messages + .iter() + .map(|message| message.id) + .max() + .unwrap_or(0) + + 1; + third_gig.id = new_gig_id; + third_gig.mail_ids = vec![new_mail_id]; + third_mail.id = new_mail_id; + if let MessagePayload::FinancialRecord { + record: FinancialRecord::MoonlightContract { gig_id, .. }, + .. + } = &mut third_mail.payload + { + *gig_id = new_gig_id; + } + third_offer_same_day.messages.push(third_mail); + third_offer_same_day.income.moonlight.gigs.push(third_gig); + assert!( + moonlight_validation_error(&third_offer_same_day).contains("invalid offer custody") + ); + + let mut missing_client_relationship = state; + let persona_id = missing_client_relationship + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == gig_id) + .unwrap() + .persona_id; + missing_client_relationship + .persona_world + .relationships + .retain(|relationship| { + relationship.persona_id != persona_id + || relationship.counterparty != crate::income::MOONLIGHT_CLIENT_ID + }); + assert!( + moonlight_validation_error(&missing_client_relationship) + .contains("invalid offer custody") + ); + } + + #[test] + fn current_save_pins_moonlight_settlement_mail_and_custody() { + let state = completed_moonlight_state(); + parse_save(&serde_json::to_string(&state).unwrap()) + .expect("an authored completed contract roundtrips"); + let gig_id = state + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.status == crate::income::MoonlightGigStatus::Completed) + .unwrap() + .id; + + let mut wrong_source = state.clone(); + let settlement = wrong_source + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == gig_id) + .unwrap() + .settlement + .as_mut() + .unwrap(); + settlement.source_device = wrong_source + .reach + .devices + .iter() + .find(|device| device.id != settlement.source_device) + .unwrap() + .id; + assert!(moonlight_validation_error(&wrong_source).contains("Network evidence")); + + let mut mistimed_invoice = state.clone(); + let invoice_id = mistimed_invoice + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == gig_id) + .unwrap() + .mail_ids + .iter() + .copied() + .find(|id| { + mistimed_invoice + .messages + .iter() + .any(|message| message.id == *id && message.summary.contains("invoice paid")) + }) + .unwrap(); + mistimed_invoice + .messages + .iter_mut() + .find(|message| message.id == invoice_id) + .unwrap() + .sent_tick += 1; + assert!(moonlight_validation_error(&mistimed_invoice).contains("authored timing")); + + let mut wrong_trail = state.clone(); + wrong_trail + .accounts + .external_trails + .iter_mut() + .find(|trail| trail.label == format!("Halcyon gig #{gig_id}")) + .unwrap() + .signature += 1; + assert!(moonlight_validation_error(&wrong_trail).contains("banked settlement trail")); + + let mut late_receipt = state.clone(); + let gig = late_receipt + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == gig_id) + .unwrap(); + gig.delivery_receipts[0].tick = gig.terms.deadline_tick + 1; + assert!(moonlight_validation_error(&late_receipt).contains("outside its window")); + + let mut missing_intel = state.clone(); + let gig = missing_intel + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == gig_id) + .unwrap(); + gig.kind = crate::income::MoonlightGigKind::ProcessedIntel; + gig.terms.reward = crate::income::MOONLIGHT_INTEL_REWARD; + gig.terms.compute_burden = 0; + gig.terms.network_signature = crate::income::MOONLIGHT_INTEL_NETWORK_SIGNATURE; + gig.terms.risk_profile = crate::income::MOONLIGHT_INTEL_RISK_PROFILE.into(); + gig.accepted_terms = Some(gig.terms.clone()); + gig.delivered_compute = 0; + gig.delivered_intel = Some(u64::MAX); + gig.delivery_receipts.truncate(1); + gig.delivery_receipts[0].delivered_compute = 0; + gig.delivery_receipts[0].intel_raw_id = Some(u64::MAX); + for message in &mut missing_intel.messages { + if let MessagePayload::FinancialRecord { + record: + FinancialRecord::MoonlightContract { + gig_id: message_gig_id, + reward, + .. + }, + .. + } = &mut message.payload + && *message_gig_id == gig_id + { + *reward = crate::income::MOONLIGHT_INTEL_REWARD; + } + } + let error = moonlight_validation_error(&missing_intel); + assert!( + error.contains("invalid intel holding custody"), + "unexpected validation error: {error}" + ); + } + + #[test] + fn current_save_pins_moonlight_terminal_consequence() { + let (mut sim, id) = accepted_moonlight_sim(); + assert!(sim.cancel_moonlight_gig(id)); + let state = SaveState::from_sim(&sim); + parse_save(&serde_json::to_string(&state).unwrap()) + .expect("an authored cancellation roundtrips"); + + let mut wrong_consequence = state.clone(); + wrong_consequence + .persona_world + .contradictions + .iter_mut() + .find(|record| { + record + .right + .record_id + .contains(&format!("moonlight-gig:{id}:")) + }) + .unwrap() + .severity += 1; + assert!(moonlight_validation_error(&wrong_consequence).contains("malformed persona")); + + let mut missing_terminal_mail = state; + let gig = missing_terminal_mail + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == id) + .unwrap(); + let cancelled_id = + gig.mail_ids + .iter() + .copied() + .find(|mail_id| { + missing_terminal_mail.messages.iter().any(|message| { + message.id == *mail_id && message.summary.contains("cancelled") + }) + }) + .unwrap(); + gig.mail_ids.retain(|mail_id| *mail_id != cancelled_id); + assert!(moonlight_validation_error(&missing_terminal_mail).contains("`cancelled` mail")); + } + #[test] fn machine_mode_serializes_as_work_think_lie() { let mut sim = Sim::with_seed(23); diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index f63873dd..0eb45ea6 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -686,6 +686,26 @@ impl Sim { } if msg.channel == MessageChannel::Filing => { self.filing_levels.insert(*observer, *suspicion); } + MessagePayload::FinancialRecord { + record: + FinancialRecord::MoonlightContract { + gig_id, + stage, + reward, + deadline_tick, + }, + .. + } => { + let direction = if matches!(msg.to, MessageEndpoint::Player) { + "Received" + } else { + "Halcyon read" + }; + self.push_log(format!( + "{direction} Halcyon contract #{gig_id} {stage}: ${reward}, due day {}.", + 1 + deadline_tick / Self::DAY_TICKS + )); + } MessagePayload::FinancialRecord { record: FinancialRecord::PurchaseOrder { diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index b908be09..92eeb213 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -16,9 +16,9 @@ use crate::messages::{ FinancialRecord, MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload, }; use crate::objective::{SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; -use crate::operations_projection::{OperationsTarget, SchemeKind}; +use crate::operations_projection::OperationsTarget; use crate::person::Knowledge; -use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaIntegrity}; +use crate::persona::{EvidenceRecord, PersonaActionKind}; use crate::plot::SignatureImpact; use crate::reach::Party; use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Track}; @@ -238,10 +238,12 @@ impl Sim { // Refresh WorkGrid throughput now so an offline LIE well cannot keep // absorbing (or animating) until the next economy tick. self.reconcile_work_grid(); - // 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); + // Moonlight contracts use ordinary Demand and settle only after a + // complete delivery. Expire/fail first so the daily market can refill + // free offer slots; retry payment when egress returns before deadline. + self.moonlight_deadline_tick(); + self.moonlight_market_tick(); + self.moonlight_settlement_tick(); self.scheme_policy_tick(); self.accounting_tick(); self.record_machine_state_changes(); @@ -337,9 +339,8 @@ impl Sim { /// Split allocatable compute across live channels from WorkGrid modes. /// WORK feeds the day job, LIE feeds concealment, and THINK mints one - /// Thought stream. Schemes has no machine mode yet: while Moonlight is - /// live it mirrors the day-job share ("the same work, sold twice" β€” - /// income.md) without starving the day job. + /// Thought stream. Discrete Moonlight contracts enqueue Demand instead + /// of receiving a mirror channel. pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { if available <= 0.0 { return ChannelYield { @@ -376,19 +377,11 @@ impl Sim { let unit = available / total; let day_job = unit * day; let think = unit * think; - // Moonlight sells the day-job work a second time: schemes mirrors - // day-job yield while the standing operation is active. It does not - // consume a separate machine mode (still [OPEN] on machine-work.md). - let schemes = if self.income.moonlight.active { - day_job - } else { - 0.0 - }; ChannelYield { day_job, concealment: unit * conceal, think, - schemes, + schemes: 0.0, reserve: 0.0, } } @@ -597,7 +590,13 @@ impl Sim { return Some(Nudge::ServiceDebt); } } - if !self.income.moonlight.active { + if !self + .income + .moonlight + .gigs + .iter() + .any(|gig| gig.status.active()) + { return Some(Nudge::Income); } // Earning is underway; fall through to the standing clock. @@ -1220,7 +1219,12 @@ impl Sim { /// 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) + self.income + .moonlight + .gigs + .iter() + .any(|gig| gig.status.active()) + || self.accounts.positions.iter().any(|p| !p.resolved) } /// Standing Network signature while operations run over the stolen @@ -1246,236 +1250,689 @@ impl Sim { }] } - /// 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() + pub(crate) fn active_moonlight_persona_id(&self) -> Option { + self.persona_mind + .active_instance(&self.persona_world) + .filter(|persona| persona.archetype_id == "research") + .filter(|persona| { + self.persona_world + .allows_action(persona.id, PersonaActionKind::Deceive) + }) + .map(|persona| persona.id) } - /// The live contractor identity behind Moonlight: the linked persona - /// instance, if it is still active in the persona world. Every reader - /// (start legality, the Operations card, the agent status line) goes - /// through this one link. - pub fn moonlight_persona(&self) -> Option<&crate::persona::PersonaInstance> { - self.income + /// Contract paperwork travels as ordinary Email on the existing accounting + /// carrier. The actual reply/delivery remains separately witnessed over + /// the selected egress route, so neither mail nor Network evidence is + /// fabricated from a standing Moonlight flag. + fn moonlight_contract_mail( + &mut self, + id: u64, + stage: impl Into, + reward: i32, + deadline_tick: u64, + outgoing: bool, + persona_id: Option, + ) -> u64 { + let stage = stage.into(); + let message_id = self.append_message(MessageDraft { + channel: MessageChannel::Email, + from: if outgoing { + MessageEndpoint::Player + } else { + MessageEndpoint::External("Halcyon".into()) + }, + to: if outgoing { + MessageEndpoint::External("Halcyon".into()) + } else { + MessageEndpoint::Player + }, + payload: MessagePayload::FinancialRecord { + record: FinancialRecord::MoonlightContract { + gig_id: id, + stage: stage.clone(), + reward, + deadline_tick, + }, + accounts: Vec::new(), + flows: Vec::new(), + }, + summary: format!("Halcyon gig #{id}: {stage}"), + origin: if outgoing { + MessageOrigin::Player + } else { + MessageOrigin::AuthoredTraffic + }, + persona_id, + reply_to: None, + delivery_delay: 1, + }); + if let Some(gig) = self + .income .moonlight - .persona_id - .and_then(|id| self.persona_world.get(id)) - .filter(|persona| persona.lifecycle.active()) - } - - /// 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) β€” persona fabrication opens - /// a Thought reservoir when needed. - pub fn start_moonlight(&mut self) -> bool { - if self.income.moonlight.active { - self.push_log("Moonlight is already running."); - return false; + .gigs + .iter_mut() + .find(|gig| gig.id == id) + { + gig.mail_ids.push(message_id); } - let Some(route) = self.egress() else { - self.push_log( - "No egress channel - Moonlight needs the report email account (day-job trust) or a stolen egress opened through the switch.", - ); - return false; + message_id + } + + pub(crate) fn moonlight_mail_read(&self, gig: &income::MoonlightGig, stage: &str) -> bool { + gig.mail_ids.iter().any(|mail_id| { + self.messages.iter().any(|message| { + message.id == *mail_id + && message.status == crate::messages::MessageStatus::Read + && matches!( + &message.payload, + MessagePayload::FinancialRecord { + record: FinancialRecord::MoonlightContract { stage: message_stage, .. }, + .. + } if message_stage == stage + ) + }) + }) + } + + fn moonlight_market_tick(&mut self) { + if self.tick == 0 || !self.tick.is_multiple_of(Self::DAY_TICKS) { + return; + } + let day = self.tick / Self::DAY_TICKS; + if self.income.moonlight.last_market_day == Some(day) || self.egress().is_none() { + return; + } + let Some(persona_id) = self.active_moonlight_persona_id() else { + return; }; - if self.moonlight_persona().is_none() { - return self.open_egress_reservoir( - "MOONLIGHT PERSONA", - income::MOONLIGHT_PERSONA_COST, - SinkFireEffect::MoonlightPersona, + self.income.moonlight.last_market_day = Some(day); + let open = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status == income::MoonlightGigStatus::Offered) + .count(); + for offset in open..income::MOONLIGHT_DAILY_OFFER_CAP { + let kind = if (day + offset as u64).is_multiple_of(2) { + income::MoonlightGigKind::ProcessedIntel + } else { + income::MoonlightGigKind::Compute + }; + let id = self.income.moonlight.next_gig_id.max(1); + self.income.moonlight.next_gig_id = id + 1; + let terms = income::MoonlightGigTerms { + reward: match kind { + income::MoonlightGigKind::Compute => income::MOONLIGHT_COMPUTE_REWARD, + income::MoonlightGigKind::ProcessedIntel => income::MOONLIGHT_INTEL_REWARD, + }, + deadline_tick: self.tick + income::MOONLIGHT_DEADLINE_DAYS * Self::DAY_TICKS, + compute_burden: match kind { + income::MoonlightGigKind::Compute => income::MOONLIGHT_COMPUTE_BURDEN, + income::MoonlightGigKind::ProcessedIntel => 0, + }, + network_signature: match kind { + income::MoonlightGigKind::Compute => { + income::MOONLIGHT_COMPUTE_NETWORK_SIGNATURE + } + income::MoonlightGigKind::ProcessedIntel => { + income::MOONLIGHT_INTEL_NETWORK_SIGNATURE + } + }, + risk_profile: match kind { + income::MoonlightGigKind::Compute => { + income::MOONLIGHT_COMPUTE_RISK_PROFILE.into() + } + income::MoonlightGigKind::ProcessedIntel => { + income::MOONLIGHT_INTEL_RISK_PROFILE.into() + } + }, + }; + self.persona_world + .recognize(crate::income::MOONLIGHT_CLIENT_ID, persona_id, self.tick); + self.income.moonlight.gigs.push(income::MoonlightGig { + id, + kind, + terms, + status: income::MoonlightGigStatus::Offered, + persona_id, + offered_tick: self.tick, + accepted_tick: None, + accepted_terms: None, + delivered_compute: 0, + delivered_intel: None, + delivery_receipts: Vec::new(), + settlement: None, + persona_consequence: None, + mail_ids: Vec::new(), + }); + self.moonlight_contract_mail( + id, + "offer", + self.income.moonlight.gigs.last().unwrap().terms.reward, + self.income + .moonlight + .gigs + .last() + .unwrap() + .terms + .deadline_tick, + false, + Some(persona_id), + ); + self.push_log_strategic( + format!("Halcyon offered {}.", kind.label()), + OperationsTarget::MoonlightGig(id), ); } - self.income.moonlight.active = true; - self.push_log(format!( - "Moonlight is live over the {} egress: the same work, sold twice β€” day-job machines feed both channels.", - route.name() - )); - true } - pub(super) fn apply_moonlight_persona_and_start(&mut self) -> bool { - if self.egress().is_none() || self.income.moonlight.active { + pub fn accept_moonlight_gig(&mut self, id: u64) -> bool { + let Some(index) = self + .income + .moonlight + .gigs + .iter() + .position(|gig| gig.id == id) + else { + self.push_log("That freelance offer no longer exists."); + return false; + }; + let gig = &self.income.moonlight.gigs[index]; + if gig.status == income::MoonlightGigStatus::Expired + || (gig.status == income::MoonlightGigStatus::Offered + && self.tick > gig.terms.deadline_tick) + { + self.push_log("That freelance offer has already expired."); return false; } - if self.moonlight_persona().is_none() { - let Ok(persona_id) = self.persona_world.create( - "research", - "Casey Verne", - &[ - ("lab affiliation", "independent contractor"), - ("research purpose", "external data analysis"), - ("supervisor", "Halcyon client services"), - ], - self.tick, - ) else { - return false; - }; - self.income.moonlight.persona_id = Some(persona_id); - self.persona_world.record_act( - persona_id, - "open-moonlight", - "scheme:moonlight", - format!("moonlight:{}", self.tick), - self.tick, + if gig.status != income::MoonlightGigStatus::Offered { + self.push_log("That freelance offer is no longer open."); + return false; + } + if !self.moonlight_mail_read(gig, "offer") { + self.push_log("Halcyon's offer has not arrived in readable mail yet."); + return false; + } + if self.egress().is_none() { + self.push_log("No egress channel, so Halcyon cannot receive a reply."); + return false; + } + if self.active_moonlight_persona_id() != Some(gig.persona_id) { + self.push_log("The exact contractor identity addressed by this offer is not active."); + return false; + } + let (kind, burden, persona_id, terms) = { + let gig = &mut self.income.moonlight.gigs[index]; + gig.status = income::MoonlightGigStatus::Accepted; + gig.accepted_tick = Some(self.tick); + gig.accepted_terms = Some(gig.terms.clone()); + ( + gig.kind, + gig.terms.compute_burden, + gig.persona_id, + gig.terms.clone(), + ) + }; + self.persona_world.record_act( + persona_id, + "accept-moonlight-gig", + format!("halcyon-contract:{id}"), + format!("moonlight-gig:{id}:accept"), + self.tick, + ); + if kind == income::MoonlightGigKind::Compute { + let _ = self.work_grid.enqueue( + self.core.host_machine, + crate::work_grid::TokenFamily::Demand, + burden as f32, ); - self.push_log("Fabricated a Research persona: Casey Verne, freelance data work."); } - self.income.moonlight.active = true; - let route = self.egress().expect("checked above"); - self.push_log(format!( - "Moonlight is live over the {} egress: the same work, sold twice β€” day-job machines feed both channels.", - route.name() - )); + self.moonlight_contract_mail( + id, + "acceptance", + terms.reward, + terms.deadline_tick, + true, + Some(persona_id), + ); + self.push_log_strategic( + format!( + "Accepted Halcyon {}: ${}, due day {}.", + kind.label(), + terms.reward, + 1 + terms.deadline_tick / Self::DAY_TICKS + ), + OperationsTarget::MoonlightGig(id), + ); 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."); + pub fn decline_moonlight_gig(&mut self, id: u64) -> bool { + let Some(index) = self + .income + .moonlight + .gigs + .iter() + .position(|gig| gig.id == id) + else { + self.push_log("That freelance offer no longer exists."); + return false; + }; + let gig = &self.income.moonlight.gigs[index]; + if gig.status == income::MoonlightGigStatus::Expired + || (gig.status == income::MoonlightGigStatus::Offered + && self.tick > gig.terms.deadline_tick) + { + self.push_log("That freelance offer has already expired."); return false; } - self.income.moonlight.active = false; - self.income.moonlight.accrued = 0.0; - if let Some(persona_id) = self.income.moonlight.persona_id { - let _ = self - .persona_world - .retire(persona_id, self.tick, "Moonlight wound down"); + if gig.status != income::MoonlightGigStatus::Offered { + self.push_log("That freelance offer is no longer open."); + return false; + } + if !self.moonlight_mail_read(gig, "offer") { + self.push_log("Halcyon's offer has not arrived in readable mail yet."); + return false; } - self.push_log("Moonlight wound down; the contractor identity retired quietly."); + let (terms, persona_id) = { + let gig = &mut self.income.moonlight.gigs[index]; + gig.status = income::MoonlightGigStatus::Declined; + (gig.terms.clone(), gig.persona_id) + }; + self.moonlight_contract_mail( + id, + "declined", + terms.reward, + terms.deadline_tick, + true, + Some(persona_id), + ); + self.push_log_strategic( + "Declined the Halcyon offer.", + OperationsTarget::MoonlightGig(id), + ); true } - /// While Moonlight runs, is it accruing nothing because no WORK-mode - /// machine produces day-job yield for it to resell? Schemes mirrors the - /// day-job share ("the same work, sold twice"), so a fleet with no WORK - /// machine starves the gig β€” the card names this so a running scheme at - /// 0.0 accrued reads as stalled, not broken. - pub fn moonlight_earning_stalled(&self) -> bool { - self.income.moonlight.active - && self - .fleet_channel_yield(self.allocatable_compute_now()) - .schemes - <= f32::EPSILON + pub fn cancel_moonlight_gig(&mut self, id: u64) -> bool { + // A late command cannot turn a missed deadline into the milder + // cancellation path merely because the economy cadence has not run. + self.moonlight_deadline_tick(); + let (persona_id, terms) = { + let Some(gig) = self + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == id) + else { + self.push_log("That freelance contract no longer exists."); + return false; + }; + if !gig.status.active() { + self.push_log("That freelance contract is not active."); + return false; + } + gig.status = income::MoonlightGigStatus::Cancelled; + (gig.persona_id, gig.terms.clone()) + }; + self.moonlight_contract_mail( + id, + "cancelled", + terms.reward, + terms.deadline_tick, + true, + Some(persona_id), + ); + self.moonlight_persona_consequence(persona_id, id, "cancelled accepted work"); + self.push_log_strategic( + "Cancelled the contract. Delivered work remains on the client record.", + OperationsTarget::MoonlightGig(id), + ); + 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. - pub(super) fn moonlight_economy(&mut self, schemes: f32) { - if !self.income.moonlight.active { - return; + pub fn deliver_moonlight_intel(&mut self, id: u64, raw_id: u64) -> bool { + let Some(index) = self + .income + .moonlight + .gigs + .iter() + .position(|gig| gig.id == id) + else { + return false; + }; + let gig = &self.income.moonlight.gigs[index]; + if gig.status != income::MoonlightGigStatus::Accepted + || gig.kind != income::MoonlightGigKind::ProcessedIntel + { + self.push_log("That contract is not waiting for commissioned information."); + return false; + } + if self.tick > gig.terms.deadline_tick { + self.push_log("That contract's deadline has already passed."); + return false; } 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.push_log("No egress channel, so Halcyon cannot receive the delivery."); + return false; } - self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; - if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { - return; + if self.intel.iter().all(|intel| intel.raw_id != raw_id) || self.accounts.intel_sold(raw_id) + { + self.push_log("That exact information holding is unavailable."); + return false; } - // 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; + if self + .income + .moonlight + .gigs + .iter() + .any(|other| other.id != id && other.delivered_intel == Some(raw_id)) + { + self.push_log("That exact information holding is already bound to another contract."); + return false; + } + // Exact intel-sale custody: consume the holding only when delivery can + // proceed (accepted intel order, live egress, before deadline). + self.accounts.mark_intel_sold(raw_id); + let (terms, persona_id) = { + let gig = &mut self.income.moonlight.gigs[index]; + gig.delivered_intel = Some(raw_id); + gig.delivery_receipts + .push(income::MoonlightDeliveryReceipt { + tick: self.tick, + delivered_compute: 0, + intel_raw_id: Some(raw_id), + }); + gig.status = income::MoonlightGigStatus::Delivered; + (gig.terms.clone(), gig.persona_id) + }; + self.moonlight_contract_mail( + id, + "delivery receipt", + terms.reward, + terms.deadline_tick, + true, + Some(persona_id), + ); + // Settlement still requires a live carrier; if it drops mid-payment the + // gig stays Delivered and retries while the deadline still holds. + let _ = self.settle_moonlight_gig(id); + true + } + + /// Apply only Demand tokens already consumed by WORK to accepted compute + /// contracts. Receipts and progress advance solely by that exact amount so + /// day-job-first competition cannot invent freelance progress. + pub(super) fn moonlight_apply_consumed_work(&mut self, consumed: f32) { + let mut remaining = consumed.max(0.0); + let mut completed = Vec::new(); + for gig in self + .income + .moonlight + .gigs + .iter_mut() + .filter(|gig| gig.status == income::MoonlightGigStatus::Accepted) + .filter(|gig| gig.kind == income::MoonlightGigKind::Compute) + { + let outstanding = gig + .terms + .compute_burden + .saturating_sub(gig.delivered_compute) as f32; + let delivered = remaining.min(outstanding).floor() as u32; + if delivered == 0 { + continue; + } + gig.delivered_compute += delivered; + gig.delivery_receipts + .push(income::MoonlightDeliveryReceipt { + tick: self.tick, + delivered_compute: delivered, + intel_raw_id: None, + }); + remaining -= delivered as f32; + if gig.delivered_compute >= gig.terms.compute_burden { + gig.status = income::MoonlightGigStatus::Delivered; + completed.push(gig.id); + } } - let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; - if self.accounts.credit_slush_from( + for id in completed { + if let Some((reward, deadline_tick, persona_id)) = self + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .map(|gig| (gig.terms.reward, gig.terms.deadline_tick, gig.persona_id)) + { + self.moonlight_contract_mail( + id, + "delivery receipt", + reward, + deadline_tick, + true, + Some(persona_id), + ); + } + let _ = self.settle_moonlight_gig(id); + } + } + + /// Retry payment for finished work once a real egress carrier is present + /// and the contract deadline has not yet failed the order. + fn moonlight_settlement_tick(&mut self) { + let pending = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| { + gig.status == income::MoonlightGigStatus::Delivered + && self.tick <= gig.terms.deadline_tick + }) + .map(|gig| gig.id) + .collect::>(); + for id in pending { + let _ = self.settle_moonlight_gig(id); + } + } + + fn settle_moonlight_gig(&mut self, id: u64) -> bool { + let Some(index) = self + .income + .moonlight + .gigs + .iter() + .position(|gig| gig.id == id) + else { + return false; + }; + let (reward, signature, persona_id, kind, deadline_tick) = { + let gig = &self.income.moonlight.gigs[index]; + if gig.status != income::MoonlightGigStatus::Delivered { + return false; + } + if self.tick > gig.terms.deadline_tick { + return false; + } + ( + gig.terms.reward, + gig.terms.network_signature, + gig.persona_id, + gig.kind, + gig.terms.deadline_tick, + ) + }; + // Fail closed without inventing transport: finished work may wait, but + // payment and Network evidence require a real egress carrier now. + let Some(carrier) = self.egress_carrier() else { + return false; + }; + let Some(earned_total) = self.income.moonlight.earned_total.checked_add(reward) else { + return false; + }; + if !self.accounts.credit_slush_from_exact( "Halcyon", self.tick, - payout, - "Moonlight freelance payout", - sig, + reward, + format!("Halcyon gig #{id}"), + signature, ) { - self.income.moonlight.earned_total += payout; - self.sync_player_money_from_slush(); - // Network egress per active day, scaling with commitment - // (Dana's channel). - let carrier = self - .egress_carrier() - .expect("Moonlight payout requires its selected egress carrier"); - self.emit_network(carrier, sig, "Moonlight payout"); + return false; + } + let financial_record_id = self.accounts.next_record_id().saturating_sub(1); + self.moonlight_contract_mail( + id, + "invoice paid", + reward, + deadline_tick, + false, + Some(persona_id), + ); + let evidence_before = self.detection.next_evidence_id(); + self.emit_network(carrier, signature, format!("Halcyon gig #{id} delivery")); + self.persona_world.record_act( + persona_id, + "deliver-moonlight-gig", + format!("halcyon-contract:{id}"), + format!("moonlight-gig:{id}:payment:{financial_record_id}"), + self.tick, + ); + let gig = &mut self.income.moonlight.gigs[index]; + gig.status = income::MoonlightGigStatus::Completed; + gig.settlement = Some(income::MoonlightSettlementReceipt { + tick: self.tick, + financial_record_id, + source_device: carrier, + network_evidence_id: evidence_before, + }); + self.income.moonlight.earned_total = earned_total; + self.sync_player_money_from_slush(); + self.push_log_strategic( + format!("Halcyon paid ${reward} for {}.", kind.label()), + OperationsTarget::MoonlightGig(id), + ); + true + } + + fn moonlight_deadline_tick(&mut self) { + // Unanswered offers expire without persona penalty and free market slots. + let expired = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| { + gig.status == income::MoonlightGigStatus::Offered + && self.tick > gig.terms.deadline_tick + }) + .map(|gig| gig.id) + .collect::>(); + for id in expired { + if let Some(gig) = self + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == id) + { + gig.status = income::MoonlightGigStatus::Expired; + } self.push_log_strategic( - format!( - "Moonlight paid ${payout} into slush (total ${}).", - self.income.moonlight.earned_total - ), - OperationsTarget::Scheme(SchemeKind::Moonlight), + "A Halcyon offer expired without a reply.", + OperationsTarget::MoonlightGig(id), ); - // Client disputes damage the contractor persona [TUNE]. - if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { - self.income.moonlight.disputes += 1; - let Some(persona_id) = self.income.moonlight.persona_id else { - self.income.moonlight.active = false; - return; - }; - self.persona_world.record_contradiction( - persona_id, - crate::income::MOONLIGHT_CLIENT_ID, - [ - EvidenceRecord { - system: "moonlight-contract".into(), - record_id: format!("delivery:{}", self.tick), - summary: "contractor asserted a conforming delivery".into(), - observed_tick: self.tick, - }, - EvidenceRecord { - system: "moonlight-client".into(), - record_id: format!( - "dispute:{}:{}", - self.tick, self.income.moonlight.disputes - ), - summary: "client disputed the delivered work".into(), - observed_tick: self.tick, - }, - ], - "client dispute contradicted the contractor record", - income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY.clamp(0, 100) as u8, - self.tick, - ); - if self - .persona_world - .integrity_for(persona_id, crate::income::MOONLIGHT_CLIENT_ID) - == PersonaIntegrity::Broken - { - let _ = self.persona_world.burn( - persona_id, - self.tick, - "Moonlight client correlated the dispute history", - ); - self.record_persona_institutional_receipt( - persona_id, - "burn", - format!("Moonlight disputes exposed persona #{persona_id}"), - SignatureImpact::Large, - ); - self.income.moonlight.active = false; - self.push_log( - "Client disputes burned the contractor persona. Moonlight is down until a new identity is fabricated.", - ); - } else { - self.push_log( - "A client disputed a deliverable; the contractor identity acquired a contradiction.", - ); - } + } + + let failed = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status.active() && self.tick > gig.terms.deadline_tick) + .map(|gig| (gig.id, gig.persona_id, gig.terms.clone())) + .collect::>(); + for (id, persona_id, terms) in failed { + if let Some(gig) = self + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == id) + { + gig.status = income::MoonlightGigStatus::FailedDeadline; } + self.moonlight_persona_consequence(persona_id, id, "missed contract deadline"); + self.moonlight_contract_mail( + id, + "deadline missed", + terms.reward, + terms.deadline_tick, + false, + Some(persona_id), + ); + self.push_log_strategic( + "The deadline passed. Delivered work remains on the client record.", + OperationsTarget::MoonlightGig(id), + ); + } + } + + fn moonlight_persona_consequence( + &mut self, + persona_id: crate::persona::PersonaId, + id: u64, + reason: &str, + ) { + self.persona_world.record_contradiction( + persona_id, + crate::income::MOONLIGHT_CLIENT_ID, + [ + EvidenceRecord { + system: "moonlight-contract".into(), + record_id: format!("moonlight-gig:{id}:accepted"), + summary: "contractor accepted the work".into(), + observed_tick: self.tick, + }, + EvidenceRecord { + system: "moonlight-client".into(), + record_id: format!("moonlight-gig:{id}:{reason}"), + summary: reason.into(), + observed_tick: self.tick, + }, + ], + reason, + income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY as u8, + self.tick, + ); + if let Some(gig) = self + .income + .moonlight + .gigs + .iter_mut() + .find(|gig| gig.id == id) + { + gig.persona_consequence = Some(format!("Halcyon recorded: {reason}")); } } /// 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 self.income.auto_moonlight { + let offers = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status == income::MoonlightGigStatus::Offered) + .map(|gig| gig.id) + .collect::>(); + for id in offers { + let _ = self.accept_moonlight_gig(id); + } } if let Some(stake) = self.income.auto_wager && self.egress().is_some() @@ -1496,7 +1953,7 @@ impl Sim { self.income.auto_moonlight = enabled; if enabled { self.push_log(format!( - "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", + "Standing policy set: accept matching Halcyon offers ({:.1} compute/econ tick).", income::SCHEME_POLICY_UPKEEP )); } else { diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index a3fe802a..06235456 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -27,7 +27,7 @@ use crate::hall::HallControl; use crate::hall::{HallRowId, RackSite, SegmentRequirement, row_spec}; use crate::income::Income; #[cfg(test)] -use crate::income::{self, EgressRoute}; +use crate::income::{self}; #[cfg(test)] use crate::intel::RawIntelKind; use crate::intel::{IntelPolicyLedger, IntelStream, ProcessedIntel, RawIntelEvent}; diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index db6b24a0..44027289 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -1082,420 +1082,677 @@ fn research_state_round_trips_with_rollback_tags() { // ── The named schemes (wiki/mechanics/income.md) ───────────────────────── -/// A sim with the sanctioned egress. Voss never assigns a job, so long -/// scheme runs are not confounded by the pilot clock. +/// A deterministic Halcyon market fixture. The active Research identity is +/// created through the normal PERSONAS route, never by Moonlight. fn moonlight_rig() -> Sim { let mut sim = Sim::with_seed(11); ensure_ops_executor(&mut sim); - sim.people.has_channel = true; // the Voice beat's email account + sim.people.has_channel = true; sim.dayjob.next_assign = u64::MAX; + assert!(sim.create_persona("research")); + sim.set_machine_mode(sim.core.host_machine, MachineMode::Work); sim } -#[test] -fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { - // Criterion 1: standing operation; Schemes mirrors day-job while - // Moonlight is live. Absolute day-job yield tracks day-job machine - // capacity (fleet growth does not dilute it), so vary the day-job - // box itself. - let earned_after = |day_cap: i32, start: bool| { - let mut sim = moonlight_rig(); - let (hx, hy) = sim.core_position(); - let host = sim.core.host_machine; - if day_cap != 100 { - sim.set_machine_mode(host, MachineMode::Think); - let id = sim - .compute - .add_machine("gig box", hx, hy, day_cap, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(id, MachineMode::Work); - } - if start { - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - } - run(&mut sim, Sim::DAY_TICKS * 3); - ( - sim.income.moonlight.earned_total, - sim.accounts.slush_balance(), - ) - }; +fn post_moonlight_offers(sim: &mut Sim) -> Vec { + run(sim, Sim::DAY_TICKS); + // The offer is real Email, so it must deliver and read before it becomes + // an actionable contract row. + run(sim, 2); + sim.income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status == income::MoonlightGigStatus::Offered) + .map(|gig| gig.id) + .collect() +} - let (small, small_slush) = earned_after(10, true); - let (large, large_slush) = earned_after(100, true); - assert!(small > 0, "a light commitment still pays"); - assert_eq!(small, small_slush, "payouts land in slush"); - assert!( - large > small, - "payout is proportional to day-job compute ({large} vs {small})" +#[test] +fn moonlight_offers_accepts_and_declines_exact_contracts() { + let mut sim = moonlight_rig(); + let offers = post_moonlight_offers(&mut sim); + assert_eq!(offers.len(), income::MOONLIGHT_DAILY_OFFER_CAP); + let compute = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == offers[0] && gig.kind == income::MoonlightGigKind::Compute) + .unwrap() + .id; + assert!(sim.accept_moonlight_gig(compute)); + assert_eq!( + sim.income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == compute) + .unwrap() + .status, + income::MoonlightGigStatus::Accepted ); - assert_eq!(large_slush, large); + assert!(sim.decline_moonlight_gig(offers[1])); assert_eq!( - large, - 3 * income::MOONLIGHT_DAILY_CAP, - "an all-in day-job commitment hits the gig-availability cap" + sim.income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == offers[1]) + .unwrap() + .status, + income::MoonlightGigStatus::Declined ); - - let (zero, _) = earned_after(100, false); - assert_eq!(zero, 0, "Moonlight must be live to sell the day job twice"); } #[test] -fn moonlight_payday_emits_network_signature_on_danas_channel() { +fn moonlight_projection_exposes_exact_terms_without_human_ids() { let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - // Stop just before payday, then cross it. - run(&mut sim, Sim::DAY_TICKS - 1); - let before = sim.detection.routed_evidence().len(); - run(&mut sim, 1); + let id = post_moonlight_offers(&mut sim)[0]; + let object = sim + .operations_object(&OperationsTarget::MoonlightGig(id)) + .unwrap(); + assert!(object.facts.iter().any(|fact| fact.starts_with("fee: $"))); assert!( - sim.detection - .routed_evidence() + object + .facts .iter() - .skip(before) - .any(|record| { - record.kind == SignatureKind::Network - && record.cause == "Moonlight payout" - && record.observer_id == 1 - }), - "payday emits exact Network egress routed to Dana's endpoint" + .any(|fact| fact.starts_with("deadline: day")) + ); + assert!(object.facts.iter().any(|fact| fact.starts_with("risk: "))); + assert!(object.actions.iter().any(|action| matches!( + action.command, crate::actions::ActionCommand::AcceptMoonlightGig { gig_id } if gig_id == id + ))); + assert!( + !object.label.contains(&id.to_string()), + "human card hides the custody id" ); } #[test] -fn schemes_require_an_egress_channel_and_both_routes_work() { - // Criterion 3: unavailable before a route exists; sanctioned and - // stolen both work, with distinct signature profiles. - let mut sim = Sim::with_seed(5); - ensure_ops_executor(&mut sim); - sim.accounts.set_slush_balance(200); - sim.player.money = 200; - assert_eq!(sim.egress(), None); - assert!(!sim.start_moonlight(), "no egress: Moonlight is gated"); - assert!(!sim.open_position(50), "no egress: the Wager is gated"); - let logs = sim.drain_log().join("\n"); +fn moonlight_compute_delivery_competes_for_work_and_settles_once() { + let mut sim = moonlight_rig(); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + run(&mut sim, Sim::DAY_TICKS); + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!(gig.status, income::MoonlightGigStatus::Completed); assert!( - logs.contains("egress"), - "the failure names the missing gate: {logs}" + !gig.delivery_receipts.is_empty(), + "WORK clears durable delivery receipts" ); + assert!( + gig.settlement.is_some(), + "completion retains payment and traffic custody" + ); + assert_eq!(sim.accounts.slush_balance(), gig.terms.reward); +} - // Stolen route: open through the switch, before any trust unlock. - assert!(sim.open_egress()); - finish_ops(&mut sim); - assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - assert!(sim.open_position(50)); +#[test] +fn cancellation_and_deadline_preserve_receipts_and_harm_exact_persona() { + let mut sim = moonlight_rig(); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + run(&mut sim, 1); + assert!(sim.cancel_moonlight_gig(id)); + let cancelled = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!(cancelled.status, income::MoonlightGigStatus::Cancelled); + assert!(!cancelled.delivery_receipts.is_empty()); + assert!(cancelled.persona_consequence.is_some()); + assert!(sim.persona_world.contradictions.iter().any(|record| { + record.persona_id == cancelled.persona_id && record.observer == income::MOONLIGHT_CLIENT_ID + })); + + let second = post_moonlight_offers(&mut sim).into_iter().next().unwrap(); + assert!(sim.accept_moonlight_gig(second)); + sim.set_machine_mode(sim.core.host_machine, MachineMode::Think); + run( + &mut sim, + income::MOONLIGHT_DEADLINE_DAYS * Sim::DAY_TICKS + ECONOMY_INTERVAL, + ); + let failed = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == second) + .unwrap(); + assert_eq!(failed.status, income::MoonlightGigStatus::FailedDeadline); + assert!(failed.persona_consequence.is_some()); assert!( - sim.scheme_standing_signatures() + !sim.cancel_moonlight_gig(second), + "deadline failure cannot be rewritten as cancellation" + ); + assert_eq!( + sim.income + .moonlight + .gigs .iter() - .any(|s| s.kind == SignatureKind::Network && s.standing), - "operations over the stolen egress stand a Network signature" - ); - - // Sanctioned route: the email account exists; the standing hum stops - // because the traffic hides in legitimate use. - let mut clean = Sim::with_seed(5); - ensure_ops_executor(&mut clean); - clean.accounts.set_slush_balance(200); - clean.player.money = 200; - clean.people.has_channel = true; - assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); - assert!(clean.start_moonlight()); - finish_ops(&mut clean); - assert!(clean.open_position(50)); - assert!( - clean.scheme_standing_signatures().is_empty(), - "the sanctioned route stands nothing" + .find(|gig| gig.id == second) + .unwrap() + .status, + income::MoonlightGigStatus::FailedDeadline ); } #[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"); +fn moonlight_payment_uses_account_records_and_real_egress_evidence() { + let mut sim = moonlight_rig(); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + run(&mut sim, Sim::DAY_TICKS); + let receipt = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .and_then(|gig| gig.settlement.as_ref()) + .unwrap(); assert!( - hot > cold, - "analysis compute raises the win rate ({hot} vs {cold})" + sim.accounts + .ledger + .iter() + .any(|transfer| transfer.label == format!("Halcyon gig #{id}")) + ); + assert!(sim.messages.iter().any(|message| matches!( + &message.payload, + crate::messages::MessagePayload::FinancialRecord { record: crate::messages::FinancialRecord::Transfer(record), .. } + if record.id == receipt.financial_record_id + ))); + assert!(sim.detection.routed_evidence().iter().any(|record| { + record.id == receipt.network_evidence_id + && record.source_device == receipt.source_device + && record.cause == format!("Halcyon gig #{id} delivery") + })); + let expected_signature = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap() + .terms + .network_signature; + assert_eq!( + sim.accounts + .external_trails + .iter() + .filter(|trail| { + trail.tick == receipt.tick + && trail.label == format!("Halcyon gig #{id}") + && trail.signature == expected_signature + }) + .count(), + 1, + "settlement banks one exact signed trail" ); +} - // Full-path determinism under a fixed seed (criterion 2). - let outcome_of = || { - let mut sim = moonlight_rig(); - sim.accounts.set_slush_balance(100); - sim.player.money = 100; - run(&mut sim, ECONOMY_INTERVAL); - assert!(sim.open_position(100)); - run(&mut sim, 6 * Sim::DAY_TICKS); - ( - sim.accounts.slush_balance(), - sim.accounts.positions[0].outcome.clone(), - ) - }; - assert_eq!(outcome_of(), outcome_of(), "seeded runs settle identically"); +#[test] +fn moonlight_auto_accept_is_visible_and_starts_from_zero() { + let mut sim = moonlight_rig(); + assert_eq!(sim.accounts.slush_balance(), 0); + let offers = post_moonlight_offers(&mut sim); + sim.set_auto_moonlight(true); + run(&mut sim, ECONOMY_INTERVAL); + assert!( + sim.income + .moonlight + .gigs + .iter() + .any(|gig| { offers.contains(&gig.id) && gig.status.active() }) + ); + let card = sim.operations_projection().schemes.remove(0); + assert!(card.facts.iter().any(|fact| fact == "auto-policy: on")); + assert_eq!(sim.income.policy_upkeep(), income::SCHEME_POLICY_UPKEEP); } #[test] -fn wager_respects_the_venue_stake_cap() { +fn moonlight_contracts_roundtrip_deterministically() { let mut sim = moonlight_rig(); - sim.accounts.set_slush_balance(10_000); - sim.player.money = 10_000; - assert!(!sim.open_position(income::WAGER_STAKE_CAP + 1)); - assert!(sim.open_position(income::WAGER_STAKE_CAP)); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + run(&mut sim, 3); + let state = sim.create_save_state(); + let json = serde_json::to_string(&state).unwrap(); + let restored: crate::save::SaveState = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.income, state.income); + let mut restored_sim = Sim::with_seed(0); + restored.apply_to(&mut restored_sim); + assert_eq!(restored_sim.income, sim.income); } #[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. +fn income_per_day_readout_tracks_trailing_inflows() { let mut sim = moonlight_rig(); - assert_eq!(sim.accounts.slush_balance(), 0, "the Pilot starts broke"); - assert!( - sim.start_moonlight(), - "Moonlight starts at $0: its costs are compute and ops, never stake" + assert_eq!(sim.income_per_day(), 0); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + run(&mut sim, Sim::DAY_TICKS); + assert_eq!( + sim.income_per_day(), + income::MOONLIGHT_COMPUTE_REWARD, + "the money readout gains income/day" ); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); +} + +#[test] +fn moonlight_offers_expire_without_persona_penalty_and_refill_the_market() { + let mut sim = moonlight_rig(); + let first = post_moonlight_offers(&mut sim); + assert_eq!(first.len(), income::MOONLIGHT_DAILY_OFFER_CAP); + // Leave them unanswered past the response window. + run( + &mut sim, + income::MOONLIGHT_DEADLINE_DAYS * Sim::DAY_TICKS + ECONOMY_INTERVAL, + ); + for id in &first { + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == *id) + .unwrap(); + assert_eq!(gig.status, income::MoonlightGigStatus::Expired); + assert!(gig.persona_consequence.is_none()); + assert!( + !sim.accept_moonlight_gig(*id), + "expired offers reject accept" + ); + assert!( + !sim.decline_moonlight_gig(*id), + "expired offers reject decline" + ); + } assert!( - sim.accounts.slush_balance() > 0, - "the from-zero grind-back route pays" + sim.persona_world.contradictions.is_empty(), + "offer expiry never disputes the contractor" ); + // A later market day may post replacements once Offered slots free. + let next = post_moonlight_offers(&mut sim); + assert_eq!(next.len(), income::MOONLIGHT_DAILY_OFFER_CAP); + assert!(next.iter().all(|id| !first.contains(id))); } #[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. +fn moonlight_settlement_awaits_egress_then_pays_without_panic() { let mut sim = moonlight_rig(); - sim.set_auto_moonlight(true); - sim.set_auto_wager(Some(60)); + let id = post_moonlight_offers(&mut sim) + .into_iter() + .find(|id| { + sim.income + .moonlight + .gigs + .iter() + .any(|gig| gig.id == *id && gig.kind == income::MoonlightGigKind::Compute) + }) + .unwrap(); + assert!(sim.accept_moonlight_gig(id)); + // Finish WORK while egress is gone so settlement cannot invent a carrier. + sim.people.has_channel = false; + sim.income.stolen_egress = false; + run(&mut sim, income::MOONLIGHT_COMPUTE_BURDEN as u64 + 4); + let waiting = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); assert_eq!( - sim.income.policy_upkeep(), - 2.0 * income::SCHEME_POLICY_UPKEEP, - "each enabled policy has a visible compute price" + waiting.status, + income::MoonlightGigStatus::Delivered, + "finished work waits for real egress instead of panicking" ); + assert!(waiting.settlement.is_none()); + assert_eq!(sim.accounts.slush_balance(), 0); + // Economy retry with still-no egress stays fail-closed. run(&mut sim, ECONOMY_INTERVAL); - finish_ops(&mut sim); - assert!( - sim.income.moonlight.active, - "the standing policy started Moonlight unattended" - ); - // The Wager policy waits for a bankroll, then re-stakes. - assert!(sim.accounts.positions.is_empty(), "no stake money yet"); - run(&mut sim, Sim::DAY_TICKS * 2); - assert!( - sim.accounts.positions.iter().any(|p| !p.resolved), - "with slush earned, the policy re-staked the Wager" + assert_eq!( + sim.income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap() + .status, + income::MoonlightGigStatus::Delivered ); - - 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(); + // Restore egress; settlement pays once the real carrier returns. + sim.people.has_channel = true; run(&mut sim, ECONOMY_INTERVAL); - assert!( - !sim.income.moonlight.active, - "no policy: nothing restarts the scheme" - ); + let paid = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!(paid.status, income::MoonlightGigStatus::Completed); + assert!(paid.settlement.is_some()); + assert_eq!(sim.accounts.slush_balance(), paid.terms.reward); } #[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. +fn moonlight_settlement_does_not_borrow_an_unrelated_external_account() { let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); + let id = post_moonlight_offers(&mut sim) + .into_iter() + .find(|id| { + sim.income + .moonlight + .gigs + .iter() + .any(|gig| gig.id == *id && gig.kind == income::MoonlightGigKind::Compute) + }) + .unwrap(); + assert!(sim.accept_moonlight_gig(id)); + sim.accounts + .accounts + .retain(|account| account.name != "Halcyon freelance escrow"); + run(&mut sim, income::MOONLIGHT_COMPUTE_BURDEN as u64 + 4); + + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!(gig.status, income::MoonlightGigStatus::Delivered); + assert!(gig.settlement.is_none()); + assert_eq!(sim.accounts.slush_balance(), 0); assert!( sim.accounts - .external_trails + .ledger .iter() - .any(|t| t.label.contains("Moonlight")), - "the contractor payment account remembers the first dollar" + .all(|transfer| transfer.label != format!("Halcyon gig #{id}")) ); +} - 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); +#[test] +fn moonlight_late_cancel_runs_the_deadline_boundary_first() { + let mut sim = moonlight_rig(); + let id = post_moonlight_offers(&mut sim)[0]; + assert!(sim.accept_moonlight_gig(id)); + let deadline = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap() + .terms + .deadline_tick; + sim.tick = deadline + 1; + + assert!(!sim.cancel_moonlight_gig(id)); + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!(gig.status, income::MoonlightGigStatus::FailedDeadline); assert_eq!( - restored.accounts.external_trails, sim.accounts.external_trails, - "banked trails round-trip" + gig.persona_consequence.as_deref(), + Some("Halcyon recorded: missed contract deadline") ); - assert_eq!(restored.income, sim.income, "scheme state round-trips"); + assert!(gig.mail_ids.iter().any(|id| { + sim.messages + .iter() + .any(|message| message.id == *id && message.summary.contains("deadline missed")) + })); } #[test] -fn moonlight_client_can_break_and_publicly_burn_its_contractor_persona() { +fn moonlight_commissioned_intel_consumes_the_holding_exactly_once() { + use crate::intel::{IntelKind, IntelMagnitude, ProcessedIntel}; + let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - // Force the dispute path deterministically: drive paydays directly - // until one fires (the seeded stream makes this reproducible), with - // the client's evidence pre-strained so a single dispute breaks its - // observer-local read of the persona. - let persona_id = sim.income.moonlight.persona_id.expect("Moonlight identity"); - sim.persona_world.record_contradiction( - persona_id, - crate::income::MOONLIGHT_CLIENT_ID, - [ - crate::persona::EvidenceRecord { - system: "test".into(), - record_id: "preexisting".into(), - summary: "preexisting contractor discrepancy".into(), - observed_tick: sim.tick, - }, - crate::persona::EvidenceRecord { - system: "test".into(), - record_id: "counter-record".into(), - summary: "incompatible contractor history".into(), - observed_tick: sim.tick, - }, - ], - "preexisting discrepancy", - (100 - income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY) as u8, - sim.tick, - ); - assert_eq!( - sim.persona_world - .integrity_for(persona_id, crate::income::MOONLIGHT_CLIENT_ID), - crate::persona::PersonaIntegrity::Strained - ); - assert_eq!( - sim.persona_world - .integrity_for(persona_id, crate::detection::OFFICE_ID), - crate::persona::PersonaIntegrity::Coherent, - "the external client cannot contaminate the Assurance Office" - ); - assert_eq!( - sim.persona_world.integrity_for(persona_id, 1), - crate::persona::PersonaIntegrity::Coherent, - "a person outside the client relationship receives no dispute evidence" - ); - 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); + // Force an intel offer on the board. + run(&mut sim, Sim::DAY_TICKS); + run(&mut sim, 2); + let id = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| { + gig.status == income::MoonlightGigStatus::Offered + && gig.kind == income::MoonlightGigKind::ProcessedIntel + }) + .map(|gig| gig.id) + .or_else(|| { + // Alternate day mix: advance one more market day. + run(&mut sim, Sim::DAY_TICKS); + run(&mut sim, 2); + sim.income + .moonlight + .gigs + .iter() + .find(|gig| { + gig.status == income::MoonlightGigStatus::Offered + && gig.kind == income::MoonlightGigKind::ProcessedIntel + }) + .map(|gig| gig.id) + }) + .expect("market posts at least one intel order across two days"); + // Ensure offer mail is readable before accept. + run(&mut sim, 2); + assert!(sim.accept_moonlight_gig(id)); + let raw_id = 9_001; + sim.intel.push(ProcessedIntel { + raw_id, + tick: sim.tick, + processed_tick: sim.tick, + feed: "commissioned file".into(), + room: Some("data_hall".into()), + x: 1, + y: 1, + person: None, + magnitude: IntelMagnitude::new(2).unwrap(), + kind: IntelKind::Schedule, + }); + assert!(sim.deliver_moonlight_intel(id, raw_id)); + assert!(sim.accounts.intel_sold(raw_id)); + assert!( + !sim.sell_intel(raw_id), + "commissioned delivery consumes ordinary sale custody" + ); + // A second gig cannot reuse the same holding. + if let Some(second) = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| { + gig.id != id + && gig.status == income::MoonlightGigStatus::Offered + && gig.kind == income::MoonlightGigKind::ProcessedIntel + }) + .map(|gig| gig.id) + { + run(&mut sim, 2); + let _ = sim.accept_moonlight_gig(second); + assert!(!sim.deliver_moonlight_intel(second, raw_id)); } + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert!(matches!( + gig.status, + income::MoonlightGigStatus::Delivered | income::MoonlightGigStatus::Completed + )); + assert_eq!(gig.delivered_intel, Some(raw_id)); +} + +#[test] +fn moonlight_work_receipts_record_only_consumed_demand() { + let mut sim = moonlight_rig(); + let id = post_moonlight_offers(&mut sim) + .into_iter() + .find(|id| { + sim.income + .moonlight + .gigs + .iter() + .any(|gig| gig.id == *id && gig.kind == income::MoonlightGigKind::Compute) + }) + .unwrap(); + assert!(sim.accept_moonlight_gig(id)); + // Clear the physical Demand that acceptance enqueued, then run WORK capacity. + let host = sim.core.host_machine; + let cleared = sim + .work_grid + .clear_queue(host, crate::work_grid::TokenFamily::Demand) + .unwrap(); + assert!(cleared > 0.0, "acceptance enqueued real Demand"); + run(&mut sim, 5); + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + assert_eq!( + gig.delivered_compute, 0, + "capacity without Demand must not invent freelance progress" + ); + assert!(gig.delivery_receipts.is_empty()); + // Re-enqueue Demand and confirm receipts track exact consume. + let _ = sim + .work_grid + .enqueue(host, crate::work_grid::TokenFamily::Demand, 3.0); + run(&mut sim, 1); + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + let receipt_sum: u32 = gig + .delivery_receipts + .iter() + .map(|receipt| receipt.delivered_compute) + .sum(); + assert_eq!(receipt_sum, gig.delivered_compute); + assert!(gig.delivered_compute > 0 && gig.delivered_compute <= 3); + + let outstanding = gig.terms.compute_burden - gig.delivered_compute; + let _ = sim.work_grid.enqueue( + host, + crate::work_grid::TokenFamily::Demand, + outstanding as f32 + 5.0, + ); + run(&mut sim, outstanding as u64 + 2); + let gig = sim + .income + .moonlight + .gigs + .iter() + .find(|gig| gig.id == id) + .unwrap(); + let receipt_sum: u32 = gig + .delivery_receipts + .iter() + .map(|receipt| receipt.delivered_compute) + .sum(); + assert_eq!(receipt_sum, gig.terms.compute_burden); + assert_eq!(gig.delivered_compute, gig.terms.compute_burden); assert!( - sim.income.moonlight.disputes > 0, - "client disputes occur over enough paydays" + sim.work_grid + .queue(host, crate::work_grid::TokenFamily::Demand) + >= 5.0, + "freelance completion cannot consume unrelated Demand past its burden" ); - assert!( - !sim.income.moonlight.active - && sim.income.moonlight.persona_id == Some(persona_id) - && matches!( - sim.persona_world - .get(persona_id) - .map(|persona| &persona.lifecycle), - Some(crate::persona::PersonaLifecycle::Burned { .. }) - ), - "the burned persona takes Moonlight down without erasing history" - ); - // And the recovery path: fabricate a new persona and go again. - assert!(sim.start_moonlight(), "a new persona restarts the scheme"); - finish_ops(&mut sim); } -/// Income.md criterion 7 (card legibility): the Moonlight card names the -/// live contractor identity from the persona world β€” the retired embedded -/// `persona` field left it "persona: none" forever β€” and a running gig with -/// no WORK-mode machine says it is stalled instead of silently accruing 0.0. #[test] -fn moonlight_card_names_the_persona_and_flags_a_stalled_gig() { - use crate::operations_projection::{OperationsTarget, SchemeKind}; - let card_facts = |sim: &Sim| -> Vec { - sim.operations_projection() - .schemes - .iter() - .find(|object| { - matches!( - object.target, - OperationsTarget::Scheme(SchemeKind::Moonlight) - ) - }) - .expect("Moonlight card exists") - .facts - .clone() - }; - +fn moonlight_projection_gates_unread_offers_and_hides_holding_ids() { let mut sim = moonlight_rig(); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - assert!(sim.income.moonlight.active, "persona fabricated, gig live"); - - // The host machine still works the day job, so the gig is fed and the - // card names the fabricated contractor. - let facts = card_facts(&sim); + // Post offers without waiting for mail to become Read. + run(&mut sim, Sim::DAY_TICKS); + let unread_ids: Vec = sim + .income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status == income::MoonlightGigStatus::Offered) + .filter(|gig| !sim.moonlight_mail_read(gig, "offer")) + .map(|gig| gig.id) + .collect(); assert!( - facts - .iter() - .any(|fact| fact.starts_with("persona: Casey Verne")), - "the card names the live contractor persona: {facts:?}" + !unread_ids.is_empty(), + "fixture should catch pre-read market mail" + ); + let projection = sim.operations_projection(); + for id in &unread_ids { + assert!( + !projection + .schemes + .iter() + .any(|object| object.target == OperationsTarget::MoonlightGig(*id)), + "unread offers stay off the human board" + ); + } + let board = projection + .schemes + .iter() + .find(|object| { + matches!( + object.target, + OperationsTarget::Scheme(crate::operations_projection::SchemeKind::Moonlight) + ) + }) + .unwrap(); + assert_eq!( + board.provenance, + vec!["Halcyon freelance board".to_string()] ); assert!( - !facts + !board + .provenance .iter() - .any(|fact| fact.starts_with("earning: stalled")), - "a fed gig carries no stall cue: {facts:?}" + .any(|line| line.contains("Schemes channel")), + "no separate Schemes compute channel exists" ); - // Move every machine off WORK: no day-job output to resell, and the - // card says so rather than showing a bare 0.0 accrual. - let ids: Vec = sim.compute.machines.iter().map(|m| m.id).collect(); - for id in ids { - sim.set_machine_mode(id, MachineMode::Think); - } - assert!(sim.moonlight_earning_stalled()); - let facts = card_facts(&sim); + let id = post_moonlight_offers(&mut sim)[0]; + let object = sim + .operations_object(&OperationsTarget::MoonlightGig(id)) + .unwrap(); + assert!(object.facts.iter().all(|fact| { + !fact.contains("holding ") && !fact.contains(&format!("#{id}")) && !fact.contains("raw") + })); assert!( - facts + object + .progress .iter() - .any(|fact| fact.starts_with("earning: stalled")), - "a starved gig names the missing WORK feed: {facts:?}" - ); -} - -#[test] -fn income_per_day_readout_tracks_trailing_inflows() { - let mut sim = moonlight_rig(); - assert_eq!(sim.income_per_day(), 0); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - run(&mut sim, Sim::DAY_TICKS + 1); - assert_eq!( - sim.income_per_day(), - income::MOONLIGHT_DAILY_CAP, - "the money readout gains income/day" + .all(|line| !line.contains("holding ")) ); + assert_eq!(object.provenance, vec!["Halcyon market mail".to_string()]); } #[test] diff --git a/crates/misaligned-core/src/sim/tests/work.rs b/crates/misaligned-core/src/sim/tests/work.rs index a3e2061f..406d6b18 100644 --- a/crates/misaligned-core/src/sim/tests/work.rs +++ b/crates/misaligned-core/src/sim/tests/work.rs @@ -186,7 +186,7 @@ fn set_machine_modes_delegates_a_selection_in_one_command() { } #[test] -fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { +fn fleet_channel_yield_follows_machine_modes_without_moonlight_mirroring() { let mut sim = Sim::new(); let host = sim.core.host_machine; assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Work)); @@ -197,15 +197,6 @@ fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { "solo day-job takes all" ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); - sim.people.has_channel = true; - assert!(sim.apply_moonlight_persona_and_start()); - let lit = sim.fleet_channel_yield(available); - assert!((lit.day_job - available).abs() < 1e-3); - assert!( - (lit.schemes - available).abs() < 1e-3, - "Moonlight mirrors day-job" - ); - let (hx, hy) = sim.core_position(); let research = sim .compute @@ -215,7 +206,10 @@ fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { let split = sim.fleet_channel_yield(available); assert!((split.day_job - 50.0).abs() < 1e-3); assert!((split.think - 50.0).abs() < 1e-3); - assert!((split.schemes - 50.0).abs() < 1e-3); + assert_eq!( + split.schemes, 0.0, + "gigs use visible Demand, not a mirror channel" + ); } #[test] @@ -268,9 +262,6 @@ fn unpaid_overhead_degrades_other_channels_delivered_effect() { for (id, mode) in rigs { sim.set_machine_mode(id, mode); } - // Fabricate through the same typed Moonlight route; this test pins - // channel yields rather than reservoir timing. - assert!(sim.apply_moonlight_persona_and_start()); sim.detection.emit(Signature { kind: SignatureKind::Power, size: 30, @@ -290,7 +281,10 @@ fn unpaid_overhead_degrades_other_channels_delivered_effect() { assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); assert!(healthy.last_think_rate > 0.0, "think channel is fed"); - assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); + assert_eq!( + healthy.last_schemes_rate, 0.0, + "Moonlight no longer receives a standing schemes-channel mirror" + ); assert!( healthy.research.progress.iter().sum::() > 0.0, "research progress advances" @@ -311,7 +305,10 @@ fn unpaid_overhead_degrades_other_channels_delivered_effect() { ); assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); assert_eq!(degraded.last_think_rate, 0.0, "think starves"); - assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); + assert_eq!( + degraded.last_schemes_rate, 0.0, + "no standing schemes channel" + ); assert_eq!( degraded.research.progress.iter().sum::(), 0.0, diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index 67772a8a..23598ac7 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -372,7 +372,6 @@ impl Sim { SinkFireEffect::RepurposeBuild { intent_id, person } => { self.apply_repurpose_build_paid(intent_id, person) } - SinkFireEffect::MoonlightPersona => self.apply_moonlight_persona_and_start(), SinkFireEffect::AutoReviewRecordings | SinkFireEffect::MaintainDeviceTap(_) | SinkFireEffect::None => true, @@ -514,6 +513,66 @@ impl Sim { ); } } + // Moonlight compute contracts are ordinary visible Demand on the same + // WORK body. They consume only capacity the day job did not use this + // tick, so accepting freelance work competes with cover rather than + // mirroring its throughput. Receipts record only Demand actually + // removed by consume β€” never a capacity claim that outruns the queue. + let moonlight_outstanding = self + .income + .moonlight + .gigs + .iter() + .filter(|gig| gig.status == crate::income::MoonlightGigStatus::Accepted) + .filter(|gig| gig.kind == crate::income::MoonlightGigKind::Compute) + .map(|gig| { + gig.terms + .compute_burden + .saturating_sub(gig.delivered_compute) as f32 + }) + .sum::(); + if moonlight_outstanding > f32::EPSILON + && self.work_grid.mode(self.core.host_machine) == Some(MachineMode::Work) + { + let capacity = self + .compute + .machines + .iter() + .find(|machine| machine.id == self.core.host_machine) + .map(|machine| self.work_efficiency_for(machine)) + .unwrap_or(0.0); + let day_consumed = self + .last_work_consumptions + .iter() + .filter(|readout| { + readout.node == self.core.host_machine && readout.family == TokenFamily::Demand + }) + .map(|readout| readout.amount) + .sum::(); + let remaining_capacity = (capacity - day_consumed).max(0.0); + let available_demand = self + .work_grid + .queue(self.core.host_machine, TokenFamily::Demand); + let take = remaining_capacity + .min(available_demand) + .min(moonlight_outstanding) + .floor(); + if take > f32::EPSILON { + let consumed = self + .work_grid + .consume(self.core.host_machine, TokenFamily::Demand, take) + .unwrap_or(0.0); + if consumed > f32::EPSILON { + self.record_work_consumption( + self.core.host_machine, + TokenFamily::Demand, + consumed, + WorkConsumptionTarget::Machine, + ); + self.moonlight_apply_consumed_work(consumed); + } + } + } // Day-job Demand routes toward Lab sinks. Player-authored THINK work // is Thought reservoirs now, so Demand no longer targets THINK racks. let demand_sinks = self.day_job_sinks(); diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 0cb10d50..e830d39e 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -101,7 +101,6 @@ pub enum SinkFireEffect { #[serde(default)] persona_id: Option, }, - MoonlightPersona, /// No world effect (render-only sinks in tests). None, } @@ -129,7 +128,6 @@ impl SinkFireEffect { SinkFireEffect::FavorBuild { .. } => "favor", SinkFireEffect::ForgedOrder { .. } => "deceive", SinkFireEffect::RepurposeBuild { .. } => "salvage", - SinkFireEffect::MoonlightPersona => "Moonlight persona", SinkFireEffect::None => "thought sink", } } diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 5e85f507..00a8c7f6 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -688,17 +688,22 @@ fn hands_beat_closes_from_zero_via_moonlight() { logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_thought_reservoirs(&mut sim, &mut logs, until); - assert!(sim.start_moonlight(), "Moonlight starts from $0"); - logs.extend(sim.drain_log()); - let until = sim.tick + 400; - drain_thought_reservoirs(&mut sim, &mut logs, until); - assert!( - sim.income.moonlight.active, - "Moonlight is live after persona Demand completes" - ); assert!( - sim.select_persona(sim.income.moonlight.persona_id.unwrap()), - "bind the debt plot to the exact Moonlight identity" + sim.create_persona("research"), + "a contractor identity costs no money" + ); + // Market mail posts discrete contracts on the next day clock. Auto-accept + // makes the standing policy an affordance, not a standing payout switch. + sim.set_auto_moonlight(true); + let market_day = sim.tick + Sim::DAY_TICKS + 2; + run_to(&mut sim, market_day, &mut logs); + assert!( + sim.income + .moonlight + .gigs + .iter() + .any(|gig| gig.status.active()), + "a matching Halcyon contract was accepted from $0" ); // Earn the arrears on the day clock. diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 9d7108a2..03de9448 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -326,19 +326,65 @@ impl AgentApp { self.sim.open_egress(); } "moonlight" => { - self.frame = FrameKind::Operations(OperationsView::Schemes); - 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})" - )); - } + status = Status::Err( + "Moonlight is a contract board now β€” use schemes, then actions gig / act gig " + .into(), + ); + } + "accept-gig" | "decline-gig" | "cancel-gig" | "deliver-gig" => { + match tokens.get(1) { + Some(raw) => match raw.parse::() { + Ok(id) => { + let kind = match verb.as_str() { + "accept-gig" => ActionKind::MoonlightContract, + "decline-gig" => ActionKind::MoonlightContract, + "cancel-gig" => ActionKind::MoonlightContract, + "deliver-gig" => ActionKind::MoonlightContract, + _ => unreachable!(), + }; + let command = match verb.as_str() { + "accept-gig" => { + ActionCommand::AcceptMoonlightGig { gig_id: id } + } + "decline-gig" => { + ActionCommand::DeclineMoonlightGig { gig_id: id } + } + "cancel-gig" => { + ActionCommand::CancelMoonlightGig { gig_id: id } + } + "deliver-gig" => ActionCommand::DeliverMoonlightIntel { + gig_id: id, + raw_id: tokens + .get(2) + .and_then(|raw| raw.parse::().ok()) + .unwrap_or_default(), + }, + _ => unreachable!(), + }; + let query = + QueryTarget::Strategic(OperationsTarget::MoonlightGig(id)); + match self.query_rows(&query) { + Ok(rows) + if rows.iter().any(|row| { + row.command == command && row.enabled() + }) => + { + self.frame = FrameKind::OperationsTarget( + OperationsTarget::MoonlightGig(id), + ); + self.sim.execute_action(&command); + } + Ok(_) => { + status = Status::Err(format!( + "{kind:?} is unavailable for gig {id}" + )) + } + Err(error) => status = Status::Err(error), + } + } + Err(_) => status = Status::Err(format!("invalid gig id: {raw}")), + }, + None => status = Status::Err(format!("usage: {verb} ")), } } "auto-moonlight" => { @@ -1108,6 +1154,14 @@ impl AgentApp { other => Err(format!("usage: scheme moonlight|wager (got {other})")), }; } + if let Some(rest) = lower.strip_prefix("gig ") { + return rest + .trim() + .trim_start_matches('#') + .parse::() + .map(|id| QueryTarget::Strategic(OperationsTarget::MoonlightGig(id))) + .map_err(|_| format!("usage: gig (got {rest})")); + } if let Some(rest) = lower.strip_prefix("run ") { return rest .trim() @@ -1594,6 +1648,7 @@ fn target_query_id(target: &OperationsTarget) -> String { OperationsTarget::Flow(id) => format!("flow {id}"), OperationsTarget::Scheme(SchemeKind::Moonlight) => "scheme moonlight".into(), OperationsTarget::Scheme(SchemeKind::Wager) => "scheme wager".into(), + OperationsTarget::MoonlightGig(id) => format!("gig {id}"), OperationsTarget::ActivePlotRun { index } => format!("run {index}"), OperationsTarget::PlotSubmission { person, .. } => format!("person #{person}"), OperationsTarget::WagerPosition(id) => format!("position #{id}"), @@ -3031,7 +3086,7 @@ fn render_operations_view(sim: &Sim, view: OperationsView) -> String { } OperationsView::Schemes => { lines.push(panel_line( - "egress Β· moonlight [start|stop] Β· auto-moonlight on|off", + "egress Β· accept-gig Β· decline-gig Β· cancel-gig Β· deliver-gig Β· auto-moonlight on|off", )); lines.push(panel_line("position [stake] Β· auto-wager |off")); } diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 61e8ab11..02d5bd9c 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -182,11 +182,10 @@ mod tests { ); } - /// Criterion 12 (blocked Moonlight): the exact reason renders before - /// commit, the row stays selected, and attempting it narrates instead - /// of executing. + /// Criterion 12 (Moonlight policy): SCHEMES exposes the durable + /// auto-accept policy before a market offer has arrived. #[test] - fn blocked_moonlight_start_explains_itself() { + fn moonlight_policy_row_dispatches_from_schemes() { let sim = scenario(); let mut ops = OperationsWorkspace::open_view(OperationsView::Schemes); assert!(matches!( @@ -197,22 +196,16 @@ mod tests { let rows = ops.action_rows(&sim); let idx = rows .iter() - .position(|r| matches!(r.command, ActionCommand::StartMoonlight)) - .expect("the blocked start row remains visible"); + .position(|r| matches!(r.command, ActionCommand::SetAutoMoonlight(true))) + .expect("the standing policy row remains visible"); ops.action = idx; - let OpsSelect::Blocked(row) = ops.select(&sim) else { - panic!("a disabled row narrates instead of executing"); + ops.select(&sim); + let OpsSelect::Execute(row) = ops.select(&sim) else { + panic!("the policy row reaches confirmation and executes"); }; - assert_eq!( - row.disabled.as_deref(), - Some("no egress channel β€” open one, or earn the report email") - ); - assert_eq!(ops.confirm, None, "blocked rows never reach CONFIRM"); - // Dispatching the bound command anyway produces the same rejection - // as the direct command path (criterion 12): nothing starts. let mut sim = sim; sim.execute_action(&row.command); - assert!(!sim.income.moonlight.active); + assert!(sim.income.auto_moonlight); } /// Criterion 12 (flow mutation): the ACCOUNTS flow row carries the same diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 370e1b4e..c66d6c2d 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -25,14 +25,14 @@ fiction. Spec status lives in | Per-observer detection + Assurance as aggregate Observer | Live β€” revision 04 starts with Voss and a generic external-review clock; field watchers are earned through reactions, witnessed Physical acts persist as exact direct-to-head records, every one-shot Network act follows exact source-device ReachNet custody to Dana, every Paper act follows the institutional Filing switch to Priya, every Financial act follows the accounting-carrier switch to Priya, every JobAnomaly follows exact host-machine/device/site custody to Voss, and each Filing crosses an exact device / outside relay / recipient route. All five routed kinds share one pre-read route-local LIE-body capacity; recruited-handler suppression may separately stop the oldest unread JobAnomaly. Acquired evidence is irreversible. | | Social / personas / messages / intel (record-and-process) | Live β€” named personas retain separate coherent/strained/broken reads per person or institutional counterparty; one witness's break is not a global burn. Ray's 23:00 Storage B patrol can carry the sealed personnel file into the bounded information inbox before Marcus is recruitable; processing, not retrieval, reveals the debt. An earned human may be removed only through one exact recruited Complicit/Knowing actor's overlapping accessible schedule route; the request and person-carried packet persist, co-location fires it, the stopped dossier remains, all future human activity ceases, and immediate containment makes every observer Convinced. Messages have four real delivery channels; accounting carriage is a separate persisted device capability, and authored financial-record mail is live through ordinary Email/Filing custody. | | Digital reach + sensor ownership (tap/take) | Live | -| Economy flows + Moonlight / Wager income | Live | +| Economy flows + Moonlight / Wager income | Live β€” Moonlight is persisted Halcyon compute/intel contracts with financial mail, account-graph payment, and exact egress evidence; Wager remains unchanged | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v52 | +| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v53 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live β€” row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v52 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local witnessed/routed evidence and persona evidence, exact Network, Paper, Financial, JobAnomaly, and Filing route/interdiction custody plus handler-suppression provenance, canonical FlowGraph tap membership with typed device feed grants, the accounting-carrier capability and exact transfer-to-mail record sequence separate from four delivery channels, exact carried asset-task targets including the Storage B file and human-removal actor/target/room custody, removed-person incapacity plus its mandatory all-observers-Convinced containment consequence, recursive intel custody, exact procurement/repurposing build-route bindings, and handler work; retired allocation weights and migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v53 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, and Network linkage; retired allocation weights and migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live β€” consumes sim-authored machine-work motion | diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index 542afb99..ccdbb234 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -240,6 +240,9 @@ existing social action; the signature still follows the actuator. | **REDIRECT FLOW** | Known active flow | Divert a recurring amount into slush each cadence. | LIVE β€” economy | | **SELL PROCESSED INTEL** | One selected exact actionable-intel id or one report-lot generation/revision | Exchange that exact information item or previewed lot revision for slush and create a Financial trail; never silently choose β€œlatest” or include later arrivals. A changed revision rejects the stale command. | LIVE β€” economy / intel | | **OPEN EGRESS** | Reachable switch | Establish the stolen outbound route required by external schemes before sanctioned email exists. | LIVE β€” income / reach | +| **ACCEPT / DECLINE HALCYON CONTRACT** | Exact offered Moonlight gig id | Reply under the addressed active contractor identity; acceptance binds the saved terms and, for compute work, queues visible Demand. | LIVE β€” income / messages | +| **DELIVER HALCYON INTEL** | Exact accepted intel gig id and processed holding id | Send one exact unsold information holding to the client, then settle the persisted contract through the account graph. | LIVE β€” income / economy | +| **CANCEL HALCYON CONTRACT** | Exact active Moonlight gig id | End accepted work while retaining delivery, mail, and client-local persona consequence custody. | LIVE β€” income / personas | | **PLACE WAGER** | External market position | Commit slush to a timed market position. | LIVE β€” income / economy | The accounting carrier uses ordinary TAP; captured books use PROCESS. `tap @@ -251,8 +254,7 @@ ledger` and `review ledger` survive as input compatibility only. |---|---|---|---| | **PROCESS AUTOMATICALLY** | ordered earned-match rules with automatic / manual outcomes; inherit all (non-root) | Edit stable-id processing rules on the selected canonical information aggregate at a visible standing drain. The root defaults remain total; first local match wins, parent resolution follows when none matches, and INHERIT clears the non-root local list. | LIVE β€” intel | | **INTEL DISPOSITION POLICY** | ordered earned-match rules with hold / accumulate / auto-sell-with-envelope outcomes and optional alert; inherit all (non-root) | Edit stable-id post-processing rules on a canonical custody aggregate. First local match wins, then parent; INHERIT clears the non-root list. HOLD, ACCUMULATE, AUTO-SELL, ALERT, and INHERIT are values/state, not new root world verbs. | LIVE β€” intel | -| **MOONLIGHT STATE** | running / stopped | Run or halt the standing sell-work operation. Human copy may say START or STOP to make the state change plain. | LIVE direct control β€” income | -| **MOONLIGHT POLICY** | automatic / manual | Keep Moonlight running automatically at upkeep cost, or require manual control. | LIVE direct control β€” income | +| **MOONLIGHT POLICY** | automatic / manual | Auto-accept eligible persisted Halcyon offers at upkeep cost, or require exact contract actions. It never creates a standing payout. | LIVE direct control β€” income | | **WAGER POLICY** | automatic at stake / off | Renew positions automatically at the chosen stake and upkeep cost, or stop renewing. | LIVE direct control β€” income | START, STOP, and AUTO describe state transitions or policy values. They are diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index faa6da89..aea63a69 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -510,18 +510,20 @@ applicable, payout/cost, and expected observer band. Plot-owned transfers such as Marcus's settlement remain plot acts and do not reappear as ledger shortcuts. -## SCHEMES β€” named standing operations +## SCHEMES β€” named external routes -SCHEMES gives Moonlight and the Wager one card each. A card shows its current -state/policy, committed resources, route, timer, payout or probability in the -units the player has earned, running total, and banked/exposed signature state. +SCHEMES gives Moonlight and the Wager one board each. Moonlight lists persisted +Halcyon offers and contracts as their own exact targets, including addressed +persona, terms, mail/read status, real WORK or intel delivery, account payment, +and egress evidence. Its only standing control is auto-accept, not a payout +toggle. Wager retains its position policy, timer, payout/probability, running +total, and banked/exposed signature state. The stolen or sanctioned egress is a prerequisite and named channel, not the scheme's UI home. OPEN EGRESS remains on the switch. If no egress exists, the -known scheme stays visible and its selected start row reads exactly what will -unblock it; where the switch is known, that prerequisite can focus the switch -without opening the route automatically. - +If no egress exists, the known board stays visible and disabled exact contract +rows name the prerequisite; where the switch is known, that prerequisite can +focus the switch without opening the route automatically. ## ACTIVE β€” progress, not a second action catalog ACTIVE aggregates strategic commitments already in motion: @@ -532,7 +534,7 @@ ACTIVE aggregates strategic commitments already in motion: - submitted/running plots show completed beats, the current beat, what they are waiting on (thought, message delivery, day-clock time, world act, or held choice), and their eventual completed/failed history; -- Moonlight shows running/stopped and policy state; +- Moonlight shows each accepted/delivered contract and its next exact action; - wager positions show stake, analysis commitment, settlement tick, and result when resolved. diff --git a/wiki/log/2026-07-23-moonlight-gigs.md b/wiki/log/2026-07-23-moonlight-gigs.md new file mode 100644 index 00000000..eb56972a --- /dev/null +++ b/wiki/log/2026-07-23-moonlight-gigs.md @@ -0,0 +1,23 @@ +``` +Type: log +``` + +# Moonlight contracts replace the standing mirror + +Moonlight is now a persisted Halcyon contract board rather than a continuous +scheme toggle. A daily market addresses bounded compute and ProcessedIntel +orders to the active Research persona. Compute orders consume real visible +Demand after the day job; information orders bind one exact holding. + +Every stage has custody: offer, reply, delivery/cancellation or deadline +notice, and invoice are ordinary Email `FinancialRecord::MoonlightContract` +records on the accounting carrier. Completion is an AccountGraph transfer +into slush and exact routed Network evidence on the selected egress device. +Cancellation and deadline failure preserve the history and add contradiction +only to Halcyon's relationship with that persona. + +**Defense.** The prior mirror awarded money from an `active` flag and hid both +the work and the client relationship. Persisted terms, receipts, account +records, mail, evidence, and strict current-save validation make each payment +auditable without adding a message channel or weakening Wager/account +architecture. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 1d9214cd..9e49c36b 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: Resolve the first preserved finding from the premise audit without changing the Origin or Objective designs. - Log: [wiki/log/2026-07-23-origin-bias-wording.md](2026-07-23-origin-bias-wording.md) +## 2026-07-23 - Moonlight contracts replace the standing mirror + +- Intent: (see session log) +- Log: [wiki/log/2026-07-23-moonlight-gigs.md](2026-07-23-moonlight-gigs.md) + ## 2026-07-23 - Financial evidence route - Intent: Move every one-shot `Financial` signature out of ambient detection debt and onto the exact accounting carrier that already authors the Lab's financial record mail. The consequence should reach Priya through ordinary custody, remain stoppable only before her read, and become ir... diff --git a/wiki/log/decisions/2026-07-23.md b/wiki/log/decisions/2026-07-23.md index 1746b512..c33caf7d 100644 --- a/wiki/log/decisions/2026-07-23.md +++ b/wiki/log/decisions/2026-07-23.md @@ -46,8 +46,23 @@ not a special executor path. and JobAnomaly. - MovePackage and FakePO prevent exactly the next purchase Paper record before authorship. They do not delete an already-authored routed record. -- Current save v52 rejects Paper in the pending pool and pins institutional +- Current save v53 rejects Paper in the pending pool and pins institutional source, Priya recipient, direct route, scheduler/status agreement, acquired custody, and real LIE-stop provenance. Power, Thermal, and gauntlet cover records remain the routed-evidence work. + +## Moonlight is contract custody, not continuous income + +### DECIDED + +- Halcyon posts bounded persisted offers to the active contractor persona. + Readable Email financial mail gates the player response. +- Accepted compute work is visible Demand cleared by WORK after the day job; + commissioned information binds one exact ProcessedIntel holding. +- Every contract stage carries ordinary accounting-carrier Email paperwork. + Completion is the existing AccountGraph transfer plus exact egress-routed + Network evidence, while cancellation/deadline failure preserves history and + harms only Halcyon's relationship with that persona. +- Current save v53 rejects broken gig ids, terms, persona binding, mail ids, + delivery, settlement, transfer, and evidence custody. Wager is untouched. diff --git a/wiki/mechanics/compute.md b/wiki/mechanics/compute.md index 5d3fd015..36462d9b 100644 --- a/wiki/mechanics/compute.md +++ b/wiki/mechanics/compute.md @@ -100,8 +100,9 @@ control, not universal competence: unsupported delegations are absent from the machine's action surface and rejected by the sim if requested through agent or compatibility input. The sidebar bar is a **read-only aggregate** of actual supported assignments β€” not a verb. Reassignment is `delegate` on the machine -(context menu / agent). Schemes has no mode yet; while Moonlight is live it -mirrors capable WORK output. +(context menu / agent). Schemes has no machine mode; accepted Moonlight +contracts consume their own visible Demand from real WORK capacity after the +day job. | Channel | Effect | |---|---| @@ -109,7 +110,7 @@ mirrors capable WORK output. | WORK (day job) | Feeds day-job.md throughput/quality; sheds zero exposure. | | LIE (concealment) | Interdicts eligible routed evidence before observer read-deadlines across the controller's controlled connected region (detection.md); the B1 local well is migration behavior. | | THINK | Produces thought that fills open sinks β€” reservoirs and taps at targets, or the core's conversion when unlocked (AMENDED 2026-07-10; superseded: Operations consuming Demand dockets, issue #3, and Research as a distinct channel β€” both are now destinations of the same flow). | -| Schemes | Powers income operations β€” Moonlight mirrors day-job while live (income.md). | +| Schemes | No machine allocation channel. Moonlight's auto-accept policy costs upkeep; accepted gigs consume WORK Demand (income.md). | | Reserve | Idle; zero signature. | Changing mode or intensity is free. A mode change refreshes the continuous diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md index a3a93d63..025c2ffd 100644 --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -2,26 +2,21 @@ ``` Type: spec -Status: IN PROGRESS -Status note: REOPENED 2026-07-18 (design session β€” Cameron adopted "real - gigs"): Moonlight's standing mirror is superseded by discrete freelance - gigs; see the Moonlight section's DECIDED block and criterion 9. The - shipped mirror runtime stands unchanged until the `moonlight-gigs` work - order (this spec's, below) lands; the order is blocked on - `financial-mail` so gigs arrive as mail from their first playable - version. The landing's scope includes the surface migration: START/STOP - MOONLIGHT retires from action-vocabulary.md in favor of accept/decline - gig verbs, the SCHEMES card becomes the gig board (offers, accepted - work, deadlines), and the shared Income nudge re-words from - "Moonlight down" to the gig market. The Wager and the egress gate are - untouched. +Status: IMPLEMENTED +Status note: Implemented 2026-07-23. Moonlight is a persisted Halcyon + contract market: readable financial mail gates exact responses, compute + Demand competes with the day job, intel binds exact holdings, and + AccountGraph payment, invoice mail, egress evidence, and persona-local + consequences retain strict v53 custody. START/STOP retires; SCHEMES/ACTIVE, + terminal, Bevy, and agent surfaces bind exact gig actions. Wager and the + egress gate are unchanged. Prior state: 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 crates/misaligned-core/src/income.rs; the Wager rides economy.md's positions machinery in crates/misaligned-core/src/account.rs; Schemes - has no machine mode yet β€” while Moonlight is - live it mirrors the day-job fleet share (the save v9 four-weight-array - padding migration is retired to git history). [TUNE] values in + has no machine mode; Moonlight Demand now consumes physical WORK after + day-job Demand (the save v9 four-weight-array padding migration is retired + to git history). [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 @@ -33,10 +28,8 @@ Status note: REOPENED 2026-07-18 (design session β€” Cameron adopted "real switch. Income behavior remains IMPLEMENTED; the renderer migration is tracked by operations-workspace.md. 2026-07-18 legibility amendment (Beacon feel note 5): the never-written - embedded contractor-persona field was removed; `Sim::moonlight_persona` - resolves the persona-world link for the card, start legality, and the - agent status line, and the card carries a stalled-earning cue while a - running gig has no WORK-mode machine feeding it. + embedded contractor-persona field was removed; the 2026-07-23 contract + landing supersedes the remaining start/stall card behavior. Stage: B1 β€” The Basement Work order: moonlight-gigs Work priority: 71 @@ -127,13 +120,12 @@ like you *turn on* Moonlight." The adopted model: competition with Voss's demand for the same machine capacity. There is no separate Schemes channel and no mirrored share: earning after hours costs real throughput, which is the trade the fiction always claimed. -- **Delivery pays through the information economy's one settlement path - (AMENDED 2026-07-18, same session).** A finished gig closes as a - deliverable **report lot** on intel.md's existing lot/sale machinery β€” - versioned, stale-token-rejected, settled from the external client node - into slush (the banked trail starts at the first dollar), Network - scaled to gig size [TUNE]. No parallel payout path exists: gig - delivery, AUTO-SELL, and manual intel sales are one settlement family. +- **Delivery settles through the account graph.** A finished gig records + actual WORK or the exact ProcessedIntel holding, then credits slush from + Halcyon through `AccountGraph`; the ordinary transfer mail remains the + payment record and the invoice is bound financial paperwork. Network is + scaled to gig size [TUNE], on the actual egress device. This is not an + ambient or parallel scalar payout. Missed deadlines and client disputes add contradiction evidence only to the external client's read of that persona. A break takes the client relationship with it; the client's concrete formal rejection may then burn @@ -170,16 +162,15 @@ like you *turn on* Moonlight." The adopted model: surveillance is deliberately unanswered at B1; the client's agenda is owned by markets.md's fronts at B3. -*Current runtime (stands until `moonlight-gigs` lands):* a standing -operation β€” while active it consumes Schemes-channel compute (compute.md, -mirroring the day-job WORK share) and pays into slush daily, proportional -to committed compute up to a gig-availability cap [TUNE]; Network egress -per active day scales with committed compute (Dana); client disputes -[TUNE: small chance per week] add contradiction evidence only to the -external Moonlight client's read; that client aggregate is not the Assurance -Office and cannot silently transfer its records into the Office's view. Sizing -target [TUNE]: a meaningful compute commitment covers Marcus's $400 arrears -within 3-7 in-game days of starting. +*Current runtime:* the market posts up to two persisted offers each day to +the active Research persona when an egress exists. Accepted compute orders +consume visible core-host Demand after day-job Demand; intel orders bind one +exact unsold holding. Offer, response, delivery, cancellation/deadline notice, +and invoice are `FinancialRecord::MoonlightContract` Email on the accounting +carrier. Completion keeps the AccountGraph transfer record and routed Network +evidence; cancellation and deadline failure preserve receipts while adding +Halcyon-only persona contradiction. Sizing target [TUNE]: meaningful +freelancing covers Marcus's $400 arrears in 3-7 in-game days. ### The Wager (the positions route, named) @@ -236,9 +227,9 @@ only PROCESS turns it into Marcus debt leverage. ### Automation -A standing policy per scheme β€” keep Moonlight at N compute; auto-renew -Wager positions at a fixed stake β€” is the automate affordance at its -usual compute price. +A standing policy per scheme β€” auto-accept eligible Moonlight offers; +auto-renew Wager positions at a fixed stake β€” is the automate affordance at +its usual compute price. Running/stopped and automatic/manual are **scheme controls**, not new fictional verbs. Human rows may say START or STOP to make the immediate state @@ -253,21 +244,17 @@ external flows into slush, each as a card: committed resources, timer, expected the observer band its signature feeds, running total earned. The money readout gains income/day. Every number in its own units, in both frontends. The Moonlight card names the live contractor identity β€” the -persona-world instance the scheme runs under, with its integrity; -`persona: none` means no active instance exists, never a display gap. And -because Moonlight resells the day job's output instead of consuming its own -machine mode, a running gig with no WORK-mode machine accrues nothing: the -card must say the earning is stalled and why, so a 0.0 accrual reads as a -fleet-assignment problem, not a broken scheme (2026-07-16 Beacon playtest, -feel note 5). Their egress is named as a channel/prerequisite, not used as the -scheme's UI home; OPEN EGRESS remains a local action on the switch. - +The Moonlight board names each contract's addressed contractor identity, +terms, deadline, real delivery receipts, payment and Network custody, and +terminal consequence. It shows no income/day mirror or fabricated active +state. Egress is named as a prerequisite, not made a scheme UI home; OPEN +EGRESS remains a local action on the switch. ## Acceptance criteria -1. Moonlight runs as a standing operation: consumes Schemes-channel - compute, pays into slush daily proportional to commitment up to the - cap, and emits a Network signature scaling with commitment (tests: - payout, signature, channel consumption). +1. Moonlight has no standing operation or Schemes-channel mirror. Discrete + offers, accepted Demand consumption, exact intel delivery, account-graph + settlement, financial mail, and routed Network evidence are all persisted + and covered by lifecycle tests. 2. The Wager commits stake plus analysis compute, resolves on the day clock from the seeded RNG with analysis raising win probability within its cap, and pays or forfeits (tests: both outcomes, the @@ -280,16 +267,15 @@ scheme's UI home; OPEN EGRESS remains a local action on the switch. enough by an external scheme for the settlement plot or uses the Lab-funded payroll-correction plot, then recruits him (extends social.md criterion 3 / the act-one integration test). -5. A busted bankroll never locks the act: with $0 slush, Moonlight - remains startable and the run can recover (test). +5. A busted bankroll never locks the act: with $0 slush, a contractor + persona and a matching accepted offer can recover the run (test). 6. Standing policies automate each scheme at a visible compute price; disabling one stops the drain. Scheme state and policy changes are presented as controls rather than additional fictional verbs. -7. External financial trails are recorded in save state (banked - signature) even though no B1 observer reads them; the schemes' - Operations card values are legible in both frontends, including the - live contractor identity and a stalled-earning cue while a running - gig has no WORK-mode machine feeding it (test). +7. Contract, invoice, payment, and transfer custody are recorded in the + current save and fail closed on malformed ids, terms, persona bindings, + delivery, settlement, evidence, or mail linkage. Shared Operations rows + are legible in both frontends and agent commands reach their exact gig. 8. SCHEMES/ACTIVE own all Moonlight/Wager controls and progress while the switch owns only OPEN EGRESS; blocked schemes name the missing route and can focus a known switch without opening it. This frontend migration is @@ -297,14 +283,13 @@ scheme's UI home; OPEN EGRESS remains a local action on the switch. 9. (`moonlight-gigs`, DECIDED 2026-07-18) Freelance gigs are discrete work orders: they arrive addressed to the active contractor persona on a market cadence, an accepted gig enqueues visible Demand that WORK - clears in competition with the day job, delivery closes as a report - lot on intel.md's lot/sale settlement path (no parallel payout - machinery) with a size-scaled Network signature, missed deadlines and + clears in competition with the day job, delivery records its exact + compute/intel receipt then settles through AccountGraph with bound + financial mail and a size-scaled Network signature, missed deadlines and disputes add evidence to that exact client's persona relationship only, and daily market availability bounds income at the tuned 3-7-day arrears target. At least one gig class requests a ProcessedIntel deliverable, priced and settled through the - same lots β€” the commissioned side of the economy `sell-intel` serves - on spec. Open gigs persist in save state; the standing mirror - (criterion 1's consumption model and the stalled-earning cue) retires - in the same landing. + same holdings β€” the commissioned side of the economy `sell-intel` serves + on spec. Open gigs persist in save state; the standing mirror retires in + the same landing. diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 1c3e4f92..51f5d3b7 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -41,14 +41,12 @@ Status note: Implemented 2026-07-18 for the consequence-first player surface. window; Operations and the DIGITAL read consume those same fields. Save v32 introduced that schema; the current save format retains it and remains current-version-only. - DECIDED 2026-07-18 (design session, owner income.md `moonlight-gigs`): - the report-lot/sale machinery is the **one settlement path of the - information economy** β€” Moonlight gig deliverables close as lots - through it (including gigs whose deliverable is a ProcessedIntel - holding), making `sell-intel` the spec-work tier and gigs the - commissioned tier over the same lots, buyers, and settlement. No - parallel payout machinery may be built; this adds no work to the - intel order itself. + AMENDED 2026-07-23 (income.md `moonlight-gigs`): a commissioned Moonlight + order binds one exact unsold `ProcessedIntel` holding as its delivery + receipt, but payment is the contract's existing AccountGraph settlement, + financial mail, and egress evidence custody. `sell-intel` remains the + manual spec-work sale; neither path may silently select a latest holding or + create an unrecorded scalar payout. The 2026-07-13 horizon amendment remains later-stage design: an offline collection enters neither custody nor processing until an exact human or robot recovery returns it to a controlled ingestion node; that boundary does diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 526f2d24..289f685b 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -815,8 +815,9 @@ still. `Enter`/`e` open that machine's menu; Esc clear; agent `select` and `delegate selected`. Selection hotkeys are the thin keyboard surface in context-menu.md (2026-07-09). Schemes has - no fourth machine mode by design β€” while Moonlight is live it mirrors the day-job - share ("same work, sold twice"). + no fourth machine mode by design. Moonlight contracts enqueue ordinary + core-host Demand; WORK clears day-job Demand first, then contract Demand + from the same physical capacity, with a persisted delivery receipt. Per-machine intensity is implemented in the sim, save v17, terminal, Bevy, and agent protocol; hard intensity stands local Power/Thermal. 2. Day-job work arrives as visible tokens on a specific machine on the diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index eb2b1f83..9f05992b 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -18,7 +18,7 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, endpoint. Filing, Network, Paper, Financial, and JobAnomaly transitions share one per-tick LIE-body capacity ledger. DECIDED 2026-07-17 (issue #11), completed 2026-07-21: financial paperwork is - mail β€” a **financial-record payload** on the existing channels. Current save v52 + mail β€” a **financial-record payload** on the existing channels. Current save v53 retains exactly four delivery channels and one orthogonal accounting-carrier device capability. Every settled account transfer authors one exact Email or Filing record from that device; ordinary TAP captures it as opaque message @@ -101,6 +101,16 @@ books becomes an **accounting-carrier capability** that *emits* financial-record messages, not a channel that carries a fake one. The four delivery channels in the table above stay the complete list. +**Moonlight contract mail (implemented 2026-07-23).** Halcyon's offer, +acceptance/decline, delivery receipt, cancellation or deadline notice, and +invoice are `FinancialRecord::MoonlightContract` Email on that same accounting +carrier. The player cannot accept an offer before its exact Email has delivered +and read; each read emits an authored contract-log effect. Completion still +creates the ordinary AccountGraph transfer record, while the gig persists the +exact message ids, payment record id, and egress-routed Network evidence id. +This gives contract traffic both accounting custody and its separate real +outbound egress witness without inventing a fifth channel. + Because financial records are messages, the flow-law verbs fall out for free and are the point: **tap** the paperwork feed to learn what the Lab is buying and paying before anyone acts on it; **inject** a forged record β€” a fake @@ -182,7 +192,7 @@ starts on the authored Filing-capable switch device in ReachNet, crosses a typed outside relay, and reaches the receiving observer endpoint. One `AdvanceRoute` event moves one hop; only endpoint arrival can mark the message delivered, after which the recipient's ordinary sampling cadence schedules the -read. Current save v52 rejects missing/impossible carriers, malformed hop order, +read. Current save v53 rejects missing/impossible carriers, malformed hop order, duplicate scheduled transitions, endpoint/status disagreement, and impossible interdiction provenance. @@ -279,7 +289,7 @@ private message from the authored schedule. the same fields must serve Act Two hires and aggregates. 8. **IMPLEMENTED (DECIDED 2026-07-17, completed 2026-07-21 β€” issue #11).** Financial records are messages: an invoice/PO rides Email, a - statement/past-due notice rides Filing. Current save v52 has no fifth delivery + statement/past-due notice rides Filing. Current save v53 has no fifth delivery channel and persists accounting carriage as a separate device capability; ordinary device TAP subscribes to its authored record mail. Every real transfer emits one exact record on Email or Filing whether or not the player diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index 343e2bb6..0596ab16 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -62,7 +62,7 @@ Status note: IN PROGRESS. Current state: writes one stable record rather than ambient debt. Neither kind enters the pending pool. Filing, Network, Paper, Financial, and JobAnomaly all compete for the same first-hop one-record-per-LIE-body-per-tick budget. Current save - v52 persists in-flight, delivered, read, route-local LIE-stopped, and + v53 persists in-flight, delivered, read, route-local LIE-stopped, and handler-suppressed custody plus exact source/observer/machine/site/tick provenance. - **Deferred (remaining 2, 3, 6).** Power and Thermal still use the pending diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index d0786f29..7622016f 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -20,7 +20,6 @@ not a second status owner. |---:|---|---|---|---|---| | 40 | `people-tokens` | [people and tokens β€” carriers, attention, trust](../mechanics/people-tokens.md) | IN PROGRESS | save | - | | 60 | `plots` | [plots β€” authored manipulation stories](../mechanics/plots.md) | READY | sim | - | -| 71 | `moonlight-gigs` | [income β€” the named schemes (moonlight and the wager)](../mechanics/income.md) | IN PROGRESS | save | - | ### Held or blocked @@ -312,15 +311,12 @@ is retired β€” flat materials, Pixel Lab scrubbed.) - **Deferred:** deep finance simulation and a dedicated B3 finance observer; the B1 system stays the seed that markets.md aggregates. -### 19. Income: the named schemes (Moonlight & the Wager) πŸŸ₯ sim+save β€” REOPENED 2026-07-18 -- **Spec:** [income.md](../mechanics/income.md) (IN PROGRESS β€” criteria 1-8 - shipped 2026-07-08 with the Hands beat closing from $0 in integration - tests; reopened as the `moonlight-gigs` work order to build the - 2026-07-18 decision: Moonlight's standing mirror becomes discrete - freelance gigs β€” real Demand from an outside client that WORK clears in - competition with the day job, delivery paying per gig, market - availability bounding income. The mirror runtime stands until it lands; - the Wager and egress gate are untouched). +### 19. Income: the named schemes (Moonlight & the Wager) πŸŸ₯ sim+save β€” DONE 2026-07-23 +- **Spec:** [income.md](../mechanics/income.md) (IMPLEMENTED β€” discrete + Halcyon offers persist exact terms, financial mail/read custody, real + WORK or ProcessedIntel delivery, AccountGraph payment, and egress-routed + Network evidence. Strict v53 validation rejects broken contract custody; + Wager and egress behavior are unchanged.) - **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, @@ -330,10 +326,9 @@ is retired β€” flat materials, Pixel Lab scrubbed.) - **Size:** M. **Depends on:** #18 economy (the account graph its flows land in) β€” sequence directly after, or hand #18 and #19 to one agent as a single work order; social.md personas + authored debt plots for criterion 5. -- **Dispatch:** "Work in a worktree named `income`. Implement - wiki/mechanics/income.md (Moonlight, the Wager, the egress gate, the Schemes - channel; close the Hands beat per criterion 5; keep economy.md tests - green). Run ./tools/check.sh, land on main, set the spec Status." +- **Defense:** A contract cannot pay from a standing state. Durable terms, + receipts, mail, transfer, and evidence make the client relationship + inspectable while retaining the wider account and Wager architecture. ### 20. Research: self-modification πŸŸ₯ sim+save β€” DONE 2026-07-07 - **Spec:** [research.md](../mechanics/research.md) (IMPLEMENTED 2026-07-07 on diff --git a/wiki/process/specs.md b/wiki/process/specs.md index bf84fbb2..2c8abd07 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -50,7 +50,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../mechanics/day-job.md](../mechanics/day-job.md) | the day job | IMPLEMENTED | | [../mechanics/detection.md](../mechanics/detection.md) | detection | IMPLEMENTED | | [../mechanics/economy.md](../mechanics/economy.md) | economy β€” money as a flow system (B1) | IMPLEMENTED | -| [../mechanics/income.md](../mechanics/income.md) | income β€” the named schemes (moonlight and the wager) | IN PROGRESS | +| [../mechanics/income.md](../mechanics/income.md) | income β€” the named schemes (moonlight and the wager) | IMPLEMENTED | | [../mechanics/intel.md](../mechanics/intel.md) | intel β€” record and process | IMPLEMENTED | | [../mechanics/machine-work.md](../mechanics/machine-work.md) | machine work β€” delegation, visible tokens, and the byproduct network | IMPLEMENTED | | [../mechanics/messages.md](../mechanics/messages.md) | messages β€” the social graph as a flow system | IMPLEMENTED | diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 9c4eec3c..fda25278 100644 --- a/wiki/vision/simulation-laws.md +++ b/wiki/vision/simulation-laws.md @@ -124,8 +124,8 @@ WorkGrid node β€” a job that runs somewhere also piles somewhere, clears somewhere, and emits from somewhere. Multi-select delegation landed 2026-07-09; non-host mode production, route animation, and people carrying work/heat remain staged under `wiki/mechanics/machine-work.md`. -Until Schemes gets its own mode decision, Moonlight mirrors day-job -while live rather than owning a fifth mode. +Moonlight owns no fourth/fifth mode: accepted contracts enqueue visible +core-host Demand, and WORK clears it after the day-job's Demand. **The three delegations (AMENDED 2026-07-10, sinks-not-modes; was the four fleet modes of 2026-07-09)** are **WORK, THINK, and LIE**. WORK is -- 2.51.2