[ui] toast cooldown: 170 lines per 1,500 ticks becomes 43, and v9.2 remaining

Approved addendum. A repeating line prints once and then counts (×N) instead of
printing again; the window runs from the last line PRINTED, so a permanent stall
still speaks every 12s rather than going silent. Brownout, commissions, research,
relics, seals, stasis and band changes are never throttled — each is news once.

RULING made live: the throttle key is the RENDERED LINE, not the entity. Keying on
(entity, reason) passed its tests and still put four identical "QUANTIZER: INTAKE
STARVED" lines on screen, one per quantizer; the copy carries no unit id, so ×4
says strictly more than four copies do.

Also: adopt contracts v9.2 CorrectionUnit.remaining in stasis.ts (finite and >= 0
wins, phase-derived estimate stays as the fallback for open-ended phases). SIM
landed it mid-write — verified against their code, the lock chip now opens at 15S
and counts down instead of opening on HELD.

And one copy fix the first real gunship mirror exposed: "YOUR MACROBLOCK BRICKS WAS
MIRRORED" is now "A CLEAN COPY OF YOUR <ITEM> SHIPPED FIRST. THE LEDGER IS
SATISFIED." — no verb left to disagree with a plural.

277 UI tests, tsc clean tree-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 13:00:39 +10:00
parent b63f9d11a1
commit 550aa6e4a9
8 changed files with 339 additions and 17 deletions

View File

@ -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 <ITEM> SHIPPED FIRST. THE LEDGER IS SATISFIED." — which also states the mechanic.
Pinned with a plural-item test.

View File

@ -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/);
});
});

View File

@ -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);
},

View File

@ -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
});
});

View File

@ -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;

149
fktry/src/ui/toasts.test.ts Normal file
View File

@ -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.']);
});
});

View File

@ -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<string, Entry>();
/** 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<string, number>();
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);
},

View File

@ -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.`;
}
/**