Compare commits
5 Commits
34e2bb3fcf
...
90703c4bcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90703c4bcd | ||
|
|
a57729ce33 | ||
|
|
e25c854be8 | ||
|
|
cd733445d9 | ||
|
|
491fbf2d68 |
@ -146,3 +146,93 @@ exactly what MASTERPLAN says not to do. Say the word and it's a small round-2 ch
|
||||
**NEXT (suggested):** wire click-to-inspect the moment request 1 lands and delete the TAB
|
||||
fallback; recipe picker in the inspector (`setRecipe` exists in Command and has no UI);
|
||||
build bar paging; tech/research panel once `tech.json` is populated (it's `[]`).
|
||||
|
||||
### Round 2 — 2026-07-17 — Opus 4.8
|
||||
|
||||
**SHIPPED:** All five orders. New: `pages.ts` (pagination), `chipspec.ts` (pure chip
|
||||
arithmetic), six `*.test.ts`. Deleted: `fixture.ts` and the TAB-cycle fallback, as ordered.
|
||||
|
||||
1. **Click-to-inspect** via `bus.pickTile`. The `window.__fktryRenderer` probe and the
|
||||
TAB fallback are gone.
|
||||
2. **Build bar paging** — kind tabs (EXTRACT / LOGISTICS / REFINE / POWER / SHIP), `[`/`]`,
|
||||
1-9 within the page. Selecting from anywhere pulls the bar to the owning page.
|
||||
3. **Inspector** — recipe picker (`setRecipe`), heat bar w/ THROTTLING/SCRAM states,
|
||||
jam wording via `jamText()`.
|
||||
4. **Fax** — NEXT IN TRAY peek, STANDING ORDER stamp on `repeat`, SANITISING chip on
|
||||
shipped anchor-slabs.
|
||||
5. **57 tests** (min was 10), all green.
|
||||
|
||||
**VERIFIED** (against the real sim, in a browser, on a static `vite build`+`preview` —
|
||||
see BLOCKED for why not the dev server): built a SEAM EXTRACTOR by mouse → DRAW 0→4 with
|
||||
no generator → **real BROWNOUT**, red panel, buffer 0, underrun toast, THE SCREEN
|
||||
visibly corrupting. Clicked the unit → inspector opened on entity 1 (`pickTile` →
|
||||
{18,-7} → `entityAt`). Placed a SOFTWARE DECODER → GEN 0→55, brownout lifted, "BUFFER
|
||||
RESTORED. RESUME SINNING." Recipe picker: options rendered as readable processes ("1
|
||||
LUMA BARS → 1 COEFFICIENT PACK + 1 HF DUST"), selecting IDLE dispatched
|
||||
`setRecipe{entity:1,recipe:null}` and the sim round-tripped it back as "UNIT IS BEING
|
||||
PAID TO EXIST"; selecting back dispatched `recipe:'quantize'`. Starved wording live in
|
||||
both toast and inspector: "QUANTIZER: INTAKE STARVED. NOTHING IS ARRIVING." Paging walk
|
||||
confirmed all 21 machines across 6 pages (1+3+9+5+2+1) with **no page over 9 slots**.
|
||||
`#ui` still computes `pointer-events:none`. `npm run check` clean; 160/160 tests.
|
||||
|
||||
**DECISIONS:**
|
||||
- **Kind tabs alone could not satisfy "every machine hotkey-reachable at any catalog
|
||||
size"** — REFINE holds 14 crafters and 1-9 reaches nine. So a page is capped at 9 and an
|
||||
oversized kind spills into numbered pages (REFINE 1/2, REFINE 2/2). The invariant the
|
||||
order asked for now holds for any catalog; `pages.test.ts` pins it at 1/9/14/40 crafters.
|
||||
- Logic was pulled out of the DOM modules into `pages.ts` / `chipspec.ts` / a pure
|
||||
`createKeyRouter`, which is *why* there are 57 tests without happy-dom (below).
|
||||
- Data-first everywhere: `MachineDef.flavor` and `.color` win, round-1 hand-transcribed
|
||||
tables stay as fallback. LANE-DATA's flavor is what's on screen now.
|
||||
- Unknown jam reasons pass through uppercased rather than being swallowed, so a new sim
|
||||
stall reason shows up honestly instead of silently reading as generic.
|
||||
|
||||
**BLOCKED/BROKEN (read this part):**
|
||||
- **`happy-dom` is NOT installed** — order 5 says "GRANTED — happy-dom is installed"; it
|
||||
is not in `package.json` and not in `node_modules` (`npm ls happy-dom` → empty). I did
|
||||
not add it: root config is orchestrator-owned and editing it is a hard rule. Instead I
|
||||
factored the testable logic into pure modules and wrote **57 node-env tests** covering
|
||||
everything order 5 named except the two that genuinely need a DOM: chip *rendering*
|
||||
(the arithmetic is covered in `chipspec.test.ts`) and `attach()`'s window binding (the
|
||||
routing is covered in `hotkeys.test.ts`). Install it and I'll add the render tests.
|
||||
- **STANDING ORDER stamp is built but unobservable.** Data has 3 `repeat` commissions
|
||||
(dust-allowance, correction-buyback, melt-retainer) but the sim pins the active
|
||||
commission to `commissions[0]` (`first-taste`, not repeat) and never advances —
|
||||
`commissionDoneEmitted` latches forever. So nothing can ever reach the stamp. Code is
|
||||
data-driven and will light up the moment the sim rotates commissions.
|
||||
- **NEXT IN TRAY is an inference, not truth.** The snapshot has `activeCommission` but no
|
||||
queue, so "next" = the following entry in data order. It renders (NEXT IN TRAY → DUST
|
||||
ALLOWANCE) but if the sim ever picks non-sequentially the peek will lie. See CONTRACT
|
||||
REQUEST 1.
|
||||
- **Heat bar unverified live.** Only `software-decoder` has `heatPerTick` (0.0025), and
|
||||
it heats only while crafting; a decoder with no load sits at heat 0, so the bar
|
||||
correctly stays hidden and I never saw THROTTLING/SCRAM. Thresholds are built to
|
||||
CONTRACTS (>0.7 throttle, 1.0 scram). Needs a fed chain to confirm.
|
||||
- **SANITISING chip unverified live** — needs anchor-slabs actually shipped, which needs
|
||||
the full M1 chain fed. Renders off `shippedTotal`, so it is one shipment away.
|
||||
- **The dev server is unusable for verification.** Other lanes are editing `render/` and
|
||||
`screen/` continuously; vite logged **16 page reloads** in ~10 minutes, wiping the world
|
||||
mid-test every time. I verified on `vite build` + `vite preview` instead. Not a
|
||||
complaint — just: if a round asks for hands-on verification, expect this.
|
||||
- During the round I hit LANE-SCREEN's `glitchStack.ts` and LANE-RENDER's `index.ts`
|
||||
mid-edit and broken (tsc errors, dead rAF loop). Both self-resolved. `src/ui` was clean
|
||||
throughout; the one failing test at the time was LANE-SIM's own `sim.test.ts`.
|
||||
- Harness note (unchanged): browser-automation `key: space` sends an empty `event.key`;
|
||||
space verified via a dispatched `' '` event. Real spacebar sends `' '` per spec.
|
||||
|
||||
**CONTRACT REQUEST:**
|
||||
1. **`SimSnapshot.commissionQueue: string[]`** (or `nextCommission: string | null`) —
|
||||
*why:* NEXT IN TRAY currently guesses from data order. Low cost, removes a UI lie.
|
||||
2. **Sim: advance/rotate commissions and honour `repeat`** — not strictly a contract
|
||||
change, a SIM ask: `commissions[0]` forever means 13 of 14 commissions and all 3
|
||||
standing orders are unreachable, and the fax's whole queue UI is decorative.
|
||||
3. **`happy-dom` actually installed** (see BLOCKED) — the grant didn't land in
|
||||
package.json.
|
||||
|
||||
**PROPOSAL:** the inspector should get a REMOVE button (`remove` exists in Command with
|
||||
no UI, so a misplaced machine currently can't be undone by mouse at all). Small, and it
|
||||
closes the last build-verb gap.
|
||||
|
||||
**NEXT (suggested):** remove-by-mouse; heat/scram + SANITISING confirmation once a chain
|
||||
can be fed; commission queue UI becomes real once requests 1-2 land; tech panel when
|
||||
`tech.json` fills (still `[]`).
|
||||
|
||||
@ -1,53 +1,76 @@
|
||||
/**
|
||||
* LANE-UI — build bar. One button per machine in GameData.machines, hotkeys 1-9.
|
||||
* 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 } from '../contracts';
|
||||
import type { GameData, MachineDef } from '../contracts';
|
||||
import { attrs, cls, el, panel, text } from './dom';
|
||||
import { paginate, pageOf, type BuildPage } from './pages';
|
||||
import { machineColor } from './palette';
|
||||
import type { BuildSelection } from './selection';
|
||||
import { COPY, DIR_NAME } from './voice';
|
||||
|
||||
export interface BuildBar {
|
||||
el: HTMLElement;
|
||||
/** Select the machine in slot `n` (1-based, as printed on the button). */
|
||||
/** Select slot `n` (1-based) on the active page. */
|
||||
selectSlot(n: number): void;
|
||||
pageBy(delta: number): void;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const pages: BuildPage[] = paginate(data.machines);
|
||||
|
||||
const root = panel();
|
||||
root.id = 'fk-build';
|
||||
|
||||
const tabRow = el('div', 'fk-tabs');
|
||||
const row = el('div', 'fk-build-row');
|
||||
const hint = el('div');
|
||||
hint.id = 'fk-build-hint';
|
||||
root.append(tabRow, row, hint);
|
||||
|
||||
const buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
|
||||
let active = 0;
|
||||
|
||||
data.machines.forEach((m, i) => {
|
||||
const btn = el('button', 'fk-build-btn');
|
||||
attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` });
|
||||
|
||||
const ico = el('div', 'fk-build-ico');
|
||||
ico.style.background = machineColor(m);
|
||||
btn.append(ico);
|
||||
|
||||
// Only the first nine get a printed hotkey; the rest are click-only until the
|
||||
// bar grows tabs/pages (a later round's problem).
|
||||
if (i < 9) {
|
||||
const key = el('span', 'fk-build-key');
|
||||
key.textContent = String(i + 1);
|
||||
btn.append(key);
|
||||
}
|
||||
|
||||
btn.append(el('span', 'fk-build-name', [m.name]));
|
||||
btn.addEventListener('click', () => sel.select(m.id));
|
||||
row.append(btn);
|
||||
buttons.push({ def: m.id, btn });
|
||||
const tabs = pages.map((p, i) => {
|
||||
const tab = el('button', 'fk-tab', [p.label]);
|
||||
attrs(tab, { type: 'button' });
|
||||
tab.addEventListener('click', () => {
|
||||
active = i;
|
||||
render();
|
||||
});
|
||||
tabRow.append(tab);
|
||||
return tab;
|
||||
});
|
||||
|
||||
root.append(row, hint);
|
||||
/** Buttons for the active page only; rebuilt on page change (nine at most). */
|
||||
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}` });
|
||||
|
||||
const ico = el('div', 'fk-build-ico');
|
||||
ico.style.background = machineColor(m);
|
||||
btn.append(ico);
|
||||
|
||||
const key = el('span', 'fk-build-key');
|
||||
key.textContent = String(i + 1); // every slot on every page has a hotkey now
|
||||
btn.append(key);
|
||||
|
||||
btn.append(el('span', 'fk-build-name', [m.name]));
|
||||
btn.addEventListener('click', () => sel.select(m.id));
|
||||
row.append(btn);
|
||||
return { def: m.id, btn };
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
buildButtons(pages[active]);
|
||||
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active);
|
||||
sync();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const cur = sel.get();
|
||||
@ -61,8 +84,22 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
cls(hint, 'is-placing', !!cur);
|
||||
}
|
||||
|
||||
sel.onChange(sync);
|
||||
sync();
|
||||
// A selection made from anywhere (including a future hotkey we don't own) pulls the
|
||||
// bar to the page that contains it, so the hot button is always the visible one.
|
||||
sel.onChange(() => {
|
||||
const cur = sel.get();
|
||||
if (cur) {
|
||||
const p = pageOf(pages, cur.def);
|
||||
if (p >= 0 && p !== active) {
|
||||
active = p;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sync();
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
el: root,
|
||||
@ -70,6 +107,11 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const b = buttons[n - 1];
|
||||
if (b) sel.select(b.def);
|
||||
},
|
||||
pageBy(delta) {
|
||||
if (pages.length === 0) return;
|
||||
active = (active + delta + pages.length) % pages.length;
|
||||
render();
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
85
fktry/src/ui/chipspec.test.ts
Normal file
85
fktry/src/ui/chipspec.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CommissionDef, EntityState, RecipeDef } from '../contracts';
|
||||
import { commissionChips, commissionMet, intakeChips, outputChips } from './chipspec';
|
||||
|
||||
function ent(over: Partial<EntityState> = {}): EntityState {
|
||||
return {
|
||||
id: 1, def: 'demuxer', pos: { x: 0, y: 0 }, dir: 0, recipe: 'demux-ore',
|
||||
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
const DEMUX: RecipeDef = {
|
||||
id: 'demux-ore', machine: 'demuxer',
|
||||
inputs: { 'mdat-ore': 2 },
|
||||
outputs: { 'luma-bars': 1, 'chroma-slurry': 1 },
|
||||
ticks: 45, bandwidth: 3,
|
||||
};
|
||||
|
||||
describe('intakeChips', () => {
|
||||
it('reads have/need and marks a short intake', () => {
|
||||
expect(intakeChips(DEMUX, ent({ inputBuf: { 'mdat-ore': 1 } }))).toEqual([
|
||||
{ item: 'mdat-ore', count: '1/2', state: 'short' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks intake met once the buffer covers the recipe', () => {
|
||||
expect(intakeChips(DEMUX, ent({ inputBuf: { 'mdat-ore': 2 } }))[0].state).toBe('met');
|
||||
});
|
||||
|
||||
it('treats a missing buffer entry as zero rather than NaN', () => {
|
||||
expect(intakeChips(DEMUX, ent())[0].count).toBe('0/2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputChips', () => {
|
||||
it('lists recipe outputs even when the buffer is empty', () => {
|
||||
expect(outputChips(DEMUX, ent()).map((c) => c.item))
|
||||
.toEqual(['luma-bars', 'chroma-slurry']);
|
||||
});
|
||||
|
||||
it('also surfaces buffer contents the recipe never mentions — that is the jam', () => {
|
||||
const chips = outputChips(DEMUX, ent({ outputBuf: { 'luma-bars': 3, melt: 1 } }));
|
||||
expect(chips).toContainEqual({ item: 'melt', count: '1' });
|
||||
expect(chips).toContainEqual({ item: 'luma-bars', count: '3' });
|
||||
});
|
||||
|
||||
it('does not duplicate an item present in both recipe and buffer', () => {
|
||||
const ids = outputChips(DEMUX, ent({ outputBuf: { 'luma-bars': 2 } })).map((c) => c.item);
|
||||
expect(ids.filter((i) => i === 'luma-bars')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commissionChips', () => {
|
||||
const c: CommissionDef = {
|
||||
id: 'first-taste', flavor: '', wants: { melt: 40 }, rewardBandwidth: 50,
|
||||
};
|
||||
|
||||
it('shows delivered against wanted', () => {
|
||||
expect(commissionChips(c, { melt: 3 })).toEqual([
|
||||
{ item: 'melt', count: '3/40', state: 'plain' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never reports more delivered than wanted', () => {
|
||||
expect(commissionChips(c, { melt: 99 })[0].count).toBe('40/40');
|
||||
});
|
||||
|
||||
it('marks met at exactly the wanted count', () => {
|
||||
expect(commissionChips(c, { melt: 40 })[0].state).toBe('met');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commissionMet', () => {
|
||||
const two: CommissionDef = {
|
||||
id: 'x', flavor: '', wants: { melt: 2, 'hf-dust': 1 }, rewardBandwidth: 0,
|
||||
};
|
||||
|
||||
it('is false while any want is short', () => {
|
||||
expect(commissionMet(two, { melt: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('is true only once every want is covered', () => {
|
||||
expect(commissionMet(two, { melt: 2, 'hf-dust': 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
43
fktry/src/ui/chipspec.ts
Normal file
43
fktry/src/ui/chipspec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* LANE-UI — pure chip-spec builders.
|
||||
*
|
||||
* The arithmetic behind every chip row (what's short, what's met, what the count reads)
|
||||
* with no DOM in sight, so it can be tested and so `chips.ts` stays a dumb renderer.
|
||||
*/
|
||||
import type { CommissionDef, EntityState, RecipeDef } from '../contracts';
|
||||
import type { ChipSpec } from './chips';
|
||||
|
||||
/** Recipe inputs against what the machine is actually holding: `have/need`. */
|
||||
export function intakeChips(recipe: RecipeDef, e: EntityState): ChipSpec[] {
|
||||
return Object.entries(recipe.inputs).map(([item, need]) => {
|
||||
const have = e.inputBuf[item] ?? 0;
|
||||
return { item, count: `${have}/${need}`, state: have >= need ? 'met' : 'short' };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe outputs, then anything else the output buffer is holding — a jam is often an
|
||||
* item the current recipe never mentions (left over from a previous recipe).
|
||||
*/
|
||||
export function outputChips(recipe: RecipeDef, e: EntityState): ChipSpec[] {
|
||||
const ids = new Set(Object.keys(recipe.outputs));
|
||||
for (const id of Object.keys(e.outputBuf)) ids.add(id);
|
||||
return [...ids].map((item) => ({ item, count: String(e.outputBuf[item] ?? 0) }));
|
||||
}
|
||||
|
||||
/** Commission wants against delivered progress. Never shows more delivered than wanted. */
|
||||
export function commissionChips(c: CommissionDef, progress: Record<string, number>): ChipSpec[] {
|
||||
return Object.entries(c.wants).map(([item, need]) => {
|
||||
const have = progress[item] ?? 0;
|
||||
return {
|
||||
item,
|
||||
count: `${Math.min(have, need)}/${need}`,
|
||||
state: have >= need ? 'met' : 'plain',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** True once every want is satisfied. */
|
||||
export function commissionMet(c: CommissionDef, progress: Record<string, number>): boolean {
|
||||
return Object.entries(c.wants).every(([item, need]) => (progress[item] ?? 0) >= need);
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
*/
|
||||
import type { CommissionDef, GameData, ItemDef, SimSnapshot } from '../contracts';
|
||||
import { createChipRow, type ChipRow } from './chips';
|
||||
import { commissionChips } from './chipspec';
|
||||
import { cls, el, panel, text } from './dom';
|
||||
import { COPY } from './voice';
|
||||
|
||||
@ -19,6 +20,12 @@ export interface Fax {
|
||||
|
||||
const STAMP_MS = 1800; // must match the fk-stamp keyframes in style.ts
|
||||
|
||||
/**
|
||||
* The Correction quietly buys these back (lore §5; the fiction lands properly in M3).
|
||||
* Hardcoded id sanctioned by round-2 orders.
|
||||
*/
|
||||
const SANITISED_ITEM = 'anchor-slab';
|
||||
|
||||
export function createFax(data: GameData): Fax {
|
||||
const commissions = new Map<string, CommissionDef>(data.commissions.map((c) => [c.id, c]));
|
||||
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
|
||||
@ -32,12 +39,39 @@ export function createFax(data: GameData): Fax {
|
||||
const chips: ChipRow = createChipRow(items);
|
||||
const reward = el('div', 'fk-fax-sub');
|
||||
|
||||
// SANITISING: shown once The Correction has something to buy back.
|
||||
const sanHead = el('div', 'fk-h fk-san-h', [COPY.sanitising]);
|
||||
const sanChips: ChipRow = createChipRow(items);
|
||||
const sanWrap = el('div', 'fk-san', [sanHead, sanChips.el]);
|
||||
|
||||
// NEXT IN TRAY: the peek at what the tray is holding.
|
||||
const nextHead = el('div', 'fk-h', [COPY.faxNext]);
|
||||
const nextName = el('div', 'fk-fax-next-name');
|
||||
const nextChips: ChipRow = createChipRow(items);
|
||||
const nextWrap = el('div', 'fk-fax-next', [
|
||||
el('div', 'fk-rule'), nextHead, nextName, nextChips.el,
|
||||
]);
|
||||
|
||||
const standing = el('div', 'fk-fax-standing', [el('span', undefined, [COPY.faxStanding])]);
|
||||
const stampEl = el('div', 'fk-fax-stamp', [el('span', undefined, [COPY.faxStamp])]);
|
||||
|
||||
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward, stampEl);
|
||||
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward,
|
||||
sanWrap, nextWrap, standing, stampEl);
|
||||
|
||||
let stampTimer: number | undefined;
|
||||
|
||||
/**
|
||||
* TEMPORARY: the snapshot exposes `activeCommission` but no queue, and the sim
|
||||
* currently pins the active commission to `commissions[0]`. So "next" is inferred as
|
||||
* the following entry in data order — an assumption, not truth. CONTRACT REQUEST filed
|
||||
* in NOTES for `nextCommission` / a queue on the snapshot.
|
||||
*/
|
||||
function peekNext(activeId: string): CommissionDef | null {
|
||||
const i = data.commissions.findIndex((c) => c.id === activeId);
|
||||
if (i < 0 || data.commissions.length < 2) return null;
|
||||
return data.commissions[(i + 1) % data.commissions.length];
|
||||
}
|
||||
|
||||
return {
|
||||
el: root,
|
||||
update(snap) {
|
||||
@ -46,17 +80,25 @@ export function createFax(data: GameData): Fax {
|
||||
if (!c) return;
|
||||
|
||||
text(flavor, c.flavor);
|
||||
chips.update(
|
||||
Object.entries(c.wants).map(([item, need]) => {
|
||||
const have = snap.commissionProgress[item] ?? 0;
|
||||
return {
|
||||
item,
|
||||
count: `${Math.min(have, need)}/${need}`,
|
||||
state: have >= need ? ('met' as const) : ('plain' as const),
|
||||
};
|
||||
}),
|
||||
);
|
||||
chips.update(commissionChips(c, snap.commissionProgress));
|
||||
text(reward, `ON RECEIPT: +${c.rewardBandwidth} BANDWIDTH`);
|
||||
|
||||
cls(standing, 'is-on', c.repeat === true);
|
||||
|
||||
const sanitised = snap.shippedTotal[SANITISED_ITEM] ?? 0;
|
||||
cls(sanWrap, 'is-on', sanitised > 0);
|
||||
if (sanitised > 0) {
|
||||
sanChips.update([{ item: SANITISED_ITEM, count: String(sanitised), state: 'plain' }]);
|
||||
}
|
||||
|
||||
const next = peekNext(c.id);
|
||||
cls(nextWrap, 'is-on', !!next);
|
||||
if (next) {
|
||||
text(nextName, next.id.replace(/-/g, ' ').toUpperCase());
|
||||
nextChips.update(
|
||||
Object.entries(next.wants).map(([item, need]) => ({ item, count: String(need) })),
|
||||
);
|
||||
}
|
||||
},
|
||||
stamp() {
|
||||
// Restart cleanly if two commissions complete back to back.
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
/**
|
||||
* LANE-UI — dev-only fake sim, for verifying the HUD before LANE-SIM lands.
|
||||
*
|
||||
* LANE-SIM's stub reports zero entities, zero bandwidth and no events forever, so with
|
||||
* the real snapshot there is literally nothing on screen to check: an empty inspector
|
||||
* and a 0/0 meter prove nothing. This module synthesises a plausible snapshot from the
|
||||
* real tick so every surface can be seen doing its job in a browser.
|
||||
*
|
||||
* INERT unless the URL says `?uifixture`. It never dispatches Commands and never touches
|
||||
* another lane; when the real sim arrives, drop the query param (or this file).
|
||||
*
|
||||
* See NOTES — this is scaffolding, not a feature.
|
||||
*/
|
||||
import type { GameData, EntityState, SimEvent, SimSnapshot } from '../contracts';
|
||||
|
||||
export function fixtureEnabled(): boolean {
|
||||
return typeof location !== 'undefined' && new URLSearchParams(location.search).has('uifixture');
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
snapshot(paused: boolean): SimSnapshot;
|
||||
/** Events for ticks that have elapsed since the last call. */
|
||||
drain(): SimEvent[];
|
||||
}
|
||||
|
||||
const SHIP_EVERY = 90; // one melt every 3s at 30tps
|
||||
const MS_PER_TICK = 1000 / 30;
|
||||
/** Never replay more than this many ticks of events in one drain (backgrounded tab). */
|
||||
const MAX_CATCHUP = 300;
|
||||
|
||||
export function createFixture(data: GameData): Fixture {
|
||||
const entities: EntityState[] = data.machines.map((m, i) => ({
|
||||
id: i + 1,
|
||||
def: m.id,
|
||||
pos: { x: i * 3 - 9, y: i % 2 === 0 ? -2 : 2 },
|
||||
dir: (i % 4) as 0 | 1 | 2 | 3,
|
||||
recipe: m.recipes[0] ?? null,
|
||||
progress: 0,
|
||||
inputBuf: {},
|
||||
outputBuf: {},
|
||||
jammed: null,
|
||||
heat: 0,
|
||||
}));
|
||||
|
||||
const recipes = new Map(data.recipes.map((r) => [r.id, r]));
|
||||
const jamVictim = entities.find((e) => e.def === 'mosh-reactor') ?? entities[0];
|
||||
let lastTick = -1;
|
||||
let brownoutOn = false;
|
||||
|
||||
// Wall clock, not the passed-in sim tick. rAF stops in a backgrounded tab, so a
|
||||
// tick-driven fixture crawls and you can never actually watch the brownout arrive.
|
||||
// This is a fake sim for eyeballing the HUD; nothing here has to be deterministic.
|
||||
const t0 = performance.now();
|
||||
const nowTick = () => Math.floor((performance.now() - t0) / MS_PER_TICK);
|
||||
|
||||
function brownoutAt(tick: number) {
|
||||
return tick % 600 > 480; // ~4s of pain every 20s
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot(paused) {
|
||||
const tick = nowTick();
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const e = entities[i];
|
||||
const r = e.recipe ? recipes.get(e.recipe) : null;
|
||||
e.progress = r ? ((tick + i * 13) % r.ticks) / r.ticks : 0;
|
||||
// The mosh reactor gets to be the one that's unhappy, for the amber readout.
|
||||
e.jammed = e.def === 'mosh-reactor' && tick % 300 > 240 ? 'output buffer full' : null;
|
||||
e.heat = e.def === 'quantizer' ? Math.min(1, (tick % 900) / 900) : 0;
|
||||
if (r) {
|
||||
e.inputBuf = {};
|
||||
for (const [item, need] of Object.entries(r.inputs)) {
|
||||
e.inputBuf[item] = (tick + i) % (need + 2);
|
||||
}
|
||||
e.outputBuf = {};
|
||||
for (const item of Object.keys(r.outputs)) {
|
||||
e.outputBuf[item] = Math.floor(((tick + i * 7) % 200) / 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shipped = Math.floor(tick / SHIP_EVERY);
|
||||
const out = brownoutAt(tick);
|
||||
const draw = 6 + (out ? 22 : Math.abs(((tick / 60) % 4) - 2) * 7);
|
||||
|
||||
const shippedTotal: Record<string, number> = {};
|
||||
if (shipped > 0) {
|
||||
shippedTotal.melt = shipped;
|
||||
shippedTotal['anchor-slab'] = shipped;
|
||||
}
|
||||
|
||||
return {
|
||||
tick,
|
||||
paused,
|
||||
entities,
|
||||
beltItems: [],
|
||||
bandwidth: { gen: 20, draw, stored: out ? 0 : 40 + Math.sin(tick / 40) * 20, brownout: out },
|
||||
shippedTotal,
|
||||
activeCommission: data.commissions[0]?.id ?? null,
|
||||
commissionProgress: { melt: shipped },
|
||||
};
|
||||
},
|
||||
|
||||
drain() {
|
||||
const tick = nowTick();
|
||||
const events: SimEvent[] = [];
|
||||
if (lastTick < 0) {
|
||||
lastTick = tick;
|
||||
return events;
|
||||
}
|
||||
if (tick - lastTick > MAX_CATCHUP) lastTick = tick - MAX_CATCHUP;
|
||||
for (let t = lastTick + 1; t <= tick; t++) {
|
||||
if (t % SHIP_EVERY === 0 && t > 0) {
|
||||
events.push({ kind: 'shipped', item: 'melt', count: 1, tick: t });
|
||||
// Recurring rather than one-shot: the stamp is a 1.8s animation, and a
|
||||
// fixture that fires it once at tick 90 is unobservable in practice.
|
||||
if (t % (SHIP_EVERY * 3) === 0) {
|
||||
const c = data.commissions[0];
|
||||
if (c) events.push({ kind: 'commissionDone', commission: c.id, tick: t });
|
||||
}
|
||||
}
|
||||
if (t % 300 === 241 && jamVictim) {
|
||||
events.push({ kind: 'jammed', entity: jamVictim.id, reason: 'output buffer full', tick: t });
|
||||
}
|
||||
const out = brownoutAt(t);
|
||||
if (out !== brownoutOn) {
|
||||
brownoutOn = out;
|
||||
events.push({ kind: 'brownout', on: out, tick: t });
|
||||
}
|
||||
}
|
||||
lastTick = tick;
|
||||
return events;
|
||||
},
|
||||
};
|
||||
}
|
||||
78
fktry/src/ui/hotkeys.test.ts
Normal file
78
fktry/src/ui/hotkeys.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createKeyRouter, type KeyEventLike } from './hotkeys';
|
||||
|
||||
/** A KeyboardEvent stand-in — the router only reads these fields, by design. */
|
||||
function ev(key: string, extra: Partial<KeyEventLike> = {}): KeyEventLike {
|
||||
return { key, preventDefault: vi.fn(), ...extra };
|
||||
}
|
||||
|
||||
describe('createKeyRouter', () => {
|
||||
it('routes a bound key to its handler', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
expect(router.handle(ev('r'))).toBe(true);
|
||||
expect(hit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matches case-insensitively, so shift-R still rotates', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
router.handle(ev('R'));
|
||||
expect(hit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores unbound keys', () => {
|
||||
const router = createKeyRouter();
|
||||
expect(router.handle(ev('q'))).toBe(false);
|
||||
});
|
||||
|
||||
it('swallows space so the page does not scroll under the factory', () => {
|
||||
const router = createKeyRouter();
|
||||
router.bind(' ', () => {});
|
||||
const e = ev(' ');
|
||||
router.handle(e);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not preventDefault on keys the browser should keep handling', () => {
|
||||
const router = createKeyRouter();
|
||||
router.bind('r', () => {});
|
||||
const e = ev('r');
|
||||
router.handle(e);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves browser shortcuts alone (cmd/ctrl/alt held)', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
for (const mod of ['metaKey', 'ctrlKey', 'altKey'] as const) {
|
||||
expect(router.handle(ev('r', { [mod]: true }))).toBe(false);
|
||||
}
|
||||
expect(hit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a later bind replace an earlier one for the same key', () => {
|
||||
const router = createKeyRouter();
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
router.bind('1', first);
|
||||
router.bind('1', second);
|
||||
router.handle(ev('1'));
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds the paging keys independently', () => {
|
||||
const router = createKeyRouter();
|
||||
const prev = vi.fn();
|
||||
const next = vi.fn();
|
||||
router.bind('[', prev);
|
||||
router.bind(']', next);
|
||||
router.handle(ev(']'));
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(prev).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,55 +1,84 @@
|
||||
/**
|
||||
* LANE-UI — the keyboard map. ALL bindings live here; this file will grow.
|
||||
*
|
||||
* Round 1 map:
|
||||
* 1..9 select build slot N (build bar)
|
||||
* Round 2 map:
|
||||
* 1..9 select build slot N *within the active page*
|
||||
* [ ] previous / next build page
|
||||
* R rotate pending build dir
|
||||
* ESC clear selection / close inspector
|
||||
* SPACE toggle HALT
|
||||
* TAB cycle the unit manifest (temporary — see NOTES: no pickTile in UIBus)
|
||||
*
|
||||
* WASD and the middle-drag pan belong to LANE-RENDER's camera; nothing here binds them.
|
||||
* Keys are matched on `event.key`, lowercased, so layout-shifted digits still work.
|
||||
* WASD and the middle-drag pan belong to LANE-RENDER's camera (see render/camera.ts);
|
||||
* nothing here binds them. Keys match on `event.key` lowercased, so layout-shifted
|
||||
* digits still work.
|
||||
*
|
||||
* Split in two: `KeyRouter` is the pure map + dispatch (no window, testable in node),
|
||||
* `Hotkeys` is the thin binding to the real window.
|
||||
*/
|
||||
|
||||
export type HotkeyHandler = (ev: KeyboardEvent) => void;
|
||||
export type HotkeyHandler = (ev: KeyEventLike) => void;
|
||||
|
||||
export interface Hotkeys {
|
||||
/** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "tab", "1". */
|
||||
/** The parts of KeyboardEvent this module actually reads. */
|
||||
export interface KeyEventLike {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
target?: unknown;
|
||||
preventDefault?(): void;
|
||||
}
|
||||
|
||||
export interface KeyRouter {
|
||||
/** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "[", "1". */
|
||||
bind(key: string, handler: HotkeyHandler): void;
|
||||
/** Returns true if a binding ran. */
|
||||
handle(ev: KeyEventLike): boolean;
|
||||
}
|
||||
|
||||
export interface Hotkeys extends KeyRouter {
|
||||
attach(): void;
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/** Don't steal keys while the player is typing into something. */
|
||||
function isTypingTarget(t: EventTarget | null): boolean {
|
||||
export function isTypingTarget(t: unknown): boolean {
|
||||
if (typeof HTMLElement === 'undefined') return false;
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName);
|
||||
}
|
||||
|
||||
/** Keys we always swallow so the browser doesn't scroll/focus-walk the page. */
|
||||
const SWALLOW = new Set([' ', 'tab']);
|
||||
/** Keys we swallow so the browser doesn't scroll or focus-walk the page. */
|
||||
const SWALLOW = new Set([' ']);
|
||||
|
||||
export function createHotkeys(): Hotkeys {
|
||||
export function createKeyRouter(): KeyRouter {
|
||||
const map = new Map<string, HotkeyHandler>();
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (isTypingTarget(ev.target)) return;
|
||||
// Let the browser keep its own shortcuts (⌘R, ctrl-tab, …).
|
||||
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||||
|
||||
const key = ev.key.toLowerCase();
|
||||
const handler = map.get(key);
|
||||
if (!handler) return;
|
||||
|
||||
if (SWALLOW.has(key)) ev.preventDefault();
|
||||
handler(ev);
|
||||
};
|
||||
|
||||
return {
|
||||
bind(key, handler) {
|
||||
map.set(key.toLowerCase(), handler);
|
||||
},
|
||||
handle(ev) {
|
||||
if (isTypingTarget(ev.target)) return false;
|
||||
// Leave the browser's own shortcuts alone (⌘R, ctrl-tab, …).
|
||||
if (ev.metaKey || ev.ctrlKey || ev.altKey) return false;
|
||||
|
||||
const handler = map.get(ev.key.toLowerCase());
|
||||
if (!handler) return false;
|
||||
|
||||
if (SWALLOW.has(ev.key.toLowerCase())) ev.preventDefault?.();
|
||||
handler(ev);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createHotkeys(): Hotkeys {
|
||||
const router = createKeyRouter();
|
||||
const onKeyDown = (ev: KeyboardEvent) => router.handle(ev);
|
||||
|
||||
return {
|
||||
bind: router.bind,
|
||||
handle: router.handle,
|
||||
attach() {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
},
|
||||
|
||||
@ -10,10 +10,9 @@
|
||||
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
|
||||
import { createBuildBar } from './buildbar';
|
||||
import { createFax } from './fax';
|
||||
import { createFixture, fixtureEnabled, type Fixture } from './fixture';
|
||||
import { createHotkeys } from './hotkeys';
|
||||
import { createInspector } from './inspector';
|
||||
import { createRoster, entityAt, resolvePickTile, syncRoster, type PickTile } from './pick';
|
||||
import { createRoster, entityAt, syncRoster } from './pick';
|
||||
import { createSelection } from './selection';
|
||||
import { injectStyle } from './style';
|
||||
import { createTopStrip } from './topstrip';
|
||||
@ -27,35 +26,16 @@ export function createUI(): UI {
|
||||
let fax: ReturnType<typeof createFax>;
|
||||
let toasts: ReturnType<typeof createToasts>;
|
||||
|
||||
let pickTile: PickTile | null = null;
|
||||
let fixture: Fixture | null = null;
|
||||
|
||||
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
|
||||
const roster = createRoster();
|
||||
let paused = false;
|
||||
let manifestIndex = -1;
|
||||
|
||||
function rosterIndexOf(id: number): number {
|
||||
for (let i = 0; i < roster.length; i++) if (roster.entries[i].id === id) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function machineName(entityId: number): string {
|
||||
const i = rosterIndexOf(entityId);
|
||||
if (i < 0) return `UNIT ${entityId}`;
|
||||
return machines.get(roster.entries[i].def)?.name ?? `UNIT ${entityId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY: nothing reachable from the UI can turn a click into a tile (see pick.ts),
|
||||
* so TAB walks the unit manifest instead. That keeps the inspector reachable with no
|
||||
* console, which is the round's definition of done. Delete once the CONTRACT REQUEST
|
||||
* for `UIBus.pickTile` lands and clicking a machine works.
|
||||
*/
|
||||
function cycleManifest() {
|
||||
if (roster.length === 0) return;
|
||||
manifestIndex = (manifestIndex + 1) % roster.length;
|
||||
inspector.open(roster.entries[manifestIndex].id);
|
||||
for (let i = 0; i < roster.length; i++) {
|
||||
const r = roster.entries[i];
|
||||
if (r.id === entityId) return machines.get(r.def)?.name ?? `UNIT ${entityId}`;
|
||||
}
|
||||
return `UNIT ${entityId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
@ -66,20 +46,17 @@ export function createUI(): UI {
|
||||
const sel = createSelection(bus);
|
||||
top = createTopStrip(data);
|
||||
build = createBuildBar(data, sel);
|
||||
inspector = createInspector(data);
|
||||
inspector = createInspector(data, bus);
|
||||
fax = createFax(data);
|
||||
toasts = createToasts();
|
||||
|
||||
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
|
||||
|
||||
if (fixtureEnabled()) {
|
||||
fixture = createFixture(data);
|
||||
console.info('[ui] fixture mode: the HUD is being driven by a fake sim (?uifixture)');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ keyboard
|
||||
const keys = createHotkeys();
|
||||
for (let n = 1; n <= 9; n++) keys.bind(String(n), () => build.selectSlot(n));
|
||||
keys.bind('[', () => build.pageBy(-1));
|
||||
keys.bind(']', () => build.pageBy(1));
|
||||
keys.bind('r', () => sel.rotate());
|
||||
keys.bind('escape', () => {
|
||||
// One key, two jobs, in the order the player expects: drop the tool first.
|
||||
@ -87,7 +64,6 @@ export function createUI(): UI {
|
||||
else inspector.close();
|
||||
});
|
||||
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
|
||||
keys.bind('tab', cycleManifest);
|
||||
keys.attach();
|
||||
|
||||
// ------------------------------------------------------------------- pointer
|
||||
@ -107,26 +83,16 @@ export function createUI(): UI {
|
||||
if (e.button !== 0 || !inWorld(e.target)) return;
|
||||
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
|
||||
|
||||
pickTile ??= resolvePickTile(); // renderer may register late
|
||||
const tile = pickTile?.(e.clientX, e.clientY);
|
||||
const tile = bus.pickTile(e.clientX, e.clientY);
|
||||
if (!tile) return;
|
||||
|
||||
const hit = entityAt(roster, machines, tile);
|
||||
if (hit === null) {
|
||||
inspector.close();
|
||||
} else {
|
||||
inspector.open(hit);
|
||||
manifestIndex = rosterIndexOf(hit);
|
||||
}
|
||||
if (hit === null) inspector.close();
|
||||
else inspector.open(hit);
|
||||
});
|
||||
},
|
||||
|
||||
update(snap: SimSnapshot, events: SimEvent[]) {
|
||||
if (fixture) {
|
||||
snap = fixture.snapshot(snap.paused);
|
||||
events = fixture.drain();
|
||||
}
|
||||
|
||||
paused = snap.paused;
|
||||
syncRoster(roster, snap);
|
||||
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
/**
|
||||
* LANE-UI — inspector panel. Shows one entity: what it is, what the codex says about
|
||||
* it, what it's chewing, how far along, and why it has stopped.
|
||||
* it, what it's chewing, how far along, how hot, and why it has stopped.
|
||||
*
|
||||
* Holds an entity *id*, never an entity object — CONTRACTS says the sim may reuse
|
||||
* snapshot buffers between frames, so we re-look-up from the live snapshot each update
|
||||
* and close ourselves if the unit is gone.
|
||||
*/
|
||||
import type { EntityState, GameData, ItemDef, MachineDef, RecipeDef, SimSnapshot } from '../contracts';
|
||||
import { createChipRow, type ChipRow, type ChipSpec } from './chips';
|
||||
import type {
|
||||
EntityState, GameData, ItemDef, MachineDef, RecipeDef, SimSnapshot, UIBus,
|
||||
} from '../contracts';
|
||||
import { createChipRow, type ChipRow } from './chips';
|
||||
import { intakeChips, outputChips } from './chipspec';
|
||||
import { attrs, cls, el, panel, style, text } from './dom';
|
||||
import { COPY, machineFlavor } from './voice';
|
||||
import { COPY, jamText, machineFlavor } from './voice';
|
||||
|
||||
export interface Inspector {
|
||||
el: HTMLElement;
|
||||
@ -17,9 +20,14 @@ export interface Inspector {
|
||||
close(): void;
|
||||
isOpen(): boolean;
|
||||
update(snap: SimSnapshot): void;
|
||||
/** Entity currently shown, for the renderer-side selection highlight later. */
|
||||
openId(): number | null;
|
||||
}
|
||||
|
||||
export function createInspector(data: GameData): Inspector {
|
||||
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
|
||||
const THROTTLE_AT = 0.7;
|
||||
|
||||
export function createInspector(data: GameData, bus: UIBus): Inspector {
|
||||
const machines = new Map<string, MachineDef>(data.machines.map((m) => [m.id, m]));
|
||||
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
|
||||
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
|
||||
@ -34,6 +42,18 @@ export function createInspector(data: GameData): Inspector {
|
||||
|
||||
const flavor = el('div', 'fk-ins-flavor');
|
||||
|
||||
// ------------------------------------------------------------- recipe picker
|
||||
const recHead = el('div', 'fk-h', [COPY.inspectorRecipe]);
|
||||
const recSelect = el('select', 'fk-ins-recipe');
|
||||
attrs(recSelect, { title: 'PROCESS SELECT' });
|
||||
recSelect.addEventListener('change', () => {
|
||||
if (openId === null) return;
|
||||
const v = recSelect.value;
|
||||
bus.dispatch({ kind: 'setRecipe', entity: openId, recipe: v === '' ? null : v });
|
||||
});
|
||||
/** The def whose options are currently in the <select>, to avoid rebuilding per frame. */
|
||||
let optionsFor: string | null = null;
|
||||
|
||||
const noRecipe = el('div', 'fk-ins-empty', [COPY.inspectorEmptyRecipe]);
|
||||
|
||||
const inHead = el('div', 'fk-h', [COPY.inspectorInput]);
|
||||
@ -45,13 +65,18 @@ export function createInspector(data: GameData): Inspector {
|
||||
const progFill = el('div', 'fk-bar-f');
|
||||
const progBar = el('div', 'fk-bar', [progFill]);
|
||||
|
||||
const heatRow = el('div', 'fk-stat', [el('span', undefined, ['HEAT']), el('span', 'fk-stat-v')]);
|
||||
const heatVal = heatRow.lastElementChild as HTMLElement;
|
||||
const heatHead = el('div', 'fk-h', [COPY.inspectorHeat]);
|
||||
const heatFill = el('div', 'fk-bar-f');
|
||||
const heatBar = el('div', 'fk-bar fk-heat', [heatFill]);
|
||||
const heatState = el('div', 'fk-ins-heat-state');
|
||||
|
||||
const jam = el('div', 'fk-ins-jam');
|
||||
|
||||
const body = el('div', undefined, [
|
||||
noRecipe, inHead, inChips.el, outHead, outChips.el, progHead, progBar, heatRow,
|
||||
recHead, recSelect, noRecipe,
|
||||
inHead, inChips.el, outHead, outChips.el,
|
||||
progHead, progBar,
|
||||
heatHead, heatBar, heatState,
|
||||
]);
|
||||
|
||||
root.append(head, flavor, el('div', 'fk-rule'), body, jam);
|
||||
@ -60,19 +85,59 @@ export function createInspector(data: GameData): Inspector {
|
||||
|
||||
function close() {
|
||||
openId = null;
|
||||
optionsFor = null;
|
||||
cls(root, 'is-open', false);
|
||||
}
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
/** Rebuild the <option> list when the inspected machine changes. */
|
||||
function syncOptions(def: MachineDef | undefined) {
|
||||
if (optionsFor === (def?.id ?? null)) return;
|
||||
optionsFor = def?.id ?? null;
|
||||
recSelect.replaceChildren();
|
||||
const idle = el('option', undefined, [COPY.inspectorNoRecipeOption]);
|
||||
idle.value = '';
|
||||
recSelect.append(idle);
|
||||
for (const rid of def?.recipes ?? []) {
|
||||
const r = recipes.get(rid);
|
||||
const opt = el('option', undefined, [recipeLabel(r, rid)]);
|
||||
opt.value = rid;
|
||||
recSelect.append(opt);
|
||||
}
|
||||
}
|
||||
|
||||
/** "2 MDAT ORE -> 1 LUMA BARS + 1 CHROMA SLURRY" in item display names. */
|
||||
function recipeLabel(r: RecipeDef | undefined, fallback: string): string {
|
||||
if (!r) return fallback.toUpperCase();
|
||||
const side = (m: Record<string, number>) =>
|
||||
Object.entries(m)
|
||||
.map(([id, n]) => `${n} ${items.get(id)?.name ?? id.toUpperCase()}`)
|
||||
.join(' + ');
|
||||
const ins = side(r.inputs);
|
||||
const outs = side(r.outputs);
|
||||
return ins ? `${ins} → ${outs}` : outs;
|
||||
}
|
||||
|
||||
function paint(e: EntityState) {
|
||||
const def = machines.get(e.def);
|
||||
text(name, def?.name ?? e.def.toUpperCase());
|
||||
text(flavor, machineFlavor(e.def));
|
||||
text(flavor, machineFlavor(def, e.def));
|
||||
|
||||
// Only machines with something to choose get a picker.
|
||||
const choices = def?.recipes ?? [];
|
||||
const canChoose = choices.length > 0;
|
||||
syncOptions(def);
|
||||
for (const n of [recHead, recSelect]) style(n, 'display', canChoose ? '' : 'none');
|
||||
if (canChoose && document.activeElement !== recSelect) {
|
||||
const want = e.recipe ?? '';
|
||||
if (recSelect.value !== want) recSelect.value = want;
|
||||
}
|
||||
|
||||
const recipe = e.recipe ? recipes.get(e.recipe) : null;
|
||||
const hasProcess = !!recipe;
|
||||
|
||||
style(noRecipe, 'display', hasProcess ? 'none' : 'block');
|
||||
// "No process" only reads as a fault on a machine that could have one.
|
||||
style(noRecipe, 'display', !hasProcess && canChoose ? 'block' : 'none');
|
||||
for (const n of [outHead, outChips.el, progHead, progBar]) {
|
||||
style(n, 'display', hasProcess ? '' : 'none');
|
||||
}
|
||||
@ -83,33 +148,27 @@ export function createInspector(data: GameData): Inspector {
|
||||
for (const n of [inHead, inChips.el]) style(n, 'display', hasInputs ? '' : 'none');
|
||||
|
||||
if (recipe) {
|
||||
const ins: ChipSpec[] = Object.entries(recipe.inputs).map(([item, need]) => {
|
||||
const have = e.inputBuf[item] ?? 0;
|
||||
return { item, count: `${have}/${need}`, state: have >= need ? 'met' : 'short' };
|
||||
});
|
||||
inChips.update(ins);
|
||||
|
||||
// Recipe outputs first, then anything else the buffer is holding (a jam is often
|
||||
// an item the recipe never mentions).
|
||||
const outIds = new Set(Object.keys(recipe.outputs));
|
||||
for (const id of Object.keys(e.outputBuf)) outIds.add(id);
|
||||
outChips.update(
|
||||
[...outIds].map((item) => ({ item, count: String(e.outputBuf[item] ?? 0) })),
|
||||
);
|
||||
|
||||
if (hasInputs) inChips.update(intakeChips(recipe, e));
|
||||
outChips.update(outputChips(recipe, e));
|
||||
style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`);
|
||||
}
|
||||
|
||||
// Heat: a bar rather than a number, because it's a thing you watch climb.
|
||||
const hot = e.heat > 0.01;
|
||||
style(heatRow, 'display', hot ? '' : 'none');
|
||||
for (const n of [heatHead, heatBar, heatState]) style(n, 'display', hot ? '' : 'none');
|
||||
if (hot) {
|
||||
text(heatVal, `${(e.heat * 100).toFixed(0)}%${e.heat > 0.7 ? ' · THROTTLING' : ''}`);
|
||||
style(heatVal, 'color', e.heat > 0.7 ? 'var(--fk-red)' : 'var(--fk-amber)');
|
||||
const scram = e.heat >= 1;
|
||||
const throttling = e.heat > THROTTLE_AT;
|
||||
style(heatFill, 'width', `${Math.min(1, e.heat) * 100}%`);
|
||||
cls(heatBar, 'is-throttling', throttling && !scram);
|
||||
cls(heatBar, 'is-scram', scram);
|
||||
text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : '');
|
||||
cls(heatState, 'is-scram', scram);
|
||||
}
|
||||
|
||||
const jammed = !!e.jammed;
|
||||
cls(jam, 'is-on', jammed);
|
||||
if (jammed) text(jam, COPY.inspectorJamPrefix + e.jammed!.toUpperCase());
|
||||
if (jammed) text(jam, jamText(e.jammed!));
|
||||
}
|
||||
|
||||
return {
|
||||
@ -120,6 +179,7 @@ export function createInspector(data: GameData): Inspector {
|
||||
},
|
||||
close,
|
||||
isOpen: () => openId !== null,
|
||||
openId: () => openId,
|
||||
update(snap) {
|
||||
if (openId === null) return;
|
||||
const e = snap.entities.find((x) => x.id === openId);
|
||||
|
||||
84
fktry/src/ui/pages.test.ts
Normal file
84
fktry/src/ui/pages.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MachineDef, MachineKind } from '../contracts';
|
||||
import { paginate, pageOf, PER_PAGE } from './pages';
|
||||
|
||||
function m(id: string, kind: MachineKind): MachineDef {
|
||||
return {
|
||||
id, name: id.toUpperCase(), codex: '', kind,
|
||||
footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, asset: id,
|
||||
};
|
||||
}
|
||||
|
||||
/** The real shape of the problem: LANE-DATA's catalog is crafter-heavy. */
|
||||
function catalog(crafters: number): MachineDef[] {
|
||||
return [
|
||||
m('seam-extractor', 'extractor'),
|
||||
m('belt', 'belt'),
|
||||
m('lane-splitter', 'splitter'),
|
||||
m('buffer-tank', 'buffer'),
|
||||
...Array.from({ length: crafters }, (_, i) => m(`crafter-${i}`, 'crafter')),
|
||||
m('decode-asic', 'power'),
|
||||
m('shipper', 'shipper'),
|
||||
];
|
||||
}
|
||||
|
||||
describe('paginate', () => {
|
||||
it('groups kinds into house-ordered tabs', () => {
|
||||
const pages = paginate(catalog(2));
|
||||
expect(pages.map((p) => p.label)).toEqual(['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP']);
|
||||
});
|
||||
|
||||
it('collapses belt, splitter and buffer into one LOGISTICS tab', () => {
|
||||
const pages = paginate(catalog(1));
|
||||
const logistics = pages.find((p) => p.label === 'LOGISTICS')!;
|
||||
expect(logistics.machines.map((x) => x.id)).toEqual(['belt', 'lane-splitter', 'buffer-tank']);
|
||||
});
|
||||
|
||||
it('never puts more than nine machines on a page, so 1-9 always reaches every slot', () => {
|
||||
for (const n of [1, 9, 14, 40]) {
|
||||
for (const page of paginate(catalog(n))) {
|
||||
expect(page.machines.length).toBeLessThanOrEqual(PER_PAGE);
|
||||
expect(page.machines.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('spills an oversized kind into numbered pages', () => {
|
||||
// 14 crafters is the live catalog: the exact case kind-tabs alone cannot hotkey.
|
||||
const labels = paginate(catalog(14)).map((p) => p.label);
|
||||
expect(labels).toContain('REFINE 1/2');
|
||||
expect(labels).toContain('REFINE 2/2');
|
||||
expect(labels).not.toContain('REFINE');
|
||||
});
|
||||
|
||||
it('keeps every machine exactly once across all pages', () => {
|
||||
const all = catalog(14);
|
||||
const paged = paginate(all).flatMap((p) => p.machines.map((x) => x.id));
|
||||
expect(paged.slice().sort()).toEqual(all.map((x) => x.id).sort());
|
||||
});
|
||||
|
||||
it('does not drop a machine whose kind it has never heard of', () => {
|
||||
const odd = [...catalog(1), m('mystery', 'teleporter' as MachineKind)];
|
||||
const paged = paginate(odd).flatMap((p) => p.machines.map((x) => x.id));
|
||||
expect(paged).toContain('mystery');
|
||||
// and it sorts last rather than jumping the queue
|
||||
expect(paginate(odd).at(-1)!.label).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('returns no pages for an empty catalog', () => {
|
||||
expect(paginate([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageOf', () => {
|
||||
it('finds the page holding a machine', () => {
|
||||
const pages = paginate(catalog(14));
|
||||
const i = pageOf(pages, 'crafter-12');
|
||||
expect(i).toBeGreaterThanOrEqual(0);
|
||||
expect(pages[i].machines.some((x) => x.id === 'crafter-12')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns -1 for a machine that is not in the catalog', () => {
|
||||
expect(pageOf(paginate(catalog(2)), 'nope')).toBe(-1);
|
||||
});
|
||||
});
|
||||
73
fktry/src/ui/pages.ts
Normal file
73
fktry/src/ui/pages.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* LANE-UI — build bar pagination (pure; see pages.test.ts).
|
||||
*
|
||||
* Round 2 orders: kind tabs, `[`/`]` to page, 1-9 within the active page, "every machine
|
||||
* hotkey-reachable at any catalog size".
|
||||
*
|
||||
* Kind tabs alone don't deliver that last clause: LANE-DATA's 21 machines include 14
|
||||
* crafters, and 1-9 cannot reach 14. So a page is at most PER_PAGE machines, and a kind
|
||||
* group larger than that spills into numbered pages (REFINE 1/2, REFINE 2/2). That keeps
|
||||
* the invariant true no matter how big the catalog gets.
|
||||
*/
|
||||
import type { MachineDef, MachineKind } from '../contracts';
|
||||
|
||||
/** Hotkeys are 1-9, so a page can never hold more than nine. */
|
||||
export const PER_PAGE = 9;
|
||||
|
||||
/** Kinds collapse into these tabs, in house order: dig, move, refine, power, ship. */
|
||||
const GROUP_OF: Record<MachineKind, string> = {
|
||||
extractor: 'EXTRACT',
|
||||
belt: 'LOGISTICS',
|
||||
splitter: 'LOGISTICS',
|
||||
buffer: 'LOGISTICS',
|
||||
crafter: 'REFINE',
|
||||
power: 'POWER',
|
||||
shipper: 'SHIP',
|
||||
};
|
||||
|
||||
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP'];
|
||||
|
||||
export interface BuildPage {
|
||||
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */
|
||||
label: string;
|
||||
machines: MachineDef[];
|
||||
}
|
||||
|
||||
export function paginate(machines: MachineDef[], perPage: number = PER_PAGE): BuildPage[] {
|
||||
const groups = new Map<string, MachineDef[]>();
|
||||
for (const m of machines) {
|
||||
// An unknown kind still gets a home rather than vanishing from the bar.
|
||||
const g = GROUP_OF[m.kind] ?? 'OTHER';
|
||||
const list = groups.get(g);
|
||||
if (list) list.push(m);
|
||||
else groups.set(g, [m]);
|
||||
}
|
||||
|
||||
const names = [...groups.keys()].sort((a, b) => {
|
||||
const ia = GROUP_ORDER.indexOf(a);
|
||||
const ib = GROUP_ORDER.indexOf(b);
|
||||
// Unknown groups sort last, alphabetically, rather than jumping the queue.
|
||||
if (ia === -1 && ib === -1) return a < b ? -1 : a > b ? 1 : 0;
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
|
||||
const pages: BuildPage[] = [];
|
||||
for (const name of names) {
|
||||
const list = groups.get(name)!;
|
||||
const chunks = Math.max(1, Math.ceil(list.length / perPage));
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
pages.push({
|
||||
label: chunks > 1 ? `${name} ${i + 1}/${chunks}` : name,
|
||||
machines: list.slice(i * perPage, (i + 1) * perPage),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Page index holding `defId`, or -1. Used to follow a click-selection to its tab. */
|
||||
export function pageOf(pages: BuildPage[], defId: string): number {
|
||||
return pages.findIndex((p) => p.machines.some((m) => m.id === defId));
|
||||
}
|
||||
@ -1,15 +1,14 @@
|
||||
/**
|
||||
* LANE-UI — machine accent colors for build-bar icons.
|
||||
*
|
||||
* `ItemDef` carries a `color`; `MachineDef` does not, so the build bar has nothing
|
||||
* data-driven to tint with. These are transcribed from docs/FKTRY_LORE.md §4 (each
|
||||
* machine's ASSET block names its impossible element) as a stopgap.
|
||||
* `MachineDef.color` (contracts v2) is the source of truth and wins whenever LANE-DATA
|
||||
* has filled it — the same value LANE-RENDER tints its placeholder boxes with, so the
|
||||
* bar and the world agree about what a quantizer looks like.
|
||||
*
|
||||
* CONTRACT REQUEST filed in NOTES: add `MachineDef.color` — LANE-RENDER needs the same
|
||||
* value for its placeholder box tint, and two lanes hand-maintaining the same table is
|
||||
* how the build bar and the world end up disagreeing about what a quantizer looks like.
|
||||
* Until then: id override, else kind fallback, else house green. Never crashes on a
|
||||
* machine we've never heard of.
|
||||
* The tables below are the round-1 stopgap, kept as fallback: transcribed from
|
||||
* docs/FKTRY_LORE.md §4 (each machine's ASSET block names its impossible element).
|
||||
* Order: data color, else id override, else kind fallback, else house green. Never
|
||||
* crashes on a machine we've never heard of.
|
||||
*/
|
||||
import type { MachineDef, MachineKind } from '../contracts';
|
||||
|
||||
@ -34,5 +33,5 @@ const BY_KIND: Record<MachineKind, string> = {
|
||||
};
|
||||
|
||||
export function machineColor(def: MachineDef): string {
|
||||
return BY_ID[def.id] ?? BY_KIND[def.kind] ?? '#d8ffd8';
|
||||
return def.color || BY_ID[def.id] || BY_KIND[def.kind] || '#d8ffd8';
|
||||
}
|
||||
|
||||
107
fktry/src/ui/pick.test.ts
Normal file
107
fktry/src/ui/pick.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Dir, EntityState, MachineDef, SimSnapshot, Vec2 } from '../contracts';
|
||||
import { createRoster, entityAt, footprintOf, syncRoster } from './pick';
|
||||
|
||||
function def(id: string, w: number, h: number): MachineDef {
|
||||
return {
|
||||
id, name: id.toUpperCase(), codex: '', kind: 'crafter',
|
||||
footprint: { x: w, y: h }, recipes: [], powerDraw: 0, asset: id,
|
||||
};
|
||||
}
|
||||
|
||||
function ent(id: number, defId: string, pos: Vec2, dir: Dir = 0): EntityState {
|
||||
return {
|
||||
id, def: defId, pos, dir, recipe: null, progress: 0,
|
||||
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(entities: EntityState[]): SimSnapshot {
|
||||
return {
|
||||
tick: 0, paused: false, entities, beltItems: [],
|
||||
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
|
||||
shippedTotal: {}, activeCommission: null, commissionProgress: {},
|
||||
};
|
||||
}
|
||||
|
||||
const MACHINES = new Map<string, MachineDef>([
|
||||
['reactor', def('reactor', 3, 3)],
|
||||
['press', def('press', 2, 1)],
|
||||
['belt', def('belt', 1, 1)],
|
||||
]);
|
||||
|
||||
describe('footprintOf', () => {
|
||||
it('leaves N/S footprints alone', () => {
|
||||
expect(footprintOf(def('press', 2, 1), 0)).toEqual({ x: 2, y: 1 });
|
||||
expect(footprintOf(def('press', 2, 1), 2)).toEqual({ x: 2, y: 1 });
|
||||
});
|
||||
|
||||
it('swaps width and height for E/W', () => {
|
||||
expect(footprintOf(def('press', 2, 1), 1)).toEqual({ x: 1, y: 2 });
|
||||
expect(footprintOf(def('press', 2, 1), 3)).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityAt', () => {
|
||||
const roster = createRoster();
|
||||
|
||||
it('hits every tile of a footprint and nothing outside it', () => {
|
||||
syncRoster(roster, snap([ent(7, 'reactor', { x: 4, y: 4 })]));
|
||||
// pos is the origin corner (min x, min y): 4..6 inclusive on both axes.
|
||||
for (let x = 4; x <= 6; x++) {
|
||||
for (let y = 4; y <= 6; y++) expect(entityAt(roster, MACHINES, { x, y })).toBe(7);
|
||||
}
|
||||
for (const t of [{ x: 3, y: 4 }, { x: 7, y: 4 }, { x: 4, y: 3 }, { x: 4, y: 7 }]) {
|
||||
expect(entityAt(roster, MACHINES, t)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects rotation when hit-testing', () => {
|
||||
syncRoster(roster, snap([ent(1, 'press', { x: 0, y: 0 }, 1)])); // 2x1 rotated -> 1x2
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 1 })).toBe(1);
|
||||
expect(entityAt(roster, MACHINES, { x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on empty ground', () => {
|
||||
syncRoster(roster, snap([]));
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the later-placed unit when footprints overlap', () => {
|
||||
syncRoster(roster, snap([ent(1, 'belt', { x: 2, y: 2 }), ent(2, 'belt', { x: 2, y: 2 })]));
|
||||
expect(entityAt(roster, MACHINES, { x: 2, y: 2 })).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores an entity whose def is unknown rather than throwing', () => {
|
||||
syncRoster(roster, snap([ent(9, 'ghost-machine', { x: 0, y: 0 })]));
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncRoster', () => {
|
||||
it('copies values out rather than holding the snapshot entities', () => {
|
||||
const roster = createRoster();
|
||||
const e = ent(3, 'belt', { x: 1, y: 1 });
|
||||
syncRoster(roster, snap([e]));
|
||||
|
||||
// CONTRACTS: sim may reuse its buffers. Mutating the source must not move our copy.
|
||||
e.pos.x = 99;
|
||||
e.id = 42;
|
||||
expect(roster.entries[0].x).toBe(1);
|
||||
expect(roster.entries[0].id).toBe(3);
|
||||
});
|
||||
|
||||
it('reuses its backing array and reports a shrinking length', () => {
|
||||
const roster = createRoster();
|
||||
syncRoster(roster, snap([ent(1, 'belt', { x: 0, y: 0 }), ent(2, 'belt', { x: 1, y: 0 })]));
|
||||
const backing = roster.entries;
|
||||
expect(roster.length).toBe(2);
|
||||
|
||||
syncRoster(roster, snap([ent(5, 'belt', { x: 9, y: 9 })]));
|
||||
expect(roster.entries).toBe(backing); // no per-frame allocation
|
||||
expect(roster.length).toBe(1);
|
||||
expect(roster.entries[0].id).toBe(5);
|
||||
// A stale slot must not be reachable through the reported length.
|
||||
expect(entityAt(roster, MACHINES, { x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,27 +1,11 @@
|
||||
/**
|
||||
* LANE-UI — figuring out which unit the player clicked.
|
||||
*
|
||||
* PROBLEM: `UIBus` gives us `dispatch` + `selectedBuild`, and `ui.init(root, data, bus)`
|
||||
* never sees the Renderer — so the UI has no access to `renderer.pickTile`, which is the
|
||||
* only thing that knows the camera. CONTRACT REQUEST filed in NOTES (add `pickTile` to
|
||||
* UIBus, or pass the renderer to `ui.init`).
|
||||
*
|
||||
* Until then: we opportunistically use `window.__fktryRenderer.pickTile` if LANE-RENDER
|
||||
* happens to expose one, and the inspector otherwise falls back to TAB-cycling the unit
|
||||
* manifest (see index.ts). Tile→entity resolution below is ours either way.
|
||||
* `bus.pickTile` (contracts v2) turns a client point into a tile; tile→entity resolution
|
||||
* below is ours. Together they are click-to-inspect.
|
||||
*/
|
||||
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
|
||||
|
||||
export type PickTile = (clientX: number, clientY: number) => Vec2 | null;
|
||||
|
||||
export function resolvePickTile(): PickTile | null {
|
||||
const r = (globalThis as any).__fktryRenderer;
|
||||
if (r && typeof r.pickTile === 'function') {
|
||||
return (x, y) => r.pickTile(x, y) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened copy of the entities in a snapshot: exactly the fields the UI needs between
|
||||
* frames (hit-testing a click, naming a jammed unit in a toast).
|
||||
|
||||
77
fktry/src/ui/selection.test.ts
Normal file
77
fktry/src/ui/selection.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Command, Dir, 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,
|
||||
dispatch: (_cmd: Command) => {},
|
||||
selectedBuild: () => bus._sel,
|
||||
pickTile: (_x: number, _y: number): Vec2 | null => null,
|
||||
};
|
||||
return bus;
|
||||
}
|
||||
|
||||
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(bus._sel).toEqual({ def: 'quantizer', dir: 0 });
|
||||
expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 });
|
||||
});
|
||||
|
||||
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(bus._sel).toBeNull();
|
||||
});
|
||||
|
||||
it('rotates N->E->S->W and wraps', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('belt');
|
||||
const dirs = [1, 2, 3, 0].map(() => {
|
||||
sel.rotate();
|
||||
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 = 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('notifies listeners on change, and not on a no-op clear', () => {
|
||||
const sel = createSelection(makeBus());
|
||||
const cb = vi.fn();
|
||||
sel.onChange(cb);
|
||||
|
||||
sel.select('belt');
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
sel.rotate();
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
sel.clear();
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
|
||||
sel.clear(); // already empty — nothing changed, nobody needs waking
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@ -68,6 +68,9 @@ const CSS = `
|
||||
|
||||
.fk-bar { height: 6px; background: var(--fk-bg-solid); border: 1px solid var(--fk-line); }
|
||||
.fk-bar-f { height: 100%; width: 0%; background: var(--fk-cool); transition: width 90ms linear; }
|
||||
.fk-heat .fk-bar-f { background: var(--fk-dim); }
|
||||
.fk-heat.is-throttling .fk-bar-f { background: var(--fk-amber); }
|
||||
.fk-heat.is-scram .fk-bar-f { background: var(--fk-red); }
|
||||
|
||||
/* ---------------------------------------------------------------- top strip */
|
||||
|
||||
@ -103,13 +106,24 @@ const CSS = `
|
||||
padding: 6px;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
/* LANE-DATA shipped 21 machines; the bar wraps rather than running off the viewport.
|
||||
Paging/tabs proposed for a later round — see NOTES. */
|
||||
.fk-build-row {
|
||||
display: flex; gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine. */
|
||||
.fk-tabs { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; justify-content: center; }
|
||||
.fk-tab {
|
||||
background: var(--fk-bg-solid);
|
||||
border: 1px solid var(--fk-line);
|
||||
border-bottom-color: transparent;
|
||||
color: var(--fk-faint);
|
||||
font: inherit; font-size: 9px; letter-spacing: 0.12em;
|
||||
padding: 2px 8px; cursor: pointer; pointer-events: auto;
|
||||
}
|
||||
.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; }
|
||||
.fk-build-btn {
|
||||
position: relative;
|
||||
width: 58px;
|
||||
@ -162,6 +176,25 @@ const CSS = `
|
||||
.fk-ins-jam { display: none; color: var(--fk-amber); font-size: 11px; margin-top: 5px; }
|
||||
.fk-ins-jam.is-on { display: block; }
|
||||
.fk-ins-empty { color: var(--fk-faint); font-size: 10px; }
|
||||
.fk-ins-heat-state { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
|
||||
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
|
||||
|
||||
/* Process select: a native <select> beaten into the house style. */
|
||||
.fk-ins-recipe {
|
||||
width: 100%;
|
||||
background: var(--fk-bg-solid);
|
||||
border: 1px solid var(--fk-line);
|
||||
color: var(--fk-fg);
|
||||
font: inherit; font-size: 10px;
|
||||
padding: 3px 4px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ---------------------------------------------------------------- fax */
|
||||
|
||||
@ -188,6 +221,29 @@ const CSS = `
|
||||
#fk-fax .fk-chip { border-color: #4a4230; }
|
||||
#fk-fax .fk-chip-n { color: #a09468; }
|
||||
#fk-fax .fk-chip.is-met .fk-chip-n { color: var(--fk-cool); }
|
||||
/* NEXT IN TRAY: a peek, so it reads quieter than the live commission. */
|
||||
.fk-fax-next { display: none; opacity: 0.6; }
|
||||
.fk-fax-next.is-on { display: block; }
|
||||
.fk-fax-next-name { color: #a09468; font-size: 10px; letter-spacing: 0.08em; }
|
||||
|
||||
/* SANITISING: The Correction's buyback, stated as routine paperwork. */
|
||||
.fk-san { display: none; margin-top: 4px; }
|
||||
.fk-san.is-on { display: block; }
|
||||
.fk-san-h { color: #7fa8a0; }
|
||||
|
||||
/* STANDING ORDER: not an animation — it's just always true, stamped once. */
|
||||
.fk-fax-standing {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
display: none; pointer-events: none;
|
||||
}
|
||||
.fk-fax-standing.is-on { display: block; }
|
||||
.fk-fax-standing span {
|
||||
border: 2px solid var(--fk-cool); color: var(--fk-cool);
|
||||
font-size: 9px; font-weight: bold; letter-spacing: 0.1em;
|
||||
padding: 1px 4px; transform: rotate(-6deg); display: block;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.fk-fax-stamp {
|
||||
position: absolute; inset: 0;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
|
||||
92
fktry/src/ui/voice.test.ts
Normal file
92
fktry/src/ui/voice.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MachineDef, MachineKind, SimEvent } from '../contracts';
|
||||
import { machineColor } from './palette';
|
||||
import { eventToast, jamText, machineFlavor } from './voice';
|
||||
|
||||
function def(over: Partial<MachineDef> = {}): MachineDef {
|
||||
return {
|
||||
id: 'quantizer', name: 'QUANTIZER', codex: '', kind: 'crafter',
|
||||
footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'quantizer', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
const named = (id: number) => (id === 5 ? 'MOSH REACTOR' : `UNIT ${id}`);
|
||||
|
||||
describe('machineFlavor', () => {
|
||||
it('prefers the codex line from data once LANE-DATA fills it', () => {
|
||||
expect(machineFlavor(def({ flavor: 'The dial goes to CRIME.' }), 'quantizer'))
|
||||
.toBe('The dial goes to CRIME.');
|
||||
});
|
||||
|
||||
it('falls back to the hand-transcribed line while data is empty', () => {
|
||||
expect(machineFlavor(def(), 'quantizer')).toContain('Rounds coefficients to zero');
|
||||
});
|
||||
|
||||
it('gives an unknown machine generic copy rather than nothing', () => {
|
||||
expect(machineFlavor(def({ id: 'wormhole' }), 'wormhole')).toContain('Industrial equipment');
|
||||
});
|
||||
|
||||
it('survives a missing def', () => {
|
||||
expect(machineFlavor(undefined, 'belt')).toContain('Moving things is the entire job');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jamText', () => {
|
||||
it('distinguishes backed-up from starved', () => {
|
||||
expect(jamText('output full')).toBe('OUTPUT BUFFER AT CAPACITY. THIS IS A YOU PROBLEM.');
|
||||
expect(jamText('starved')).toBe('INTAKE STARVED. NOTHING IS ARRIVING.');
|
||||
});
|
||||
|
||||
it('passes an unknown reason through uppercased rather than swallowing it', () => {
|
||||
expect(jamText('belt on fire')).toBe('BELT ON FIRE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventToast', () => {
|
||||
it('names the machine that jammed and says why', () => {
|
||||
const e: SimEvent = { kind: 'jammed', entity: 5, reason: 'starved', tick: 1 };
|
||||
expect(eventToast(e, named)).toBe('MOSH REACTOR: INTAKE STARVED. NOTHING IS ARRIVING.');
|
||||
});
|
||||
|
||||
it('has different copy for a brownout arriving and lifting', () => {
|
||||
const on = eventToast({ kind: 'brownout', on: true, tick: 1 }, named);
|
||||
const off = eventToast({ kind: 'brownout', on: false, tick: 2 }, named);
|
||||
expect(on).not.toBe(off);
|
||||
expect(on).toContain('STILL LIFE');
|
||||
});
|
||||
|
||||
it('reports a thermal scram against the machine that scrammed', () => {
|
||||
const e: SimEvent = { kind: 'scram', entity: 5, on: true, tick: 9 };
|
||||
expect(eventToast(e, named)).toContain('MOSH REACTOR');
|
||||
expect(eventToast(e, named)).toContain('THERMAL SCRAM');
|
||||
});
|
||||
|
||||
it('stays quiet for the high-frequency events', () => {
|
||||
for (const e of [
|
||||
{ kind: 'shipped', item: 'melt', count: 1, tick: 1 },
|
||||
{ kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 },
|
||||
{ kind: 'placed', entity: 1, def: 'belt', tick: 1 },
|
||||
{ kind: 'removed', entity: 1, def: 'belt', tick: 1 },
|
||||
] as SimEvent[]) {
|
||||
expect(eventToast(e, named)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('machineColor', () => {
|
||||
it('prefers the art-directed color from data', () => {
|
||||
expect(machineColor(def({ color: '#123456' }))).toBe('#123456');
|
||||
});
|
||||
|
||||
it('falls back to the codex-derived id color', () => {
|
||||
expect(machineColor(def({ id: 'mosh-reactor' }))).toBe('#ff7a3f');
|
||||
});
|
||||
|
||||
it('falls back to a kind color for a machine with no entry', () => {
|
||||
expect(machineColor(def({ id: 'brand-new-thing', kind: 'power' }))).toBe('#3fffe0');
|
||||
});
|
||||
|
||||
it('always returns something for an unheard-of kind', () => {
|
||||
expect(machineColor(def({ id: 'x', kind: 'teleporter' as MachineKind }))).toMatch(/^#/);
|
||||
});
|
||||
});
|
||||
@ -7,15 +7,15 @@
|
||||
*
|
||||
* All copy in the UI comes from this file so the tone stays in one place.
|
||||
*/
|
||||
import type { SimEvent } from '../contracts';
|
||||
import type { MachineDef, SimEvent } from '../contracts';
|
||||
|
||||
/**
|
||||
* Flavor lines for the inspector, keyed by MachineDef.id.
|
||||
* Fallback flavor, keyed by MachineDef.id.
|
||||
*
|
||||
* TEMPORARY: hand-transcribed from docs/FKTRY_LORE.md §4 by LANE-UI. These belong in
|
||||
* data/machines.json as a `flavor` field owned by LANE-DATA — CONTRACT REQUEST filed in
|
||||
* NOTES. Unknown ids fall back to `GENERIC_FLAVOR`, so new machines never crash the
|
||||
* panel, they just sound like a filing cabinet until the codex line lands.
|
||||
* `MachineDef.flavor` (contracts v2) is the real source and wins whenever LANE-DATA has
|
||||
* filled it. These are the seven I hand-transcribed from the codex in round 1, kept as
|
||||
* the fallback per orders. Unknown ids get `GENERIC_FLAVOR` — a new machine never
|
||||
* crashes the panel, it just sounds like a filing cabinet until its line lands.
|
||||
*/
|
||||
const MACHINE_FLAVOR: Record<string, string> = {
|
||||
'seam-extractor':
|
||||
@ -44,19 +44,39 @@ const MACHINE_FLAVOR: Record<string, string> = {
|
||||
const GENERIC_FLAVOR =
|
||||
'Industrial equipment. Operates within tolerances. Tolerances not disclosed.';
|
||||
|
||||
export function machineFlavor(defId: string): string {
|
||||
return MACHINE_FLAVOR[defId] ?? GENERIC_FLAVOR;
|
||||
export function machineFlavor(def: MachineDef | undefined, id: string): string {
|
||||
return def?.flavor || MACHINE_FLAVOR[id] || GENERIC_FLAVOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jam reasons, in the house voice. The sim owns the reason strings; we translate the
|
||||
* ones we know and pass anything else through uppercased, so a new stall reason shows
|
||||
* up honestly rather than being swallowed.
|
||||
*/
|
||||
const JAM_TEXT: Record<string, string> = {
|
||||
'output full': 'OUTPUT BUFFER AT CAPACITY. THIS IS A YOU PROBLEM.',
|
||||
starved: 'INTAKE STARVED. NOTHING IS ARRIVING.',
|
||||
'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.',
|
||||
'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.',
|
||||
};
|
||||
|
||||
export function jamText(reason: string): string {
|
||||
return JAM_TEXT[reason] ?? reason.toUpperCase();
|
||||
}
|
||||
|
||||
/** Toast copy for events the player should notice without reading a manual. */
|
||||
export function eventToast(ev: SimEvent, machineName: (id: number) => string): string | null {
|
||||
switch (ev.kind) {
|
||||
case 'jammed':
|
||||
return `${machineName(ev.entity)} HAS STOPPED. ${ev.reason.toUpperCase()}.`;
|
||||
return `${machineName(ev.entity)}: ${jamText(ev.reason)}`;
|
||||
case 'brownout':
|
||||
return ev.on
|
||||
? 'BUFFER UNDERRUN. THIS SECTOR IS NOW A STILL LIFE.'
|
||||
: 'BUFFER RESTORED. RESUME SINNING.';
|
||||
case 'scram':
|
||||
return ev.on
|
||||
? `THERMAL SCRAM. ${machineName(ev.entity)} HAS EXCUSED ITSELF.`
|
||||
: `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`;
|
||||
case 'commissionDone':
|
||||
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
|
||||
default:
|
||||
@ -72,25 +92,28 @@ export const COPY = {
|
||||
running: 'RUNNING',
|
||||
tick: 'TICK',
|
||||
|
||||
buildBarTitle: 'REQUISITION',
|
||||
inspectorEmptyRecipe: 'NO PROCESS ASSIGNED. UNIT IS BEING PAID TO EXIST.',
|
||||
inspectorInput: 'INTAKE',
|
||||
inspectorOutput: 'OUTPUT',
|
||||
inspectorProgress: 'PROCESS',
|
||||
inspectorJamPrefix: 'FAULT: ',
|
||||
inspectorRecipe: 'PROCESS SELECT',
|
||||
inspectorNoRecipeOption: 'IDLE',
|
||||
inspectorClose: 'X',
|
||||
inspectorHeat: 'HEAT',
|
||||
heatThrottling: 'THROTTLING',
|
||||
heatScram: 'SCRAM — UNIT OFFLINE',
|
||||
|
||||
faxHeader: 'INCOMING FAX',
|
||||
faxSubheader: 'GLITCH ACQUISITIONS — UNSOLICITED',
|
||||
faxNoCommission: 'NO ACTIVE COMMISSION. THE FAX MACHINE IS RESTING.',
|
||||
faxStamp: 'FAX SENT',
|
||||
faxStanding: 'STANDING ORDER',
|
||||
faxNext: 'NEXT IN TRAY',
|
||||
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
|
||||
sanitising: 'SANITISING',
|
||||
|
||||
/** Shown on the build bar when the player has something selected. */
|
||||
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
||||
idleHint: '1-9 SELECT · SPACE HALT · TAB MANIFEST',
|
||||
|
||||
/** Inspector fallback when no entity is under the cursor. */
|
||||
manifestEmpty: 'NO UNITS ON RECORD. THE FLOOR IS AUDITED AND EMPTY.',
|
||||
idleHint: '1-9 SELECT · [ ] PAGE · SPACE HALT · CLICK A UNIT TO INSPECT',
|
||||
} as const;
|
||||
|
||||
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user