From 5500949d83e4ab49852ba9a098be5bf696755147 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 29 Jul 2026 12:40:06 +1000 Subject: [PATCH] [ui] the enemy has paperwork: PARITY DEBT gauge, notices, seals + PRY, stasis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/lanes/LANE-UI.md | 124 ++++++++ fktry/src/ui/correction.test.ts | 524 ++++++++++++++++++++++++++++++++ fktry/src/ui/corrections.ts | 20 +- fktry/src/ui/debtgauge.ts | 59 ++++ fktry/src/ui/fax.ts | 17 +- fktry/src/ui/index.ts | 47 ++- fktry/src/ui/inspector.ts | 93 +++++- fktry/src/ui/live.test.ts | 192 +++++++++++- fktry/src/ui/notices.ts | 79 +++++ fktry/src/ui/parity.ts | 59 ++++ fktry/src/ui/seal.ts | 70 +++++ fktry/src/ui/stasis.ts | 97 ++++++ fktry/src/ui/style.ts | 125 +++++++- fktry/src/ui/topstrip.ts | 59 +++- fktry/src/ui/voice.ts | 89 ++++++ 15 files changed, 1628 insertions(+), 26 deletions(-) create mode 100644 fktry/src/ui/correction.test.ts create mode 100644 fktry/src/ui/debtgauge.ts create mode 100644 fktry/src/ui/notices.ts create mode 100644 fktry/src/ui/parity.ts create mode 100644 fktry/src/ui/seal.ts create mode 100644 fktry/src/ui/stasis.ts diff --git a/fktry/lanes/LANE-UI.md b/fktry/lanes/LANE-UI.md index b564bae..b39eb31 100644 --- a/fktry/lanes/LANE-UI.md +++ b/fktry/lanes/LANE-UI.md @@ -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. diff --git a/fktry/src/ui/correction.test.ts b/fktry/src/ui/correction.test.ts new file mode 100644 index 0000000..07fed28 --- /dev/null +++ b/fktry/src/ui/correction.test.ts @@ -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 { + 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 => + ({ 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 = {}) => 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+\)/); + }); +}); diff --git a/fktry/src/ui/corrections.ts b/fktry/src/ui/corrections.ts index 12d87d3..a22bb7d 100644 --- a/fktry/src/ui/corrections.ts +++ b/fktry/src/ui/corrections.ts @@ -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(); + let mirrors = 0; + const perMirroredItem = new Map(); 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) { diff --git a/fktry/src/ui/debtgauge.ts b/fktry/src/ui/debtgauge.ts new file mode 100644 index 0000000..f42ed73 --- /dev/null +++ b/fktry/src/ui/debtgauge.ts @@ -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); + }, + }; +} diff --git a/fktry/src/ui/fax.ts b/fktry/src/ui/fax.ts index f6a64f9..8fdb6da 100644 --- a/fktry/src/ui/fax.ts +++ b/fktry/src/ui/fax.ts @@ -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) { diff --git a/fktry/src/ui/index.ts b/fktry/src/ui/index.ts index b0a6086..7cda514 100644 --- a/fktry/src/ui/index.ts +++ b/fktry/src/ui/index.ts @@ -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; let tuner: ReturnType; let screenView: ReturnType; + let notices: ReturnType; /** 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(); + /** 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(); 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); }, }; } diff --git a/fktry/src/ui/inspector.ts b/fktry/src/ui/inspector.ts index 8f041e3..734211d 100644 --- a/fktry/src/ui/inspector.ts +++ b/fktry/src/ui/inspector.ts @@ -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 diff --git a/fktry/src/ui/live.test.ts b/fktry/src/ui/live.test.ts index 098b725..bbfba04 100644 --- a/fktry/src/ui/live.test.ts +++ b/fktry/src/ui/live.test.ts @@ -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); + }); +}); diff --git a/fktry/src/ui/notices.ts b/fktry/src/ui/notices.ts new file mode 100644 index 0000000..5ef3d23 --- /dev/null +++ b/fktry/src/ui/notices.ts @@ -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, + }; +} diff --git a/fktry/src/ui/parity.ts b/fktry/src/ui/parity.ts new file mode 100644 index 0000000..8fa6504 --- /dev/null +++ b/fktry/src/ui/parity.ts @@ -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, + }; +} diff --git a/fktry/src/ui/seal.ts b/fktry/src/ui/seal.ts new file mode 100644 index 0000000..8631ccb --- /dev/null +++ b/fktry/src/ui/seal.ts @@ -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 = SIM_CONSTANTS as unknown as Record, +): 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(); + 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); + } + }, + }; +} diff --git a/fktry/src/ui/stasis.ts b/fktry/src/ui/stasis.ts new file mode 100644 index 0000000..c2e3d55 --- /dev/null +++ b/fktry/src/ui/stasis.ts @@ -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}`; +} diff --git a/fktry/src/ui/style.ts b/fktry/src/ui/style.ts index 1c50479..4cab345 100644 --- a/fktry/src/ui/style.ts +++ b/fktry/src/ui/style.ts @@ -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