[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
|
||||
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.
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
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
|
||||
* 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);
|
||||
},
|
||||
|
||||
@ -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.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user