RELEASE
planning/2.8.0/plans/NATIVE_USE_AND_TERMINAL_STATS_PLAN.md
sha256 d64d4c30af12e548 · 34696 bytes ·
original held in the private archive
# NATIVE USE + TERMINAL STAT-EDITS + AI→NATIVE SURVEY — Implementation Plan
> **Protocol 8 stage: OPUS — Diagnose & Plan.** No edits made. A Sonnet session
> implements from this plan afterward, reviews it against the live files first
> (line numbers drift), then runs the full pre-commit gate + Protocol 2/2a docs.
>
> **Branch:** `dev` (Protocol 43 — same gate as `main`).
> **Baseline read at HEAD `2f4ba79`** (clean `origin/dev`). APP_VERSION `2.7.0`,
> CACHE_NAME `robco-terminal-v2.7.0-r116`, 2659 tests / 199 suites.
>
> Parts A + B are new **user-facing** features → **MINOR** `APP_VERSION` bump is
> permitted without asking (Protocol 2), but both land under the existing
> `[Unreleased]` block for the v2.8.0 cycle — **do not** open a dated version
> block on `dev` (Protocol 43). Served files (`js/`, `index.html`) change →
> **bump CACHE_NAME** to `-r117` (Protocol 1). Part C is **report-only**.
---
## PART A — NATIVE USE (deterministic item consumption, no AI)
### A.0 — Confirmed current behavior (Protocol 27)
Clicking **USE** on an inventory row sends the free-text string `> [USE] <name>`
to the Director (Gemini) and lets the AI narrate + apply the effect. Quoted from
[`js/ui-render.js:245`](js/ui-render.js) (`renderInventory()` click handler):
```js
const use = e.target.closest('[data-use]');
if (use) {
const item = state.inventory[+use.dataset.use];
if (!item) return;
document.getElementById('chatInput').value = `> [USE] ${item.name}`;
transmitMessage(); // ← round-trips to the AI
return;
}
```
The button is rendered on **every** row unconditionally
([`js/ui-render.js:234`](js/ui-render.js)). The AI side is governed only by the
freeform directive line *"Consumable Purge: Upon consumption of ANY item,
execute a -1 deduction"* ([`js/api.js:139`](js/api.js)) — there is **no**
structured `[USE]` contract. This is also the code path behind the critical
"USE dims the screen and locks out interaction" bug fixed in `ff00975` (the
pre-existing unguarded dim-in inside `transmitMessage()`). Making USE native
removes the AI round-trip for consumption entirely.
The data source is already parsed and game-agnostic:
`getChemsTable()` ([`js/db_nv.js:878`](js/db_nv.js),
[`js/db_fo3.js:532`](js/db_fo3.js)) returns
`{name, effect, addictionRisk, addictionDebuff, family}` for every CHEMS.CSV row.
**All aid/food/chem/med items are typed `aid`** (the CSV section maps to type
`aid`, e.g. [`js/db_nv.js:769`](js/db_nv.js)), so a single table + `it.type`
check covers the whole consumable set for both games.
### A.1 — Effect-string patterns observed across BOTH games
From the full CHEMS.CSV of `db_nv.js` and `db_fo3.js` (the `Effect` column, `/`-
delimited clauses). These are the ONLY shapes the parser must recognize:
| Clause shape | Examples | Deterministic action |
|---|---|---|
| `Restore <N> HP` | `Restore 100 HP`, `Restore 15 HP` | heal +N |
| `+<N> HP` (not "Max HP") | `+20 HP`, `+16 HP` (FO3 foods) | heal +N |
| `Restore HP` (no number) | FNV `Caravan Lunch`, `Pinyon Nuts` | heal +`_AID_DEFAULT_HEAL` |
| `+<N> HP/s for <S>s` | `+2 HP/s for 10s` | heal +(N×S) (instant total) |
| `Remove <N> Rads` | `Remove 150 Rads` | rads −N |
| `+<N> RAD` | `+5 RAD`, `+32 RAD` | rads +N |
| `Restore crippled limb` | Hydra | heal ONE crippled limb |
| `Restore all crippled limbs` / `Heal limbs` | Doctor's Bag (FNV/FO3) | heal ALL limbs |
| `Remove addiction` | Fixer | clear addiction status(es) |
| `Remove Poison` | Antivenom/Antidote | clear poison status |
| any timed buff clause (`+N STR`, `+25 DR`, `+25% DMG`, `+N AP`, `+N Max HP`, `Night Vision`, `Invisibility`, `Slow time …`) with **Duration > 0** | Buffout, Med-X, Jet, Psycho, Mentats, Rad-X, Steady… | add named **BUFF** status effect (duration→ticks); surfaces addiction risk in BIO-SCAN |
| `… crafting ingredient` | `Stimpak crafting ingredient`, `Xander Root` | **NO use effect** — no-op, no decrement |
| aid item absent from CHEMS.CSV | AI-added ad-hoc aid | **NO effect data** — no-op, no decrement |
**Addiction model (deterministic, no RNG).** The app has **no** dedicated
addiction state field. Addiction risk is already surfaced by
`_bioScanCompute()` ([`js/ui-render.js:3714`](js/ui-render.js)), which flags any
active **status effect** whose name/family matches an addictive chem
(`_bioChemHasRisk()`). Therefore adding the chem's **named BUFF status effect**
on use *is* the deterministic representation of "addiction risk" — BIO-SCAN will
report `ADDICTION RISK: <CHEM> (active)` for free. **No random addiction roll,
no persistent withdrawal-debuff application** (the `Addiction_Debuff` column
stays unconsumed — implementing a full withdrawal system is out of scope and
noted as future work). This keeps USE deterministic (Protocol 24) and reuses the
existing addiction surfacing (Protocol 22).
**Why a structured parser, not a lookup table:** the effect strings are
semi-structured and shared across two games' CSVs; a token parser over the
existing `getChemsTable().effect` column keeps the data as the single authority
(Protocol 3) and needs **zero** new per-item data. A hand-maintained item→effect
map would duplicate the CSVs and drift.
### A.2 — Shared native stat setters (used by BOTH Part A and Part B)
Create these in **`js/ui-core.js`** next to the existing setters
(`commitStat` @5450, `_skillVuSet` @5661, `nativeLevelUp` @3265, the bar-drag
handlers @3023–3201). Each is the ONE clamp/mirror/emit/save choke point for its
stat (Protocol 22), mirroring the exact idiom the drag handlers already use
(set `state.X` **and** the DOM `#input.value`, then `updateMath()` +
`saveState()` — the WU-N2 caps lesson: without the DOM mirror the next
`syncStateFromDom()` reverts the write):
- `_nativeSetHp(v)` → clamp `[0, hpMax]` (read `#stat_hp_max`/`state.hpMax`),
write `#stat_hp_cur` + `state.hpCur`, `_emitStatChangeIfDiffers('hp', v)`,
`updateMath()`, `saveState()`; returns clamped value.
- `_nativeSetRads(v)` → clamp `[0, _resolveMaxRads()]` (the existing per-game
ceiling helper @5433), write `#stat_rads` + `state.rads`, emit `'rads'`, …
- `_nativeSetXp(v)` → clamp `[0, xpNext(lvl)-1]` using the SAME curve as
`onXpInputChanged()` (@3229 — `75*(lvl+1)² − 25*(lvl+1) − 50`, Protocol 22, no
second formula), write `#stat_xp` + `state.xp`, emit `'xp'`, …
- `_nativeSetLevel(v)` → clamp `[1, MAX_PLAYER_LEVEL]` (@3252); set
`#stat_lvl`+`state.lvl`; if `v > old` emit `'level.up'` `{oldLvl, newLvl}`
(same event `nativeLevelUp()` emits); `updateMath()`+`saveState()`.
- `_nativeSetSpecial(key, v)` → clamp `[1,10]`, write `#s_<key>` + `state[key]`,
emit `'stat.change'` `{key, oldVal, newVal}` (guard `old!==v`), `updateMath()`,
`saveState()`. **Refactor `commitStat(el)` to delegate** to this (extract
`key=el.id.slice(2)`, `v=parseInt(el.value)` → `_nativeSetSpecial(key,v)`), so
the DOM `onchange` path and the terminal path share one clamp (Protocol 22).
- `_nativeSetSkill(key, v)` → thin wrapper over the existing
**`_skillVuSet(key, v)`** (@5661 — already clamps `[0,100]`, mirrors
`#sk_<key>`+VU fill/aria, emits `'stat.change'` `skill:<key>`, `saveState()`).
Reuse verbatim.
- `_nativeSetKarma(v)` → clamp `[-1000, 1000]` (matches the `_KARMA_TIERS`
range @5528), write `#stat_karma` + `state.karma`, `updateMath()` (which runs
`updateKarmaUI()`), `saveState()`.
- `_nativeSetCaps(v)` / caps deltas already covered by `_quickLogCaps(delta)`
([`js/api.js:1373`](js/api.js)); add a tiny `_nativeSetCaps(v)` mirroring it
(clamp `≥0`, mirror `#c_caps`, `_logEvent`, `updateMath`, `saveState`).
Delta forms compute `cur + delta` and call the same `_nativeSet*` (one truth for
both set and delta). These are `function` declarations (global scope, hoisted),
callable cross-file from `ui-render.js`/`api.js` at runtime (load order:
ui-render → ui-core → api; USE runs at click time, terminal handlers run after
full parse).
### A.3 — The USE handler (Part A core)
**In `js/ui-render.js`:**
1. **Gate the USE button to consumables.** Change the row template
([`js/ui-render.js:234`](js/ui-render.js)) to render `use-btn` **only when
`cat === 'aid'`** (weapons/armor already have EQUIP; ammo/mod/misc have no
consume semantics). Removes the confusing "USE a rifle → AI narration" path.
2. **Pure compute core `_computeAidUse(chemEntry)`** (side-effect-free, unit-
testable like `_bioScanCompute`). Splits `chemEntry.effect` on `/`, matches
each clause against the A.1 table, and returns:
```
{ heal, radDelta, healLimbs: 'all'|'one'|null,
clearAddiction, clearPoison,
buff: {name, ticks, type:'BUFF'} | null,
recognized: bool, summary: [strings] }
```
Helpers: `_durationToTicks(durStr)` — `Nh→N*10`, `Nm→max(1,round(N/6))`,
`Ns→1`, `0`/unparseable → `0` (the app's canonical 6-min/tick scale from
`_nativeSleep`/`_nativeWait`); a status effect is emitted iff `ticks > 0`.
`_AID_DEFAULT_HEAL` — a named constant (propose `20`) for bare `Restore HP`;
commented as a deterministic default (the CSV literally carries no number,
Protocol 3 — not an AI guess).
3. **Executor `nativeUseItem(idx)`** (replaces the inline click handler):
- Read `item = state.inventory[idx]`; if missing → return.
- If `item.type !== 'aid'` → `appendToChat('> [USE] <name> is not a
consumable.', 'sys')`, no decrement, return.
- `chemEntry = getChemsTable().find(c => c.name.toLowerCase() ===
item.name.toLowerCase())` (exact, case-insensitive). If none → `> [USE]
No effect data for <name>.`, **no decrement**, return.
- `r = _computeAidUse(chemEntry)`. If `!r.recognized` (e.g. crafting
ingredient) → `> [USE] <name> has no usable effect (<effect text>).`, **no
decrement**, return.
- Apply, routing through the A.2 shared setters:
- `r.heal` → `_nativeSetHp(state.hpCur + r.heal)`
- `r.radDelta` → `_nativeSetRads(state.rads + r.radDelta)`
- `r.healLimbs === 'all'` → set `hd/la/ra/ll/rl = 'OK'`;
`=== 'one'` → set the FIRST crippled limb (order hd,la,ra,ll,rl) to `OK`.
- `r.buff` → `_applyStatusEffect(buff.name, buff.ticks, 'BUFF')`
(see A.4).
- `r.clearAddiction` → drop `state.status` entries matching an addictive
chem (`_bioChemHasRisk`); `r.clearPoison` → drop `name`-contains-"poison".
- **Decrement qty by 1** (only reached when ≥1 effect applied), mirroring
`adjItemQty()`'s "0 → splice the row" semantics.
- Re-render: `updateMath()` (bars) is called by the setters;
additionally `renderStatus()` if a buff/removal changed `state.status`,
`renderInventory()` for the qty, and a limb re-render (`loadUI()` if limbs
changed — heaviest but correct; else the targeted renders). `saveState()`
is called by the setters; call once more after qty change.
- `appendToChat('> [USE] ' + r.summary.join(' · ') + ' — ' + qtyLeft + '
remaining.', 'sys')`.
- Update the click handler @245 to call `nativeUseItem(+use.dataset.use)`
instead of the `chatInput`+`transmitMessage()` pair.
### A.4 — Reuse `addStatusEffect()` (Protocol 22)
Extract the effect-push core from `addStatusEffect()`
([`js/ui-render.js:746`](js/ui-render.js)) into
`_applyStatusEffect(name, ticks, type)` that both the DOM add-form and
`nativeUseItem()` call — it keeps the existing dedup (update ticks/type if the
compound is already active), the `RobcoEvents.emit('effect.applied', …)` +
`_pendingEffectWarmup.push(name)` (Wave 3 #28) in **one** place. `addStatusEffect()`
becomes: read the 3 DOM inputs → `_applyStatusEffect(...)` → clear inputs →
`renderStatus()`+`updateMath()`.
### A.5 — Acceptance criteria (Protocol 26) — enumerated cases
Verified on the real UI at 360/412/desktop (Protocol 10) in **both** games:
1. **Stimpak** (FNV `Restore HP`… actually `Stimpak,Restore HP`? no — FNV has no
plain Stimpak number; FO3 `Restore 30 HP`) → HP rises by the parsed amount,
clamped at `hpMax`; qty −1; row vanishes at qty 0.
2. **Super Stimpak** FNV `Restore 100 HP` / FO3 `Restore 100 HP / -1 STR (1m)` →
+100 HP (the `-1 STR (1m)` clause is a timed debuff clause with duration →
status-effect representation via the duration column, **not** a permanent
SPECIAL change).
3. **RadAway** `Remove 150 Rads` → rads −150, clamped ≥0; qty −1.
4. **Rad-X** `+25 Rad Resistance` (Duration 4m) → BUFF status `Rad-X` added
(ticks from 4m), no HP/rad change.
5. **Buffout** `+2 STR / +2 END / +60 Max HP` (4m, 25% risk) → BUFF status
`Buffout` added; BIO-SCAN now flags `ADDICTION RISK: BUFFOUT (active)`; **no**
permanent SPECIAL/hpMax change; qty −1.
6. **Food w/ rads** FO3 `Cram` `+10 HP / +5 RAD` → +10 HP **and** +5 rads.
7. **Bare `Restore HP` food** (FNV `Caravan Lunch`) → +`_AID_DEFAULT_HEAL` HP.
8. **Regen food** `+2 HP/s for 10s` → +20 HP instant.
9. **Hydra** `Restore crippled limb` → one crippled limb → OK (no-op message if
none crippled — but still a valid use? propose: if nothing to heal, treat as
recognized but "no effect right now", **no decrement**).
10. **Doctor's Bag** `Restore all crippled limbs` / FO3 `Heal limbs` → all limbs
OK.
11. **Fixer** `Remove addiction` → clears addictive-chem status effects.
12. **Antivenom/Antidote** `Remove Poison` → clears poison-named status.
13. **Crafting ingredient** (`Broc Flower` `Stimpak crafting ingredient`) →
"no usable effect" message, **no decrement**, no state change.
14. **Aid item not in CHEMS.CSV** → "No effect data", **no decrement**.
15. **Non-aid item** (should not show USE) → if forced, "not a consumable",
no decrement.
16. **Zero AI**: no `fetch`/`transmitMessage` on any USE path (grep-guarded).
17. **Offline-safe** (Protocol 24): works with no key/no network.
18. **Both games** exercise the same code via `getChemsTable()` (Protocol 38 —
no hardcoded item names in the handler).
### A.6 — Files / functions / line ranges (Part A)
| File | Change |
|---|---|
| `js/ui-render.js` | Gate `use-btn` render to `cat==='aid'` (@234); rewrite click handler (@245–258) → `nativeUseItem(idx)`; add `_computeAidUse`, `_durationToTicks`, `_AID_DEFAULT_HEAL`, `nativeUseItem`; extract `_applyStatusEffect` from `addStatusEffect` (@746). |
| `js/ui-core.js` | Add the A.2 shared setters; refactor `commitStat` (@5450) → `_nativeSetSpecial`. |
| `js/db_nv.js` / `js/db_fo3.js` | **No change** — `getChemsTable()` already exposes everything. |
| `js/api.js` | **No change for Part A** (the "Consumable Purge" directive line @139 stays — free-text "I use a stimpak" during narrative is still valid AI behavior; only the button stops calling the AI). |
| `sw.js` | Bump CACHE_NAME → `-r117`. |
---
## PART B — TERMINAL comm-link edits EVERY stat (deterministic, no AI)
### B.0 — Confirmed current behavior (Protocol 27)
TERMINAL mode routes a submitted line through `transmitTerminal()`
([`js/api.js:1497`](js/api.js)) → `_routeNativeCommand()` (native `[TOKEN]`s)
→ `_routeQuickLogMulti()` (comma-split) → `_routeQuickLog()`
([`js/api.js:1459`](js/api.js)), which matches against `QUICK_LOG_PATTERNS`
([`js/api.js:1414`](js/api.js)). Today only **four** patterns exist: `kill`,
`caps`, `location`, `faction`. Anything else — `+50 rads`, `str 8`, `leveled up`
— falls through to the hint at [`js/api.js:1529`](js/api.js):
```js
appendToChat(
'> [TERM] UNRECOGNIZED — did you mean a native command (see [FEATURES]) or a
quick-log entry like "killed <target>", "+50 caps", "arrived <location>", or
"rep <faction> up/down"?', 'sys');
```
Autocomplete for `#chatInput` comes from `_commandSuggestions()`
([`js/api.js:1664`](js/api.js)) + `_quickLogContentSuggestions()` (@1606), both
already iterating `QUICK_LOG_PATTERNS`. The `[FEATURES]` help modal renders
`COMMAND_REGISTRY` ([`js/ui-core.js:5223`](js/ui-core.js), `showHelpModal`
@5329). Comma multi-action (`_routeQuickLogMulti`) already splits on commas and
routes each segment, so adding patterns yields multi-edit lines for free.
### B.1 — Grammar (owner-LOCKED: both set + delta)
All matched after `_stripPrompt()`, case-insensitive, per comma-segment. **New**
`QUICK_LOG_PATTERNS` entries (added to the existing array so `_routeQuickLog`,
`_routeQuickLogMulti`, and autocomplete pick them up with zero new plumbing):
1. **Level-up phrase** — `re: /^level(?:ed)?\s*up$/i` → `nativeLevelUp()`.
(Placed before the generic set pattern; has no numeric arg.)
2. **Generic stat SET** — `re: /^([a-z][a-z _]*?)\s+(\d+)$/i` → resolve group 1
via `_resolveStatToken()`; apply group 2 through the matching A.2 setter.
Covers `hp 80`, `rads 50`, `xp 200`, `level 5`, `karma 100`, `caps 30`,
`str 8`, `guns 45`, `energy weapons 60`, `small_guns 45`.
3. **Generic stat DELTA (leading sign)** — `re: /^([+-]\d+)\s+([a-z][a-z _]*?)$/i`
→ resolve group 2; apply `cur + (+group1)`. Covers `+2 str`, `-1 per`,
`+50 rads`, `-10 hp`, `+500 xp`.
4. **Generic stat DELTA (trailing sign)** — `re: /^([a-z][a-z _]*?)\s+([+-]\d+)$/i`
→ resolve group 1; apply delta group 2. Covers `str +2`, `melee weapons -5`.
Ordering: existing `kill`/`caps`/`location`/`faction` patterns stay **first**
(zero regression — `+50 caps` still hits the specific caps pattern; the resolver
also recognizes `caps` so `caps 30`/`caps +5` work via the generic set/delta).
The level-up phrase and the three generic stat patterns append after them. The
generic patterns' handlers **return `false`** when the token doesn't resolve, so
a non-stat line (`something 5`) correctly falls through to the UNRECOGNIZED hint
(the established "shape matched, content didn't" convention, e.g.
`_quickLogFaction`).
### B.2 — `_resolveStatToken(token)` (game-agnostic, Protocol 38)
Normalize `token` → `trim().toLowerCase().replace(/\s+/g,'_')`, then resolve:
- **Scalar stats** (universal Fallout mechanics — a static alias map is fine, not
a game literal): `hp` → `_nativeSetHp`; `rads`/`rad` → `_nativeSetRads`;
`xp` → `_nativeSetXp`; `level`/`lvl` → `_nativeSetLevel`; `karma` →
`_nativeSetKarma`; `caps` → `_nativeSetCaps`.
- **SPECIAL** (universal 7-attribute set — a static token→state-key map, NOT
per-game data): `str/strength→s`, `per/perception→p`, `end/endurance→e`,
`cha/chr/charisma→c`, `int/intelligence→i`, `agi/agl/agility→a`,
`lck/luck→l` → `_nativeSetSpecial(key, …)`.
- **Skills** (per-game — MUST be data-driven): match the normalized token against
`getSkillKeys()` exactly, against `getSkillKeys()` with `_`↔space, and against
`SKILL_LABELS[key].toLowerCase()`. This yields `guns` (FNV) vs
`small_guns`/`big_guns` (FO3) automatically — no hardcoded skill list. →
`_nativeSetSkill(key, …)`.
- Anything else → `null` (handler returns `false`).
`SKILL_LABELS` lives in `ui-core.js` (@5608) and `getSkillKeys()` in `state.js`
(@1432) — both global, callable from `api.js`. **Note:** the SPECIAL/scalar maps
are universal to every Fallout title (not game-specific data), so a static map
here does **not** violate Protocol 38 (which targets game-specific literals like
faction keys / file paths / two-game coercions). Only the **skill** resolution
must be registry-driven — and it is.
### B.3 — Hint expansion (owner spec #2)
Rewrite the UNRECOGNIZED hint (@1529) to list the new stat commands concisely,
e.g.: `… or a stat edit like "hp 80", "+2 str", "rads 50", "level up", "guns
45".` Keep it one line, scannable.
### B.4 — Autocomplete (owner spec #3)
- Each new pattern carries `stub` / `hint` / `tag` so `_commandSuggestions()`
(@1664) surfaces them automatically (it already maps `QUICK_LOG_PATTERNS` →
suggestions). Propose stubs: `'hp 80'`, `'+2 str'`, `'level up'`, `'rads 50'`.
- Add a `_statTokenSuggestions(text)` branch to `_quickLogContentSuggestions()`
(@1606) so a partial token completes: typing a fragment that prefix-matches a
SPECIAL alias, a scalar-stat name, or a `getSkillKeys()` key suggests
`"<token> "` (then the user types the value). Game-agnostic — skill names come
from `getSkillKeys()`/`SKILL_LABELS`, never a literal list.
### B.5 — Help modal / COMMAND_REGISTRY (owner spec #4)
Add a group (or entries) to `COMMAND_REGISTRY` ([`js/ui-core.js:5223`](js/ui-core.js))
under a new **`STAT EDITS — TERMINAL, OFFLINE`** heading, e.g.:
`{cmd:'<stat> <N>', desc:'Set any stat: hp, rads, xp, level, karma, caps, a
SPECIAL (str/per/…), or a skill. Offline.'}`,
`{cmd:'+N <stat> / <stat> +N', desc:'Nudge a stat up/down by N. Offline.'}`,
`{cmd:'level up / leveled up', desc:'Gain one level (deterministic). Offline.'}`.
`showHelpModal()` renders it unchanged. This keeps `[FEATURES]` in lock-step
(the Suite 113 registry↔router consistency guard covers this pattern).
### B.6 — Acceptance criteria (Protocol 26)
Both games, verified by driving `transmitTerminal(text)` behaviorally:
1. `hp 80` → HP set to 80 (clamped ≤ hpMax); `#stat_hp_cur` + `state.hpCur`
both updated; no revert after `saveState()`.
2. `+2 str` / `str +2` → STR = min(10, cur+2); `-1 per` → PER = max(1, cur−1).
3. `str 8` → STR set to 8; `str 99` → clamped to 10; `str 0` → clamped to 1.
4. `guns 45` (FNV) / `small_guns 45` (FO3) → skill set via `_skillVuSet` (VU fill
repaints); FO3 `guns 45` → UNRECOGNIZED (no such skill) — proves per-game.
5. `rads 50` / `+50 rads` / `-30 rads` → clamped `[0, maxRads]`.
6. `xp 500` → clamped to `xpNext(lvl)-1`; `+500 xp` → delta then clamp.
7. `level 5` → set; `level up` / `leveled up` → +1 (≤ MAX_PLAYER_LEVEL),
emits `level.up` (jingle/haptic/auto-log subscribers fire).
8. `karma 250` / `-100 karma` → clamped `[-1000,1000]`; karma tier UI updates.
9. `caps 30` / `+5 caps` → existing caps pattern still handles `+5 caps`; `caps
30` set works via resolver.
10. **Multi-action**: `hp 100, +2 str, rads 0, killed 2 raiders` applies ALL four
(comma routing via `_routeQuickLogMulti`), one collated hint if any segment
fails.
11. Unknown token `foo 5` → falls through to UNRECOGNIZED hint (not a silent
no-op).
12. Autocomplete surfaces the new commands; `[FEATURES]` lists them.
13. Zero AI on every path (no `fetch`, no `transmitMessage`).
### B.7 — Files / functions / line ranges (Part B)
| File | Change |
|---|---|
| `js/api.js` | Add 4 `QUICK_LOG_PATTERNS` entries (@1414); add `_resolveStatToken()`; expand UNRECOGNIZED hint (@1529); add `_statTokenSuggestions` branch to `_quickLogContentSuggestions` (@1606). |
| `js/ui-core.js` | A.2 shared setters (shared with Part A); add STAT-EDITS group to `COMMAND_REGISTRY` (@5223). |
| `sw.js` | CACHE_NAME `-r117` (shared with Part A). |
---
## Cross-cutting: Protocol 8 plan-audit (entry paths / states / edge cases)
- **Entry paths to consumption:** the USE button (native now) + free-text "I use
X" typed to OVERSEER (still AI narrative — unchanged, not our concern) + the
AI's own `autoImportState()` state writes (unchanged). Only the **button**
changes. ✔
- **Entry paths to stat edits:** DOM inputs (`commitStat`/bar drags/VU
drag/`onXpInputChanged`/`nativeLevelUp` — unchanged, now some route through the
shared setters) + TERMINAL grammar (new) + AI `autoImportState()` (unchanged).
All clamp identically because the shared setters own the clamp. ✔
- **Mode resolution:** `/msg` forces TERMINAL, `@msg` forces OVERSEER
(`_resolveCommandInput`) — stat grammar only runs in the TERMINAL branch, so
an `@`-pinged AI message is never intercepted. ✔
- **Comma multi-action:** each segment independently routed; a native `[TOKEN]`
runs on the whole unsplit line first, so a token's args aren't comma-split. New
stat patterns are pure per-segment. ✔
- **Empty / malformed:** parsers return `false`/`recognized:false` → hint or
no-op, never a throw; setters `parseInt`-guard and clamp (no `NaN` reaches
state — the Suite 133 `|| 0` lesson). ✔
- **Games:** FNV (`guns`) vs FO3 (`small_guns`/`big_guns`) via `getSkillKeys()`;
FO4 design-only entry (`skillKeys: []`) degrades to "no skills resolvable" with
no throw. ✔
- **Offline / no-key:** every path is pure-local (Protocol 24/32 — nothing new to
register with the kill-switch; no network). ✔
- **Persistence round-trip:** setters mirror to DOM so `saveState()` →
`syncStateFromDom()` reads the new values back (no revert); serialized-whole
into `robco_v8`, survives sanitize→migrate (Protocol 34 — no new state field,
so no Protocol 4 work). ✔
- **UTF-8 (Protocol 39):** all edits via the Edit tool / Node — never PowerShell
writes (the files contain box-drawing/emoji). ✔
**No new `state` field is introduced** (Part A reuses `status`/`hpCur`/`rads`/
limbs; Part B edits existing fields), so Protocol 4 (4-file state-field dance)
does **not** apply. `state.padBindings` remains the only recent additive field.
---
## Regression tests (Protocol 13/15/42 — both runners at parity, Protocol 2a)
Add **Suite 200** (Native USE) and **Suite 201** (Terminal stat edits) to BOTH
`tests/robco-diagnostics.js` and `tests/robco-diagnostics.ps1` at identical
coverage/counts, plus the runtime mirror `tests/test.html` where the import
contract is touched (it is not here — no new state field — so only the
`Suites: N` marker if a runtime suite were added; these are static/behavioral, so
no test.html change beyond confirming none is needed).
**Suite 200 — Native USE (mix of static + VM-behavioral):**
1. Static: `nativeUseItem` defined in `ui-render.js`; the row click handler calls
it and **not** `transmitMessage()` (no-AI guard — grep the handler body).
2. Static: `use-btn` rendered only for `cat==='aid'`.
3. Static: `_applyStatusEffect(name,ticks,type)` extracted; `addStatusEffect`
delegates to it (Protocol 22 — one emit site for `effect.applied`).
4. Behavioral (Node `vm`, load `state.js`+`reg_nv.js`+`db_nv.js` + the extracted
`_computeAidUse`/`_durationToTicks`, the Suite 133/151 sandbox idiom):
- Stimpak/Super-Stimpak → `heal` = parsed N.
- RadAway → `radDelta` = −150.
- Cram (FO3) → heal +10 **and** radDelta +5.
- Buffout → `buff.name==='Buffout'`, `buff.ticks>0`, no permanent SPECIAL.
- bare `Restore HP` → heal `_AID_DEFAULT_HEAL`.
- `+2 HP/s for 10s` → heal 20.
- Doctor's Bag → `healLimbs==='all'`; Hydra → `'one'`.
- crafting ingredient → `recognized:false`, no ops.
- `_durationToTicks`: `4m→1`, `1h→10`, `30s→1`, `0→0`.
5. Behavioral: running the FULL apply against a synthetic state heals HP clamped
at hpMax; qty decrements; qty-0 removes the row.
6. Behavioral: a crafting-ingredient / unknown-aid use leaves qty **unchanged**.
7. Static (Protocol 38): the USE handler contains no hardcoded game item name
(Stimpak/RadAway/etc.) — items resolved via `getChemsTable()`.
8. Static (Protocol 24): no `fetch`/`XMLHttpRequest`/`transmitMessage` anywhere
in `nativeUseItem`/`_computeAidUse`.
9. Behavioral: adding Buffout's status then running `_bioScanCompute` yields an
`ADDICTION RISK: BUFFOUT` advisory (proves the addiction-surfacing reuse).
**Suite 201 — Terminal stat edits (mostly VM-behavioral):**
1. Static: the 4 new `QUICK_LOG_PATTERNS` entries + `_resolveStatToken` exist.
2. Behavioral (`vm`, load `state.js`+`reg_nv.js` + the shared setters + the
terminal router bodies against a synthetic DOM, the Suite 151/152 idiom):
`hp 80`→state.hpCur 80 clamped; `str 8`/`str 99`(→10)/`str 0`(→1);
`+2 str`/`str +2`/`-1 per`; `rads 50`/`+50 rads`/`-30 rads` clamped;
`xp 500` clamped to band; `level 5`; `level up`/`leveled up` (+1, emits
`level.up`); `karma 250`/`-100 karma`; `caps 30`.
3. Behavioral (Protocol 38): FNV `guns 45` sets the skill; **FO3** `guns 45`
returns `false` (UNRECOGNIZED) while `small_guns 45` sets it — proves
registry-driven skill resolution, no literal list.
4. Behavioral: multi-action `hp 100, +2 str, rads 0` applies all three
(`_routeQuickLogMulti`).
5. Behavioral: `foo 5` → `false` → UNRECOGNIZED path (no silent no-op).
6. Behavioral: DOM mirror — after `hp 80`, `#stat_hp_cur.value==='80'` so a
following `syncStateFromDom()` does not revert it.
7. Static: `commitStat` delegates to `_nativeSetSpecial` (shared clamp, one
truth); the SPECIAL clamp stays `[1,10]`, skill `[0,100]`.
8. Static: UNRECOGNIZED hint text lists the new stat commands; `COMMAND_REGISTRY`
has the STAT-EDITS group; `_commandSuggestions` surfaces a new stub
(registry↔router↔help lock-step, the Suite 113 pattern).
**Protocol 2a count sync (same commit):** update the hardcoded `2659`→ new total
in `CLAUDE.md` (5 sites), `RULES.md`, `README.md` (4 sites), `ARCHITECTURE.md`,
`CHANGELOG.md` `[Unreleased]` header comment, both `robco-diagnostics.*` per-suite
markers, and `tests/test.html` `Suites:` marker if a runtime suite is added. Run
the `Select-String` drift scan from CLAUDE.md Protocol 2a before committing.
**Docs (Protocol 2):** `CHANGELOG.md` `[Unreleased]` (plain-English, Protocol 21
— "USE now applies effects instantly offline"; "type any stat change straight
into the TERMINAL command line"); `ARCHITECTURE.md` (version header + the
**Native-Input-Path Audit** table — USE + all stat edits now have native
setters; the U10 `state.equipped`/affinity notes get company); `README.md`
Current-State + feature table.
---
## PART C — AI → NATIVE SURVEY (report-only; owner decides)
Every remaining feature that still calls Gemini or depends on the network,
classified **convertible-to-native** / **must-stay-AI** / **hybrid**. Nothing
here is planned or built beyond USE + terminal stats.
### C.1 — Gemini (AI) surfaces
| Feature | Where | Class | Notes / native sketch |
|---|---|---|---|
| **Director narrative** (freeform story, combat outcomes, dialogue, world reactions) | `transmitMessage()` → Gemini, Tri-Node JSON | **MUST STAY AI** | The generative core. Inherently open-ended; no deterministic equivalent. Keep. |
| **`[USE]` consumption** | button → `transmitMessage` | **CONVERTIBLE** ← *this plan, Part A* | Fully deterministic from CHEMS.CSV. Done here. |
| **VISUAL UPLOAD** (screenshot → inventory parse) | `attachedImageData` → Gemini vision; directive @143 | **HYBRID / CONVERTIBLE (owner goal)** | Owner wants on-device OCR eventually. Sketch: bundle a WASM OCR (e.g. Tesseract) or use the browser's `Shape/TextDetector` where available → parse item lines → cross-ref `lookupItemInDb()` for wgt/val/type → additively merge (the `item.added` diff path already exists). Fully offline once OCR is local. Fallback to AI vision when OCR unavailable/low-confidence = the hybrid. **Largest of the remaining conversions.** |
| **`[GPS]` / `[MAP]` compass grid** | AI `modal.type:'GPS'` (directive @89–92) | **CONVERTIBLE** | The app already ships a native **CARTOGRAPHY TABLE** (`renderWorldMap`, SVG node map from `FALLOUT_REGISTRY.locations` + `state.locationHistory`). A native `[GPS]` could render a localized compass slice of that data offline — no AI. |
| **`[ROADMAP]` perk roadmap** | AI `modal` (COMMAND_REGISTRY @5297) | **HYBRID** | Perk data is in the registry (`level_taken`, ranks), so a native "perks you qualify for / upcoming by level" table is convertible; but a *build-goal-aware* roadmap ("toward a sniper build") needs generative reasoning → keep an AI mode, add a deterministic "eligible perks by level" native view. |
| **LEVEL-UP skill-point award** | AI modal on `[LEVEL UP]` (directive @162) | **HYBRID** | The **native LEVEL UP button** already exists (`nativeLevelUp`, deterministic +1). The AI's *skill-point distribution* modal (`10 + INT/2` points) is a generative allocation; a native "here are N points, allocate via the skill VU meters" flow is convertible (the VU setters exist) — worth considering. |
| **Gemini key validation** | `fetchAuthorizedModels()` (@272) | **MUST STAY NETWORK** | Support infra for the AI, not generative. Only runs when a key is entered. |
### C.2 — Network (non-AI) surfaces — not "AI", included for completeness
| Feature | Where | Class | Notes |
|---|---|---|---|
| **Cloud save sync** (push/pull, version history) | `cloud.js` (Firestore) | **MUST STAY NETWORK** | It *is* remote storage; "native" = the already-shipped local slots + rolling backups + IDB, which are the offline fallback. Not an AI conversion. |
| **Auth** (Google sign-in, anon) | `cloud.js` / `ui-account.js` | **MUST STAY NETWORK** | Popup-only (Protocol 30). Gated + offline-degrading already. |
| **Remote kill-switch config** | `loadRemoteConfig()` (Firestore `/config/flags`) | **MUST STAY NETWORK** | Fail-safe by design (Protocol 33); LKG-cached. |
| **Gemini-key cloud sync** | `saveGeminiKeyToCloud` / secrets doc | **MUST STAY NETWORK** | Convenience sync of the key; local mirror already exists. |
| **AI Studio external link** | `ui-account.js` anchor | n/a | Static outbound link. |
### C.3 — Survey takeaways (for the owner to prioritize)
- **Genuinely generative → keep AI:** the Director narrative, and the *reasoning*
half of ROADMAP / build-goal advice. These are the product's soul.
- **Best next native conversions (deterministic data already present):**
1. **`[GPS]`/`[MAP]`** → trivial, reuses `renderWorldMap` data (offline today).
2. **LEVEL-UP point allocation** → reuses the skill VU setters.
3. **ROADMAP "eligible perks by level"** native view (keep AI for goal-aware).
- **Biggest, owner-flagged:** **VISUAL UPLOAD → on-device OCR** — the one large,
high-value conversion; hybrid (local OCR primary, AI vision fallback) is the
safe path and fits Protocol 32's kill-switch+fallback model.
- **Leave as-is:** all `cloud.js`/auth/config network surfaces (not AI; already
offline-degrading).
---
## Suggested implementation order for Sonnet
1. Add the A.2 shared setters in `ui-core.js`; refactor `commitStat` to use one.
(Foundation for both parts.)
2. Part A: `_computeAidUse`/`_durationToTicks`, `_applyStatusEffect` extraction,
`nativeUseItem`, USE-button gating + handler swap.
3. Part B: `_resolveStatToken`, 4 new patterns, hint + autocomplete + registry.
4. Suites 200 + 201 in both runners; Protocol 2a count sync everywhere.
5. Docs (CHANGELOG/ARCHITECTURE/README); CACHE_NAME `-r117`.
6. Full gate (`npm run gate`) + `render-check.mjs`; verify USE + a few terminal
edits live at 360/412/desktop in BOTH games (Protocol 10/26). Batch → ONE push
(Protocol 19).
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).