[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:
parent
de5a1b5770
commit
5500949d83
@ -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.
|
||||
|
||||
524
fktry/src/ui/correction.test.ts
Normal file
524
fktry/src/ui/correction.test.ts
Normal 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+\)/);
|
||||
});
|
||||
});
|
||||
@ -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
59
fktry/src/ui/debtgauge.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
79
fktry/src/ui/notices.ts
Normal 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
59
fktry/src/ui/parity.ts
Normal 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
70
fktry/src/ui/seal.ts
Normal 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
97
fktry/src/ui/stasis.ts
Normal 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}`;
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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)])),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user