[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:
parent
b63f9d11a1
commit
550aa6e4a9
@ -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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@ -415,6 +415,32 @@ describe('sector locks', () => {
|
|||||||
expect(lockChipText(lock, c, 600, COPY.stasisHeld)).toBe('8C4F119E · 10S');
|
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', () => {
|
it('says HELD rather than inventing a number it cannot know yet', () => {
|
||||||
const l = createStasisLocks();
|
const l = createStasisLocks();
|
||||||
l.record(ev(true, 500));
|
l.record(ev(true, 500));
|
||||||
@ -495,7 +521,16 @@ describe('the MIRRORED row', () => {
|
|||||||
|
|
||||||
it('blames the ledger, not the player', () => {
|
it('blames the ledger, not the player', () => {
|
||||||
const msg = eventToast(mirrored('melt'), () => 'UNIT', (i) => i.toUpperCase())!;
|
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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,9 @@ export function createUI(): UI {
|
|||||||
update(snap: SimSnapshot, events: SimEvent[]) {
|
update(snap: SimSnapshot, events: SimEvent[]) {
|
||||||
paused = snap.paused;
|
paused = snap.paused;
|
||||||
syncRoster(roster, snap);
|
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);
|
top.update(snap, corrections, locks);
|
||||||
build.update(snap); // padlocks track research
|
build.update(snap); // padlocks track research
|
||||||
@ -220,7 +223,7 @@ export function createUI(): UI {
|
|||||||
else warnedPiles.add(ev.id);
|
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,
|
// The alarm alone teaches nothing. Once per session, on the very first brownout,
|
||||||
// say what to do about it.
|
// say what to do about it.
|
||||||
@ -229,7 +232,6 @@ export function createUI(): UI {
|
|||||||
toasts.pushMessage(COPY.firstBrownoutMercy, 'mercy');
|
toasts.pushMessage(COPY.firstBrownoutMercy, 'mercy');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const now = performance.now();
|
|
||||||
toasts.update(now);
|
toasts.update(now);
|
||||||
notices.update(now);
|
notices.update(now);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -671,17 +671,21 @@ describe('THE CORRECTION against the real sim', () => {
|
|||||||
h.step(1500);
|
h.step(1500);
|
||||||
chargeDebt(h.sim, 0.85); // band 3+ puts a gunship on a sector
|
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 mirrored = until(h, () => {
|
||||||
const row = document.querySelector('.fk-mirrored-row') as HTMLElement | null;
|
const row = document.querySelector('.fk-mirrored-row') as HTMLElement | null;
|
||||||
return !!row && row.style.display !== 'none';
|
return !!row && row.style.display !== 'none';
|
||||||
}, 8000);
|
}, 8000);
|
||||||
if (mirrored < 0) {
|
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');
|
expect(($('.fk-mirrored-row')).style.display).toBe('none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(Number($('.fk-mirrored-v').textContent)).toBeGreaterThan(0);
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -70,7 +70,12 @@ export function lockUnit(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Seconds left on a lock, or null while it can't honestly be known yet.
|
* 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(
|
export function remainingSeconds(
|
||||||
lock: StasisLock,
|
lock: StasisLock,
|
||||||
@ -78,6 +83,10 @@ export function remainingSeconds(
|
|||||||
nowTick: number,
|
nowTick: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!unit) return 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 elapsed = nowTick - lock.sinceTick;
|
||||||
const p = unit.progress;
|
const p = unit.progress;
|
||||||
if (elapsed <= 0 || !(p > 0.02) || p >= 1) return null;
|
if (elapsed <= 0 || !(p > 0.02) || p >= 1) return null;
|
||||||
|
|||||||
149
fktry/src/ui/toasts.test.ts
Normal file
149
fktry/src/ui/toasts.test.ts
Normal 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.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,18 +3,30 @@
|
|||||||
*
|
*
|
||||||
* Wall-clock timing is fine here: toasts are chrome, not sim. The determinism rule in
|
* 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.
|
* 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 type { SimEvent } from '../contracts';
|
||||||
import { cls, el } from './dom';
|
import { cls, el, text } from './dom';
|
||||||
import { eventToast } from './voice';
|
import { eventToast } from './voice';
|
||||||
|
|
||||||
export interface Toasts {
|
export interface Toasts {
|
||||||
el: HTMLElement;
|
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(
|
push(
|
||||||
ev: SimEvent,
|
ev: SimEvent,
|
||||||
machineName: (entityId: number) => string,
|
machineName: (entityId: number) => string,
|
||||||
itemName?: (id: string) => string,
|
itemName?: (id: string) => string,
|
||||||
|
nowMs?: number,
|
||||||
): void;
|
): void;
|
||||||
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
|
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
|
||||||
pushMessage(msg: string, kind: string): void;
|
pushMessage(msg: string, kind: string): void;
|
||||||
@ -27,32 +39,100 @@ export interface Toasts {
|
|||||||
const TTL_MS = 6000;
|
const TTL_MS = 6000;
|
||||||
const FADE_MS = 400; // must match the .fk-toast.is-out transition
|
const FADE_MS = 400; // must match the .fk-toast.is-out transition
|
||||||
const MAX = 4;
|
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 {
|
export function createToasts(): Toasts {
|
||||||
const root = el('div');
|
const root = el('div');
|
||||||
root.id = 'fk-toasts';
|
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);
|
cls(entry.node, 'is-out', true);
|
||||||
|
if (entry.key && shown.get(entry.key) === entry) shown.delete(entry.key);
|
||||||
setTimeout(() => entry.node.remove(), FADE_MS);
|
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]);
|
const node = el('div', `fk-toast k-${kind}`, [msg]);
|
||||||
root.append(node);
|
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()!);
|
while (live.length > MAX) retire(live.shift()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
el: root,
|
el: root,
|
||||||
push(ev, machineName, itemName) {
|
push(ev, machineName, itemName, nowMs = performance.now()) {
|
||||||
const msg = eventToast(ev, machineName, itemName);
|
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) {
|
setStandoff(on) {
|
||||||
cls(root, 'is-standoff', on);
|
cls(root, 'is-standoff', on);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -126,7 +126,11 @@ export function eventToast(
|
|||||||
* "subtracts from the sky ledger"). The insult is bureaucratic, not violent.
|
* "subtracts from the sky ledger"). The insult is bureaucratic, not violent.
|
||||||
*/
|
*/
|
||||||
export function mirroredToast(itemName: string): string {
|
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.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user