glytch/fktry/src/contracts.ts
type-two 60a14313b4 [orchestrator] round 7 phase 1 orders: THE CORRECTION, EMBODIED
Contracts v9: CorrectionState (debt/band/units, rungs 2-4 — the mite stays in
wildlife by ruling), EntityState.sealed, pry command, CommissionDef.compliance,
notice/sealed/mirrored/stasis/debtBand events, and Renderer.onEvents?() — RENDER's
round-6 request, granted now that three cues derive events from snapshot deltas.
main.ts fans the same event array to the renderer.

Rulings baked into the orders (from the adversarial design pass): fax-first and
auto-release are CONTRACTUAL on the auditor; warden faxes carry no grid reference;
gunships target deviation, not throughput; no debt for canning a unit (an immune
system does not hold a grudge); THE INVERSION - The Correction is the only thing
in the world that never drops a frame; the compliance tone is one grid-locked
triad voice, discovered-gated, sparse by construction.

Also: archaeology-lab.glb re-baked through the corrected farm pipeline
(bg_remove_local before meshing) - backdrop-slab flatness 0.614 -> 0.480.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:12:31 +10:00

308 lines
15 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).
// 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<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
compliance?: boolean; // v9: a Correction quota — completing it vents PARITY
// DEBT; wants must be tier-0/1 (validator-enforced)
}
// v7: lifted verbatim from SIM's round-5 mirror — data/seams.json's shape.
export interface SeamDef {
id: string;
rect: { x: number; y: number; w: number; h: number };
era?: string;
resource?: string;
richness?: number;
hidden?: boolean; // treasure — only the numbers station may reveal it
}
export interface SeamMap {
/** DATA keeps this corner seam-free so the seed-derived relic always lands somewhere. */
relicReserve?: { x: number; y: number; w: number; h: number };
seams: SeamDef[];
}
export interface GameData {
items: ItemDef[];
machines: MachineDef[];
recipes: RecipeDef[];
tech: TechDef[];
commissions: CommissionDef[];
seams?: SeamMap; // v7: wired by main.ts from data/seams.json; extractors need ore
}
// ---------------------------------------------------------------- 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)
sealed?: boolean; // v9: warden compliance seal — machine refuses to run
// until pried (the pry command). UI/renderer read this.
}
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 v6)
wildlife?: ReadonlyArray<WildlifeState>; // v5: piles & swarms (M2's living hazards)
relics?: ReadonlyArray<RelicState>; // v6: buried secrets (the hidden pipe)
correction?: CorrectionState; // v9: PARITY DEBT + rungs 2-4 (mite stays wildlife)
}
/** v9: THE CORRECTION, embodied. Debt is the escalation throttle; bands gate spawns. */
export interface CorrectionState {
debt: number; // 0..1
band: 0 | 1 | 2 | 3 | 4; // NOMINAL | OBSERVED | AUDITED | SEALED | REFUSED
units: ReadonlyArray<CorrectionUnit>;
}
export interface CorrectionUnit {
id: number;
kind: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor';
pos: Vec2;
/** sim-defined phase, e.g. 'approach' | 'audit' | 'tether' | 'prescan' | 'stasis' | 'leaving' */
phase: string;
progress: number; // 0..1 of current phase
target?: number; // warden: audited entity; gunship: the PARITY ANCHOR
rect?: { x: number; y: number; w: number; h: number }; // gunship sector / auditor stasis
}
export interface RelicState {
id: number;
kind: 'optical-fossil'; // v6: the severed audio bus — one per world
pos: Vec2;
found: boolean; // excavated; persists through save/load
}
export interface WildlifeState {
id: number;
kind: 'dust-pile' | 'mosquito-swarm' | 'parity-mite'; // v8: The Correction walks
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<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: 'grantResearch'; tech: string[] } // v5: instant unlock — the
// sandbox/demo verb, also a
// legitimate creative mode
| { kind: 'excavate'; pos: Vec2 } // v6: dig at a relic site
| { kind: 'pry'; pos: Vec2 } // v9: pry a compliance seal
// off (takes time, adds debt
// - you defaced a notice)
| { 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' | 'parity-mite';
id: number; pos: Vec2; tick: number } // v5: hazard lifecycle
| { kind: 'relicFound'; relic: 'optical-fossil'; id: number; tick: number } // v6
| { kind: 'repaired'; item: string; pos: Vec2; tick: number } // v8: a mite corrected your work
// v9 — THE CORRECTION, rungs 2-4. 'notice' always precedes action (fax first, never
// silent); auditor stasis ALWAYS auto-releases (safety is contractual, not tunable).
| { kind: 'notice'; unit: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor'; tick: number }
| { kind: 'sealed'; entity: number; on: boolean; tick: number }
| { kind: 'mirrored'; item: string; pos: Vec2; tick: number } // subtracts from the sky ledger
| { kind: 'stasis'; rect: { x: number; y: number; w: number; h: number }; on: boolean;
lockHash: string; tick: number }
| { kind: 'debtBand'; band: 0 | 1 | 2 | 3 | 4; tick: number };
// ---------------------------------------------------------------- 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<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;
/** v9 GRANT (requested round 6, three consumers by round 7): the same event array
* ScreenFX/AudioFX get, fanned out by main.ts each frame. Optional — a renderer
* without it keeps deriving from snapshot deltas. */
onEvents?(events: SimEvent[]): 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;
}
/** v6: THE SPEAKER — everything audible. All sound is synthesized (Web Audio), no
* asset files. Pre-discovery: minimal diegetic UI sounds only. After the optical
* fossil is found (snapshot.relics / relicFound), the full band unlocks: tuner,
* stations, world ambience. Owned by LANE-SCREEN (src/audio/**). */
export interface AudioFX {
init(data: GameData): void;
/** call on first user gesture — browsers gate AudioContext on interaction */
unlock(): void;
onEvents(events: SimEvent[]): void;
frame(timeMs: number, snap?: SimSnapshot): 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
}