Compare commits

...

5 Commits

Author SHA1 Message Date
type-two
edca93f298 [ui] round 4 notes
All five orders shipped. The honest parts: SIM's research gating breaks SIM's
own reference factory at HEAD (GEN 80 vs DRAW 100, permanent brownout), nothing
in the game can make a science pack so research can't be performed by playing,
and the order's numbers went stale mid-round for the third round running.
No contract requests; one proposal (grantResearch, so tests stop coupling to
the save schema).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
7501353311 [ui] live: a full research run, and a faithful main.ts in the harness
The harness bus is now a real v4 host: it owns the typed selection and it is
the thing that dispatches place/remove on a world click. That is what caught
the round-3 double-fire.

New: the whole loop the DoD asked for, against the real sim — locked machine
visible, its `place` silently dropped, build a lab, pick the tech through the
panel, packs delivered, `researched` fires, toast lands, padlock falls off, and
the same `place` now succeeds.

Packs are teleported into the lab's intake via the sim's own save()/load()
because nothing in the game can make a science pack yet (see NOTES);
everything after that is the sim's real drainLabs -> researched -> applyUnlocks
path, unmocked.

Reference-factory tests now run with research pre-granted, because SIM's gating
drops the software decoders reference.ts depends on. Stopped hardcoding the
i-only assembler's coordinates too — reference.ts moved it mid-round and broke
my own test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
2904ddbb8c [ui] research: the STANDARDS COMMITTEE, and padlocks that fall off live
T opens the tech tree — every tech grouped by era in era order, cost chips in
item colours, era prefix stripped from node names (the heading already says
it), UNLOCKS: tooltips in machine names not ids. Pick a node to dispatch
setResearch; picking the active one stands it down; ratified nodes are inert.

The v4 gating rule lives in a pure research.ts: an id any tech claims is locked
until that tech completes, and an id no tech mentions is always available — so
belts never padlock. Locked machines are greyed with a padlock and a
REQUIRES: <TECH> tooltip, and can't be picked by click or by hotkey. The
padlock falls off in the same frame `researched` lands, and the player never
keeps holding something they can no longer build.

With no ResearchState at all the safe reading is that gates hold rather than
fall open, and the panel says RESEARCH OFFLINE instead of rendering a tree that
can't do anything.

Accent precedence now matches RENDER exactly: MachineDef.accent, then the
first-output derivation, then the codex kind table. RESEARCH joins the page
order for the new 'lab' kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
5a36e32246 [ui] migrate to the v4 typed selection protocol
Writes go through bus.setSelection(); the __remove magic string and its
REMOVE_DEF export are deleted — the renderer gets a real mode from main.ts now.

Deleted my own `remove` dispatch: main.ts dispatches it on click as of v4, and
the UI doing it too was a double-fire that only looked harmless because the
second command no-oped. Verified live: one click, one removal.

selection/setSelection are optional in the contract, so a legacy host still
gets the old {def,dir} shape (sentinel included) rather than silently getting
no selection at all. Tested both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:23 +10:00
type-two
3cec62368a [ui] fix: BROWNOUT was lying about a covered deficit
The banner keyed on `out || ratio > 1`, so any time draw exceeded generation
the panel screamed BROWNOUT — even while the buffer tanks were quietly
covering it, the sim's flag was false and THE SCREEN was calm. That state is
the factory working as designed, not failing. Only the HUD was panicking.

BROWNOUT is now the sim's flag and nothing else. A covered deficit gets its
own amber, steady, non-flashing state: "ON RESERVE · 34s", where the seconds
are stored/(draw-gen) — real arithmetic, because v3 ruled stored is
bandwidth-SECONDS and gen/draw are per-second. Same maths SCREEN uses.

Four states (ok / tight / reserve / brownout) now live in a pure power.ts and
are pinned by tests, including the exact case that lied: gen 100, draw 134,
stored 1000, brownout false -> reserve, not brownout.

Also uses v4 bandwidth.capacity to turn the buffer readout into a fuel gauge
(0/900) rather than a bare number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:23 +10:00
16 changed files with 1128 additions and 135 deletions

View File

@ -367,3 +367,104 @@ and needed a manual re-dispatch. Say the word.
**NEXT (suggested):** tech panel the moment `tech.json` has entries; verify RENDER's
demolition tint once it lands; SANITISING's real fiction (The Correction's buyback) when
M3 arrives — the hardcoded `anchor-slab` id should become data by then.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED:** All five orders. New: `power.ts` + `power.test.ts`, `research.ts` +
`research.test.ts`, `techpanel.ts` + `techpanel.test.ts`. **145 UI tests** (was 105);
`npm run check` clean.
1. **The BROWNOUT bug is fixed.** BROWNOUT is now the sim's flag and nothing else. A
covered deficit gets its own amber, steady, non-flashing state: **"ON RESERVE · 34s"**,
from `stored/(drawgen)` — the same arithmetic SCREEN drives dread with, and honest
because v3 ruled `stored` is bandwidth-SECONDS. Four states now: ok / tight / reserve /
brownout, all pure in `power.ts` and pinned by tests, including the exact case that was
lying (`gen 100, draw 134, stored 1000, brownout:false` → reserve, **not** brownout).
Bonus: the buffer readout is a fuel gauge now (`0/900`) using v4 `bandwidth.capacity`.
2. **Selection protocol migrated.** Writes go through `bus.setSelection()`, `REMOVE_DEF`
is deleted, and **my `remove` dispatch is gone** — main.ts owns it. Verified live: one
click, one removal, no double-fire. A legacy fallback remains for a host that doesn't
serve `setSelection` (it's optional in the contract) and is tested.
3. **Research UI.** `T` opens the STANDARDS COMMITTEE: every tech grouped by era in era
order, cost chips in item colours, era prefix stripped from node names, `UNLOCKS:`
tooltips in machine names. Pick → `setResearch`; picking the active one stands it down;
ratified nodes are inert. Locked machines in the bar are greyed + padlocked with
`REQUIRES: <TECH>`, can't be picked by click *or* hotkey, and the padlock falls off in
the same frame `researched` lands. `researched` → "STANDARD RATIFIED. NEW CRIMES
AVAILABLE."
4. **`?uidemo`** — boots the reference factory after init. It already paid for itself.
5. **Accent adopted**: `MachineDef.accent` → first-output derivation → codex kind table.
**LANE-RENDER: confirming we read this identically** — same three tiers, same order.
**VERIFIED — live, in the browser** (`?uidemo`, screenshots taken): the factory builds on
boot with no console poking; the STANDARDS COMMITTEE renders all 23 techs across REEL /
BROADCAST / DISC / STREAM / FORTRESS; clicking SOFTWARE DECODING dispatches `setResearch`
and the node goes hot magenta reading **IN COMMITTEE** with a progress bar (round-tripped
through the real sim); the POWER page shows DECODE ASIC bright and free next to SOFTWARE
DECODER and ASIC COOLER greyed with padlocks and `REQUIRES: STREAM SOFTWARE DECODING` /
`REQUIRES: BROADCAST ASIC COOLING`; `bus.selection()` reads `{mode:'remove'}` and a click
demolishes the mosh reactor once — DRAW 100→92, "UNIT RECLAIMED. THE FLOOR REMEMBERS."
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 16 tests): the whole
research loop the DoD asked for — **lock visible → `place` silently dropped by the sim →
build a lab → pick the tech through the panel → packs delivered → `researched` → toast →
padlock falls off → the same `place` now succeeds.** Its harness is now a faithful main.ts
(typed selection; *it* dispatches place/remove), which is what caught the double-fire.
**DECISIONS:**
- Packs in the research run are teleported into the lab's intake via the sim's own
`save()`/`load()` (v4 hardened; `load()` re-runs `applyUnlocks`). Everything after that
is the sim's real `drainLabs → researched → applyUnlocks` path, unmocked. Reason in
BLOCKED: nothing in the game can currently *make* a science pack.
- With no `ResearchState` at all, the safe reading of the v4 rule is that gates **hold**
(padlocks on) rather than fall open, and the panel says RESEARCH OFFLINE instead of
rendering a tree that can't do anything. The sim always ships a ResearchState today, so
this is belt-and-braces.
- `live.test.ts` no longer hardcodes reference-factory coordinates — it finds the i-only
assembler by its recipe. reference.ts moved it from (-6,5) to (-6,6) *during this
round* and broke my own test; that's my brittleness, fixed.
**BLOCKED/BROKEN (read this part):**
- **SIM's research gating breaks SIM's own reference factory, and it's live right now.**
`stream-software-decoding` gates `software-decoder`; the sim silently drops placements
of gated machines (`if (!isAvailable(def.id)) return`); reference.ts places two software
decoders for its 200 of generation. Result at HEAD: `?uidemo` builds a factory with
**GEN 80 against DRAW 100, buffer 0/900, permanent BROWNOUT** — it can never run. My
screenshots show it. Either reference.ts should use decode-asics, or the demo should
pre-unlock, or that tech shouldn't gate the generator. (This is also why every
reference-factory test in `live.test.ts` now runs with research pre-granted.)
- **Nothing in the game can make a science pack yet, so no research can actually be
performed by playing.** Packs come only from `artifact-bottler`, whose recipes need
`field-splinters` / `v-hold-roll` / `static-canister` — a broadcast-era chain that
neither the reference factory nor any tech-free layout builds. The tree is *reachable*
on paper (18 techs have satisfiable costs, and DATA fixed the artifact-bottler deadlock
I was about to report), but unreachable in practice. Suggest reference.ts grow a
research rig — it would make this verifiable by playing rather than by save/load.
- **LANE-SIM's `reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
surplus rather than jamming"), and the factory layout shifted twice while I worked. Not
mine; flagging because the suite isn't green.
- **RENDER's demolition tint is still unverified from my side.** The typed `mode` reaches
`setGhost` via main.ts now, so the magic string is no longer my problem — but I never
saw the tint land.
- **The order's own numbers went stale mid-round, again — third round running.**
"24 nodes" is 23 at HEAD; `tech.json`'s era distribution changed under me; the
artifact-bottler deadlock I'd have reported as blocking was fixed by DATA while I
worked; SIM landed *all* of research after my round-start grep said it had none. I now
re-probe everything against a running sim before writing a word of NOTES, which is the
only reason this entry is accurate. Worth considering whether lanes should get a
"HEAD as of" line rather than counts baked into orders.
- Minor, mine: the tab row wraps to two lines now that RESEARCH joined (8 tabs + REMOVE).
Legible, not pretty. Real fix is a scrolling tab strip; say the word.
**CONTRACT REQUEST:** none this round. v4 gave me everything I asked for and the
`SelectionState` migration was clean.
**PROPOSAL:** `Sim.grantResearch(techIds)` or a `?unlocked` demo flag. Every research-
adjacent test I wrote needs research pre-granted, and my only route is
`JSON.parse(sim.save())` → mutate → `load()`. That works and uses public API, but it
couples my tests to SIM's save schema — if they rename `research` in the blob, my suite
breaks for no good reason.
**NEXT (suggested):** verify RENDER's tint; research rig in reference.ts so a pack economy
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
panel once eras mean something visually (§8).

View File

@ -2,10 +2,11 @@
* LANE-UI build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page.
* Click or hotkey selects for placement; main.ts does the ghost and the actual place.
*/
import type { GameData, MachineDef } from '../contracts';
import type { GameData, MachineDef, SimSnapshot } from '../contracts';
import { attrs, cls, el, panel, text } from './dom';
import { paginate, pageOf, type BuildPage } from './pages';
import { createAccentLookup, machineChassis } from './palette';
import { gateFor, indexTech, type TechIndex } from './research';
import type { BuildSelection } from './selection';
import { COPY, DIR_NAME } from './voice';
@ -15,11 +16,16 @@ export interface BuildBar {
selectSlot(n: number): void;
pageBy(delta: number): void;
sync(): void;
/** Re-read the lock state; cheap enough to call per frame. */
update(snap: SimSnapshot): void;
}
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const pages: BuildPage[] = paginate(data.machines);
const accentOf = createAccentLookup(data);
const techIndex: TechIndex = indexTech(data);
/** Machine ids currently gated by unfinished research. */
let locked = new Set<string>();
const root = panel();
root.id = 'fk-build';
@ -54,13 +60,12 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
tabRow.append(removeBtn);
/** Buttons for the active page only; rebuilt on page change (nine at most). */
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
let buttons: Array<{ def: string; btn: HTMLButtonElement; lock: HTMLElement }> = [];
function buildButtons(page: BuildPage) {
row.replaceChildren();
buttons = page.machines.map((m: MachineDef, i: number) => {
const btn = el('button', 'fk-build-btn');
attrs(btn, { type: 'button', title: `${m.name}${m.kind}` });
// Two-material rule (codex §8): grimy body, one impossible element.
const ico = el('div', 'fk-build-ico');
@ -69,6 +74,8 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
accent.style.background = accentOf(m);
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
ico.append(accent);
const lock = el('span', 'fk-build-lock', ['\u{1F512}']);
ico.append(lock);
btn.append(ico);
const key = el('span', 'fk-build-key');
@ -76,10 +83,33 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
btn.append(key);
btn.append(el('span', 'fk-build-name', [m.name]));
btn.addEventListener('click', () => sel.select(m.id));
btn.addEventListener('click', () => select(m.id));
row.append(btn);
return { def: m.id, btn };
return { def: m.id, btn, lock };
});
applyLocks();
}
/** A locked machine can't be picked — by click or by hotkey. */
function select(defId: string) {
if (locked.has(defId)) return;
sel.select(defId);
}
function applyLocks() {
for (const b of buttons) {
const isLocked = locked.has(b.def);
cls(b.btn, 'is-locked', isLocked);
cls(b.lock, 'is-on', isLocked);
const m = data.machines.find((x) => x.id === b.def);
const gate = isLocked ? techIndex.gatedBy.get(b.def) : null;
attrs(b.btn, {
title: gate
? `${m?.name ?? b.def}${COPY.requires} ${gate.id.replace(/-/g, ' ').toUpperCase()}`
: `${m?.name ?? b.def}${m?.kind ?? ''}`,
});
}
}
function render() {
@ -126,9 +156,24 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
return {
el: root,
update(snap) {
// Recompute only when the unlocked set actually changes — the padlocks then fall
// off in the same frame the `researched` event lands.
const next = new Set<string>();
for (const m of data.machines) {
if (gateFor(techIndex, snap.research, m.id)) next.add(m.id);
}
if (next.size === locked.size && [...next].every((d) => locked.has(d))) return;
locked = next;
applyLocks();
// Never leave the player holding something they can no longer build.
const cur = sel.get();
if (cur && locked.has(cur.def)) sel.clear();
},
selectSlot(n) {
const b = buttons[n - 1];
if (b) sel.select(b.def);
if (b) select(b.def);
},
pageBy(delta) {
if (pages.length === 0) return;

View File

@ -15,6 +15,7 @@ import { createInspector } from './inspector';
import { createRoster, entityAt, syncRoster } from './pick';
import { createSelection } from './selection';
import { injectStyle } from './style';
import { createTechPanel } from './techpanel';
import { createTopStrip } from './topstrip';
import { createToasts } from './toasts';
@ -25,6 +26,7 @@ export function createUI(): UI {
let inspector: ReturnType<typeof createInspector>;
let fax: ReturnType<typeof createFax>;
let toasts: ReturnType<typeof createToasts>;
let tech: ReturnType<typeof createTechPanel>;
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster();
@ -49,8 +51,9 @@ export function createUI(): UI {
inspector = createInspector(data, bus);
fax = createFax(data);
toasts = createToasts();
tech = createTechPanel(data, bus);
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
root.append(top.el, build.el, inspector.el, fax.el, tech.el, toasts.el);
// ------------------------------------------------------------------ keyboard
const keys = createHotkeys();
@ -59,10 +62,12 @@ export function createUI(): UI {
keys.bind(']', () => build.pageBy(1));
keys.bind('r', () => sel.rotate());
keys.bind('x', () => sel.toggleRemove());
keys.bind('t', () => tech.toggle());
keys.bind('escape', () => {
// One key, several jobs, in the order the player expects: put the tool down
// first (wrecking ball included), close the panel only when empty-handed.
// first (wrecking ball included), then shut whatever is open.
if (sel.isArmed()) sel.clear();
else if (tech.isOpen()) tech.close();
else inspector.close();
});
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
@ -83,24 +88,28 @@ export function createUI(): UI {
addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !inWorld(e.target)) return;
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
// Anything held — a machine or the wrecking ball — makes this main.ts's click:
// it places, and as of v4 it dispatches the removal too. We must not also
// dispatch, or every demolition fires twice.
if (sel.isArmed()) return;
const tile = bus.pickTile(e.clientX, e.clientY);
if (!tile) return;
if (sel.isRemoving()) {
// Only fire at something that's actually there, so a stray click on open
// ground doesn't read as a broken tool. Sim owns the real teardown.
if (entityAt(roster, machines, tile) !== null) {
bus.dispatch({ kind: 'remove', pos: tile });
}
return; // remove mode stays armed: demolition is usually plural
}
const hit = entityAt(roster, machines, tile);
if (hit === null) inspector.close();
else inspector.open(hit);
});
// ?uidemo — build SIM's reference factory on boot. main.ts defines __fktryDemo
// after ui.init(), and only in DEV, so ask on the next task rather than now.
if (new URLSearchParams(location.search).has('uidemo')) {
setTimeout(() => {
const demo = (globalThis as any).__fktryDemo;
if (typeof demo === 'function') demo();
else console.info('[ui] ?uidemo: no __fktryDemo on window (production build?)');
}, 0);
}
},
update(snap: SimSnapshot, events: SimEvent[]) {
@ -108,8 +117,10 @@ export function createUI(): UI {
syncRoster(roster, snap);
top.update(snap);
build.update(snap); // padlocks track research
inspector.update(snap);
fax.update(snap);
tech.update(snap);
for (const ev of events) {
toasts.push(ev, machineName);

View File

@ -15,7 +15,7 @@
* milliseconds and stay reproducible. See NOTES.
*/
import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, UIBus, Vec2 } from '../contracts';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { referenceFactory } from '../sim/reference';
import { createUI } from './index';
@ -28,22 +28,46 @@ import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function harness() {
/** Grant research through the sim's own save/load — `load()` re-runs applyUnlocks(). */
function unlockAll(sim: Sim) {
const s = JSON.parse(sim.save());
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) };
sim.load(JSON.stringify(s));
}
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
const sim = createSim();
const ui = createUI();
const game = document.getElementById('game')!;
let pick: Vec2 | null = null;
let state: SelectionState = null;
const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = {
_sel: null,
// A faithful stand-in for main.ts's v4 bus: it owns the selection, and it is the one
// that dispatches `place`/`remove` on a world click. The UI must never do that itself.
const bus: UIBus = {
dispatch: (c) => sim.enqueue(c),
selectedBuild: () => bus._sel,
selectedBuild: () =>
state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null,
pickTile: () => pick,
selection: () => state,
setSelection: (s) => { state = s; },
};
sim.init(DATA, 1337);
if (opts.unlocked) unlockAll(sim); // before any tick, so placements aren't dropped
ui.init(document.getElementById('ui')!, DATA, bus);
for (const c of referenceFactory(DATA)) sim.enqueue(c);
// main.ts's own click glue, copied verbatim in behaviour.
game.addEventListener('pointerdown', (e) => {
if ((e as MouseEvent).button !== 0) return;
const sel = bus.selection!();
if (!sel || !pick) return;
if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: pick });
else sim.enqueue({ kind: 'place', def: sel.def, pos: pick, dir: sel.dir });
});
if (opts.reference !== false) for (const c of referenceFactory(DATA)) sim.enqueue(c);
const step = (n = 1) => {
for (let i = 0; i < n; i++) {
@ -51,17 +75,15 @@ function harness() {
ui.update(sim.snapshot(), sim.drainEvents());
}
};
/** The real click-to-inspect path: pickTile -> entityAt -> inspector.open. */
/** A real click on the world: main.ts's handler and the UI's both see it. */
const clickTile = (t: Vec2) => {
pick = t;
document
.getElementById('game')!
.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
game.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
};
const find = (defId: string): EntityState | undefined =>
sim.snapshot().entities.find((e) => e.def === defId);
return { sim, step, clickTile, find, bus };
return { sim, step, clickTile, find, bus, state: () => state };
}
const $ = (s: string) => document.querySelector(s) as HTMLElement;
@ -70,7 +92,7 @@ const toasts = () => Array.from(document.querySelectorAll('.fk-toast')).map((t)
describe('the HUD against the reference factory', () => {
it('boots, builds and reports a working factory', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(200);
const snap = h.sim.snapshot();
expect(snap.entities.length).toBeGreaterThan(20);
@ -79,7 +101,7 @@ describe('the HUD against the reference factory', () => {
});
it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const dec = h.find('software-decoder');
@ -107,7 +129,7 @@ describe('the HUD against the reference factory', () => {
it('keeps reading SCRAM from the latch while the unit cools back below 1.0', () => {
// The exact case `heat >= 1` got wrong, and why contracts v3 added the bool.
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const dec = h.find('software-decoder')!;
h.clickTile(dec.pos);
@ -121,7 +143,7 @@ describe('the HUD against the reference factory', () => {
});
it('ships and tickers up', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(3000);
const shipped = h.sim.snapshot().shippedTotal;
expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
@ -133,12 +155,16 @@ describe('the HUD against the reference factory', () => {
// The reference factory never ships slabs — it recycles them into the i-only
// assembler on purpose ("Rather than sell them, feed the i-only assembler").
// So do what a player would: demolish that assembler and drop an uplink on it.
const h = harness();
const h = harness({ unlocked: true });
h.step(200);
expect(on('.fk-san')).toBe(false); // nothing sanitised yet
h.bus.dispatch({ kind: 'remove', pos: { x: -6, y: 5 } }); // the i-only assembler
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: -6, y: 5 }, dir: 0 });
// Find it by what it *is*, not where it was — reference.ts moves its layout around.
const iOnly = h.sim.snapshot().entities.find((e) => e.recipe === 'assemble-gop-i-only');
expect(iOnly, 'reference factory should recycle slabs through an i-only assembler')
.toBeTruthy();
h.bus.dispatch({ kind: 'remove', pos: iOnly!.pos });
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: iOnly!.pos, dir: 0 });
let shippedAt = -1;
for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
@ -151,7 +177,7 @@ describe('the HUD against the reference factory', () => {
});
it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => {
const h = harness();
const h = harness({ unlocked: true });
let sawStanding = false;
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
h.step(100);
@ -174,7 +200,7 @@ describe('the HUD against the reference factory', () => {
});
it('completes a commission and stamps the fax', () => {
const h = harness();
const h = harness({ unlocked: true });
let done = false;
for (let t = 0; t < 6000 && !done; t += 50) {
h.step(50);
@ -185,7 +211,7 @@ describe('the HUD against the reference factory', () => {
});
it('speaks in the house voice when things go wrong', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(400);
const all = toasts().join(' | ');
// Something in a cold-starting factory is always starved; whatever it is, it must
@ -195,16 +221,121 @@ describe('the HUD against the reference factory', () => {
});
});
describe('research against the real sim', () => {
const SOFTWARE_TECH = 'stream-software-decoding'; // gates 'software-decoder'
const lockBtn = () =>
Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
b.textContent?.includes('SOFTWARE DECODER'),
) as HTMLElement | undefined;
/** Walk the build bar to the page holding a machine. */
function pageTo(name: string) {
for (let i = 0; i < 12; i++) {
if (lockBtn()) return true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
}
return !!lockBtn();
}
it('padlocks a tech-gated machine and names the tech it needs', () => {
const h = harness({ reference: false });
h.step(1);
expect(pageTo('SOFTWARE DECODER')).toBe(true);
const btn = lockBtn()!;
expect(btn.classList.contains('is-locked')).toBe(true);
expect(btn.querySelector('.fk-build-lock')!.classList.contains('is-on')).toBe(true);
expect(btn.getAttribute('title')).toContain('REQUIRES:');
expect(btn.getAttribute('title')).toContain('STREAM SOFTWARE DECODING');
});
it('leaves an ungated machine alone — the rule only locks what a tech claims', () => {
const h = harness({ reference: false });
h.step(1);
// Walk the pages to LOGISTICS rather than assuming the belt is on the open one.
let belt: Element | undefined;
for (let i = 0; i < 12 && !belt; i++) {
belt = Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
b.textContent?.includes('STREAM BELT'),
);
if (!belt) window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
}
expect(belt, 'belt should be somewhere in the bar').toBeTruthy();
expect(belt!.classList.contains('is-locked')).toBe(false);
});
it('refuses to select a locked machine, by click or hotkey', () => {
const h = harness({ reference: false });
h.step(1);
pageTo('SOFTWARE DECODER');
lockBtn()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(h.state()).toBeNull(); // nothing picked up
});
it('runs the whole loop: lock → pick research → deliver → unlock → place succeeds', () => {
const h = harness({ reference: false });
h.step(1);
// 1. locked, and the sim agrees: a place command is silently dropped.
h.clickTile({ x: 0, y: 0 });
h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2);
expect(h.find('software-decoder'), 'gated machine must not place').toBeUndefined();
// 2. a lab is buildable from a cold start, and picking a tech goes through the panel.
h.sim.enqueue({ kind: 'place', def: 'archaeology-lab', pos: { x: 10, y: 10 }, dir: 0 });
h.step(2);
expect(h.find('archaeology-lab')).toBeTruthy();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 't' })); // open the panel
h.step(1);
expect(document.getElementById('fk-tech')!.classList.contains('is-open')).toBe(true);
const node = Array.from(document.querySelectorAll('.fk-tech-node')).find((n) =>
n.textContent?.includes('SOFTWARE DECODING'),
) as HTMLElement;
node.dispatchEvent(new MouseEvent('click', { bubbles: true })); // -> setResearch
h.step(2);
expect(h.sim.snapshot().research!.active).toBe(SOFTWARE_TECH);
expect(node.classList.contains('is-active')).toBe(true);
// 3. deliver the packs. Nothing in the game can make them yet (see NOTES), so they
// are teleported into the lab's intake via save/load — every step after this is
// the sim's own drainLabs -> researched -> applyUnlocks path, unmocked.
const cost = DATA.tech.find((t) => t.id === SOFTWARE_TECH)!.cost;
const s = JSON.parse(h.sim.save());
const lab = s.entities.find((e: any) => e.state.def === 'archaeology-lab');
lab.state.inputBuf = { ...cost };
h.sim.load(JSON.stringify(s));
// 4. the unlock lands: real event, toast in the house voice, padlock falls off.
h.step(3);
expect(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
pageTo('SOFTWARE DECODER');
expect(lockBtn()!.classList.contains('is-locked'), 'padlock should fall off live')
.toBe(false);
expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:');
// 5. and now it builds.
h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2);
expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy();
});
});
describe('remove mode against the real sim', () => {
it('demolishes the clicked unit and toasts it', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const before = h.sim.snapshot().entities.length;
const victim = h.find('belt')!;
// arm remove the way the X hotkey does
// arm remove the way the X hotkey does — typed protocol, no magic string
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // the ghost sentinel
expect(h.state()).toEqual({ mode: 'remove' });
h.clickTile(victim.pos);
h.step(2);
@ -215,21 +346,21 @@ describe('remove mode against the real sim', () => {
});
it('stays armed for a second demolition, and Esc stands it down', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
const first = h.find('belt')!;
h.clickTile(first.pos);
h.step(2);
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // still armed
expect(h.state()).toEqual({ mode: 'remove' }); // still armed
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(h.bus._sel).toBeNull();
expect(h.state()).toBeNull();
});
it('does not fire at open ground', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const before = h.sim.snapshot().entities.length;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));

View File

@ -26,7 +26,7 @@ const GROUP_OF: Record<MachineKind, string> = {
lab: 'RESEARCH', // v4 kind
};
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP'];
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'RESEARCH', 'SHIP'];
export interface BuildPage {
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import type { SimSnapshot } from '../contracts';
import { formatReserve, powerState, TIGHT_AT } from './power';
type BW = SimSnapshot['bandwidth'];
const bw = (over: Partial<BW> = {}): BW =>
({ gen: 100, draw: 50, stored: 0, brownout: false, ...over });
describe('powerState', () => {
it('is ok with headroom', () => {
expect(powerState(bw()).state).toBe('ok');
});
it('goes tight at 85% of generation, before anything is wrong', () => {
expect(powerState(bw({ draw: 84 })).state).toBe('ok');
expect(powerState(bw({ draw: TIGHT_AT * 100 })).state).toBe('tight');
expect(powerState(bw({ draw: 99 })).state).toBe('tight');
});
// ---- the round-3 bug, pinned ------------------------------------------------
it('a covered deficit is ON RESERVE, not a brownout', () => {
// draw exceeds gen, tanks have charge, and the sim says no brownout.
// The old code keyed on `out || ratio > 1` and screamed BROWNOUT at this.
const p = powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.state).not.toBe('brownout');
});
it('reports seconds of reserve left, because stored is bandwidth-SECONDS', () => {
// 1000 bandwidth-seconds stored, bleeding 34/sec => ~29s
const p = powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: false }));
expect(p.reserveSeconds).toBeCloseTo(1000 / 34, 5);
});
it('only the sim gets to say BROWNOUT', () => {
// Same covered-deficit numbers, but the sim has raised the flag: believe the flag.
expect(powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: true })).state)
.toBe('brownout');
// ...and a brownout at nominal draw is still a brownout.
expect(powerState(bw({ gen: 100, draw: 1, brownout: true })).state).toBe('brownout');
});
it('treats an exhausted reserve honestly rather than rounding it up', () => {
const p = powerState(bw({ gen: 10, draw: 20, stored: 0, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.reserveSeconds).toBe(0); // about to become the sim's problem
});
it('handles drawing with no generation at all', () => {
const p = powerState(bw({ gen: 0, draw: 4, stored: 40, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.ratio).toBe(Infinity);
expect(p.reserveSeconds).toBe(10);
});
it('is ok on an empty floor — no generation, no draw', () => {
const p = powerState(bw({ gen: 0, draw: 0 }));
expect(p.state).toBe('ok');
expect(p.ratio).toBe(0);
});
it('exactly break-even is not a deficit', () => {
expect(powerState(bw({ gen: 100, draw: 100, stored: 5 })).state).toBe('tight');
});
});
describe('formatReserve', () => {
it('floors, because a countdown that rounds up lies at the end', () => {
expect(formatReserve(29.9)).toBe('29s');
expect(formatReserve(0.4)).toBe('0s');
});
it('caps absurd numbers instead of printing a wall of digits', () => {
expect(formatReserve(100000)).toBe('999s');
expect(formatReserve(Infinity)).toBe('∞');
});
});

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

@ -0,0 +1,59 @@
/**
* LANE-UI reading the bandwidth economy (pure; see power.test.ts).
*
* Four states, and the distinction between the middle two is the whole point:
*
* ok draw comfortably under generation
* tight draw 85% of generation nothing is wrong yet, but there's no headroom
* reserve draw EXCEEDS generation and the buffer tanks are covering the difference.
* This is the factory working as designed. Round 1-3 painted it as BROWNOUT,
* which was a lie: the sim's flag was false, THE SCREEN was calm, and only the
* HUD was screaming. Survival is not failure.
* brownout the sim says so. That flag is the only truth here the tanks are dry and
* the sector is a still life.
*
* Units (contracts v3 ruling): `gen`/`draw` are bandwidth per SECOND, `stored` is
* bandwidth-SECONDS. So `stored / (draw - gen)` is literally seconds of reserve left
* the same arithmetic LANE-SCREEN drives its dread with.
*/
import type { SimSnapshot } from '../contracts';
export type PowerState = 'ok' | 'tight' | 'reserve' | 'brownout';
/** Draw at or above this share of generation is "tight" — a warning, not a fault. */
export const TIGHT_AT = 0.85;
export interface PowerReadout {
state: PowerState;
/** draw/gen, clamped for the meter fill. Infinity when drawing with no generation. */
ratio: number;
/** Seconds of reserve left; only meaningful in the 'reserve' state. */
reserveSeconds: number | null;
}
export function powerState(bw: SimSnapshot['bandwidth']): PowerReadout {
const { gen, draw, stored, brownout } = bw;
// With no generation at all, any draw is already past budget.
const ratio = gen > 0 ? draw / gen : draw > 0 ? Infinity : 0;
// The sim owns this word. If it says brownout, it's a brownout, whatever the maths say.
if (brownout) return { state: 'brownout', ratio, reserveSeconds: 0 };
const deficit = draw - gen;
if (deficit > 0) {
// Covered deficit: the tanks are doing exactly what tanks are for.
const seconds = deficit > 0 ? stored / deficit : Infinity;
return { state: 'reserve', ratio, reserveSeconds: Math.max(0, seconds) };
}
if (ratio >= TIGHT_AT) return { state: 'tight', ratio, reserveSeconds: null };
return { state: 'ok', ratio, reserveSeconds: null };
}
/** "34s" — floored, because a countdown that rounds up lies at the end. */
export function formatReserve(seconds: number): string {
if (!Number.isFinite(seconds)) return '∞';
if (seconds >= 999) return '999s';
return `${Math.floor(seconds)}s`;
}

View File

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import type { GameData, ResearchState, TechDef } from '../contracts';
import { gateFor, indexTech, isUnlocked, techProgress, techStatus } from './research';
const TECH: TechDef[] = [
{ id: 'disc-artifact-bottling', era: 'disc', cost: { 'analog-pack': 10 }, unlocks: ['artifact-bottler'] },
{ id: 'reel-silver-seams', era: 'reel', cost: { 'analog-pack': 5 }, unlocks: ['extract-silver-frames'] },
{ id: 'stream-software-decoding', era: 'stream', cost: { 'spatial-pack': 4, 'analog-pack': 2 }, unlocks: ['software-decoder', 'mosh-reactor'] },
];
const DATA = { items: [], machines: [], recipes: [], tech: TECH, commissions: [] } as unknown as GameData;
const index = indexTech(DATA);
const research = (over: Partial<ResearchState> = {}): ResearchState =>
({ active: null, progress: {}, unlocked: [], ...over });
describe('the v4 gating rule', () => {
it('locks an id that any tech claims', () => {
expect(isUnlocked(index, research(), 'artifact-bottler')).toBe(false);
expect(isUnlocked(index, research(), 'mosh-reactor')).toBe(false);
});
it('leaves ids referenced by no tech always available', () => {
// "ids referenced by no tech are always available" — belts must never padlock.
expect(isUnlocked(index, research(), 'belt')).toBe(true);
expect(isUnlocked(index, research(), 'quantizer')).toBe(true);
});
it('unlocks every id a completed tech claims', () => {
const r = research({ unlocked: ['stream-software-decoding'] });
expect(isUnlocked(index, r, 'software-decoder')).toBe(true);
expect(isUnlocked(index, r, 'mosh-reactor')).toBe(true);
expect(isUnlocked(index, r, 'artifact-bottler')).toBe(false); // different tech
});
it('treats absent research as nothing unlocked, not as everything unlocked', () => {
// SIM has no research yet; the safe reading of the rule is that gates hold.
expect(isUnlocked(index, undefined, 'artifact-bottler')).toBe(false);
expect(isUnlocked(index, undefined, 'belt')).toBe(true);
});
it('names the tech a locked machine is waiting on, for the REQUIRES tooltip', () => {
expect(gateFor(index, research(), 'artifact-bottler')?.id).toBe('disc-artifact-bottling');
expect(gateFor(index, research(), 'belt')).toBeNull();
expect(gateFor(index, research({ unlocked: ['disc-artifact-bottling'] }), 'artifact-bottler'))
.toBeNull();
});
});
describe('indexTech', () => {
it('groups techs by era in era order, skipping empty eras', () => {
expect(indexTech(DATA).byEra.map((g) => g.era)).toEqual(['reel', 'disc', 'stream']);
});
it('keeps an era DATA invents rather than dropping its techs', () => {
const odd = { ...DATA, tech: [...TECH, { id: 'x', era: 'vhs', cost: {}, unlocks: [] } as unknown as TechDef] };
const eras = indexTech(odd as GameData).byEra.map((g) => g.era);
expect(eras).toContain('vhs');
expect(eras.at(-1)).toBe('vhs'); // last, not jumping the queue
});
});
describe('techStatus / techProgress', () => {
it('reports done, active and available', () => {
expect(techStatus(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe('done');
expect(techStatus(research({ active: 'reel-silver-seams' }), TECH[1])).toBe('active');
expect(techStatus(research(), TECH[1])).toBe('available');
});
it('is 1 when done and 0 when not being worked on', () => {
expect(techProgress(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe(1);
expect(techProgress(research({ active: 'disc-artifact-bottling' }), TECH[1])).toBe(0);
});
it('averages across every pack the tech costs', () => {
// stream-software-decoding wants 4 spatial + 2 analog = 6 packs; 3 delivered = 0.5
const r = research({ active: 'stream-software-decoding', progress: { 'spatial-pack': 2, 'analog-pack': 1 } });
expect(techProgress(r, TECH[2])).toBeCloseTo(0.5);
});
it('never exceeds 1 when the sim over-delivers a pack', () => {
const r = research({ active: 'reel-silver-seams', progress: { 'analog-pack': 99 } });
expect(techProgress(r, TECH[1])).toBe(1);
});
it('is 0 with no research state at all', () => {
expect(techProgress(undefined, TECH[1])).toBe(0);
expect(techStatus(undefined, TECH[1])).toBe('available');
});
});

91
fktry/src/ui/research.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* LANE-UI the research model (pure; see research.test.ts).
*
* CONTRACTS v4 gating rule, verbatim: "a machine/recipe id that appears in ANY
* TechDef.unlocks is locked until that tech completes; ids referenced by no tech are
* always available."
*
* So a lock is a property of the *data*, not of the sim: the tree says what gates what,
* and `ResearchState.unlocked` says what has been paid for. With no research state at all
* (SIM hasn't shipped it yet) nothing is unlocked, which is the correct reading of the
* rule rather than a failure every gated machine shows its padlock.
*/
import type { GameData, ResearchState, TechDef } from '../contracts';
export const ERA_ORDER = ['reel', 'broadcast', 'disc', 'stream', 'fortress'] as const;
export type Era = (typeof ERA_ORDER)[number];
export interface TechIndex {
/** id (machine or recipe) -> the tech that gates it. */
gatedBy: Map<string, TechDef>;
/** techs grouped by era, in era order; eras with no techs are omitted. */
byEra: Array<{ era: string; techs: TechDef[] }>;
}
export function indexTech(data: GameData): TechIndex {
const gatedBy = new Map<string, TechDef>();
for (const t of data.tech) {
for (const id of t.unlocks) {
// First tech to claim an id wins; a second one would be a data bug, and picking
// deterministically beats picking randomly.
if (!gatedBy.has(id)) gatedBy.set(id, t);
}
}
const groups = new Map<string, TechDef[]>();
for (const t of data.tech) {
const list = groups.get(t.era);
if (list) list.push(t);
else groups.set(t.era, [t]);
}
const byEra: TechIndex['byEra'] = [];
for (const era of ERA_ORDER) {
const techs = groups.get(era);
if (techs) byEra.push({ era, techs });
}
// An era DATA invents that we've never heard of still gets shown, just last.
for (const [era, techs] of groups) {
if (!(ERA_ORDER as readonly string[]).includes(era)) byEra.push({ era, techs });
}
return { gatedBy, byEra };
}
export function isUnlocked(index: TechIndex, research: ResearchState | undefined, id: string): boolean {
const gate = index.gatedBy.get(id);
if (!gate) return true; // gated by nothing — always available
return !!research?.unlocked.includes(gate.id);
}
/** The tech a locked id is waiting on, or null if it isn't locked. */
export function gateFor(
index: TechIndex,
research: ResearchState | undefined,
id: string,
): TechDef | null {
const gate = index.gatedBy.get(id);
if (!gate) return null;
return research?.unlocked.includes(gate.id) ? null : gate;
}
export type TechStatus = 'done' | 'active' | 'available';
export function techStatus(research: ResearchState | undefined, tech: TechDef): TechStatus {
if (research?.unlocked.includes(tech.id)) return 'done';
if (research?.active === tech.id) return 'active';
return 'available';
}
/** 0..1 across every pack the tech costs; 1 when done. */
export function techProgress(research: ResearchState | undefined, tech: TechDef): number {
if (research?.unlocked.includes(tech.id)) return 1;
if (!research || research.active !== tech.id) return 0;
let need = 0;
let have = 0;
for (const [item, n] of Object.entries(tech.cost)) {
need += n;
have += Math.min(n, research.progress[item] ?? 0);
}
return need > 0 ? have / need : 0;
}

View File

@ -1,76 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import type { Command, Dir, UIBus, Vec2 } from '../contracts';
import { createSelection, REMOVE_DEF } from './selection';
import type { Command, SelectionState, UIBus, Vec2 } from '../contracts';
import { createSelection } from './selection';
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
function makeBus(): BusWithSel {
const bus: BusWithSel = {
_sel: null,
/** A v4 host: typed selection, main.ts owns the state. */
function makeBus() {
let state: SelectionState = null;
const bus: UIBus = {
dispatch: (_cmd: Command) => {},
selectedBuild: () => bus._sel,
selectedBuild: () => (state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null),
pickTile: (_x: number, _y: number): Vec2 | null => null,
selection: () => state,
setSelection: (s) => { state = s; },
};
return bus;
return { bus, read: () => state };
}
describe('createSelection', () => {
it('writes the selection through to bus._sel, which main.ts reads for the ghost', () => {
const bus = makeBus();
describe('createSelection (v4 typed protocol)', () => {
it('publishes a typed build selection', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('quantizer');
expect(bus._sel).toEqual({ def: 'quantizer', dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 });
expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 });
});
it('toggles off when the selected machine is selected again', () => {
const bus = makeBus();
it('publishes {mode:"remove"} instead of a magic def string', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(read()).toEqual({ mode: 'remove' });
});
it('publishes null when empty-handed', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.clear();
expect(read()).toBeNull();
});
it('toggles a machine off when re-selected', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.select('belt');
expect(sel.get()).toBeNull();
expect(bus._sel).toBeNull();
expect(read()).toBeNull();
});
it('rotates N->E->S->W and wraps', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
const dirs = [1, 2, 3, 0].map(() => {
const dirs = [0, 0, 0, 0].map(() => {
sel.rotate();
return sel.get()!.dir;
return (read() as { dir: number }).dir;
});
expect(dirs).toEqual([1, 2, 3, 0]);
});
it('keeps the facing when swapping machines — you aim once, then build a line', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.rotate();
sel.rotate();
sel.select('demuxer');
expect(sel.get()).toEqual({ def: 'demuxer', dir: 2 });
});
it('does nothing on rotate with nothing selected', () => {
const sel = createSelection(makeBus());
sel.rotate();
expect(sel.get()).toBeNull();
});
it('publishes the __remove sentinel on the bus so RENDER can tint the target', () => {
// The ghost is the only channel the UI has to the renderer (main.ts feeds setGhost
// from selectedBuild), so this sentinel IS the remove-mode hover contract.
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(read()).toEqual({ mode: 'build', def: 'demuxer', dir: 2 });
});
it('reports no build selection while removing, so nothing gets placed', () => {
const bus = makeBus();
const { bus } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(sel.get()).toBeNull();
@ -79,52 +78,52 @@ describe('createSelection', () => {
});
it('disarms remove when a machine is picked — you cannot build and demolish at once', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.select('belt');
expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
expect(read()).toEqual({ mode: 'build', def: 'belt', dir: 0 });
});
it('drops the held machine when remove is armed', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.toggleRemove();
expect(sel.get()).toBeNull();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(read()).toEqual({ mode: 'remove' });
});
it('toggles remove off again, leaving empty hands', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.toggleRemove();
expect(sel.isRemoving()).toBe(false);
expect(sel.isArmed()).toBe(false);
expect(bus._sel).toBeNull();
expect(read()).toBeNull();
});
it('clear() disarms remove mode, which is what Esc rides on', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.clear();
expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toBeNull();
expect(read()).toBeNull();
});
it('does not rotate the wrecking ball', () => {
const bus = makeBus();
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.rotate();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(read()).toEqual({ mode: 'remove' });
});
it('notifies listeners on change, and not on a no-op clear', () => {
const sel = createSelection(makeBus());
const { bus } = makeBus();
const sel = createSelection(bus);
const cb = vi.fn();
sel.onChange(cb);
@ -139,3 +138,31 @@ describe('createSelection', () => {
expect(cb).toHaveBeenCalledTimes(3);
});
});
describe('legacy host fallback', () => {
// selection/setSelection are optional in the contract; a host that predates them must
// still get a selection rather than silently getting nothing.
function legacyBus() {
const bus: UIBus & { _sel?: unknown } = {
_sel: null,
dispatch: () => {},
selectedBuild: () => null,
pickTile: () => null,
};
return bus;
}
it('writes the old {def,dir} shape when setSelection is absent', () => {
const bus = legacyBus();
const sel = createSelection(bus);
sel.select('belt');
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
});
it('still speaks the deprecated __remove sentinel to a legacy host', () => {
const bus = legacyBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(bus._sel).toEqual({ def: '__remove', dir: 0 });
});
});

View File

@ -1,28 +1,16 @@
/**
* LANE-UI the pending build selection, and remove mode.
*
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue,
* and the hook it documents is the `_sel` field on the bus object. UI is the only writer;
* we write through to `_sel` on every change and treat our own copy as the truth.
* v4: the selection is main.ts's state and we write it through `bus.setSelection()`
* (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone the
* renderer gets a real `mode` now and so is our own `remove` dispatch: **main.ts
* dispatches the removal on click.** We only say what is held; main.ts decides what a
* click on the world means.
*
* REMOVE MODE / the `__remove` sentinel
* ------------------------------------
* Remove-mode hover has to tint the target machine, and the renderer's only view of what
* the player is holding is `setGhost(...)`, which main.ts feeds from `selectedBuild()`.
* The UI has no Renderer reference, so the sentinel *is* the channel: while remove mode
* is armed, `_sel` reads `{ def: '__remove', dir: 0 }`. LANE-RENDER keys its demolition
* tint off that def id (agreed via NOTES).
*
* Safe because sim's `place` handler does `const def = defs.get(cmd.def); if (def) ...`
* main.ts's click-to-place fires a `place` for `__remove`, no machine matches, nothing
* happens. The actual removal is dispatched by the UI's own handler.
*
* A `UIBus.setGhostMode(...)` would be cleaner than a magic string; CONTRACT REQUEST filed.
* `selection`/`setSelection` are optional in the contract, so we fall back to the legacy
* `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one).
*/
import type { Dir, UIBus } from '../contracts';
/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */
export const REMOVE_DEF = '__remove';
import type { Dir, SelectionState, UIBus } from '../contracts';
export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */
@ -39,18 +27,32 @@ export interface BuildSelection {
onChange(cb: () => void): void;
}
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection {
const hook = bus as BusWithSel;
let sel: { def: string; dir: Dir } | null = null;
let removing = false;
const listeners: Array<() => void> = [];
function publish() {
const state: SelectionState = removing
? { mode: 'remove' }
: sel
? { mode: 'build', def: sel.def, dir: sel.dir }
: null;
if (bus.setSelection) {
bus.setSelection(state);
} else {
// Legacy host: keep the old shape alive rather than silently doing nothing.
(bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel;
}
}
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
sel = next;
removing = nextRemoving;
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next;
publish();
for (const cb of listeners) cb();
}

View File

@ -82,6 +82,19 @@ const CSS = `
.fk-bw-nums { display: flex; justify-content: space-between; font-size: 11px; }
.fk-bw-draw { color: var(--fk-fg); }
#fk-top.is-tight .fk-bar-f { background: var(--fk-amber); }
/* ON RESERVE: amber, steady, no flashing. The tanks are covering the deficit that is
the factory working, not failing. Only a real brownout gets to shout. */
#fk-top.is-reserve { border-color: var(--fk-amber); }
#fk-top.is-reserve .fk-bar-f { background: var(--fk-amber); }
#fk-reserve {
display: none;
color: var(--fk-amber);
letter-spacing: 0.12em;
margin-top: 3px;
}
#fk-top.is-reserve #fk-reserve { display: block; }
#fk-top.is-brownout { border-color: var(--fk-red); animation: fk-flash 0.45s steps(2) infinite; }
#fk-top.is-brownout .fk-bar-f { background: var(--fk-red); }
#fk-brownout {
@ -125,8 +138,10 @@ const CSS = `
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard. */
.fk-tab-remove { margin-left: auto; color: var(--fk-dim); }
/* REMOVE is a mode, not a machine it sits with the tabs and reads as a hazard.
No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw
REMOVE onto a line of its own. */
.fk-tab-remove { margin-left: 10px; color: var(--fk-dim); }
.fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); }
.fk-tab-remove.is-armed {
color: #12080a; background: var(--fk-red); border-color: var(--fk-red);
@ -160,6 +175,21 @@ const CSS = `
display: flex; align-items: flex-end;
}
.fk-build-accent { width: 100%; height: 4px; }
/* Locked: the machine exists, you just haven't earned it. Greyed, padlocked, still
hoverable so the REQUIRES tooltip can explain itself. */
.fk-build-lock {
display: none;
position: absolute; inset: 0;
align-items: center; justify-content: center;
font-size: 11px;
text-shadow: 0 0 4px #000;
}
.fk-build-lock.is-on { display: flex; }
.fk-build-btn.is-locked { cursor: not-allowed; }
.fk-build-btn.is-locked .fk-build-ico { filter: grayscale(1) brightness(0.45); }
.fk-build-btn.is-locked .fk-build-name { color: var(--fk-faint); }
.fk-build-btn.is-locked:hover { border-color: var(--fk-line); color: var(--fk-faint); }
.fk-build-key {
position: absolute; top: 1px; right: 2px;
font-size: 8px; color: var(--fk-faint);
@ -214,6 +244,47 @@ const CSS = `
.fk-ins-recipe:hover, .fk-ins-recipe:focus { border-color: var(--fk-cool); outline: none; }
.fk-ins-recipe option { background: var(--fk-bg-solid); color: var(--fk-fg); }
/* ---------------------------------------------------------------- tech panel */
#fk-tech {
display: none;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 40px));
max-height: calc(100vh - 160px);
overflow-y: auto;
z-index: 12;
}
#fk-tech.is-open { display: block; }
.fk-tech-sub { color: var(--fk-faint); font-size: 9px; letter-spacing: 0.14em; }
.fk-tech-offline {
display: none;
color: var(--fk-amber); font-size: 10px; margin-top: 6px;
border: 1px solid var(--fk-amber); padding: 3px 6px;
}
.fk-tech-offline.is-on { display: block; }
.fk-tech-body { margin-top: 8px; }
.fk-tech-era { margin-top: 10px; border-bottom: 1px solid var(--fk-line); padding-bottom: 3px; }
.fk-tech-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px;
margin-top: 5px;
}
.fk-tech-node {
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
color: var(--fk-fg);
font: inherit; font-size: 9px; text-align: left;
padding: 5px; cursor: pointer; pointer-events: auto;
}
.fk-tech-node:hover { border-color: var(--fk-cool); }
.fk-tech-node.is-active { border-color: var(--fk-hot); background: #1c0a20; }
.fk-tech-node.is-done { border-color: #2e5a3e; color: var(--fk-dim); cursor: default; }
.fk-tech-node.is-done .fk-tech-name { color: #4e8a5e; }
.fk-tech-name { color: var(--fk-cool); letter-spacing: 0.06em; margin-bottom: 3px; }
.fk-tech-node.is-active .fk-tech-name { color: var(--fk-hot); }
.fk-tech-status { font-size: 8px; letter-spacing: 0.12em; color: var(--fk-dim); margin-top: 3px; }
.fk-tech-node.is-active .fk-tech-status { color: var(--fk-hot); }
#fk-tech .fk-chip { font-size: 9px; }
/* ---------------------------------------------------------------- fax */
#fk-fax {

View File

@ -0,0 +1,138 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest';
import type { Command, GameData, ResearchState, SimSnapshot, UIBus } from '../contracts';
import { createTechPanel } from './techpanel';
const DATA: GameData = {
items: [
{ id: 'analog-pack', name: 'ANALOG PACK', codex: '', tier: 2, color: '#7fff9f' },
{ id: 'spatial-pack', name: 'SPATIAL PACK', codex: '', tier: 2, color: '#7fb0ff' },
],
machines: [
{ id: 'software-decoder', name: 'SOFTWARE DECODER', codex: '', kind: 'power', footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'x' },
],
recipes: [],
tech: [
{ id: 'reel-silver-seams', era: 'reel', cost: { 'analog-pack': 4 }, unlocks: ['extract-silver'] },
{ id: 'stream-software-decoding', era: 'stream', cost: { 'analog-pack': 2, 'spatial-pack': 2 }, unlocks: ['software-decoder'] },
],
commissions: [],
};
function mount(research?: ResearchState) {
document.body.innerHTML = '';
const sent: Command[] = [];
const bus: UIBus = {
dispatch: (c) => sent.push(c),
selectedBuild: () => null,
pickTile: () => null,
};
const panel = createTechPanel(DATA, bus);
document.body.append(panel.el);
const snap = { research } as unknown as SimSnapshot;
return { panel, sent, snap };
}
const nodes = () => Array.from(document.querySelectorAll('.fk-tech-node')) as HTMLElement[];
const nodeFor = (text: string) => nodes().find((n) => n.textContent?.includes(text))!;
describe('tech panel', () => {
it('starts shut and toggles on T', () => {
const { panel } = mount();
expect(panel.isOpen()).toBe(false);
panel.toggle();
expect(panel.isOpen()).toBe(true);
expect(panel.el.classList.contains('is-open')).toBe(true);
panel.close();
expect(panel.isOpen()).toBe(false);
});
it('renders one node per tech, grouped under era headings in era order', () => {
const { panel } = mount();
panel.toggle();
expect(nodes()).toHaveLength(2);
const eras = Array.from(document.querySelectorAll('.fk-tech-era')).map((e) => e.textContent);
expect(eras).toEqual(['REEL', 'STREAM']);
});
it('strips the era prefix from the node name — the heading already says it', () => {
const { panel } = mount();
panel.toggle();
expect(nodeFor('SILVER SEAMS').querySelector('.fk-tech-name')!.textContent)
.toBe('SILVER SEAMS');
});
it('says what a tech unlocks, in machine names not ids', () => {
const { panel } = mount();
panel.toggle();
expect(nodeFor('SOFTWARE DECODING').getAttribute('title'))
.toBe('UNLOCKS: SOFTWARE DECODER');
});
it('dispatches setResearch when a node is picked', () => {
const { panel, sent, snap } = mount({ active: null, progress: {}, unlocked: [] });
panel.toggle();
panel.update(snap);
nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(sent).toEqual([{ kind: 'setResearch', tech: 'reel-silver-seams' }]);
});
it('stands the active tech down when it is picked again', () => {
const { panel, sent, snap } = mount({ active: 'reel-silver-seams', progress: {}, unlocked: [] });
panel.toggle();
panel.update(snap);
nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(sent).toEqual([{ kind: 'setResearch', tech: null }]);
});
it('ignores clicks on a ratified tech', () => {
const { panel, sent, snap } = mount({ active: null, progress: {}, unlocked: ['reel-silver-seams'] });
panel.toggle();
panel.update(snap);
nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(sent).toEqual([]);
});
it('marks active and ratified nodes, and shows pack progress on the active one', () => {
const { panel, snap } = mount({
active: 'stream-software-decoding',
progress: { 'analog-pack': 1 },
unlocked: ['reel-silver-seams'],
});
panel.toggle();
panel.update(snap);
const done = nodeFor('SILVER SEAMS');
expect(done.classList.contains('is-done')).toBe(true);
expect(done.querySelector('.fk-tech-status')!.textContent).toBe('RATIFIED');
const active = nodeFor('SOFTWARE DECODING');
expect(active.classList.contains('is-active')).toBe(true);
expect(active.querySelector('.fk-tech-status')!.textContent).toBe('IN COMMITTEE');
// 1 of 4 packs delivered
expect((active.querySelector('.fk-bar-f') as HTMLElement).style.width).toBe('25%');
expect(active.textContent).toContain('1/2'); // analog packs
});
it('says RESEARCH OFFLINE when the sim reports no research state', () => {
const { panel, snap } = mount(undefined);
panel.toggle();
panel.update(snap);
expect(document.querySelector('.fk-tech-offline')!.classList.contains('is-on')).toBe(true);
});
it('drops the offline notice once research is running', () => {
const { panel } = mount();
panel.toggle();
panel.update({ research: { active: null, progress: {}, unlocked: [] } } as unknown as SimSnapshot);
expect(document.querySelector('.fk-tech-offline')!.classList.contains('is-on')).toBe(false);
});
it('does no work while shut', () => {
const { panel, snap } = mount({ active: 'reel-silver-seams', progress: {}, unlocked: [] });
const spy = vi.spyOn(document, 'querySelector');
panel.update(snap); // closed
spy.mockRestore();
expect(nodeFor('SILVER SEAMS').classList.contains('is-active')).toBe(false);
});
});

130
fktry/src/ui/techpanel.ts Normal file
View File

@ -0,0 +1,130 @@
/**
* LANE-UI the research panel (T). The tech tree as media archaeology: you research
* downward into older strata and upward into newer codecs (lore §6), so the eras are the
* spine and each node is one ratified standard.
*
* Read-mostly: pick a node and it dispatches `setResearch`. Progress, active and unlocked
* all come from `snapshot.research` nothing here remembers anything.
*/
import type { GameData, ItemDef, SimSnapshot, TechDef, UIBus } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { attrs, cls, el, panel, style, text } from './dom';
import { indexTech, techProgress, techStatus, type TechIndex } from './research';
import { COPY } from './voice';
export interface TechPanel {
el: HTMLElement;
toggle(): void;
close(): void;
isOpen(): boolean;
update(snap: SimSnapshot): void;
}
interface NodeView {
tech: TechDef;
root: HTMLElement;
fill: HTMLElement;
chips: ChipRow;
status: HTMLElement;
}
export function createTechPanel(data: GameData, bus: UIBus): TechPanel {
const index: TechIndex = indexTech(data);
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
const machines = new Map(data.machines.map((m) => [m.id, m.name]));
const root = panel();
root.id = 'fk-tech';
const closeBtn = el('button', 'fk-ins-x', [COPY.inspectorClose]);
attrs(closeBtn, { type: 'button', title: 'CLOSE (T / ESC)' });
const head = el('div', 'fk-ins-head', [el('div', 'fk-ins-name', [COPY.techHeader]), closeBtn]);
const sub = el('div', 'fk-tech-sub', [COPY.techSubheader]);
const offline = el('div', 'fk-tech-offline', [COPY.researchOffline]);
const body = el('div', 'fk-tech-body');
root.append(head, sub, offline, body);
const views: NodeView[] = [];
for (const { era, techs } of index.byEra) {
body.append(el('div', 'fk-h fk-tech-era', [era.toUpperCase()]));
const grid = el('div', 'fk-tech-grid');
for (const tech of techs) {
const name = el('div', 'fk-tech-name', [techLabel(tech)]);
const status = el('div', 'fk-tech-status');
const fill = el('div', 'fk-bar-f');
const bar = el('div', 'fk-bar', [fill]);
const chips = createChipRow(items);
const node = el('button', 'fk-tech-node', [name, chips.el, bar, status]);
attrs(node, { type: 'button', title: unlocksLabel(tech) });
node.addEventListener('click', () => {
// Clicking the active tech stands it down; clicking a done one does nothing.
const done = views.find((v) => v.tech.id === tech.id)!.root.classList.contains('is-done');
if (done) return;
const active = root.querySelector('.fk-tech-node.is-active');
bus.dispatch({ kind: 'setResearch', tech: active === node ? null : tech.id });
});
grid.append(node);
views.push({ tech, root: node, fill, chips, status });
}
body.append(grid);
}
/** "SILVER SEAMS" — the era prefix is already the heading above it. */
function techLabel(t: TechDef): string {
return t.id.replace(new RegExp(`^${t.era}-`), '').replace(/-/g, ' ').toUpperCase();
}
function unlocksLabel(t: TechDef): string {
const names = t.unlocks.map((u) => machines.get(u) ?? u.replace(/-/g, ' ').toUpperCase());
return `UNLOCKS: ${names.join(', ')}`;
}
let open = false;
function setOpen(v: boolean) {
open = v;
cls(root, 'is-open', v);
}
closeBtn.addEventListener('click', () => setOpen(false));
return {
el: root,
toggle: () => setOpen(!open),
close: () => setOpen(false),
isOpen: () => open,
update(snap) {
if (!open) return; // 24 nodes is not worth a frame's work while shut
const research = snap.research;
// No research state at all means the sim isn't running research yet — say so
// plainly rather than rendering a tree that silently can't do anything.
cls(offline, 'is-on', !research);
for (const v of views) {
const st = techStatus(research, v.tech);
cls(v.root, 'is-done', st === 'done');
cls(v.root, 'is-active', st === 'active');
style(v.fill.parentElement!, 'display', st === 'available' ? 'none' : '');
if (st !== 'available') style(v.fill, 'width', `${techProgress(research, v.tech) * 100}%`);
text(v.status, st === 'done' ? COPY.techDone : st === 'active' ? COPY.techActive : '');
v.chips.update(
Object.entries(v.tech.cost).map(([item, need]) => {
const have = st === 'done' ? need : (research?.active === v.tech.id ? research.progress[item] ?? 0 : 0);
return {
item,
count: `${Math.min(have, need)}/${need}`,
state: have >= need ? ('met' as const) : ('plain' as const),
};
}),
);
}
},
};
}

View File

@ -7,6 +7,7 @@
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { cls, el, panel, style, text } from './dom';
import { formatReserve, powerState } from './power';
import { COPY } from './voice';
export interface TopStrip {
@ -14,9 +15,6 @@ export interface TopStrip {
update(snap: SimSnapshot): void;
}
/** Draw above this fraction of generation is "tight" — amber, before it's a fault. */
const TIGHT_AT = 0.85;
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
const v = el('span', valueCls ? `fk-stat-v ${valueCls}` : 'fk-stat-v');
const row = el('div', 'fk-stat', [el('span', undefined, [label]), v]);
@ -40,6 +38,10 @@ export function createTopStrip(data: GameData): TopStrip {
brownout.id = 'fk-brownout';
brownout.textContent = COPY.brownout;
// Amber, not red: the factory is surviving on stored charge, not failing.
const reserve = el('div');
reserve.id = 'fk-reserve';
const buffer = stat('BUFFER', 'fk-buf');
const shipped = stat(COPY.shipped);
const run = stat(COPY.tick);
@ -48,24 +50,31 @@ export function createTopStrip(data: GameData): TopStrip {
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
const chips: ChipRow = createChipRow(items);
root.append(head, nums, bar, brownout, el('div', 'fk-rule'),
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
buffer.row, shipped.row, run.row, chips.el);
return {
el: root,
update(snap) {
const { gen, draw, stored, brownout: out } = snap.bandwidth;
const { gen, draw, stored, capacity } = snap.bandwidth;
text(drawEl, `${draw.toFixed(0)} DRAW`);
text(genEl, `${gen.toFixed(0)} GEN`);
// With no generators at all, any draw is already over budget.
const ratio = gen > 0 ? draw / gen : draw > 0 ? Infinity : 0;
style(fill, 'width', `${Math.min(1, ratio) * 100}%`);
cls(root, 'is-tight', !out && ratio >= TIGHT_AT);
cls(root, 'is-brownout', out || ratio > 1);
// BROWNOUT is the sim's flag and nothing else. A covered deficit is the tanks
// doing their job, and it gets its own amber word — see power.ts.
const p = powerState(snap.bandwidth);
style(fill, 'width', `${Math.min(1, p.ratio) * 100}%`);
cls(root, 'is-tight', p.state === 'tight');
cls(root, 'is-reserve', p.state === 'reserve');
cls(root, 'is-brownout', p.state === 'brownout');
text(buffer.v, stored.toFixed(0));
if (p.state === 'reserve') {
text(reserve, `${COPY.onReserve} · ${formatReserve(p.reserveSeconds!)}`);
}
// Capacity (v4) turns the buffer readout into a fuel gauge rather than a number.
text(buffer.v, capacity ? `${stored.toFixed(0)}/${capacity.toFixed(0)}` : stored.toFixed(0));
let total = 0;
for (const n of Object.values(snap.shippedTotal)) total += n;

View File

@ -81,6 +81,8 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
case 'removed':
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
case 'researched':
return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.';
default:
return null; // shipped/crafted/placed are too frequent to toast
}
@ -89,6 +91,8 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
export const COPY = {
bandwidth: 'BANDWIDTH',
brownout: 'BROWNOUT',
/** Deficit covered by the tanks. Not a fault — the machine is doing its job. */
onReserve: 'ON RESERVE',
shipped: 'SHIPPED',
paused: 'HALTED',
running: 'RUNNING',
@ -114,8 +118,16 @@ export const COPY = {
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
sanitising: 'SANITISING',
techHeader: 'STANDARDS COMMITTEE',
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
techActive: 'IN COMMITTEE',
techDone: 'RATIFIED',
/** Shown when the sim reports no research state at all. */
researchOffline: 'RESEARCH OFFLINE. THE COMMITTEE HAS NOT CONVENED.',
requires: 'REQUIRES:',
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · SPACE HALT · CLICK A UNIT TO INSPECT',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT',
removeLabel: 'REMOVE',
removeKey: 'X',