GENERATED VIEW · PRIVATE ARCHIVE · DO NOT SERVE
RobCo Industries · Archive Museum

WASTELAND_UPLINK_SPEC.md



RELEASE

planning/2.8.0/plans/WASTELAND_UPLINK_SPEC.md

sha256 6b61bb0a910f8bcc · 33760 bytes · original held in the private archive

# WASTELAND UPLINK — Unified Ambient Living-World Engine (SPEC) > **ANALYSIS / PLAN-ONLY.** Untracked (`planning/` is gitignored). Nothing here is committed or pushed. > Grounded in files/functions actually read on `dev` @ `027be1b` (v2.7.0, cache `robco-terminal-v2.7.0-r5`). > This spec expands RECREATION_IDEAS.md **Idea #4** into a full design AND folds in four already-parked > ideas so they become **ONE engine**, not parallel systems. > > **Prime directive (load-bearing):** the engine **READS** state via getters and **CALLS** existing > functions/effects, but **WRITES NOTHING DURABLE** — no `saveState`, no `pushToCloud`, no `robco_v8`, > no `chatHistory`, no cloud, no mutation of `state.rads`/`state.ticks`. It cannot cause an outage and > it persists nothing. A permanent gate guard enforces this (§8). --- ## 0. The combine map — five ideas → one engine | Parked idea | Source | Role in the unified engine | Shared with core | Distinct part | |---|---|---|---|---| | **WASTELAND UPLINK** (ambient event engine) | `RECREATION_IDEAS.md` #4 | **THE SUBSTRATE.** World-clock + scheduler + push channel + body-class arbiter | — | is the core | | **INTERCEPT** (AI distress/numbers signals) | `AI_SLATE.md` AI-H | **Optional online AI-augment layer** on top of the static bank | The event/broadcast **bank** & the push channel | The AI call + `aiIntercept` flag + fallback seam | | **Pip-Boy Radio tuner** (multi-station DJ/news) | `CAPABILITY_SLATE.md` B1-05 (+round-3 expansion) | **PULL surface** — user tunes in to the same bank UPLINK pushes | The **bulletin/broadcast data bank** + day/night substrate | Tuning-dial UI + synth music beds (already partly shipped as WU-F5) | | **World-Map Exploration overhaul** (travel-time + encounter rolls) | round-3 #5 | **Consumer** — encounter rolls driven by the engine's seeded-roll + event tables | The **seeded-roll infra** + **event tables** | Travel-time model + map UI + on-arrival trigger | | **Day/Night cycle** | UPLINK #4 + existing `time-night` (ui-core.js:2418) | **SHARED INFRA** owned by the world-clock; every consumer reads it | The **world-clock** (`ticks → hour → phase`) | none — pure substrate | | **Survival / Hardcore** (rad-poisoning stages) | round-3 #7 (deferred) | **Boundary only** — layers ON the temporal substrate but is the mutating sibling UPLINK is NOT | The world-clock substrate (read-only) | It **mutates** `state` → a separate future build, never inside `ambient.js` | **One-sentence synthesis:** UPLINK is a deterministic, offline **world-clock + weighted event scheduler** that owns three shared primitives — a **world-clock** (day/night), a **seeded-roll engine**, and a **local event/bulletin bank** — and expresses them three ways: it **PUSHES** ambient broadcasts/weather/nudges into the terminal, the **RADIO PULLS** the same bank on demand, and **MAP ENCOUNTERS** consume the same seeded rolls + tables. INTERCEPT is the optional AI enrichment of the bank; Hardcore is the mutating sibling that may *read* the clock but lives outside the "writes nothing" core. --- ## 1. Grounding — the real hooks the engine rides (all verified on `dev`) | Hook | Location | Engine use | |---|---|---| | `appendToChat(text, 'sys', true)` | `ui-core.js:2617` | **Push channel.** 3rd arg `isHistoryLoad=true` → appends visually but **does NOT push `chatHistory` nor write `localStorage`** (`ui-core.js:2657`). This IS the no-persist mechanism. | | `setGeigerRate(rate)` | `ui-audio.js:150` | Transient rad-storm geiger tick-up; engine restores the real rate after. | | Body classes `rad-warning/critical/fatal`, `time-night` | applied in `updateMath()` `ui-core.js:2418-2446` | **⚠ RE-DERIVED every `updateMath()` from `state.rads`/`state.ticks`.** Engine must NOT reuse these names — `updateMath` would strip them mid-storm. Engine owns **its own** `ambient-*` classes (§4). | | In-game hour = `Math.floor((state.ticks % 240) / 10)` | `ui-core.js:2418` | Day/night substrate: **240 ticks/day, 10 ticks/hour**. The world-clock reads this — never writes `ticks`. | | `getGameContext()`, `_activeDef()`, `GAME_DEFS[ctx]` | `state.js:498/472/289` | Read-only game context + the `theme`/`calendar`/`collectibleLabel` data seam for the template bank (game-agnostic, Protocol 38). | | `getFactionRegistry()`, `getSkillKeys()` | `state.js:484/488` | Read-only registries for state-templated broadcast copy. | | `state.loc`, `state.quests`, `state.factions`, `state.rads` | `state.js:533+` (read-only) | Broadcast templating inputs (e.g. `_pendingDirectivesCount()` logic, `ui-core.js:1100`). | | `enterStandby()`/`exitStandby()` | `ui-core.js:483/504` — **closures, NOT global** | Engine can't read `_standbyActive`. It listens to the **same signals** (`blur`/`focus`/`visibilitychange` + `document.hidden`) and maintains its own `focused` flag → pause on blur. | | `window.isFeatureEnabled(key)`, `loadRemoteConfig()`, `_recordFeatureFailure()` | `cloud.js:118/86/77` | Fail-open kill-switch. The **AI-augment** (INTERCEPT) gates on a new `aiIntercept` flag; the **deterministic core needs no flag** (offline, can't fail outward). | | Tri-Node contract + `NATIVE_COMMAND_ROUTER` + `getSystemDirective()` | `api.js:954/23`, `responseMimeType:'application/json'` (`api.js:1446`) | INTERCEPT's AI call obeys the locked JSON contract + injection rules; radio `[TUNE]` etc. route through the native router. | | `startRadio()`/`toggleRadio()`/`isRadioOn()`, `AudioSettings.radio` (ON-semantics) | `ui-audio.js:611/710/722` | Radio **already exists** as a synth bed (WU-F5). The tuner extends it with the shared bulletin bank + day/night greetings — Protocol 22, no parallel radio. | | `sw.js` `ASSETS` + `CACHE_NAME` | `sw.js:7-30` | One new precached **JS** file (`js/ambient.js`) → add to `ASSETS`, one `-r6` bump (Protocol 1). No media. | | Boot script order | `index.html:2283-2289`: ui-audio → ui-render → ui-saves → ui-account → ui-core → api → cloud(module) | `js/ambient.js` loads **after `api.js`, before the `cloud.js` module** (§3). | --- ## 2. Architecture of the unified engine ### 2.1 Files & load order (Protocol 23) ``` index.html boot chain (global scope, not modules): js/database (db_nv / db_fo3) → databaseCSVs, lookupItemInDb() js/state.js → state, GAME_DEFS, getGameContext(), getSkillKeys() [read seam] js/registry (reg_* + core) → FALLOUT_REGISTRY, registrySearch() [read-only] js/ui-audio.js → setGeigerRate(), startRadio(), ambient synth [call seam] js/ui-render.js → render*(), FALLOUT_REGISTRY.zones [call seam] js/ui-saves.js js/ui-account.js js/ui-core.js → appendToChat(), updateMath(), standby signals [call seam] js/api.js → NATIVE_COMMAND_ROUTER, transmit path [AI-augment seam] → js/ambient.js ★ NEW ★ → AmbientEngine (reads getters, calls existing fns) js/cloud.js (module) → isFeatureEnabled(), loadRemoteConfig() [kill-switch seam] ``` `ambient.js` sits **after `api.js`** (so it can optionally reach the AI-augment path and the native router) and **before the `cloud.js` ES-module** (which is deferred by nature). It attaches `window.AmbientEngine` and self-boots on `DOMContentLoaded` after `loadUI()` has run once. **Boundary compliance (Protocol 23):** `ambient.js` is a *consumer/conductor* — it renders nothing of its own into durable stores, owns no state, and touches other layers ONLY through their published functions/getters. It never writes a save, never mutates `state`, never touches the registry. ### 2.2 The read-only / getter boundary (exhaustive allow-list) The engine's ONLY permitted interactions: **READS (getters / DOM-safe reads only):** `getGameContext()`, `_activeDef()`, `GAME_DEFS[ctx]`, `getFactionRegistry()`, `getSkillKeys()`, `state.loc`, `state.ticks`, `state.rads`, `state.quests`, `state.factions` (read), `FALLOUT_REGISTRY.zones/collectibles` (read), `document.hidden`, `matchMedia('(prefers-reduced-motion: reduce)')`, `localStorage.getItem` **only** for the engine's own device prefs (`robco_ambient_*` — a device toggle/intensity, NOT campaign state). **CALLS (existing side-effecting functions, all transient):** `appendToChat(txt, 'sys', true)`, `setGeigerRate(n)`, `document.body.classList.add/remove('ambient-…')`, existing ambient-audio functions (hum shift), `startRadio()` family (radio pull), `window.isFeatureEnabled('aiIntercept')`. **FORBIDDEN (guarded — §8):** `saveState`, `pushToCloud`/`pullFromCloud`, any `localStorage.setItem('robco_v8'|'robco_chat')`, any write to `state.*` fields, `appendToChat(…, …, false)` (would persist), reuse of the real `rad-warning`/`time-night`/`standby` class names, `setDoc`/`addDoc`/`updateDoc`, `signInAnonymously`. ### 2.3 The tick / cooldown / weighting model ``` World tick (setInterval), FOCUSED-ONLY: BASE_TICK_MS = 45_000 // long, battery-friendly; paused on blur/hidden intensity ∈ {off, low(default), medium, high} // device pref, scales effective cadence + weights On each tick (only if: engine on ∧ tab focused ∧ !reduced-motion-hard-block ∧ intensity≠off): 1. advance world-clock read (hour/phase from state.ticks) → update day/night substrate (§ shared) 2. roll the weighted event table with a seeded RNG (§2.4) 3. respect per-event cooldowns + a global min-gap so lines never cluster 4. dispatch the chosen event through its existing hook, transient-only ``` **Weighted event table (deterministic, seeded — not `Math.random()` in the hot path so it stays testable):** | Event | Weight (low intensity) | Cooldown | Hook | Transient effect | |---|---|---|---|---| | `broadcast` (UPLINK sys-line) | high | 4 ticks | `appendToChat(…, 'sys', true)` | one templated `> [WASTELAND UPLINK] …` line | | `radStorm` | low | 12 ticks | `ambient-radstorm` class + `setGeigerRate` blip | ~8-12s interference, then auto-clear + restore real rate | | `dayNightShift` | (event-free) | every tick | `ambient-dusk`/`ambient-dawn` transition | tint + optional hum nudge on phase crossing | | `incomingTx` | rare | 30 ticks | pre-fill `#chatInput` (no send) | `> UNKNOWN FREQUENCY DETECTED — REPLY?` nudge | **Cooldown bookkeeping** lives in engine-local memory only (`_lastFiredTick[event]`), never persisted — a reload resets cooldowns, which is fine (the engine is stateless by design). ### 2.4 The shared seeded-roll engine (the piece three features reuse) A tiny deterministic PRNG (e.g. a mulberry32/xorshift over an integer seed) plus a `rollTable(seed, table)` helper. `Date.now()`/`Math.random()` are used ONLY to derive a fresh seed at *dispatch* time (never inside a testable table roll), so: - **UPLINK** seeds per world-tick → picks the ambient event. - **RADIO** seeds per tune/refresh → picks the next bulletin from the same bank. - **MAP ENCOUNTERS** seed per `(location, travelLeg)` → roll the encounter table. One roll engine, one event-table format → no duplicated RNG, and the whole thing is unit-testable by feeding a fixed seed and asserting the pick. ### 2.5 The shared data banks (one bank, three readers) ``` GAME_DEFS[ctx].ambient = { // ★ new read-only data seam, per game (Protocol 38) broadcasts: [ { id, tod?: 'day'|'night'|'any', tpl } … ], // state-templated UPLINK/RADIO copy bulletins: [ { id, station, tpl } … ], // radio DJ/news lines (pull) encounters: [ { id, zoneType?, weight, tpl } … ], // map encounter table (roll) djGreetings:{ day: […], night: […] }, // day/night radio greetings } ``` - **UPLINK push** reads `broadcasts` (+ templates them with `loc`/faction/quest-count/time-of-day). - **RADIO pull** reads `bulletins` + `djGreetings` (day/night aware). - **MAP encounters** read `encounters` (zone-type weighted). All copy is **templated from live state** at emit time (never stored), and every entry is game-scoped via `GAME_DEFS[ctx]` so a third game (FO4) needs only a new `ambient` data block — zero engine-code change. --- ## 3. Offline-first core + AI-augment seam (INTERCEPT fold-in) **The seam is a clean two-layer split:** ``` ┌── DETERMINISTIC CORE (js/ambient.js) — offline, no flag, no network ─────────────┐ │ world-clock · seeded rolls · static bank (GAME_DEFS[ctx].ambient) · push/pull │ │ Ships fully functional with ZERO signal. This is what actually runs 99% of time.│ └──────────────────────────────────────────────────────────────────────────────────┘ ▲ enrich(entry) — optional, best-effort ┌── AI-AUGMENT LAYER (INTERCEPT) — online, flagged, fail-safe ──────────────────────┐ │ IF window.isFeatureEnabled('aiIntercept') ∧ key present ∧ !offline: │ │ request ONE short in-voice signal via the locked Tri-Node JSON contract, │ │ injection-hardened (player data delimited), cost-capped, on-demand only. │ │ ON any failure/timeout/flag-off/no-key → fall straight back to the static bank. │ │ Never blocks a tick, never blocks boot, never persists the AI output. │ └──────────────────────────────────────────────────────────────────────────────────┘ ``` **Rules the AI layer inherits (Protocols 24/30/32/33/14):** - New kill-switch flag `aiIntercept` registered in `_featureFlags` (cloud.js) — remotely disableable. - **Fail-safe fallback is the static bank**, not a dead feature (Protocol 33 — the read is fail-safe). - Uses the existing `responseMimeType:'application/json'` contract + `_validateTriNode`; a malformed response is discarded → static bank (Protocol 14/24: AI never the sole source, always validated). - Injection-safe: player/state text is passed as delimited *data*, never instruction (mirrors `transmitMessage`). - Cost-capped + on-demand (INTERCEPT is a *pull* — user taps `[INTERCEPT]`; ambient UPLINK stays deterministic so the passive engine never spends tokens on a timer). - **AI output is displayed via `appendToChat(…, 'sys', true)` too** → still not persisted. The invariant holds even online. **Clean seam statement:** the deterministic engine calls a single optional `enrichSignal(staticEntry) → Promise<string>`; if the AI layer isn't present/enabled/online it's simply never called and the static entry renders as-is. The engine has **no hard dependency** on `api.js`/AI — remove the AI layer entirely and the core is unchanged. --- ## 4. Body-class arbitration (the trickiest correctness surface) **Problem:** `updateMath()` unconditionally does `classList.remove('rad-warning','rad-critical','rad-fatal')` then re-adds based on `state.rads` (ui-core.js:2443), and toggles `time-night` from `state.ticks` (2420). If ambient added those same classes, the next `updateMath()` (fired on any stat change) would strip them mid-effect, and worse, ambient could visually imply a rad level the user doesn't have. **Solution — the engine owns a disjoint class namespace:** | Real (owned by `updateMath`, driven by `state`) | Ambient (owned by `ambient.js`, transient) | CSS relationship | |---|---|---| | `rad-warning` / `rad-critical` / `rad-fatal` | `ambient-radstorm` | `ambient-radstorm` maps to a **milder** flicker than `rad-warning`; **real rad classes always win** via a higher-specificity/`!important` rule so a real `rad-critical` visually dominates a cosmetic storm. | | `time-night` (driven by `state.ticks`) | `ambient-dusk` / `ambient-dawn` (transition tints) | Ambient tints are transient overlays layered *under* the authoritative `time-night`; never conflict. | | `standby` (closure-owned) | engine **pauses** entirely on blur/hidden | No overlap — engine is inert while `standby` is active. | **Arbitration rules (enumerated for the plan-audit, Protocol 8/26):** 1. `ambient-radstorm` may be present **only when no real `rad-*` class is present** — before adding, the engine reads `state.rads`; if `rads ≥ 200` (a real warning tier) the storm is **suppressed** (a real hazard outranks a cosmetic one). 2. On storm end, the engine **restores the real geiger rate** by recomputing from `state.rads` (same formula as updateMath:2452), never leaving a stale rate. 3. The engine adds/removes only `ambient-*` classes, so any concurrent `updateMath()` is orthogonal — no interference. 4. While `standby` is active (tab blurred/hidden) the engine's tick is paused → no ambient class can coexist with standby. 5. `reduced-motion` hard-block: storm/tint animations degrade to a **static** state or are skipped (§7). **CSS lives in the already-precached `css/terminal.css`** (new `ambient-*` rules) → no new precached asset from the CSS side; only `js/ambient.js` is the new asset. --- ## 5. Per-combined-feature: shared vs distinct ### 5.1 INTERCEPT (AI-H) - **Overlap:** identical to UPLINK's broadcast channel — both surface short in-voice wasteland signals as sys-lines. - **Shared:** the static bank (`GAME_DEFS[ctx].ambient.broadcasts`/`bulletins`) + the `appendToChat(…,'sys',true)` push + the no-persist invariant. - **Distinct:** the AI network call, the `aiIntercept` flag, the `enrichSignal()` seam, and that INTERCEPT is a *pull* (`[INTERCEPT]` command) whereas UPLINK broadcasts are *ambient push* (deterministic timer, never AI). - **Unified design:** the deterministic static bank **is** the offline core; INTERCEPT is the optional online garnish on the same bank (§3). ### 5.2 Pip-Boy Radio tuner (B1-05 / round-3 #2) - **Overlap:** the radio is another expression of the same bulletin/broadcast bank. - **Shared:** `GAME_DEFS[ctx].ambient.bulletins` + `djGreetings` + the day/night substrate ("good evening, wastelanders" is the world-clock phase read); the seeded-roll engine picks the next bulletin. - **Distinct:** tuning-dial UI (multi-station select), the synth music beds (already shipped as WU-F5 `startRadio()` — Protocol 22 *extends* it, no parallel radio), and that radio is a **PULL** surface (user tunes in) vs UPLINK's PUSH. - **Unified design:** **one bank, two directions** — UPLINK pushes a bulletin ambiently; the radio lets the user pull the same bank on demand; the DJ greeting is the day/night clock rendered as speech. A bulletin that UPLINK would push and one the radio would read are literally the same data row. ### 5.3 World-Map Exploration overhaul (round-3 #5) - **Overlap:** an ambient event ≈ an ambient encounter; both are "the wasteland does something to you." - **Shared:** the **seeded-roll engine** + the **event-table format** — `encounters` is just another table the same `rollTable(seed, table)` consumes; the day/night phase can weight encounters (more hostile at night). - **Distinct:** the travel-time model, the map UI, and the *trigger* (an encounter fires on **arrival/travel**, a discrete user action — not the passive world-tick). Encounters may present a *choice* (map UI), where UPLINK broadcasts are ambient flavor. - **Unified design:** the map's "roll an encounter on this leg" calls the engine's `rollTable` against `GAME_DEFS[ctx].ambient.encounters` with a `(location, leg)` seed — reusing the exact roll infra, zero duplication. Encounters that *mutate* state (loot, damage) would live in the map feature (which is allowed to write), **not** in `ambient.js` — the engine only *rolls*; the map *applies*. This keeps the "writes nothing" invariant intact: the roll is pure, the persistence is the map layer's responsibility. ### 5.4 Day/Night cycle (shared infra) - **Owner:** the UPLINK world-clock (`state.ticks → hour → phase`), read-only. - **Consumers:** (a) ambient tint (`ambient-dusk`/`ambient-dawn` + authoritative `time-night`), (b) radio DJ greeting (day/night copy), (c) encounter weighting (night = harsher table), (d) **any future survival/hardcore** (colder nights, etc. — reads the same clock). - **Distinct:** nothing — it's pure substrate. The existing `time-night` toggle (ui-core.js:2418) stays authoritative; the engine merely *adds transient transition tints* and *reads the phase* for its consumers. ### 5.5 Survival / Hardcore (round-3 #7 — boundary, NOT built here) - **The line:** Hardcore's rad-poisoning/dehydration/environmental stages **MUTATE `state`** (they tick damage, change `state.rads`, etc.). UPLINK **explicitly does not** — its rad-storm is `ambient-radstorm` + a transient geiger blip and **never touches `state.rads`**. - **Clean layering:** a future Hardcore build **reads** UPLINK's temporal substrate (the world-clock/day-night) to time its effects, but does its mutation in **its own** module (which is permitted to `saveState`). It layers *on top of* the substrate without ever putting a write inside `ambient.js`. The gate guard (§8) is exactly what prevents a future dev from "just adding one little `state.rads += …`" into the ambient engine — Hardcore must build its own writer. --- ## 6. Unified data flow (one picture) ``` state.ticks (read-only) │ ┌───────────▼───────────┐ │ WORLD-CLOCK (read) │ hour = floor((ticks%240)/10); phase = day|dusk|night|dawn └───────────┬───────────┘ │ phase ┌───────────────────────┼───────────────────────┐ │ │ │ ┌──────▼──────┐ ┌───────▼────────┐ ┌───────▼────────┐ │ SEEDED ROLL │◄───────┤ EVENT/BULLETIN │──────►│ DAY/NIGHT │ │ ENGINE │ reads │ BANK (per game)│ reads │ SUBSTRATE │ └──────┬──────┘ │ GAME_DEFS[ctx]. │ │ ambient-dusk/ │ │ │ .ambient │ │ dawn + tint │ │ picks └───────┬─────────┘ └────────────────┘ │ │ ┌──────▼───────┐ ┌───────────▼──────────┐ ┌─────────────────────┐ │ UPLINK PUSH │ │ RADIO PULL (tuner) │ │ MAP ENCOUNTER ROLL │ │ appendToChat │ │ startRadio + DJ │ │ rollTable(loc,leg) │ │ (sys,true) │ │ (day/night greeting)│ │ → map layer APPLIES│ │ radStorm │ └──────────────────────┘ │ (map writes, not │ │ incomingTx │ │ ambient.js) │ └──────┬───────┘ └─────────────────────┘ │ enrichSignal()? (optional) ┌──────▼──────────────────────┐ │ INTERCEPT AI-AUGMENT (flag) │ isFeatureEnabled('aiIntercept') → else static bank └─────────────────────────────┘ ``` Every arrow is a **read** or a **transient call**. The only writes in the whole picture are inside the **map layer** (its own module) and a future **Hardcore module** — never inside `ambient.js`. --- ## 7. Safety invariants + the permanent gate guard **Invariants (the engine's contract):** 1. **Writes nothing durable** — no `saveState`, `pushToCloud`, `robco_v8`/`robco_chat` write, no `state.*` field mutation. 2. **Persists no chat** — every emitted line uses `appendToChat(txt, 'sys', true)` (isHistoryLoad). 3. **Never mutates `state.rads`/`state.ticks`** — rad-storm is cosmetic (`ambient-radstorm` + transient geiger, restored). 4. **Disjoint class namespace** — only `ambient-*` classes; real `rad-*`/`time-night`/`standby` untouched. 5. **Real hazards outrank cosmetic** — storm suppressed when `state.rads ≥ 200`. 6. **Fail-safe & offline** — deterministic core needs no network; AI layer degrades to the static bank; nothing blocks boot. 7. **Focused-only + paused on blur/hidden** — no background battery drain, no coexistence with standby. 8. **Fully gated** — master toggle + intensity + `prefers-reduced-motion`; off = total silence. **Permanent gate guard — new dedicated Suite (both runners at parity + `test.html`):** - **Static (source-scan) guards:** assert `js/ambient.js` contains **zero** occurrences of `saveState`, `pushToCloud`, `pullFromCloud`, `localStorage.setItem`, `setDoc`/`addDoc`/`updateDoc`, `signInAnonymously`, direct `state.rads =` / `state.ticks =` assignments, and the real class literals `'rad-warning'`/`'time-night'`/`'standby'` in `classList.add`. Assert every `appendToChat(` call in the file passes a truthy 3rd arg. (This is the load-bearing "writes nothing" guard — the class this whole design exists to prevent regressing, per Protocol 20/36b.) - **Behavioral guards (`test.html`, real browser):** with the engine forced to dispatch each event via a seeded override, assert (a) a broadcast appends a `.msg-sys` node but `chatHistory.length` and `localStorage.robco_chat` are unchanged; (b) a rad-storm adds `ambient-radstorm`, does **not** change `state.rads`, and clears + restores the real geiger rate; (c) storm is suppressed when `state.rads ≥ 200`; (d) `reduced-motion` + `intensity:off` = no class, no line; (e) blur pauses the tick. - **Escape-ratchet (Protocol 36b):** any future "ambient wrote something" bug adds a check at this layer. --- ## 8. a11y / reduced-motion / toggle design - **Master toggle** (device pref `robco_ambient_on`, default **ON at low intensity** — owner can default OFF if preferred), **intensity** select `{off, low, medium, high}` (`robco_ambient_intensity`), both in a DISPLAY/AUDIO sub-panel with an accessible `<label for>` (Protocol UI-1/UI-5). These are **device prefs**, not campaign state (localStorage only). - **`prefers-reduced-motion: reduce`** → hard-block on animated storms/tints: rad-storm becomes a brief static dim (no flicker), dusk/dawn tints are instant (no transition), and the engine still emits text lines (motion-free) if intensity permits. Mirrors the existing global reduced-motion block (`terminal.css:2620`, Suite 94). - **No color-only meaning** — every ambient signal has text (the sys-line), not just a tint (Protocol 17). - **`aria-live`** — ambient sys-lines land in `#chatDisplay` which is already `aria-live="polite"` (Suite 94); to avoid over-announcing, ambient lines should be *low-frequency* (long cooldowns) so a screen reader isn't spammed. - **Mobile (Protocol 10/17):** no new layout; sys-lines and `ambient-*` overlays must not cause horizontal overflow at 360/412px — render-check required. No hover-only affordance. - **Toggle-off = total silence:** the engine's `setInterval` is not even armed when intensity is off. --- ## 9. Cache / [NET] / [DATA] posture - **Cache (Protocol 1):** one new served file `js/ambient.js` → add to `sw.js` `ASSETS` + **one `-r6`** revision bump (`robco-terminal-v2.7.0-r5` → `-r6`). CSS `ambient-*` rules live in the already-precached `terminal.css`. **No new precached media** (no audio/image — all text + synth reuse + CSS). - **[NET] posture:** the **deterministic core is `[NET]`-free** — no fetch, no Firestore, no Gemini; it can't cause an outage and needs no kill-switch (Protocol 32/33 satisfied by being offline). The **only** network is the optional INTERCEPT AI-augment, which is flagged (`aiIntercept`), fail-safe (Protocol 33), and pull-only. - **[DATA] posture:** reads campaign `state` via getters; **writes zero data** anywhere (localStorage/Firestore/state). Its own device prefs (`robco_ambient_*`) are the only writes and are non-campaign device settings (like the WU-F toggles). - **APP_VERSION:** **no bump** — additive-feel, PATCH-class, no rewrite/breaking change (consistent with RECREATION #4). - **Auth:** untouched — popup-only auth and `cloud.js` are not modified by the core (Protocol 30/31 intact). - **Game-agnostic (Protocol 38):** all copy/tables come from `GAME_DEFS[ctx].ambient`; no game literals in the engine; a third game = one new data block, zero engine change. --- ## 10. Effort / risk | Dimension | Assessment | |---|---| | **New files** | `js/ambient.js` (engine). Data added to `GAME_DEFS[ctx].ambient` in `state.js`. `ambient-*` CSS in `terminal.css`. Toggle/intensity markup in `index.html`. `sw.js` ASSETS + rev. Both runners + `test.html` + new Suite. | | **Test surface** | ~25–35 new tests (static no-write guards, behavioral no-persist, class arbitration, reduced-motion, seeded-roll determinism, per-game bank presence). Parity across both runners + `test.html` marker. | | **Risk: HIGH** | New subsystem touching the shared body-state layer; the "writes nothing durable" invariant is load-bearing and easy to violate in a later edit → the permanent guard (§7) is mandatory. Battery (mitigated: focused-only, 45s tick, paused on blur). Noise (mitigated: conservative cooldowns, low/off default). | | **Pipeline** | **Full Opus-plans → Sonnet-builds → Opus-audits, likely multiple loops** (per RECREATION #4 / Protocol 8). The plan-audit must enumerate every event × every concurrent body-state × reduced-motion × toggle-off × mobile × FNV/FO3 before a line is written. | --- ## 11. Phased build order (which piece ships first as the substrate) The unified engine is built **substrate-up** so each layer is independently shippable and testable: | Phase | Unit | Delivers | Depends on | Gate additions | |---|---|---|---|---| | **U0** | **World-clock + seeded-roll engine** (pure, read-only) | `js/ambient.js` skeleton: `getPhase()` from `state.ticks`, `rollTable(seed,table)`, no side-effects yet | — | seeded-roll determinism tests; the load-bearing static "no-write" guard (Suite) established NOW so every later phase inherits it | | **U1** | **UPLINK push channel + broadcast bank** | ambient `> [WASTELAND UPLINK]` sys-lines via `appendToChat(…,true)`; `GAME_DEFS[ctx].ambient.broadcasts`; focused-only tick; toggle+intensity | U0 | no-persist behavioral guard; class-namespace guard | | **U2** | **Day/night substrate + rad-storm weather** | `ambient-dusk/dawn` tints, `ambient-radstorm` + transient geiger with real-hazard suppression + rate restore | U1 | body-class arbitration guards; reduced-motion guard; storm-suppression test | | **U3** | **Radio tuner (PULL)** | extend `startRadio()` (Protocol 22) with the shared `bulletins`/`djGreetings` bank + day/night greeting + tuning UI | U1 bank, U2 clock | radio-reads-shared-bank test; day/night greeting test | | **U4** | **Map encounter rolls (CONSUMER)** | map "roll on arrival" calls `rollTable` against `ambient.encounters`; **map layer applies** any state change (not `ambient.js`) | U0 roll engine | encounter-table parity; assert `ambient.js` still writes nothing | | **U5** | **INTERCEPT AI-augment (optional)** | `enrichSignal()` + `aiIntercept` flag + fail-safe fallback to static bank | U1 bank, kill-switch | AI-contract test (Protocol 14); flag-off → static fallback; injection-safe | | **(future)** | **Hardcore/Survival** | separate mutating module that READS the U0/U2 clock; **never** inside `ambient.js` | U0/U2 substrate | its own write-path tests; the ambient no-write guard proves the boundary held | **Ship-first recommendation:** **U0 → U1** is the minimum viable "living terminal" (world-clock + ambient broadcasts) and establishes the permanent no-write guard that protects everything after it. U2 adds the atmosphere, U3/U4 reuse the banks/rolls, U5 is the online garnish, and Hardcore is explicitly out-of-engine. --- ## 12. Hard-constraint checklist (all honored) - [x] Vanilla, global-scope JS — no build/modules/framework (`js/ambient.js` is a plain global-scope file). - [x] **No `APP_VERSION` bump** — additive-feel, PATCH-class. - [x] Popup-only auth untouched — `cloud.js` auth not modified. - [x] Free / BYO-key — deterministic core is offline; only the optional INTERCEPT uses the user's own Gemini key. - [x] Game-agnostic (Protocol 38) — all copy/tables in `GAME_DEFS[ctx].ambient`; a third game = data only. - [x] Offline-safe — core has no network; AI layer fail-safe to static bank (Protocol 33). - [x] **Writes nothing durable** — enforced by a permanent gate guard (§7). - [x] One new precached **JS** file (code, tiny) → one `-r6` cache rev; **no new media**. - [x] Analysis-only — this document writes/pushes **no code**. --- *WASTELAND_UPLINK_SPEC.md — plan-only, untracked. Grounded on `dev` @ `027be1b`. No code written, nothing committed, nothing pushed.*
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).