Review: 5/5 lanes pass. 160/160 tests, tsc clean, M1 COMPLETE — verified live: reference factory ships melt, commission queue advances with standing orders, SCREEN's new artifact passes respond item-specifically (block 0.46 from bricks). Contracts v3 (granted): EntityState.scrammed, SimSnapshot.commissionQueue, MachineDef.coolPerTick, setRecipeAt command, Sim.save/load, ScreenFX.frame(t, snap), BeltItem.id hardened required, MachineDef.color re-ruled as chassis/body. main.ts: passes snapshot to screen.frame; dev __fktryDemo() builds reference factory. happy-dom ACTUALLY installed this time (round-2 grant never landed; UI caught it). Rulings: bandwidth units are per-second (SIM converts); heat retune ordered (nothing can currently overheat - heatPerTick < ambient cooling); validator test moves to data/; RENDER's color deviation approved as the ruling; scram wipe 3 frames; REMOVE button approved. Round 3 orders in all lane docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
7.5 KiB
TypeScript
180 lines
7.5 KiB
TypeScript
/**
|
|
* FKTRY shared contracts — ORCHESTRATOR-OWNED.
|
|
* Every lane imports from this file. NO lane edits it. If a contract blocks you,
|
|
* write the problem in your lane doc's NOTES and stub around it locally.
|
|
*/
|
|
|
|
export const TICKS_PER_SECOND = 30;
|
|
export const TILE = 1; // world units per grid tile
|
|
|
|
export type Dir = 0 | 1 | 2 | 3; // N E S W (clockwise)
|
|
export interface Vec2 { x: number; y: number }
|
|
|
|
// ---------------------------------------------------------------- data schemas
|
|
// These mirror data/*.json (LANE-DATA owns the JSON, orchestrator owns the shape).
|
|
|
|
export interface ItemDef {
|
|
id: string; // kebab-case, matches codex name, e.g. "chroma-slurry"
|
|
name: string; // display, e.g. "CHROMA SLURRY"
|
|
codex: string; // section ref into docs/FKTRY_LORE.md, e.g. "2:chroma-slurry"
|
|
tier: number; // 0 raw, 1 refined, 2 product, 3 endgame
|
|
color: string; // hex, used by renderer placeholders + UI chips
|
|
}
|
|
|
|
export interface RecipeDef {
|
|
id: string;
|
|
machine: string; // MachineDef.id that can run it
|
|
inputs: Record<string, number>; // itemId -> count
|
|
outputs: Record<string, number>;
|
|
ticks: number; // duration at speed 1
|
|
bandwidth: number; // power draw while crafting (negative = generates)
|
|
heat?: number; // heat per craft (software decoders etc.)
|
|
}
|
|
|
|
export type MachineKind =
|
|
| 'extractor' // pulls items from seam tiles, no inputs
|
|
| 'crafter' // recipe in -> out
|
|
| 'belt' // moves items
|
|
| 'splitter' // 1 in, N out round-robin
|
|
| 'power' // generates bandwidth
|
|
| 'buffer' // stores bandwidth smoothness (brownout protection)
|
|
| 'shipper'; // consumes items => 'shipped' events => THE SCREEN
|
|
|
|
export interface MachineDef {
|
|
id: string;
|
|
name: string;
|
|
codex: string;
|
|
kind: MachineKind;
|
|
footprint: Vec2; // tiles occupied (w,h) before rotation
|
|
recipes: string[]; // RecipeDef.ids this machine may run ([] for belts etc.)
|
|
powerDraw: number; // idle bandwidth draw
|
|
powerGen?: number;
|
|
beltSpeed?: number; // tiles/sec, kind==='belt'
|
|
asset: string; // codex asset key for renderer mesh registry, e.g. "quantizer"
|
|
color?: string; // v3 RULING: CHASSIS/BODY color (codex §8 grime); the emissive
|
|
// accent stays derived from outputs. (Was mislabeled "accent".)
|
|
flavor?: string; // v2: codex one-liner for the inspector (DATA's material)
|
|
heatPerTick?: number; // v2: heat while active (the software decoder's whole bit)
|
|
bufferCap?: number; // v2: bandwidth storage in bandwidth-SECONDS (see SimSnapshot)
|
|
coolPerTick?: number; // v3: per-machine cooling rate; sim default 0.004
|
|
}
|
|
|
|
export interface TechDef {
|
|
id: string;
|
|
era: 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress';
|
|
cost: Record<string, number>; // science-pack itemId -> count
|
|
unlocks: string[]; // machine/recipe ids
|
|
}
|
|
|
|
export interface CommissionDef {
|
|
id: string;
|
|
flavor: string; // the fax text — deadpan absurd
|
|
wants: Record<string, number>;
|
|
rewardBandwidth: number;
|
|
repeat?: boolean; // v2: standing order — re-activates after completion
|
|
}
|
|
|
|
export interface GameData {
|
|
items: ItemDef[];
|
|
machines: MachineDef[];
|
|
recipes: RecipeDef[];
|
|
tech: TechDef[];
|
|
commissions: CommissionDef[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------- live sim state
|
|
|
|
export interface EntityState {
|
|
id: number;
|
|
def: string; // MachineDef.id
|
|
pos: Vec2; // tile coords of origin corner
|
|
dir: Dir;
|
|
recipe: string | null;
|
|
progress: number; // 0..1 of current craft
|
|
inputBuf: Record<string, number>;
|
|
outputBuf: Record<string, number>;
|
|
jammed: string | null; // human-readable reason or null
|
|
heat: number; // 0..1, throttles >0.7, scrams at 1
|
|
scrammed?: boolean; // v3: heat-scram latch (heat alone can't show the
|
|
// hysteresis window; renderer/UI read this, not 0.5)
|
|
}
|
|
|
|
export interface BeltItem {
|
|
item: string;
|
|
entity: number; // belt entity id
|
|
t: number; // 0..1 progress along the belt tile
|
|
id: number; // v3: stable per-item id (required — sim always stamps)
|
|
}
|
|
|
|
export interface SimSnapshot {
|
|
tick: number;
|
|
paused: boolean;
|
|
entities: ReadonlyArray<EntityState>;
|
|
beltItems: ReadonlyArray<BeltItem>;
|
|
// v3 RULING on units: gen/draw are bandwidth per SECOND; stored is bandwidth-seconds
|
|
// (sim integrates surplus/deficit divided by TICKS_PER_SECOND each tick).
|
|
bandwidth: { gen: number; draw: number; stored: number; brownout: boolean };
|
|
shippedTotal: Record<string, number>;
|
|
activeCommission: string | null;
|
|
commissionQueue?: string[]; // v3: truth for "next in tray" — active first, then
|
|
// upcoming in activation order (hardens in v4)
|
|
commissionProgress: Record<string, number>;
|
|
}
|
|
|
|
export type Command =
|
|
| { kind: 'place'; def: string; pos: Vec2; dir: Dir }
|
|
| { kind: 'remove'; pos: Vec2 }
|
|
| { kind: 'rotate'; pos: Vec2 }
|
|
| { kind: 'setRecipe'; entity: number; recipe: string | null }
|
|
| { kind: 'setRecipeAt'; pos: Vec2; recipe: string | null } // v3: id-free variant
|
|
| { kind: 'setPaused'; paused: boolean };
|
|
|
|
export type SimEvent =
|
|
| { kind: 'shipped'; item: string; count: number; tick: number }
|
|
| { kind: 'crafted'; entity: number; recipe: string; tick: number }
|
|
| { kind: 'jammed'; entity: number; reason: string; tick: number }
|
|
| { kind: 'brownout'; on: boolean; tick: number }
|
|
| { kind: 'placed'; entity: number; def: string; tick: number }
|
|
| { kind: 'removed'; entity: number; def: string; tick: number }
|
|
| { kind: 'commissionDone'; commission: string; tick: number }
|
|
| { kind: 'scram'; entity: number; on: boolean; tick: number }; // v2: heat shutdown edge
|
|
|
|
// ---------------------------------------------------------------- module interfaces
|
|
|
|
export interface Sim {
|
|
init(data: GameData, seed: number): void;
|
|
enqueue(cmd: Command): void;
|
|
tick(): void; // advance exactly one tick
|
|
snapshot(): SimSnapshot; // read-only view; renderer/ui must not mutate
|
|
drainEvents(): SimEvent[]; // events since last drain (main loop fans out)
|
|
save?(): string; // v3: full state as JSON, deterministic round-trip
|
|
load?(json: string): void; // v3: restore a save() blob (hardens in v4)
|
|
}
|
|
|
|
export interface Renderer {
|
|
init(container: HTMLElement, data: GameData): Promise<void>;
|
|
render(snap: SimSnapshot, alpha: number): void; // alpha: 0..1 interp between ticks
|
|
pickTile(clientX: number, clientY: number): Vec2 | null;
|
|
setGhost(def: string | null, pos: Vec2 | null, dir: Dir): void;
|
|
}
|
|
|
|
export interface UIBus {
|
|
dispatch(cmd: Command): void;
|
|
/** current build selection; renderer ghost + click-to-place read this */
|
|
selectedBuild(): { def: string; dir: Dir } | null;
|
|
/** v2: world picking, plumbed from the renderer — UI's click-to-inspect */
|
|
pickTile(clientX: number, clientY: number): Vec2 | null;
|
|
}
|
|
|
|
export interface UI {
|
|
init(root: HTMLElement, data: GameData, bus: UIBus): void;
|
|
update(snap: SimSnapshot, events: SimEvent[]): void;
|
|
}
|
|
|
|
export interface ScreenFX {
|
|
init(canvas: HTMLCanvasElement, data: GameData): void;
|
|
onEvents(events: SimEvent[]): void; // shipped items feed the corruption
|
|
frame(timeMs: number, snap?: SimSnapshot): void; // v3: read-only peek for continuous
|
|
// strain (stored, heat); never retain
|
|
}
|