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).