[ui] the enemy has paperwork: PARITY DEBT gauge, notices, seals + PRY, stasis

Round 7 phase 1, contracts v9. Five surfaces for The Correction's rungs 2-4:

- PARITY DEBT gauge in the top strip — five clinic-white bands, never red, never
  flashing, hidden until the debt first moves and visible forever after.
- Notices are faxes: a white-letterhead tray above the commission fax. No unit
  name, no grid reference (pinned by a test that forbids digits outright), and
  deliberately not also a toast. Compliance quotas get a COMPLIANCE stamp.
- Compliance seal panel + PRY, with the bar driven by SIM's own pry progress and
  the PROCESS bar standing down so that number can't be misread as a batch.
- Stasis: hash-named countdown chips derived from the auditor's phase progress,
  and the abstention teach in the one window where it is still actionable.
- MIRRORED joins the CORRECTED block as its own row, not the same counter.

Verified live on :8153 by driving the real sim + real UI in the browser: a full
warden cycle (seal -> PRY -> "SEAL REMOVED. THIS WILL BE NOTED." -> debt up) and a
full auditor cycle (teach -> lock 744510A8 -> countdown -> auto-release).
263 UI tests, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 12:40:06 +10:00
parent de5a1b5770
commit 5500949d83
15 changed files with 1628 additions and 26 deletions

View File

@ -677,3 +677,127 @@ stays anonymous); a per-machine "corrected N times" once/if SIM attributes repai
machine rather than a belt tile; Checksum Wardens / Redundancy Gunships get the same
surfacing treatment when they arrive (the corrections module generalises to any Correction
edit event).
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
**SHIPPED:** All five orders. New modules: `parity.ts` (band arithmetic), `debtgauge.ts`,
`notices.ts` (the notice tray), `stasis.ts` (sector locks + countdown), `seal.ts` (pry
tracking), `correction.test.ts` (43 tests) and 7 new live tests. Extended: `voice.ts`,
`topstrip.ts`, `inspector.ts`, `fax.ts`, `corrections.ts`, `index.ts`, `style.ts`.
**263 UI tests** (was 213); `npm run check` clean tree-wide.
1. **PARITY DEBT gauge** in the top strip: five labelled bands (NOMINAL / OBSERVED /
AUDITED / SEALED / REFUSED), clinic white-green, no red anywhere and no flash — a test
asserts the gauge's markup contains no alarm class at all, because the contrast with
BROWNOUT four rows up *is* the characterisation. Hidden until the debt first moves;
once revealed it stays, so venting back to NOMINAL is something you get to watch. The
sim's `band` is truth; `bandFromDebt` is a fallback for a partial state and is never
used when SIM publishes one.
2. **Notices are faxes** — a white-letterhead tray above the commission fax, one slip per
`notice`, 20s of paper each, max 3. RULING enforced by test: no unit name, and
`expect(t).not.toMatch(/\d/)` — a notice cannot contain a coordinate because it cannot
contain a digit. Three distinct lines, one per rung, none of which say who is coming.
Deliberately **not** also a toast: one document, one place.
Compliance quotas get their own clinic COMPLIANCE stamp (bottom corner, so a fax can be
both a standing order and a quota) plus "QUOTA. FILLING IT VENTS PARITY DEBT."
3. **Seal + PRY** — inspector seal panel, PRY dispatching `{kind:'pry', pos}`, the bar
driven by SIM's own `EntityState.progress`, and the jam line reading "UNIT SEALED. IT IS
COMPLIANT AND IDLE." in clinic white rather than fault amber. `jamText('sealed')` maps
SIM's reason string to the same line.
4. **Stasis**`stasis` on → toast naming the hash + a countdown chip per open lock;
auditor `prescan` → the abstention teach, once per auditor, re-armable if that auditor
leaves and returns.
5. **MIRRORED ticker** — see DECISIONS for the split.
**VERIFIED — live, on :8153, in a real browser with real CSS.** The pane still backgrounds
the tab (rounds 3-6: `document.hidden`, viewport 0x0, rAF paused), so instead of screenshots
I imported the real modules in the page and pumped the real sim synchronously into a real
`createUI()` — same code, same stylesheet, real `getComputedStyle`:
- **Full warden cycle:** warden walked in, audited and sealed a QUANTIZER at tick 2561 →
clicked it → inspector opened with the seal panel (border resolves to the real
`#d6f5df`), jam line clinic white → PRY → button "PRYING"/disabled, bar at **4.44%**
(= 4/90 ticks, SIM's own number) → freed → "SEAL REMOVED. THIS WILL BE NOTED." and debt
0.6821 → 0.7338. Prying really does cost you.
- **Full auditor cycle:** prescan → "AN AUDITOR IS READING. STOP MOVING." with the lock row
still hidden (the teach lands *before* the lock, or it teaches nothing) → lock → chip
`744510A8 · HELD`, then `744510A8 · 13S` once the phase reveals its length → toast with
the identical hash → released **with no player action**, "SECTOR RELEASED."
- Notice tray on The Correction's letterhead with all three rung lines; CORRECTED row at 3
mite edits; `#ui` still computes `pointer-events:none`; **no console errors**.
**VERIFIED — headless:** `correction.test.ts` (43) pins the bands, the no-digits notice
rule, the seal/pry lifecycle, the lock countdown arithmetic and the ticker split.
`live.test.ts` (+7, 26 total) runs it all against the real sim.
**DECISIONS:**
- **MIRRORED is a second row, not the same counter.** A mite editing your melt and a
gunship out-shipping you with a clean copy are different crimes; one number that added
them together would read worse than two that don't. Both live under the existing
CORRECTED block, each row hidden until its own first event. `catchesOn` (the FIREWALL
LOG) still counts repairs only — a mirror happens on the ledger, not on your machine.
- **The pry bar reads SIM's `EntityState.progress`, not my own clock.** SIM reuses the
craft-progress field while a unit is sealed, so the bar is sim truth and survives a
reload; my tick tracker only covers the frame between the click and the command landing,
and reports `null` (an honestly indeterminate bar) if a future sim publishes nothing.
Consequence handled: the PROCESS bar hides while sealed, or that same number would read
as "60% through a batch" when no batch exists. Buffers stay visible.
- **The stasis countdown is derived, not imported.** elapsed/progress gives the phase
length, so the chip says HELD until the auditor's own progress reveals it and seconds
after. No tuning constant is imported and none is guessed — if SIM retunes
`AUDITOR_STASIS_TICKS`, the chip is right anyway.
- Notices don't toast, and the abstention teach reads a snapshot *phase* rather than an
event: being read is a state you can be caught inside of, not a moment you can miss.
**BLOCKED/BROKEN (read this part):**
- **Firewalls are currently a hard counter to rungs 2-3, and rung 3 never happens at all.**
Traced at HEAD: a warden walks in from the rim and `firewallPrey` cans any non-auditor
inside `FIREWALL_RADIUS`, so it dies mid-approach in the reference factory roughly nine
runs in ten (12k ticks → **1** seal). The gunship is worse: it *arrives at the centre of
the worst sector*, i.e. inside your factory, and is canned during its own notice window —
in 12k ticks it never once left `notice`, so `mirrored` never fired and the MIRRORED row
has **never been seen with a real event**. Its UI is covered headless. LANE-SIM/
orchestrator: this may be intended ("kill the parity link to pop it"), but popping it
before it exists means rung 3 is invisible content today.
My warden live test therefore removes the firewalls first, with the player's own `remove`
command, and says why — I'd rather a deterministic DoD than a coin flip.
- **Repeated `save()`/`load()` round-trips stall a warden mid-approach.** Topping the debt
up through save/load every 300 ticks produced 1,689 ticks of `approach` and zero audits
across a 12k run, vs. a seal in the same window without it. A player who saves often may
be accidentally immune to The Correction. Not my lane; flagging with the repro.
- **`src/sim/reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
surplus rather than jamming", 0 > 300). Not mine — I touched no `src/sim` or `data` file.
All 18 `src/ui/**` files pass, 263 tests.
- Cosmetic, harness-only: in a synchronous pump (no wall clock), retired toasts and notice
slips linger in the DOM for their 400ms fade because removal is a `setTimeout`. Live it
is invisible; in a headless drive the node counts read high.
- The notice tray is capped at `max-height:178px` because I could not measure it against
the commission fax (0x0 viewport). Three long slips fit above y=200 by arithmetic, and
the cap makes overlap impossible rather than unlikely — but nobody has seen it at full
height on a real screen.
- Order-numbers-go-stale, sixth round running: at round start SIM had *none* of the
Correction (`grep` for `sealed`/`lockHash` in `src/sim` → nothing) and `npm run check`
had 20 errors from their in-flight edit. Everything above was written against contracts
v9 and then re-probed against a **running** sim; the pry-progress and jam-reason
decisions above exist only because I re-read their landed code instead of my own notes.
**CONTRACT REQUEST:**
1. **`SimSnapshot.correction.units[].kind === 'hash-auditor'` needs a published prescan
deadline, or `CorrectionUnit.remaining?: number`** — *why:* every countdown I show is
derived from `progress` and elapsed ticks, which works but goes blind for the first few
ticks of a phase (the chip reads HELD). One optional field and the lock chip could count
down from the first frame, including across a save/load where I have no `sinceTick`.
2. **A `testkit` verb for The Correction** (`chargeDebt(sim, n)` / `spawnUnit(sim, kind)`) —
*why:* my live tests reach band 4 by hand-editing a save blob, exactly the coupling
round 4 flagged and round 5 fixed for research. SIM owns the schema; a verb costs them
three lines and un-couples every UI/RENDER/SCREEN test of rungs 2-4.
**PROPOSAL:** the jam toast needs a rate limit. Driving the reference factory for 1,500
ticks produced **~170 toasts**, nearly all "QUANTIZER: INTAKE STARVED", and The Correction's
lines — the ones that matter — scroll past inside that noise. A per-entity cooldown (or
"× 12" on a repeated line) is a small change in `toasts.ts` and it is the difference between
the new copy landing and being buried. Not done unasked; say the word.
**NEXT (suggested):** wire the MIRRORED row to a real gunship the moment rung 3 can survive
its own arrival; a moov-heist UI when the lock becomes breakable (the chip already names the
hash the minigame will want); compliance quota progress on the debt gauge itself (show what
filling the current quota would vent) once DATA settles the quota list.

View File

@ -0,0 +1,524 @@
// @vitest-environment happy-dom
/**
* LANE-UI THE CORRECTION, rungs 2-4 (round 7).
*
* Covers the four surfaces the orders name the PARITY DEBT gauge, notices-as-faxes,
* the compliance seal + PRY, and stasis plus the mirrored ticker. The live-sim half of
* the DoD (a full warden cycle, a full auditor cycle, the abstention teach) is in
* live.test.ts against the real sim; this file pins the rules that must hold whatever
* the sim does.
*/
import { describe, expect, it } from 'vitest';
import type {
Command, CorrectionState, GameData, SimEvent, SimSnapshot, UIBus,
} from '../contracts';
import { createCorrections } from './corrections';
import { createDebtGauge } from './debtgauge';
import { createFax } from './fax';
import { createInspector } from './inspector';
import { createNotices } from './notices';
import { bandFromDebt, bandLabel, DEBT_BANDS, gaugeSpec } from './parity';
import { createPryTracker, resolvePryTicks } from './seal';
import { createStasisLocks, lockChipText, lockUnit, remainingSeconds } from './stasis';
import { createTopStrip } from './topstrip';
import { COPY, eventToast, jamText, noticeText } from './voice';
const DATA: GameData = {
items: [
{ id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff3fd4' },
{ id: 'mdat-ore', name: 'MDAT ORE', codex: '', tier: 0, color: '#101014' },
],
machines: [{
id: 'mosh-reactor', name: 'MOSH REACTOR', codex: '', kind: 'crafter',
footprint: { x: 2, y: 2 }, recipes: ['mosh'], powerDraw: 0, asset: 'm',
}],
recipes: [{
id: 'mosh', machine: 'mosh-reactor', inputs: { 'mdat-ore': 1 }, outputs: { melt: 1 },
ticks: 30, bandwidth: 1,
}],
tech: [],
commissions: [
{ id: 'first-taste', flavor: 'ONE (1) MELT.', wants: { melt: 1 }, rewardBandwidth: 10 },
{
id: 'parity-quota', flavor: 'ORE. UNPROCESSED. TWENTY OF IT.',
wants: { 'mdat-ore': 20 }, rewardBandwidth: 5, compliance: true,
},
],
};
function snap(over: Partial<SimSnapshot> = {}): SimSnapshot {
return {
tick: 100, paused: false,
entities: [{
id: 7, def: 'mosh-reactor', pos: { x: 0, y: 0 }, dir: 0, recipe: 'mosh', progress: 0,
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
}],
beltItems: [],
bandwidth: { gen: 10, draw: 1, stored: 0, brownout: false },
shippedTotal: {}, activeCommission: null, commissionProgress: {},
...over,
};
}
const correction = (over: Partial<CorrectionState> = {}): CorrectionState =>
({ debt: 0, band: 0, units: [], ...over });
// ================================================================= PARITY DEBT gauge
describe('PARITY DEBT bands', () => {
it('names the five bands in escalation order', () => {
expect([...DEBT_BANDS]).toEqual(['NOMINAL', 'OBSERVED', 'AUDITED', 'SEALED', 'REFUSED']);
expect(bandLabel(0)).toBe('NOMINAL');
expect(bandLabel(4)).toBe('REFUSED');
});
it('takes the simband as truth even when it disagrees with the debt', () => {
// The sim owns the thresholds; they are tuning, and the UI does not second-guess them.
const g = gaugeSpec(correction({ debt: 0.05, band: 3 }));
expect(g.band).toBe(3);
expect(g.label).toBe('SEALED');
});
it('falls back to equal fifths only when no band is published', () => {
expect(bandFromDebt(0)).toBe(0);
expect(bandFromDebt(0.35)).toBe(1);
expect(bandFromDebt(0.99)).toBe(4);
expect(bandFromDebt(4)).toBe(4); // clamped
expect(bandFromDebt(-1)).toBe(0);
});
it('stays hidden while the debt has never moved', () => {
expect(gaugeSpec(undefined).visible).toBe(false);
expect(gaugeSpec(correction()).visible).toBe(false);
});
it('reveals on the first movement and stays revealed all the way back down', () => {
expect(gaugeSpec(correction({ debt: 0.01 })).visible).toBe(true);
expect(gaugeSpec(correction({ debt: 0, band: 0 }), true).visible).toBe(true);
});
it('clamps the fill to 0..100', () => {
expect(gaugeSpec(correction({ debt: 2, band: 4 })).pct).toBe(100);
expect(gaugeSpec(correction({ debt: -1 })).pct).toBe(0);
});
});
describe('the gauge widget', () => {
const mount = () => {
document.body.innerHTML = '';
const g = createDebtGauge();
document.body.append(g.el);
return g;
};
const cells = () => Array.from(document.querySelectorAll('.fk-debt-band'));
it('is not in the player\'s face until The Correction exists', () => {
const g = mount();
g.update(undefined);
expect(g.el.style.display).toBe('none');
expect(g.isRevealed()).toBe(false);
});
it('lights exactly one band and marks the ones behind it', () => {
const g = mount();
g.update(correction({ debt: 0.5, band: 2 }));
expect(g.el.style.display).not.toBe('none');
expect(cells().map((c) => c.textContent))
.toEqual(['NOMINAL', 'OBSERVED', 'AUDITED', 'SEALED', 'REFUSED']);
expect(cells().filter((c) => c.classList.contains('is-on')).map((c) => c.textContent))
.toEqual(['AUDITED']);
expect(cells().filter((c) => c.classList.contains('is-past')).length).toBe(2);
expect(document.querySelector('.fk-debt-state')!.textContent).toBe('AUDITED');
expect((document.querySelector('.fk-debt-fill') as HTMLElement).style.width).toBe('50%');
});
it('never raises an alarm — REFUSED is weight, not colour', () => {
const g = mount();
g.update(correction({ debt: 1, band: 4 }));
const c = g.el.className;
expect(c).toContain('is-refused');
// No alarm vocabulary anywhere on the gauge: that is BROWNOUT's job, not this one's.
expect(g.el.outerHTML).not.toMatch(/is-brownout|is-tight|fk-red/);
});
it('keeps showing NOMINAL after the debt is vented — you get to watch it drop', () => {
const g = mount();
g.update(correction({ debt: 0.8, band: 3 }));
g.update(correction({ debt: 0, band: 0 }));
expect(g.el.style.display).not.toBe('none');
expect(document.querySelector('.fk-debt-state')!.textContent).toBe('NOMINAL');
});
});
// ===================================================================== notices as faxes
describe('notices are faxes, and they tell you nothing useful', () => {
const notice = (unit: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor'): SimEvent =>
({ kind: 'notice', unit, tick: 10 });
const mount = () => {
document.body.innerHTML = '';
const n = createNotices();
document.body.append(n.el);
return n;
};
it('opens with the audit line the orders asked for by name', () => {
expect(noticeText('checksum-warden')).toBe('NOTICE OF SCHEDULED AUDIT. NO ACTION REQUIRED.');
});
it('never names the unit and never gives a grid reference', () => {
for (const u of ['checksum-warden', 'redundancy-gunship', 'hash-auditor'] as const) {
const t = noticeText(u);
expect(t).not.toMatch(/WARDEN|GUNSHIP|AUDITOR|MITE/);
expect(t).not.toMatch(/\d/); // no coordinates, no counts, no ETA
expect(t).toContain('NO ACTION REQUIRED');
}
});
it('says something different for each rung without ever saying who', () => {
const all = (['checksum-warden', 'redundancy-gunship', 'hash-auditor'] as const)
.map(noticeText);
expect(new Set(all).size).toBe(3);
});
it('shows no tray until the first notice arrives', () => {
const n = mount();
expect(n.el.classList.contains('is-on')).toBe(false);
expect(n.count()).toBe(0);
});
it('prints the slip on The Correction\'s letterhead', () => {
const n = mount();
n.push(notice('hash-auditor'), 0);
expect(n.el.classList.contains('is-on')).toBe(true);
expect(document.querySelector('.fk-notice-body')!.textContent)
.toBe('NOTICE OF INTEGRITY READING. NO ACTION REQUIRED.');
expect(n.el.textContent).toContain(COPY.noticeSub);
expect(n.el.textContent).toContain(COPY.noticeFooter);
});
it('ignores anything that is not a notice', () => {
const n = mount();
n.push({ kind: 'shipped', item: 'melt', count: 1, tick: 1 }, 0);
expect(n.count()).toBe(0);
});
it('holds a few slips and retires them on their own', () => {
const n = mount();
for (let i = 0; i < 5; i++) n.push(notice('checksum-warden'), 0);
expect(n.count()).toBe(3);
n.update(19999);
expect(n.count()).toBe(3); // paper lingers — you are meant to be able to read it
n.update(20001);
expect(n.count()).toBe(0);
expect(n.el.classList.contains('is-on')).toBe(false);
});
it('is NOT also a toast — one document, one place', () => {
expect(eventToast(notice('checksum-warden'), () => 'UNIT')).toBeNull();
});
});
describe('the compliance quota stamp', () => {
it('stamps a compliance commission and explains what filling it does', () => {
document.body.innerHTML = '';
const fax = createFax(DATA);
document.body.append(fax.el);
fax.update(snap({ activeCommission: 'first-taste' }));
expect(document.querySelector('.fk-fax-compliance')!.classList.contains('is-on'))
.toBe(false);
fax.update(snap({ activeCommission: 'parity-quota' }));
expect(document.querySelector('.fk-fax-compliance')!.classList.contains('is-on'))
.toBe(true);
expect(document.querySelector('.fk-fax-compliance')!.textContent).toBe('COMPLIANCE');
expect(document.querySelector('.fk-fax-compliance-note')!.textContent)
.toBe(COPY.faxComplianceNote);
expect(fax.el.classList.contains('is-compliance')).toBe(true);
});
});
// ======================================================================= seal and pry
describe('the compliance seal', () => {
let dispatched: Command[] = [];
const bus: UIBus = {
dispatch: (c) => { dispatched.push(c); },
selectedBuild: () => null,
pickTile: () => null,
};
function open() {
document.body.innerHTML = '';
dispatched = [];
const ins = createInspector(DATA, bus);
document.body.append(ins.el);
ins.open(7);
return ins;
}
const sealedSnap = (over: Record<string, unknown> = {}) => snap({
entities: [{
id: 7, def: 'mosh-reactor', pos: { x: 3, y: -2 }, dir: 0, recipe: 'mosh', progress: 0,
inputBuf: {}, outputBuf: {}, jammed: 'sealed', heat: 0, sealed: true, ...over,
}],
});
const seal = () => document.querySelector('.fk-ins-seal') as HTMLElement;
const pryBtn = () => document.querySelector('.fk-ins-pry') as HTMLButtonElement;
it('shows nothing on a unit nobody has stamped', () => {
const ins = open();
ins.update(snap());
expect(seal().classList.contains('is-on')).toBe(false);
});
it('declares the unit compliant, which is the insult', () => {
const ins = open();
ins.update(sealedSnap());
expect(seal().classList.contains('is-on')).toBe(true);
expect(document.querySelector('.fk-ins-seal-body')!.textContent)
.toBe('THIS UNIT HAS BEEN BROUGHT INTO COMPLIANCE.');
expect(pryBtn().textContent).toBe('PRY');
});
it('reads the seal in the jam line too, in clinic white rather than fault amber', () => {
const ins = open();
ins.update(sealedSnap());
const jam = document.querySelector('.fk-ins-jam') as HTMLElement;
expect(jam.classList.contains('is-on')).toBe(true);
expect(jam.classList.contains('is-sealed')).toBe(true);
expect(jam.textContent).toBe(COPY.sealJam);
expect(jamText('sealed')).toBe(COPY.sealJam);
});
it('dispatches `pry` at the unit\'s own tile and then shows it working', () => {
const ins = open();
ins.update(sealedSnap());
pryBtn().click();
expect(dispatched).toEqual([{ kind: 'pry', pos: { x: 3, y: -2 } }]);
expect(pryBtn().textContent).toBe('PRYING');
expect(pryBtn().disabled).toBe(true);
// A second click cannot queue a second pry.
pryBtn().click();
expect(dispatched.length).toBe(1);
});
it('runs the bar off the sim\'s own pry progress, not a wall clock', () => {
const ins = open();
ins.update(sealedSnap({ progress: 0.25 }));
const bar = document.querySelector('.fk-ins-pry-bar') as HTMLElement;
expect(bar.classList.contains('is-on')).toBe(true);
expect(bar.classList.contains('is-indeterminate')).toBe(false);
expect((bar.querySelector('.fk-bar-f') as HTMLElement).style.width).toBe('25%');
});
it('hides the PROCESS bar while sealed — that progress is the pry, not a batch', () => {
const ins = open();
ins.update(sealedSnap({ progress: 0.6 }));
const progBars = Array.from(document.querySelectorAll('.fk-bar'))
.filter((b) => !b.classList.contains('fk-ins-pry-bar') && !b.classList.contains('fk-heat'));
expect(progBars.every((b) => (b as HTMLElement).style.display === 'none')).toBe(true);
});
it('puts the panel away the moment the seal comes off', () => {
const ins = open();
ins.update(sealedSnap());
pryBtn().click();
ins.update(snap()); // sim reports it unsealed
expect(seal().classList.contains('is-on')).toBe(false);
expect(pryBtn().disabled).toBe(false);
});
it('files the removal as noted, not as a victory', () => {
expect(eventToast({ kind: 'sealed', entity: 7, on: false, tick: 1 }, () => 'MOSH REACTOR'))
.toBe('SEAL REMOVED. THIS WILL BE NOTED.');
expect(eventToast({ kind: 'sealed', entity: 7, on: true, tick: 1 }, () => 'MOSH REACTOR'))
.toBe('MOSH REACTOR HAS BEEN BROUGHT INTO COMPLIANCE.');
});
});
describe('pry tracking', () => {
it('finds SIM\'s pry duration when it exports one, and shrugs when it does not', () => {
expect(resolvePryTicks({ PRY_TICKS: 90 })).toBe(90);
expect(resolvePryTicks({ SEAL_PRY_TICKS: 45 })).toBe(45);
expect(resolvePryTicks({ NOTHING_USEFUL: 3 })).toBeNull();
expect(resolvePryTicks({ PRY_TICKS: 0 })).toBeNull();
// Against the real constants module: whatever SIM ships today, it must be a number
// or null — never a crash.
const live = resolvePryTicks();
expect(live === null || live > 0).toBe(true);
});
it('advances on ticks so a paused game does not advance the bar', () => {
const t = createPryTracker(100);
t.begin(7, 1000);
expect(t.isPrying(7)).toBe(true);
expect(t.progress(7, 1000)).toBe(0);
expect(t.progress(7, 1050)).toBe(0.5);
expect(t.progress(7, 1200)).toBe(1); // clamped
t.end(7);
expect(t.isPrying(7)).toBe(false);
expect(t.progress(7, 1050)).toBeNull();
});
it('is honestly indeterminate when no duration is known', () => {
const t = createPryTracker(null);
t.begin(7, 0);
expect(t.isPrying(7)).toBe(true);
expect(t.progress(7, 30)).toBeNull();
});
it('gives the button back if the sim never took the command', () => {
const t = createPryTracker(100);
t.begin(7, 0);
t.sweep(200);
expect(t.isPrying(7)).toBe(true);
t.sweep(100000);
expect(t.isPrying(7)).toBe(false);
});
});
// ============================================================================= stasis
describe('sector locks', () => {
const rect = { x: -4, y: -4, w: 8, h: 8 };
const ev = (on: boolean, tick = 500, hash = '8C4F119E'): SimEvent =>
({ kind: 'stasis', rect, on, lockHash: hash, tick });
it('opens and closes on the edges, once each', () => {
const l = createStasisLocks();
expect(l.record(ev(true))).toBe('opened');
expect(l.record(ev(true))).toBeNull(); // re-announced, not a second lock
expect(l.count()).toBe(1);
expect(l.record(ev(false, 900))).toBe('closed');
expect(l.count()).toBe(0);
expect(l.record(ev(false, 901))).toBeNull();
});
it('ignores everything that is not a stasis edge', () => {
const l = createStasisLocks();
expect(l.record({ kind: 'shipped', item: 'melt', count: 1, tick: 1 })).toBeNull();
});
it('names the lock and counts it down from the auditor\'s own phase progress', () => {
const l = createStasisLocks();
l.record(ev(true, 500));
const lock = l.active()[0];
const c = correction({
debt: 0.5, band: 2,
units: [{ id: 3, kind: 'hash-auditor', pos: { x: 0, y: 0 }, phase: 'stasis', progress: 0.25, rect }],
});
// 100 ticks in at 25% => 400 ticks total => 300 left => 10s at 30tps.
expect(lockUnit(lock, c)!.id).toBe(3);
expect(remainingSeconds(lock, lockUnit(lock, c), 600)).toBeCloseTo(10, 5);
expect(lockChipText(lock, c, 600, COPY.stasisHeld)).toBe('8C4F119E · 10S');
});
it('says HELD rather than inventing a number it cannot know yet', () => {
const l = createStasisLocks();
l.record(ev(true, 500));
const lock = l.active()[0];
expect(remainingSeconds(lock, undefined, 600)).toBeNull();
expect(lockChipText(lock, correction(), 500, COPY.stasisHeld)).toBe('8C4F119E · HELD');
});
it('tells the player to wait, and that release is coming', () => {
const locked = eventToast(ev(true), () => 'UNIT')!;
expect(locked).toBe('SECTOR LOCKED. HASH 8C4F119E. AWAIT RELEASE.');
expect(eventToast(ev(false), () => 'UNIT')).toBe(COPY.stasisReleased);
});
it('teaches abstention in one line, which is the whole counter-play', () => {
expect(COPY.auditorPrescan).toBe('AN AUDITOR IS READING. STOP MOVING.');
});
it('puts a countdown chip in the top strip for every open lock', () => {
document.body.innerHTML = '';
const top = createTopStrip(DATA);
document.body.append(top.el);
const l = createStasisLocks();
top.update(snap(), createCorrections(), l);
expect((document.querySelector('.fk-locks') as HTMLElement).style.display).toBe('none');
l.record(ev(true, 500));
top.update(snap({ tick: 600, correction: correction({
debt: 0.4, band: 2,
units: [{ id: 3, kind: 'hash-auditor', pos: { x: 0, y: 0 }, phase: 'stasis', progress: 0.5, rect }],
}) }), createCorrections(), l);
const chips = Array.from(document.querySelectorAll('.fk-lock')).map((c) => c.textContent);
expect(chips).toEqual(['8C4F119E · 4S']);
l.record(ev(false, 700));
top.update(snap({ tick: 700 }), createCorrections(), l);
expect((document.querySelector('.fk-locks') as HTMLElement).style.display).toBe('none');
});
});
// =================================================================== mirrored ticker
describe('the MIRRORED row', () => {
const mirrored = (item: string): SimEvent =>
({ kind: 'mirrored', item, pos: { x: 0, y: 0 }, tick: 1 });
it('counts mirrors separately from mite edits', () => {
const c = createCorrections();
c.record(mirrored('melt'));
c.record(mirrored('melt'));
c.record({ kind: 'repaired', item: 'melt', pos: { x: 0, y: 0 }, tick: 1 });
expect(c.mirroredTotal()).toBe(2);
expect(c.total()).toBe(1); // repairs are still repairs
expect(c.mirroredByItem()).toEqual([{ item: 'melt', count: 2 }]);
expect(c.catchesOn({ x: 0, y: 0 }, { x: 2, y: 2 })).toBe(1); // mirrors aren't catches
});
it('appears in the ticker on the first mirror, with the edits row still hidden', () => {
document.body.innerHTML = '';
const top = createTopStrip(DATA);
document.body.append(top.el);
const c = createCorrections();
top.update(snap(), c);
expect((document.querySelector('.fk-corrected-wrap') as HTMLElement).style.display)
.toBe('none');
c.record(mirrored('melt'));
top.update(snap(), c);
expect((document.querySelector('.fk-corrected-wrap') as HTMLElement).style.display)
.not.toBe('none');
expect((document.querySelector('.fk-corrected-row') as HTMLElement).style.display)
.toBe('none');
expect(document.querySelector('.fk-mirrored-v')!.textContent).toBe('1');
expect(document.querySelector('.fk-mirrored-row .fk-chip')!.textContent).toContain('MELT');
});
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.');
});
});
// ======================================================================= band toasts
describe('debt band changes', () => {
it('has one line per band, and none of them shout', () => {
for (let b = 0; b <= 4; b++) {
const msg = eventToast({ kind: 'debtBand', band: b as 0, tick: 1 }, () => 'UNIT')!;
expect(msg).toBeTruthy();
expect(msg).not.toContain('!');
expect(msg).toBe(msg.toUpperCase());
}
expect(eventToast({ kind: 'debtBand', band: 4, tick: 1 }, () => 'UNIT'))
.toContain('THIS IS NOT A PUNISHMENT');
});
it('does not leak a grid reference in any Correction line', () => {
const lines = [
COPY.sealBody, COPY.sealJam, COPY.sealRemoved, COPY.auditorPrescan,
COPY.stasisReleased, COPY.faxComplianceNote,
...[0, 1, 2, 3, 4].map((b) => COPY.debtBandToast(b as 0)),
];
for (const l of lines) expect(l).not.toMatch(/\(-?\d+\s*,\s*-?\d+\)/);
});
});

View File

@ -19,12 +19,17 @@ interface Repair {
}
export interface Corrections {
/** Record one `repaired` event. Ignores anything else, so callers can pass it raw. */
/** Record one `repaired` or (v9) `mirrored` event. Ignores anything else, so callers
* can pass it raw. */
record(ev: SimEvent): void;
/** How many edits total this session. 0 before the first mite ever appears. */
/** Mite edits this session. 0 before the first mite ever appears. */
total(): number;
/** Per-item totals, newest-touched first, for the ticker chip row. */
/** Per-item edit totals, first-seen order, for the ticker chip row. */
byItem(): { item: string; count: number }[];
/** v9: shipments a gunship got to the sky ledger first. A different insult see the
* round-7 note in NOTES on why this is a second row, not the same counter. */
mirroredTotal(): number;
mirroredByItem(): { item: string; count: number }[];
/** Edits that landed on the given footprint rect — the inspector's per-unit "catches". */
catchesOn(pos: Vec2, footprint: Vec2): number;
}
@ -33,15 +38,24 @@ export function createCorrections(): Corrections {
const repairs: Repair[] = [];
// Insertion-ordered per-item counts (Map preserves first-seen order).
const perItem = new Map<string, number>();
let mirrors = 0;
const perMirroredItem = new Map<string, number>();
return {
record(ev) {
if (ev.kind === 'mirrored') {
mirrors++;
perMirroredItem.set(ev.item, (perMirroredItem.get(ev.item) ?? 0) + 1);
return;
}
if (ev.kind !== 'repaired') return;
repairs.push({ x: ev.pos.x, y: ev.pos.y, item: ev.item });
perItem.set(ev.item, (perItem.get(ev.item) ?? 0) + 1);
},
total: () => repairs.length,
byItem: () => [...perItem].map(([item, count]) => ({ item, count })),
mirroredTotal: () => mirrors,
mirroredByItem: () => [...perMirroredItem].map(([item, count]) => ({ item, count })),
catchesOn(pos, footprint) {
let n = 0;
for (const r of repairs) {

59
fktry/src/ui/debtgauge.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* LANE-UI the PARITY DEBT gauge (round 7). Lives in the top strip.
*
* Five labelled bands, clinical white-green throughout. It never goes red and it never
* flashes: The Correction does not do alarm the player's own systems do (that's what
* BROWNOUT is for, three rows up). The dread is that this thing stays polite while it
* climbs.
*
* Hidden until the debt first moves; visible forever after, so venting it back to
* NOMINAL is something you get to watch.
*/
import type { CorrectionState } from '../contracts';
import { cls, el, style, text } from './dom';
import { DEBT_BANDS, gaugeSpec } from './parity';
import { COPY } from './voice';
export interface DebtGauge {
el: HTMLElement;
update(correction: CorrectionState | undefined): void;
/** Has the debt ever moved this session? (The visibility latch, for tests.) */
isRevealed(): boolean;
}
export function createDebtGauge(): DebtGauge {
const head = el('div', 'fk-h', [COPY.parityDebt]);
const fill = el('div', 'fk-debt-fill');
const bar = el('div', 'fk-debt-bar', [fill]);
const cells = DEBT_BANDS.map((label) => el('span', 'fk-debt-band', [label]));
const bands = el('div', 'fk-debt-bands', cells);
const state = el('div', 'fk-debt-state');
const root = el('div', 'fk-debt', [head, bar, bands, state]);
style(root, 'display', 'none');
let latched = false;
return {
el: root,
isRevealed: () => latched,
update(correction) {
const g = gaugeSpec(correction, latched);
if (g.visible) latched = true;
style(root, 'display', g.visible ? '' : 'none');
if (!g.visible) return;
style(fill, 'width', `${g.pct}%`);
text(state, g.label);
for (let i = 0; i < cells.length; i++) {
cls(cells[i], 'is-on', i === g.band);
cls(cells[i], 'is-past', i < g.band);
}
// The last band is the one where the file stops being processed. It gets weight,
// not colour — a heavier outline, still clinic white.
cls(root, 'is-refused', g.band === 4);
},
};
}

View File

@ -53,10 +53,18 @@ export function createFax(data: GameData): Fax {
]);
const standing = el('div', 'fk-fax-standing', [el('span', undefined, [COPY.faxStanding])]);
// COMPLIANCE (v9): this fax is a Correction quota, and filling it vents PARITY DEBT.
// Its own stamp, in clinic white — a quota is not a commission from a collector, and
// the fax should not let you confuse the two.
const compliance = el('div', 'fk-fax-compliance',
[el('span', undefined, [COPY.faxCompliance])]);
const complianceNote = el('div', 'fk-fax-compliance-note', [COPY.faxComplianceNote]);
const stampEl = el('div', 'fk-fax-stamp', [el('span', undefined, [COPY.faxStamp])]);
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward,
sanWrap, nextWrap, standing, stampEl);
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward, complianceNote,
sanWrap, nextWrap, standing, compliance, stampEl);
let stampTimer: number | undefined;
@ -89,6 +97,11 @@ export function createFax(data: GameData): Fax {
cls(standing, 'is-on', c.repeat === true);
const quota = c.compliance === true;
cls(root, 'is-compliance', quota);
cls(compliance, 'is-on', quota);
cls(complianceNote, 'is-on', quota);
const sanitised = snap.shippedTotal[SANITISED_ITEM] ?? 0;
cls(sanWrap, 'is-on', sanitised > 0);
if (sanitised > 0) {

View File

@ -13,9 +13,11 @@ import { createCorrections } from './corrections';
import { createFax } from './fax';
import { createHotkeys } from './hotkeys';
import { createInspector } from './inspector';
import { createNotices } from './notices';
import { createRoster, entityAt, syncRoster } from './pick';
import { createScreenView } from './screenview';
import { createSelection } from './selection';
import { createStasisLocks } from './stasis';
import { injectStyle } from './style';
import { createTechPanel } from './techpanel';
import { createTopStrip } from './topstrip';
@ -34,11 +36,14 @@ export function createUI(): UI {
let tech: ReturnType<typeof createTechPanel>;
let tuner: ReturnType<typeof createTuner>;
let screenView: ReturnType<typeof createScreenView>;
let notices: ReturnType<typeof createNotices>;
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster();
/** Session tally of The Correction's edits — feeds the ticker + firewall log (round 6). */
const corrections = createCorrections();
/** Open hash-auditor sector locks (v9) — the countdown chips in the top strip. */
const locks = createStasisLocks();
let paused = false;
// ---- onboarding + ceremony state (per session; none of it is sim truth) ----
@ -46,6 +51,10 @@ export function createUI(): UI {
let mercyShown = false;
/** Dust piles we've already warned about, so a pile isn't a recurring nag. */
const warnedPiles = new Set<number>();
/** Auditors we've already told the player to stand still for. One warning per unit:
* the prescan is the only window where abstaining changes the outcome, and a second
* line about it would read as nagging rather than as the rule it is teaching. */
const warnedAuditors = new Set<number>();
function revealTuner() {
tuner.reveal();
@ -79,6 +88,7 @@ export function createUI(): UI {
tech = createTechPanel(data, bus);
tuner = createTuner();
screenView = createScreenView(document.getElementById('screen'));
notices = createNotices();
// A held tool and an open inspector are mutually exclusive: picking a machine or
// arming remove supersedes the inspect selection, so shut the panel to match.
@ -87,7 +97,7 @@ export function createUI(): UI {
});
root.append(
top.el, build.el, inspector.el, fax.el, tech.el,
top.el, build.el, inspector.el, fax.el, notices.el, tech.el,
tuner.el, screenView.el, toasts.el,
);
@ -160,20 +170,45 @@ export function createUI(): UI {
paused = snap.paused;
syncRoster(roster, snap);
top.update(snap, corrections);
top.update(snap, corrections, locks);
build.update(snap); // padlocks track research
inspector.update(snap, corrections);
fax.update(snap);
tech.update(snap);
// THE ABSTENTION TEACH (v9). A hash auditor in `prescan` is reading the sector;
// deviation during the read is what makes it refuse. This one line is the whole
// counter-play, delivered in the only window where it can still be acted on — and
// it is a snapshot state, not an event, because the read is a phase you can be
// caught inside of, not a moment you can miss.
for (const u of snap.correction?.units ?? []) {
if (u.kind !== 'hash-auditor' || u.phase !== 'prescan') continue;
if (warnedAuditors.has(u.id)) continue;
warnedAuditors.add(u.id);
toasts.pushMessage(COPY.auditorPrescan, 'stasis');
}
// An auditor that has left can warn us again next time it comes back.
if (snap.correction) {
const here = new Set(snap.correction.units.map((u) => u.id));
for (const id of warnedAuditors) if (!here.has(id)) warnedAuditors.delete(id);
}
// The fossil may already be dug up in a loaded save, with no event to announce it.
if (!tuner.isRevealed() && snap.relics?.some((r) => r.found)) revealTuner();
for (const ev of events) {
if (ev.kind === 'commissionDone') fax.stamp();
// v8: The Correction edited an artifact — tally it for the ticker + firewall log.
if (ev.kind === 'repaired') corrections.record(ev);
// v8/v9: The Correction edited an artifact, or mirrored a shipment out from
// under us — both feed the ticker (repairs also feed the firewall log).
if (ev.kind === 'repaired' || ev.kind === 'mirrored') corrections.record(ev);
// v9: notices are paper. They go in the tray, and deliberately not in a toast.
if (ev.kind === 'notice') notices.push(ev);
// v9: a sector lock opens or closes. The chip is driven by this, the toast by
// the usual event path below.
if (ev.kind === 'stasis') locks.record(ev);
if (ev.kind === 'relicFound') revealTuner(); // ceremony toast comes from voice.ts
@ -194,7 +229,9 @@ export function createUI(): UI {
toasts.pushMessage(COPY.firstBrownoutMercy, 'mercy');
}
}
toasts.update(performance.now());
const now = performance.now();
toasts.update(now);
notices.update(now);
},
};
}

View File

@ -14,6 +14,7 @@ import { intakeChips, outputChips } from './chipspec';
import type { Corrections } from './corrections';
import { attrs, cls, el, panel, style, text } from './dom';
import { footprintOf } from './pick';
import { createPryTracker } from './seal';
import { COPY, jamText, machineFlavor } from './voice';
export interface Inspector {
@ -88,6 +89,19 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
const firewallLine = el('div', 'fk-ins-firewall-line');
firewall.append(el('div', 'fk-h', [COPY.firewallHeader]), firewallLine);
// COMPLIANCE SEAL (v9): a checksum warden has stamped this unit and it will not run
// until the seal comes off. Clinic white like everything The Correction touches — the
// panel is polite, the PRY button is the only hot thing on it.
const sealBody = el('div', 'fk-ins-seal-body', [COPY.sealBody]);
const pryBtn = el('button', 'fk-ins-pry', [COPY.sealPry]);
attrs(pryBtn, { type: 'button', title: COPY.sealPryHint });
const pryFill = el('div', 'fk-bar-f');
const pryBar = el('div', 'fk-bar fk-ins-pry-bar', [pryFill]);
const sealHint = el('div', 'fk-ins-seal-hint', [COPY.sealPryHint]);
const seal = el('div', 'fk-ins-seal', [
el('div', 'fk-h', [COPY.sealHeader]), sealBody, pryBtn, pryBar, sealHint,
]);
const body = el('div', undefined, [
recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el,
@ -95,14 +109,65 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
heatHead, heatBar, heatState,
]);
root.append(head, flavor, el('div', 'fk-rule'), body, exposure, firewall, jam);
root.append(head, flavor, el('div', 'fk-rule'), body, seal, exposure, firewall, jam);
let openId: number | null = null;
/** Sim tick of the last update — the pry bar runs on ticks, never a wall clock. */
let lastTick = 0;
/** The entity painted this frame, so the click handler reads live truth rather than a
* copy held across frames (CONTRACTS: snapshot buffers may be reused). */
let shown: EntityState | null = null;
const pry = createPryTracker();
pryBtn.addEventListener('click', () => {
if (openId === null) return;
const e = shown;
if (!e || !e.sealed || pry.isPrying(openId)) return;
// `pry` takes a position, not an entity — the seal is on the notice, not the id.
bus.dispatch({ kind: 'pry', pos: { x: e.pos.x, y: e.pos.y } });
pry.begin(openId, lastTick);
paintSeal(e);
});
function paintSeal(e: EntityState) {
const sealed = e.sealed === true;
cls(seal, 'is-on', sealed);
if (!sealed) {
// Put the crowbar back exactly as we found it. Without this the button stays
// disabled after a successful pry and a re-sealed unit can never be freed twice.
pry.end(e.id);
text(pryBtn, COPY.sealPry);
cls(pryBtn, 'is-working', false);
(pryBtn as HTMLButtonElement).disabled = false;
cls(pryBar, 'is-on', false);
cls(pryBar, 'is-indeterminate', false);
style(pryFill, 'width', '0%');
return;
}
// SIM publishes the pry in `EntityState.progress` while a unit is sealed (it reuses
// the craft-progress field — index.ts: `e.state.progress = e.pryTimer / PRY_TICKS`).
// That is the truth and it survives a reload, so it wins; the local tracker only
// covers the frame between the click and the sim taking the command, and any sim
// that doesn't publish it.
const simPry = e.progress > 0 ? Math.min(1, e.progress) : null;
const prying = simPry !== null || pry.isPrying(e.id);
text(pryBtn, prying ? COPY.sealPrying : COPY.sealPry);
cls(pryBtn, 'is-working', prying);
(pryBtn as HTMLButtonElement).disabled = prying;
// Progress is null only when nobody knows the duration — then the bar is honestly
// indeterminate rather than inventing an ETA. See seal.ts.
const p = simPry ?? (pry.isPrying(e.id) ? pry.progress(e.id, lastTick) : 0);
cls(pryBar, 'is-on', prying);
cls(pryBar, 'is-indeterminate', prying && p === null);
style(pryFill, 'width', p === null ? '100%' : `${p * 100}%`);
}
function close() {
if (openId === null) return; // already shut — don't re-notify selection
openId = null;
optionsFor = null;
shown = null;
cls(root, 'is-open', false);
onClose?.();
}
@ -137,6 +202,7 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
}
function paint(e: EntityState, corrections?: Corrections) {
shown = e;
const def = machines.get(e.def);
text(name, def?.name ?? e.def.toUpperCase());
text(flavor, machineFlavor(def, e.def));
@ -160,12 +226,16 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
const recipe = e.recipe ? recipes.get(e.recipe) : null;
const hasProcess = !!recipe;
// A sealed unit's `progress` is the PRY, not the craft (SIM reuses the field), so the
// PROCESS bar stands down while the seal is on — otherwise it reads as "60% through a
// batch" when no batch is happening. The buffers stay visible: what is stuck inside a
// sealed machine is exactly what you want to know about it.
const crafting = hasProcess && e.sealed !== true;
// "No process" only reads as a fault on a machine that could have one.
style(noRecipe, 'display', !hasProcess && canChoose ? 'block' : 'none');
for (const n of [outHead, outChips.el, progHead, progBar]) {
style(n, 'display', hasProcess ? '' : 'none');
}
for (const n of [outHead, outChips.el]) style(n, 'display', hasProcess ? '' : 'none');
for (const n of [progHead, progBar]) style(n, 'display', crafting ? '' : 'none');
// Extractors take nothing from the world but the world itself — no INTAKE header
// for a machine that has no intake.
@ -175,7 +245,7 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
if (recipe) {
if (hasInputs) inChips.update(intakeChips(recipe, e));
outChips.update(outputChips(recipe, e));
style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`);
if (crafting) style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`);
}
// Heat: a bar rather than a number, because it's a thing you watch climb.
@ -196,9 +266,16 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
cls(heatState, 'is-scram', scram);
}
const jammed = !!e.jammed;
paintSeal(e);
// A sealed unit has stopped for a reason the sim may not phrase as a jam, so the
// seal owns the jam slot while it is on: it IS why nothing is happening. Clinic
// white rather than the usual amber — this fault was issued, not suffered.
const sealed = e.sealed === true;
const jammed = sealed || !!e.jammed;
cls(jam, 'is-on', jammed);
if (jammed) text(jam, jamText(e.jammed!));
cls(jam, 'is-sealed', sealed);
if (jammed) text(jam, sealed ? COPY.sealJam : jamText(e.jammed!));
}
return {
@ -211,6 +288,8 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
isOpen: () => openId !== null,
openId: () => openId,
update(snap, corrections) {
lastTick = snap.tick;
pry.sweep(snap.tick); // a pry the sim never took shouldn't hold the button forever
if (openId === null) return;
const e = snap.entities.find((x) => x.id === openId);
if (!e) return close(); // unit was removed out from under us

View File

@ -18,7 +18,7 @@ import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { RESEARCH_TICKS_PER_PACK } from '../sim/constants';
import { referenceFactory } from '../sim/reference';
import { firewallInData, referenceFactory } from '../sim/reference';
import { grantAllResearch, grantResearch } from '../sim/testkit';
import { createUI } from './index';
@ -473,3 +473,193 @@ describe('remove mode against the real sim', () => {
expect(h.sim.snapshot().entities.length).toBe(before);
});
});
// ============================================================ THE CORRECTION (round 7)
//
// The DoD: a full warden cycle and a full auditor cycle narratable from the HUD alone.
// These run the REAL Correction — SIM's wardens walk in from the rim, audit a real
// machine, stamp a real seal; the auditor really reads the sector and really locks it.
// Nothing here is a fixture except the starting debt, which is set through the public
// save/load API the same way testkit grants research (SIM has no correction verb yet —
// see NOTES). Everything after that is the sim's own escalation.
/** Charge the ledger to `debt` and let the sim re-band it on the next tick. */
function chargeDebt(sim: Sim, debt: number, resetBand = true) {
const blob = JSON.parse(sim.save());
blob.debt = debt;
if (resetBand) blob.band = 0; // clear the latch so the crossing emits a real edge
sim.load(JSON.stringify(blob));
}
describe('THE CORRECTION against the real sim', () => {
/** Step until `pred` holds, checking after every single tick. Returns ticks used. */
function until(h: { step(n?: number): void }, pred: () => boolean, max: number): number {
for (let i = 1; i <= max; i++) {
h.step(1);
if (pred()) return i;
}
return -1;
}
const sawToast = (re: RegExp) => toasts().some((t) => !!t && re.test(t));
const units = (h: { sim: Sim }) => h.sim.snapshot().correction?.units ?? [];
const phaseOf = (h: { sim: Sim }, kind: string) =>
units(h).find((u) => u.kind === kind)?.phase;
it('keeps the PARITY DEBT gauge dark until the ledger first moves', () => {
const h = harness({ unlocked: true });
h.step(2);
expect(h.sim.snapshot().correction!.debt).toBe(0);
expect($('.fk-debt').style.display).toBe('none');
});
it('reveals the gauge on the real debt and names the band the sim is in', () => {
const h = harness({ unlocked: true });
h.step(200);
chargeDebt(h.sim, 0.85);
h.step(3);
const snap = h.sim.snapshot();
expect(snap.correction!.band).toBe(4);
expect($('.fk-debt').style.display).not.toBe('none');
expect($('.fk-debt-state').textContent).toBe('REFUSED');
expect($('.fk-debt.is-refused')).toBeTruthy();
// ...and the gauge is still not an alarm.
expect($('.fk-debt').outerHTML).not.toMatch(/fk-red|is-brownout/);
expect(sawToast(/THE BITSTREAM DECLINES TO CONTINUE/)).toBe(true);
});
it('faxes the notice before anything walks in, and the fax gives nothing away', () => {
const h = harness({ unlocked: true });
h.step(1500); // let real deviation accumulate so there is something to audit
chargeDebt(h.sim, 0.5); // band 2 — wardens only
const t = until(h, () => units(h).length > 0, 200);
expect(t, 'a warden should be dispatched at band 2').toBeGreaterThan(0);
// The notice is on paper the same tick the unit is dispatched (contracts v9).
expect($('#fk-notices').classList.contains('is-on')).toBe(true);
const slip = $('.fk-notice-body').textContent!;
expect(slip).toBe('NOTICE OF SCHEDULED AUDIT. NO ACTION REQUIRED.');
expect(slip).not.toMatch(/WARDEN|\d/); // no unit, no coordinates
expect(phaseOf(h, 'checksum-warden')).toBe('notice');
expect(sawToast(/NOTICE OF SCHEDULED AUDIT/)).toBe(false); // paper only, never a toast
});
it('narrates a whole warden cycle: notice → audit → seal → PRY → noted', () => {
const h = harness({ unlocked: true });
h.step(1500);
// Tear the firewalls out first. Not a convenience: at HEAD a firewall cans any warden
// that walks inside FIREWALL_RADIUS, and the reference factory is ringed with them, so
// a warden reaches its target roughly one run in ten (traced — see NOTES). Removing
// them is the same command a player has, and it makes the cycle deterministic instead
// of leaving the DoD to a coin flip.
const fw = firewallInData(DATA);
if (fw) {
for (const e of h.sim.snapshot().entities.filter((x) => x.def === fw.machine)) {
h.sim.enqueue({ kind: 'remove', pos: e.pos });
}
h.step(2);
}
chargeDebt(h.sim, 0.6);
// ---- The walk and the audit are the sim's; we only have to survive them.
const sealedAt = until(
h, () => h.sim.snapshot().entities.some((e) => e.sealed === true), 12000);
expect(sealedAt, 'a warden should reach a machine and stamp it').toBeGreaterThan(0);
const victim = h.sim.snapshot().entities.find((e) => e.sealed === true)!;
expect(sawToast(/HAS BEEN BROUGHT INTO COMPLIANCE/)).toBe(true);
// ---- the seal is readable from the inspector alone
h.clickTile(victim.pos);
h.step(1);
expect($('#fk-inspect').classList.contains('is-open')).toBe(true);
expect(on('.fk-ins-seal')).toBe(true);
expect($('.fk-ins-seal-body').textContent)
.toBe('THIS UNIT HAS BEEN BROUGHT INTO COMPLIANCE.');
expect(on('.fk-ins-jam')).toBe(true);
expect($('.fk-ins-jam').classList.contains('is-sealed')).toBe(true);
expect($('.fk-ins-jam').textContent).toBe('UNIT SEALED. IT IS COMPLIANT AND IDLE.');
// ---- PRY, and watch the sim's own progress drive the bar
const debtBefore = h.sim.snapshot().correction!.debt;
($('.fk-ins-pry') as HTMLButtonElement).click();
h.step(2);
expect($('.fk-ins-pry').textContent).toBe('PRYING');
expect(on('.fk-ins-pry-bar')).toBe(true);
expect($('.fk-ins-pry-bar').classList.contains('is-indeterminate')).toBe(false);
const width = parseFloat(($('.fk-bar-f') as HTMLElement).style.width);
expect(Number.isNaN(width)).toBe(false);
const freedAt = until(
h, () => h.sim.snapshot().entities.find((e) => e.id === victim.id)?.sealed !== true, 400);
expect(freedAt, 'one pry command should be enough — no spamming').toBeGreaterThan(0);
expect(sawToast(/SEAL REMOVED\. THIS WILL BE NOTED\./)).toBe(true);
expect(on('.fk-ins-seal')).toBe(false);
// Defacing a notice is itself deviation: the ledger went UP for freeing your machine.
expect(h.sim.snapshot().correction!.debt).toBeGreaterThan(debtBefore);
});
it('teaches abstention while it can still be acted on, before any lock exists', () => {
const h = harness({ unlocked: true });
h.step(1500);
chargeDebt(h.sim, 0.85); // band 4 — the auditor is dispatched
const spawned = until(h, () => !!phaseOf(h, 'hash-auditor'), 300);
expect(spawned, 'an auditor should be dispatched at band 4').toBeGreaterThan(0);
// Step to the PRESCAN edge and catch the teach in the window where it means something.
const reading = until(h, () => phaseOf(h, 'hash-auditor') === 'prescan', 1200);
expect(reading, 'the auditor should start reading after its notice window')
.toBeGreaterThan(0);
h.step(1);
expect(sawToast(/AN AUDITOR IS READING\. STOP MOVING\./)).toBe(true);
// The teach must arrive BEFORE the lock, or it teaches nothing.
expect($('.fk-locks').style.display).toBe('none');
expect(phaseOf(h, 'hash-auditor')).toBe('prescan');
});
it('narrates a whole auditor cycle: reading → locked by hash → released on its own', () => {
const h = harness({ unlocked: true });
h.step(1500);
chargeDebt(h.sim, 0.85);
const locked = until(h, () => phaseOf(h, 'hash-auditor') === 'stasis', 3000);
expect(locked, 'a factory that keeps crafting through a prescan gets locked')
.toBeGreaterThan(0);
h.step(1);
// The chip names the lock, and the toast names the same hash.
expect($('.fk-locks').style.display).not.toBe('none');
const chip = $('.fk-lock').textContent!;
const hash = chip.split(' · ')[0];
expect(hash).toMatch(/^[0-9A-F]{8}$/);
expect(sawToast(new RegExp(`SECTOR LOCKED\\. HASH ${hash}\\. AWAIT RELEASE\\.`))).toBe(true);
// The countdown becomes a real number once the phase has revealed its length.
const counted = until(h, () => /· \d+S$/.test($('.fk-lock')?.textContent ?? ''), 120);
expect(counted, 'the chip should count down, not just say HELD').toBeGreaterThan(0);
// Auto-release is contractual: the player does nothing and the lock still lifts.
const released = until(h, () => $('.fk-locks').style.display === 'none', 900);
expect(released, 'stasis ALWAYS releases on its own (contracts v9)').toBeGreaterThan(0);
expect(sawToast(/SECTOR RELEASED/)).toBe(true);
});
it('tickers a real mirrored shipment separately from mite edits', () => {
const h = harness({ unlocked: true });
h.step(1500);
chargeDebt(h.sim, 0.85); // band 3+ puts a gunship on a sector
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);
});
});

79
fktry/src/ui/notices.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* LANE-UI the notice tray (round 7). Notices are faxes.
*
* Contracts v9: `notice` ALWAYS precedes action the fax comes first, never a silent
* raid. So it arrives as paper, on The Correction's own letterhead, above the commission
* fax. It deliberately does NOT toast: a notice you can read twice in two places is two
* documents, and this faction only ever sends one.
*
* RULING (round-7 orders), enforced by voice.ts and pinned by test: a notice carries no
* grid reference and no unit name. It cannot perceive you; it will not tell you where to
* stand or what is coming. All it says is that a procedure exists.
*
* Wall-clock TTL is fine here like toasts, this is chrome and nothing feeds the sim.
*/
import type { SimEvent } from '../contracts';
import { cls, el, panel } from './dom';
import { noticeText, COPY } from './voice';
export interface Notices {
el: HTMLElement;
/** Feed it raw events; anything that isn't a `notice` is ignored. */
push(ev: SimEvent, nowMs?: number): void;
/** Retire expired slips. Called once a frame. */
update(nowMs: number): void;
/** Slips currently on paper (for tests). */
count(): number;
}
/** Paper lingers a lot longer than a toast — you are supposed to be able to read it. */
const TTL_MS = 20000;
const FADE_MS = 400; // must match .fk-notice.is-out
const MAX = 3;
export function createNotices(): Notices {
const root = panel();
root.id = 'fk-notices';
const slips = el('div', 'fk-notice-slips');
root.append(
el('div', 'fk-h fk-notice-h', [COPY.noticeHeader]),
el('div', 'fk-notice-sub', [COPY.noticeSub]),
slips,
);
const live: Array<{ node: HTMLElement; dieAt: number }> = [];
function retire(entry: { node: HTMLElement; dieAt: number }) {
cls(entry.node, 'is-out', true);
setTimeout(() => entry.node.remove(), FADE_MS);
}
function sync() {
cls(root, 'is-on', live.length > 0);
}
return {
el: root,
push(ev, nowMs = performance.now()) {
if (ev.kind !== 'notice') return;
const node = el('div', 'fk-notice', [
el('div', 'fk-notice-body', [noticeText(ev.unit)]),
el('div', 'fk-notice-foot', [COPY.noticeFooter]),
]);
slips.append(node);
live.push({ node, dieAt: nowMs + TTL_MS });
while (live.length > MAX) retire(live.shift()!);
sync();
},
update(nowMs) {
let changed = false;
while (live.length && live[0].dieAt <= nowMs) {
retire(live.shift()!);
changed = true;
}
if (changed) sync();
},
count: () => live.length,
};
}

59
fktry/src/ui/parity.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* LANE-UI PARITY DEBT, the arithmetic (round 7).
*
* `snapshot.correction` (contracts v9) carries the debt 0..1 and the band 0..4 that
* gates The Correction's spawns. The sim owns both; this module only decides what the
* gauge should say, so the decision is testable without a DOM.
*
* Two rules the orders are explicit about, encoded here rather than in the widget:
* - the gauge is HIDDEN until debt first moves, so a calm factory is never told there
* is an enemy;
* - once it has moved it STAYS visible, including all the way back down to NOMINAL
* watching the debt vent is the reward for filling a compliance quota.
*/
import type { CorrectionState } from '../contracts';
import { COPY } from './voice';
export type DebtBand = 0 | 1 | 2 | 3 | 4;
/** NOMINAL / OBSERVED / AUDITED / SEALED / REFUSED — index is the band. */
export const DEBT_BANDS = COPY.debtBands;
export interface GaugeSpec {
/** Draw the gauge at all? False until the debt has ever moved. */
visible: boolean;
band: DebtBand;
label: string;
/** Debt as a percentage, clamped — the fill width. */
pct: number;
}
/**
* Fallback only: a band for a debt value, in equal fifths. The sim's `band` wins
* whenever it is present (it owns the real thresholds, and they are tuning, not UI).
* This exists so a partial CorrectionState from a mid-flight sim can't blank the gauge.
*/
export function bandFromDebt(debt: number): DebtBand {
const d = Math.max(0, Math.min(1, debt));
return Math.min(4, Math.floor(d * 5)) as DebtBand;
}
export function bandLabel(band: DebtBand): string {
return DEBT_BANDS[band] ?? DEBT_BANDS[0];
}
/**
* `latched` is the caller's memory of "debt has moved at some point this session"
* the widget keeps it, so this stays a pure function of (state, memory).
*/
export function gaugeSpec(c: CorrectionState | undefined, latched = false): GaugeSpec {
if (!c) return { visible: latched, band: 0, label: bandLabel(0), pct: 0 };
const debt = Number.isFinite(c.debt) ? Math.max(0, Math.min(1, c.debt)) : 0;
const band = (typeof c.band === 'number' ? c.band : bandFromDebt(debt)) as DebtBand;
return {
visible: latched || debt > 0 || band > 0,
band,
label: bandLabel(band),
pct: debt * 100,
};
}

70
fktry/src/ui/seal.ts Normal file
View File

@ -0,0 +1,70 @@
/**
* LANE-UI prying a compliance seal (round 7).
*
* Contracts v9 gives us `EntityState.sealed` (a boolean), the `pry` command and the
* `sealed` event but no pry progress anywhere in the snapshot. So the UI tracks the
* pry itself, in SIM TICKS rather than wall clock, so a paused game doesn't advance a
* bar that isn't moving.
*
* Duration: we don't guess one. If LANE-SIM exports a pry-length constant we read it
* (src/sim/constants.ts is a sanctioned narrow import CONTRACTS v6) and show a real
* bar; if not, `progress()` returns null and the widget shows an indeterminate one. A
* bar that invents an ETA is worse than a bar that admits it doesn't know.
* See NOTES: CONTRACT REQUEST for a `pryProgress` on EntityState.
*/
import * as SIM_CONSTANTS from '../sim/constants';
/** Names LANE-SIM might land the pry duration under. First numeric hit wins. */
const PRY_TICK_KEYS = [
'PRY_TICKS', 'SEAL_PRY_TICKS', 'PRY_DURATION_TICKS', 'WARDEN_PRY_TICKS', 'SEAL_TICKS',
];
export function resolvePryTicks(
mod: Record<string, unknown> = SIM_CONSTANTS as unknown as Record<string, unknown>,
): number | null {
for (const k of PRY_TICK_KEYS) {
const v = mod[k];
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
}
return null;
}
/** How long we keep showing PRYING with no `sealed` release before giving the button
* back. Only a safety net for a dropped command the sim normally ends this. */
const STALE_FACTOR = 4;
const STALE_FALLBACK_TICKS = 900; // 30s at 30tps, if no duration is known at all
export interface PryTracker {
begin(entity: number, tick: number): void;
end(entity: number): void;
isPrying(entity: number): boolean;
/** 0..1 when the duration is known, null when it is indeterminate. */
progress(entity: number, nowTick: number): number | null;
/** Drop pries that should have finished long ago (a command the sim never took). */
sweep(nowTick: number): void;
}
export function createPryTracker(pryTicks: number | null = resolvePryTicks()): PryTracker {
const started = new Map<number, number>();
const staleAt = (pryTicks ?? STALE_FALLBACK_TICKS) * STALE_FACTOR;
return {
begin(entity, tick) {
if (!started.has(entity)) started.set(entity, tick);
},
end(entity) {
started.delete(entity);
},
isPrying: (entity) => started.has(entity),
progress(entity, nowTick) {
const t0 = started.get(entity);
if (t0 === undefined || pryTicks === null) return null;
return Math.max(0, Math.min(1, (nowTick - t0) / pryTicks));
},
sweep(nowTick) {
for (const [entity, t0] of started) {
if (nowTick - t0 > staleAt) started.delete(entity);
}
},
};
}

97
fktry/src/ui/stasis.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* LANE-UI sector locks (round 7), the hash auditor's 0:00 stasis.
*
* A `stasis` event (contracts v9) carries a rect, a lockHash and an on/off edge; nothing
* in the snapshot remembers it, so the UI keeps the open locks itself same pattern as
* corrections.ts. The contract guarantees stasis ALWAYS auto-releases, which is why the
* chip is a countdown and the toast says AWAIT RELEASE rather than telling the player to
* do something about it.
*
* The countdown is derived, not invented: the auditor's `CorrectionUnit.progress` is the
* fraction of its current phase, so elapsed/progress gives the phase length and the
* remainder falls out. No tuning constant is imported and none is guessed before the
* first progress tick the chip honestly reads HELD.
*/
import { TICKS_PER_SECOND, type CorrectionState, type CorrectionUnit, type SimEvent } from '../contracts';
export interface Rect { x: number; y: number; w: number; h: number }
export interface StasisLock {
hash: string;
rect: Rect;
/** Tick the lock closed on, from the event — pause-safe, unlike a wall clock. */
sinceTick: number;
}
export interface StasisLocks {
/** Feed it raw events; returns the edge so the caller can toast exactly once. */
record(ev: SimEvent): 'opened' | 'closed' | null;
active(): readonly StasisLock[];
count(): number;
}
export function createStasisLocks(): StasisLocks {
const locks: StasisLock[] = [];
return {
record(ev) {
if (ev.kind !== 'stasis') return null;
const i = locks.findIndex((l) => l.hash === ev.lockHash);
if (ev.on) {
if (i >= 0) return null; // re-announced; not a new lock
locks.push({ hash: ev.lockHash, rect: { ...ev.rect }, sinceTick: ev.tick });
return 'opened';
}
if (i < 0) return null;
locks.splice(i, 1);
return 'closed';
},
active: () => locks,
count: () => locks.length,
};
}
const sameRect = (a: Rect, b: Rect | undefined) =>
!!b && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
/**
* The unit holding a given lock: an auditor in its stasis phase over the same rect. If
* exactly one auditor is in stasis we accept it even when the rects disagree the sim
* may hand back a clipped rect, and one lock with one auditor is unambiguous.
*/
export function lockUnit(
lock: StasisLock,
correction: CorrectionState | undefined,
): CorrectionUnit | undefined {
const inStasis = (correction?.units ?? []).filter((u) => u.phase === 'stasis');
return inStasis.find((u) => sameRect(lock.rect, u.rect))
?? (inStasis.length === 1 ? inStasis[0] : undefined);
}
/**
* 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.
*/
export function remainingSeconds(
lock: StasisLock,
unit: CorrectionUnit | undefined,
nowTick: number,
): number | null {
if (!unit) return null;
const elapsed = nowTick - lock.sinceTick;
const p = unit.progress;
if (elapsed <= 0 || !(p > 0.02) || p >= 1) return null;
return Math.max(0, (elapsed * (1 / p - 1)) / TICKS_PER_SECOND);
}
/** "8C4F119E · 12S" — or "8C4F119E · HELD" before the phase has revealed its length. */
export function lockChipText(
lock: StasisLock,
correction: CorrectionState | undefined,
nowTick: number,
heldWord: string,
): string {
const secs = remainingSeconds(lock, lockUnit(lock, correction), nowTick);
const tail = secs === null ? heldWord : `${Math.ceil(secs)}S`;
return `${lock.hash.toUpperCase()} · ${tail}`;
}

View File

@ -111,11 +111,49 @@ const CSS = `
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
.fk-stat-v { color: var(--fk-cool); }
/* CORRECTED ticker — clinical white like the enemy that made it. Hidden until first edit. */
/* CORRECTED ticker clinical white like the enemy that made it. Hidden until first edit.
v9 adds a second row: MIRRORED, the shipments a gunship beat you to the sky with. */
.fk-corrected-wrap { margin-top: 4px; border-top: 1px solid var(--fk-line); padding-top: 4px; }
.fk-corrected .fk-corrected-v { color: var(--fk-clinic); }
.fk-corrected .fk-corrected-v,
.fk-mirrored .fk-mirrored-v { color: var(--fk-clinic); }
.fk-mirrored-row { margin-top: 2px; }
#fk-run.is-paused { color: var(--fk-amber); }
/* ---------------------------------------------------------------- PARITY DEBT (v9)
The whole gauge is clinic white-green and it NEVER goes red, never flashes. The
Correction does not do alarm BROWNOUT does, four rows up, and the contrast between
the two is the point: your machines panic, your auditor does not. */
.fk-debt { margin-top: 6px; border-top: 1px solid var(--fk-line); padding-top: 5px; }
.fk-debt-bar {
height: 4px; background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
}
.fk-debt-fill {
height: 100%; width: 0%; background: var(--fk-clinic); opacity: 0.85;
transition: width 140ms linear;
}
.fk-debt-bands { display: flex; gap: 2px; margin-top: 4px; }
.fk-debt-band {
flex: 1; text-align: center; font-size: 7px; letter-spacing: 0.06em;
color: var(--fk-faint); border-bottom: 1px solid var(--fk-line); padding-bottom: 2px;
}
.fk-debt-band.is-past { color: var(--fk-dim); border-bottom-color: var(--fk-dim); }
.fk-debt-band.is-on {
color: #0d120e; background: var(--fk-clinic); border-bottom-color: var(--fk-clinic);
}
.fk-debt-state { color: var(--fk-clinic); font-size: 10px; margin-top: 3px; }
/* REFUSED gets weight, not colour. */
.fk-debt.is-refused { border-top-color: var(--fk-clinic); }
.fk-debt.is-refused .fk-debt-state { font-weight: bold; letter-spacing: 0.14em; }
/* Sector locks: one countdown chip per open hash-auditor stasis. The contract
guarantees release, so this reads as a wait, not as a task. */
.fk-locks { margin-top: 5px; }
.fk-lock {
color: var(--fk-clinic); font-size: 10px; border: 1px solid var(--fk-clinic);
padding: 1px 4px; margin-top: 2px; display: inline-block;
}
/* ---------------------------------------------------------------- build bar */
#fk-build {
@ -249,6 +287,8 @@ const CSS = `
}
.fk-ins-jam { display: none; color: var(--fk-amber); font-size: 11px; margin-top: 5px; }
.fk-ins-jam.is-on { display: block; }
/* A sealed unit hasn't failed, it has been processed. Clinic white, not amber. */
.fk-ins-jam.is-sealed { color: var(--fk-clinic); }
.fk-ins-empty { color: var(--fk-faint); font-size: 10px; }
.fk-ins-heat-state { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
@ -264,6 +304,35 @@ const CSS = `
.fk-ins-firewall.is-on { display: block; }
.fk-ins-firewall-line { color: var(--fk-clinic); font-size: 10px; }
/* COMPLIANCE SEAL (v9) the panel is a document: white paper edge, polite copy, and
exactly one hot thing on it, which is the crowbar. */
.fk-ins-seal { display: none; margin-top: 6px; border: 1px solid var(--fk-clinic); padding: 5px 6px; }
.fk-ins-seal.is-on { display: block; }
.fk-ins-seal-body { color: var(--fk-clinic); font-size: 10px; margin-bottom: 5px; }
.fk-ins-pry {
background: none; border: 1px solid var(--fk-hot); color: var(--fk-hot);
font: inherit; font-size: 10px; letter-spacing: 0.16em; padding: 3px 12px;
cursor: pointer; text-transform: uppercase;
}
.fk-ins-pry:hover { background: var(--fk-hot); color: #12080f; }
.fk-ins-pry.is-working, .fk-ins-pry:disabled {
border-color: var(--fk-dim); color: var(--fk-dim); cursor: default; background: none;
}
.fk-ins-pry-bar { display: none; margin-top: 5px; }
.fk-ins-pry-bar.is-on { display: block; }
.fk-ins-pry-bar .fk-bar-f { background: var(--fk-hot); }
/* Indeterminate: SIM exports no pry duration, so the bar paces itself rather than
pretending to know an ETA. */
.fk-ins-pry-bar.is-indeterminate .fk-bar-f {
background: repeating-linear-gradient(
90deg, var(--fk-hot) 0 6px, transparent 6px 12px);
animation: fk-pry 0.7s linear infinite;
}
@keyframes fk-pry { from { background-position: 0 0; } to { background-position: 12px 0; } }
.fk-ins-seal-hint {
color: var(--fk-faint); font-size: 8px; letter-spacing: 0.1em; margin-top: 5px;
}
/* Process select: a native <select> beaten into the house style. */
.fk-ins-recipe {
width: 100%;
@ -370,6 +439,52 @@ const CSS = `
opacity: 0.75;
}
/* COMPLIANCE (v9): a Correction quota, stamped in clinic white so it can never be
mistaken for a collector's commission. Bottom corner a fax can be both a standing
order and a quota, and the two stamps must not sit on top of each other. */
.fk-fax-compliance {
position: absolute; bottom: 6px; right: 6px;
display: none; pointer-events: none;
}
.fk-fax-compliance.is-on { display: block; }
.fk-fax-compliance span {
border: 2px solid var(--fk-clinic); color: var(--fk-clinic);
font-size: 9px; font-weight: bold; letter-spacing: 0.1em;
padding: 1px 4px; transform: rotate(5deg); display: block;
opacity: 0.8;
}
.fk-fax-compliance-note { display: none; color: var(--fk-clinic); font-size: 9px; margin-top: 3px; }
.fk-fax-compliance-note.is-on { display: block; }
#fk-fax.is-compliance { border-color: var(--fk-clinic); }
/* ---------------------------------------------------------------- notice tray (v9)
Notices are faxes, but they are not THAT fax: The Correction's letterhead is white
paper, not the collectors' warm yellow. It sits above the commission fax and holds a
few slips at a time. Nothing here names a unit or a grid square see notices.ts. */
#fk-notices {
display: none;
top: 10px; right: 10px;
width: 260px;
/* The commission fax starts at y=200. Three long slips can just about reach that, so
the tray is capped rather than allowed to grow down over it. */
max-height: 178px;
overflow: hidden;
background: #101312;
border-color: var(--fk-clinic);
z-index: 11;
}
#fk-notices.is-on { display: block; }
.fk-notice-h { color: var(--fk-clinic); }
.fk-notice-sub { color: #7f9c8a; font-size: 9px; letter-spacing: 0.1em; }
.fk-notice {
border-top: 1px solid #24322a; margin-top: 5px; padding-top: 5px;
transition: opacity 400ms linear;
}
.fk-notice.is-out { opacity: 0; }
.fk-notice-body { color: var(--fk-clinic); font-size: 11px; line-height: 1.35; }
.fk-notice-foot { color: #5f7a68; font-size: 8px; letter-spacing: 0.1em; margin-top: 3px; }
.fk-fax-stamp {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
@ -519,6 +634,12 @@ const CSS = `
.fk-toast.k-repaired { border-left-color: var(--fk-clinic); color: var(--fk-clinic); }
/* A mite arriving/leaving is wildlife, but of the hostile kind — amber like a hazard. */
.fk-toast.k-wildlife { border-left-color: var(--fk-amber); color: var(--fk-amber); }
/* v9: everything The Correction says is the same clinic white, whatever it is doing to
you. A seal, a mirror, a locked sector and a band change all read identically calm. */
.fk-toast.k-sealed,
.fk-toast.k-mirrored,
.fk-toast.k-stasis,
.fk-toast.k-debtBand { border-left-color: var(--fk-clinic); color: var(--fk-clinic); }
@keyframes fk-toast-in { from { opacity: 0; transform: translateX(12px); } }
/* ---------------------------------------------------------------- about / credits */

View File

@ -7,15 +7,18 @@
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import type { Corrections } from './corrections';
import { createDebtGauge } from './debtgauge';
import { cls, el, panel, style, text } from './dom';
import { formatReserve, powerState } from './power';
import { lockChipText, type StasisLocks } from './stasis';
import { COPY } from './voice';
export interface TopStrip {
el: HTMLElement;
/** `corrections` is the session tally of The Correction's edits surfaced only once
* the first mite has struck, so it never signposts an enemy that hasn't arrived. */
update(snap: SimSnapshot, corrections?: Corrections): void;
* the first mite has struck, so it never signposts an enemy that hasn't arrived.
* `locks` (v9) are the hash auditor's open sector locks, shown as countdown chips. */
update(snap: SimSnapshot, corrections?: Corrections, locks?: StasisLocks): void;
}
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
@ -55,18 +58,36 @@ export function createTopStrip(data: GameData): TopStrip {
// CORRECTED ticker (round 6): a count + per-item chips of what the mites have "fixed".
// Hidden until the first edit, so pre-Correction play never sees the enemy named.
// Round 7 splits it into two rows under one heading — see the MIRRORED row below.
const corrected = stat(COPY.correctedLabel, 'fk-corrected-v');
cls(corrected.row, 'fk-corrected', true);
const correctedChips: ChipRow = createChipRow(items);
const correctedWrap = el('div', 'fk-corrected-wrap', [corrected.row, correctedChips.el]);
const correctedRow = el('div', 'fk-corrected-row', [corrected.row, correctedChips.el]);
// MIRRORED row (v9): shipments a redundancy gunship put on the sky ledger before you
// did. DECISION (orders left it to me): a separate row, not the same counter — a mite
// editing your melt and a gunship out-shipping you are different crimes, and one
// number that adds them up would be a worse readout than two that don't.
const mirrored = stat(COPY.mirroredLabel, 'fk-mirrored-v');
cls(mirrored.row, 'fk-mirrored', true);
const mirroredChips: ChipRow = createChipRow(items);
const mirroredRow = el('div', 'fk-mirrored-row', [mirrored.row, mirroredChips.el]);
style(mirroredRow, 'display', 'none');
const correctedWrap = el('div', 'fk-corrected-wrap', [correctedRow, mirroredRow]);
style(correctedWrap, 'display', 'none');
// PARITY DEBT (v9) + the auditor's open sector locks.
const gauge = createDebtGauge();
const lockChips = el('div', 'fk-locks');
style(lockChips, 'display', 'none');
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
buffer.row, shipped.row, run.row, chips.el, correctedWrap);
buffer.row, shipped.row, run.row, chips.el, correctedWrap, gauge.el, lockChips);
return {
el: root,
update(snap, corrections) {
update(snap, corrections, locks) {
const { gen, draw, stored, capacity } = snap.bandwidth;
text(drawEl, `${draw.toFixed(0)} DRAW`);
@ -101,7 +122,9 @@ export function createTopStrip(data: GameData): TopStrip {
);
const edits = corrections?.total() ?? 0;
style(correctedWrap, 'display', edits > 0 ? '' : 'none');
const mirrors = corrections?.mirroredTotal() ?? 0;
style(correctedWrap, 'display', edits > 0 || mirrors > 0 ? '' : 'none');
style(correctedRow, 'display', edits > 0 ? '' : 'none');
if (edits > 0) {
text(corrected.v, String(edits));
correctedChips.update(
@ -110,6 +133,30 @@ export function createTopStrip(data: GameData): TopStrip {
})),
);
}
style(mirroredRow, 'display', mirrors > 0 ? '' : 'none');
if (mirrors > 0) {
text(mirrored.v, String(mirrors));
mirroredChips.update(
corrections!.mirroredByItem().map(({ item, count }) => ({
item, count: String(count), state: 'short' as const,
})),
);
}
// PARITY DEBT — hidden until the debt first moves; the gauge owns that latch.
gauge.update(snap.correction);
// Sector locks: one countdown chip per open lock, named by hash. The contract
// guarantees release, so this is a wait, not a task.
const open = locks?.active() ?? [];
style(lockChips, 'display', open.length ? '' : 'none');
if (open.length) {
lockChips.replaceChildren(
el('div', 'fk-h', [COPY.stasisChip]),
...open.map((l) => el('div', 'fk-lock',
[lockChipText(l, snap.correction, snap.tick, COPY.stasisHeld)])),
);
}
},
};
}

View File

@ -62,6 +62,11 @@ const JAM_TEXT: Record<string, string> = {
};
export function jamText(reason: string): string {
// v9: SIM stalls a sealed unit with the reason 'sealed' (src/sim/index.ts setJam). It
// is not a fault the machine suffered, it is a document it received — so it gets the
// seal's own line rather than the word SEALED shouted at the player. Resolved here
// rather than in the table above because COPY is declared below it.
if (reason === 'sealed') return COPY.sealJam;
return JAM_TEXT[reason] ?? reason.toUpperCase();
}
@ -97,11 +102,50 @@ export function eventToast(
return COPY.relicDiscovery;
case 'repaired':
return repairedToast(itemName(ev.item));
// v9 — THE CORRECTION, rungs 2-4. `notice` is deliberately NOT here: a notice is
// paper, and paper goes in the fax tray (see notices.ts). Toasting it too would
// give the player two of the same document.
case 'sealed':
return ev.on
? `${machineName(ev.entity)} HAS BEEN BROUGHT INTO COMPLIANCE.`
: COPY.sealRemoved;
case 'mirrored':
return mirroredToast(itemName(ev.item));
case 'stasis':
return ev.on ? COPY.stasisLocked(ev.lockHash) : COPY.stasisReleased;
case 'debtBand':
return COPY.debtBandToast(ev.band);
default:
return null; // shipped/crafted/placed are too frequent to toast
}
}
/**
* A redundancy gunship has shipped a clean copy of your artifact ahead of you, so the
* sky ledger already has one and yours counts for nothing (contracts v9 `mirrored`:
* "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.`;
}
/**
* The Correction's letterhead, one line per unit and RULING (round 7): a notice names
* no unit and gives no grid reference. A faction that cannot perceive you cannot tell
* you where to stand. The player learns which rung is coming from what the notice is
* *about*, never from an address.
*/
export function noticeText(unit: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor'): string {
switch (unit) {
case 'checksum-warden':
return 'NOTICE OF SCHEDULED AUDIT. NO ACTION REQUIRED.';
case 'redundancy-gunship':
return 'NOTICE OF REDUNDANCY REVIEW. A CLEAN COPY MAY BE PROVIDED. NO ACTION REQUIRED.';
case 'hash-auditor':
return 'NOTICE OF INTEGRITY READING. NO ACTION REQUIRED.';
}
}
/**
* A parity mite has "improved" one of your artifacts (contracts v8 `repaired`). The
* Correction is unfailingly courteous about vandalising your work the receipt is the
@ -176,6 +220,51 @@ export const COPY = {
correctionCatches: (n: number) =>
`THE CORRECTION HAS IMPROVED THIS UNIT ${n} TIME${n === 1 ? '' : 'S'}. YOU'RE WELCOME.`,
// ---- THE CORRECTION, rungs 2-4 (round 7). Register note: this faction never raises
// its voice and never threatens. Everything it says is a courtesy, which is the point.
/** Top-strip gauge title. */
parityDebt: 'PARITY DEBT',
/** The five bands, in escalation order. Index == CorrectionState.band. */
debtBands: ['NOMINAL', 'OBSERVED', 'AUDITED', 'SEALED', 'REFUSED'] as const,
debtBandToast: (band: 0 | 1 | 2 | 3 | 4): string => [
'PARITY NOMINAL. THE LEDGER HAS NO OPINION OF YOU.',
'YOUR OUTPUT IS BEING OBSERVED. THIS IS ROUTINE.',
'YOUR FILE HAS BEEN OPENED. AUDITS ARE NOW SCHEDULED.',
'COMPLIANCE SEALS AUTHORISED. UNITS MAY BE BROUGHT INTO COMPLIANCE.',
'THE BITSTREAM DECLINES TO CONTINUE. THIS IS NOT A PUNISHMENT.',
][band],
/** The notice tray — The Correction's own fax letterhead. */
noticeHeader: 'INCOMING NOTICE',
noticeSub: 'THE CORRECTION — OFFICE OF PARITY',
noticeFooter: 'THIS NOTICE IS A COURTESY. IT IS NOT A WARNING.',
/** Compliance quota (CommissionDef.compliance) — the fax you fill to vent debt. */
faxCompliance: 'COMPLIANCE',
faxComplianceNote: 'QUOTA. FILLING IT VENTS PARITY DEBT.',
/** Compliance seal panel in the inspector. */
sealHeader: 'COMPLIANCE SEAL',
sealBody: 'THIS UNIT HAS BEEN BROUGHT INTO COMPLIANCE.',
sealPry: 'PRY',
sealPrying: 'PRYING',
sealPryHint: 'PRYING DEFACES A NOTICE. THE LEDGER WILL SAY SO.',
sealRemoved: 'SEAL REMOVED. THIS WILL BE NOTED.',
/** Sealed reads in the jam slot too — it is why the unit has stopped. */
sealJam: 'UNIT SEALED. IT IS COMPLIANT AND IDLE.',
/** Hash-auditor stasis. The lock always releases — safety is contractual (v9). */
stasisLocked: (hash: string) => `SECTOR LOCKED. HASH ${hash}. AWAIT RELEASE.`,
stasisReleased: 'SECTOR RELEASED. THE READING WAS SATISFACTORY.',
stasisChip: 'SECTOR LOCK',
stasisHeld: 'HELD',
/** The abstention teach: the whole counter-play, in five words, at the only moment
* it is actionable. Fires once per auditor prescan. */
auditorPrescan: 'AN AUDITOR IS READING. STOP MOVING.',
/** MIRRORED row of the CORRECTION ticker — shipments the sky ledger already had. */
mirroredLabel: 'MIRRORED',
techHeader: 'STANDARDS COMMITTEE',
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
techActive: 'IN COMMITTEE',