/** * 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; // itemId -> count outputs: Record; 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 | 'lab'; // v4: consumes science packs toward the active research 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. v4 RULING: this is GROSS heat — net // climb = heatPerTick − coolPerTick; a machine whose // heatPerTick ≤ its coolPerTick can never overheat. bufferCap?: number; // v2: bandwidth storage in bandwidth-SECONDS (see SimSnapshot) coolPerTick?: number; // v3: per-machine cooling rate; sim default 0.004 accent?: string; // v4: art-directed emissive accent; fallback stays derived // from first output item (renderer + UI share the derivation) coolRadius?: number; // v4: spatial cooling aura in tiles (chebyshev from footprint). // v5 CLARIFIED: auras are ADDITIVE — a machine in range cools at // its own coolPerTick + sum of in-range auras, capped at the // exported HEAT_COOL_MAX (see src/sim/constants.ts) } export interface TechDef { id: string; era: 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress'; cost: Record; // science-pack itemId -> count unlocks: string[]; // machine/recipe ids } export interface CommissionDef { id: string; flavor: string; // the fax text — deadpan absurd wants: Record; 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; outputBuf: Record; 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; beltItems: ReadonlyArray; // 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). // v4: capacity = summed bufferCap of placed tanks (0 if none) — SCREEN's 2nd dread axis. bandwidth: { gen: number; draw: number; stored: number; capacity?: number; brownout: boolean }; shippedTotal: Record; activeCommission: string | null; commissionQueue?: string[]; // v3: active first, then upcoming in activation // order. May alias a live array — read-only, never // retain. HARDENS IN v5: update fixtures this round. commissionProgress: Record; research?: ResearchState; // v4: research progress (hardens in v6) wildlife?: ReadonlyArray; // v5: piles & swarms (M2's living hazards) } export interface WildlifeState { id: number; kind: 'dust-pile' | 'mosquito-swarm'; pos: Vec2; size: number; // pile: accumulation 0..1; swarm: severity 0..1 target?: number; // swarm: entity id it is currently degrading } export interface ResearchState { active: string | null; // TechDef.id being researched progress: Record; // packs delivered toward active (itemId -> n) unlocked: string[]; // completed TechDef.ids } // v4 gating rule: a machine/recipe id that appears in ANY TechDef.unlocks is locked // until that tech completes; ids referenced by no tech are always available. 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: 'setResearch'; tech: string | null } // v4: pick the lab target | { kind: 'grantResearch'; tech: string[] } // v5: instant unlock — the // sandbox/demo verb, also a // legitimate creative mode | { 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 | { kind: 'researched'; tech: string; tick: number } // v4: research complete | { kind: 'wildlife'; on: 'spawned' | 'cleared'; wildlife: 'dust-pile' | 'mosquito-swarm'; id: number; pos: Vec2; tick: number }; // v5: hazard lifecycle // ---------------------------------------------------------------- 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; // v4: HARDENED — full state, deterministic round-trip load(json: string): void; // v4: HARDENED — restore a save() blob } /** v4: the typed selection protocol — kills the '__remove' magic-string sentinel. */ export type SelectionState = | { mode: 'build'; def: string; dir: Dir } | { mode: 'remove' } | { mode: 'inspect'; entity: number } // v5: open inspector — renderer highlights it | null; export interface Renderer { init(container: HTMLElement, data: GameData): Promise; render(snap: SimSnapshot, alpha: number): void; // alpha: 0..1 interp between ticks pickTile(clientX: number, clientY: number): Vec2 | null; /** v4: mode drives the ghost visual — 'build' = placement preview, 'remove' = * demolition tint. def is null in remove mode. The '__remove' sentinel is deprecated * and main.ts stops sending it in v5. */ setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode?: 'build' | 'remove'): void; } export interface UIBus { dispatch(cmd: Command): void; /** current build selection; renderer ghost + click-to-place read this. * DEPRECATED in v4 (still served): use selection()/setSelection(). */ 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; /** v4: typed selection. main.ts owns the state; UI writes via setSelection. * In remove mode main.ts dispatches the `remove` command on click — the UI must * NOT also dispatch it (that was the round-3 double-fire). */ selection?(): SelectionState; setSelection?(sel: SelectionState): void; } 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 }