Compare commits
5 Commits
c97dd80a9b
...
edca93f298
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edca93f298 | ||
|
|
7501353311 | ||
|
|
2904ddbb8c | ||
|
|
5a36e32246 | ||
|
|
3cec62368a |
@ -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/(draw−gen)` — 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).
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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' }));
|
||||
|
||||
@ -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". */
|
||||
|
||||
77
fktry/src/ui/power.test.ts
Normal file
77
fktry/src/ui/power.test.ts
Normal 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
59
fktry/src/ui/power.ts
Normal 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`;
|
||||
}
|
||||
89
fktry/src/ui/research.test.ts
Normal file
89
fktry/src/ui/research.test.ts
Normal 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
91
fktry/src/ui/research.ts
Normal 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;
|
||||
}
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
138
fktry/src/ui/techpanel.test.ts
Normal file
138
fktry/src/ui/techpanel.test.ts
Normal 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
130
fktry/src/ui/techpanel.ts
Normal 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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user