diff --git a/fktry/lanes/LANE-UI.md b/fktry/lanes/LANE-UI.md index 4f1e122..002e1ee 100644 --- a/fktry/lanes/LANE-UI.md +++ b/fktry/lanes/LANE-UI.md @@ -822,3 +822,42 @@ Re-verified against HEAD rather than against my own commit: live `commissionQueue[1]` — and only asserts the advance when the head is one this rig can actually pay. The head-of-line blocking I flagged in round 5 is still live and is still a design question, not a UI fix. + +**ADDENDUM 2 — the toast cooldown (orchestrator-approved) + contracts v9.2.** +**277 UI tests** (was 263), 19 files, `npm run check` clean tree-wide. + +- **Toast cooldown shipped** (`toasts.ts` + `toasts.test.ts`, 12 tests). Measured the same + way I reported the bug: 1,500 ticks of the reference factory printed **~170 lines + before, 43 after** — and with The Correction at band 4, all six of its line types are on + screen instead of buried. A throttled repeat isn't silenced, it counts: the live line + gains `×N` (biggest observed: ×14), and the window is measured from the last line + PRINTED, so a permanent stall still speaks once every 12s rather than going quiet. + Never throttled, by list: brownout, commissionDone, researched, relicFound, sealed, + stasis, debtBand — each of those is news exactly once. +- **RULING I had to make live: the throttle key is the RENDERED LINE, not the entity.** + My first cut keyed on (entity, reason) and passed its tests, but driving the real + factory still showed **four identical "QUANTIZER: INTAKE STARVED" lines** — one per + quantizer. The copy deliberately carries no unit id, so four identical lines tell the + player strictly less than "×4" does. A line that differs in any way — different reason, + different machine name — is a different fact and is never suppressed. Both cases are + pinned (`merges two units the player cannot tell apart`, `lets a NEW reason through`). +- **v9.2 `CorrectionUnit.remaining` adopted** in `stasis.ts`: used whenever it is a finite + number ≥ 0 (so `0` counts — it is a real number, not a missing one), with the + phase-derived estimate kept as the fallback. Both paths are pinned by test. Worth + recording how narrowly I avoided writing a false NOTE: my grep said SIM had not filled + it, and by the time I finished the sentence they had (`phaseClock`, index.ts ~1705), so + I re-probed and rewrote this bullet. **It is live**, the chip is now exact from frame + one, and a fixed-length phase counts down while an open-ended one (a gunship's tether) + correctly leaves it undefined — which my `typeof === 'number'` guard reads as "nothing + to count down" and falls back on, exactly as intended. Verified live against their + landed code: the chip now opens at `744510A8 · 15S` and counts 15/14/13/12/11 instead of + opening on HELD, and a tethered gunship reports `remaining: undefined` as documented. +- **My firewall finding is FIXED, and it changed a "never verified" into a verified one.** + Gunships now reach `tether` against the firewalled reference factory, so `mirrored` + fires for real: the MIRRORED row lit live with **3** and a MACROBLOCK BRICKS chip in + clinic white. The live test's fallback branch stays, but only as a regression guard. +- **One copy wart the first real mirror exposed:** it printed "YOUR MACROBLOCK BRICKS WAS + MIRRORED" — item names are a mix of mass nouns and plurals, and the deadpan does not + survive a broken verb. Rewritten to carry no verb that has to agree: "A CLEAN COPY OF + YOUR SHIPPED FIRST. THE LEDGER IS SATISFIED." — which also states the mechanic. + Pinned with a plural-item test. diff --git a/fktry/src/ui/correction.test.ts b/fktry/src/ui/correction.test.ts index 07fed28..1971e84 100644 --- a/fktry/src/ui/correction.test.ts +++ b/fktry/src/ui/correction.test.ts @@ -415,6 +415,32 @@ describe('sector locks', () => { expect(lockChipText(lock, c, 600, COPY.stasisHeld)).toBe('8C4F119E · 10S'); }); + it('prefers the sim\'s published `remaining` (v9.2) over its own derivation', () => { + const l = createStasisLocks(); + l.record(ev(true, 500)); + const lock = l.active()[0]; + const withRemaining = (remaining: number | undefined) => correction({ + units: [{ + id: 3, kind: 'hash-auditor', pos: { x: 0, y: 0 }, phase: 'stasis', + progress: 0.25, rect, ...(remaining === undefined ? {} : { remaining }), + }], + }); + // The derivation would say 10s here (100 ticks at 25%). The sim says 60 ticks left, + // and the sim wins — it is the one that knows. + const c = withRemaining(60); + expect(remainingSeconds(lock, lockUnit(lock, c), 600)).toBeCloseTo(2, 5); + expect(lockChipText(lock, c, 600, COPY.stasisHeld)).toBe('8C4F119E · 2S'); + // Exact from frame one: no elapsed ticks, and it still counts down instead of HELD. + expect(lockChipText(lock, withRemaining(450), 500, COPY.stasisHeld)).toBe('8C4F119E · 15S'); + // Zero is a real number, not a missing one. + expect(remainingSeconds(lock, lockUnit(lock, withRemaining(0)), 600)).toBe(0); + // Absent or nonsense falls back to the phase-derived estimate. + expect(remainingSeconds(lock, lockUnit(lock, withRemaining(undefined)), 600)) + .toBeCloseTo(10, 5); + expect(remainingSeconds(lock, lockUnit(lock, withRemaining(NaN)), 600)).toBeCloseTo(10, 5); + expect(remainingSeconds(lock, lockUnit(lock, withRemaining(-5)), 600)).toBeCloseTo(10, 5); + }); + it('says HELD rather than inventing a number it cannot know yet', () => { const l = createStasisLocks(); l.record(ev(true, 500)); @@ -495,7 +521,16 @@ describe('the MIRRORED row', () => { it('blames the ledger, not the player', () => { const msg = eventToast(mirrored('melt'), () => 'UNIT', (i) => i.toUpperCase())!; - expect(msg).toBe('YOUR MELT WAS MIRRORED. THE LEDGER ALREADY HAD ONE.'); + expect(msg).toBe('A CLEAN COPY OF YOUR MELT SHIPPED FIRST. THE LEDGER IS SATISFIED.'); + }); + + it('reads correctly for a plural item name too', () => { + // The first live gunship mirror printed "YOUR MACROBLOCK BRICKS WAS MIRRORED". + // Item names are a mix of mass nouns and plurals, so the line carries no verb that + // has to agree with one. + const msg = eventToast(mirrored('macroblock-bricks'), () => 'UNIT')!; + expect(msg).toBe('A CLEAN COPY OF YOUR MACROBLOCK BRICKS SHIPPED FIRST. THE LEDGER IS SATISFIED.'); + expect(msg).not.toMatch(/\bWAS\b|\bIS MIRRORED\b/); }); }); diff --git a/fktry/src/ui/index.ts b/fktry/src/ui/index.ts index 7cda514..52c6eac 100644 --- a/fktry/src/ui/index.ts +++ b/fktry/src/ui/index.ts @@ -169,6 +169,9 @@ export function createUI(): UI { update(snap: SimSnapshot, events: SimEvent[]) { paused = snap.paused; syncRoster(roster, snap); + // One clock reading per frame, shared by the toast cooldown and both expiry passes, + // so a burst of events inside a single frame is measured against the same instant. + const now = performance.now(); top.update(snap, corrections, locks); build.update(snap); // padlocks track research @@ -220,7 +223,7 @@ export function createUI(): UI { else warnedPiles.add(ev.id); } - toasts.push(ev, machineName, itemName); + toasts.push(ev, machineName, itemName, now); // The alarm alone teaches nothing. Once per session, on the very first brownout, // say what to do about it. @@ -229,7 +232,6 @@ export function createUI(): UI { toasts.pushMessage(COPY.firstBrownoutMercy, 'mercy'); } } - const now = performance.now(); toasts.update(now); notices.update(now); }, diff --git a/fktry/src/ui/live.test.ts b/fktry/src/ui/live.test.ts index 85edd83..010b6d4 100644 --- a/fktry/src/ui/live.test.ts +++ b/fktry/src/ui/live.test.ts @@ -671,17 +671,21 @@ describe('THE CORRECTION against the real sim', () => { h.step(1500); chargeDebt(h.sim, 0.85); // band 3+ puts a gunship on a sector + // Round-7 history, kept because it explains the shape of this test: at first landing + // every gunship was canned by a firewall inside its own notice window and `mirrored` + // could not fire at all. SIM fixed it mid-round, so the real branch is the live one + // now — the guard stays only so a regression reads as "hidden", never as "shows 0". const mirrored = until(h, () => { const row = document.querySelector('.fk-mirrored-row') as HTMLElement | null; return !!row && row.style.display !== 'none'; }, 8000); if (mirrored < 0) { - // A gunship only mirrors a sector that keeps deviating; if this build's reference - // factory never feeds one, the row must stay hidden rather than show a zero. expect(($('.fk-mirrored-row')).style.display).toBe('none'); return; } expect(Number($('.fk-mirrored-v').textContent)).toBeGreaterThan(0); - expect(sawToast(/WAS MIRRORED\. THE LEDGER ALREADY HAD ONE\./)).toBe(true); + expect(sawToast(/A CLEAN COPY OF YOUR .* SHIPPED FIRST\. THE LEDGER IS SATISFIED\./)) + .toBe(true); + expect($('.fk-mirrored-row .fk-chip')).toBeTruthy(); // per-item chip, not just a count }); }); diff --git a/fktry/src/ui/stasis.ts b/fktry/src/ui/stasis.ts index c2e3d55..c7af87d 100644 --- a/fktry/src/ui/stasis.ts +++ b/fktry/src/ui/stasis.ts @@ -70,7 +70,12 @@ export function lockUnit( /** * Seconds left on a lock, or null while it can't honestly be known yet. - * elapsed / progress = phase length; the rest is the remainder over 30 tps. + * + * v9.2: `CorrectionUnit.remaining` (granted on this lane's request) is ticks left in the + * phase — exact from frame one and correct across a save/load, where we have no + * `sinceTick` of our own. It wins whenever the sim fills it. The derivation below stays + * as the fallback for a sim that doesn't: elapsed / progress = phase length, and the + * remainder falls out of that. */ export function remainingSeconds( lock: StasisLock, @@ -78,6 +83,10 @@ export function remainingSeconds( nowTick: number, ): number | null { if (!unit) return null; + const r = unit.remaining; + if (typeof r === 'number' && Number.isFinite(r) && r >= 0) { + return r / TICKS_PER_SECOND; + } const elapsed = nowTick - lock.sinceTick; const p = unit.progress; if (elapsed <= 0 || !(p > 0.02) || p >= 1) return null; diff --git a/fktry/src/ui/toasts.test.ts b/fktry/src/ui/toasts.test.ts new file mode 100644 index 0000000..748c682 --- /dev/null +++ b/fktry/src/ui/toasts.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom +/** + * LANE-UI — the toast cooldown (round 7 addendum, approved by the orchestrator). + * + * The bug it fixes: 1,500 ticks of the reference factory printed ~170 toasts, nearly all + * the same starved unit repeating the same stall, and The Correction's lines were + * unreadable inside that. The rule being pinned here is the one that keeps it honest — a + * repeat is throttled, but a CHANGE never is, and the rare lines are never throttled at + * all. + */ +import { describe, expect, it } from 'vitest'; +import type { SimEvent } from '../contracts'; +import { COOLDOWN_MS, createToasts, throttles, toastKey } from './toasts'; + +const jam = (entity: number, reason: string): SimEvent => + ({ kind: 'jammed', entity, reason, tick: 1 }); +const named = (id: number) => `UNIT ${id}`; + +function mount() { + document.body.innerHTML = ''; + const t = createToasts(); + document.body.append(t.el); + return t; +} +const lines = () => Array.from(document.querySelectorAll('.fk-toast')).map((n) => n.textContent); + +describe('toast throttle keys', () => { + it('keys on the rendered line, so identical lines merge and different ones do not', () => { + expect(toastKey(jam(7, 'starved'), 'A')).toBe('A'); + // Same line from two different units is the same key — see the RULING in toasts.ts. + expect(toastKey(jam(8, 'starved'), 'A')).toBe(toastKey(jam(7, 'starved'), 'A')); + expect(toastKey(jam(7, 'output full'), 'B')).not.toBe(toastKey(jam(7, 'starved'), 'A')); + }); + + it('never throttles the lines that are news', () => { + const rare: SimEvent[] = [ + { kind: 'brownout', on: true, tick: 1 }, + { kind: 'commissionDone', commission: 'c', tick: 1 }, + { kind: 'researched', tech: 't', tick: 1 }, + { kind: 'relicFound', relic: 'optical-fossil', id: 1, tick: 1 }, + { kind: 'sealed', entity: 3, on: true, tick: 1 }, + { kind: 'stasis', rect: { x: 0, y: 0, w: 1, h: 1 }, on: true, lockHash: 'AB', tick: 1 }, + { kind: 'debtBand', band: 2, tick: 1 }, + ]; + for (const ev of rare) { + expect(throttles(ev), ev.kind).toBe(false); + expect(toastKey(ev, 'ANY LINE'), ev.kind).toBeNull(); + } + }); + + it('throttles the ones a factory repeats at you', () => { + const noisy: SimEvent[] = [ + jam(1, 'starved'), + { kind: 'repaired', item: 'melt', pos: { x: 0, y: 0 }, tick: 1 }, + { kind: 'mirrored', item: 'melt', pos: { x: 0, y: 0 }, tick: 1 }, + { kind: 'removed', entity: 1, def: 'belt', tick: 1 }, + { kind: 'scram', entity: 1, on: true, tick: 1 }, + { kind: 'wildlife', on: 'spawned', wildlife: 'parity-mite', id: 1, pos: { x: 0, y: 0 }, tick: 1 }, + ]; + for (const ev of noisy) expect(throttles(ev), ev.kind).toBe(true); + }); +}); + +describe('the cooldown', () => { + it('prints a repeated stall once and then counts it', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + t.push(jam(7, 'starved'), named, undefined, 100); + t.push(jam(7, 'starved'), named, undefined, 200); + expect(lines().length).toBe(1); + expect(lines()[0]).toBe('UNIT 7: INTAKE STARVED. NOTHING IS ARRIVING. ×3'); + }); + + it('lets a NEW reason on the same unit through immediately', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + t.push(jam(7, 'output full'), named, undefined, 50); + expect(lines().length).toBe(2); + expect(lines()[1]).toContain('OUTPUT BUFFER AT CAPACITY'); + }); + + it('lets a distinguishable unit through — the line differs, so it is a different fact', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + t.push(jam(8, 'starved'), named, undefined, 10); + expect(lines().length).toBe(2); // "UNIT 7: …" and "UNIT 8: …" read differently + }); + + it('merges two units the player cannot tell apart into one counted line', () => { + // What actually happens in the game: four quantizers, one machine name, four + // identical lines. The copy carries no unit id, so ×4 says strictly more. + const t = mount(); + const asQuantizer = () => 'QUANTIZER'; + t.push(jam(7, 'starved'), asQuantizer, undefined, 0); + t.push(jam(8, 'starved'), asQuantizer, undefined, 10); + t.push(jam(9, 'starved'), asQuantizer, undefined, 20); + expect(lines()).toEqual(['QUANTIZER: INTAKE STARVED. NOTHING IS ARRIVING. ×3']); + }); + + it('speaks again once the window has passed — a permanent stall is not silenced', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + t.update(7000); // the first line expires + t.push(jam(7, 'starved'), named, undefined, 8000); // still inside the window + expect(lines().filter((l) => !l?.includes('×')).length).toBe(1); + t.push(jam(7, 'starved'), named, undefined, COOLDOWN_MS + 1); + expect(document.querySelectorAll('.fk-toast').length).toBe(2); + }); + + it('does not extend its own window off suppressed repeats', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + for (let i = 1; i < 60; i++) t.push(jam(7, 'starved'), named, undefined, i * 200); + // 11.8s of repeats must not push the next real line past the window. + t.push(jam(7, 'starved'), named, undefined, COOLDOWN_MS + 1); + expect(document.querySelectorAll('.fk-toast').length).toBe(2); + }); + + it('never throttles The Correction, however fast it talks', () => { + const t = mount(); + const sealed = (on: boolean): SimEvent => ({ kind: 'sealed', entity: 3, on, tick: 1 }); + t.push(sealed(true), named, undefined, 0); + t.push(sealed(false), named, undefined, 1); + t.push({ kind: 'debtBand', band: 3, tick: 1 }, named, undefined, 2); + t.push({ kind: 'debtBand', band: 4, tick: 1 }, named, undefined, 3); + expect(lines().length).toBe(4); + expect(lines().some((l) => l?.includes('SEAL REMOVED'))).toBe(true); + expect(lines().some((l) => l?.includes('DECLINES TO CONTINUE'))).toBe(true); + }); + + it('still caps the stack at four', () => { + const t = mount(); + for (let i = 0; i < 8; i++) t.push(jam(i, 'starved'), named, undefined, i); + // Evicted nodes fade for 400ms before removal, so count the ones still standing. + expect(lines().filter((l) => !!l).length).toBeGreaterThan(0); + const standing = Array.from(document.querySelectorAll('.fk-toast:not(.is-out)')); + expect(standing.length).toBe(4); + }); + + it('drops the ×N counter when the line it belonged to is gone', () => { + const t = mount(); + t.push(jam(7, 'starved'), named, undefined, 0); + t.update(7000); // expired and retired + t.push(jam(7, 'starved'), named, undefined, COOLDOWN_MS + 1); + const standing = Array.from(document.querySelectorAll('.fk-toast:not(.is-out)')) + .map((n) => n.textContent); + expect(standing).toEqual(['UNIT 7: INTAKE STARVED. NOTHING IS ARRIVING.']); + }); +}); diff --git a/fktry/src/ui/toasts.ts b/fktry/src/ui/toasts.ts index 0e31c18..3832c7c 100644 --- a/fktry/src/ui/toasts.ts +++ b/fktry/src/ui/toasts.ts @@ -3,18 +3,30 @@ * * Wall-clock timing is fine here: toasts are chrome, not sim. The determinism rule in * MASTERPLAN binds `src/sim/**`, and nothing in this file feeds back into it. + * + * ROUND 7: a per-key cooldown, because the stack was unreadable. 1,500 ticks of the + * reference factory produced ~170 toasts, nearly all "QUANTIZER: INTAKE STARVED" from the + * same starved unit re-reporting the same stall — and The Correction's lines, the ones the + * whole round was about, scrolled past inside that noise. A repeating line now prints once + * and then counts (`... ×7`) rather than printing again. + * + * What is NOT throttled is deliberate: brownouts, commissions, research, relics, seals, + * stasis and band changes are rare and each one is news. Only the lines a factory emits + * over and over — a stall, a scram, wildlife, an edit, a demolition — carry a key. */ import type { SimEvent } from '../contracts'; -import { cls, el } from './dom'; +import { cls, el, text } from './dom'; import { eventToast } from './voice'; export interface Toasts { el: HTMLElement; - /** `itemName` resolves item ids for events that name an item (e.g. v8 `repaired`). */ + /** `itemName` resolves item ids for events that name an item (e.g. v8 `repaired`). + * `nowMs` is injectable so the cooldown is testable without a fake clock. */ push( ev: SimEvent, machineName: (entityId: number) => string, itemName?: (id: string) => string, + nowMs?: number, ): void; /** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */ pushMessage(msg: string, kind: string): void; @@ -27,32 +39,100 @@ export interface Toasts { const TTL_MS = 6000; const FADE_MS = 400; // must match the .fk-toast.is-out transition const MAX = 4; +/** Minimum gap between two identical lines. Longer than TTL, so a repeat lands on a clean + * stack instead of immediately under the line it repeats. */ +export const COOLDOWN_MS = 12000; + +/** Which kinds may be throttled at all. The rest are rare, and each one is news. */ +export function throttles(ev: SimEvent): boolean { + switch (ev.kind) { + case 'jammed': // a stall re-reports for as long as it stalls + case 'scram': + case 'wildlife': + case 'repaired': // a mite raid files a receipt per edit + case 'mirrored': + case 'removed': // bulk demolition is one action, not twelve + return true; + default: // brownout / commissionDone / researched / relicFound / + return false; // sealed / stasis / debtBand — never throttled + } +} + +/** + * The throttle key for an event, or null for "always print this". + * + * RULING (round 7, after watching it live): the key is the RENDERED LINE, not the entity. + * Keying on (unit, reason) still put four identical "QUANTIZER: INTAKE STARVED" lines on + * screen, one per quantizer — and since the copy deliberately carries no unit id, four + * identical lines tell the player nothing that "×4" doesn't tell them better. A line that + * differs in any way — a different reason, a different machine NAME — is a different fact + * and is never suppressed. + */ +export function toastKey(ev: SimEvent, message: string): string | null { + return throttles(ev) ? message : null; +} + +interface Entry { + node: HTMLElement; + dieAt: number; + key: string | null; + /** The line without its multiplier, so the suffix can be recomputed. */ + base: string; + count: number; +} export function createToasts(): Toasts { const root = el('div'); root.id = 'fk-toasts'; - const live: Array<{ node: HTMLElement; dieAt: number }> = []; + const live: Entry[] = []; + /** Live entries by key, for attaching the ×N to the line already on screen. */ + const shown = new Map(); + /** When each key was last PRINTED. A suppressed repeat does not extend the window, + * so a permanent stall still speaks once every cooldown rather than going silent. */ + const lastAt = new Map(); - function retire(entry: { node: HTMLElement; dieAt: number }) { + function retire(entry: Entry) { cls(entry.node, 'is-out', true); + if (entry.key && shown.get(entry.key) === entry) shown.delete(entry.key); setTimeout(() => entry.node.remove(), FADE_MS); } - function emit(msg: string, kind: string) { + function emit(msg: string, kind: string, key: string | null = null, now = performance.now()) { const node = el('div', `fk-toast k-${kind}`, [msg]); root.append(node); - live.push({ node, dieAt: performance.now() + TTL_MS }); + const entry: Entry = { node, dieAt: now + TTL_MS, key, base: msg, count: 1 }; + live.push(entry); + if (key) { + shown.set(key, entry); + lastAt.set(key, now); + } while (live.length > MAX) retire(live.shift()!); } return { el: root, - push(ev, machineName, itemName) { + push(ev, machineName, itemName, nowMs = performance.now()) { const msg = eventToast(ev, machineName, itemName); - if (msg) emit(msg, ev.kind); + if (!msg) return; + + const key = toastKey(ev, msg); + if (key) { + const last = lastAt.get(key); + if (last !== undefined && nowMs - last < COOLDOWN_MS) { + // Inside the window. If the line it repeats is still on screen, count it there, + // so an ongoing stall reads as ongoing instead of vanishing entirely. + const open = shown.get(key); + if (open) { + open.count++; + text(open.node, `${open.base} ×${open.count}`); + } + return; + } + } + emit(msg, ev.kind, key, nowMs); }, - pushMessage: emit, + pushMessage: (msg, kind) => emit(msg, kind), setStandoff(on) { cls(root, 'is-standoff', on); }, diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts index 1bfe92b..7da7b8b 100644 --- a/fktry/src/ui/voice.ts +++ b/fktry/src/ui/voice.ts @@ -126,7 +126,11 @@ export function eventToast( * "subtracts from the sky ledger"). The insult is bureaucratic, not violent. */ export function mirroredToast(itemName: string): string { - return `YOUR ${itemName} WAS MIRRORED. THE LEDGER ALREADY HAD ONE.`; + // Phrased to dodge verb agreement: item names are a mix of mass nouns and plurals + // ("MELT", "HF DUST", "MACROBLOCK BRICKS"), and the first live gunship mirror printed + // "YOUR MACROBLOCK BRICKS WAS MIRRORED", which is the kind of thing the deadpan cannot + // survive. Naming the copy instead of the cargo fixes it and states the mechanic. + return `A CLEAN COPY OF YOUR ${itemName} SHIPPED FIRST. THE LEDGER IS SATISFIED.`; } /**