The pin caught SIM's in-flight code using a 'notice' phase within ten minutes of landing - the mandated gap between the fax and the act is a real lifecycle state the original six missed. SCREEN's test fixture takes CorrectionUnit['phase'] now, so a typo'd phase fails at compile instead of silently never matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
313 lines
15 KiB
TypeScript
313 lines
15 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
|
||
| '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;
|
||
/** v9.1 PINNED (SCREEN string-matches this; a free string was a silent-miss trap):
|
||
* the full lifecycle vocabulary. SIM: request additions via NOTES, never invent. */
|
||
phase: 'notice' | 'approach' | 'audit' | 'tether' | 'prescan' | 'stasis' | 'leaving';
|
||
// 'notice' = the mandated gap between the fax and the act — the unit exists,
|
||
// is visible on approach vectors, but has not begun. (Added v9.1 when the pin
|
||
// caught SIM's in-flight code already using it — the union earned its keep in
|
||
// its first ten minutes.)
|
||
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
|
||
}
|