glytch/fktry/src/contracts.ts
type-two 557c78b208 [orchestrator] round 4 review + contracts v5 + round 5 orders
Review: 5/5 pass, 318/318, tsc clean. Verified live: STANDARDS COMMITTEE renders all
eras with ratified nodes, fuel gauge + ON RESERVE state, composite seam in the tree.
Fixed the demo brownout (mine): __fktryDemo now grants referenceFactoryTech() via
testkit before dispatching (swaps to the grantResearch command when SIM lands it).

Contracts v5: grantResearch command (sandbox is a feature), SelectionState inspect
mode (RENDER's placed-and-selected auras), wildlife (WildlifeState + event),
coolRadius clarified ADDITIVE capped at HEAT_COOL_MAX. main.ts: typed selection
store (inspect-capable), sentinel string no longer sent, narrowing fixed in UI's
faithful-copy harness (my breakage, my fix).

Rulings: research-time approved; pack abandonment kept as design; DATA's ungating +
starter-plant arithmetic ratified; SCREEN era-feed + jammed-rot approved; UI tab
strip approved. MASTERPLAN: bare-stash ban, stale-numbers process note.

Round 5 = M2 closes: wildlife (dust piles, mosquito swarms, traps), research takes
time, era-aging sky, seam map groundwork, inspect-mode handshake.

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

232 lines
11 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
}
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 v6)
wildlife?: ReadonlyArray<WildlifeState>; // 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<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: '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<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
}