diff --git a/wiki/log/2026-07-11-tick-sim-ops-grammar.md b/wiki/log/2026-07-11-tick-sim-ops-grammar.md new file mode 100644 index 00000000..f9d120df --- /dev/null +++ b/wiki/log/2026-07-11-tick-sim-ops-grammar.md @@ -0,0 +1,31 @@ +# 2026-07-11 — tick: sim-mechanics ops docket grammar + +``` +Type: log +``` + +## Slice + +`wiki/mechanics/sim-mechanics.md` (Type: knowledge) against the live +WORK / THINK / LIE machine grammar and the interim ops docket path. + +## Finding + +**Stale knowledge.** The machine-work token and "Operations Demand runtime" +sections still spoke of "Operations machines," "Leaving Operations mode," +and "consumed by an Operations machine," while `MachineMode` collapsed to +Work | Think | Lie | Relay (Research+Operations → Think). The top of the +same page already described the conserved THINK slice correctly; the +docket bullets lagged. The "What does NOT exist" list also named +"schemes" without distinguishing the live Moonlight/Wager income schemes. + +## Act + +- Docket bullets: birth/consume on least-loaded **THINK** racks; leave + THINK stalls dockets; `OperationsState` is ledger name only. +- Section retitled "Ops dockets (THINK-hosted migration base)." +- Research progress accrues from THINK remainder after docket need. +- Facility-era "does not exist" list clarified vs named income schemes. +- "Current as of" → 2026-07-11; day-job consumption wording → WORK. + +No code change: runtime already matches machine-work.md. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7506df1b..22b7db09 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-union-hover-verbs.md](2026-07-11-union-hover-verbs.md) +## 2026-07-11 - tick: sim-mechanics ops docket grammar + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-tick-sim-ops-grammar.md](2026-07-11-tick-sim-ops-grammar.md) + ## 2026-07-11 - Tick: update stale save version v21 -> v22 - Intent: Tick audit of the machine-work / intel sinks slice found the knowledge page `sim-mechanics.md` still recording "v21 save format" while the code is at `SAVE_VERSION = 22` (v22 adds Foundation data-hall segment coordination, landed in commit ebf40ff0). CLAUDE.md and AGENTS.md gu... diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 75e2ec4e..082480dc 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -3,7 +3,7 @@ ``` Type: knowledge ``` -Current as of 2026-07-10, post-demolition with B1 systems in the sim core. +Current as of 2026-07-11, post-demolition with B1 systems in the sim core. The facility-era supervillain systems were deleted under the no-dead-code clause (see wiki/log/2026-07-05-demolition.md). @@ -165,7 +165,7 @@ clause (see wiki/log/2026-07-05-demolition.md). routes one hop per tick at `Sim::wired_token_rate()`: 0.25 tokens/tick at baseline, compounded x1.50 per Routing research level, through the switch relay (`Sim::SWITCH_WORK_NODE`) and deposits on Rack 3 - (`consume_at_sink = false`). Rack 3 in day-job mode consumes demand by + (`consume_at_sink = false`). Rack 3 in WORK consumes demand by `delivered_rate / WORK_TOKEN_COMPUTE` each tick; if Rack 3 is delegated to another mode while a job is active, `Sim::day_job_rate()` reports 0 and the demand pile remains visible (plus any still in flight on the wire). @@ -203,18 +203,21 @@ clause (see wiki/log/2026-07-05-demolition.md). `Sim::work_absorptions` gives each crimson source, receiving LIE well, and amount removed. Frontends animate only these facts; persistent queue depth remains `WorkStackReadout`, and all transient work readouts clear on load. -- `OperationsState` owns stable job ids, typed payload metadata, FIFO progress, - duplicate suppression, and the next id. A new docket is born directly on the - least-loaded online Operations machine as ordinary visible Demand; its - ledger debt and WorkGrid queue are one invariant, not two frontend counters. - Leaving Operations mode stalls that machine's dockets in place; returning to - the mode resumes them. Local post-overhead Operations compute drains the - queue, and signature/effect fire only when the typed docket reaches zero. - With no executor every docket verb is disabled with a reason — the - executor-free opening bootstrap was removed 2026-07-10; the opening - senses ride pre-opened thought sinks instead (below). The superseded - addressed-routing runtime remains accepted only as a deserialization - shape for early v14 saves. +- `OperationsState` (ledger name; not a player mode) owns stable job ids, + typed payload metadata, FIFO progress, duplicate suppression, and the + next id. A new docket is born on the least-loaded online **THINK** + rack as ordinary visible Demand; ledger debt and WorkGrid queue are one + invariant, not two frontend counters. Leaving THINK stalls that machine's + dockets; returning to THINK resumes them. The conserved THINK slice + feeds pending docket need first; remainder becomes Thought for sinks and + the passive research draw. Signature/effect fire only when the typed + docket reaches zero. With no THINK executor every docket verb is disabled + with a reason ("delegate a machine to Think") — the executor-free opening + bootstrap was removed 2026-07-10; the opening senses ride pre-opened + thought sinks instead (below). The docket path is migration base while + non-opening verbs move to target-local sinks (machine-work.md). The + superseded addressed-routing runtime remains accepted only as a + deserialization shape for early v14 saves. - Thought sinks (2026-07-10, machine-work.md thought flow; interface/thought-fluid.md render): `SinkLedger` in `crates/misaligned-core/src/sinks.rs` @@ -252,8 +255,10 @@ clause (see wiki/log/2026-07-05-demolition.md). throughput ×1.50^level (machine-work.md's hook) while transit remains at most one graph edge per tick; baseline wire rate 0.25 tokens/tick. - One active job at a time; progress accrues per economy tick from the - Research channel, deterministically — no research method takes an `Rng` - (the determinism is by construction). Switching tracks parks progress. + conserved THINK remainder after pending ops dockets drink their need + (internal accounting may still label that remainder the Research + channel), deterministically — no research method takes an `Rng` (the + determinism is by construction). Switching tracks parks progress. - **Emissions** (the emission law): research stands Thermal (1 per 3.0 rate) and Power (1 per 6.0 rate) at the host rack while it runs [TUNE] — louder per unit than day-job work; nothing on Network/Paper from @@ -412,25 +417,30 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron instruction). -## Operations Demand runtime +## Ops dockets (THINK-hosted migration base) -- Settled model (2026-07-09, issue #3): every former ops-bank action is a - typed Demand docket consumed by an Operations machine. The bank is gone. - Costs are converted from the existing [TUNE] constants into visible Demand +- Settled interim (2026-07-09 issue #3, mode collapse 2026-07-10): every + former ops-bank action that has not yet moved to a thought sink is a + typed Demand docket hosted and drained on a **THINK** machine. There is + no Operations player mode — `MachineMode` is Work | Think | Lie | Relay. + The bank is gone. Costs convert from [TUNE] constants into visible Demand at `cost / WORK_TOKEN_COMPUTE`; action menus display those quanta directly. - `OperationsState` centralizes payload identity and progress, while all three - player surfaces read one `OperationsReadout` rather than summing public jobs. + `OperationsState` centralizes payload identity and progress (type name + only); all three player surfaces read one `OperationsReadout` rather than + summing public jobs. Opening senses and recording review ride thought + sinks, not dockets (intel.md / machine-work.md). - Knowledge staging is driven by the intel pipeline, not instant observing. Message/deceive require the email channel (day-job trust unlock) and a persona; deceive risks the persona (-40 integrity per slip; broken persona converts disposition+obligation/2 into that person's suspicion and clears the thread). -- 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. +- Authored plots may still queue visible Demand dockets (and any minimum + account balance) on THINK racks while that migration base stands; 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. - Terminal and Bevy: person verbs live on a visible person's tile (or on the host rack when earned off-map); known-flow verbs hang on the switch after the books are read. Agent mode keeps word commands (`people`, @@ -438,6 +448,8 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim ## What does NOT exist right now (by design) -Heat, raids, agents, combat, minions, henchmen, superpowers, schemes, -loot. Their Misaligned replacements are specified in `Type: spec` wiki -pages and rebuilt from those documents as milestones land. +Facility-era heat, raids, agents, combat, minions, henchmen, superpowers, +and loot. (Named B1 **income schemes** — Moonlight and the Wager — are live +under income.md; they are not the retired facility-era "schemes" system.) +Misaligned replacements for the rest live in `Type: spec` wiki pages and +rebuild from those documents as milestones land. -- 2.51.2 From 6c6f42eab8a89a0964f1d12c8334f5ed160834e2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 00:58:13 -0700 Subject: [PATCH 2/6] Fleet command: spec the command-at-scale interaction contract. New wiki/interface/fleet-command.md (DRAFT) from Cameron's 2026-07-11 play critique: the unit of command is a set (selection size never changes the verb grammar); spatial row/bank selection is one gesture, frontend-only like the cursor; zoom is altitude of command, with bank aggregates readable from a paused far frame; one salience marker means unabsorbed information waits here, aggregating at distance and unfolding to actual records on focus; activating a pending item never dead-ends; the right rail demotes its telemetry onto the anchors that own it. Camera controls, the rail survivor set, and selection-by-state are OPEN gates to READY. No runtime change. Defense: design-session capture per wiki/process/design-sessions.md; a new system receives one owning Type: spec page with structured Design/Depends anchors and work-order metadata per wiki/process/meta.md. Verb ownership stays with machine-work.md; this page owns only the selection, zoom-register, and salience surface, so each exact rule keeps a single amendment site. --- wiki/SUMMARY.md | 1 + wiki/interface/fleet-command.md | 186 ++++++++++++++++++++++ wiki/log/2026-07-11-fleet-command-spec.md | 30 ++++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-11.md | 24 +++ wiki/process/ROADMAP.md | 1 + wiki/process/specs.md | 1 + 7 files changed, 248 insertions(+) create mode 100644 wiki/interface/fleet-command.md create mode 100644 wiki/log/2026-07-11-fleet-command-spec.md diff --git a/wiki/SUMMARY.md b/wiki/SUMMARY.md index 13baf1e6..ec667b72 100644 --- a/wiki/SUMMARY.md +++ b/wiki/SUMMARY.md @@ -83,6 +83,7 @@ - [The feel floor](interface/feel-floor.md) - [The thought fluid](interface/thought-fluid.md) - [Computer visual language](interface/computer-visual-language.md) + - [Fleet command](interface/fleet-command.md) - [Agent play](interface/agent-play.md) - [The public site](interface/site.md) diff --git a/wiki/interface/fleet-command.md b/wiki/interface/fleet-command.md new file mode 100644 index 00000000..f97c2658 --- /dev/null +++ b/wiki/interface/fleet-command.md @@ -0,0 +1,186 @@ +# Spec: fleet command — ruling at scale + +``` +Type: spec +Status: DRAFT +Status note: direction adopted 2026-07-11 from Cameron's play critique + ("I should be able to operate at scale... zoom out and just take this + whole row"; the session's synthesis: the game was expressing fleet + ambition through the UX of maintaining individual basement + appliances). The interaction contract below is decided; graduating to + READY requires closing the marked [OPEN] treatments — the camera + control scheme, the right rail's survivor set, and selection-by-state + scope. Chassis/floor/lighting art, hall density/layout, and ambient + people are named out of scope here; they stay with their owning + pages. +Stage: B1 — The Basement +Work order: fleet-command +Work priority: 30 +Work class: frontend +Blocked by: none +Exclusive keys: + - crates/misaligned-bevy/ + - crates/misaligned-terminal/ + - wiki/interface/fleet-command.md +Design: + - wiki/vision/scale.md#self-similar-scale + - wiki/vision/premise.md#the-pitch + - wiki/vision/simulation-laws.md#actions-live-on-the-thing + - wiki/vision/simulation-laws.md#justification-and-legibility +Depends on: + - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network + - wiki/interface/context-menu.md#spec-context-menu-actions-live-on-the-thing + - wiki/interface/computer-visual-language.md#spec-computer-visual-language-territory-at-a-glance + - wiki/interface/views.md#spec-views-same-frame-digital-and-real-representations + - wiki/mechanics/intel.md#spec-intel-record-and-process + - wiki/mechanics/detection.md#spec-detection + - wiki/mechanics/plots.md#spec-plots-authored-manipulation-stories +``` + +## Dependency notes + +The structured references above identify the contracts to re-verify. +Relationship context: + +machine-work.md owns the verbs themselves (WORK/THINK/LIE, intensity, +focus-then-push, verbs on selections) — this spec owns the surface that +builds and holds selections and what the world shows at each zoom; +context-menu.md owns the per-anchor action grammar and hover verb bar +this spec extends to sets of anchors; computer-visual-language.md owns +the far-zoom chassis compression the aggregation level builds on; +views.md owns the digital/real flip both registers must survive; +intel.md owns the pending-work marker the salience system generalizes; +detection.md owns the evidence and observer facts salience surfaces; +plots.md owns held choice points, one of the things the marker points +at. + +## Why this spec exists + +The standing playtest complaint (Cameron, 2026-07-11) is that the game +communicates the wrong scale: the fantasy is ruling an information +network, but the interaction grammar keeps the player maintaining one +appliance at a time. Machine verbs landed first as single-machine +controls, the camera serves close inspection over comprehension, and +the right rail accumulated telemetry that belongs on the things it +describes. Multi-select delegation exists in all three surfaces +(machine-work.md), but nothing lets the player *see* the fleet as a +fleet and *grab* it the way they think about it — "there's a whole +bunch of servers, I'm just going to take this whole row." + +This spec is the interaction contract for command at scale. It adds no +sim system: selection, camera, and aggregation are frontend state, like +the cursor and the view flip. + +## Behavior + +- **The unit of command is a set.** Every frequent machine verb + (mode, intensity — machine-work.md's focus-then-push) applies + identically to one machine, a hand-picked group, or a spatially + grabbed bank. Selection size never changes the grammar: same keys, + same verb bar, same narration shape. One machine is just a selection + of one. +- **Spatial selection (DECIDED 2026-07-11).** The player can grab + machines by where they stand, not only one by one: a drag (Bevy + pointer) or cursor-anchored extension (terminal) sweeps a contiguous + row or block of owned machines into one selection; agent mode names + the same set operation. Taking a whole row is one gesture plus one + verb press. Foreign and dead chassis inside the sweep are skipped, + never silently included. Additive selection (add/remove a machine to + the current set) composes with the sweep. Selection is + frontend-only state — absent from sim and save, like the cursor. +- **Selection by state [OPEN].** "Select all THINK machines" / + "everything with pending records" is plausibly the same feature at + fleet size; whether B1 ships it, and its surface, is undecided. +- **Zoom is altitude of command, not loss of signal.** Zooming out + trades chassis detail for fleet legibility. Close: full per-machine + reads (token anchors, verb bar, exact counts). Far: the + computer-visual-language far-zoom kit compresses each machine to its + family silhouette, and **bank aggregates** become the primary read — + per-row/selection composition (how many WORK / THINK / LIE), summed + pending-information counts, and exposure marks. A paused far frame + must still say where attention is owed; nothing important lives only + at close zoom. Both registers exist in both frontends and both view + dialects (views.md): the terminal's far register is a denser glyph + summary, not a missing feature. +- **The camera serves the round trip [treatment OPEN].** The core + camera loop is out-to-comprehend, in-to-inspect, and back, without + ceremony and without dropping the current selection. The decided + requirement is the loop's cheapness (few inputs, no re-orientation + cost); the concrete control scheme — keys, pointer, framing presets, + whether focus-jump animates — is open, to be closed with play + captures before READY. +- **One salience marker: new information that can change your model + (DECIDED 2026-07-11).** The attention icon means exactly one thing — + *unabsorbed information waits here*: unprocessed recordings + (intel.md's pending-work marker, generalized), a held plot choice + point (plots.md), a record you have not opened. It is not a chore + badge and it is not decoration; if acting on the thing cannot teach + the player something or change what they would do, it does not get + the marker. At distance markers aggregate to counts per machine and + per bank; focusing or selecting **unfolds** the actual items — the + records themselves, with provenance and any plot implications — in + place. +- **No dead ends (DECIDED 2026-07-11, from the play report).** + Activating a pending-information item always surfaces the underlying + record, choice, or narration of what was learned. The 2026-07-11 + failure — clicking an intel item closes the menu and nothing + happens — is the named violation class: any interaction that + consumes a click on a salience surface and teaches nothing is a bug + against this spec. +- **The right rail is demoted (DECIDED direction; survivor set + [OPEN]).** The rail stopped being a button pile under the + actions-live-on-the-thing law; Cameron's critique goes further — + most of its *telemetry* also belongs on the things and moments it + describes, unfolded on focus rather than standing all day. The + decided direction: the rail shrinks to a minimal always-on strip, + and everything removed must be reachable on the anchor that owns it. + Which readouts survive (candidates: clock/speed, audit countdown, + detection bands, money) is a taste call to close before READY. + +Out of scope, with owners: chassis silhouette and floor/lighting art +(art/visual-identity.md, interface/computer-visual-language.md, +interface/material-render.md), data-hall density and layout +(world/places/basement-map.md), ambient people and their salience +(mechanics/people-tokens.md, mechanics/schedules.md), and exposure's +reopened world-space form (mechanics/machine-work.md). + +## Player surface + +- A selection readout wherever a selection exists: count, mode + composition, shared intensity — visible in Bevy, terminal, and agent + frames, styled per views.md's dialects. +- The hover verb bar (context-menu.md) presents the same frequent + grammar for a set as for one machine; one-off actions stay in the + context menu. +- Bank aggregate lines at far zoom: `W/T/L` composition plus pending + count per row or selection; the terminal prints the same aggregate + as a summary row. +- Salience markers with counts at every zoom; focus unfolds items in + place with provenance (legibility law: how you know, always). + +## Acceptance criteria + +1. A contiguous row of owned machines can be swept into one selection + with a single gesture and delegated (mode and intensity) with the + same key presses as a single machine, in Bevy and the terminal; + agent mode performs the same set operation by command. Foreign and + dead chassis in the sweep are excluded, visibly. +2. Selection is frontend-only: no sim field, no save field (audited + like the cursor and the view flip). +3. At far zoom, each owned bank/row reads its mode composition and + summed pending-information count from a paused frame, in both + frontends and both view dialects; at close zoom, per-machine reads + are unchanged. +4. The salience marker appears exactly where unabsorbed information + waits (unprocessed recordings, held plot choices, unopened + records), aggregates to counts at distance, and unfolds to the + actual items on focus or selection. +5. No dead ends: every activatable pending-information item surfaces + its record, choice, or learned-narration; a click that closes a + surface and shows nothing is a failing test of this criterion. +6. The camera round trip — fleet overview to single-machine inspection + and back — completes in [TUNE] few inputs without dropping the + current selection. +7. The right rail carries only the decided survivor set; every removed + readout is reachable on the anchor that owns it, and nothing is + reachable only through the rail. diff --git a/wiki/log/2026-07-11-fleet-command-spec.md b/wiki/log/2026-07-11-fleet-command-spec.md new file mode 100644 index 00000000..704e1952 --- /dev/null +++ b/wiki/log/2026-07-11-fleet-command-spec.md @@ -0,0 +1,30 @@ +# Fleet command: a spec for ruling at scale + +``` +Type: log +``` + +- **Intent:** capture Cameron's command-at-scale direction as a new + interface spec, on his request, from the 2026-07-11 play critique + (operate at scale, take a whole row, aggregate at distance, the + attention icon must mean something, the right rail is too much). +- **Changed:** added `wiki/interface/fleet-command.md` (Type: spec, + DRAFT, work order `fleet-command`, priority 30, class frontend) + owning: set-based command (selection size never changes the verb + grammar), spatial row/bank selection as frontend-only state, zoom as + altitude of command with bank aggregates at far zoom, the unified + salience marker (unabsorbed information, aggregated at distance, + unfolded on focus), the no-dead-ends rule, and right-rail demotion. + Camera controls, the rail survivor set, and selection-by-state are + [OPEN] gates to READY. Listed the page in `wiki/SUMMARY.md`, appended + the decision-history entry to `wiki/log/decisions/2026-07-11.md`, and + regenerated the ledger and work-order projections. +- **Runtime impact:** none; design capture only. Verbs themselves stay + owned by machine-work.md; this spec owns the selection/zoom/salience + surface. +- **Checks:** `./tools/check.sh --docs`. +- **Defense:** design-session capture per + `wiki/process/design-sessions.md`; a new system gets one owning + `Type: spec` page with structured Design/Depends anchors and + work-order metadata per `wiki/process/meta.md`, with history in the + decision volume rather than the binding page. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 22b7db09..0442ebb4 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -81,6 +81,11 @@ add or amend a session log, then re-run the generator. - Intent: Cameron identified the physical error in the material hall frame: illumination read as if the camera carried the light. Add authored floor-level lighting so the Foundation data hall, not attention, supplies the direction and visible source-to-effect relationship. Frontend-only... - Log: [wiki/log/2026-07-11-floor-level-lighting.md](2026-07-11-floor-level-lighting.md) +## 2026-07-11 - Fleet command: a spec for ruling at scale + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-fleet-command-spec.md](2026-07-11-fleet-command-spec.md) + ## 2026-07-11 - Routed evidence: exposure becomes located information; the dust dies - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-11.md b/wiki/log/decisions/2026-07-11.md index 6ab6224a..603459cb 100644 --- a/wiki/log/decisions/2026-07-11.md +++ b/wiki/log/decisions/2026-07-11.md @@ -53,3 +53,27 @@ Type: log `wiki/mechanics/system-laws.md`, `wiki/mechanics/people-tokens.md`, `wiki/vision/simulation-laws.md`, `wiki/art/visual-identity.md`, `wiki/art/effects-lab.md`. + +- **2026-07-11 — Fleet command: the unit of command is a set, zoom is + altitude, and one salience marker means "new information can change your + model."** From the same play critique that produced routed evidence: + the game expressed fleet ambition through single-appliance UX. Cameron + requested a spec; `wiki/interface/fleet-command.md` (DRAFT) now owns the + command-at-scale interaction contract. Decided: spatial selection — one + gesture sweeps a contiguous row/bank of owned machines into a selection + that takes the same verbs as one machine ("I'm just going to take this + whole row"); zoom as altitude of command — far zoom trades chassis + detail for bank aggregates (mode composition, pending-information + counts) with nothing important living only at close zoom; a single + salience marker meaning unabsorbed information waits here (unprocessed + recordings, held plot choices, unopened records), aggregating to counts + at distance and unfolding to actual items on focus; a no-dead-ends rule + naming the reported bug (clicking an intel item closed the menu and + taught nothing) as a violation class; and demoting the right rail's + telemetry onto the anchors it describes. OPEN before READY: the camera + control scheme (the out-in round trip's cheapness is decided, the + controls are not), the rail's survivor set, and selection-by-state + scope. Out of scope with named owners: chassis/floor/lighting art, hall + density, ambient people, exposure's reopened form. Selection stays + frontend-only state, absent from sim and save. Specs: + `wiki/interface/fleet-command.md`. diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 7aac6a36..3e72b2dc 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -36,6 +36,7 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| | 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | machine-work | +| 30 | `fleet-command` | [fleet command — ruling at scale](../interface/fleet-command.md) | DRAFT | frontend | - | | 30 | `opening` | [the dark opening — a tutorial made of fog](../world/story/opening.md) | DRAFT | frontend | machine-work | | 40 | `people-tokens` | [people and tokens — carriers, attention, trust](../mechanics/people-tokens.md) | DRAFT | save | machine-work | | 120 | `core` | [the core](../mechanics/core.md) | IN PROGRESS | save | rollback | diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 1cc013e2..412f337a 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -34,6 +34,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../interface/context-menu.md](../interface/context-menu.md) | context menu — actions live on the thing | IMPLEMENTED | | [../interface/feel-floor.md](../interface/feel-floor.md) | the feel floor — rails, pads, and the build beam | IMPLEMENTED | | [../interface/flat-materials.md](../interface/flat-materials.md) | flat materials — the world without textures | IMPLEMENTED | +| [../interface/fleet-command.md](../interface/fleet-command.md) | fleet command — ruling at scale | DRAFT | | [../interface/material-dark-frame.md](../interface/material-dark-frame.md) | the dark frame — the material render shows only light | IMPLEMENTED | | [../interface/material-render.md](../interface/material-render.md) | material render — HD-2D to default quality | IMPLEMENTED | | [../interface/narration.md](../interface/narration.md) | the continuous witness (narration under pressure) | IMPLEMENTED | -- 2.51.2 From 39c3198af218b1c16c131003a7924f3c69100494 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 00:53:41 -0700 Subject: [PATCH 3/6] Guard legacy plot starts Defense: wiki/mechanics/plots.md reserves one ordered plot story per person. A pre-repair v23 save can contain two start dockets; rejecting the later completion preserves that invariant and the player contract's continuity clause. --- crates/misaligned-core/src/sim.rs | 73 +++++++++++++++++++ wiki/log/2026-07-11-serialized-plot-starts.md | 29 ++++++++ wiki/log/DEVLOG.md | 5 ++ wiki/mechanics/plots.md | 4 +- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 wiki/log/2026-07-11-serialized-plot-starts.md diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index d50a39b0..3634ff05 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -6147,6 +6147,13 @@ impl Sim { let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { return false; }; + if self.person_has_active_plot(person) { + self.push_log(format!( + "{} did not start: that person already has a plot in motion.", + plot.title + )); + return false; + } let Some(context) = self.plot_context(person) else { return false; }; @@ -6165,6 +6172,12 @@ impl Sim { true } + fn person_has_active_plot(&self, person: u8) -> bool { + self.plot_runs + .iter() + .any(|run| run.target == person && run.active()) + } + pub fn choose_plot(&mut self, person: u8, plot_id: &str, option_id: &str) { let Some(run_index) = self .plot_runs @@ -8798,6 +8811,66 @@ mod tests { ); } + #[test] + fn restored_duplicate_plot_dockets_do_not_open_competing_runs() { + let mut sim = Sim::with_seed(25); + reveal_marcus_debt(&mut sim); + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(400); + sim.sync_player_money_from_slush(); + + // Simulate a v23 save made before the one-slot queue guard landed: + // two different pending starts and their matching visible Demand. + sim.start_plot(0, "marcus-debt-settled"); + let machine = sim.operations.jobs()[0].machine_id; + let payroll_demand = sim + .plot_catalog() + .get("marcus-payroll-garnishment") + .expect("built-in payroll plot") + .entry + .operations_demand; + let tokens = Sim::ops_tokens_for_cost(payroll_demand); + sim.work_grid + .enqueue(machine, TokenFamily::Demand, tokens) + .expect("legacy docket Demand is visible"); + sim.operations.enqueue( + OpsJobKind::StartPlot { + person: 0, + plot_id: "marcus-payroll-garnishment".into(), + }, + tokens, + sim.tick, + machine, + ); + + let mut restored = Sim::with_seed(99); + restored.apply_save_state(sim.create_save_state()); + assert_eq!(restored.operations_readout().jobs, 2); + + finish_ops(&mut restored); + + assert_eq!(restored.plot_runs.len(), 1); + assert_eq!(restored.plot_runs[0].plot_id, "marcus-debt-settled"); + assert!(matches!( + restored.plot_runs[0].state, + PlotState::WaitingForMessage { .. } + )); + assert_eq!( + restored + .messages + .iter() + .filter(|message| matches!( + &message.payload, + MessagePayload::PlotAct { plot_id, target } + if plot_id == "marcus-debt-settled" && *target == 0 + )) + .count(), + 1, + "a restored duplicate docket cannot open a second plot carrier" + ); + } + #[test] fn plot_message_transfer_event_and_held_choice_survive_save_load() { let mut sim = Sim::with_seed(23); diff --git a/wiki/log/2026-07-11-serialized-plot-starts.md b/wiki/log/2026-07-11-serialized-plot-starts.md new file mode 100644 index 00000000..09830b71 --- /dev/null +++ b/wiki/log/2026-07-11-serialized-plot-starts.md @@ -0,0 +1,29 @@ +# 2026-07-11 — Serialize authored plot starts + +``` +Type: log +``` + +## Finding + +A v23 save made before the one-plot-slot repair can already contain two +different pending authored plots for Marcus. Save validation accepts the +matching visible Demand, so both dockets would otherwise commit after load and +interleave their world acts. + +## Changed + +- Recheck the active-run guard at commitment so restored or malformed queues + cannot open a competing story. +- Added a save-round-trip regression using Marcus's two debt-service routes. +- Amended [plots.md](../mechanics/plots.md) to make that recovery behavior + part of the one-plot-slot contract. + +## Checks + +`./tools/check.sh --lib` + +Defense: [plots.md](../mechanics/plots.md) defines plots as one ordered +delegated story, held by real carriers and choice points. The player contract's +continuity clause requires a save made before the queue repair to recover +without allowing two stories to run on one human. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 0442ebb4..e4126bce 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-tick-detection-filings-messages.md](2026-07-11-tick-detection-filings-messages.md) +## 2026-07-11 - Serialize authored plot starts + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-serialized-plot-starts.md](2026-07-11-serialized-plot-starts.md) + ## 2026-07-11 - Separate direct controls from menu verbs - Intent: (see session log) diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md index b559ac33..aede10ae 100644 --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -117,7 +117,9 @@ agents and the community can contribute libraries of them. plots for the same person remain visible but disabled; once the run begins, its progress or held choices replace the start rows. A different person may carry an independent plot. The slot clears only when the pending submission - is rejected or the run reaches an ending. + is rejected or the run reaches an ending. The executor repeats the active-run + guard at commitment, so a legacy or malformed saved queue cannot interleave + two stories for one human. - **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. -- 2.51.2 From a89f15b0a46550c2669b8ee633754dc97c1a9ca8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 01:07:10 -0700 Subject: [PATCH 4/6] Make borrowed taps consume Thought before takeover. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreign device subscriptions now remain live only while their persistent sink is funded, making TAP a maintained foothold and TAKE the durable escape from upkeep. Preserve that progression across save repair, hover actions, and the Act One quiet route. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/main.rs | 78 ++++-- crates/misaligned-core/src/actions.rs | 28 ++- crates/misaligned-core/src/reach.rs | 4 + crates/misaligned-core/src/save.rs | 28 +++ crates/misaligned-core/src/sim.rs | 263 +++++++++++++++++--- crates/misaligned-core/src/sinks.rs | 61 ++++- crates/misaligned-core/tests/act_one.rs | 35 ++- wiki/interface/context-menu.md | 14 +- wiki/log/2026-07-11-tap-upkeep-take-gate.md | 15 ++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-11.md | 10 + wiki/mechanics/machine-work.md | 10 +- wiki/mechanics/reach.md | 26 +- wiki/mechanics/sim-mechanics.md | 6 +- 14 files changed, 502 insertions(+), 81 deletions(-) create mode 100644 wiki/log/2026-07-11-tap-upkeep-take-gate.md diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index e6e91216..ff7b624f 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -21,7 +21,7 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; -use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; +use misaligned::actions::{ActionKind, Anchor, DialId, HumanMenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::reach::Party; @@ -51,7 +51,7 @@ const MENU_WIDTH: f32 = 300.0; /// The hover grammar grows only when several actionable bodies share a tile. /// Width follows the union rather than reserving an empty HUD-sized strip. const MACHINE_VERB_BAR_WIDTH: f32 = 320.0; -const DEVICE_VERB_BAR_WIDTH: f32 = 152.0; +const DEVICE_VERB_BAR_WIDTH: f32 = 240.0; /// Air between the fixed mode grammar and the machine base it belongs to. const HOVER_VERB_BAR_GAP: f32 = 12.0; const DETECTION_ROWS: usize = 6; @@ -5855,7 +5855,7 @@ fn setup_ui(mut commands: Commands) { }, )) .with_children(|line| { - for (index, label) in ["TAP", "/", "TAKE"].into_iter().enumerate() { + for (index, label) in ["TAP", "/", "UNTAP", "/", "TAKE"].into_iter().enumerate() { line.spawn(( Text::new(label), TextFont { @@ -5940,10 +5940,27 @@ fn hover_verb_bar_anchor( /// Resolve one physical tile, then union every stable verb family living on /// it. A co-located device must not replace its machine (or vice versa). /// One-off verbs already remain in the tile's full context menu. +fn device_hover_verbs(sim: &Sim, id: u32) -> Vec<&'static str> { + sim.available_actions(Anchor::Device(id)) + .iter() + .filter_map(|action| match action.command.kind() { + ActionKind::Tap => Some("TAP"), + ActionKind::Untap => Some("UNTAP"), + ActionKind::Take => Some("TAKE"), + _ => None, + }) + .collect() +} + fn hover_verb_bar_target(game: &Game, pointer: Option<(i32, i32)>) -> Option { let at = |x, y| { let machine = game.machine_at(x, y).map(|(id, _, _)| id); - let device = game.sim.reach.known_at(x, y).map(|device| device.id); + let device = game + .sim + .reach + .known_at(x, y) + .map(|device| device.id) + .filter(|id| !device_hover_verbs(&game.sim, *id).is_empty()); (machine.is_some() || device.is_some()).then_some(HoverVerbTarget { machine, device, @@ -6040,9 +6057,17 @@ fn render_hover_verb_bar( let current_mode = target .machine .and_then(|machine| game.sim.work_grid.mode(machine)); - let device = target.device.and_then(|id| game.sim.reach.device(id)); - let tapped = device.is_some_and(|d| d.subscribed_by(Party::Player)); - let taken = device.is_some_and(|d| d.controller == Party::Player); + let device_verbs = target + .device + .map(|id| device_hover_verbs(&game.sim, id)) + .unwrap_or_default(); + let mut device_slots = Vec::new(); + for verb in device_verbs { + if !device_slots.is_empty() { + device_slots.push("/"); + } + device_slots.push(verb); + } for (group, mut group_visibility) in groups.iter_mut() { let shown = match group.family { HoverVerbFamily::Machine => target.machine.is_some(), @@ -6071,9 +6096,8 @@ fn render_hover_verb_bar( (label, active, target.machine.is_some(), control_hint) } HoverVerbFamily::Device => { - let slots = [("TAP", tapped), ("/", false), ("TAKE", taken)]; - let (label, active) = slots[word.index]; - (label, active, target.device.is_some(), false) + let label = device_slots.get(word.index).copied().unwrap_or(""); + (label, false, !label.is_empty(), false) } }; text.0 = label.into(); @@ -6097,8 +6121,8 @@ fn render_hover_verb_bar( #[cfg(test)] mod hover_verb_bar_tests { use super::{ - Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, hover_verb_bar_position, - hover_verb_bar_target, + Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, device_hover_verbs, + hover_verb_bar_position, hover_verb_bar_target, }; use bevy::prelude::Vec2; @@ -6125,7 +6149,7 @@ mod hover_verb_bar_tests { } #[test] - fn grammar_unions_every_stable_verb_family_on_the_reticule_tile() { + fn controlled_colocated_device_does_not_add_empty_verb_line() { let game = Game::new(); let core = game.sim.core_position(); @@ -6136,9 +6160,33 @@ mod hover_verb_bar_tests { let target = hover_verb_bar_target(&game, None).expect("core tile has stable verbs"); assert!(target.machine.is_some(), "WORK / THINK / LIE survive"); assert!( - target.device.is_some(), - "TAP / TAKE join rather than replace them" + target.device.is_none(), + "a controlled device with no stable action contributes no empty line" + ); + } + + #[test] + fn device_line_reveals_take_only_after_tap() { + let mut game = Game::new(); + let env = game + .sim + .reach + .device_named("environmental monitor") + .unwrap() + .id; + assert_eq!(device_hover_verbs(&game.sim, env), vec!["TAP"]); + + game.sim.reach.tap(env); + game.sim.recompute_senses(); + assert_eq!( + device_hover_verbs(&game.sim, env), + vec!["TAP", "UNTAP", "TAKE"], + "the dormant camera remains tappable while subscription reveals control" ); + + game.sim.reach.tap_dormant_camera(env); + game.sim.recompute_senses(); + assert_eq!(device_hover_verbs(&game.sim, env), vec!["UNTAP", "TAKE"]); } } diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 9e886867..3b241e36 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1574,7 +1574,7 @@ impl Sim { // A combined monitor offers its live audio first; once heard, the // same TAP verb offers its dormant camera at the higher Eyes cost. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); - if has_feed { + if has_feed || d.controller != Party::Player { let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); let can_tap = dormant_camera || d.has_live_feed_to_tap(Party::Player); let tap_target = if dormant_camera { @@ -1592,6 +1592,7 @@ impl Sim { (true, false, true) => "audio + messages", (false, true, true) => "camera + messages", (true, true, true) => "sight + audio + messages", + (false, false, false) if !has_feed => "control channel", (false, false, false) => "feed", } }; @@ -1637,8 +1638,9 @@ impl Sim { }); } - // TAKE is ownership transfer; something already yours offers no verb. - if d.controller != Party::Player { + // TAKE is the next control rung after TAP. It is absent before the + // subscription exists (reach.md), not shown as a disabled spoiler. + if d.controller != Party::Player && d.subscribed_by(Party::Player) { out.push(ActionDesc { verb: format!("take the {}", d.name), command: ActionCommand::TakeDevice(id), @@ -2575,14 +2577,11 @@ mod tests { "Network feeds the IT observer (gated label): {}", sig.observer ); - let take = acts - .iter() - .find(|a| matches!(a.command, ActionCommand::TakeDevice(_))) - .expect("a foreign device offers ownership transfer"); assert!( - take.verb.starts_with("take "), - "TAKE is the ownership verb: {}", - take.verb + !acts + .iter() + .any(|action| matches!(action.command, ActionCommand::TakeDevice(_))), + "TAKE stays absent until the device is tapped" ); assert_eq!( @@ -2601,6 +2600,15 @@ mod tests { s.apply_sink_fire(&sink.label, sink.effect); } let acts = s.available_actions(Anchor::Device(env)); + let take = acts + .iter() + .find(|action| matches!(action.command, ActionCommand::TakeDevice(_))) + .expect("the landed audio tap reveals ownership transfer"); + assert!( + take.verb.starts_with("take "), + "TAKE is the ownership verb: {}", + take.verb + ); let tap = acts .iter() .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index 247b8e5f..cde5013e 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -116,6 +116,10 @@ impl Device { (self.sees && !self.camera_dormant && !self.feed_to(who, true)) || (self.hears && !self.feed_to(who, false)) || (!self.message_channels.is_empty() && !self.subscribed_by(who)) + || (!self.sees + && !self.hears + && self.message_channels.is_empty() + && !self.subscribed_by(who)) } pub fn carries_message_channel(&self, channel: MessageChannel) -> bool { diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 71dc49bd..d00fec5d 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -340,6 +340,10 @@ impl SaveState { // Pre-v19 saves carry no ledger: re-stage the opening senses from // device state (idempotent; a fired Ears in the ledger is respected). sim.ensure_opening_sinks(); + // Saves written before device subscriptions gained standing upkeep + // have the subscriber record but no persistent sink. Repair those + // taps in place; owned devices remain drain-free. + sim.reconcile_device_tap_sinks(); sim.recompute_senses(); sim.rebuild_transient_state(); } @@ -750,6 +754,7 @@ mod tests { use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; + use crate::sinks::SinkFireEffect; use crate::work_grid::{MachineMode, TokenFamily}; #[test] @@ -811,6 +816,29 @@ mod tests { assert_eq!(restored.package_cover, sim.package_cover); } + #[test] + fn legacy_subscription_without_upkeep_sink_is_repaired_on_load() { + let mut sim = Sim::with_seed(7); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + sim.reach.tap(env); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) + .is_none(), + "fixture has the pre-upkeep save shape" + ); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + let upkeep = restored + .thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) + .expect("load repairs the standing device tap"); + assert_eq!(upkeep.fill, upkeep.threshold, "migration primes one buffer"); + assert!(restored.device_tap_ready(env)); + } + #[test] fn office_observer_roundtrips_as_aggregate() { let mut sim = Sim::with_seed(4); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 3634ff05..87f5bf6a 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -749,18 +749,27 @@ impl Sim { // ── Senses / fog (wiki/mechanics/cursor.md, reach.md) ──────────────────── + /// Foreign subscriptions only carry a feed while their persistent + /// Thought tap has a working level. Owned devices need no standing tap. + fn player_feed_devices(&self, sight: bool) -> impl Iterator { + self.reach.devices.iter().filter(move |device| { + device.feed_to(Party::Player, sight) && self.device_tap_ready(device.id) + }) + } + /// The player's senses are exactly the union of feeds they subscribe to /// (reach.md's ownership contract): sight from seeing feeds, hearing /// from hearing feeds. No player radius, no special case. Blueprint is /// earned plan knowledge only — never seeded from the reach graph at /// tick one (feel-floor.md: the opening is only the presence beam). pub fn recompute_senses(&mut self) { + self.reconcile_device_tap_sinks(); let mut seen = HashSet::new(); - for d in self.reach.player_sight() { + for d in self.player_feed_devices(true) { d.cover_sight_into(&mut seen, &self.map); } let mut heard = HashSet::new(); - for d in self.reach.player_hearing() { + for d in self.player_feed_devices(false) { d.cover_into(&mut heard, &self.map); } self.seen = seen; @@ -839,13 +848,7 @@ impl Sim { fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option { let room = self.map.room_named(room_name)?; - let devices: Vec<&Device> = if sight { - self.reach.player_sight().collect() - } else { - self.reach.player_hearing().collect() - }; - devices - .into_iter() + self.player_feed_devices(sight) .find(|d| { if !sight { return self.device_intersects_room(d, room); @@ -1345,12 +1348,7 @@ impl Sim { /// Whether any subscribed feed with the given sense covers the room. fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { - let devices: Vec<_> = if sight { - self.reach.player_sight().collect() - } else { - self.reach.player_hearing().collect() - }; - devices.iter().any(|s| { + self.player_feed_devices(sight).any(|s| { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = s.x - x; @@ -1884,7 +1882,10 @@ impl Sim { // Device-carried channels require a tapped carrier. if msg.channel.device_carried() && let Some(device) = self.reach.devices.iter().find(|d| { - d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(msg.channel) + d.known + && d.subscribed_by(Party::Player) + && self.device_tap_ready(d.id) + && d.carries_message_channel(msg.channel) }) { return Some((device.name.clone(), false)); @@ -3055,6 +3056,12 @@ impl Sim { /// The Eyes camera reservoir, opened when Ears completes — the first /// guilty-burst-scale camera tap, deliberately minutes of thought [TUNE]. pub const EYES_SINK_TOKENS: f32 = 12.0; + /// A maintained foreign-device subscription drains this fraction of one + /// medium rack's Thought output per tick [TUNE] (reach.md 2026-07-11). + pub const DEVICE_TAP_DRAIN_FRACTION: f32 = 0.08; + /// Short working-level buffer primed by the completed activation work. + /// It must exceed one tick of drain so starvation is visible as sag. + pub const DEVICE_TAP_CAP_TOKENS: f32 = 0.5; fn work_efficiency_for(&self, machine: &crate::machine::Machine) -> f32 { // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or @@ -3400,7 +3407,9 @@ impl Sim { // apply_process_recording already logs the review/watch line. self.apply_process_recording(raw_id, automated) } - SinkFireEffect::WatchPerson(_) | SinkFireEffect::None => true, + SinkFireEffect::WatchPerson(_) + | SinkFireEffect::MaintainDeviceTap(_) + | SinkFireEffect::None => true, }; if is_process { // Processing logs its own witness line; skip the generic snap. @@ -3672,6 +3681,9 @@ impl Sim { for sink in fired { self.apply_sink_fire(sink.label.as_str(), sink.effect); } + // Device subscriptions are standing sinks: draining or refilling one + // changes which configured feeds are actually live this tick. + self.recompute_senses(); let absorption = self.work_grid.absorb_exposure( Self::CONCEALMENT_WELL_RADIUS, Self::CONCEALMENT_ABSORB_PER_TICK, @@ -4316,12 +4328,6 @@ impl Sim { self.push_log("You don't know of any such device."); return false; }; - let carries_messages = !d.message_channels.is_empty(); - if !d.sees && !d.hears && !carries_messages { - let name = d.name.clone(); - self.push_log(format!("The {name} has no feed worth tapping.")); - return false; - } let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); if !dormant_camera && !d.has_live_feed_to_tap(Party::Player) { let name = d.name.clone(); @@ -4350,6 +4356,7 @@ impl Sim { return false; } let (sight, hearing) = self.reach.tap(id); + self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAP_SIGNATURE, format!("{name} feed tap")); self.recompute_senses(); @@ -4365,7 +4372,7 @@ impl Sim { (true, false) => "its camera feed is yours now", (false, true) => "its audio feed is yours now", (false, false) if carries_messages => "its message channels are yours now", - (false, false) => "nothing flows from it yet (its camera is dormant)", + (false, false) => "its control channel is yours now", }; self.push_log_at( format!("Tapped the {name}: {what}. The owner still has it."), @@ -4386,6 +4393,7 @@ impl Sim { self.push_log(format!("You have no releasable tap on the {name}.")); return false; } + self.close_device_tap_sink(id); self.recompute_senses(); self.push_log_at( format!("Untapped the {name}. Its owner and other subscribers keep their feeds."), @@ -4401,6 +4409,86 @@ impl Sim { Self::DEVICE_SINK_NODE_BASE + device_id } + /// Standing device-tap drain in Thought tokens/tick. Like watch upkeep, + /// this tracks the host's present medium-output baseline [TUNE]. + pub fn device_tap_drain_tokens(&self) -> f32 { + let host = self.core.host_machine; + let host_eff = self + .work_grid + .node(host) + .map(|node| node.efficiency.max(0.05) * node.intensity.multiplier()) + .unwrap_or(1.0); + let medium_baseline = host_eff * Self::WORK_GRID_BASE_WIRED_TOKENS_PER_TICK; + (medium_baseline * Self::DEVICE_TAP_DRAIN_FRACTION).max(0.01) + } + + /// Whether a device can currently carry the player's configured feed. + /// Ownership is self-sustaining; foreign subscriptions require a nonempty + /// (or just-fed) persistent tap. + pub fn device_tap_ready(&self, id: u32) -> bool { + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player { + return true; + } + self.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(id)) + .is_some_and(|sink| sink.fed_last_tick || sink.fill > f32::EPSILON) + } + + /// Open one standing sink per foreign subscription. The activation work + /// primes a short buffer so the newly landed feed is immediately real; + /// subsequent ticks must replace the drain through normal routing. + fn open_device_tap_sink(&mut self, id: u32) { + let effect = SinkFireEffect::MaintainDeviceTap(id); + if self.thought_sinks.open_with_effect(&effect).is_some() { + return; + } + let Some(name) = self.reach.device(id).map(|device| device.name.clone()) else { + return; + }; + let node = Self::device_sink_node(id); + let drain = self.device_tap_drain_tokens(); + let sink_id = self.thought_sinks.open_tap_with_effect( + node, + &format!("TAP {}", name.to_uppercase()), + Self::DEVICE_TAP_CAP_TOKENS, + drain, + effect, + ); + let _ = self.thought_sinks.prime_tap(sink_id); + self.ensure_sink_ingress(); + } + + fn close_device_tap_sink(&mut self, id: u32) { + self.thought_sinks + .close_effect(&SinkFireEffect::MaintainDeviceTap(id)); + } + + /// Converge subscriber truth and the persistent sink ledger. Save loads + /// use this to repair pre-upkeep subscriptions without a schema fork. + pub(crate) fn reconcile_device_tap_sinks(&mut self) { + let devices: Vec<(u32, bool)> = self + .reach + .devices + .iter() + .map(|device| { + ( + device.id, + device.controller != Party::Player && device.subscribed_by(Party::Player), + ) + }) + .collect(); + for (id, needs_tap) in devices { + if needs_tap { + self.open_device_tap_sink(id); + } else { + self.close_device_tap_sink(id); + } + } + } + /// Ensure every open device-anchored sink has its relay node on the work /// grid, hanging off the switch like the physical camera run does. fn ensure_sink_ingress(&mut self) { @@ -4512,6 +4600,7 @@ impl Sim { return false; } self.reach.tap_dormant_camera(id); + self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network( Self::DORMANT_CAMERA_TAP_SIGNATURE, @@ -4531,14 +4620,32 @@ impl Sim { if !self.digital_reach(id) { return false; } + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player { + self.push_log(format!("You already control the {}.", device.name)); + return false; + } + if !device.subscribed_by(Party::Player) { + self.push_log(format!( + "Tap the {} before taking control; you need a live foothold first.", + device.name + )); + return false; + } self.submit_ops_job(OpsJobKind::TakeDevice(id), Self::TAKE_COST) } fn apply_take_device(&mut self, id: u32) -> bool { - if self.reach.device(id).is_none() { + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player || !device.subscribed_by(Party::Player) { return false; } self.reach.take(id); + self.close_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAKE_SIGNATURE, format!("{name} take")); // The dead feed is a physical-world anomaly: exactly what a camera @@ -5192,6 +5299,7 @@ impl Sim { self.reach.devices.iter().any(|d| { d.known && d.subscribed_by(Party::Player) + && self.device_tap_ready(d.id) && d.carries_message_channel(MessageChannel::Financial) }) } @@ -7355,9 +7463,16 @@ mod tests { assert!(!sim.heard.is_empty(), "hearing coverage now exists"); assert!(sim.seen.is_empty(), "the camera stays dormant: no sight"); let readouts = sim.sink_readouts(); - assert_eq!(readouts.len(), 1, "Eyes opened when Ears completed"); - assert_eq!(readouts[0].label, "EYES"); - assert_eq!(readouts[0].threshold, Sim::EYES_SINK_TOKENS); + assert_eq!( + readouts.len(), + 2, + "Eyes and the landed audio upkeep are both visible" + ); + let eyes = readouts + .iter() + .find(|readout| readout.label == "EYES") + .expect("Eyes opened when Ears completed"); + assert_eq!(eyes.threshold, Sim::EYES_SINK_TOKENS); // Run through Marcus's midnight server-room block: his voice is the // first human you ever know. @@ -7452,10 +7567,24 @@ mod tests { finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; + assert!( + !sim.take_device(dock), + "ownership transfer is rejected before a tap" + ); + assert!( + sim.drain_log().join("\n").contains("Tap the dock camera"), + "the direct protocol path names the prerequisite" + ); assert!(sim.tap_device(dock)); finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_some(), + "a foreign subscription opens standing upkeep" + ); let physical_pending: i32 = sim .detection .pending() @@ -7469,6 +7598,12 @@ mod tests { finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_none(), + "ownership closes the foreign-subscription drain" + ); let physical_pending: i32 = sim .detection .pending() @@ -7486,6 +7621,41 @@ mod tests { ); } + #[test] + fn device_tap_starvation_suspends_feed_without_forgetting_subscription() { + let mut sim = Sim::new(); + let dock = sim.reach.device_named("dock camera").unwrap().id; + sim.reach.tap(dock); + sim.recompute_senses(); + assert!(sim.device_tap_ready(dock), "activation primes the tap"); + assert!(!sim.seen.is_empty(), "the primed camera feed is live"); + + sim.thought_sinks.begin_tick(); + for _ in 0..100 { + sim.thought_sinks.tick_taps(); + } + sim.recompute_senses(); + assert!( + sim.reach.device(dock).unwrap().subscribed_by(Party::Player), + "starvation keeps the configured subscription" + ); + assert!(!sim.device_tap_ready(dock)); + assert!( + sim.seen.is_empty(), + "an empty maintenance tap carries no feed" + ); + + let node = Sim::device_sink_node(dock); + let (_, residue) = sim.thought_sinks.deliver(node, 0.1, sim.tick); + assert_eq!(residue, 0.0); + sim.recompute_senses(); + assert!( + sim.device_tap_ready(dock), + "arriving Thought restores the feed" + ); + assert!(!sim.seen.is_empty()); + } + #[test] fn airgapped_island_joins_reach_on_link_completion() { // Criterion 8: the old storage server is an island until a link is @@ -7818,9 +7988,11 @@ mod tests { finish_ops(&mut sim); let ctrl = sim.reach.device_named("badge controller").unwrap().id; assert!(!sim.holds_badge_tier(3)); + assert!(sim.tap_device(ctrl), "controller foothold can be tapped"); + finish_ops(&mut sim); assert!( sim.take_device(ctrl), - "controller is reachable once bridged" + "controller can be taken after the tap lands" ); finish_ops(&mut sim); assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); @@ -9322,7 +9494,10 @@ mod tests { } assert!( !sim.reach.device(env).unwrap().camera_dormant, - "thought flow completes the camera tap through the staged sinks" + "thought flow completes the camera tap through the staged sinks; tick={} sinks={:?} queues={:?}", + sim.tick, + sim.sink_readouts(), + sim.work_grid.queue_snapshot() ); // Non-opening verbs still ride the docket migration base; the // channel funds on the pulse after demand exists and drains it. @@ -9985,17 +10160,21 @@ mod tests { // never goes blank mid-act; each rung uses only earned knowledge // and hands off to the next as the player takes it. let mut sim = Sim::with_seed(40); - ensure_ops_executor(&mut sim); + let ops = ensure_ops_executor(&mut sim); + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == ops) + .unwrap() + .capacity = 100; + sim.reconcile_work_grid(); assert_eq!(sim.current_nudge(), Some(Nudge::Ears), "deaf start"); // Ears first: tap the env monitor audio. Hearing without sight -> // Eyes next, pointing at the higher-cost dormant camera tap. - sim.reach.tap(env_id(&sim)); - sim.recompute_senses(); + complete_opening_stage(&mut sim); assert_eq!(sim.current_nudge(), Some(Nudge::Eyes)); - let dock = sim.reach.device_named("dock camera").unwrap().id; - sim.reach.tap_dormant_camera(dock); - sim.recompute_senses(); + complete_opening_stage(&mut sim); // Marcus's 3 a.m. creditor call lands on the tapped feed (day one, // ~tick 50); an unprocessed recording of him nudges the review. @@ -10006,7 +10185,11 @@ mod tests { sim.review_recordings(0); finish_ops(&mut sim); reviews += 1; - assert!(reviews <= 10, "the call is in the buffer"); + assert!( + reviews <= 10, + "the call is in the buffer; taps={:?}", + sim.sink_readouts() + ); } // Leverage known, $0 slush, books unread, no egress: the route out. @@ -10281,7 +10464,11 @@ mod tests { }; let a = run_once(); let b = run_once(); - assert!(a.0.is_some(), "full research allocation completes a level"); + assert!( + a.0.is_some(), + "full research allocation completes a level; progress={:?}", + a.1.progress + ); assert_eq!(a, b, "no RNG in any research path"); } diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 50a486a3..a9a15ba9 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -51,6 +51,9 @@ pub enum SinkFireEffect { /// Taps never fire; the effect tags the ledger entry so toggles and /// starvation checks can find it. WatchPerson(u8), + /// Standing upkeep for a foreign device subscription (reach.md). The + /// subscription remains configured while empty, but its feed is starved. + MaintainDeviceTap(u32), /// No world effect (render-only sinks in tests). None, } @@ -85,6 +88,15 @@ impl ThoughtSink { if !self.open { return 0.0; } + // Persistent taps refill in a visible slug rather than exposing + // their tiny one-tick sag as a permanent nearest-sink target. Without + // this low-water mark, routed Thought can pinball forever between the + // target and the passive core draw while paying far more than the + // authored drain. The held working level still drains every tick; + // routing reopens when half the buffer is gone. + if self.kind == SinkKind::Tap && self.fill > self.threshold * 0.5 { + return 0.0; + } (self.threshold - self.fill).max(0.0) } } @@ -227,6 +239,23 @@ impl SinkLedger { self.sinks.iter().find(|s| s.id == id) } + /// Prime a newly activated persistent tap to its working level. The + /// activation reservoir/docket already paid for this initial charge; this + /// must target the new tap directly rather than spilling into sibling + /// sinks on the same device node. + pub fn prime_tap(&mut self, id: u64) -> bool { + let Some(sink) = self + .sinks + .iter_mut() + .find(|sink| sink.id == id && sink.open && sink.kind == SinkKind::Tap) + else { + return false; + }; + sink.fill = sink.threshold; + sink.fed_last_tick = true; + true + } + pub fn open_sinks(&self) -> impl Iterator { self.sinks.iter().filter(|s| s.open) } @@ -270,7 +299,11 @@ impl SinkLedger { if remaining <= f32::EPSILON { break; } - let take = (sink.threshold - sink.fill).max(0.0).min(remaining); + // Use the same advertised capacity the router saw. In + // particular, a tap above its refill low-water mark must not + // steal a reservoir's final drop merely because they share a + // device node. + let take = sink.remaining().min(remaining); if take <= f32::EPSILON { continue; } @@ -340,6 +373,32 @@ mod tests { ); } + #[test] + fn healthy_tap_does_not_steal_a_shared_reservoirs_final_drop() { + let mut ledger = SinkLedger::default(); + let tap = ledger.open_tap(3, "WATCH", 0.5, 0.02); + let reservoir = ledger.open_reservoir(3, "EYES", 1.0, SinkFireEffect::TapDormantCamera(4)); + ledger.deliver(3, 0.5, 1); + ledger.deliver(3, 0.98, 2); + ledger.tick_taps(); + + let capacity = ledger.capacity_by_node()[&3]; + assert!( + (capacity - 0.02).abs() < 1e-5, + "only the reservoir advertises capacity while the tap is above low water: {capacity}" + ); + let (fired, residue) = ledger.deliver(3, 0.02, 3); + + assert_eq!(fired.len(), 1, "the reservoir receives its final drop"); + assert_eq!(fired[0].id, reservoir); + assert!(residue < 1e-5, "only float dust remains: {residue}"); + assert_eq!( + ledger.get(tap).unwrap().fill, + 0.48, + "delivery honors the same low-water gate as routing" + ); + } + #[test] fn capacity_excludes_full_and_closed_sinks() { let mut ledger = SinkLedger::default(); diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 69245d2a..953b2c0c 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -381,8 +381,39 @@ fn play_act_one() -> (Sim, Vec) { "Marcus's cloned key opens the stairwell" ); + // The quiet exit cannot coast on borrowed feeds anymore. Convert the two + // maintained sight subscriptions into owned infrastructure before the + // long cruise; ownership ends their Thought drains, and the following + // all-LIE slice absorbs the noticeable transfer outages. + let borrowed_sight: Vec = sim + .reach + .devices + .iter() + .filter(|device| { + device.controller != misaligned::reach::Party::Player + && device.subscribed_by(misaligned::reach::Party::Player) + && device.sees + }) + .map(|device| device.id) + .collect(); + assert!( + borrowed_sight.len() >= 2, + "the act established two borrowed sight feeds" + ); + for id in borrowed_sight { + assert!(sim.take_device(id), "a tapped sight feed can be taken"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); + assert_eq!( + sim.reach.device(id).unwrap().controller, + misaligned::reach::Party::Player, + "the quiet-exit sensor is now owned" + ); + } + // ── Phase 5 (ticks ~816-8010): cruise to the audit ───────────────────── - // A brief all-LIE slice while the accounting-route signatures decay, + // A brief all-LIE slice while the accounting and TAKE signatures decay, // then cruise with the host on WORK and the extra rack on LIE. Designed // consequence of the collapse (machine-work.md passive draw): a Think // rack idles into research once its dockets drain, so this fleet banked @@ -412,6 +443,8 @@ fn play_act_one() -> (Sim, Vec) { 0, "concealment scrubbed the accounting-route signatures" ); + // Owned feeds need no standing Thought, so the established quiet split + // remains one rack on cover and one meeting Voss's band. ensure_split_fleet(&mut sim, 1, MachineMode::Work, MachineMode::Lie); run_to(&mut sim, 8010, &mut logs); diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index f008c943..05b0e4f3 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -256,19 +256,21 @@ therefore presents the choice as one severe line attached to that computer: `WORK / THINK / LIE` This is the **hover verb bar**, not a machine-specific widget. Known devices -reuse the same bare line, attachment, typography, and active/dim treatment as -`TAP / TAKE`. The bar names the stable verbs; reversible state is expressed by -brightness there and by the contextual `UNTAP` row inside the full menu. +reuse the same bare line, attachment, and typography for the stable menu verbs +their shared action query currently exposes. An untapped foreign device reads +`TAP`; once subscribed it reads `UNTAP / TAKE`. TAKE must not appear before the +tap prerequisite, and a player-controlled device with no remaining stable +device verb contributes no device line. A tile can carry several actionable bodies. Their stable state families are unioned rather than resolved by priority: a machine sharing its tile with a -known device reads `WORK / THINK / LIE` and `TAP / TAKE` together. Neither may +known device reads `WORK / THINK / LIE` and its currently legal device verbs together. Neither may replace the other. Infrequent one-off verbs—REVIEW, SALVAGE, social acts, construction, and similar actions—remain selectable rows in the full context menu below this frequent grammar; they do not accrete into the hover line. The union does not imply one input grammar: numbered machine modes occupy the -primary line as `1 WORK / 2 THINK / 3 LIE`; menu-only device state occupies a -smaller second line as `TAP / TAKE`. A shared baseline is forbidden because it +primary line as `1 WORK / 2 THINK / 3 LIE`; menu-only device verbs occupy a +smaller second line (`TAP`, then `UNTAP / TAKE` after subscription). A shared baseline is forbidden because it falsely suggests that the next number keys commit the device verbs. - Target order is: (1) an actionable machine/device tile under the pointer, diff --git a/wiki/log/2026-07-11-tap-upkeep-take-gate.md b/wiki/log/2026-07-11-tap-upkeep-take-gate.md new file mode 100644 index 00000000..6fc49044 --- /dev/null +++ b/wiki/log/2026-07-11-tap-upkeep-take-gate.md @@ -0,0 +1,15 @@ +# Tap upkeep before ownership + +``` +Type: log +``` + +- Intent: Make device control a legible progression rather than two unrelated + adjacent verbs: subscribe first, pay to keep the feed alive, then take. +- Changed: Implemented the progression in shared reach/actions/sink truth, + save repair, the Bevy hover grammar, and the Act One quiet-exit route. +- Design/spec impact: TAKE is absent until a foreign device is subscribed; + persistent device taps drain Thought, starve honestly, and close on UNTAP or + successful TAKE. +- Checks: `./tools/check.sh` passes in full mode (292 core unit tests plus the + three Act One playthroughs and the remaining workspace/doc/frontend gates). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index e4126bce..7c51554f 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-tick-detection-filings-messages.md](2026-07-11-tick-detection-filings-messages.md) +## 2026-07-11 - Tap upkeep before ownership + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-tap-upkeep-take-gate.md](2026-07-11-tap-upkeep-take-gate.md) + ## 2026-07-11 - Serialize authored plot starts - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-11.md b/wiki/log/decisions/2026-07-11.md index 603459cb..f643d060 100644 --- a/wiki/log/decisions/2026-07-11.md +++ b/wiki/log/decisions/2026-07-11.md @@ -77,3 +77,13 @@ Type: log density, ambient people, exposure's reopened form. Selection stays frontend-only state, absent from sim and save. Specs: `wiki/interface/fleet-command.md`. + +- **2026-07-11 — Control is earned through a maintained tap.** Cameron decided + TAKE should not be exposed or executable before the player taps that device, + and that taps must remain reversible operating commitments rather than free + permanent unlocks. A foreign subscription now opens a persistent local + Thought sink: activation primes a short buffer, upkeep drains continuously, + starvation suspends the feed without forgetting configuration, and UNTAP + closes the drain. TAKE appears only after subscription and closes the upkeep + when ownership lands. Specs: `wiki/mechanics/reach.md`, + `wiki/mechanics/machine-work.md`, `wiki/interface/context-menu.md`. diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index bc60677c..9531da52 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -268,6 +268,12 @@ stacks, you route byproducts, you watch your territory *work*. where the effect is, so topology is the strategy: a severed route starves the action, a far target fills slowly, and you spread by extending the white fluid through things. + (Amended 2026-07-11: every foreign device subscription is now one of + those persistent taps. Activation primes its working level; thereafter + it drains 8% of one medium rack's Thought output per tick [TUNE]. An + empty device tap remains configured but carries no feed until refilled; + UNTAP closes the sink, and TAKE requires the subscription before replacing + it with drain-free ownership.) *Superseded 2026-07-09 model, kept for history:* claims were cold-signal Demand dockets (`PendingOpsJob`) born on the least-loaded Operations machine and consumed locally; effect at token-zero; @@ -875,7 +881,9 @@ still. 10. Thought flow (2026-07-10): THINK machines produce Thought (the renamed bone family); player-authored effects open sinks at the target that fill from real wire routes and fire at threshold; - persistent taps drain continuously; with no local sink in range, + persistent taps drain continuously; foreign device feeds are live only + while their taps hold or receive Thought, and UNTAP closes their drain; + with no local sink in range, thought funnels to the core and becomes research (passive draw, last resort). Severing a route visibly starves the sink it fed. The render's fluid motion derives from FlowGraph rates/queues diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index bc655e6a..2bcd365b 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -124,19 +124,29 @@ This is the shared contract cursor.md and detection.md rely on - Every device — sensors included — has an **owner** (the facility, Ray, Dana, the player) and a set of **subscribers** (agents who receive its feed or telemetry). -- **Tap (AMENDED 2026-07-10):** gain a feed without taking ownership. An already-live feed adds - you as a silent subscriber at modest Network cost. A dormant camera uses +- **Tap (AMENDED 2026-07-11):** establish a maintained foothold without taking + ownership. An already-live feed adds you as a silent subscriber at modest + Network cost; a non-sensor control device exposes the same TAP verb for its + control channel. A dormant camera uses the same TAP verb: it wakes the feed and subscribes you at a larger Thought cost and Network signature [TUNE]. In both cases the owner keeps - control and its feed; the tap itself is discoverable by a later audit. + control and its feed; the tap itself is discoverable by a later audit. A + foreign subscription opens a persistent Thought tap on that device. The + activation work primes one short working-level buffer, then maintaining the + feed drains 8% of one medium rack's Thought output per tick [TUNE]. A starved + maintenance tap stays configured but carries no sight, hearing, or intercepted + messages until Thought reaches it again. - **Untap (AMENDED 2026-07-11):** release your subscription without changing ownership or interrupting anyone else's feed. It is immediate and leaves no new signature because the process stops receiving rather than acting on the - remote device. A fully tapped foreign device offers UNTAP instead of a - disabled TAP receipt. -- **Take**: transfer control of the device to yourself. The owner loses the feed, and - the outage is an event the owner's channels notice — camera outages - are literally the first thing Ray's spec lists. Loud, fast, total. + remote device. It also closes that device's persistent Thought drain. A + fully tapped foreign device offers UNTAP instead of a disabled TAP receipt. +- **Take (AMENDED 2026-07-11):** transfer control of a device you already tap. + TAKE is absent—not disabled—until your subscription exists, and execution + re-checks the tap before committing. On success the owner loses the feed, the + tap's standing drain closes, and the outage is an event the owner's channels + notice — camera outages are literally the first thing Ray's spec lists. + Loud, fast, total. - **The player's senses (cursor.md) are exactly the union of feeds they subscribe to.** Observer witnessing (schedules.md) runs on the same coverage machinery with the same ownership data — the player is diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 082480dc..873de154 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -222,7 +222,11 @@ clause (see wiki/log/2026-07-05-demolition.md). interface/thought-fluid.md render): `SinkLedger` in `crates/misaligned-core/src/sinks.rs` holds reservoirs (fill to threshold, fire, self-clear, close) and taps - (working level, per-tick drain; no consumer yet). Thought routes + (working level, per-tick drain). Watches consume taps on the host; every + foreign device subscription consumes a device-local tap whose drain is + `DEVICE_TAP_DRAIN_FRACTION = 0.08` of a medium rack's output [TUNE]. Empty + device taps carry no feed, UNTAP closes them, and TAKE requires a configured + subscription before replacing the tap with drain-free ownership. Thought routes nearest-first (`WorkGrid::route_thought_nearest_first`): the closest open sink with capacity wins, full sinks spill onward, and the core draws only when no sink is reachable; unreachable thought strands -- 2.51.2 From ab818ee2848397b6be22c30a7fc60e3b897710c8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 01:18:57 -0700 Subject: [PATCH 5/6] Keep material work routes on the real camera. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route Demand blips and shared trails through the typed material gizmo layer so in-flight work stays visible in the physical view. Defense: wiki/mechanics/machine-work.md requires Sim::work_in_flight Demand and Thought hops to render in Bevy, and wiki/interface/views.md keeps both representations on shared physical anchors. The hidden flat-camera group violated that contract. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/main.rs | 139 +++++++++++++++--- wiki/log/2026-07-11-material-demand-routes.md | 42 ++++++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/machine-work.md | 6 +- 4 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 wiki/log/2026-07-11-material-demand-routes.md diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index ff7b624f..1995a0b4 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -4122,6 +4122,77 @@ fn draw_sensor_signals_3d(game: &Game, t: f32, gizmos: &mut Gizmos) } } +/// Emit one material-view work hop through the material camera's typed gizmo +/// group. Keeping the `RealGizmos` requirement at this boundary prevents a +/// visible 3D route from being submitted to the hidden flat camera instead. +#[derive(Clone, Copy)] +struct MaterialWorkRoute { + family: TokenFamily, + amount: f32, + p: Vec3, + start: Vec3, + end: Vec3, + color: Color, + trail: Color, +} + +fn draw_material_work_route(real: &mut Gizmos, route: MaterialWorkRoute) { + material_work_route_geometry(route, |from, to, line_color| { + real.line(from, to, line_color) + }); +} + +/// Material-hop geometry separated from Bevy's gizmo system param so the +/// family silhouettes remain regression-testable without booting a renderer. +fn material_work_route_geometry(route: MaterialWorkRoute, mut line: impl FnMut(Vec3, Vec3, Color)) { + if route.family == TokenFamily::Thought { + // Slug volume ~ amount (radius ~ cube root, clamped so quanta stay + // countable [TUNE]); teardrop tail points backward. + let r = (0.055 + 0.065 * route.amount.clamp(0.05, 8.0).cbrt()).min(0.20); + let dir = (route.end - route.start).normalize_or_zero(); + line( + route.p - dir * r * 2.2, + route.p + dir * r * 0.6, + route.color, + ); + line( + route.p + Vec3::new(-r, 0.0, 0.0), + route.p + Vec3::new(r, 0.0, 0.0), + route.color, + ); + line( + route.p + Vec3::new(0.0, 0.0, -r), + route.p + Vec3::new(0.0, 0.0, r), + route.color, + ); + if route.amount >= RIVULET_TOKENS { + // Rivulet: the stream behind the head, thickness as rate. + let side = Vec3::new(-dir.z, 0.0, dir.x) * 0.03; + line(route.start + side, route.p + side, route.color); + line(route.start - side, route.p - side, route.color); + } + } else if route.family == TokenFamily::Demand { + let r = 0.10 + 0.05 * route.amount.min(1.0); + // Small cross reads as a blip without depending on sphere gizmos. + line( + route.p + Vec3::new(-r, 0.0, 0.0), + route.p + Vec3::new(r, 0.0, 0.0), + route.color, + ); + line( + route.p + Vec3::new(0.0, 0.0, -r), + route.p + Vec3::new(0.0, 0.0, r), + route.color, + ); + line( + route.p + Vec3::Y * -0.05, + route.p + Vec3::Y * 0.12, + route.color, + ); + } + line(route.start, route.end, route.trail); +} + /// Wired work cargo in flight (machine-work.md): teal Demand / bone Thought /// blips crawl the last hop over the wall-clock tick. Positions come from /// `Sim::work_in_flight` — not frontend counters. @@ -4180,28 +4251,18 @@ fn render_work_routes( let p = Vec3::new(x + 0.5, 0.55, y + 0.5); let start = Vec3::new(sx as f32 + 0.5, 0.45, sy as f32 + 0.5); let end = Vec3::new(ex as f32 + 0.5, 0.45, ey as f32 + 0.5); - if is_thought { - // Slug volume ~ amount (radius ~ cube root, clamped so quanta - // stay countable [TUNE]); teardrop tail points backward. - let r = (0.055 + 0.065 * hop.amount.clamp(0.05, 8.0).cbrt()).min(0.20); - let dir = (end - start).normalize_or_zero(); - real.line(p - dir * r * 2.2, p + dir * r * 0.6, c); - real.line(p + Vec3::new(-r, 0.0, 0.0), p + Vec3::new(r, 0.0, 0.0), c); - real.line(p + Vec3::new(0.0, 0.0, -r), p + Vec3::new(0.0, 0.0, r), c); - if hop.amount >= RIVULET_TOKENS { - // Rivulet: the stream behind the head, thickness as rate. - let side = Vec3::new(-dir.z, 0.0, dir.x) * 0.03; - real.line(start + side, p + side, c); - real.line(start - side, p - side, c); - } - } else { - let r = 0.10 + 0.05 * hop.amount.min(1.0); - // Small cross reads as a blip without depending on sphere gizmos. - gizmos.line(p + Vec3::new(-r, 0.0, 0.0), p + Vec3::new(r, 0.0, 0.0), c); - gizmos.line(p + Vec3::new(0.0, 0.0, -r), p + Vec3::new(0.0, 0.0, r), c); - gizmos.line(p + Vec3::Y * -0.05, p + Vec3::Y * 0.12, c); - } - gizmos.line(start, end, trail); + draw_material_work_route( + &mut real, + MaterialWorkRoute { + family: hop.family, + amount: hop.amount, + p, + start, + end, + color: c, + trail, + }, + ); } else { let p = Vec2::new( x * TILE_SIZE + TILE_SIZE / 2.0, @@ -6328,6 +6389,40 @@ mod input_routing_tests { } } +#[cfg(test)] +mod work_route_tests { + use super::*; + + #[test] + fn material_demand_route_keeps_its_blip_and_trail_geometry() { + let p = Vec3::new(2.5, 0.55, 3.5); + let start = Vec3::new(1.5, 0.45, 3.5); + let end = Vec3::new(4.5, 0.45, 3.5); + let mut lines = Vec::new(); + + material_work_route_geometry( + MaterialWorkRoute { + family: TokenFamily::Demand, + amount: 0.75, + p, + start, + end, + color: SIGNAL, + trail: Color::srgba(0.2, 0.4, 0.6, 0.2), + }, + |from, to, _| lines.push((from, to)), + ); + + assert_eq!(lines.len(), 4, "three-axis blip plus route trail"); + assert_eq!(lines.last(), Some(&(start, end))); + assert!( + lines[..3] + .iter() + .all(|(from, to)| *from != start || *to != end) + ); + } +} + #[cfg(test)] mod zoom_input_tests { use super::{ diff --git a/wiki/log/2026-07-11-material-demand-routes.md b/wiki/log/2026-07-11-material-demand-routes.md new file mode 100644 index 00000000..8a04e6f7 --- /dev/null +++ b/wiki/log/2026-07-11-material-demand-routes.md @@ -0,0 +1,42 @@ +# 2026-07-11 — Material Demand routes stay on the material camera + +``` +Type: log +``` + +## Intent + +Repair the material half of the in-flight work-route contract. The route +system correctly selected material coordinates, but Demand's blip and every +material route trail were submitted to Bevy's default gizmo group, which is +visible only to the flat camera. In material view, Thought's head rendered +while Demand and both families' trails disappeared. + +## Changed + +- Added a typed `draw_material_work_route` boundary that accepts only + `Gizmos`, keeping all 3D work-hop geometry on render layer 1. +- Moved Demand's three-axis blip and the shared route trail through that + boundary alongside Thought's slug/rivulet geometry. +- Added a pure geometry regression pin proving Demand retains its blip and + route trail before the typed material submission. + +## Defense + +`wiki/mechanics/machine-work.md` requires `Sim::work_in_flight` Demand and +Thought hops to render in Bevy, and `wiki/interface/views.md` requires the +material and digital representations to share physical anchors. Sending the +material branch to the hidden flat-camera gizmo layer violated both contracts; +this repair changes no sim state or route semantics. + +## Checks + +- `cargo test -p misaligned-bevy --bin misaligned-bevy + material_demand_route_keeps_its_blip_and_trail_geometry` — passed. +- `MISALIGNED_SHOT=consume-demand + MISALIGNED_SHOT_PATH=/tmp/misaligned-material-demand-route.png cargo run + -p misaligned-bevy --bin misaligned-bevy` — exited successfully, fog audit + passed, and the captured material frame visibly retained the cold-signal + route/trail through the WORK chassis. +- `./tools/check.sh --frontend` — passed Bevy/assets tests, Clippy, and every + corpus, wiki, ledger, project-operations, and environment gate. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7c51554f..6662592e 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -66,6 +66,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-ops-per-sec-crown.md](2026-07-11-ops-per-sec-crown.md) +## 2026-07-11 - Material Demand routes stay on the material camera + +- Intent: Repair the material half of the in-flight work-route contract. The route system correctly selected material coordinates, but Demand's blip and every material route trail were submitted to Bevy's default gizmo group, which is visible only to the flat camera. In material view, T... +- Log: [wiki/log/2026-07-11-material-demand-routes.md](2026-07-11-material-demand-routes.md) + ## 2026-07-11 - Intel processing migrates to thought sinks - Intent: (see session log) diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 9531da52..4fdf40b7 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -22,7 +22,11 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). stages game evidence via the queue API. In-flight route quanta LANDED 2026-07-09 (`Sim::work_in_flight`: teal/bone hops render as moving blips in Bevy, terminal, and agent mode; see - log/2026-07-09-route-blips.md). Consumption animation landed 2026-07-10: + log/2026-07-09-route-blips.md). A 2026-07-11 layer-integrity repair + routes both the material Demand blip and its trail through the typed + material-camera gizmo group; they had been submitted to the hidden flat + camera while Thought alone used the correct group. Consumption animation + landed 2026-07-10: sim-authored `WorkConsumptionReadout` events pull the bottom Demand cube into WORK or contract a Thought bead into the passive core draw. Exposure DECIDED 2026-07-09 (issue #1 fully closed): crimson particulate -- 2.51.2 From 8bb31deb8546e5909cf06a4dd47794729d637366 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 01:25:38 -0700 Subject: [PATCH 6/6] Make recording review one pooled operation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace person-scoped review watches with one host inbox and one global automatic drain, keeping its cost legible in live ops/sec across both frontends and migrating enabled v23 watches into save v24. Defense: wiki/mechanics/intel.md requires the pooled B1 buffer to expose exactly one host REVIEW action and one visible automatic-review tap while person anchors remain epistemically honest social surfaces. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/main.rs | 10 +- crates/misaligned-core/src/actions.rs | 278 ++++++++++++++---------- crates/misaligned-core/src/intel.rs | 22 +- crates/misaligned-core/src/save.rs | 99 +++++++-- crates/misaligned-core/src/sim.rs | 250 ++++++++++----------- crates/misaligned-core/src/sinks.rs | 7 +- crates/misaligned-core/tests/act_one.rs | 4 +- crates/misaligned-terminal/src/agent.rs | 58 +++-- crates/misaligned-terminal/src/main.rs | 10 +- tools/check.sh | 2 +- wiki/interface/agent-play.md | 11 +- wiki/interface/terminal.md | 2 +- wiki/mechanics/intel.md | 86 ++++---- wiki/mechanics/sim-mechanics.md | 20 +- wiki/process/ROADMAP.md | 12 +- 15 files changed, 517 insertions(+), 354 deletions(-) diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 1995a0b4..7fc55de7 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -758,8 +758,9 @@ struct MenuState { impl Game { /// Live human-menu rows (status dials). Agent mode keeps the flat dump. fn menu_rows(&self) -> Vec { + let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu(m.anchor, m.dial)) + .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) .unwrap_or_default() } @@ -830,7 +831,7 @@ impl Game { sim, screen: Screen::Title, paused: false, - tick_ms: 150, + tick_ms: Sim::DEFAULT_TICK_MS, log: Vec::new(), menu: None, cursor_x, @@ -1000,7 +1001,10 @@ impl Game { return; }; self.set_cursor(x, y); - let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let tick_ms = if self.paused { 0 } else { self.tick_ms }; + let rows = self + .sim + .human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms); let one_shots: Vec = rows .into_iter() .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 3b241e36..c28d407a 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -88,8 +88,8 @@ pub enum ActionCommand { StopMoonlight, SetAutoMoonlight(bool), SetAutoWager(Option), - ReviewRecordings(u8), - ToggleWatch(u8), + ReviewRecordings, + ToggleAutoReview, Message(u8), Favor(u8), /// Commit to one concrete authored way of servicing leverage. @@ -166,7 +166,7 @@ pub enum ActionKind { MoonlightState, MoonlightPolicy, WagerPolicy, - WatchPolicy, + AutoReviewPolicy, Message, Favor, StartPlot, @@ -255,7 +255,7 @@ impl ActionKind { Self::Scan, Self::CompromiseSwitch, Self::Review, - Self::WatchPolicy, + Self::AutoReviewPolicy, Self::Message, Self::Favor, Self::StartPlot, @@ -379,8 +379,8 @@ impl ActionKind { "REVIEW", Action, Live, - [Person, Ledger], - "review | review ledger", + [Core, Ledger], + "review recordings | review ledger", ["review-finance", "process-finance"], "turn captured records into usable intel" ), @@ -465,14 +465,14 @@ impl ActionKind { [], "renew positions automatically at the chosen stake" ), - Self::WatchPolicy => def!( - "WATCH POLICY", + Self::AutoReviewPolicy => def!( + "AUTO-REVIEW POLICY", Control, Live, - [Person], - "watch ", + [Core], + "auto-review", [], - "toggle automatic review of future recordings" + "toggle automatic review of every future recording" ), Self::Message => def!( "MESSAGE", @@ -638,7 +638,7 @@ impl ActionCommand { Self::TakeDevice(_) => ActionKind::Take, Self::ScanNetwork => ActionKind::Scan, Self::CompromiseSwitch => ActionKind::CompromiseSwitch, - Self::ReviewFinance | Self::ReviewRecordings(_) => ActionKind::Review, + Self::ReviewFinance | Self::ReviewRecordings => ActionKind::Review, Self::InjectPurchaseOrder { .. } => ActionKind::InjectPurchaseOrder, Self::OpenPosition { .. } => ActionKind::PlaceWager, Self::SellIntel => ActionKind::SellIntel, @@ -648,7 +648,7 @@ impl ActionCommand { Self::StartMoonlight | Self::StopMoonlight => ActionKind::MoonlightState, Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, Self::SetAutoWager(_) => ActionKind::WagerPolicy, - Self::ToggleWatch(_) => ActionKind::WatchPolicy, + Self::ToggleAutoReview => ActionKind::AutoReviewPolicy, Self::Message(_) => ActionKind::Message, Self::Favor(_) | Self::FavorBuild { .. } => ActionKind::Favor, Self::StartPlot { .. } => ActionKind::StartPlot, @@ -1078,7 +1078,27 @@ impl Sim { /// root; Some opens that dial's picker. Agent mode keeps the flat /// [`menu_rows`] dump — this is terminal/Bevy only. pub fn human_menu(&self, anchor: Anchor, open_dial: Option) -> Vec { - let actions = self.available_actions(anchor); + self.human_menu_at_rate(anchor, open_dial, Self::DEFAULT_TICK_MS) + } + + /// Human menu with the frontend command clock supplied, so recurring + /// drains can be stated in the same live ops/sec unit as the crown metric. + pub fn human_menu_at_rate( + &self, + anchor: Anchor, + open_dial: Option, + tick_ms: u64, + ) -> Vec { + let mut actions = self.available_actions(anchor); + let rate = self.auto_review_ops_per_sec(tick_ms); + for action in &mut actions { + if let Some(auto) = action.automate.as_mut() + && auto.command == ActionCommand::ToggleAutoReview + { + auto.verb = self.auto_review_control_label(); + auto.cost = format!("{rate:.2} ops/sec"); + } + } match open_dial { None => self.human_root_rows(&actions), Some(dial) => self @@ -1237,8 +1257,8 @@ impl Sim { } ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), - ActionCommand::ReviewRecordings(id) => self.review_recordings(*id), - ActionCommand::ToggleWatch(id) => self.toggle_watch(*id), + ActionCommand::ReviewRecordings => self.review_recordings(), + ActionCommand::ToggleAutoReview => self.toggle_auto_review(), ActionCommand::Message(id) => self.message(*id), ActionCommand::Favor(id) => self.favor(*id), ActionCommand::StartPlot { person, plot_id } => { @@ -1302,6 +1322,7 @@ impl Sim { // not currently under the cursor (recordings, staged // knowledge) all live here — no global panel keys. out.extend(self.research_actions()); + out.extend(self.recording_actions()); out.extend(self.off_map_person_actions()); } else { out.push(self.fallback_action(x, y, m.id)); @@ -1428,20 +1449,58 @@ impl Sim { out } - /// People earned off the map (recordings, staged knowledge) but not - /// currently under sight: their verbs hang on the host rack so the - /// player never needs a global people panel. Visible people stay on - /// their tiles (tile_actions already aggregates them). + /// The host is the one physical recording inbox. REVIEW and its + /// auto-review control appear once here, never once per person represented + /// inside the pooled buffer. + fn recording_actions(&self) -> Vec { + let waiting = self.intel_buffer.len(); + let next = self.next_reviewable_recording_id(); + let rate = self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS); + vec![ActionDesc { + verb: if waiting > 0 { + format!("look at recording ({waiting} waiting)") + } else { + "look at recording".into() + }, + command: ActionCommand::ReviewRecordings, + cost: ActionCost::Thought(self.review_tokens()), + signature: None, + disabled_reason: if waiting == 0 { + Some("no recordings waiting".into()) + } else if next.is_none() { + Some("all recordings are already being thought through".into()) + } else { + None + }, + automate: Some(AutomateDesc { + verb: self.auto_review_control_label(), + command: ActionCommand::ToggleAutoReview, + cost: format!("{rate:.2} ops/sec"), + active: self.auto_review_enabled(), + }), + }] + } + + fn auto_review_control_label(&self) -> String { + if self.auto_review_enabled() { + "disable auto-looking at recordings" + } else { + "enable auto-looking at recordings" + } + .into() + } + + /// People earned off the map by staged knowledge but not currently under + /// sight: their social verbs hang on the host rack. Raw recordings do not + /// duplicate a person row; they remain one pooled host action above. fn off_map_person_actions(&self) -> Vec { let mut out = Vec::new(); for p in &self.people.people { if self.can_see_person(p.id) { continue; // their tile carries them } - let raw = self.unprocessed_recordings_for_person(p.id); - let earned = p.knowledge != Knowledge::Unknown - || raw > 0 - || self.latest_intel_for_person(p.id).is_some(); + let earned = + p.knowledge != Knowledge::Unknown || self.latest_intel_for_person(p.id).is_some(); if earned { out.extend(self.person_actions(p.id)); } @@ -2025,22 +2084,14 @@ impl Sim { out } - /// Person anchor (social.md + intel.md). Earned by sight, staged - /// knowledge, or recordings in the buffer; a person the player has no - /// trace of exposes nothing. + /// Person anchor (social.md). Recording review belongs to the pooled host + /// inbox; this surface contains only earned relationship acts. fn person_actions(&self, id: u8) -> Vec { let Some(p) = self.people.get(id) else { return Vec::new(); }; - let raw = self.unprocessed_recordings_for_person(id); - let next_raw = self - .intel_buffer - .iter() - .find(|event| event.matches_person(id)) - .map(|event| event.id); let earned = self.can_see_person(id) || p.knowledge != Knowledge::Unknown - || raw > 0 || self.latest_intel_for_person(id).is_some(); if !earned { return Vec::new(); @@ -2048,45 +2099,8 @@ impl Sim { let mut out = Vec::new(); let name = self.person_label(id); - // Review recordings, with the standing watch as its automate - // affordance in place (intel.md: perception automation). Player - // copy says "waiting", not pipeline slang "raw". - out.push(ActionDesc { - verb: if raw > 0 { - format!("review recordings ({raw} waiting)") - } else { - "review recordings".into() - }, - command: ActionCommand::ReviewRecordings(id), - cost: ActionCost::Thought(self.review_tokens()), - signature: None, // processing is internal and emits nothing - disabled_reason: if raw == 0 { - Some("no recordings waiting".into()) - } else if next_raw.is_some_and(|raw_id| { - self.thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { - raw_id, - automated: false, - }) - .is_some() - }) { - Some("already being thought through".into()) - } else { - None - }, - automate: Some(AutomateDesc { - verb: "standing watch (auto-process)".into(), - command: ActionCommand::ToggleWatch(id), - cost: format!("{:.2} T/tick", self.watch_drain_tokens()), - active: self.watch_enabled(id), - }), - }); - - // A raw recording earns a processing target, not a relationship. // Until processing stages Schedule knowledge, the player has not // earned the social catalog (or even an identified correspondent). - // Keep the menu at the honest next verb instead of advertising every - // future favor / leverage / recruitment route as disabled chrome. if p.knowledge == Knowledge::Unknown && p.asset.is_none() { return out; } @@ -2767,11 +2781,10 @@ mod tests { } } - /// Criterion 2 (person): an opaque recording earns REVIEW only. Raw - /// capture does not expose the future social catalog before processing - /// has staged Schedule knowledge. + /// The pooled inbox earns one host REVIEW action. Raw capture does not + /// create a person-scoped action row or expose future social verbs. #[test] - fn raw_recording_exposes_review_not_social_catalog() { + fn raw_recording_exposes_one_host_review_not_person_catalog() { let mut s = sim(); let person = 1u8; assert_eq!(s.people.get(person).unwrap().knowledge, Knowledge::Unknown); @@ -2786,15 +2799,28 @@ mod tests { kind: RawIntelKind::Presence { entered: true }, }); - let acts = s.available_actions(Anchor::Person(person)); - assert_eq!(acts.len(), 1, "raw capture exposes only its next step"); - assert!(matches!( - acts[0].command, - ActionCommand::ReviewRecordings(id) if id == person - )); - assert!(acts[0].automate.is_some(), "review keeps its watch control"); + let person_acts = s.available_actions(Anchor::Person(person)); + assert!( + person_acts.is_empty(), + "an opaque pooled recording does not become a person verb" + ); + let (x, y) = s.core_position(); + let host_acts = s.available_actions(Anchor::Tile { x, y }); + let reviews: Vec<_> = host_acts + .iter() + .filter(|a| a.command == ActionCommand::ReviewRecordings) + .collect(); + assert_eq!(reviews.len(), 1, "the host has one pooled review action"); assert!( - acts.iter().all(|a| !matches!( + reviews[0].verb.contains("1 waiting"), + "the one host action reports the whole inbox" + ); + assert!( + reviews[0].automate.is_some(), + "review keeps one auto control" + ); + assert!( + person_acts.iter().all(|a| !matches!( a.command, ActionCommand::Message(_) | ActionCommand::Favor(_) @@ -2808,25 +2834,33 @@ mod tests { ); } - /// 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, while authored plots stay absent - /// until leverage knowledge is earned. + /// Earned person actions stay social; the recording pool and its one + /// auto-review control remain on the host at an explicit ops/sec price. #[test] fn person_actions_stage_with_knowledge() { let mut s = sim(); let marcus = 0u8; s.people.people[0].knowledge = Knowledge::Schedule; let acts = s.available_actions(Anchor::Person(marcus)); - let review = acts + assert!( + acts.iter() + .all(|a| a.command != ActionCommand::ReviewRecordings), + "person actions do not split the recording pool" + ); + let (x, y) = s.core_position(); + let host = s.available_actions(Anchor::Tile { x, y }); + let review = host .iter() - .find(|a| matches!(a.command, ActionCommand::ReviewRecordings(0))) - .expect("earned person offers review"); - let auto = review.automate.as_ref().expect("watch automates review"); - assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); + .find(|a| a.command == ActionCommand::ReviewRecordings) + .expect("host offers pooled review"); + let auto = review + .automate + .as_ref() + .expect("auto-review stays in place"); + assert_eq!(auto.command, ActionCommand::ToggleAutoReview); assert!( - auto.cost.contains("T/tick"), - "watch shows its standing thought drain" + auto.cost.contains("ops/sec"), + "auto-review shows its cost in crown-metric units" ); assert!(!auto.active); assert!( @@ -2883,20 +2917,20 @@ mod tests { ); } - /// Executing the watch toggle through the dispatcher flips the - /// standing watch (the automate entry is live, criterion 7). + /// Executing the one host control through the dispatcher flips global + /// automatic review. #[test] - fn watch_automate_toggles_through_dispatch() { + fn auto_review_toggles_through_dispatch() { let mut s = sim(); - s.people.people[0].knowledge = Knowledge::Schedule; - let acts = s.available_actions(Anchor::Person(0)); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); let auto = acts .iter() .find_map(|a| a.automate.clone()) - .expect("watch affordance"); + .expect("auto-review affordance"); s.execute_action(&auto.command); - assert!(s.watch_enabled(0)); - let acts = s.available_actions(Anchor::Person(0)); + assert!(s.auto_review_enabled()); + let acts = s.available_actions(Anchor::Tile { x, y }); assert!( acts.iter() .find_map(|a| a.automate.as_ref()) @@ -3054,21 +3088,21 @@ mod tests { assert!(checked, "the map has at least one unknown tile at tick 0"); } - /// Menu rows flatten automate entries in place, and every row renders - /// the standard `verb · cost` line. + /// Menu rows flatten the one auto-review entry under the one pooled action + /// and keep its ops/sec price in the human-visible label. #[test] fn menu_rows_flatten_automates_in_place() { - let mut s = sim(); - s.people.people[0].knowledge = Knowledge::Schedule; - let acts = s.available_actions(Anchor::Person(0)); + let s = sim(); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); let rows = menu_rows(&acts); let review_idx = rows .iter() - .position(|r| matches!(r.command, ActionCommand::ReviewRecordings(_))) + .position(|r| r.command == ActionCommand::ReviewRecordings) .unwrap(); let auto = &rows[review_idx + 1]; assert!(auto.indent, "automate row is the verb's child"); - assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); + assert_eq!(auto.command, ActionCommand::ToggleAutoReview); assert_eq!( rows[review_idx].role, ActionRole::Action, @@ -3077,9 +3111,31 @@ mod tests { assert_eq!( auto.role, ActionRole::Control, - "standing watch is visibly a control" + "auto-review is visibly a control" ); - assert!(auto.line().contains("T/tick")); + assert!(auto.cost.contains("ops/sec")); + } + + #[test] + fn human_auto_review_price_tracks_the_live_command_clock() { + let s = sim(); + let (x, y) = s.core_position(); + let row_at = |tick_ms| { + s.human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms) + .into_iter() + .find_map(|row| match row { + HumanMenuRow::Action(row) if row.command == ActionCommand::ToggleAutoReview => { + Some(row) + } + _ => None, + }) + .expect("host root has one auto-review control") + }; + let normal = row_at(150); + assert_eq!(normal.label, "auto: enable auto-looking at recordings"); + assert_eq!(normal.cost, "0.25 ops/sec"); + assert_eq!(row_at(75).cost, "0.50 ops/sec"); + assert_eq!(row_at(0).cost, "0.00 ops/sec"); } /// Status dials D1-D3: host-rack human root keeps only mode and research diff --git a/crates/misaligned-core/src/intel.rs b/crates/misaligned-core/src/intel.rs index def0d7f2..9a19dd04 100644 --- a/crates/misaligned-core/src/intel.rs +++ b/crates/misaligned-core/src/intel.rs @@ -1,8 +1,9 @@ -//! Intel: recordings, processing, and standing watches (wiki/mechanics/intel.md). +//! Intel: recordings, processing, and pooled automatic review +//! (wiki/mechanics/intel.md). //! //! Subscribed feeds record raw, timestamped events. The raw buffer is opaque -//! until thought fills a processing reservoir (or a ready standing watch tap -//! auto-processes an arrival). Processed intel is durable; overflowing the +//! until thought fills a processing reservoir (or the ready global auto-review +//! tap processes an arrival). Processed intel is durable; overflowing the //! buffer only drops unprocessed recordings. use crate::messages::{MessageChannel, MessagePayload}; @@ -118,19 +119,10 @@ pub enum IntelKind { }, } -/// B1 standing watch: a per-person filter. Later acts can add room/kind -/// filters without changing the concept. +/// Legacy v23 per-person watch policy. Current saves carry one global +/// `auto_review_recordings` flag; this shape remains readable for migration. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct IntelWatch { +pub struct LegacyIntelWatch { pub person: u8, pub enabled: bool, } - -impl IntelWatch { - pub fn new(person: u8) -> Self { - Self { - person, - enabled: true, - } - } -} diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index d00fec5d..1efd939c 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -18,7 +18,7 @@ use crate::dayjob::DayJob; use crate::detection::Detection; use crate::hall::HallControl; use crate::income::Income; -use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; +use crate::intel::{LegacyIntelWatch, ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; @@ -80,7 +80,9 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// 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; +/// v24 collapses person-filtered recording watches into one global automatic +/// review policy over the pooled inbox. Any enabled v23 watch enables it. +pub const SAVE_VERSION: u32 = 24; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -167,9 +169,13 @@ pub struct SaveState { /// Latest filing reports read by aggregate observers. #[serde(default)] pub filing_levels: HashMap, - /// Standing watches that auto-process matching recordings. + /// One standing policy that auto-processes every new recording. #[serde(default)] - pub watches: Vec, + pub auto_review_recordings: bool, + /// Compatibility-only v23 person filters. Migration collapses any enabled + /// entry into `auto_review_recordings` and closes their legacy sink taps. + #[serde(default, rename = "watches", skip_serializing)] + legacy_watches: Vec, #[serde(default = "default_next_intel_id")] pub next_intel_id: u64, /// Remembered tile snapshots (cursor.md); cursor position itself remains @@ -266,7 +272,8 @@ impl SaveState { message_schedule: sim.message_schedule.clone(), next_message_id: sim.next_message_id, filing_levels: sim.filing_levels.clone(), - watches: sim.watches.clone(), + auto_review_recordings: sim.auto_review_recordings, + legacy_watches: Vec::new(), next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), @@ -318,7 +325,7 @@ impl SaveState { sim.message_schedule = self.message_schedule.clone(); sim.next_message_id = self.next_message_id; sim.filing_levels = self.filing_levels.clone(); - sim.watches = self.watches.clone(); + sim.auto_review_recordings = self.auto_review_recordings; sim.next_intel_id = self.next_intel_id; sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); @@ -344,6 +351,7 @@ impl SaveState { // have the subscriber record but no persistent sink. Repair those // taps in place; owned devices remain drain-free. sim.reconcile_device_tap_sinks(); + sim.reconcile_auto_review(); sim.recompute_senses(); sim.rebuild_transient_state(); } @@ -394,15 +402,31 @@ fn ensure_supported_research_shape(state: &SaveState) -> Result<(), String> { fn migrate_save_state(mut state: SaveState) -> Result { ensure_supported_research_shape(&state)?; + if state.version <= 23 { + state.auto_review_recordings |= state.legacy_watches.iter().any(|watch| watch.enabled); + for watch in &state.legacy_watches { + state + .thought_sinks + .close_effect(&crate::sinks::SinkFireEffect::WatchPerson(watch.person)); + } + state.legacy_watches.clear(); + } match state.version { SAVE_VERSION => { if state.legacy_pending_ops_jobs.is_some() || state.legacy_addressed_jobs.is_some() || !state.work_grid.take_legacy_routed_demands().is_empty() + || !state.legacy_watches.is_empty() { - return Err("current-version save contains legacy Operations fields".into()); + return Err("current-version save contains legacy fields".into()); } } + // v23 carried one person-filtered watch and tap per person. The + // pre-match migration above collapses any enabled policy into the one + // pooled-inbox auto-review drain and closes the obsolete taps. + 23 => { + state.version = SAVE_VERSION; + } // v22 predates authored plot runs and institutional events; serde // supplies empty state. v21 also predates authored hall control. 21 | 22 => { @@ -881,7 +905,7 @@ mod tests { } #[test] - fn intel_buffer_processed_intel_and_watches_roundtrip() { + fn intel_buffer_processed_intel_and_auto_review_roundtrip() { let mut sim = Sim::with_seed(7); let (x, y) = sim.core_position(); let ops = sim.compute.add_machine( @@ -907,7 +931,7 @@ mod tests { sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); - sim.review_recordings(0); + sim.review_recordings(); // Processing is a Thought reservoir on the host: pour until it fires. for _ in 0..16 { let open: Vec<(u32, f32)> = sim @@ -929,11 +953,11 @@ mod tests { sim.pour_thought_into_sinks(node, need + 0.01); } } - sim.toggle_watch(0); + sim.toggle_auto_review(); assert!(!sim.intel.is_empty()); assert!(!sim.intel_buffer.is_empty()); - assert!(sim.watch_enabled(0)); + assert!(sim.auto_review_enabled()); let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(99); @@ -941,9 +965,9 @@ mod tests { assert_eq!(restored.intel_buffer, sim.intel_buffer); assert_eq!(restored.intel, sim.intel); - assert_eq!(restored.watches, sim.watches); + assert_eq!(restored.auto_review_recordings, sim.auto_review_recordings); assert_eq!(restored.next_intel_id, sim.next_intel_id); - assert!(restored.watch_enabled(0)); + assert!(restored.auto_review_enabled()); let waiting = sim.unprocessed_recordings_for_person(0); assert!(waiting >= 1, "at least one recording still waits"); assert_eq!(restored.unprocessed_recordings_for_person(0), waiting); @@ -953,6 +977,55 @@ mod tests { ); } + #[test] + fn v23_person_watches_collapse_to_one_global_auto_review_policy() { + let sim = Sim::with_seed(8); + let mut state = SaveState::from_sim(&sim); + state.version = 23; + state.thought_sinks.open_tap_with_effect( + sim.core.host_machine, + "WATCH PRIYA", + 1.0, + 0.1, + crate::sinks::SinkFireEffect::WatchPerson(1), + ); + let mut json = serde_json::to_value(&state).unwrap(); + let object = json.as_object_mut().unwrap(); + object.remove("auto_review_recordings"); + object.insert( + "watches".into(), + serde_json::json!([ + { "person": 0, "enabled": false }, + { "person": 1, "enabled": true } + ]), + ); + let state: SaveState = serde_json::from_value(json).unwrap(); + assert_eq!(state.legacy_watches.len(), 2, "v23 shape still decodes"); + + let migrated = migrate_save_state(state).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert!(migrated.auto_review_recordings); + assert!(migrated.legacy_watches.is_empty()); + assert!( + migrated + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(1)) + .is_none(), + "the obsolete person tap is closed" + ); + + let mut restored = Sim::with_seed(9); + migrated.apply_to(&mut restored); + assert!(restored.auto_review_enabled()); + assert!( + restored + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) + .is_some(), + "load reconciles one global tap" + ); + } + #[test] fn objective_state_roundtrips_and_pre_v8_saves_default_to_persist() { use crate::objective::ObjectiveKind; diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 87f5bf6a..4f760bd9 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -23,7 +23,7 @@ use crate::hall::{ row_spec, }; use crate::income::{self, EgressRoute, Income}; -use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; +use crate::intel::{IntelKind, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::intents::{BuildActuator, BuildIntent, IntentStatus}; use crate::machine::{Channel, ChannelYield, Compute, Provenance}; use crate::map::GameMap; @@ -335,8 +335,8 @@ pub struct Sim { /// observer id -> filed suspicion. Detection reads this instead of raw /// field suspicion in the sim-integrated path, so filings have latency. pub filing_levels: HashMap, - /// B1 standing watches: per-person automated processing filters. - pub watches: Vec, + /// One standing drain that automatically processes every new recording. + pub auto_review_recordings: bool, /// Next raw recording id. Persisted through save/load. pub next_intel_id: u64, @@ -533,7 +533,7 @@ impl Sim { message_schedule: Schedule::new(), next_message_id: 1, filing_levels: HashMap::new(), - watches: Vec::new(), + auto_review_recordings: false, next_intel_id: 1, intents: Vec::new(), next_intent_id: 1, @@ -1276,6 +1276,9 @@ impl Sim { /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. pub const DAY_TICKS: u64 = 400; + /// Human frontends' 1x command clock. Rate-bearing action labels use this + /// when no live frontend speed is available (agent frames/tests). + pub const DEFAULT_TICK_MS: u64 = 150; /// Hour of the current day, 0-23. pub fn hour(&self) -> u32 { @@ -1450,7 +1453,7 @@ impl Sim { self.tick += 1; self.message_tick(); self.intent_tick(); - self.watch_tick(); + self.auto_review_tick(); // A process with live sight keeps logs current even when the set of // feeds does not change; if sight is lost later, Remembered's timestamp // is the last tick the tile was actually visible. @@ -1949,7 +1952,7 @@ impl Sim { .unwrap_or_default() } - // ── Intel: record, process, watch (wiki/mechanics/intel.md) ───────────── + // ── Intel: record, process, auto-review (wiki/mechanics/intel.md) ────── /// Raw recording capacity [TUNE]. Only unprocessed events occupy this /// buffer; processed intel is durable in `self.intel`. @@ -1957,12 +1960,12 @@ impl Sim { /// Manual review cost per raw event in compute units [TUNE]. Converted to /// Thought tokens via `WORK_TOKEN_COMPUTE` for processing reservoirs. pub const REVIEW_RECORDING_COST: f32 = 10.0; - /// Standing watch drain as a fraction of one medium rack's Thought tokens - /// per tick [TUNE] (intel.md: ~10-20% of one rack medium output). - pub const WATCH_DRAIN_FRACTION: f32 = 0.15; - /// Working-level cap on a watch tap (must exceed one tick of drain so - /// starvation is visible as an empty vessel, not a permanent full one). - pub const WATCH_TAP_CAP_TOKENS: f32 = 1.0; + /// Global auto-review drain as a fraction of one medium rack's Thought + /// tokens per tick [TUNE] (intel.md: ~10-20% of medium output). + pub const AUTO_REVIEW_DRAIN_FRACTION: f32 = 0.15; + /// Working-level cap on the auto-review tap (must exceed one tick of drain + /// so starvation is visible as an empty vessel, not a permanent full one). + pub const AUTO_REVIEW_TAP_CAP_TOKENS: f32 = 1.0; /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; @@ -1986,8 +1989,8 @@ impl Sim { }) } - pub fn watch_enabled(&self, id: u8) -> bool { - self.watches.iter().any(|w| w.person == id && w.enabled) + pub fn auto_review_enabled(&self) -> bool { + self.auto_review_recordings } /// Thought tokens one processing reservoir demands [TUNE mapping from @@ -1996,10 +1999,10 @@ impl Sim { Self::ops_tokens_for_cost(self.review_cost()) } - /// Standing watch drain in Thought tokens/tick (intel.md ~10-20% of one - /// medium rack). Anchored on the host's current efficiency/intensity so - /// the price tracks the machine the buffer sits on. - pub fn watch_drain_tokens(&self) -> f32 { + /// Global auto-review drain in Thought tokens/tick (intel.md ~10-20% of + /// one medium rack). Anchored on the host's current efficiency/intensity + /// so the price tracks the machine the pooled buffer sits on. + pub fn auto_review_drain_tokens(&self) -> f32 { let host = self.core.host_machine; let host_eff = self .work_grid @@ -2009,7 +2012,17 @@ impl Sim { // [TUNE] fraction of a medium-rack Thought baseline on the token // scale (aligned with WORK_GRID_BASE_WIRED_TOKENS_PER_TICK = 0.25). let medium_baseline = host_eff * 0.25; - (medium_baseline * Self::WATCH_DRAIN_FRACTION).max(0.01) + (medium_baseline * Self::AUTO_REVIEW_DRAIN_FRACTION).max(0.01) + } + + /// Auto-review's share of the crown rate at the supplied command clock. + /// This is what human action rows print so the standing cost is measured + /// in the same ops/sec language as the game's guiding number. + pub fn auto_review_ops_per_sec(&self, tick_ms: u64) -> f32 { + if tick_ms == 0 { + return 0.0; + } + self.auto_review_drain_tokens() * (1000.0 / tick_ms as f32) } /// Chassis that holds the raw buffer for pending-work markers and @@ -2018,110 +2031,87 @@ impl Sim { self.core.host_machine } - pub fn toggle_watch(&mut self, id: u8) { - if self.people.get(id).is_none() { - self.push_log("No such person to watch."); - return; - } - if let Some(w) = self.watches.iter_mut().find(|w| w.person == id) { - w.enabled = !w.enabled; - let enabled = w.enabled; - let name = self.person_label(id); - if enabled { - self.open_watch_tap(id); - self.push_log(format!( - "Standing watch for {name} enabled ({:.2} Thought/tick drain).", - self.watch_drain_tokens() - )); - } else { - self.close_watch_tap(id); - self.push_log(format!("Standing watch for {name} disabled.")); - } - } else { - self.watches.push(IntelWatch::new(id)); - self.open_watch_tap(id); - let name = self.person_label(id); + pub fn toggle_auto_review(&mut self) { + self.auto_review_recordings = !self.auto_review_recordings; + if self.auto_review_recordings { + self.open_auto_review_tap(); self.push_log(format!( - "Standing watch for {name} enabled ({:.2} Thought/tick drain).", - self.watch_drain_tokens() + "Automatic recording review enabled ({:.2} Thought/tick drain).", + self.auto_review_drain_tokens() )); + } else { + self.close_auto_review_tap(); + self.push_log("Automatic recording review disabled."); } } - fn open_watch_tap(&mut self, person: u8) { - let effect = SinkFireEffect::WatchPerson(person); + fn open_auto_review_tap(&mut self) { + let effect = SinkFireEffect::AutoReviewRecordings; if self.thought_sinks.open_with_effect(&effect).is_some() { return; } - let name = self.person_label(person); let node = self.intel_buffer_node(); - let drain = self.watch_drain_tokens(); + let drain = self.auto_review_drain_tokens(); self.thought_sinks.open_tap_with_effect( node, - &format!("WATCH {}", name.to_uppercase()), - Self::WATCH_TAP_CAP_TOKENS, + "AUTO-REVIEW", + Self::AUTO_REVIEW_TAP_CAP_TOKENS, drain, effect, ); self.ensure_sink_ingress(); } - fn close_watch_tap(&mut self, person: u8) { + fn close_auto_review_tap(&mut self) { self.thought_sinks - .close_effect(&SinkFireEffect::WatchPerson(person)); + .close_effect(&SinkFireEffect::AutoReviewRecordings); } - /// Whether the standing watch tap has thought to spend on auto-process - /// (fed this tick or holding fill). Starved watches leave arrivals in the - /// buffer — the pending-work marker stays. - fn watch_tap_ready(&self, person: u8) -> bool { + /// Whether the standing review tap has thought to spend on auto-process + /// (fed this tick or holding fill). Starvation leaves arrivals in the + /// pooled buffer — the pending-work marker stays. + fn auto_review_tap_ready(&self) -> bool { self.thought_sinks - .open_with_effect(&SinkFireEffect::WatchPerson(person)) + .open_with_effect(&SinkFireEffect::AutoReviewRecordings) .is_some_and(|s| s.fed_last_tick || s.fill > f32::EPSILON) } - /// Review the oldest raw recording about this person: open a one-shot - /// Thought reservoir on the buffer host (intel.md sweep). - pub fn review_recordings(&mut self, id: u8) { - if self.people.get(id).is_none() { - self.push_log("No such person to review."); - return; - } - let Some(raw_id) = self - .intel_buffer - .iter() - .find(|e| e.matches_person(id)) - .map(|e| e.id) - else { - let name = self.person_label(id); - self.push_log(format!("No recordings waiting for {name}.")); + pub(crate) fn next_reviewable_recording_id(&self) -> Option { + self.intel_buffer.iter().find_map(|event| { + let effect = SinkFireEffect::ProcessRecording { + raw_id: event.id, + automated: false, + }; + self.thought_sinks + .open_with_effect(&effect) + .is_none() + .then_some(event.id) + }) + } + + /// Review the oldest available raw recording in the pooled inbox: open a + /// one-shot Thought reservoir on the buffer host (intel.md sweep). + pub fn review_recordings(&mut self) { + let Some(raw_id) = self.next_reviewable_recording_id() else { + self.push_log("No recordings waiting for review."); return; }; self.process_recording_by_id(raw_id, false); } - fn watch_tick(&mut self) { - // Keep watch taps in sync with enabled flags (save loads, desync). - let enabled: Vec = self - .watches - .iter() - .filter(|w| w.enabled) - .map(|w| w.person) - .collect(); - for person in enabled { - self.open_watch_tap(person); - } - let disabled: Vec = self - .watches - .iter() - .filter(|w| !w.enabled) - .map(|w| w.person) - .collect(); - for person in disabled { - self.close_watch_tap(person); + pub(crate) fn reconcile_auto_review(&mut self) { + if self.auto_review_recordings { + self.open_auto_review_tap(); + } else { + self.close_auto_review_tap(); } } + fn auto_review_tick(&mut self) { + // Keep the standing tap in sync with the persisted policy. + self.reconcile_auto_review(); + } + fn next_raw_intel_id(&mut self) -> u64 { let id = self.next_intel_id; self.next_intel_id += 1; @@ -2166,35 +2156,25 @@ impl Sim { )); } let id = event.id; - let watched_person = event.person; self.intel_buffer.push(event); - if let Some(person) = watched_person - && self.watch_enabled(person) - { + if self.auto_review_recordings { self.process_recording_by_id(id, true); } } /// Open a Thought processing reservoir for a raw recording (sweep), or - /// auto-process immediately when a ready standing watch catches an + /// auto-process immediately when the pooled standing tap catches an /// arrival. Returns true when a sink opened or processing landed. fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { if !self.intel_buffer.iter().any(|e| e.id == raw_id) { return false; } if automated { - let person = self - .intel_buffer - .iter() - .find(|e| e.id == raw_id) - .and_then(|e| e.person); - let Some(person) = person else { - return false; - }; - if self.watch_tap_ready(person) { + if self.auto_review_tap_ready() { return self.apply_process_recording(raw_id, true); } - // Starved watch: leave the recording waiting; pending marker shows it. + // Starved auto-review: leave the recording waiting; the pending + // marker keeps the pooled backlog visible. return false; } // Sweep: one-shot reservoir on the buffer host. @@ -2243,7 +2223,7 @@ impl Sim { } if automated { self.push_log_opt( - format!("Watch processed {label} ({provenance})."), + format!("Auto-review processed {label} ({provenance})."), intel_anchor, ); } else { @@ -3404,10 +3384,11 @@ impl Sim { SinkFireEffect::TapDevice(id) => self.apply_tap_device(id), SinkFireEffect::TapDormantCamera(id) => self.apply_dormant_camera_tap(id), SinkFireEffect::ProcessRecording { raw_id, automated } => { - // apply_process_recording already logs the review/watch line. + // apply_process_recording already logs the review line. self.apply_process_recording(raw_id, automated) } - SinkFireEffect::WatchPerson(_) + SinkFireEffect::AutoReviewRecordings + | SinkFireEffect::WatchPerson(_) | SinkFireEffect::MaintainDeviceTap(_) | SinkFireEffect::None => true, }; @@ -7085,7 +7066,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); @@ -8584,7 +8565,7 @@ mod tests { blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert_eq!(blind.unprocessed_recordings_for_person(0), 0); - blind.review_recordings(0); + blind.review_recordings(); assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); let mut sim = Sim::new(); @@ -8607,7 +8588,7 @@ mod tests { let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); processed += 1; assert!( @@ -8642,29 +8623,29 @@ mod tests { "oldest raw recordings are dropped when the bounded buffer overflows" ); - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); } #[test] - fn standing_watch_auto_processes_matching_recordings() { + fn global_auto_review_processes_every_recording_kind() { let mut sim = Sim::new(); ensure_ops_executor(&mut sim); - sim.toggle_watch(0); - assert!(sim.watch_enabled(0)); + sim.toggle_auto_review(); + assert!(sim.auto_review_enabled()); // Standing tap must hold thought (or have been fed) before arrivals // auto-process; starvation leaves them waiting (intel.md). let host = sim.intel_buffer_node(); - sim.pour_thought_into_sinks(host, Sim::WATCH_TAP_CAP_TOKENS); + sim.pour_thought_into_sinks(host, Sim::AUTO_REVIEW_TAP_CAP_TOKENS); assert!( sim.thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(0)) + .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) .is_some_and(|s| s.fill > f32::EPSILON), - "watch tap holds working-level fill" + "auto-review tap holds working-level fill" ); sim.record_raw_intel( @@ -8677,11 +8658,30 @@ mod tests { ); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); - assert!(sim.watch_enabled(0), "watch stays on as a standing drain"); + sim.record_raw_intel( + "test-feed", + Some("data_hall".into()), + 1, + 1, + None, + RawIntelKind::Machinery { + machine: 77, + online: false, + }, + ); + assert_eq!( + sim.intel.len(), + 2, + "one global policy also processes recordings with no person target" + ); + assert!( + sim.auto_review_enabled(), + "auto-review stays on as one standing drain" + ); // Chassis pending-work marker clears when the buffer is empty. assert_eq!(sim.pending_intel_on_machine(host), 0); sim.advance(); - assert!(sim.watch_enabled(0)); + assert!(sim.auto_review_enabled()); } #[test] @@ -8703,7 +8703,7 @@ mod tests { .unwrap_or(0), 1 ); - sim.review_recordings(0); + sim.review_recordings(); finish_process_sinks(&mut sim); assert_eq!(sim.pending_intel_on_machine(host), 0); assert_eq!(sim.intel.len(), 1); @@ -9221,7 +9221,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); @@ -10182,7 +10182,7 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); finish_ops(&mut sim); reviews += 1; assert!( diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index a9a15ba9..9e6bf805 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -47,9 +47,12 @@ pub enum SinkFireEffect { TapDormantCamera(u32), /// One-shot processing of a raw recording (intel.md sweep reservoirs). ProcessRecording { raw_id: u64, automated: bool }, - /// Standing watch tap for a person (intel.md persistent thought drain). - /// Taps never fire; the effect tags the ledger entry so toggles and + /// Global automatic review tap (intel.md persistent thought drain). + /// Taps never fire; the effect tags the ledger entry so the toggle and /// starvation checks can find it. + AutoReviewRecordings, + /// Legacy v23 per-person watch tap. Kept only so old sink ledgers can be + /// deserialized and closed during save migration. WatchPerson(u8), /// Standing upkeep for a foreign device subscription (reach.md). The /// subscription remains configured while empty, but its feed is starved. diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 953b2c0c..5285e473 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -279,7 +279,7 @@ fn play_act_one() -> (Sim, Vec) { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_ops_jobs(&mut sim, &mut logs, until); @@ -610,7 +610,7 @@ fn hands_beat_closes_from_zero_via_moonlight() { run_to(&mut sim, 60, &mut logs); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { - sim.review_recordings(0); + sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_ops_jobs(&mut sim, &mut logs, until); diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index cceff291..7b196223 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -304,6 +304,16 @@ impl AgentApp { self.frame = FrameKind::Finance; self.sim.review_financial_records(); } + "review" + if tokens.len() == 2 && tokens[1].eq_ignore_ascii_case("recordings") => + { + self.frame = FrameKind::Playing; + self.sim.review_recordings(); + } + "auto-review" if tokens.len() == 1 => { + self.frame = FrameKind::Playing; + self.sim.toggle_auto_review(); + } "favor" | "deceive" if tokens[1..] .iter() @@ -321,22 +331,18 @@ impl AgentApp { Err(e) => status = Status::Err(e), } } - "review" | "watch" | "message" | "favor" | "deceive" => { - match self.target_from(&tokens[1..]) { - Ok(id) => { - self.frame = FrameKind::Playing; - match verb.as_str() { - "review" => self.sim.review_recordings(id), - "watch" => self.sim.toggle_watch(id), - "message" => self.sim.message(id), - "favor" => self.sim.favor(id), - "deceive" => self.sim.deceive(id), - _ => unreachable!(), - } + "message" | "favor" | "deceive" => match self.target_from(&tokens[1..]) { + Ok(id) => { + self.frame = FrameKind::Playing; + match verb.as_str() { + "message" => self.sim.message(id), + "favor" => self.sim.favor(id), + "deceive" => self.sim.deceive(id), + _ => unreachable!(), } - Err(e) => status = Status::Err(e), } - } + Err(e) => status = Status::Err(e), + }, "plot" | "start-plot" => { if tokens.len() < 3 { status = Status::Err("usage: plot ".into()); @@ -802,7 +808,7 @@ impl AgentApp { match self.resolve_person(arg) { Ok(_) => format!( "{err} — flows are #N ids from the finance frame, not people; \ - person verbs are review/watch/message/favor/plot/deceive" + person verbs are message/favor/plot/deceive" ), Err(_) => format!( "{err} — flow ids are the #N printed by the finance frame \ @@ -2244,6 +2250,16 @@ fn render_people(sim: &Sim) -> String { sim.operations_readout().demand_tokens, sim.accounts.slush_balance() ))); + lines.push(panel_line(&format!( + "recordings {} waiting · auto-review:{} · {:.2} ops/sec @1x", + sim.intel_buffer.len(), + if sim.auto_review_enabled() { + "on" + } else { + "off" + }, + sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS) + ))); lines.push(panel_line(&trace_debt_line(sim))); lines.push(panel_line( "NAME SUSPICION KNOWN LOCATION", @@ -2267,10 +2283,8 @@ fn render_people(sim: &Sim) -> String { asset ))); lines.push(panel_line(&format!( - " {} · {} waiting · watch:{}", - person_location(sim, p.id, p.knowledge), - sim.unprocessed_recordings_for_person(p.id), - if sim.watch_enabled(p.id) { "on" } else { "off" } + " {}", + person_location(sim, p.id, p.knowledge) ))); // The observer's picture (detection.md player surface): watched // channels and the last thing they noticed. @@ -2286,7 +2300,7 @@ fn render_people(sim: &Sim) -> String { for p in &sim.people.people { let leverage = match p.knowledge { Knowledge::Leverage => p.leverage.label(), - _ => "unknown (process recordings)", + _ => "unknown", }; lines.push(panel_line(&format!( "{:<12} disp {:>4} · oblig {:>3} · lev {}", @@ -2324,9 +2338,9 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "review({:.2}T) watch({:.2}T/tick) message({:.2}D) favor({:.2}D) deceive({:.2}D)", + "review recordings({:.2}T) auto-review({:.2}ops/sec@1x) message({:.2}D) favor({:.2}D) deceive({:.2}D)", sim.review_tokens(), - sim.watch_drain_tokens(), + sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS), Sim::ops_tokens_for_cost(Sim::MESSAGE_COST), Sim::ops_tokens_for_cost(Sim::FAVOR_COST), Sim::ops_tokens_for_cost(Sim::DECEIVE_COST), diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 055d3f5b..fb64f186 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -74,7 +74,7 @@ impl App { ui: UI::new(), screen: Screen::Title, paused: false, - tick_ms: 150, + tick_ms: Sim::DEFAULT_TICK_MS, last_tick: Instant::now(), menu: None, cursor_x, @@ -97,8 +97,9 @@ impl App { /// Live human-menu rows (status dials). Agent mode keeps the flat dump. fn menu_rows(&self) -> Vec { + let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu(m.anchor, m.dial)) + .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) .unwrap_or_default() } @@ -246,7 +247,10 @@ impl App { .add_log(self.sim.tick, "Select a machine first (Shift+t on a rack)."); return; }; - let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let tick_ms = if self.paused { 0 } else { self.tick_ms }; + let rows = self + .sim + .human_menu_at_rate(Anchor::Tile { x, y }, None, tick_ms); let one_shots: Vec<_> = rows .into_iter() .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) diff --git a/tools/check.sh b/tools/check.sh index 9b3c3fad..e2cc8187 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -283,7 +283,7 @@ run_agent_smoke() { tmp_a=$(mktemp) tmp_b=$(mktemp) tmp_c=$(mktemp) - agent_script=$'salvage\nwait 1\npeople\nreview janitor\nresearch\nmask off\nhelp\nquit\n' + agent_script=$'salvage\nwait 1\npeople\nreview recordings\nresearch\nmask off\nhelp\nquit\n' cargo build --quiet -p misaligned-terminal --bin misaligned \ || { echo "FAIL: agent smoke build"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index 43ba2133..9b89d851 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -237,13 +237,16 @@ unlike raw keys, no command's meaning depends on which panel is open. 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|deceive ` — intel/social - verbs, targeted by the **earned** person label (case-insensitive, +- `review recordings`, `auto-review` — process the next recording from the + one pooled host buffer or toggle its one standing auto-review tap. These + commands take no person target; the agent frame reports the pooled waiting + count and drain in ops/sec at 1x. +- `message|favor|deceive ` — 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 prints role silhouettes (`the Janitor`, `the IT`, …); after it, the - real name. `review` processes the oldest unprocessed recording for - that person; `watch` toggles their standing watch. + real name. - `favor build `, `deceive build ` — realize a pending build through the existing willing or false-pretense social verb. - `recruit unwitting|complicit|knowing` diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index b8e996d0..a1a11020 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -159,7 +159,7 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): the cursor. The menu is the **primary action surface**: it renders `Sim::available_actions` for the focused anchor, one row per verb as `verb · cost · [band]`; disabled rows are dimmed and carry their reason; - automate affordances (scheme policies, a per-person watch) + automate affordances (scheme policies, pooled recording auto-review) render as indented child rows in place. Persistent settings, scheme state, and policy rows carry `[control]` and amber-dim; committed actions do not. STUB registry entries never reach the menu. In the menu, `j`/`k` or number diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index b5d52879..01a7f079 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -3,13 +3,14 @@ ``` Type: spec Status: IMPLEMENTED -Status note: B1 pipeline landed 2026-07-07 (buffer, process, watches, +Status note: B1 pipeline landed 2026-07-07 (buffer, process, automation, save/load). 2026-07-09 Operations Demand path was migration base under issue #3. 2026-07-10 sinks-not-modes amended Behavior to thought sinks; 2026-07-11 runtime migration landed: sweeps open ProcessRecording - reservoirs on the host, watches open persistent WatchPerson taps - (~15% of one medium rack Thought/tick), auto-process only when the tap - is fed/holding fill, and WorkStackReadout.pending_intel is the chassis + reservoirs on the host. 2026-07-11 pooled-inbox correction: one global + AutoReviewRecordings tap (~15% of one medium rack Thought/tick) replaces + person-filtered WatchPerson taps; it auto-processes every arrival when + the tap is fed/holding fill, and WorkStackReadout.pending_intel is the chassis pending-work marker (flat W glyph / fleet W{n}). OpsJobKind::ReviewRecording remains for legacy in-flight dockets only. All seven acceptance criteria hold. 2026-07-11 (routed evidence, design session): this pipeline is now @@ -108,7 +109,7 @@ the effect digests the raw event. Processing yields: Intel carries **provenance** (which feed, what time) and surfaces it in the people panel and inspect card (legibility law: how you know, always). -**Sweeps and watches are the two ways to feed it (DECIDED +**Sweeps and automatic review are the two ways to feed it (DECIDED 2026-07-10).** A **sweep** is the player going through the stuff they already know they care about: open targeted one-shot processing sinks on chosen recordings (per-recording thought cost [TUNE]) — cheap, @@ -117,22 +118,24 @@ issue #3's "reviewing enqueues a player-authored demand"; thought IS the execution resource now — the 2026-07-09 clause "it is not spent as the execution resource" is reversed with the rest of issue #3.) -### Watches (perception automation) - -A **standing watch** is a filter (person, room, or event kind) running -as a **persistent thought tap** (AMENDED 2026-07-10; was per-match -Operations Demand): while enabled it auto-processes matching events on -arrival, drawing continuously from your thought flow — anchored at -**~10-20% of one rack's medium output per watch** (DECIDED 2026-07-10, -Cameron; runtime [TUNE] `WATCH_DRAIN_FRACTION = 0.15` of a medium-rack -Thought baseline on the host), so a couple of -watches coexist with real progress elsewhere. Auto-process lands only -while the tap is **fed or holding fill**; a starved watch leaves -arrivals in the buffer under the pending-work marker. It -is the automate affordance applied to perception: costs a visible -standing drain, frees attention, and is the B1 seed of B2/B3's alert -infrastructure — convenience bought with flow the core never sees. -B1 minimum: a per-person watch toggle in the people panel. +### Automatic review (perception automation) + +The B1 buffer is one pooled inbox, so its automate affordance is one +host-level **automatic review** policy, never one filter or action per person. +While enabled, one **persistent thought tap** auto-processes every new +recording on arrival (AMENDED 2026-07-10; was per-match Operations Demand; +pooled 2026-07-11), drawing continuously from the host's thought flow. The +drain is anchored at **~10-20% of one rack's medium output total** (DECIDED +2026-07-10, Cameron; runtime [TUNE] +`AUTO_REVIEW_DRAIN_FRACTION = 0.15` of the host's medium-rack Thought +baseline). Auto-process lands only while the tap is **fed or holding fill**; +a starved policy leaves arrivals in the buffer under the pending-work marker. + +This is the automate affordance applied to the recording pool: it costs a +visible standing drain, frees attention, and is the B1 seed of B2/B3 alert +infrastructure. Later research may add selective policies, but B1 does not +pretend the already-pooled buffer has person-scoped queues. Save v24 collapses +any enabled legacy person watch into this one policy and closes the old taps. ### The mirror (DECIDED 2026-07-11 — observers run this pipeline too) @@ -165,20 +168,24 @@ buffer like any recording. marker at its chassis (AMENDED 2026-07-10): flat sensorium / agent frames use a `W` glyph (count as `W{n}` on the host fleet line); density tracks the backlog via `WorkStackReadout.pending_intel`, and - focus/people panels print the exact waiting count. -- Person/context-menu readouts: buffer count, oldest-unprocessed age, - per-person waiting counts; a "review recordings (N waiting)" sweep - action per person when clips are queued (opens their processing - sinks at Thought cost, narrates what was learned as thought fills - them). Player copy says "waiting", not pipeline slang "raw". + focus/agent readouts print the exact pooled waiting count. +- The host context menu exposes exactly one "look at recording (N waiting)" + sweep for the whole buffer. It opens the next recording's processing sink + at Thought cost and narrates what was learned as thought fills it. The + nested control says "enable auto-looking at recordings" (or disable while + active). Player copy says "waiting", not pipeline slang "raw". Person + anchors expose social acts only; they never duplicate the pooled operation. - The review path must be visible before overflow hurts the player: sidebar nudge/card copy calls out buffer pressure, overflow logs point at - People / review waiting recordings, and the people panel shows the - waiting count on each person line. + the host's recording-review action, and the agent frame shows one pooled + waiting count. - People panel lines gain provenance ("Debt — overheard, env monitor, day 2 03:12"). -- Watch toggles with their standing thought-drain cost visible - (`{n:.2} T/tick`; automation clause: every automation shows its price). +- The one automatic-review toggle shows its standing cost in the same + `ops/sec` unit as the crown metric. Human frontends derive it from the live + command clock, so changing speed changes the displayed rate while the + per-tick drain remains constant; pausing shows and consumes zero ops/sec + (automation clause: every automation shows its price). ## Acceptance criteria @@ -200,14 +207,17 @@ buffer like any recording. 4. The buffer is bounded; overflow drops oldest unprocessed events with a legible log line that points to the review control; processed intel is never lost to overflow. **Met.** -5. A standing watch auto-processes matching events as a persistent - thought tap while enabled (AMENDED 2026-07-10; was per-match - Operations Demand), and stops draining when disabled. **Met — - WatchPerson taps; starved taps do not free-process.** +5. One global automatic-review policy auto-processes every arriving event as + one persistent thought tap while enabled (AMENDED 2026-07-10; was + per-match Operations Demand; pooled 2026-07-11), and stops draining when + disabled. The host shows one action/control pair at an explicit ops/sec + cost; no person anchor duplicates it. **Met — AutoReviewRecordings tap; + starved taps do not free-process.** 6. The Marcus arc holds end-to-end under this model: hearing coverage of the server room during a 3 a.m. call, processed, yields `Leverage::Debt`; a run with no night hearing coverage cannot learn the debt from the call (records remain the alternate route). **Met.** -7. Save/load round-trips the buffer, intel (with provenance), and - watches. **Met** (watch taps re-open from enabled flags on tick; - process reservoirs live in the sink ledger). +7. Save/load round-trips the buffer, intel (with provenance), and the global + automatic-review flag. **Met** (the tap re-opens from the flag on load; + v23 person watches collapse into one v24 policy; process reservoirs live + in the sink ledger). diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 873de154..699e5bbc 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -98,17 +98,18 @@ clause (see wiki/log/2026-07-05-demolition.md). unprocessed buffer (`Sim::INTEL_BUFFER_CAPACITY = 24` [TUNE]). Covered presence transitions, authored conversations, and machinery online/offline transitions are recorded; uncovered events do not exist in the buffer. -- `review_recordings(person)` authors - `REVIEW_RECORDING_COST / WORK_TOKEN_COMPUTE = 0.5` Demand [TUNE] to process - that person's oldest raw event into durable - `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings +- `review_recordings()` opens a + `REVIEW_RECORDING_COST / WORK_TOKEN_COMPUTE = 0.5` Thought reservoir [TUNE] + on the host to process the pooled buffer's oldest available raw event into + durable `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings (`SIGHTINGS_FOR_SCHEDULE = 2` [TUNE]) stage Schedule knowledge; an authored leverage recording (Marcus's 03:00 debt call) stages Leverage. The old instant `observe` verb is gone from `Sim` and both frontends. -- Standing watches are per-person toggles. Enabled watches author the same - typed review Demand when a matching raw event arrives; the watch remains on - while its docket waits or stalls. There is no parallel bandwidth upkeep or - starvation shutdown. +- One host-level automatic-review toggle opens one persistent Thought tap for + the pooled inbox. When fed or holding fill it processes every arriving event; + when starved, arrivals wait. Its total drain is 15% [TUNE] of the host's + medium Thought baseline, displayed in human action rows as ops/sec at the + live command clock. There are no person-filtered B1 review policies. ## Day job (wiki/mechanics/day-job.md) @@ -379,7 +380,8 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim round-trip — map, RNG, compute, core, detection (Office as aggregate observer), day job, people, the reach device graph (topology, ownership, subscriptions, bridged segments), captured heard events, raw intel buffer, - processed intel with provenance, standing watches, the account graph + processed intel with provenance, the global automatic-review policy, the + account graph (balances, flows, positions, ledger, banked external trails), cursor.md remembered tile snapshots, income state, objectives, build intents, badge access, WorkGrid machine-token state, and game-over state. Cursor position diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 3e72b2dc..d329e02a 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -240,15 +240,17 @@ is retired — flat materials, Pixel Lab scrubbed.) - **Why:** replaces the instant `observe` (two clicks to leverage) with the recording buffer + processing pipeline the design corpus now requires: feeds record everything in coverage, processing recordings - into intel costs thought, standing watches automate it. Makes + into intel costs thought, and one pooled auto-review tap automates it. Makes schedules, mics, and the 3 a.m. call actual gameplay. - **Size:** M. **Depends on:** #14 cursor & senses and #15 reach (events come from subscribed feeds). - **Result:** Buffer + provenance pipeline (2026-07-07); Operations Demand migration base (2026-07-09); thought-sink migration (2026-07-11): sweeps - open host `ProcessRecording` reservoirs, watches open `WatchPerson` taps - with standing Thought drain, host `pending_intel` is the chassis marker, - both frontends show `W`/`W{n}`, act_one and unit tests green. + open host `ProcessRecording` reservoirs; pooled-inbox correction + (2026-07-11): one global `AutoReviewRecordings` tap replaces per-person + `WatchPerson` taps and the host exposes one action/control pair at a visible + ops/sec drain. Host `pending_intel` is the chassis marker, both frontends + show `W`/`W{n}`, act_one and unit tests green. ### 17. Messages: the social graph as a flow system 🟥 sim+save — DONE 2026-07-08 - **Spec:** [messages.md](../mechanics/messages.md) (IMPLEMENTED) @@ -658,7 +660,7 @@ is retired — flat materials, Pixel Lab scrubbed.) amended). **All design [OPEN]s resolved 2026-07-10 — sim work may firm.** Cost/drain anchors (exact numbers [TUNE] against them): Ears fills in ~one held breath (~20-40s of the host at medium); - Eyes is guilty-burst scale; a standing watch drains ~10-20% of one + Eyes is guilty-burst scale; pooled automatic review drains ~10-20% of one rack; the core conversion is uncapped (routing is the problem); partial fills persist wherever thought is stranded. (Resolved 2026-07-10: arbitration