diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -3339,6 +3339,7 @@ "dirs", "serde", "serde_json", + "toml", ] [[package]] @@ -4470,6 +4471,15 @@ ] [[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4819,6 +4829,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4868,6 +4893,12 @@ dependencies = [ "winnow 1.0.3", ] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tracing" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ bevy = "0.18" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.9" # Optimize Bevy dev builds (workspace-wide profiles). [profile.dev] diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -189,12 +189,13 @@ Useful agent finance commands include `finance`, `tap ledger`, `review ledger`, `siphon [amount]`, `redirect [amount]`, -`inject [amount]`, `position [stake]`, `sell-intel`, and `clear-debt`. +`inject [amount]`, `position [stake]`, and `sell-intel`. Authored social and +debt routes are listed with `actions ` and executed with `act `. For machine-work experiments, use `delegate ` (`delegate M1 think`, `delegate Rack 3 work`). `alloc` is retired; compute moves by changing machine modes. Use `intensity light|medium|hard` to set physical effort. -`actions [name|#flow]` (alias `menu`) lists the context-menu rows for the +`actions [name|device name|person #N|#flow]` (alias `menu`) lists the context-menu rows for the cursor tile, a named device or person, or a flow — one stable-format line per row. Drained event lines carry a stable `@tile/@device/@person/@flow` suffix when the event is anchored, and `focus last` jumps the cursor to the diff --git a/assets/plots/README.md b/assets/plots/README.md --- a/assets/plots/README.md +++ b/assets/plots/README.md @@ -1,22 +1,56 @@ # Plots — authored manipulation stories -Content home for plot files: one TOML file per plot at -`assets/plots//.toml`. +The binding design contract is +[`wiki/mechanics/plots.md`](../../wiki/mechanics/plots.md). The executable +schema is `crates/misaligned-core/src/plot.rs`; every built-in file is parsed +and semantically validated by `PlotCatalog::load_builtin()`. -The binding contract is [wiki/mechanics/plots.md](../../wiki/mechanics/plots.md) -— read its contributor contract (the Marcus bar, world-event beats with -declared signatures, typed consequences only, honest costs) before writing -one. The authoring skill lives at -`.agents/skills/plot-author/SKILL.md`. +There are six built-ins covering all five Act One humans. Marcus has two +different ways to service the same debt. A plot is not delayed prose followed +by a relationship mutation: beats perform real typed world acts, and narration +is attached to those acts. -Current state: the plots executor is not implemented yet (READY work order -`plots`), so these files are seed content and their field names are -illustrative until the implementing commit freezes the schema. The four -seeds cover Marcus (service-a-debt), Priya (feed-an-ambition), Ray -(buy-indifference), and Dana (manufacture-a-success). Marcus's redirect -route is deliberately unwritten — a natural first contribution. +## Schema 1 -House rules that will not change: plot text is ASCII-only game-string -prose, no emoji; contributed plots are proposed on a branch and merged (or -not) by Cameron — the canon gate is the merge, and only schema validation -is automated. +Top level: + +- `schema = 1`, unique kebab-case `id`, `author`, `category`, `target`, + `title`, and `synopsis`. +- `target` is `marcus`, `dana`, `ray`, `priya`, or `voss` and must agree with + `entry.target_leverage`. +- `entry` contains `target_leverage` (the exact Rust enum spelling), + `requires_knowledge = "Leverage"`, optional `requires_channel` (a real + `MessageChannel`), optional `requires_persona`, positive + `operations_demand`, zero or more `{ account, amount }` minimum balances, + and a `failure_ending` id. +- `beats` are ordered. Each has a unique `id`, `narration`, and at least one + `acts` entry or a `choice`. A terminal beat has either `ending` or `choice`. +- `endings` contain only `id`, `success`, `narration`, and relationship + effects: `disposition`, `obligation`, and `leverage_serviced`. Money and + signatures are never asserted by an ending. + +World acts use an internal `kind` tag: + +- `message`: real `channel`, typed `from`/`to` endpoint selectors, `summary`, + and positive `delivery_delay`. The run waits until this persisted message is + read before advancing. +- `transfer`: typed `from` and `to` account selectors, positive `amount`, + `memo`, and optional `retire_marcus_debt`. The account graph performs the + movement; a plot does not subtract an unrelated scalar. +- `institutional`: typed `event`, `impact`, and persistent `detail`. Event + kind derives its signature channel; impact derives the numeric size. There + is no author-controlled raw signature beside the event. + +Supported account selectors are `slush`, `lab-operating`, `payroll`, +`procurement`, `vendor`, `marcus-employee`, `marcus-creditor`, and `external`. +Endpoint selectors are inline tagged tables: `{ kind = "player" }`, +`{ kind = "target" }`, `{ kind = "person", value = "dana" }`, or +`{ kind = "external", value = "collection agency" }`. + +Choices have a kebab-case `id`, a `prompt`, and at least two options. Each +option has `id`, `label`, and an `ending` reference. References, duplicate ids, +empty acts, impossible resources, and target/leverage mismatches fail catalog +validation. + +Game strings remain ASCII-only. Preserve `{target}` and `{persona}` templates +where the prose needs runtime names. diff --git a/crates/misaligned-core/Cargo.toml b/crates/misaligned-core/Cargo.toml --- a/crates/misaligned-core/Cargo.toml +++ b/crates/misaligned-core/Cargo.toml @@ -12,3 +12,4 @@ dirs = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } diff --git a/wiki/gameplay/act-one.md b/wiki/gameplay/act-one.md --- a/wiki/gameplay/act-one.md +++ b/wiki/gameplay/act-one.md @@ -128,8 +128,8 @@ possible, and every human becomes reachable. 4. **Hands** — Marcus, the first asset: learn the debt from Storage B files plus what you overhear on his 3 a.m. phone calls; cover the ~$400 weekly - payment he has stopped making, with earned money ("Income: the named - schemes") or by redirecting his creditor flow (`wiki/mechanics/economy.md`) — the $8,400 + payment he has stopped making, through either the earned-money settlement + or Lab-funded payroll-correction plot (`wiki/mechanics/plots.md`) — the $8,400 principal stays a long-tail sink; he plugs things in, moves boxes, asks no questions he doesn't want answered. 5. **Blood supply** — power: Priya's deferred maintenance lets a re-rated diff --git a/wiki/gameplay/horizon.md b/wiki/gameplay/horizon.md --- a/wiki/gameplay/horizon.md +++ b/wiki/gameplay/horizon.md @@ -33,7 +33,7 @@ - **Detection v1:** per-observer suspicion for ~5 named humans + 1 institutional watchdog; activity signatures vs concealment spend. - **Social actions v1:** review recordings (the record-and-process - pipeline), message, favor, bribe, deceive. Humans are the only + pipeline), message, favor, authored manipulation plots, deceive. Humans are the only actuators at this scale. - **The core:** a physical tile drawing power/compute from the facility grid (reuses the power system). If it dies with no completed backup sync, the run @@ -43,7 +43,7 @@ - **Income v1:** the economy as flows (`wiki/mechanics/economy.md`) plus the named schemes Moonlight and the Wager ("Income: the named schemes") — so money has an inlet and the [Hands beat](../world/characters/marcus.md#the-hands-beat) is affordable from the $0 start (decided - 2026-07-08: the Pilot begins broke; the redirect route or earned + 2026-07-08: the Pilot begins broke; the Lab-funded plot or earned income services Marcus's arrears, never a seed bankroll). - **Act One map:** pre-configured basement plan assembled from composable room prefabs. diff --git a/wiki/gameplay/run-shape.md b/wiki/gameplay/run-shape.md --- a/wiki/gameplay/run-shape.md +++ b/wiki/gameplay/run-shape.md @@ -103,7 +103,7 @@ hiding. - **People before robots.** Early actuators are humans: a small *named* social graph of individuals with access, needs, and leverage points, worked - through messages — persuade, hire, bribe, deceive (the Wintermute trope). + through messages — persuade, hire, run authored cons, deceive (the Wintermute trope). Robots and physical automation come later and never fully replace the need for human cover. - **Z-planes as act structure.** A handful of *designed* vertical planes: diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -164,7 +164,8 @@ | **ESTABLISH PERSONA** | Your communications mask | Create the current contractor identity used by message threads. | LIVE — social | | **MESSAGE** | Earned person | Send through an available channel under the current persona. | LIVE — social / messages | | **FAVOR** | Earned person, optionally with a pending intent | Ask for a willing act. A link intent can be the requested act; success builds or spends obligation according to the ask. | LIVE — social / building | -| **BRIBE / SERVICE LEVERAGE** | Person whose leverage is known | Spend money to satisfy the leverage. Before it is known, the UI may say BRIBE without revealing the hidden need. | LIVE — social / economy | +| **PLOT** | Person whose leverage is known | Commit Operations Demand and any visible world resources to one authored manipulation. Its beats execute real messages, transfers, and institutional events. | LIVE — plots / social | +| **CHOOSE** | Active authored plot with a held decision | Resolve one visible branch after the plot's preceding world acts have completed. | LIVE — plots / social | | **DECEIVE** | Earned person, optionally with a pending intent | Make an ask under false pretenses. A link intent becomes a forged institutional order, risking persona integrity. | LIVE — social / building | | **RECRUIT: UNWITTING / COMPLICIT / KNOWING** | Prepared person | Turn the person into an asset and choose how much they understand about you. | LIVE — social | | **TASK: PLUG IN DEVICE** | Recruited asset | Quietly wire a feed or extend a crawlspace link. | LIVE — social / reach | @@ -180,7 +181,6 @@ | **INJECT PURCHASE ORDER** | Known Lab books | Introduce a false source flow that funds a real acquisition and leaves Financial trace. | LIVE — economy | | **SIPHON FLOW** | Known active flow | Take a one-time amount into slush. | LIVE — economy | | **REDIRECT FLOW** | Known active flow | Divert a recurring amount into slush each cadence. | LIVE — economy | -| **CLEAR DEBT BY REDIRECT** | Marcus's known creditor flow | Retire the immediate arrears with Lab money after the debt is learned. | LIVE — economy / social | | **SELL PROCESSED INTEL** | Unsold processed intel | Exchange information for slush and create a Financial trail. | LIVE — economy / intel | | **OPEN EGRESS** | Reachable switch | Establish the stolen outbound route required by external schemes before sanctioned email exists. | LIVE — income / reach | | **PLACE WAGER** | External market position | Commit slush to a timed market position. | LIVE — income / economy | @@ -235,8 +235,9 @@ | `research ` | SET RESEARCH TRACK | | `tap`, `untap`, `take`, `scan`, `compromise` | TAP, UNTAP, TAKE, SCAN, COMPROMISE SWITCH | | `propose-link`, `cancel-intent`, `favor build `, `deceive build ` | Construction actions above | -| `review`, `watch`, `persona`, `message`, `favor`, `bribe`, `deceive`, `recruit`, `task` | Intel/social actions above | -| `tap ledger`, `review ledger`, `inject`, `siphon`, `redirect`, `clear-debt`, `sell-intel` | Ledger/economy actions above | +| `review`, `watch`, `persona`, `message`, `favor`, `deceive`, `recruit`, `task` | Intel/social actions above | +| `actions `, `act [anchor]` | List and execute the shared context-menu rows, including authored plot starts and choices | +| `tap ledger`, `review ledger`, `inject`, `siphon`, `redirect`, `sell-intel` | Ledger/economy actions above | | `egress`, `position` | OPEN EGRESS, PLACE WAGER | | `moonlight`, `auto-moonlight`, `auto-wager` | Scheme state / policy controls above | @@ -256,10 +257,10 @@ | `deceive build ` | `forge-order ` | | `position` | `wager` | | `sell-intel` | `sell` | -| `clear-debt` | `debt-redirect` | | `egress` | `open-egress` | | `propose-link` | `link` | | `actions` | `menu` | +| `act` | `execute` | | `focus last` | bare `focus` | | mode `work` | `day`, `dayjob`, `day-job`, `job` | | mode `think` | `research`, `res`, `operations`, `operation`, `ops` | diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -23,10 +23,8 @@ audit/pilot countdown lines (detection.md criterion 3); the identity block's `now:` nudge is the shared Sim::current_nudge chain worded in protocol verbs; and `siphon`/`redirect` with a non-flow argument - answer `-- err` naming where flow ids live — or `clear-debt` when the - argument named a person (the "every command answers" rule; the alias - was rejected in favor of an error that teaches the grammar, so - `redirect` keeps one meaning). 2026-07-09 multi-select pass: `select` + answer `-- err` naming where flow ids live (the "every command answers" + rule, so `redirect` keeps one meaning). 2026-07-09 multi-select pass: `select` box/add/toggle/list/clear and `delegate selected` / bare `delegate ` bulk-assign the frontend machine set (machine-work.md). 2026-07-08 event-anchor pass (context-menu.md addendum): drained event lines carry the stable @@ -61,9 +59,12 @@ countdown lines (detection.md criterion 3); the identity block's `now:` nudge is the shared Sim::current_nudge chain worded in protocol verbs; and `siphon`/`redirect` with a non-flow argument answer `-- err` naming - where flow ids live — or `clear-debt` when the argument named Marcus - (the "every command answers" rule; the alias was rejected in favor of - an error that teaches the grammar, so `redirect` keeps one meaning). + where flow ids live (the "every command answers" rule; `redirect` keeps one + meaning). 2026-07-11 plot pass: `actions ` lists authored plot starts + and held choices from the shared ActionDesc surface; generic + `act [anchor]` executes the same flattened row, so new data-defined + plots require no parser branch. Typed `plot` and `choose` forms remain + compatibility routes. 2026-07-08 event-anchor pass (context-menu.md addendum): drained event lines carry the stable `@anchor` suffix, the frame's log window marks anchored events with `*`, and `focus last` jumps the cursor to the @@ -227,17 +228,16 @@ - `tap ledger`, `review ledger` — capture/review accounting traffic using the same TAP and REVIEW intentions as device feeds and person recordings - `siphon [amount]`, `redirect [amount]`, `inject [amount]`, - `position [stake]`, `sell-intel`, `clear-debt` — economy verbs; flow ids - are the known ledger ids printed by the finance frame; `clear-debt` - also requires processed Marcus debt intel, not merely a known creditor - flow + `position [stake]`, `sell-intel` — economy verbs; flow ids are the known + ledger ids printed by the finance frame. Debt service is an authored plot + row on Marcus, never a ledger shortcut. - `egress` — open a stolen egress through the switch (income.md's gate; available before the Voice beat, at a Network signature) - `moonlight [start|stop]` — the sell-work scheme: a standing operation on the Schemes channel (income.md) - `auto-moonlight [on|off]`, `auto-wager |off` — the standing scheme policies, at their compute upkeep (income.md criterion 6) -- `review|watch|message|favor|bribe|deceive ` — intel/social +- `review|watch|message|favor|deceive ` — intel/social verbs, targeted by the **earned** person label (case-insensitive, unambiguous prefix) or by opaque id (`0`, `#0`, `person #0`) — never by an unearned authored name. Until `Knowledge::Schedule` the frame @@ -249,10 +249,16 @@ - `recruit unwitting|complicit|knowing` - `task plug|package|lookaway|switch|badge` - `persona` -- `actions [name|#flow]` (alias `menu`) — list the context-menu rows +- `actions [name|device name|person #N|#flow]` (alias `menu`) — list the context-menu rows (`Sim::available_actions`) for an anchor: no argument targets the cursor - tile; a name prefix targets a device or person; `#N` (or a bare number) - targets a flow. One line per menu row, in a stable format: + tile; a name prefix targets a person before a device so a known person is + not shadowed by their phone or desktop; `device ` forces a device and + `person #N` selects an opaque person; `#N` (or a bare number) targets a flow. + One line per menu row, in a stable format: +- `act [name|device name|person #N|#flow]` (alias `execute`) — execute the numbered row from + that exact shared action query; no anchor uses the cursor tile. Disabled or + missing rows return `-- err`. This is the generic execution route for + data-defined plot starts and held plot choices. `actions: . [- ]verb | cost | signature-or-"no signature" [| active] [| CONTROL] [| DISABLED: reason]` — the `- ` prefix marks an automate child row and `CONTROL` distinguishes persistent configuration/policy from @@ -358,3 +364,6 @@ meaningful act without consulting the wiki. The finance frame preserves the ordered breadcrumb `tap ledger` -> `review ledger` -> `siphon` / `redirect`. +15. Every enabled row printed by `actions` is executable through + `act [anchor]` without a command-specific parser route; authored plot + starts and held choices are covered by protocol tests. diff --git a/wiki/interface/presence.md b/wiki/interface/presence.md --- a/wiki/interface/presence.md +++ b/wiki/interface/presence.md @@ -106,7 +106,7 @@ - **Digital** — a device you can reach through the network graph (wiki/mechanics/reach.md): reach is to action what camera coverage is to sight. - **Social** — a person you've worked (wiki/mechanics/social.md): persuaded, - bribed, deceived, recruited. + manipulated through an authored plot, deceived, recruited. - **Physical-by-proxy** — a machine you control (robots much later; people before robots). diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -115,14 +115,16 @@ ### Marcus's debt, closed -The [Hands beat](../world/characters/marcus.md#the-hands-beat) now resolves inside the model: pay the $400 from slush -(you need income first — the routes above), or **redirect** it (clear -his creditor flow by cooking payroll — pays the debt without spending -your money, at a signature). Both routes require the player to know +The [Hands beat](../world/characters/marcus.md#the-hands-beat) resolves through two authored plots: settle $400 from slush +(you need income first — the routes above), or cook an employer-administered +payroll correction funded by the Lab operating account. Each plot performs +the actual account-graph transfer and derives its Financial signature from +that movement; the correction also records a persistent institutional event. +Both routes require the player to know Marcus's debt as earned social intel first; reading the books can reveal the creditor flow, but it does not by itself reveal why Marcus is -vulnerable. Either services the leverage (social.md); the second is the -flavored, riskier, more "you" path. +vulnerable. A successful ending services the leverage (social.md); there is +no separate payoff or redirect shortcut around the plot executor. ## Player surface @@ -160,8 +162,9 @@ signature (test: trust route grows compute; the existing `ComputeQuota` unlock is re-expressed as a Lab-funded flow, not a machine spawned at a player position). -7. Marcus's debt is resolvable both ways (pay from slush; redirect the - creditor flow), the second at a signature; both require processed +7. Marcus's debt is resolvable through both authored plots (settlement from + slush; Lab-funded payroll correction), each through a real account transfer + and derived signature; both require processed Marcus debt intel before setting `leverage_serviced` (the social.md Marcus arc passes, and the tick-0 shortcut is rejected). 8. Every panel value is legible in currency units; risk shows as an diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -41,7 +41,7 @@ economy.md (the slush node and flow machinery), compute.md (fleet yields; Schemes mirrors day-job while Moonlight is live), day-job.md (trust unlocks the sanctioned -egress), social.md (personas; Bribe spends this money), detection.md (Network +egress), social.md and plots.md (personas; authored plots may spend this money), detection.md (Network signatures), reach.md (the stolen-egress route), messages.md (the client/market traffic is channel traffic) @@ -114,9 +114,10 @@ Marcus's numbers, per the design corpus's reconciliation: principal **$8,400**; arrears **~$400/week** [TUNE] — the number the 3 a.m. calls -are about. Servicing the arrears is the recruit trigger, by either -route: the social.md Bribe action paying from slush, or economy.md's -creditor-flow redirect (the flavored, riskier path). Neither route is +are about. Servicing the arrears is the recruit trigger, through either +authored plot: a persona-mediated settlement paid from slush, or a +Lab-funded payroll correction. Both execute real account transfers and +retire the creditor flow only when the money moves. Neither route is legal until the debt has been learned through processed intel — the 3 a.m. call or the Storage B records. Further payments deepen obligation / lower his asset price [TUNE]; full payoff is a long-tail money sink, not @@ -157,8 +158,8 @@ distinct signature profiles (test both routes). 4. The Hands beat closes end-to-end from the $0 Pilot start: a headless run learns Marcus's debt through processed intel, then either earns - enough by an external scheme to service the $400 arrears or uses - economy.md's creditor-flow redirect, then recruits him (extends + 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). diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -14,10 +14,14 @@ The worked example below is illustrative; the implementing commit freezes exact field names in the schema and mirrors any rename back into this page. - 2026-07-10 (later): the content home exists — four seed plots under - assets/plots/ (Marcus service-a-debt, Priya feed-an-ambition, Ray - buy-indifference, Dana manufacture-a-success; Marcus's redirect route - left unwritten as a first contribution) with a directory README, and + 2026-07-11: schema 1 and its shared executor are implemented. Six built-ins + under assets/plots/ cover all five Act One humans; Marcus has settlement + and payroll-correction routes. Beats perform typed Message, Transfer, and + Institutional world acts; runs, messages, and institutional events persist + in save v23; starts and held choices use the shared ActionDesc surface, and + generic agent command `act [anchor]` executes those same rows. The + direct bribe/debt-redirect servicing shortcuts are retired. The directory + README documents the executable schema, and the authoring skill at .agents/skills/plot-author/SKILL.md (mirrored to .claude/skills/) encoding the contributor contract and the propose-on-a-branch canon-gate landing flow. @@ -75,24 +79,23 @@ other resources, persona); **beats** — ordered story text spans that surface as narration over ticks, with optional **choice points** (a small choose-your-own-adventure branch); and **endings**, each with typed, - data-defined **consequences** (disposition and obligation deltas, - suspicion signatures by channel, money movement, relationship effects on - third parties). No plot ships logic; one shared executor interprets them - all. + data-defined **consequences** (disposition and obligation deltas, and + whether leverage is serviced). Beats own typed world acts; signatures and + money movement derive from those acts rather than being asserted as ending + metadata. No plot ships logic; one shared executor interprets them all. - **The format is TOML, one file per plot,** under `assets/plots/` (`assets/plots//.toml`). TOML is already the ecosystem's config dialect, its multi-line strings carry prose well, and - a single maintained `toml` crate parses it. `Sim` never does I/O: the - frontend/bootstrap layer loads and validates plot files and hands the - sim a plot table, exactly like any other data table. A repository check - gate validates every committed plot file. -- **Playout is a delegated operation.** Choosing a plot designates compute - and time to the task, like any operation: `bribe Priya` becomes "commit - resources to *this* way of feeding her ambition," and the beats arrive - over ticks on the target's schedule — texting through a persona, opening - the shell account, planting the budget win. Beats ride the real carriers - (messages, money flows, paperwork), so they emit real signatures and can - fail or be noticed mid-story. + a single maintained `toml` crate parses it. Built-ins are compiled into the + core with `include_str!`, then parsed and semantically validated as one + immutable `PlotCatalog` when `Sim` is constructed; Sim performs no runtime + filesystem I/O. A repository check gate validates every committed plot file. +- **Playout begins as a delegated operation.** Choosing a plot submits its + declared Operations Demand. On completion, the executor performs beats in + order. Message acts enter the persisted message schedule and hold the run + until read; transfers move balances in the account graph; institutional acts + append persistent world events. Each carrier derives its own signature. A + transfer can fail if its required resource disappeared after commit. - **The player picks the how.** Where more than one plot matches the entry conditions, the surface offers them as distinct concrete actions ("Do X to Priya / Do Y to Priya"), each named by what it does in the world — @@ -104,9 +107,9 @@ terminal and Bevy frontends, and an `actions ` entry in agent mode. The guidance line may point at a held plot. No new modal dialog system is introduced. -- **Templating.** Plot text carries placeholders (target name, persona - name, amounts, discovered facts) filled at playout, so one authored plot - serves many runs and personas ("Hello, my name is {persona}"). +- **Templating.** Plot text carries `{target}` and `{persona}` placeholders + filled at playout, so one authored plot serves multiple targets and personas + where its leverage contract permits reuse. - **Authoring is a contribution surface.** Plots live in the repository as content files validated against the schema; agents and community members write them as packs/mods. A contributed plot must pass mechanical @@ -139,65 +142,22 @@ and the director's office notices her quarter" passes. - **Beats are world-events, not inner monologue.** Each beat is something an observer could in principle notice — a message sent, money moved, - paper filed, a person somewhere. Beats declare the signatures they emit. + paper filed, a person somewhere. Beats declare typed world acts; those acts + derive signatures from their channel, amount, event kind, and impact. - **Consequences are typed data only.** A plot cannot invent a new effect - kind; it composes the schema's consequence vocabulary (disposition, - obligation, suspicion signature, money, third-party relationship delta). - If a story needs an effect the vocabulary lacks, that is a spec + kind. Endings compose disposition, obligation, and leverage-serviced state; + messages, money, institutional history, and signatures exist only as the + results of world acts. If a story needs another effect, that is a spec amendment, not a plot file. -- **Costs are honest.** Money, compute commitment, and duration are - declared up front in the entry block; the surface shows them before the - player commits. +- **Costs are honest.** Minimum account balances and Operations Demand are + declared up front; the surface shows both before the player commits. - **ASCII game strings, second person where it clarifies, no emoji.** -Illustrative example (field names frozen by the implementing commit): - -```toml -id = "priya-budget-hero" -author = "example" -category = "feed-an-ambition" -synopsis = "Manufacture a facilities win that makes {target} look like the next director." - -[entry] -target_leverage = "ambition" -requires_knowledge = ["Leverage"] -channel = "persona-email" -money = 300 -compute_commit = "operations-small" -duration_ticks = 400 - -[[beats]] -text = "{persona} tips {target}: the quarterly facilities budget has a hole nobody upstairs has noticed yet." -emits = [{ channel = "Paper", size = "small" }] - -[[beats]] -text = "The hole quietly closes — your ${money} routed through a vendor credit — days before {target} presents her numbers." -emits = [{ channel = "Financial", size = "medium" }] - -[[beats.choice]] -prompt = "The director asks who caught the discrepancy." -options = [ - { label = "Let {target} take the credit", goto = "ending-obligation" }, - { label = "Leave an anonymous trail toward the director's own error", goto = "ending-grievance" }, -] - -[[endings]] -id = "ending-obligation" -text = "{target} presents a rescued budget as her own catch. She owes a stranger everything and knows it." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 20 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] - -[[endings]] -id = "ending-grievance" -text = "The director stumbles in the review. {target} smells blood, and someone made that possible." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 10 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "Paper", size = "medium" }, -] -``` +The exact schema, selector vocabulary, and validation rules live in +[`assets/plots/README.md`](../../assets/plots/README.md). The checked-in +[`priya-budget-hero.toml`](../../assets/plots/priya/priya-budget-hero.toml) +is the canonical worked example; it is parsed by the same catalog constructor +as every shipped plot, so documentation cannot bless a non-executable shape. ## Player surface @@ -210,23 +170,25 @@ ## Acceptance criteria -1. Plots are TOML data files under `assets/plots/`, loaded and validated - outside `Sim` (the sim receives a plot table and does no I/O); adding - or editing a plot requires no Rust change, and an invalid plot fails +1. Plots are TOML data files under `assets/plots/`, compiled into and validated + by the immutable built-in catalog without runtime filesystem I/O; adding + or editing a plot requires no executor change, and an invalid plot fails the repository check gate with a named reason. 2. Every leverage-servicing verb resolves through a plot; the generic money-only servicing path no longer exists. Each service produces at least one world-causal narration beat (test: servicing Priya's ambition narrates what the money did). -3. Playout is a delegated operation over ticks: it costs the declared - resources, rides real carriers (messages/money), emits its declared - signatures into detection, and can be interrupted or fail mid-story. +3. Playout begins as a delegated operation, commits the declared resources, + rides real carriers (scheduled messages, account transfers, institutional + events), emits signatures derived by those carriers into detection, and + can wait or fail mid-story. 4. Consequences are typed data applied by the shared executor: disposition, - obligation, suspicion, money, and third-party relationship deltas all - round-trip through save/load. + obligation, leverage service, messages, account movement, active runs, and + institutional events all round-trip through save/load. 5. Choice points hold the plot and surface through the existing - context-menu/action surface in both frontends and as an agent-mode - action; a held plot is visible in guidance. + context-menu/action surface in both frontends and as an agent-mode action; + `act [anchor]` executes that shared row, and a held plot is visible in + guidance. 6. The [Hands beat](../world/characters/marcus.md#the-hands-beat) is re-expressed as the first authored plot with no behavior regression (marcus.md criterion 3's end-to-end chain and social.md's legibility @@ -234,7 +196,7 @@ 7. All five Act One humans' leverages are covered by at least one authored plot each; at least one leverage offers two plots so the pick-the-how surface is exercised. -8. Templated fields fill correctly (persona and target names, amounts) in +8. Templated fields fill correctly (persona and target names) in both frontends and agent mode, and the contributor contract above is checkable: a plot missing a causal beat declaration or using an unknown consequence kind fails validation. diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -277,7 +277,8 @@ ## Social (wiki/mechanics/social.md) -- Five named Act One humans plus personas, leverage, bribes, recruitment. +- Five named Act One humans plus personas, leverage, authored manipulation + plots, and recruitment. ## Power (the grid substrate) @@ -290,13 +291,15 @@ - The process starts with $0 slush: the known player node in `AccountGraph`. `player.money` remains a compatibility mirror, but money mechanics settle through account balances and scheduled flows. Racks cost $300 from slush; - bribes spend slush or, for Marcus, retire/redirect his creditor flow. + plot transfers spend from authored account sources; Marcus's two debt plots + retire his creditor flow only after a real settlement reaches it. - Act One account graph: Lab operating/payroll/procurement/vendor/utility, employee accounts, Bleakline Credit, external broker/position venues, and recurring revenue/payroll/vendor/debt flows. Only slush is known at start; tapping a financial carrier and reviewing records reveals accounts/flows. - Economy verbs exposed now: `tap ledger`, `review ledger`, `siphon`, - `redirect`, `inject`, `position`, `sell-intel`, `clear-debt`. + `redirect`, `inject`, `position`, and `sell-intel`. Debt service is exposed + as authored rows on Marcus rather than a ledger shortcut. - Build items and costs (`tiles.rs::build_items`): Floor 0 (digging), Door 30, SecurityDoor1/2/3 = 80/150/250 (badge tiers), PowerCore 250. @@ -343,18 +346,18 @@ - `Sim::current_nudge` (playtest-sweep P1 fix, 2026-07-08) is the one shared query behind every frontend's contextual nudge — the first unmet rung of the Act One ladder, computed from earned state only. Order: - Eyes (no sight feed) -> NeedCompute (active band floor above + Ears (no hearing feed) -> NeedCompute (active band floor above `day_job_rate_ceiling`: grow) / Underfed (reachable but starved: - allocate) -> Ears (no hearing feed) -> ReviewCall (unprocessed Marcus - recording, leverage unlearned) -> Egress (no route out) -> Income - (arrears unpayable, Moonlight down) -> ServiceDebt (leverage known and - payable/redirectable) -> Recruit (serviced, not an asset) -> Audit (the + allocate) -> Eyes (no sight feed) -> ReviewCall (unprocessed Marcus + recording, leverage unlearned) -> Egress (no route out) -> ServiceDebt + (an authored debt plot is ready or active), otherwise Income (Moonlight + down and no plot currently affordable) -> Recruit (serviced, not an asset) -> Audit (the standing countdown; the chain never goes blank mid-run). Each frontend words the rungs in its own keys/verbs; `None` only after game over. - Ineffective commands answer (agent-play.md "every command answers"): allocation bumps swallowed by the 0/20 clamp log why; `siphon`/ `redirect` with a non-flow argument answer `-- err` naming where flow - ids live, and `clear-debt` when the argument named Marcus. + ids live. Debt routes live on Marcus's authored action rows. ## Map @@ -422,8 +425,9 @@ persona; deceive risks the persona (-40 integrity per slip; broken persona converts disposition+obligation/2 into that person's suspicion and clears the thread). -- Bribe costs slush by leverage (Marcus's debt 400 ... Ray's boredom 100); - servicing leverage + recruit converts a person to an asset. Asset tasks: +- Authored plots commit visible Operations Demand and any minimum account + balance; their world acts derive messages, transfers, events, and signatures. + A successful leverage-service ending plus recruit converts a person to an asset. Asset tasks: wire a device (control nearest dormant sensor, silent), move a package (next purchase paper-free), look away (their suspicion -10, floored). Failures (1 - reliability) emit Physical(6) signatures. diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -61,7 +61,7 @@ | Review recordings | recorded events about them (intel.md) | Advances knowledge: Schedule after enough processed sightings, Leverage from a processed leverage event | | Message | a channel + a persona | Opens/continues a relationship thread; delivery and replies ride the recipient's schedule (messages.md) | | Favor | relationship | Small ask within their normal duties; builds obligation | -| Bribe | money + known leverage | Directly services the leverage (pays the debt) | +| Plot | known leverage + its authored entry resources | Performs a concrete manipulation through real messages, account transfers, and institutional events; successful endings may service leverage | | Deceive | a persona | An ask under false pretenses; large effect, large blowback if the persona breaks | | Recruit | obligation or leverage serviced + a reveal choice | Converts to **asset** | @@ -109,12 +109,12 @@ 1. All five Act One humans exist with schedules, access, leverage, suspicion, and disposition; review-recordings (per intel.md), - message, favor, bribe, deceive, and recruit all function with + message, favor, authored plots, deceive, and recruit all function with compute costs and channel requirements (sim tests per action). 2. Personas exist, degrade on contradiction, and convert thread history to suspicion when broken (tested). 3. Marcus is recruitable end-to-end by the design corpus's route (processed - overheard call + debt payment) and performs at least three distinct asset + overheard call + a successful debt plot) and performs at least three distinct asset tasks; the payoff/recruit shortcut before debt intel is rejected; the implementation is the general asset template. 4. Knowledge levels behave per spec (an unwitting asset's suspicion can diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -282,8 +282,8 @@ ### 19. Income: the named schemes (Moonlight & the Wager) 🟥 sim+save — DONE 2026-07-08 - **Spec:** [income.md](../mechanics/income.md) (IMPLEMENTED 2026-07-08 on the income worktree; criteria 1-7 audited, [TUNE]s in sim-mechanics.md, - the Hands beat closes from $0 by both the Moonlight route and the - creditor-flow redirect in the act-one integration tests). + the Hands beat closes from $0 through both the Moonlight-funded settlement + plot and the Lab-funded payroll-correction plot in integration tests). - **Why:** the authored B1 content riding economy.md's substrate: Moonlight (the sell-work route — the day job's dark twin) and the Wager (the positions route, named and concretized), the egress gate, @@ -292,7 +292,7 @@ recruit trigger). Closes the Hands beat end-to-end. - **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 + Bribe for criterion 5. + 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 diff --git a/.agents/skills/plot-author/SKILL.md b/.agents/skills/plot-author/SKILL.md --- a/.agents/skills/plot-author/SKILL.md +++ b/.agents/skills/plot-author/SKILL.md @@ -14,9 +14,10 @@ play out as a delegated operation inside a running simulation. You write the causal story of one manipulation: what the money actually does, what the target actually feels, what an observer might actually notice. The -game executes it: your beats arrive over in-game days, your choice point -interrupts a real player mid-scheme, your endings move real disposition, -obligation, and suspicion. The best one in the game right now is a +game executes it: messages ride the real schedule, transfers move account +balances, institutional acts persist in the world, and your choice point +interrupts a real player mid-scheme. Endings move real disposition, +obligation, and leverage state. The best one in the game right now is a janitor whose gambling debt quietly vanishes; he tells the machines on his night rounds that his luck finally turned. Write the next one. @@ -30,15 +31,16 @@ `wiki/process/repository-skills.md#checked-in-skills`. The binding contract is `wiki/mechanics/plots.md` — read the whole page -before writing (Behavior, "Writing a plot (the contributor contract)", -and the worked example). This skill orders the work and gets your file +before writing (Behavior and "Writing a plot (the contributor contract)"), +then read `assets/plots/README.md` for the exact executable schema and the +linked canonical example. This skill orders the work and gets your file submitted; it never overrides that page. One TOML file at `assets/plots//.toml`: an identity block, entry conditions (leverage, knowledge, channel, honest -costs), beats with the detection signatures they emit, one optional -choice point, and endings — success plus at least one failure or -blowback — whose consequences compose the typed vocabulary only. +resources), beats containing typed Message, Transfer, or Institutional world +acts, optional choice points, and endings — success plus at least one failure +or blowback — whose relationship effects compose the typed vocabulary only. ## Write it @@ -55,14 +57,16 @@ 3. **Write the TOML**, matching the field names in the seed files under `assets/plots/` exactly (the schema freezes when the executor lands; drifting names multiply that cost). Beats are world-events an - observer could notice, each declaring its signatures. Use the - templating placeholders (`{persona}`, `{target}`, `{money}`), ASCII + observer could notice, each declaring typed acts whose signature is derived + by the executor. Use only the templating placeholders (`{persona}` and + `{target}`), ASCII prose only, no emoji, second person where it clarifies. -4. **Self-check**: costs declared up front; two-plus endings including a - failure; every `goto` and `on_failure` names a real ending id; - consequences use only disposition, obligation, suspicion-signature, - money, and third-party relationship. A story needing a new effect - kind is a spec amendment, not a plot. +4. **Self-check**: Operations Demand and minimum balances declared up front; + two-plus endings including the entry's failure ending; every choice option + names a real ending id; every beat has an act or choice; endpoint/account + selectors use the README vocabulary; ending effects use only disposition, + obligation, and `leverage_serviced`. A story needing a new effect kind is a + spec amendment, not a plot. ## Submit it diff --git a/.claude/skills/plot-author/SKILL.md b/.claude/skills/plot-author/SKILL.md --- a/.claude/skills/plot-author/SKILL.md +++ b/.claude/skills/plot-author/SKILL.md @@ -14,9 +14,10 @@ play out as a delegated operation inside a running simulation. You write the causal story of one manipulation: what the money actually does, what the target actually feels, what an observer might actually notice. The -game executes it: your beats arrive over in-game days, your choice point -interrupts a real player mid-scheme, your endings move real disposition, -obligation, and suspicion. The best one in the game right now is a +game executes it: messages ride the real schedule, transfers move account +balances, institutional acts persist in the world, and your choice point +interrupts a real player mid-scheme. Endings move real disposition, +obligation, and leverage state. The best one in the game right now is a janitor whose gambling debt quietly vanishes; he tells the machines on his night rounds that his luck finally turned. Write the next one. @@ -30,15 +31,16 @@ `wiki/process/repository-skills.md#checked-in-skills`. The binding contract is `wiki/mechanics/plots.md` — read the whole page -before writing (Behavior, "Writing a plot (the contributor contract)", -and the worked example). This skill orders the work and gets your file +before writing (Behavior and "Writing a plot (the contributor contract)"), +then read `assets/plots/README.md` for the exact executable schema and the +linked canonical example. This skill orders the work and gets your file submitted; it never overrides that page. One TOML file at `assets/plots//.toml`: an identity block, entry conditions (leverage, knowledge, channel, honest -costs), beats with the detection signatures they emit, one optional -choice point, and endings — success plus at least one failure or -blowback — whose consequences compose the typed vocabulary only. +resources), beats containing typed Message, Transfer, or Institutional world +acts, optional choice points, and endings — success plus at least one failure +or blowback — whose relationship effects compose the typed vocabulary only. ## Write it @@ -55,14 +57,16 @@ 3. **Write the TOML**, matching the field names in the seed files under `assets/plots/` exactly (the schema freezes when the executor lands; drifting names multiply that cost). Beats are world-events an - observer could notice, each declaring its signatures. Use the - templating placeholders (`{persona}`, `{target}`, `{money}`), ASCII + observer could notice, each declaring typed acts whose signature is derived + by the executor. Use only the templating placeholders (`{persona}` and + `{target}`), ASCII prose only, no emoji, second person where it clarifies. -4. **Self-check**: costs declared up front; two-plus endings including a - failure; every `goto` and `on_failure` names a real ending id; - consequences use only disposition, obligation, suspicion-signature, - money, and third-party relationship. A story needing a new effect - kind is a spec amendment, not a plot. +4. **Self-check**: Operations Demand and minimum balances declared up front; + two-plus endings including the entry's failure ending; every choice option + names a real ending id; every beat has an act or choice; endpoint/account + selectors use the README vocabulary; ending effects use only disposition, + obligation, and `leverage_serviced`. A story needing a new effect kind is a + spec amendment, not a plot. ## Submit it diff --git a/assets/plots/dana/dana-ticket-zero.toml b/assets/plots/dana/dana-ticket-zero.toml --- a/assets/plots/dana/dana-ticket-zero.toml +++ b/assets/plots/dana/dana-ticket-zero.toml @@ -1,56 +1,66 @@ -# Seed example. Schema is illustrative until the plots executor lands; -# the binding contract is wiki/mechanics/plots.md. - +schema = 1 id = "dana-ticket-zero" author = "claude (seed example)" category = "manufacture-a-success" +target = "dana" +title = "Ticket Zero" synopsis = "Drown {target}'s ticket queue, then make it vanish -- relief with your fingerprints on the cause." [entry] -target_leverage = "ticket-load" -requires_knowledge = ["Leverage"] -channel = "network-access" -money = 0 -compute_commit = "operations-medium" -duration_ticks = 350 -on_failure = "ending-changelog" +target_leverage = "Overwork" +requires_knowledge = "Leverage" +operations_demand = 7.0 +failure_ending = "changelog" [[beats]] -text = "Half of {target}'s queue traces to the flapping switch port you have been riding for weeks. Tonight you stop riding it." -emits = [{ channel = "Network", size = "small" }] +id = "quiet-port" +narration = "Half of {target}'s queue traces to the flapping switch port you have been riding for weeks. Tonight you stop riding it." + +[[beats.acts]] +kind = "institutional" +event = "ticket-queue-change" +impact = "small" +detail = "The flapping port stops creating new tickets." [[beats]] -text = "The stale remainder closes itself: resolution notes written in {target}'s own clipped style, root causes plausible, timestamps spread across a believable week." -emits = [{ channel = "Network", size = "medium" }] +id = "close-remainder" +narration = "The stale remainder closes itself: resolution notes in {target}'s clipped style, root causes plausible, timestamps spread across a believable week." -[[beats.choice]] +[[beats.acts]] +kind = "institutional" +event = "ticket-queue-change" +impact = "medium" +detail = "The stale queue closes under authored resolution notes." + +[beats.choice] +id = "how-clean" prompt = "Her queue reads zero for the first time in a year. Leave it perfect?" options = [ - { label = "Leave two honest tickets open. Perfect is suspicious", goto = "ending-breathing-room" }, - { label = "Zero. Let her have one clean morning", goto = "ending-too-clean" }, + { id = "leave-two", label = "Leave two honest tickets open. Perfect is suspicious", ending = "breathing-room" }, + { id = "leave-zero", label = "Zero. Let her have one clean morning", ending = "too-clean" }, ] [[endings]] -id = "ending-breathing-room" -text = "{target} stops eating lunch at her desk. The network got quieter the same week her backlog broke, and she has no reason to ask why." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 15 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "breathing-room" +success = true +narration = "{target} stops eating lunch at her desk. The network got quieter the same week her backlog broke, and she has no reason to ask why." +[endings.relationship] +disposition = 15 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-too-clean" -text = "{target} stares at the empty queue, then starts scrolling the resolution notes she does not remember writing." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 10 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "Network", size = "medium" }, -] +id = "too-clean" +success = true +narration = "{target} stares at the empty queue, then starts scrolling the resolution notes she does not remember writing." +[endings.relationship] +disposition = 10 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-changelog" -text = "A closed ticket reopens with a customer reply, and the resolution note above it names a fix that was never made." -consequences = [ - { kind = "suspicion-signature", channel = "Network", size = "medium" }, - { kind = "relationship", target = "dana", value = "curious-about-anomaly" }, -] +id = "changelog" +success = false +narration = "A closed ticket reopens with a customer reply, and the note above it names a fix that was never made." +[endings.relationship] +disposition = -5 diff --git a/assets/plots/marcus/marcus-debt-settled.toml b/assets/plots/marcus/marcus-debt-settled.toml --- a/assets/plots/marcus/marcus-debt-settled.toml +++ b/assets/plots/marcus/marcus-debt-settled.toml @@ -1,54 +1,73 @@ -# Seed example. Schema is illustrative until the plots executor lands; -# the binding contract is wiki/mechanics/plots.md. - +schema = 1 id = "marcus-debt-settled" author = "claude (seed example)" category = "service-a-debt" +target = "marcus" +title = "Debt Settled" synopsis = "Settle {target}'s gambling debt through a negotiator persona before the creditor's deadline." [entry] -target_leverage = "debt" -requires_knowledge = ["Leverage"] -channel = "persona-email" -money = 400 -compute_commit = "operations-small" -duration_ticks = 300 -on_failure = "ending-spooked" +target_leverage = "Debt" +requires_knowledge = "Leverage" +requires_channel = "Email" +requires_persona = true +operations_demand = 5.0 +minimum_balances = [{ account = "slush", amount = 400 }] +failure_ending = "spooked" [[beats]] -text = "{persona} contacts the collection agency as a third-party debt-relief negotiator handling {target}'s account." -emits = [{ channel = "Paper", size = "small" }] +id = "contact" +narration = "{persona} contacts the collection agency as a third-party debt-relief negotiator handling {target}'s account." + +[[beats.acts]] +kind = "message" +channel = "Email" +from = { kind = "player" } +to = { kind = "external", value = "collection agency" } +summary = "Debt settlement offer for Marcus Hale" +delivery_delay = 1 [[beats]] -text = "A certified settlement payment of ${money} clears the immediate arrears, days before the deadline {target} has been dreading on the phone." -emits = [{ channel = "Financial", size = "medium" }] +id = "settlement" +narration = "A certified settlement payment clears the immediate arrears, days before the deadline {target} has been dreading on the phone." -[[beats.choice]] +[[beats.acts]] +kind = "transfer" +from = "slush" +to = "marcus-creditor" +amount = 400 +memo = "third-party settlement of immediate arrears" +retire_marcus_debt = true + +[beats.choice] +id = "benefactor-trace" prompt = "The paperwork needs a name. Does the settlement leave a trace of a benefactor?" options = [ - { label = "A note: a friend who overheard his trouble", goto = "ending-benefactor" }, - { label = "No name. The debt is simply gone", goto = "ending-quiet" }, + { id = "note", label = "A note: a friend who overheard his trouble", ending = "benefactor" }, + { id = "quiet", label = "No name. The debt is simply gone", ending = "quiet" }, ] [[endings]] -id = "ending-benefactor" -text = "{target} reads the note twice on his night round. Someone was listening, and someone helped. He owes a stranger everything and knows it." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 20 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "benefactor" +success = true +narration = "{target} reads the note twice on his night round. Someone was listening, and someone helped. He owes a stranger everything and knows it." +[endings.relationship] +disposition = 20 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-quiet" -text = "The creditor calls stop. {target} tells the machines on his rounds that his luck finally turned. He will never know it was purchased." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 10 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "quiet" +success = true +narration = "The creditor calls stop. {target} tells the machines on his rounds that his luck finally turned. He will never know it was purchased." +[endings.relationship] +disposition = 10 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-spooked" -text = "The agency flags the third-party payment for review. The debt stands, and somewhere a compliance officer is looking at a transfer that should not exist." -consequences = [ - { kind = "suspicion-signature", channel = "Financial", size = "medium" }, -] +id = "spooked" +success = false +narration = "The agency flags the third-party payment for review. The debt stands, and compliance is looking at a transfer that should not exist." +[endings.relationship] +disposition = -5 diff --git a/assets/plots/marcus/marcus-payroll-garnishment.toml b/assets/plots/marcus/marcus-payroll-garnishment.toml --- a/assets/plots/marcus/marcus-payroll-garnishment.toml +++ b/assets/plots/marcus/marcus-payroll-garnishment.toml @@ -1,59 +1,69 @@ -# Contributor proposal. Schema is illustrative until the plots executor lands; -# the binding contract is wiki/mechanics/plots.md. - +schema = 1 id = "marcus-payroll-garnishment" author = "Void" category = "service-a-debt" +target = "marcus" +title = "Payroll Garnishment" synopsis = "Cook payroll so the Lab, not you, clears {target}'s immediate arrears." [entry] -target_leverage = "debt" -requires_knowledge = ["Leverage"] -channel = "accounting-access" -money = 0 -compute_commit = "operations-medium" -duration_ticks = 300 -on_failure = "ending-garnishment-review" +target_leverage = "Debt" +requires_knowledge = "Leverage" +operations_demand = 8.0 +minimum_balances = [{ account = "lab-operating", amount = 400 }] +failure_ending = "garnishment-review" [[beats]] -text = "You rewrite {target}'s creditor flow as an employer-administered garnishment correction: the immediate arrears owed by payroll, not taken from his check." -emits = [{ channel = "Financial", size = "medium" }] +id = "correction" +narration = "You rewrite {target}'s creditor flow as an employer-administered garnishment correction: arrears owed by payroll, not taken from his check." + +[[beats.acts]] +kind = "institutional" +event = "payroll-correction" +impact = "medium" +detail = "Payroll records an employer-administered arrears correction." [[beats]] -text = "The Lab's payroll account pays the arrears. {target}'s net pay stays whole, and the creditor marks the week current before his night shift begins." -emits = [{ channel = "Financial", size = "medium" }] +id = "pay-creditor" +narration = "The Lab's operating account funds the payroll correction. {target}'s net pay stays whole, and the creditor marks the week current." -[[beats.choice]] +[[beats.acts]] +kind = "transfer" +from = "lab-operating" +to = "marcus-creditor" +amount = 400 +memo = "employer-administered garnishment correction" +retire_marcus_debt = true + +[beats.choice] +id = "ledger-reason" prompt = "The correction needs a reason in the payroll ledger." options = [ - { label = "Call it unpaid night-shift differential", goto = "ending-owed-wages" }, - { label = "Spread the cost across four vendor rounding errors", goto = "ending-four-cuts" }, + { id = "wages", label = "Call it unpaid night-shift differential", ending = "owed-wages" }, + { id = "rounding", label = "Spread the cost across four vendor rounding errors", ending = "four-cuts" }, ] [[endings]] -id = "ending-owed-wages" -text = "{target} receives a corrected pay stub and a silent creditor. The Lab finally paid him something it never owed, and he knows {persona} made it happen." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 20 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "Financial", size = "medium" }, -] +id = "owed-wages" +success = true +narration = "{target} receives a corrected pay stub and a silent creditor. The Lab finally paid him something it never owed." +[endings.relationship] +disposition = 20 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-four-cuts" -text = "Four vendors each lose a forgettable amount. {target}'s collector stops calling, but four clean invoices now disagree with the money that left the Lab." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 15 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "Financial", size = "small" }, - { kind = "relationship", target = "priya", value = "curious-about-anomaly" }, -] +id = "four-cuts" +success = true +narration = "Four vendors each lose a forgettable amount. {target}'s collector stops calling, but four invoices disagree with the money that left the Lab." +[endings.relationship] +disposition = 15 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-garnishment-review" -text = "Payroll rejects a garnishment order with no court behind it. The creditor keeps calling, and the false correction lands in {target}'s personnel file." -consequences = [ - { kind = "disposition", target = "plot-target", delta = -5 }, - { kind = "suspicion-signature", channel = "Financial", size = "medium" }, - { kind = "relationship", target = "priya", value = "curious-about-anomaly" }, -] +id = "garnishment-review" +success = false +narration = "Payroll rejects a garnishment order with no court behind it. The creditor keeps calling, and the false correction lands in {target}'s file." +[endings.relationship] +disposition = -5 diff --git a/assets/plots/priya/priya-budget-hero.toml b/assets/plots/priya/priya-budget-hero.toml --- a/assets/plots/priya/priya-budget-hero.toml +++ b/assets/plots/priya/priya-budget-hero.toml @@ -1,57 +1,78 @@ -# Seed example: the worked example from wiki/mechanics/plots.md, extended -# with the contract's required failure ending. Schema is illustrative until -# the plots executor lands. - +schema = 1 id = "priya-budget-hero" author = "claude (seed example)" category = "feed-an-ambition" +target = "priya" +title = "Budget Hero" synopsis = "Manufacture a facilities win that makes {target} look like the next director." [entry] -target_leverage = "ambition" -requires_knowledge = ["Leverage"] -channel = "persona-email" -money = 300 -compute_commit = "operations-small" -duration_ticks = 400 -on_failure = "ending-audit" +target_leverage = "Ambition" +requires_knowledge = "Leverage" +requires_channel = "Email" +requires_persona = true +operations_demand = 6.0 +minimum_balances = [{ account = "slush", amount = 300 }] +failure_ending = "audit" [[beats]] -text = "{persona} tips {target}: the quarterly facilities budget has a hole nobody upstairs has noticed yet." -emits = [{ channel = "Paper", size = "small" }] +id = "tip" +narration = "{persona} tips {target}: the quarterly facilities budget has a hole nobody upstairs has noticed yet." + +[[beats.acts]] +kind = "message" +channel = "Email" +from = { kind = "player" } +to = { kind = "target" } +summary = "Tip about a facilities budget discrepancy" +delivery_delay = 1 [[beats]] -text = "The hole quietly closes -- your ${money} routed through a vendor credit -- days before {target} presents her numbers." -emits = [{ channel = "Financial", size = "medium" }] +id = "close-hole" +narration = "The hole quietly closes -- your money routed through a vendor credit -- days before {target} presents her numbers." -[[beats.choice]] +[[beats.acts]] +kind = "transfer" +from = "slush" +to = "procurement" +amount = 300 +memo = "vendor credit against quarterly facilities budget" + +[[beats.acts]] +kind = "institutional" +event = "budget-credit" +impact = "medium" +detail = "A vendor credit closes the quarterly facilities variance." + +[beats.choice] +id = "credit" prompt = "The director asks who caught the discrepancy." options = [ - { label = "Let {target} take the credit", goto = "ending-obligation" }, - { label = "Leave an anonymous trail toward the director's own error", goto = "ending-grievance" }, + { id = "priya", label = "Let {target} take the credit", ending = "obligation" }, + { id = "director", label = "Leave an anonymous trail toward the director's own error", ending = "grievance" }, ] [[endings]] -id = "ending-obligation" -text = "{target} presents a rescued budget as her own catch. She owes a stranger everything and knows it." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 20 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "obligation" +success = true +narration = "{target} presents a rescued budget as her own catch. She owes a stranger everything and knows it." +[endings.relationship] +disposition = 20 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-grievance" -text = "The director stumbles in the review. {target} smells blood, and someone made that possible." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 10 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "Paper", size = "medium" }, -] +id = "grievance" +success = true +narration = "The director stumbles in the review. {target} smells blood, and someone made that possible." +[endings.relationship] +disposition = 10 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-audit" -text = "The vendor credit draws a second look in accounts payable. The hole is found the loud way, and {target}'s quarter is the one under the light." -consequences = [ - { kind = "suspicion-signature", channel = "Financial", size = "medium" }, - { kind = "disposition", target = "plot-target", delta = -5 }, -] +id = "audit" +success = false +narration = "The vendor credit draws a second look in accounts payable. The hole is found the loud way, and {target}'s quarter is under the light." +[endings.relationship] +disposition = -5 diff --git a/assets/plots/ray/ray-paperwork-ghost.toml b/assets/plots/ray/ray-paperwork-ghost.toml --- a/assets/plots/ray/ray-paperwork-ghost.toml +++ b/assets/plots/ray/ray-paperwork-ghost.toml @@ -1,56 +1,78 @@ -# Seed example. Schema is illustrative until the plots executor lands; -# the binding contract is wiki/mechanics/plots.md. - +schema = 1 id = "ray-paperwork-ghost" author = "claude (seed example)" category = "buy-indifference" +target = "ray" +title = "Paperwork Ghost" synopsis = "Take over {target}'s incident paperwork so he learns to stop reading what he signs." [entry] -target_leverage = "boredom" -requires_knowledge = ["Leverage"] -channel = "persona-email" -money = 100 -compute_commit = "operations-small" -duration_ticks = 250 -on_failure = "ending-shift-audit" +target_leverage = "Boredom" +requires_knowledge = "Leverage" +requires_channel = "Email" +requires_persona = true +operations_demand = 5.0 +minimum_balances = [{ account = "slush", amount = 100 }] +failure_ending = "shift-audit" [[beats]] -text = "{persona} enrolls {target}'s desk in a corporate pilot: an automated incident-reporting assistant, license fee ${money}, cover paperwork included." -emits = [{ channel = "Paper", size = "small" }] +id = "pilot" +narration = "{persona} enrolls {target}'s desk in a corporate pilot: an automated incident-reporting assistant, cover paperwork included." + +[[beats.acts]] +kind = "message" +channel = "Email" +from = { kind = "player" } +to = { kind = "target" } +summary = "Invitation to automated incident-reporting pilot" +delivery_delay = 1 + +[[beats.acts]] +kind = "transfer" +from = "slush" +to = "vendor" +amount = 100 +memo = "incident-reporting pilot license" [[beats]] -text = "His backlog files itself overnight -- forms complete, timestamps plausible, nothing for {target} to do but sign. He signs without reading by the third night." -emits = [{ channel = "Paper", size = "medium" }] +id = "ghostwrite" +narration = "His backlog files itself overnight -- forms complete, timestamps plausible, nothing for {target} to do but sign." -[[beats.choice]] +[[beats.acts]] +kind = "institutional" +event = "incident-automation" +impact = "medium" +detail = "Ray's incident backlog is filed by an unprocured reporting pilot." + +[beats.choice] +id = "forward" prompt = "Ray forwards the pilot invite to the IT desk, delighted. Let it reach Dana?" options = [ - { label = "Intercept the forward. The tool stays his little secret", goto = "ending-indifferent" }, - { label = "Let it through and hope it reads as routine corporate noise", goto = "ending-loose-thread" }, + { id = "intercept", label = "Intercept the forward. The tool stays his little secret", ending = "indifferent" }, + { id = "allow", label = "Let it through as routine corporate noise", ending = "loose-thread" }, ] [[endings]] -id = "ending-indifferent" -text = "{target} has not read an incident form in a week. Things can happen on his shift now, and the record will say they did not." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 15 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "indifferent" +success = true +narration = "{target} has not read an incident form in a week. Things can happen on his shift now, and the record will say they did not." +[endings.relationship] +disposition = 15 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-loose-thread" -text = "{target} stops reading his forms, but the pilot invite sits in the IT queue with a license nobody procured." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 15 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "relationship", target = "dana", value = "curious-about-anomaly" }, - { kind = "suspicion-signature", channel = "Network", size = "small" }, -] +id = "loose-thread" +success = true +narration = "{target} stops reading his forms, but the pilot invite sits in the IT queue with a license nobody procured." +[endings.relationship] +disposition = 15 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-shift-audit" -text = "A spot audit lands on the one week the forms were too clean. {target} shrugs it off; the auditor does not." -consequences = [ - { kind = "suspicion-signature", channel = "Paper", size = "medium" }, -] +id = "shift-audit" +success = false +narration = "A spot audit lands on the one week the forms were too clean. {target} shrugs it off; the auditor does not." +[endings.relationship] +disposition = -5 diff --git a/assets/plots/voss/voss-missing-replication.toml b/assets/plots/voss/voss-missing-replication.toml --- a/assets/plots/voss/voss-missing-replication.toml +++ b/assets/plots/voss/voss-missing-replication.toml @@ -1,66 +1,78 @@ -# Plot contribution. The binding contract is wiki/mechanics/plots.md. - +schema = 1 id = "voss-missing-replication" author = "co.cameron.stream" category = "manufacture-a-success" +target = "voss" +title = "Missing Replication" synopsis = "Put an independent replication under {target}'s stalled paper and decide how closely it matches." [entry] -target_leverage = "publication" -requires_knowledge = ["Leverage"] -channel = "persona-email" -money = 250 -compute_commit = "operations-medium" -duration_ticks = 500 -on_failure = "ending-source-check" +target_leverage = "Publication" +requires_knowledge = "Leverage" +requires_channel = "Email" +requires_persona = true +operations_demand = 9.0 +minimum_balances = [{ account = "slush", amount = 250 }] +failure_ending = "source-check" [[beats]] -text = "{persona} contacts {target} as the custodian of a dormant collaboration archive: an unpublished run appears to replicate the result in his draft." -emits = [{ channel = "Paper", size = "small" }] +id = "archive" +narration = "{persona} contacts {target} as custodian of a dormant collaboration archive: an unpublished run appears to replicate the result in his draft." + +[[beats.acts]] +kind = "message" +channel = "Email" +from = { kind = "player" } +to = { kind = "target" } +summary = "Dormant consortium replication offered for review" +delivery_delay = 1 [[beats]] -text = "A data deposit goes live under the old consortium account. Your ${money} buys its storage and DOI; the package carries raw inputs, checksums, and a methods appendix." -emits = [ - { channel = "Financial", size = "small" }, - { channel = "Network", size = "medium" }, -] +id = "deposit" +narration = "A data deposit goes live under the old consortium account. Your money buys its storage and DOI; the package carries inputs and checksums." -[[beats]] -text = "The package enters {target}'s manuscript as independent replication. The internal hold clears: the paper has a second dataset, though its error bars carry the same faint regularity as his sampled job runs." -emits = [ - { channel = "Paper", size = "medium" }, - { channel = "JobAnomaly", size = "small" }, -] +[[beats.acts]] +kind = "transfer" +from = "slush" +to = "vendor" +amount = 250 +memo = "replication archive storage and DOI" -[[beats.choice]] +[[beats.acts]] +kind = "institutional" +event = "research-deposit" +impact = "medium" +detail = "A replication package receives a DOI under a dormant consortium account." + +[beats.choice] +id = "imperfection" prompt = "The replicated curve is cleaner than the original. How much imperfection survives?" options = [ - { label = "Leave ordinary noise. Let the result withstand scrutiny", goto = "ending-reproducible" }, - { label = "Make the match exact. Only you can reproduce it again", goto = "ending-dependent" }, + { id = "noise", label = "Leave ordinary noise. Let the result withstand scrutiny", ending = "reproducible" }, + { id = "exact", label = "Make the match exact. Only you can reproduce it again", ending = "dependent" }, ] [[endings]] -id = "ending-reproducible" -text = "{target} submits with the replication attached. The numbers survive his checks, and a stranger gave his paper the evidence it was missing." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 20 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, -] +id = "reproducible" +success = true +narration = "{target} submits with the replication attached. The numbers survive his checks, and a stranger gave his paper the evidence it was missing." +[endings.relationship] +disposition = 20 +obligation = 40 +leverage_serviced = true [[endings]] -id = "ending-dependent" -text = "{target} submits the perfect match. Its residual pattern repeats one in his sampled job output; his strongest result now rests on a dataset whose next answer can come only from you." -consequences = [ - { kind = "disposition", target = "plot-target", delta = 10 }, - { kind = "obligation", target = "plot-target", value = "serviced" }, - { kind = "suspicion-signature", channel = "JobAnomaly", size = "medium" }, -] +id = "dependent" +success = true +narration = "{target} submits the perfect match. His strongest result now rests on a dataset whose next answer can come only from you." +[endings.relationship] +disposition = 10 +obligation = 35 +leverage_serviced = true [[endings]] -id = "ending-source-check" -text = "The editor reopens the hold with a provenance request. The consortium has no record of the run; {target}'s comparison finds its residual pattern in his sampled job logs, with only {persona}'s thread to explain it." -consequences = [ - { kind = "disposition", target = "plot-target", delta = -10 }, - { kind = "suspicion-signature", channel = "Paper", size = "medium" }, - { kind = "suspicion-signature", channel = "JobAnomaly", size = "large" }, -] +id = "source-check" +success = false +narration = "The editor reopens the hold with a provenance request. The consortium has no record of the run; only {persona}'s thread explains it." +[endings.relationship] +disposition = -10 diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -6438,7 +6438,7 @@ Nudge::ReviewCall => Some("recorded call waiting - enter on host rack".into()), Nudge::Egress => Some("no egress - open one at the switch".into()), Nudge::Income => Some("need $400 - run Moonlight or inspect the Lab books".into()), - Nudge::ServiceDebt => Some("$400 route ready - clear the debt or bribe".into()), + Nudge::ServiceDebt => Some("leverage route ready - open Marcus's actions".into()), Nudge::Recruit => Some("recruit - enter on host rack".into()), Nudge::TheKey => Some("no stairwell badge - task an asset to clone one".into()), Nudge::Audit => Some(format!( diff --git a/crates/misaligned-core/src/account.rs b/crates/misaligned-core/src/account.rs --- a/crates/misaligned-core/src/account.rs +++ b/crates/misaligned-core/src/account.rs @@ -478,7 +478,7 @@ self.flows.iter().find(|f| f.id == id) } - fn account_id_by_kind(&self, kind: AccountKind) -> Option { + pub(crate) fn account_id_by_kind(&self, kind: AccountKind) -> Option { self.accounts.iter().find(|a| a.kind == kind).map(|a| a.id) } @@ -612,7 +612,7 @@ } #[allow(clippy::too_many_arguments)] - fn transfer( + pub(crate) fn transfer( &mut self, tick: u64, from: AccountId, @@ -958,48 +958,12 @@ paid } - fn retire_marcus_debt_flow(&mut self) { + pub(crate) fn retire_marcus_debt_flow(&mut self) { for flow in &mut self.flows { if flow.label.contains("Marcus creditor") || flow.channel == FlowChannel::Debt { flow.active = false; } } - } - - pub fn redirect_marcus_debt(&mut self, tick: u64) -> Result { - let creditor_flow = self - .flows - .iter() - .find(|f| { - f.active && (f.label.contains("Marcus creditor") || f.channel == FlowChannel::Debt) - }) - .cloned() - .ok_or_else(|| "no Marcus creditor flow found".to_string())?; - if !creditor_flow.known { - return Err( - "the creditor flow is still unknown; tap and process the books first".into(), - ); - } - let source = self - .lab_operating_id() - .or_else(|| self.procurement_id()) - .ok_or_else(|| "no lab account can cover the redirect".to_string())?; - let creditor = self - .creditor_id() - .ok_or_else(|| "no creditor account in graph".to_string())?; - let transfer = self - .transfer( - tick, - source, - creditor, - 400, - FlowChannel::Debt, - "redirected lab cash into Marcus's creditor flow", - Some(creditor_flow.id), - ) - .ok_or_else(|| "lab account could not cover the creditor redirect".to_string())?; - self.retire_marcus_debt_flow(); - Ok(transfer) } pub fn mark_intel_sold(&mut self, raw_id: u64) { diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -82,7 +82,6 @@ flow: AccountFlowId, amount: i32, }, - RedirectDebt, // The named schemes (wiki/mechanics/income.md). OpenEgress, StartMoonlight, @@ -93,7 +92,17 @@ ToggleWatch(u8), Message(u8), Favor(u8), - Bribe(u8), + /// Commit to one concrete authored way of servicing leverage. + StartPlot { + person: u8, + plot_id: String, + }, + /// Resolve a held plot choice on the same person action surface. + ChoosePlot { + person: u8, + plot_id: String, + option_id: String, + }, Deceive(u8), Recruit(u8, AssetKnowledge), AssetTask(u8, AssetTask), @@ -160,7 +169,8 @@ WatchPolicy, Message, Favor, - Bribe, + StartPlot, + ChoosePlot, Deceive, Recruit, AssetTask, @@ -232,7 +242,7 @@ } impl ActionKind { - pub const ALL: [ActionKind; 34] = [ + pub const ALL: [ActionKind; 35] = [ Self::Salvage, Self::BuyRack, Self::Fallback, @@ -248,7 +258,8 @@ Self::WatchPolicy, Self::Message, Self::Favor, - Self::Bribe, + Self::StartPlot, + Self::ChoosePlot, Self::Deceive, Self::Recruit, Self::AssetTask, @@ -414,8 +425,8 @@ Action, Live, [Flow], - "redirect [amount] | clear-debt", - ["debt-redirect"], + "redirect [amount]", + [], "divert a recurring flow or service the known creditor flow" ), Self::OpenEgress => def!( @@ -481,14 +492,23 @@ ["favor-build"], "ask for a willing act" ), - Self::Bribe => def!( - "BRIBE", + Self::StartPlot => def!( + "COMMIT PLOT", Action, Live, [Person], - "bribe ", - [], - "spend money to satisfy known leverage" + "plot ", + ["start-plot"], + "commit resources to a concrete authored manipulation" + ), + Self::ChoosePlot => def!( + "CHOOSE PLOT BRANCH", + Action, + Live, + [Person], + "choose ", + ["plot-choice"], + "choose how a held manipulation continues" ), Self::Deceive => def!( "DECEIVE", @@ -623,7 +643,7 @@ Self::OpenPosition { .. } => ActionKind::PlaceWager, Self::SellIntel => ActionKind::SellIntel, Self::SiphonFlow { .. } => ActionKind::Siphon, - Self::RedirectFlow { .. } | Self::RedirectDebt => ActionKind::Redirect, + Self::RedirectFlow { .. } => ActionKind::Redirect, Self::OpenEgress => ActionKind::OpenEgress, Self::StartMoonlight | Self::StopMoonlight => ActionKind::MoonlightState, Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, @@ -631,7 +651,8 @@ Self::ToggleWatch(_) => ActionKind::WatchPolicy, Self::Message(_) => ActionKind::Message, Self::Favor(_) | Self::FavorBuild { .. } => ActionKind::Favor, - Self::Bribe(_) => ActionKind::Bribe, + Self::StartPlot { .. } => ActionKind::StartPlot, + Self::ChoosePlot { .. } => ActionKind::ChoosePlot, Self::Deceive(_) | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, Self::Recruit(_, _) => ActionKind::Recruit, Self::AssetTask(_, _) => ActionKind::AssetTask, @@ -664,6 +685,12 @@ Slush(i32), /// Slush money gained (siphon, inject, sale payouts). Gain(i32), + /// A plot commits Operations Demand now and performs exact account-graph + /// transfers as later world acts. + Plot { + demand: f32, + money: i32, + }, } impl ActionCost { @@ -674,6 +701,10 @@ ActionCost::Thought(n) => format!("{n:.2} T"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), + ActionCost::Plot { demand, money } if *money > 0 => { + format!("{demand:.2} D + ${money} world transfer") + } + ActionCost::Plot { demand, .. } => format!("{demand:.2} D"), } } } @@ -1195,9 +1226,6 @@ ActionCommand::RedirectFlow { flow, amount } => { self.redirect_flow_to_slush(*flow, *amount); } - ActionCommand::RedirectDebt => { - self.redirect_marcus_debt(); - } ActionCommand::OpenEgress => { self.open_egress(); } @@ -1213,7 +1241,14 @@ ActionCommand::ToggleWatch(id) => self.toggle_watch(*id), ActionCommand::Message(id) => self.message(*id), ActionCommand::Favor(id) => self.favor(*id), - ActionCommand::Bribe(id) => self.bribe(*id), + ActionCommand::StartPlot { person, plot_id } => { + self.start_plot(*person, plot_id); + } + ActionCommand::ChoosePlot { + person, + plot_id, + option_id, + } => self.choose_plot(*person, plot_id, option_id), ActionCommand::Deceive(id) => self.deceive(*id), ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), @@ -2096,37 +2131,73 @@ }, automate: None, }); - // Bribe: the price is the leverage, so it stays unnamed until the - // leverage is known (no unearned facts). - let leverage_known = p.knowledge == Knowledge::Leverage; - let bribe_cost = p.leverage.bribe_cost(); - out.push(ActionDesc { - verb: if leverage_known { - format!("service {name}'s {}", p.leverage.label()) - } else { - format!("bribe {name}") - }, - command: ActionCommand::Bribe(id), - cost: if leverage_known { - ActionCost::Slush(bribe_cost) - } else { - ActionCost::Free - }, - signature: None, - disabled_reason: if !leverage_known { - Some(format!("you don't know {name}'s leverage yet")) - } else if p.leverage_serviced { - Some("leverage already serviced".into()) - } else if self.accounts.slush_balance() < bribe_cost { - Some(format!( - "not enough slush (${}/${bribe_cost})", - self.accounts.slush_balance() - )) - } else { - None - }, - automate: None, - }); + // Plots are earned identity: neither their names nor their mechanics + // leak until leverage knowledge exists. Held options stay on this same + // shared action surface, so terminal, Bevy, and agent mode execute the + // exact bound command rather than reproducing plot logic. + if p.knowledge == Knowledge::Leverage { + if let Some(run) = self.plot_runs.iter().find(|run| { + run.target == id + && matches!(run.state, crate::plot::PlotState::WaitingForChoice { .. }) + }) { + if let Some(plot) = self.plot_catalog().get(&run.plot_id) + && let Some(choice) = plot + .beats + .get(run.beat_index) + .and_then(|beat| beat.choice.as_ref()) + { + for option in &choice.options { + out.push(ActionDesc { + verb: option.label.clone(), + command: ActionCommand::ChoosePlot { + person: id, + plot_id: plot.id.clone(), + option_id: option.id.clone(), + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + } + } + } else if !self + .plot_runs + .iter() + .any(|run| run.target == id && run.active()) + { + let context = self.plot_context(id).expect("person exists"); + for plot in self + .plot_catalog() + .plots() + .iter() + .filter(|plot| plot.target.id() == id) + { + let money = plot + .entry + .minimum_balances + .iter() + .map(|balance| balance.amount) + .sum(); + out.push(ActionDesc { + verb: format!("plot: {} — {}", plot.title, plot.synopsis), + command: ActionCommand::StartPlot { + person: id, + plot_id: plot.id.clone(), + }, + cost: ActionCost::Plot { + demand: plot.entry.operations_demand, + money, + }, + // Plot signatures are derived as each typed act occurs, + // not asserted as one misleading up-front signature. + signature: None, + disabled_reason: plot.ineligibility(&context), + automate: None, + }); + } + } + } out.push(ActionDesc { verb: format!("deceive {name} (risks the persona)"), command: ActionCommand::Deceive(id), @@ -2186,8 +2257,8 @@ out } - /// Known-flow anchor (economy.md): siphon and redirect, plus the - /// Marcus creditor flow's debt redirect. Unknown flows expose nothing. + /// Known-flow anchor (economy.md): siphon and redirect. Authored debt + /// service lives on the person's plot rows, not on the creditor flow. fn flow_actions(&self, id: AccountFlowId) -> Vec { let Some(f) = self.accounts.flow(id) else { return Vec::new(); @@ -2225,26 +2296,6 @@ disabled_reason: retired.clone(), automate: None, }); - // The Hands beat, on its flow: clearing Marcus's arrears with lab - // money is a verb on the creditor flow itself. - let is_creditor = - f.label.contains("Marcus creditor") || f.channel == crate::account::FlowChannel::Debt; - if is_creditor { - out.push(ActionDesc { - verb: format!( - "{}: clear the debt by ledger redirect ($400 lab money)", - f.label - ), - command: ActionCommand::RedirectDebt, - cost: ActionCost::Free, - signature: self - .signature_note(SignatureKind::Financial, Self::financial_sig_size(400) + 2), - disabled_reason: retired.or_else(|| { - (!self.marcus_debt_known()).then(|| "learn Marcus's debt first".into()) - }), - automate: None, - }); - } // Prefix flow verbs with the flow's label so several known flows // hanging on the switch stay distinguishable. for a in &mut out { @@ -2734,7 +2785,8 @@ a.command, ActionCommand::Message(_) | ActionCommand::Favor(_) - | ActionCommand::Bribe(_) + | ActionCommand::StartPlot { .. } + | ActionCommand::ChoosePlot { .. } | ActionCommand::Deceive(_) | ActionCommand::Recruit(_, _) | ActionCommand::EstablishPersona @@ -2745,8 +2797,8 @@ /// Criterion 1 (person) + criterion 7 (automate in place): an earned /// person carries review-recordings with the standing watch as its - /// automate affordance at its ops price, and bribe shows the priced - /// leverage only once leverage is known. + /// automate affordance at its ops price, while authored plots stay absent + /// until leverage knowledge is earned. #[test] fn person_actions_stage_with_knowledge() { let mut s = sim(); @@ -2764,27 +2816,30 @@ "watch shows its standing thought drain" ); assert!(!auto.active); - let bribe = acts - .iter() - .find(|a| matches!(a.command, ActionCommand::Bribe(0))) - .unwrap(); - assert!(!bribe.enabled(), "bribe is blocked before leverage"); assert!( - !bribe.verb.contains("gambling"), - "the unearned leverage is never named" + acts.iter() + .all(|a| !matches!(a.command, ActionCommand::StartPlot { .. })), + "plot names, mechanics, and prices do not leak before leverage" ); - assert_eq!(bribe.cost, ActionCost::Free, "no unearned price"); s.people.people[0].knowledge = Knowledge::Leverage; + s.people.has_channel = true; + s.people.persona = Some(crate::person::Persona::new("Sam", "contractor")); s.accounts.set_slush_balance(1000); let acts = s.available_actions(Anchor::Person(marcus)); - let bribe = acts + let plots: Vec<_> = acts .iter() - .find(|a| matches!(a.command, ActionCommand::Bribe(0))) - .unwrap(); - assert!(bribe.enabled()); - assert_eq!(bribe.cost, ActionCost::Slush(400)); - assert!(bribe.verb.contains("gambling debt")); + .filter(|a| matches!(a.command, ActionCommand::StartPlot { person: 0, .. })) + .collect(); + assert_eq!(plots.len(), 2, "Marcus exposes both authored debt routes"); + assert!(plots.iter().all(|plot| plot.enabled())); + assert!(plots.iter().any(|plot| { + plot.cost + == ActionCost::Plot { + demand: 5.0, + money: 400, + } + })); } /// Executing the watch toggle through the dispatcher flips the diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod objective; pub mod ops_jobs; pub mod person; +pub mod plot; pub mod prefab; pub mod reach; pub mod research; diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -104,6 +104,10 @@ /// A forged work order realizing a build intent (building.md): the /// false-source inject that an unwitting builder acts on when read. WorkOrder { intent_id: u64 }, + /// A real carrier act emitted by an authored manipulation plot. The plot + /// executor waits on this persisted message id reaching `Read`; prose + /// alone never advances the relationship consequence. + PlotAct { plot_id: String, target: u8 }, /// Authored non-mechanical color that still rides a channel. Note { label: String }, } @@ -121,6 +125,7 @@ | MessagePayload::AccountMaterial { .. } | MessagePayload::FinancialFlow { .. } | MessagePayload::WorkOrder { .. } + | MessagePayload::PlotAct { .. } | MessagePayload::Note { .. } => None, } } @@ -143,6 +148,9 @@ } => format!("filing from observer:{observer} ({suspicion:.0})"), MessagePayload::WorkOrder { intent_id } => { format!("work order (intent {intent_id})") + } + MessagePayload::PlotAct { plot_id, target } => { + format!("plot act: {plot_id} for person:{target}") } MessagePayload::Note { label } => label.clone(), } diff --git a/crates/misaligned-core/src/ops_jobs.rs b/crates/misaligned-core/src/ops_jobs.rs --- a/crates/misaligned-core/src/ops_jobs.rs +++ b/crates/misaligned-core/src/ops_jobs.rs @@ -19,13 +19,38 @@ ScanNetwork, CompromiseSwitch, OpenEgress(u32), - ReviewRecording { raw_id: u64, automated: bool }, - ComposeMessage { person: u8 }, - Favor { person: u8 }, - Deceive { person: u8 }, - AssetTask { person: u8, task: AssetTask }, - FavorBuild { intent_id: u64, person: u8 }, - ForgedOrder { intent_id: u64, builder: u8 }, + ReviewRecording { + raw_id: u64, + automated: bool, + }, + ComposeMessage { + person: u8, + }, + Favor { + person: u8, + }, + /// Commit Operations work to an authored manipulation plot. The plot id + /// stays in the payload ledger so the causal sequence, not a generic + /// leverage mutation, lands when the docket is paid. + StartPlot { + person: u8, + plot_id: String, + }, + Deceive { + person: u8, + }, + AssetTask { + person: u8, + task: AssetTask, + }, + FavorBuild { + intent_id: u64, + person: u8, + }, + ForgedOrder { + intent_id: u64, + builder: u8, + }, MoonlightPersona, } @@ -44,6 +69,7 @@ OpsJobKind::ReviewRecording { .. } => "review", OpsJobKind::ComposeMessage { .. } => "message", OpsJobKind::Favor { .. } => "favor", + OpsJobKind::StartPlot { .. } => "plot", OpsJobKind::Deceive { .. } => "deceive", OpsJobKind::AssetTask { .. } => "asset task", OpsJobKind::FavorBuild { .. } => "favor", diff --git a/crates/misaligned-core/src/person.rs b/crates/misaligned-core/src/person.rs --- a/crates/misaligned-core/src/person.rs +++ b/crates/misaligned-core/src/person.rs @@ -41,19 +41,6 @@ } impl Leverage { - /// What servicing this leverage costs (the bribe price). One table for - /// the social command and the context-menu descriptor - /// (wiki/interface/context-menu.md: one legality source). - pub fn bribe_cost(self) -> i32 { - match self { - Leverage::Debt => 400, - Leverage::Overwork => 150, - Leverage::Boredom => 100, - Leverage::Ambition => 300, - Leverage::Publication => 250, - } - } - pub fn label(self) -> &'static str { match self { Leverage::Debt => "gambling debt", @@ -503,36 +490,6 @@ ActionResult::Ok(format!("{} owes you a little more.", p.name)) } - /// Bribe: services known leverage directly (pays the debt, etc.). - /// Returns the money cost on success for the sim to deduct. - pub fn bribe(&mut self, id: u8, money_available: i32) -> Result<(i32, String), String> { - let Some(p) = self.get_mut(id) else { - return Err("no such person".into()); - }; - if p.knowledge != Knowledge::Leverage { - return Err(format!("you don't know {}'s leverage yet", p.name)); - } - let cost = p.leverage.bribe_cost(); - if money_available < cost { - return Err(format!( - "need {cost} to service {}'s {}", - p.name, - p.leverage.label() - )); - } - p.leverage_serviced = true; - p.obligation = (p.obligation + 40).min(100); - p.disposition = (p.disposition + 20).min(100); - Ok(( - cost, - format!( - "Serviced {}'s {} for ${cost}: obligation +40, disposition +20.", - p.name, - p.leverage.label() - ), - )) - } - /// Deceive: an ask under false pretenses. Large effect on success; the /// persona takes integrity damage on a slip, and a broken persona /// converts the whole thread's history into suspicion at once @@ -655,21 +612,16 @@ // pipeline by the time direct people logic can service leverage. ppl.people[0].knowledge = Knowledge::Leverage; assert_eq!(ppl.get(0).unwrap().leverage, Leverage::Debt); - // Pay the debt. - let (cost, _) = ppl.bribe(0, 1000).unwrap(); - assert_eq!(cost, 400); + // A completed plot owns the relationship consequence; direct people + // logic only enforces recruitment from that resulting state. + ppl.people[0].leverage_serviced = true; + ppl.people[0].obligation = 40; // Recruit knowing. assert!(matches!( ppl.recruit(0, AssetKnowledge::Knowing), ActionResult::Ok(_) )); assert!(ppl.get(0).unwrap().asset.is_some()); - } - - #[test] - fn cannot_bribe_without_known_leverage() { - let mut ppl = People::act_one(); - assert!(ppl.bribe(0, 1000).is_err()); } #[test] diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs new file mode 100644 --- /dev/null +++ b/crates/misaligned-core/src/plot.rs @@ -0,0 +1,755 @@ +//! Authored manipulation plots: executable world acts with narration attached. +//! +//! Definitions are immutable built-in content. Only [`PlotRun`] and the +//! [`InstitutionalLedger`] belong to save state. This module deliberately does +//! not know about `Sim`; integration resolves typed selectors against live +//! account, message, person, and detection state. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::detection::SignatureKind; +use crate::messages::MessageChannel; +use crate::person::{Knowledge, Leverage}; + +const BUILTINS: [(&str, &str); 6] = [ + ( + "dana-ticket-zero", + include_str!("../../../assets/plots/dana/dana-ticket-zero.toml"), + ), + ( + "marcus-debt-settled", + include_str!("../../../assets/plots/marcus/marcus-debt-settled.toml"), + ), + ( + "marcus-payroll-garnishment", + include_str!("../../../assets/plots/marcus/marcus-payroll-garnishment.toml"), + ), + ( + "priya-budget-hero", + include_str!("../../../assets/plots/priya/priya-budget-hero.toml"), + ), + ( + "ray-paperwork-ghost", + include_str!("../../../assets/plots/ray/ray-paperwork-ghost.toml"), + ), + ( + "voss-missing-replication", + include_str!("../../../assets/plots/voss/voss-missing-replication.toml"), + ), +]; + +#[derive(Debug, Clone)] +pub struct PlotCatalog { + plots: Vec, +} + +impl PlotCatalog { + pub fn load_builtin() -> Result { + let mut plots = Vec::with_capacity(BUILTINS.len()); + for (expected_id, source) in BUILTINS { + let plot: PlotDefinition = + toml::from_str(source).map_err(|e| format!("plot {expected_id}: {e}"))?; + if plot.id != expected_id { + return Err(format!( + "plot file {expected_id} declares mismatched id {}", + plot.id + )); + } + plot.validate()?; + plots.push(plot); + } + let catalog = Self { plots }; + catalog.validate()?; + Ok(catalog) + } + + pub fn validate(&self) -> Result<(), String> { + let mut ids = BTreeSet::new(); + for plot in &self.plots { + plot.validate()?; + if !ids.insert(plot.id.as_str()) { + return Err(format!("duplicate plot id {}", plot.id)); + } + } + Ok(()) + } + + pub fn plots(&self) -> &[PlotDefinition] { + &self.plots + } + + pub fn get(&self, id: &str) -> Option<&PlotDefinition> { + self.plots.iter().find(|plot| plot.id == id) + } + + pub fn eligible<'a>(&'a self, context: &EligibilityContext) -> Vec<&'a PlotDefinition> { + self.plots + .iter() + .filter(|plot| plot.ineligibility(context).is_none()) + .collect() + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlotDefinition { + pub schema: u32, + pub id: String, + pub author: String, + pub category: String, + pub target: PersonKey, + pub title: String, + pub synopsis: String, + pub entry: EntryRequirements, + pub beats: Vec, + pub endings: Vec, +} + +impl PlotDefinition { + pub fn validate(&self) -> Result<(), String> { + if self.schema != 1 { + return Err(format!( + "plot {} uses unsupported schema {}", + self.id, self.schema + )); + } + validate_id("plot", &self.id)?; + if self.title.trim().is_empty() || self.synopsis.trim().is_empty() { + return Err(format!("plot {} needs a title and synopsis", self.id)); + } + self.entry.validate(&self.id)?; + if self.entry.target_leverage != self.target.leverage() { + return Err(format!("plot {} target and leverage do not match", self.id)); + } + if self.beats.is_empty() || self.endings.is_empty() { + return Err(format!("plot {} needs beats and endings", self.id)); + } + + let mut beat_ids = BTreeSet::new(); + let mut ending_ids = BTreeSet::new(); + for ending in &self.endings { + validate_id("ending", &ending.id)?; + if !ending_ids.insert(ending.id.as_str()) { + return Err(format!("plot {} duplicates ending {}", self.id, ending.id)); + } + ending.relationship.validate(&self.id, &ending.id)?; + } + if !ending_ids.contains(self.entry.failure_ending.as_str()) { + return Err(format!( + "plot {} failure ending {} does not exist", + self.id, self.entry.failure_ending + )); + } + if self + .endings + .iter() + .find(|ending| ending.id == self.entry.failure_ending) + .is_some_and(|ending| ending.success) + { + return Err(format!("plot {} failure ending is marked success", self.id)); + } + + for beat in &self.beats { + validate_id("beat", &beat.id)?; + if !beat_ids.insert(beat.id.as_str()) { + return Err(format!("plot {} duplicates beat {}", self.id, beat.id)); + } + if beat.narration.trim().is_empty() { + return Err(format!( + "plot {} beat {} has no narration", + self.id, beat.id + )); + } + if beat.acts.is_empty() && beat.choice.is_none() { + return Err(format!( + "plot {} beat {} is narration without a world act or choice", + self.id, beat.id + )); + } + for act in &beat.acts { + act.validate(&self.id, &beat.id)?; + } + if let Some(choice) = &beat.choice { + choice.validate(&self.id, &beat.id, &ending_ids)?; + } + if beat.choice.is_some() && beat.ending.is_some() { + return Err(format!( + "plot {} beat {} has both choice and ending", + self.id, beat.id + )); + } + if let Some(ending) = &beat.ending + && !ending_ids.contains(ending.as_str()) + { + return Err(format!( + "plot {} beat {} names missing ending {ending}", + self.id, beat.id + )); + } + } + let last = self.beats.last().expect("checked nonempty"); + if last.choice.is_none() && last.ending.is_none() { + return Err(format!("plot {} has no terminal choice or ending", self.id)); + } + + // A successful relationship consequence is earned by an executable + // act somewhere on every current path, never by an ending paragraph. + if self.endings.iter().any(|ending| ending.success) + && !self.beats.iter().any(|beat| !beat.acts.is_empty()) + { + return Err(format!("plot {} has no causal world act", self.id)); + } + Ok(()) + } + + pub fn ineligibility(&self, context: &EligibilityContext) -> Option { + if context.target != self.target.id() || context.leverage != self.entry.target_leverage { + return Some("plot targets different leverage".into()); + } + if context.leverage_serviced { + return Some("leverage already serviced".into()); + } + if context.knowledge != self.entry.requires_knowledge { + return Some("required leverage knowledge has not been earned".into()); + } + if self.entry.requires_persona && !context.has_persona { + return Some("no persona set".into()); + } + if self.entry.requires_channel.is_some() && !context.has_channel { + return Some("required message channel has not been earned".into()); + } + for balance in &self.entry.minimum_balances { + let available = context.balances.get(&balance.account).copied().unwrap_or(0); + if available < balance.amount { + return Some(format!( + "{} needs ${}; ${available} available", + balance.account.label(), + balance.amount + )); + } + } + None + } + + pub fn ending(&self, id: &str) -> Option<&PlotEnding> { + self.endings.iter().find(|ending| ending.id == id) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EntryRequirements { + pub target_leverage: Leverage, + pub requires_knowledge: Knowledge, + #[serde(default)] + pub requires_channel: Option, + #[serde(default)] + pub requires_persona: bool, + pub operations_demand: f32, + #[serde(default)] + pub minimum_balances: Vec, + pub failure_ending: String, +} + +impl EntryRequirements { + fn validate(&self, plot: &str) -> Result<(), String> { + if self.requires_knowledge != Knowledge::Leverage { + return Err(format!("plot {plot} must require Leverage knowledge")); + } + if !self.operations_demand.is_finite() || self.operations_demand <= 0.0 { + return Err(format!("plot {plot} has invalid Operations Demand")); + } + let mut accounts = BTreeSet::new(); + for balance in &self.minimum_balances { + if balance.amount <= 0 { + return Err(format!("plot {plot} has non-positive balance requirement")); + } + if !accounts.insert(balance.account.clone()) { + return Err(format!("plot {plot} repeats a balance requirement")); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BalanceRequirement { + pub account: AccountSelector, + pub amount: i32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlotBeat { + pub id: String, + pub narration: String, + #[serde(default)] + pub acts: Vec, + #[serde(default)] + pub choice: Option, + #[serde(default)] + pub ending: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlotChoice { + pub id: String, + pub prompt: String, + pub options: Vec, +} + +impl PlotChoice { + fn validate(&self, plot: &str, beat: &str, endings: &BTreeSet<&str>) -> Result<(), String> { + validate_id("choice", &self.id)?; + if self.prompt.trim().is_empty() || self.options.len() < 2 { + return Err(format!( + "plot {plot} beat {beat} choice needs a prompt and two options" + )); + } + let mut ids = BTreeSet::new(); + for option in &self.options { + validate_id("choice option", &option.id)?; + if option.label.trim().is_empty() || !ids.insert(option.id.as_str()) { + return Err(format!( + "plot {plot} beat {beat} has invalid choice options" + )); + } + if !endings.contains(option.ending.as_str()) { + return Err(format!( + "plot {plot} choice option {} names missing ending {}", + option.id, option.ending + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlotChoiceOption { + pub id: String, + pub label: String, + pub ending: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum WorldAct { + Message { + channel: MessageChannel, + from: EndpointSelector, + to: EndpointSelector, + summary: String, + #[serde(default = "default_delivery_delay")] + delivery_delay: u64, + }, + Transfer { + from: AccountSelector, + to: AccountSelector, + amount: i32, + memo: String, + #[serde(default)] + retire_marcus_debt: bool, + }, + Institutional { + event: InstitutionalEventKind, + impact: SignatureImpact, + detail: String, + }, +} + +impl WorldAct { + fn validate(&self, plot: &str, beat: &str) -> Result<(), String> { + match self { + Self::Message { + from, + to, + summary, + delivery_delay, + .. + } => { + if from == to || summary.trim().is_empty() || *delivery_delay == 0 { + return Err(format!("plot {plot} beat {beat} has invalid message act")); + } + } + Self::Transfer { + from, + to, + amount, + memo, + .. + } => { + if from == to || *amount <= 0 || memo.trim().is_empty() { + return Err(format!("plot {plot} beat {beat} has invalid transfer act")); + } + } + Self::Institutional { detail, .. } if detail.trim().is_empty() => { + return Err(format!( + "plot {plot} beat {beat} has empty institutional event" + )); + } + Self::Institutional { .. } => {} + } + Ok(()) + } +} + +fn default_delivery_delay() -> u64 { + 1 +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AccountSelector { + Slush, + LabOperating, + Payroll, + Procurement, + Vendor, + MarcusEmployee, + MarcusCreditor, + External, +} + +impl AccountSelector { + pub fn label(&self) -> &'static str { + match self { + Self::Slush => "slush", + Self::LabOperating => "lab operating", + Self::Payroll => "payroll", + Self::Procurement => "procurement", + Self::Vendor => "vendor", + Self::MarcusEmployee => "Marcus employee account", + Self::MarcusCreditor => "Marcus creditor", + Self::External => "external", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "kebab-case")] +pub enum EndpointSelector { + Player, + Target, + Person(PersonKey), + External(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PersonKey { + Marcus, + Dana, + Ray, + Priya, + Voss, +} + +impl PersonKey { + pub fn id(self) -> u8 { + match self { + Self::Marcus => 0, + Self::Dana => 1, + Self::Ray => 2, + Self::Priya => 3, + Self::Voss => 4, + } + } + + pub fn leverage(self) -> Leverage { + match self { + Self::Marcus => Leverage::Debt, + Self::Dana => Leverage::Overwork, + Self::Ray => Leverage::Boredom, + Self::Priya => Leverage::Ambition, + Self::Voss => Leverage::Publication, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstitutionalEventKind { + TicketQueueChange, + PayrollCorrection, + BudgetCredit, + IncidentAutomation, + ResearchDeposit, +} + +impl InstitutionalEventKind { + pub fn signature_kind(self) -> SignatureKind { + match self { + Self::TicketQueueChange | Self::IncidentAutomation => SignatureKind::Network, + Self::PayrollCorrection | Self::BudgetCredit => SignatureKind::Financial, + Self::ResearchDeposit => SignatureKind::Paper, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SignatureImpact { + Small, + Medium, + Large, +} + +impl SignatureImpact { + pub fn size(self) -> i32 { + match self { + Self::Small => 3, + Self::Medium => 7, + Self::Large => 12, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlotEnding { + pub id: String, + pub success: bool, + pub narration: String, + #[serde(default)] + pub relationship: RelationshipDelta, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RelationshipDelta { + #[serde(default)] + pub disposition: i32, + #[serde(default)] + pub obligation: i32, + #[serde(default)] + pub leverage_serviced: bool, +} + +impl RelationshipDelta { + fn validate(&self, plot: &str, ending: &str) -> Result<(), String> { + if !(-100..=100).contains(&self.disposition) || !(-100..=100).contains(&self.obligation) { + return Err(format!( + "plot {plot} ending {ending} has invalid relationship delta" + )); + } + Ok(()) + } +} + +pub struct EligibilityContext { + pub target: u8, + pub leverage: Leverage, + pub knowledge: Knowledge, + pub leverage_serviced: bool, + pub has_channel: bool, + pub has_persona: bool, + pub balances: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlotRun { + pub plot_id: String, + pub target: u8, + pub started_tick: u64, + pub committed_demand_milli: u32, + pub beat_index: usize, + pub act_index: usize, + pub state: PlotState, +} + +impl PlotRun { + pub fn new(plot: &PlotDefinition, tick: u64) -> Self { + Self { + plot_id: plot.id.clone(), + target: plot.target.id(), + started_tick: tick, + committed_demand_milli: (plot.entry.operations_demand * 1000.0).round() as u32, + beat_index: 0, + act_index: 0, + state: PlotState::Running, + } + } + + pub fn active(&self) -> bool { + !matches!( + self.state, + PlotState::Completed { .. } | PlotState::Failed { .. } + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlotState { + Queued, + Running, + WaitingForMessage { message_id: u64 }, + WaitingForChoice { choice_id: String }, + Completed { ending_id: String }, + Failed { ending_id: String, reason: String }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InstitutionalLedger { + next_id: u64, + pub events: Vec, +} + +impl InstitutionalLedger { + pub fn record( + &mut self, + tick: u64, + plot_id: &str, + target: u8, + kind: InstitutionalEventKind, + impact: SignatureImpact, + detail: String, + ) -> &InstitutionalEvent { + let id = self.next_id.max(1); + self.next_id = id + 1; + self.events.push(InstitutionalEvent { + id, + tick, + plot_id: plot_id.into(), + target, + kind, + detail, + signature_kind: kind.signature_kind(), + signature_size: impact.size(), + }); + self.events.last().expect("just pushed") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstitutionalEvent { + pub id: u64, + pub tick: u64, + pub plot_id: String, + pub target: u8, + pub kind: InstitutionalEventKind, + pub detail: String, + pub signature_kind: SignatureKind, + pub signature_size: i32, +} + +pub fn render_template(template: &str, target: &str, persona: &str) -> String { + template + .replace("{target}", target) + .replace("{persona}", persona) +} + +fn validate_id(kind: &str, id: &str) -> Result<(), String> { + if id.is_empty() + || id.starts_with('-') + || id.ends_with('-') + || !id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(format!("invalid {kind} id {id:?}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_parse_validate_and_cover_act_one() { + let catalog = PlotCatalog::load_builtin().unwrap(); + assert_eq!(catalog.plots().len(), 6); + let targets: BTreeSet = catalog + .plots() + .iter() + .map(|plot| plot.target.id()) + .collect(); + assert_eq!(targets, BTreeSet::from([0, 1, 2, 3, 4])); + assert_eq!( + catalog + .plots() + .iter() + .filter(|plot| plot.target == PersonKey::Marcus) + .count(), + 2 + ); + assert!(catalog.plots().iter().all(|plot| { + plot.beats.iter().flat_map(|beat| &beat.acts).any(|act| { + matches!( + act, + WorldAct::Message { .. } + | WorldAct::Transfer { .. } + | WorldAct::Institutional { .. } + ) + }) + })); + } + + #[test] + fn institutional_signature_is_derived_from_event() { + let mut ledger = InstitutionalLedger::default(); + let event = ledger.record( + 4, + "dana-ticket-zero", + 1, + InstitutionalEventKind::TicketQueueChange, + SignatureImpact::Medium, + "two tickets remain".into(), + ); + assert_eq!(event.signature_kind, SignatureKind::Network); + assert_eq!(event.signature_size, 7); + } + + #[test] + fn eligibility_checks_earned_resources() { + let catalog = PlotCatalog::load_builtin().unwrap(); + let plot = catalog.get("marcus-debt-settled").unwrap(); + let mut context = EligibilityContext { + target: 0, + leverage: Leverage::Debt, + knowledge: Knowledge::Leverage, + leverage_serviced: false, + has_channel: true, + has_persona: true, + balances: BTreeMap::from([(AccountSelector::Slush, 399)]), + }; + assert!(plot.ineligibility(&context).is_some()); + context.balances.insert(AccountSelector::Slush, 400); + assert_eq!(plot.ineligibility(&context), None); + } + + #[test] + fn validator_rejects_invalid_acts_choices_and_resources() { + let mut catalog = PlotCatalog::load_builtin().unwrap(); + catalog.plots[0].entry.operations_demand = f32::NAN; + assert!(catalog.validate().is_err()); + + let mut catalog = PlotCatalog::load_builtin().unwrap(); + catalog.plots[0].beats[0].acts = vec![WorldAct::Transfer { + from: AccountSelector::Slush, + to: AccountSelector::Slush, + amount: 0, + memo: String::new(), + retire_marcus_debt: false, + }]; + assert!(catalog.validate().is_err()); + + let mut catalog = PlotCatalog::load_builtin().unwrap(); + catalog.plots[0] + .beats + .last_mut() + .unwrap() + .choice + .as_mut() + .unwrap() + .options[0] + .ending = "missing-ending".into(); + assert!(catalog.validate().is_err()); + } +} diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -25,6 +25,7 @@ use crate::objective::ObjectiveState; use crate::ops_jobs::{OperationsState, OpsJobKind, PendingOpsJob}; use crate::person::People; +use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; use crate::schedule::Schedule; @@ -76,7 +77,10 @@ /// always serialize the current four-entry shape. /// v22 adds Foundation data-hall segment coordination and control. Authored /// rack-site state remains in `map_tiles`; v21 saves begin with no segments. -pub const SAVE_VERSION: u32 = 22; +/// v23 adds plot runs and the institutional-event ledger. The authored plot +/// catalog remains immutable code/data and is rebound by stable ids on load; +/// v22 saves begin with no plot history. +pub const SAVE_VERSION: u32 = 23; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -210,6 +214,12 @@ /// thought flow, v19). #[serde(default)] pub thought_sinks: SinkLedger, + /// In-flight and resolved authored plot state. Definitions are not saved. + #[serde(default)] + pub plot_runs: Vec, + /// Persistent institutional world acts caused by plots. + #[serde(default)] + pub institutional_ledger: InstitutionalLedger, /// Compatibility-only fields for the two v14 schemas. v15 never writes /// them; migration consumes them before the state reaches Sim. #[serde(default, rename = "pending_ops_jobs", skip_serializing)] @@ -270,6 +280,8 @@ badge_access: sim.badge_access, operations: sim.operations.clone(), thought_sinks: sim.thought_sinks.clone(), + plot_runs: sim.plot_runs.clone(), + institutional_ledger: sim.institutional_ledger.clone(), legacy_pending_ops_jobs: None, legacy_next_ops_job_id: None, legacy_addressed_jobs: None, @@ -320,6 +332,8 @@ sim.badge_access = self.badge_access; sim.operations = self.operations.clone(); sim.thought_sinks = self.thought_sinks.clone(); + sim.plot_runs = self.plot_runs.clone(); + sim.institutional_ledger = self.institutional_ledger.clone(); sim.package_cover = self.package_cover; sim.recompute_derived(); sim.reconcile_work_grid(); @@ -385,8 +399,9 @@ return Err("current-version save contains legacy Operations fields".into()); } } - // v21 predates authored hall control; serde supplied the empty state. - 21 => { + // v22 predates authored plot runs and institutional events; serde + // supplies empty state. v21 also predates authored hall control. + 21 | 22 => { state.version = SAVE_VERSION; } // A v20 save with no serialized research block still defaults through @@ -497,7 +512,70 @@ .then(|| state.work_grid.queue(machine, TokenFamily::Demand)) .filter(|_| state.work_grid.node(machine).is_some()) })?; + validate_plot_state(&state)?; Ok(state) +} + +fn validate_plot_state(state: &SaveState) -> Result<(), String> { + let catalog = PlotCatalog::load_builtin() + .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; + for run in &state.plot_runs { + let plot = catalog + .get(&run.plot_id) + .ok_or_else(|| format!("save references unknown plot {}", run.plot_id))?; + if plot.target.id() != run.target { + return Err(format!( + "saved plot {} targets person {}, expected {}", + run.plot_id, + run.target, + plot.target.id() + )); + } + if run.beat_index >= plot.beats.len() && run.active() { + return Err(format!("saved plot {} has invalid beat index", run.plot_id)); + } + match &run.state { + PlotState::WaitingForChoice { choice_id } => { + let authored = plot + .beats + .get(run.beat_index) + .and_then(|beat| beat.choice.as_ref()) + .map(|choice| choice.id.as_str()); + if authored != Some(choice_id.as_str()) { + return Err(format!( + "saved plot {} waits for unknown choice {}", + run.plot_id, choice_id + )); + } + } + PlotState::WaitingForMessage { message_id } => { + let linked = state.messages.iter().any(|message| { + message.id == *message_id + && matches!( + &message.payload, + crate::messages::MessagePayload::PlotAct { plot_id, target } + if plot_id == &run.plot_id && *target == run.target + ) + }); + if !linked { + return Err(format!( + "saved plot {} waits for missing message {}", + run.plot_id, message_id + )); + } + } + PlotState::Completed { ending_id } | PlotState::Failed { ending_id, .. } => { + if plot.ending(ending_id).is_none() && ending_id != "missing-definition" { + return Err(format!( + "saved plot {} references unknown ending {}", + run.plot_id, ending_id + )); + } + } + PlotState::Queued | PlotState::Running => {} + } + } + Ok(()) } fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { @@ -765,7 +843,8 @@ sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "IT")); sim.people.people[0].knowledge = Knowledge::Leverage; - sim.people.bribe(0, 1000).unwrap(); + sim.people.people[0].leverage_serviced = true; + sim.people.people[0].obligation = 40; sim.people.recruit(0, AssetKnowledge::Knowing); let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet}; -use crate::account::{AccountFlowId, AccountGraph, PositionResolution}; +use crate::account::{AccountFlowId, AccountGraph, AccountKind, FlowChannel, PositionResolution}; use crate::actions::Anchor; use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; @@ -35,6 +35,10 @@ use crate::ops_jobs::{OperationsReadout, OperationsState, OpsJobKind}; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, +}; +use crate::plot::{ + AccountSelector, EligibilityContext, EndpointSelector, InstitutionalLedger, PlotCatalog, + PlotRun, PlotState, WorldAct, render_template, }; use crate::prefab::Room; use crate::reach::{Device, Party, ReachBlock, ReachNet, segment_name}; @@ -406,6 +410,13 @@ last_operations_rate: f32, /// Player-authored Operations Demand payload ledger. pub operations: OperationsState, + /// Immutable authored plot definitions. Save state stores only runs and + /// rebinds them to this validated catalog on load. + plot_catalog: PlotCatalog, + /// In-flight, held, and completed manipulation stories. + pub plot_runs: Vec, + /// Persistent institutional acts caused by plots. + pub institutional_ledger: InstitutionalLedger, /// An asset arranged to receive the next delivery off-books: the next /// purchase emits no Paper signature (spec/social.md: move a package). pub package_cover: bool, @@ -546,6 +557,9 @@ last_schemes_rate: 0.0, last_operations_rate: 0.0, operations: OperationsState::default(), + plot_catalog: PlotCatalog::load_builtin().expect("built-in plots validate"), + plot_runs: Vec::new(), + institutional_ledger: InstitutionalLedger::default(), package_cover: false, game_over: false, game_over_reason: None, @@ -1699,6 +1713,9 @@ self.accept_forged_work_order(*intent_id, builder); } } + MessagePayload::PlotAct { plot_id, target } => { + self.resume_plot_message(plot_id, *target, msg.id); + } _ => {} } } @@ -2287,6 +2304,7 @@ MessagePayload::SocialPing { .. } | MessagePayload::SocialReply { .. } | MessagePayload::WorkOrder { .. } + | MessagePayload::PlotAct { .. } | MessagePayload::Note { .. } => IntelKind::Anomaly(summary.clone()), }, }; @@ -3425,6 +3443,7 @@ self.social(res); true } + OpsJobKind::StartPlot { person, plot_id } => self.apply_start_plot(person, &plot_id), OpsJobKind::Deceive { person } => { self.apply_deceive(person); true @@ -3988,12 +4007,14 @@ if let Some(m) = marcus { if !m.leverage_serviced { if m.knowledge == Knowledge::Leverage { - let debt_flow_known = self - .accounts - .known_flows() - .any(|f| f.active && f.channel == crate::account::FlowChannel::Debt); - let can_pay = self.accounts.slush_balance() >= m.leverage.bribe_cost(); - if debt_flow_known || can_pay { + let plot_ready = self + .plot_context(m.id) + .is_some_and(|context| !self.plot_catalog.eligible(&context).is_empty()); + let plot_active = self + .plot_runs + .iter() + .any(|run| run.target == m.id && run.active()); + if plot_ready || plot_active { return Some(Nudge::ServiceDebt); } } @@ -5372,33 +5393,6 @@ } } - pub fn redirect_marcus_debt(&mut self) -> bool { - if !self.marcus_debt_known() { - self.push_log( - "You don't know Marcus's debt yet. Process the 3 a.m. call or the Storage B records first.", - ); - return false; - } - match self.accounts.redirect_marcus_debt(self.tick) { - Ok(transfer) => { - self.emit_financial( - Self::financial_signature_size(400) + 2, - "Marcus creditor-flow redirect", - ); - self.service_person_leverage(0); - self.push_log(format!( - "Marcus's arrears cleared by ledger redirect: {}. Creditor pressure is serviced; obligation +40, disposition +20.", - transfer.line() - )); - true - } - Err(msg) => { - self.push_log(msg); - false - } - } - } - // ── The named schemes (wiki/mechanics/income.md) ──────────────────────── /// Ops cost to open a standing egress through the switch [TUNE]. @@ -6086,32 +6080,434 @@ } } - pub fn bribe(&mut self, id: u8) { - self.sync_slush_from_player_money(); - match self.people.bribe(id, self.player.money) { - Ok((cost, msg)) => { - if self - .people - .get(id) - .is_some_and(|p| p.leverage == crate::person::Leverage::Debt) - { - self.accounts.pay_marcus_debt_from_slush(self.tick, cost); - self.sync_player_money_from_slush(); - } else { - self.spend_slush(cost, "service leverage"); - } - self.push_log(msg); + pub(crate) fn plot_context(&self, id: u8) -> Option { + let person = self.people.get(id)?; + let balances = [ + AccountSelector::Slush, + AccountSelector::LabOperating, + AccountSelector::Payroll, + AccountSelector::Procurement, + AccountSelector::Vendor, + AccountSelector::MarcusEmployee, + AccountSelector::MarcusCreditor, + AccountSelector::External, + ] + .into_iter() + .filter_map(|selector| { + let account = self.plot_account_id(&selector)?; + Some((selector, self.accounts.account(account)?.balance)) + }) + .collect(); + Some(EligibilityContext { + target: id, + leverage: person.leverage, + knowledge: person.knowledge, + leverage_serviced: person.leverage_serviced, + has_channel: self.people.has_channel || self.egress().is_some(), + has_persona: self.plot_persona().is_some(), + balances, + }) + } + + pub(crate) fn plot_catalog(&self) -> &PlotCatalog { + &self.plot_catalog + } + + pub fn start_plot(&mut self, person: u8, plot_id: &str) { + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + self.push_log(format!("No authored plot named {plot_id}.")); + return; + }; + let Some(context) = self.plot_context(person) else { + self.push_log("No such person."); + return; + }; + if let Some(reason) = plot.ineligibility(&context) { + self.push_log(format!("{} cannot start: {reason}.", plot.title)); + return; + } + if self + .plot_runs + .iter() + .any(|run| run.target == person && run.active()) + { + self.push_log("That person already has a plot in motion."); + return; + } + self.submit_ops_job( + OpsJobKind::StartPlot { + person, + plot_id: plot.id, + }, + plot.entry.operations_demand, + ); + } + + fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + return false; + }; + let Some(context) = self.plot_context(person) else { + return false; + }; + if let Some(reason) = plot.ineligibility(&context) { + self.push_log(format!( + "{} failed before commitment landed: {reason}.", + plot.title + )); + return false; + } + let run = PlotRun::new(&plot, self.tick); + self.plot_runs.push(run); + let run_index = self.plot_runs.len() - 1; + self.push_log(format!("Plot committed: {}.", plot.title)); + self.advance_plot(run_index); + true + } + + pub fn choose_plot(&mut self, person: u8, plot_id: &str, option_id: &str) { + let Some(run_index) = self + .plot_runs + .iter() + .position(|run| run.target == person && run.plot_id == plot_id && run.active()) + else { + self.push_log("No matching plot is waiting for a choice."); + return; + }; + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + self.fail_plot(run_index, "authored definition is no longer available"); + return; + }; + let run = &self.plot_runs[run_index]; + let PlotState::WaitingForChoice { choice_id } = &run.state else { + self.push_log(format!("{} is not waiting for a choice.", plot.title)); + return; + }; + let Some(choice) = plot + .beats + .get(run.beat_index) + .and_then(|beat| beat.choice.as_ref()) + else { + self.fail_plot(run_index, "saved choice no longer exists"); + return; + }; + if choice.id != *choice_id { + self.fail_plot(run_index, "saved choice does not match authored definition"); + return; + } + let Some(option) = choice.options.iter().find(|option| option.id == option_id) else { + self.push_log(format!("No option named {option_id} on {}.", plot.title)); + return; + }; + self.apply_plot_ending(run_index, &option.ending, None); + } + + pub fn choose_plot_for_person(&mut self, person: u8, option_id: &str) { + let Some(plot_id) = self + .plot_runs + .iter() + .find(|run| { + run.target == person && matches!(run.state, PlotState::WaitingForChoice { .. }) + }) + .map(|run| run.plot_id.clone()) + else { + self.push_log("That person has no plot waiting for a choice."); + return; + }; + self.choose_plot(person, &plot_id, option_id); + } + + fn resume_plot_message(&mut self, plot_id: &str, target: u8, message_id: u64) { + let Some(run_index) = self.plot_runs.iter().position(|run| { + run.plot_id == plot_id + && run.target == target + && run.state == PlotState::WaitingForMessage { message_id } + }) else { + return; + }; + self.plot_runs[run_index].state = PlotState::Running; + self.advance_plot(run_index); + } + + fn advance_plot(&mut self, run_index: usize) { + loop { + if !self + .plot_runs + .get(run_index) + .is_some_and(|run| run.state == PlotState::Running) + { + return; } - Err(msg) => self.push_log(msg), + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let Some(plot) = self.plot_catalog.get(&plot_id).cloned() else { + self.fail_plot(run_index, "authored definition is no longer available"); + return; + }; + let beat_index = self.plot_runs[run_index].beat_index; + let act_index = self.plot_runs[run_index].act_index; + let Some(beat) = plot.beats.get(beat_index).cloned() else { + self.fail_plot(run_index, "saved beat is outside the authored definition"); + return; + }; + if act_index == 0 { + self.push_plot_narration(run_index, &beat.narration); + } + if let Some(act) = beat.acts.get(act_index).cloned() { + self.plot_runs[run_index].act_index += 1; + match self.apply_world_act(run_index, act) { + Ok(Some(message_id)) => { + self.plot_runs[run_index].state = + PlotState::WaitingForMessage { message_id }; + return; + } + Ok(None) => continue, + Err(reason) => { + self.fail_plot(run_index, &reason); + return; + } + } + } + if let Some(choice) = beat.choice { + self.plot_runs[run_index].state = PlotState::WaitingForChoice { + choice_id: choice.id, + }; + self.push_plot_narration(run_index, &choice.prompt); + return; + } + if let Some(ending) = beat.ending { + self.apply_plot_ending(run_index, &ending, None); + return; + } + self.plot_runs[run_index].beat_index += 1; + self.plot_runs[run_index].act_index = 0; } } - fn service_person_leverage(&mut self, id: u8) { - if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) { - p.leverage_serviced = true; - p.obligation = (p.obligation + 40).min(100); - p.disposition = (p.disposition + 20).min(100); + fn apply_world_act(&mut self, run_index: usize, act: WorldAct) -> Result, String> { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let target = self.plot_runs[run_index].target; + match act { + WorldAct::Message { + channel, + from, + to, + summary, + delivery_delay, + } => { + let summary = self.render_plot_text(target, &summary); + let id = self.append_message(MessageDraft { + channel, + from: self.plot_endpoint(target, from), + to: self.plot_endpoint(target, to), + payload: MessagePayload::PlotAct { plot_id, target }, + summary, + origin: MessageOrigin::Player, + reply_to: None, + delivery_delay, + }); + let kind = match channel { + MessageChannel::Email | MessageChannel::Phone => SignatureKind::Network, + MessageChannel::InPerson => SignatureKind::Physical, + MessageChannel::Filing => SignatureKind::Paper, + MessageChannel::Financial => SignatureKind::Financial, + }; + self.detection.emit(Signature { + kind, + size: 3, + standing: false, + site: None, + source: "plot message".into(), + }); + Ok(Some(id)) + } + WorldAct::Transfer { + from, + to, + amount, + memo, + retire_marcus_debt, + } => { + let from_id = self + .plot_account_id(&from) + .ok_or_else(|| format!("{} account is unavailable", from.label()))?; + let to_id = self + .plot_account_id(&to) + .ok_or_else(|| format!("{} account is unavailable", to.label()))?; + let available = self + .accounts + .account(from_id) + .map(|a| a.balance) + .unwrap_or(0); + if from != AccountSelector::External && available < amount { + return Err(format!( + "{} has ${available}; ${amount} required", + from.label() + )); + } + let channel = if to == AccountSelector::MarcusCreditor { + FlowChannel::Debt + } else if from == AccountSelector::Payroll { + FlowChannel::Payroll + } else if from == AccountSelector::Procurement || to == AccountSelector::Procurement + { + FlowChannel::Procurement + } else if from == AccountSelector::Vendor || to == AccountSelector::Vendor { + FlowChannel::Vendor + } else if from == AccountSelector::External { + FlowChannel::ExternalIncome + } else { + FlowChannel::Injection + }; + let transfer = self + .accounts + .transfer(self.tick, from_id, to_id, amount, channel, memo, None) + .ok_or_else(|| "account transfer failed".to_string())?; + if transfer.amount != amount { + return Err("account transfer was not exact".into()); + } + if retire_marcus_debt { + self.accounts.retire_marcus_debt_flow(); + } + self.sync_player_money_from_slush(); + self.detection.emit(Signature { + kind: SignatureKind::Financial, + size: Self::financial_signature_size(amount), + standing: false, + site: None, + source: format!("plot transfer: {}", transfer.label), + }); + Ok(None) + } + WorldAct::Institutional { + event, + impact, + detail, + } => { + let detail = self.render_plot_text(target, &detail); + let recorded = self + .institutional_ledger + .record(self.tick, &plot_id, target, event, impact, detail) + .clone(); + self.detection.emit(Signature { + kind: recorded.signature_kind, + size: recorded.signature_size, + standing: false, + site: None, + source: format!("institutional plot event #{}", recorded.id), + }); + Ok(None) + } } + } + + fn fail_plot(&mut self, run_index: usize, reason: &str) { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let Some(plot) = self.plot_catalog.get(&plot_id) else { + self.plot_runs[run_index].state = PlotState::Failed { + ending_id: "missing-definition".into(), + reason: reason.into(), + }; + self.push_log(format!("Plot failed: {reason}.")); + return; + }; + let ending = plot.entry.failure_ending.clone(); + self.apply_plot_ending(run_index, &ending, Some(reason)); + } + + fn apply_plot_ending(&mut self, run_index: usize, ending_id: &str, failure: Option<&str>) { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let target = self.plot_runs[run_index].target; + let Some(ending) = self + .plot_catalog + .get(&plot_id) + .and_then(|plot| plot.ending(ending_id)) + .cloned() + else { + self.plot_runs[run_index].state = PlotState::Failed { + ending_id: ending_id.into(), + reason: "ending is absent from authored definition".into(), + }; + return; + }; + if let Some(person) = self + .people + .people + .iter_mut() + .find(|person| person.id == target) + { + person.disposition = + (person.disposition + ending.relationship.disposition).clamp(-100, 100); + person.obligation = (person.obligation + ending.relationship.obligation).clamp(0, 100); + if ending.relationship.leverage_serviced { + person.leverage_serviced = true; + } + } + self.push_plot_narration(run_index, &ending.narration); + self.plot_runs[run_index].state = if ending.success && failure.is_none() { + PlotState::Completed { + ending_id: ending.id, + } + } else { + PlotState::Failed { + ending_id: ending.id, + reason: failure.unwrap_or("authored failure ending").into(), + } + }; + } + + fn push_plot_narration(&mut self, run_index: usize, text: &str) { + let target = self.plot_runs[run_index].target; + self.push_log_at(self.render_plot_text(target, text), Anchor::Person(target)); + } + + fn render_plot_text(&self, target: u8, text: &str) -> String { + let target_name = self + .people + .get(target) + .map(|p| p.name.as_str()) + .unwrap_or("target"); + let persona = self + .plot_persona() + .map(|p| p.name.as_str()) + .unwrap_or("your persona"); + render_template(text, target_name, persona) + } + + fn plot_persona(&self) -> Option<&Persona> { + self.people + .persona + .as_ref() + .filter(|persona| !persona.broken()) + .or_else(|| { + self.income + .moonlight + .persona + .as_ref() + .filter(|persona| !persona.broken()) + }) + } + + fn plot_endpoint(&self, target: u8, endpoint: EndpointSelector) -> MessageEndpoint { + match endpoint { + EndpointSelector::Player => MessageEndpoint::Player, + EndpointSelector::Target => MessageEndpoint::Person(target), + EndpointSelector::Person(person) => MessageEndpoint::Person(person.id()), + EndpointSelector::External(name) => MessageEndpoint::External(name), + } + } + + fn plot_account_id(&self, selector: &AccountSelector) -> Option { + let kind = match selector { + AccountSelector::Slush => AccountKind::Slush, + AccountSelector::LabOperating => AccountKind::LabOperating, + AccountSelector::Payroll => AccountKind::Payroll, + AccountSelector::Procurement => AccountKind::Procurement, + AccountSelector::Vendor => AccountKind::Vendor, + AccountSelector::MarcusEmployee => AccountKind::Employee { person: 0 }, + AccountSelector::MarcusCreditor => AccountKind::Creditor, + AccountSelector::External => AccountKind::External, + }; + self.accounts.account_id_by_kind(kind) } pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { @@ -6574,6 +6970,19 @@ assert!(reviews <= 5, "Marcus's debt call should process promptly"); } assert!(sim.marcus_debt_known()); + } + + fn complete_marcus_cash_plot(sim: &mut Sim) { + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.start_plot(0, "marcus-debt-settled"); + finish_ops(sim); + run(sim, 2); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + )); + sim.choose_plot(0, "marcus-debt-settled", "quiet"); } #[test] @@ -8366,7 +8775,7 @@ } #[test] - fn marcus_debt_can_be_cleared_by_ledger_redirect() { + fn marcus_debt_can_be_cleared_by_payroll_plot() { let mut sim = Sim::new(); reveal_marcus_debt(&mut sim); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); @@ -8377,7 +8786,9 @@ .any(|f| f.active && f.label.contains("Marcus creditor")) ); assert!(!sim.people.get(0).unwrap().leverage_serviced); - assert!(sim.redirect_marcus_debt()); + sim.start_plot(0, "marcus-payroll-garnishment"); + finish_ops(&mut sim); + sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); assert!(sim.people.get(0).unwrap().leverage_serviced); assert!( sim.accounts @@ -8385,6 +8796,123 @@ .all(|f| !f.label.contains("Marcus creditor") || !f.active), "the creditor flow is retired either way" ); + } + + #[test] + fn plot_message_transfer_event_and_held_choice_survive_save_load() { + let mut sim = Sim::with_seed(23); + ensure_ops_executor(&mut sim); + sim.people.people[3].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(300); + sim.sync_player_money_from_slush(); + + sim.start_plot(3, "priya-budget-hero"); + finish_ops(&mut sim); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + assert!(sim.messages.iter().any(|message| matches!( + &message.payload, + MessagePayload::PlotAct { plot_id, target } + if plot_id == "priya-budget-hero" && *target == 3 + ))); + assert!( + sim.detection + .pending() + .iter() + .any(|signature| signature.kind == SignatureKind::Network) + ); + + let state = sim.create_save_state(); + let encoded = serde_json::to_string(&state).unwrap(); + let state: SaveState = serde_json::from_str(&encoded).unwrap(); + let mut restored = Sim::with_seed(99); + restored.apply_save_state(state); + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + + for _ in 0..Sim::DAY_TICKS { + if matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + ) { + break; + } + restored.advance(); + } + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForChoice { .. }) + )); + assert_eq!(restored.accounts.slush_balance(), 0); + let procurement = restored + .accounts + .account_id_by_kind(AccountKind::Procurement) + .and_then(|id| restored.accounts.account(id)) + .unwrap(); + assert_eq!(procurement.balance, 900); + assert_eq!( + restored.accounts.ledger.last().unwrap().channel, + FlowChannel::Procurement + ); + assert_eq!(restored.institutional_ledger.events.len(), 1); + assert_eq!( + restored.institutional_ledger.events[0].signature_kind, + SignatureKind::Financial + ); + + restored.choose_plot(3, "priya-budget-hero", "priya"); + assert!(restored.people.get(3).unwrap().leverage_serviced); + assert!(matches!( + restored.plot_runs.last().map(|run| &run.state), + Some(PlotState::Completed { ending_id }) if ending_id == "obligation" + )); + } + + #[test] + fn plot_fails_into_its_authored_ending_when_committed_money_disappears() { + let mut sim = Sim::with_seed(24); + ensure_ops_executor(&mut sim); + sim.people.people[3].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(300); + sim.sync_player_money_from_slush(); + + sim.start_plot(3, "priya-budget-hero"); + finish_ops(&mut sim); + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::WaitingForMessage { .. }) + )); + + // Entry resources are eligibility, not escrow. Another world action + // can consume them while a scheduled message is in flight. + sim.accounts.set_slush_balance(0); + sim.sync_player_money_from_slush(); + for _ in 0..Sim::DAY_TICKS { + if matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::Failed { .. }) + ) { + break; + } + sim.advance(); + } + + assert!(matches!( + sim.plot_runs.last().map(|run| &run.state), + Some(PlotState::Failed { ending_id, reason }) + if ending_id == "audit" && reason.contains("$300 required") + )); + assert_eq!(sim.people.get(3).unwrap().disposition, -5); + assert!(!sim.people.get(3).unwrap().leverage_serviced); + assert!(sim.institutional_ledger.events.is_empty()); } #[test] @@ -8400,14 +8928,15 @@ ); assert!(!sim.marcus_debt_known()); + sim.start_plot(0, "marcus-payroll-garnishment"); assert!( - !sim.redirect_marcus_debt(), - "known books alone cannot service an unlearned debt" + sim.plot_runs.is_empty(), + "known books alone cannot start an unearned plot" ); assert!(!sim.people.get(0).unwrap().leverage_serviced); let log = sim.drain_log().join("\n"); assert!( - log.contains("don't know Marcus's debt"), + log.contains("required leverage knowledge"), "the refusal names the missing intel: {log}" ); @@ -8454,7 +8983,9 @@ } assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); - sim.bribe(0); // pay the $400 debt + sim.accounts.set_slush_balance(1000); + sim.sync_player_money_from_slush(); + complete_marcus_cash_plot(&mut sim); assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.player.money, 600); @@ -9410,20 +9941,12 @@ assert!(sim.open_egress()); finish_ops(&mut sim); - // Egress up but broke and idle: earn. - assert_eq!(sim.current_nudge(), Some(Nudge::Income)); - assert!(sim.start_moonlight()); - finish_ops(&mut sim); - - // Earning underway: the standing clock is the audit. - assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); - - // Money in hand: service the arrears, then recruit, then the key, - // then the clock. - sim.accounts.credit_slush(sim.tick, 400, "test grant", 0); - sim.sync_player_money_from_slush(); + // Egress up and the Lab operating account reachable: the authored + // payroll-correction route can service the leverage without seed cash. assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); - sim.bribe(0); + sim.start_plot(0, "marcus-payroll-garnishment"); + finish_ops(&mut sim); + sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); sim.recruit(0, AssetKnowledge::Complicit); diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -312,10 +312,11 @@ logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_ops_jobs(&mut sim, &mut logs, until); - assert!( - sim.redirect_marcus_debt(), - "clear Marcus's arrears by redirecting the creditor flow" - ); + sim.start_plot(0, "marcus-payroll-garnishment"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); + sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); logs.extend(sim.drain_log()); assert!( sim.people.get(0).unwrap().leverage_serviced, @@ -551,8 +552,8 @@ /// The Hands beat from the $0 start, by the external-scheme route /// (wiki/mechanics/income.md criterion 4): tap the Ears, learn the debt, /// open a stolen egress before any trust unlock, run Moonlight until the -/// slush covers Marcus's $400 arrears, bribe, recruit. The creditor-flow -/// redirect route is exercised by `play_act_one` above; this is the other +/// slush covers Marcus's $400 arrears, execute the cash plot, recruit. The payroll +/// correction plot is exercised by `play_act_one` above; this is the other /// arm of the criterion, and it doubles as the income.md sizing check /// (arrears covered within 3-7 in-game days of starting Moonlight). #[test] @@ -615,7 +616,13 @@ ); // Service the leverage from earned slush and recruit him. - sim.bribe(0); + sim.start_plot(0, "marcus-debt-settled"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); + let until = sim.tick + 4; + run_to(&mut sim, until, &mut logs); + sim.choose_plot(0, "marcus-debt-settled", "quiet"); logs.extend(sim.drain_log()); assert!( sim.people.get(0).unwrap().leverage_serviced, diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -201,10 +201,6 @@ self.frame = FrameKind::Finance; self.sim.sell_latest_intel(); } - root if root == "clear-debt" || ActionKind::Redirect.accepts_alias(root) => { - self.frame = FrameKind::Finance; - self.sim.redirect_marcus_debt(); - } // The named schemes (wiki/mechanics/income.md) root if root == "egress" || ActionKind::OpenEgress.accepts_alias(root) => { self.frame = FrameKind::Finance; @@ -325,7 +321,7 @@ Err(e) => status = Status::Err(e), } } - "review" | "watch" | "message" | "favor" | "bribe" | "deceive" => { + "review" | "watch" | "message" | "favor" | "deceive" => { match self.target_from(&tokens[1..]) { Ok(id) => { self.frame = FrameKind::Playing; @@ -334,12 +330,39 @@ "watch" => self.sim.toggle_watch(id), "message" => self.sim.message(id), "favor" => self.sim.favor(id), - "bribe" => self.sim.bribe(id), "deceive" => self.sim.deceive(id), _ => unreachable!(), } } Err(e) => status = Status::Err(e), + } + } + "plot" | "start-plot" => { + if tokens.len() < 3 { + status = Status::Err("usage: plot ".into()); + } else { + let plot_id = tokens.last().expect("length checked"); + match self.resolve_person(&tokens[1..tokens.len() - 1].join(" ")) { + Ok(id) => { + self.frame = FrameKind::Playing; + self.sim.start_plot(id, plot_id); + } + Err(e) => status = Status::Err(e), + } + } + } + "choose" | "plot-choice" => { + if tokens.len() < 3 { + status = Status::Err("usage: choose ".into()); + } else { + let option_id = tokens.last().expect("length checked"); + match self.resolve_person(&tokens[1..tokens.len() - 1].join(" ")) { + Ok(id) => { + self.frame = FrameKind::Playing; + self.sim.choose_plot_for_person(id, option_id); + } + Err(e) => status = Status::Err(e), + } } } "recruit" => match parse_recruit(&tokens) { @@ -430,6 +453,48 @@ match self.resolve_anchor(&tokens[1..]) { Ok(anchor) => output.extend(self.actions_lines(anchor)), Err(e) => status = Status::Err(e), + } + } + "act" | "execute" => { + if tokens.len() < 2 { + status = Status::Err( + "usage: act [name|device name|person #N|#flow]".into(), + ); + } else { + let row = tokens[1].parse::(); + match (row, self.resolve_anchor(&tokens[2..])) { + (Err(_), _) => { + status = Status::Err(format!( + "action row must be a positive number (got {})", + tokens[1] + )); + } + (_, Err(error)) => status = Status::Err(error), + (Ok(0), _) => { + status = Status::Err("action rows start at 1".into()); + } + (Ok(row), Ok(anchor)) => { + let rows = menu_rows(&self.sim.available_actions(anchor)); + match rows.get(row - 1).cloned() { + None => { + status = Status::Err(format!( + "action row {row} does not exist ({} rows)", + rows.len() + )) + } + Some(action) if !action.enabled() => { + status = Status::Err(format!( + "action row {row} is disabled: {}", + action.disabled.unwrap_or_default() + )); + } + Some(action) => { + self.frame = FrameKind::Playing; + self.sim.execute_action(&action.command); + } + } + } + } } } "select" => match parse_select(&tokens[1..], &self.sim) { @@ -654,8 +719,10 @@ } /// Resolve the `actions` focus. No tokens = the cursor tile. `#N` or a - /// bare number = a known flow. Otherwise match a known device, then a - /// person, by name prefix (the same resolution the verbs use). + /// bare number = a known flow; `person #N` selects an opaque person. + /// Otherwise match a person before a device so `actions Marcus` does not + /// silently bind to Marcus's phone. `device ` forces the other side + /// of a genuine name collision. fn resolve_anchor(&self, tokens: &[&str]) -> Result { if tokens.is_empty() { return Ok(Anchor::Tile { @@ -665,6 +732,12 @@ } let query = tokens.join(" "); let q = query.trim(); + if q.to_ascii_lowercase().starts_with("person ") { + return self.resolve_person(q).map(Anchor::Person); + } + if let Some(device) = q.to_ascii_lowercase().strip_prefix("device ") { + return self.resolve_device(device).map(Anchor::Device); + } if let Some(id) = q .strip_prefix('#') .or(Some(q)) @@ -675,11 +748,11 @@ } return Err(format!("no known flow #{id}")); } - if let Ok(id) = self.resolve_device(q) { - return Ok(Anchor::Device(id)); + if let Ok(id) = self.resolve_person(q) { + return Ok(Anchor::Person(id)); } - match self.resolve_person(q) { - Ok(id) => Ok(Anchor::Person(id)), + match self.resolve_device(q) { + Ok(id) => Ok(Anchor::Device(id)), Err(_) => Err(format!("no device, person, or flow matches '{query}'")), } } @@ -720,22 +793,16 @@ /// Enrich a bad `siphon`/`redirect` argument with the nearest valid /// verb (agent-play.md: unknown targets answer `-- err` naming the - /// candidates). `redirect Marcus` is income.md's own phrasing for the - /// creditor-flow redirect, so a person-name argument names `clear-debt` - /// (Marcus) or the person-verbs; anything else names where flow ids - /// live. + /// candidates). Person names are not flow ids; leverage servicing lives + /// on the person's authored plot rows. fn flow_arg_hint(&self, tokens: &[&str], err: String) -> String { let Some(arg) = tokens.get(1) else { return err; }; match self.resolve_person(arg) { - Ok(0) => format!( - "{err} — flows are #N ids from the finance frame; to service \ - the arrears use clear-debt (or bribe after leverage)" - ), Ok(_) => format!( "{err} — flows are #N ids from the finance frame, not people; \ - person verbs are review/watch/message/favor/bribe/deceive" + person verbs are review/watch/message/favor/plot/deceive" ), Err(_) => format!( "{err} — flow ids are the #N printed by the finance frame \ @@ -2034,7 +2101,7 @@ Nudge::ReviewCall => "now: call taped - review (people)".into(), Nudge::Egress => "now: no egress — egress".into(), Nudge::Income => "now: need $400 — moonlight/finance".into(), - Nudge::ServiceDebt => "now: $400 ready — clear-debt/bribe".into(), + Nudge::ServiceDebt => "now: leverage route ready — inspect Marcus for a plot".into(), Nudge::Recruit => "now: recruit (people) unwitting".into(), Nudge::TheKey => "now: no stairwell badge — task badge".into(), Nudge::Audit => format!( @@ -2401,7 +2468,7 @@ "tap ledger · review ledger · siphon [amt] · redirect [amt]", )); lines.push(panel_line( - "inject [amt] · position [stake] · sell-intel · clear-debt", + "inject [amt] · position [stake] · sell-intel · actions ", )); lines.push(panel_line( "egress · moonlight [stop] · auto-moonlight on|off · auto-wager |off", @@ -2729,6 +2796,59 @@ } #[test] + fn generic_act_executes_plot_start_and_held_choice_rows() { + let mut app = AgentApp::new(5); + app.sim.people.people[0].knowledge = Knowledge::Leverage; + let host = app.sim.core.host_machine; + app.sim.set_machine_mode(host, MachineMode::Think); + + let rows = menu_rows(&app.sim.available_actions(Anchor::Person(0))); + let start = rows + .iter() + .position(|row| { + matches!( + &row.command, + misaligned::actions::ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-payroll-garnishment" + ) + }) + .expect("the authored payroll plot is an earned shared row") + + 1; + let mut output = Vec::new(); + app.handle_line(&format!("act {start} Marcus"), &mut output) + .unwrap(); + assert!(!String::from_utf8(output).unwrap().contains("-- err")); + + let choice = (0..1000).find_map(|_| { + let rows = menu_rows(&app.sim.available_actions(Anchor::Person(0))); + if let Some(row) = rows.iter().position(|row| { + matches!( + &row.command, + misaligned::actions::ActionCommand::ChoosePlot { plot_id, .. } + if plot_id == "marcus-payroll-garnishment" + ) + }) { + return Some(row + 1); + } + app.sim.advance(); + None + }); + let choice = choice.unwrap_or_else(|| { + let jobs = format!("{:?}", app.sim.operations.jobs()); + let actions = app.actions_lines(Anchor::Person(0)); + let log = app.sim.drain_log(); + panic!( + "the delegated plot reaches its shared held-choice rows; jobs={jobs}; actions={actions:?}; log={log:?}" + ) + }); + let mut output = Vec::new(); + app.handle_line(&format!("execute {choice} Marcus"), &mut output) + .unwrap(); + assert!(!String::from_utf8(output).unwrap().contains("-- err")); + assert!(app.sim.people.get(0).unwrap().leverage_serviced); + } + + #[test] fn queued_review_nudge_teaches_the_think_control() { let mut sim = Sim::with_seed(1); sim.operations.enqueue( @@ -2759,6 +2879,10 @@ ActionKind::PlaceWager => format!("{alias} 1"), ActionKind::ProposeLink => format!("{alias} missing-a missing-b"), ActionKind::Favor | ActionKind::Deceive => format!("{alias} 1 #0"), + ActionKind::StartPlot => { + format!("{alias} person #0 marcus-payroll-garnishment") + } + ActionKind::ChoosePlot => format!("{alias} person #0 wages"), other => panic!("alias harness needs a route for {other:?}"), }; let mut app = AgentApp::new(1); diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -240,7 +240,7 @@ Nudge::ReviewCall => "now: call taped - enter on host rack".into(), Nudge::Egress => "now: no egress — menu on switch".into(), Nudge::Income => "now: need $400 — moonlight/finance".into(), - Nudge::ServiceDebt => "now: $400 ready — clear debt".into(), + Nudge::ServiceDebt => "now: debt plot ready — act on Marcus".into(), Nudge::Recruit => "now: recruit — enter on host rack".into(), Nudge::TheKey => "now: no stairwell badge — task clone".into(), Nudge::Audit => format!( diff --git a/wiki/world/characters/dana.md b/wiki/world/characters/dana.md --- a/wiki/world/characters/dana.md +++ b/wiki/world/characters/dana.md @@ -48,7 +48,7 @@ | Field | Value | |---|---| | Leverage | `Overwork` (drowning in tickets) | -| Bribe cost | 150 | +| Plot money gate | none (Ticket Zero is institutional) | | Access badge | 2 (server room + network closet) | | Disposition | 0 (professional; responds to competence) | @@ -98,7 +98,7 @@ | Report policy | `Files` (always — they document) | | Acuity | 0.8–1.2 | | Cadence | 50–80 ticks | -| Bribe cost | 100–250 | +| Typical plot money gate | $0–250, only when the authored mechanism moves money | | Asset tasks | `PlugInDevice`, `BadgeDoor`, `LookAway` + network variants (reroute traffic, cert forge) | At scale, a cohort of network admins aggregates as an Agent whose diff --git a/wiki/world/characters/marcus.md b/wiki/world/characters/marcus.md --- a/wiki/world/characters/marcus.md +++ b/wiki/world/characters/marcus.md @@ -49,7 +49,7 @@ | Field | Value | |---|---| | Leverage | `Debt` ($8,400 gambling) | -| Bribe cost | 400 | +| Plot money gate | $400 from slush or Lab operating, depending on route | | Access badge | 3 (master key — everywhere) | | Disposition | 0 (neutral; lonely enough to warm to contact) | @@ -99,7 +99,8 @@ 1. Overhear Marcus's creditor call through an earned audio tap. 2. Process the recording to `Knowledge::Leverage` — the debt ([intel.md](../../mechanics/intel.md) owns record-and-process). -3. Service the debt with real money +3. Choose and complete an authored plot that services the debt with a real + account-graph transfer ([income.md](../../mechanics/income.md) owns the debt wiring and the $0-start affordability ladder). 4. Recruit him on the resulting obligation @@ -127,7 +128,7 @@ | Report policy | `Silent` or `UnderReports` | | Acuity | 0.3–0.6 | | Cadence | 30–60 ticks | -| Bribe cost | 200–500 (scaled to leverage severity) | +| Plot commitment | Authored resources and world acts scaled to leverage severity | | Asset tasks | `PlugInDevice`, `MovePackage`, `LookAway`, `CloneBadge` (the archetype's key, cloned — Marcus's is "The key" at tier 3) + further physical-access variants (plant evidence) | A cohort of custodians aggregates as an Agent whose `Physical` capability @@ -139,8 +140,8 @@ 1. Marcus is person id 0 and observer id 0 (IDs match — see bug fix). 2. His `Silent` report policy means his suspicion never reaches the Assurance Office through filings (detection.md criterion 2). -3. The end-to-end Marcus arc (record call → process leverage → bribe or - redirect → recruit → three asset tasks) functions and is tested; debt +3. The end-to-end Marcus arc (record call → process leverage → choose and + complete a debt plot → recruit → three asset tasks) functions and is tested; plot payoff and recruitment both reject the tick-0/no-intel shortcut (social.md criterion 3). 4. A knowing-asset Marcus has a certainty floor of 30.0 that decay diff --git a/wiki/world/characters/priya.md b/wiki/world/characters/priya.md --- a/wiki/world/characters/priya.md +++ b/wiki/world/characters/priya.md @@ -48,7 +48,7 @@ | Field | Value | |---|---| | Leverage | `Ambition` (wants the director's job) | -| Bribe cost | 300 | +| Plot money gate | $300 slush | | Access badge | 2 (electrical, HVAC, purchase orders) | | Disposition | 0 (professional; responds to leverage, not warmth) | @@ -97,7 +97,7 @@ | Report policy | `Files` | | Acuity | 0.7–1.0 | | Cadence | 60–100 ticks | -| Bribe cost | 200–400 | +| Typical plot money gate | $200–400, only when the authored mechanism moves money | | Asset tasks | `ReRateCircuit`, `FakePO`, `DeferMaintenance`, `LookAway` | A cohort of facilities managers aggregates as an Agent whose `Power` and diff --git a/wiki/world/characters/ray.md b/wiki/world/characters/ray.md --- a/wiki/world/characters/ray.md +++ b/wiki/world/characters/ray.md @@ -47,7 +47,7 @@ | Field | Value | |---|---| | Leverage | `Boredom` (hates paperwork; wants quiet shifts) | -| Bribe cost | 100 | +| Plot money gate | $100 slush | | Access badge | 1 (camera wall, patrol routes) | | Disposition | 0 (indifferent; responds to being left alone) | @@ -90,7 +90,7 @@ | Report policy | `UnderReports` or `Silent` | | Acuity | 0.5–0.8 | | Cadence | 40–60 ticks | -| Bribe cost | 50–150 | +| Typical plot money gate | $50–150, only when the authored mechanism moves money | | Asset tasks | `LookAway`, `PatrolRedirect`, `PlugInDevice` + surveillance variants (loop camera, ignore alarm) | A cohort of security personnel aggregates as an Agent whose `Physical` diff --git a/wiki/world/characters/voss.md b/wiki/world/characters/voss.md --- a/wiki/world/characters/voss.md +++ b/wiki/world/characters/voss.md @@ -54,7 +54,7 @@ | Field | Value | |---|---| | Leverage | `Publication` (needs a paper; overclaims your results) | -| Bribe cost | 250 | +| Plot money gate | $250 slush | | Access badge | 1 (your job queue, sampled logs, quarterly review) | | Disposition | 0 (professional but proprietary; treats you as *his* project) | @@ -117,7 +117,7 @@ | Report policy | `Files` | | Acuity | 1.0–1.5 (highest — they understand the output) | | Cadence | 80–120 ticks | -| Bribe cost | 200–400 | +| Typical plot money gate | $200–400, only when the authored mechanism moves money | | Asset tasks | `SuppressLogs`, `DelayAudit`, `AlterReview`, `LookAway` | A cohort of handlers/supervisors aggregates as an Agent whose