Compare commits

..

No commits in common. "edca93f2988496017c07776edeb66e67e1c27598" and "c97dd80a9b80085e584e1cd496fa6e627b484352" have entirely different histories.

16 changed files with 135 additions and 1128 deletions

View File

@ -367,104 +367,3 @@ 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,11 +2,10 @@
* 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, SimSnapshot } from '../contracts';
import type { GameData, MachineDef } 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';
@ -16,16 +15,11 @@ 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';
@ -60,12 +54,13 @@ 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; lock: HTMLElement }> = [];
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
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');
@ -74,8 +69,6 @@ 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');
@ -83,33 +76,10 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
btn.append(key);
btn.append(el('span', 'fk-build-name', [m.name]));
btn.addEventListener('click', () => select(m.id));
btn.addEventListener('click', () => sel.select(m.id));
row.append(btn);
return { def: m.id, btn, lock };
return { def: m.id, btn };
});
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() {
@ -156,24 +126,9 @@ 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) select(b.def);
if (b) sel.select(b.def);
},
pageBy(delta) {
if (pages.length === 0) return;

View File

@ -15,7 +15,6 @@ 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';
@ -26,7 +25,6 @@ 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();
@ -51,9 +49,8 @@ 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, tech.el, toasts.el);
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
// ------------------------------------------------------------------ keyboard
const keys = createHotkeys();
@ -62,12 +59,10 @@ 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), then shut whatever is open.
// first (wrecking ball included), close the panel only when empty-handed.
if (sel.isArmed()) sel.clear();
else if (tech.isOpen()) tech.close();
else inspector.close();
});
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
@ -88,28 +83,24 @@ export function createUI(): UI {
addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !inWorld(e.target)) return;
// 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;
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
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[]) {
@ -117,10 +108,8 @@ 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, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import type { EntityState, GameData, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { referenceFactory } from '../sim/reference';
import { createUI } from './index';
@ -28,46 +28,22 @@ import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
/** 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 } = {}) {
function harness() {
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;
// 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 = {
const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = {
_sel: null,
dispatch: (c) => sim.enqueue(c),
selectedBuild: () =>
state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null,
selectedBuild: () => bus._sel,
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);
// 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);
for (const c of referenceFactory(DATA)) sim.enqueue(c);
const step = (n = 1) => {
for (let i = 0; i < n; i++) {
@ -75,15 +51,17 @@ function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
ui.update(sim.snapshot(), sim.drainEvents());
}
};
/** A real click on the world: main.ts's handler and the UI's both see it. */
/** The real click-to-inspect path: pickTile -> entityAt -> inspector.open. */
const clickTile = (t: Vec2) => {
pick = t;
game.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
document
.getElementById('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, state: () => state };
return { sim, step, clickTile, find, bus };
}
const $ = (s: string) => document.querySelector(s) as HTMLElement;
@ -92,7 +70,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({ unlocked: true });
const h = harness();
h.step(200);
const snap = h.sim.snapshot();
expect(snap.entities.length).toBeGreaterThan(20);
@ -101,7 +79,7 @@ describe('the HUD against the reference factory', () => {
});
it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
const h = harness({ unlocked: true });
const h = harness();
h.step(50);
const dec = h.find('software-decoder');
@ -129,7 +107,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({ unlocked: true });
const h = harness();
h.step(50);
const dec = h.find('software-decoder')!;
h.clickTile(dec.pos);
@ -143,7 +121,7 @@ describe('the HUD against the reference factory', () => {
});
it('ships and tickers up', () => {
const h = harness({ unlocked: true });
const h = harness();
h.step(3000);
const shipped = h.sim.snapshot().shippedTotal;
expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
@ -155,16 +133,12 @@ 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({ unlocked: true });
const h = harness();
h.step(200);
expect(on('.fk-san')).toBe(false); // nothing sanitised yet
// 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 });
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 });
let shippedAt = -1;
for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
@ -177,7 +151,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({ unlocked: true });
const h = harness();
let sawStanding = false;
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
h.step(100);
@ -200,7 +174,7 @@ describe('the HUD against the reference factory', () => {
});
it('completes a commission and stamps the fax', () => {
const h = harness({ unlocked: true });
const h = harness();
let done = false;
for (let t = 0; t < 6000 && !done; t += 50) {
h.step(50);
@ -211,7 +185,7 @@ describe('the HUD against the reference factory', () => {
});
it('speaks in the house voice when things go wrong', () => {
const h = harness({ unlocked: true });
const h = harness();
h.step(400);
const all = toasts().join(' | ');
// Something in a cold-starting factory is always starved; whatever it is, it must
@ -221,121 +195,16 @@ 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({ unlocked: true });
const h = harness();
h.step(50);
const before = h.sim.snapshot().entities.length;
const victim = h.find('belt')!;
// arm remove the way the X hotkey does — typed protocol, no magic string
// arm remove the way the X hotkey does
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(h.state()).toEqual({ mode: 'remove' });
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // the ghost sentinel
h.clickTile(victim.pos);
h.step(2);
@ -346,21 +215,21 @@ describe('remove mode against the real sim', () => {
});
it('stays armed for a second demolition, and Esc stands it down', () => {
const h = harness({ unlocked: true });
const h = harness();
h.step(50);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
const first = h.find('belt')!;
h.clickTile(first.pos);
h.step(2);
expect(h.state()).toEqual({ mode: 'remove' }); // still armed
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // still armed
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(h.state()).toBeNull();
expect(h.bus._sel).toBeNull();
});
it('does not fire at open ground', () => {
const h = harness({ unlocked: true });
const h = harness();
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', 'RESEARCH', 'SHIP'];
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP'];
export interface BuildPage {
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */

View File

@ -1,77 +0,0 @@
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('∞');
});
});

View File

@ -1,59 +0,0 @@
/**
* 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

@ -1,89 +0,0 @@
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');
});
});

View File

@ -1,91 +0,0 @@
/**
* 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,75 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import type { Command, SelectionState, UIBus, Vec2 } from '../contracts';
import { createSelection } from './selection';
import type { Command, Dir, UIBus, Vec2 } from '../contracts';
import { createSelection, REMOVE_DEF } from './selection';
/** A v4 host: typed selection, main.ts owns the state. */
function makeBus() {
let state: SelectionState = null;
const bus: UIBus = {
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
function makeBus(): BusWithSel {
const bus: BusWithSel = {
_sel: null,
dispatch: (_cmd: Command) => {},
selectedBuild: () => (state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null),
selectedBuild: () => bus._sel,
pickTile: (_x: number, _y: number): Vec2 | null => null,
selection: () => state,
setSelection: (s) => { state = s; },
};
return { bus, read: () => state };
return bus;
}
describe('createSelection (v4 typed protocol)', () => {
it('publishes a typed build selection', () => {
const { bus, read } = makeBus();
describe('createSelection', () => {
it('writes the selection through to bus._sel, which main.ts reads for the ghost', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.select('quantizer');
expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 });
expect(bus._sel).toEqual({ def: 'quantizer', dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 });
});
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();
it('toggles off when the selected machine is selected again', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.select('belt');
expect(sel.get()).toBeNull();
expect(read()).toBeNull();
expect(bus._sel).toBeNull();
});
it('rotates N->E->S->W and wraps', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.select('belt');
const dirs = [0, 0, 0, 0].map(() => {
const dirs = [1, 2, 3, 0].map(() => {
sel.rotate();
return (read() as { dir: number }).dir;
return sel.get()!.dir;
});
expect(dirs).toEqual([1, 2, 3, 0]);
});
it('keeps the facing when swapping machines — you aim once, then build a line', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.rotate();
sel.rotate();
sel.select('demuxer');
expect(read()).toEqual({ mode: 'build', def: 'demuxer', dir: 2 });
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 });
});
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();
@ -78,52 +79,52 @@ describe('createSelection (v4 typed protocol)', () => {
});
it('disarms remove when a machine is picked — you cannot build and demolish at once', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.select('belt');
expect(sel.isRemoving()).toBe(false);
expect(read()).toEqual({ mode: 'build', def: 'belt', dir: 0 });
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
});
it('drops the held machine when remove is armed', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.toggleRemove();
expect(sel.get()).toBeNull();
expect(read()).toEqual({ mode: 'remove' });
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
});
it('toggles remove off again, leaving empty hands', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.toggleRemove();
expect(sel.isRemoving()).toBe(false);
expect(sel.isArmed()).toBe(false);
expect(read()).toBeNull();
expect(bus._sel).toBeNull();
});
it('clear() disarms remove mode, which is what Esc rides on', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.clear();
expect(sel.isRemoving()).toBe(false);
expect(read()).toBeNull();
expect(bus._sel).toBeNull();
});
it('does not rotate the wrecking ball', () => {
const { bus, read } = makeBus();
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.rotate();
expect(read()).toEqual({ mode: 'remove' });
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
});
it('notifies listeners on change, and not on a no-op clear', () => {
const { bus } = makeBus();
const sel = createSelection(bus);
const sel = createSelection(makeBus());
const cb = vi.fn();
sel.onChange(cb);
@ -138,31 +139,3 @@ describe('createSelection (v4 typed protocol)', () => {
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,16 +1,28 @@
/**
* LANE-UI the pending build selection, and remove mode.
*
* 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.
* 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.
*
* `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).
* 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.
*/
import type { Dir, SelectionState, UIBus } from '../contracts';
import type { Dir, UIBus } from '../contracts';
/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */
export const REMOVE_DEF = '__remove';
export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */
@ -27,32 +39,18 @@ export interface BuildSelection {
onChange(cb: () => void): void;
}
type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
type BusWithSel = 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;
publish();
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next;
for (const cb of listeners) cb();
}

View File

@ -82,19 +82,6 @@ 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 {
@ -138,10 +125,8 @@ 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.
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); }
/* 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); }
.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);
@ -175,21 +160,6 @@ 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);
@ -244,47 +214,6 @@ 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

@ -1,138 +0,0 @@
// @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);
});
});

View File

@ -1,130 +0,0 @@
/**
* 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,7 +7,6 @@
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 {
@ -15,6 +14,9 @@ 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]);
@ -38,10 +40,6 @@ 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);
@ -50,31 +48,24 @@ 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, reserve, el('div', 'fk-rule'),
root.append(head, nums, bar, brownout, el('div', 'fk-rule'),
buffer.row, shipped.row, run.row, chips.el);
return {
el: root,
update(snap) {
const { gen, draw, stored, capacity } = snap.bandwidth;
const { gen, draw, stored, brownout: out } = snap.bandwidth;
text(drawEl, `${draw.toFixed(0)} DRAW`);
text(genEl, `${gen.toFixed(0)} GEN`);
// 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');
// 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);
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));
text(buffer.v, stored.toFixed(0));
let total = 0;
for (const n of Object.values(snap.shippedTotal)) total += n;

View File

@ -81,8 +81,6 @@ 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
}
@ -91,8 +89,6 @@ 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',
@ -118,16 +114,8 @@ 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 · T RESEARCH · SPACE HALT · CLICK A UNIT',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · SPACE HALT · CLICK A UNIT TO INSPECT',
removeLabel: 'REMOVE',
removeKey: 'X',