glytch/fktry/src/contracts.ts
type-two 83e3ad2107 [orchestrator] round 3 review + contracts v4 + composite seam codex + round 4 orders
Review: 5/5 lanes pass, 238/238, tsc clean. Verified live: scram duty-cycling with
tanks covering (zero brownout), shipment stamps, strain fever 0.824. Found 1 real bug
in review: UI BROWNOUT banner fires on covered deficit (topstrip.ts:66) - ordered.

Contracts v4: research (lab kind, setResearch, ResearchState, researched event),
SelectionState protocol (kills __remove sentinel, main.ts owns remove dispatch),
accent?, coolRadius? (spatial cooling), bandwidth.capacity?, save/load hardened,
heat ruled GROSS (net = heat - cool). tsconfig now typechecks data/ (DATA's proof).
Orchestrator patched lab entries into exhaustive kind maps (my breakage, my fix).

Rulings: pathspec-commit rule after 3 shared-index collisions (worktrees = named
escalation); bloom escalator reading accepted; UI ?uidemo + SCREEN boredom approved.

Codex: docs/FKTRY_LORE.md grew §10 THE COMPOSITE SEAM - keying/transparency artifact
family from the Corridor Crew compositing history + our corridorkey-mrp-mlx (which
doubles as a PYXLFK ground-truth artifact factory). DATA transcribes it round 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:43:48 +10:00

215 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
| '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);
// this machine's coolPerTick also applies to machines in range
}
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).
// 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<string, number>;
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<string, number>;
research?: ResearchState; // v4: research progress (hardens in v5)
}
export interface ResearchState {
active: string | null; // TechDef.id being researched
progress: Record<string, number>; // 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: '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
// ---------------------------------------------------------------- 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' }
| null;
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;
/** 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
}