/** * Item -> artifact mapping. This is the design core of THE SCREEN: a player who ships * 40 chroma slurry must SEE chroma damage specifically, not "more glitch". * * Two independent quantities come out of the ledger: * - MIX (which artifacts) = the weighted blend of what you ship * - TOTAL (how much) = the escalation curve over lifetime shipments * Shipping a lot of one thing bends the mix hard toward that item's signature. */ import type { GameData } from '../contracts'; import { PARAMS, type Param, type ParamSet, zeroParams } from './params'; export interface ItemCorruption { /** How much this item escalates overall corruption. Negative = sanitizes. */ potency: number; /** Which artifacts this item argues for. Values 0..1, blended by ship share. */ weights: Partial>; } /** * Keyed by ItemDef.id (codex canon, see docs/FKTRY_LORE.md §2/§3). Items not listed * here still work — they get FALLBACK, a small generic noise bump — so LANE-DATA can * add content without touching this lane. Unrecognised ids are reported at init. */ const CORRUPTION: Record = { // ---- raw (§2) : undifferentiated damage, cheap to ship, weak signature 'mdat-ore': { potency: 0.4, weights: { noise: 0.5, burn: 0.25 } }, 'bayer-grit': { potency: 0.4, weights: { noise: 0.55, chroma: 0.25 } }, 'field-splinters': { potency: 0.5, weights: { tear: 0.6, roll: 0.45 } }, // interlace 'silver-frames': { potency: 0.5, weights: { burn: 0.7 } }, // celluloid // ---- refined (§2) 'luma-bars': { potency: 0.6, weights: { roll: 0.6, tear: 0.5 } }, 'chroma-slurry': { potency: 1.0, weights: { chroma: 1.0, burn: 0.12 } }, // Subsampling grades: the more you throw away, the harder the colour bleeds. 'chroma-422': { potency: 1.05, weights: { chroma: 1.0, melt: 0.15 } }, 'chroma-420': { potency: 1.1, weights: { chroma: 1.0, melt: 0.3 } }, 'color-plate': { potency: 0.7, weights: { chroma: 0.75 } }, // DCT coefficients overdriven: quantization, and the grid it confesses to. 'coefficient-pack': { potency: 0.7, weights: { block: 0.7, freeze: 0.4, noise: 0.2 } }, 'hf-dust': { potency: 0.8, weights: { noise: 0.9, tear: 0.2 } }, 'mv-flux': { potency: 1.0, weights: { mosh: 0.9, melt: 0.45 } }, // motion vectors 'v-hold-roll': { potency: 0.7, weights: { roll: 1.0 } }, // ---- frames (§2) // I-frames are CLEAN REFERENCE. Shipping anchors sanitizes THE SCREEN. // See NOTES: deliberate tension, flagged for the orchestrator. 'anchor-slab': { potency: -2.0, weights: {} }, 'delta-wafer': { potency: 0.9, weights: { mosh: 0.85, melt: 0.3 } }, // P: prediction, no anchor 'janus-wafer': { potency: 1.0, weights: { mosh: 0.6, freeze: 0.6, melt: 0.25 } }, // B: bidirectional 'gop-crate': { potency: 1.0, weights: { freeze: 0.7, mosh: 0.45, roll: 0.25 } }, // ---- glitch products (§3) : the signatures. These must read INSTANTLY. melt: { potency: 1.2, weights: { melt: 0.95, mosh: 0.55 } }, 'bloom-concentrate': { potency: 1.3, weights: { mosh: 0.9, melt: 0.6 } }, // Round 2: these three now have artifact passes of their own. A pile of bricks must // read as a blocky grid, not as displacement; halos as ringing, not as static. 'macroblock-bricks': { potency: 1.1, weights: { block: 1.0, mosh: 0.2 } }, 'ringing-halos': { potency: 0.9, weights: { ring: 1.0, noise: 0.15 } }, 'static-canister': { potency: 1.0, weights: { noise: 1.0 } }, 'moire-crystal': { potency: 1.1, weights: { moire: 1.0, chroma: 0.2 } }, 'false-color-speckle': { potency: 1.2, weights: { chroma: 1.0, noise: 0.3 } }, 'judder-cams': { potency: 1.1, weights: { freeze: 1.0 } }, // 3:2 pulldown 'explosive-hits': { potency: 1.5, weights: { mosh: 0.8, burn: 0.6, chroma: 0.5 } }, // Gamut paints: house-gray is nearly inert, laser primaries are weapons-grade. 'gamut-paint-rec709': { potency: 0.3, weights: { chroma: 0.2 } }, 'gamut-paint-dci-p3': { potency: 0.8, weights: { chroma: 0.7 } }, 'gamut-paint-rec2020': { potency: 1.3, weights: { chroma: 1.0, burn: 0.5 } }, // ---- wildlife (§5) 'mosquito-swarm': { potency: 0.9, weights: { noise: 0.95 } }, // literal mosquito noise 'gibbs-wraith': { potency: 1.0, weights: { ring: 0.85, noise: 0.25, freeze: 0.25 } }, }; const FALLBACK: ItemCorruption = { potency: 0.5, weights: { noise: 0.6 } }; export function corruptionFor(id: string): ItemCorruption { return CORRUPTION[id] ?? FALLBACK; } /** Items whose corruption profile is unmapped — logged at init so new data is visible. */ export function unmappedItems(data: GameData): string[] { return data.items.filter((i) => !(i.id in CORRUPTION)).map((i) => i.id); } /** Shipments needed to reach full corruption. Tuned by feel; log-ish, so early ships land hard. */ export const ESCALATION_ITEMS = 100; const KNEE = 6; // lower = steeper early curve /** Corruption units -> 0..1 total. log-shaped: the first handful of ships feel enormous. */ export function escalation(units: number): number { if (units <= 0) return 0; const t = Math.log1p(units / KNEE) / Math.log1p(ESCALATION_ITEMS / KNEE); return Math.min(1, t); } export interface LedgerResult { targets: ParamSet; /** net corruption units after anchor-slab sanitation */ units: number; /** 0..1 escalation */ total: number; } /** * Blend the per-item tally into target param pressures. * Mix uses only positive-potency items (you can't corrupt *toward* clean); anchors * pull down `units`, which scales everything back down through `total`. */ export function resolveLedger(tally: ReadonlyMap): LedgerResult { const targets = zeroParams(); let units = 0; let mixWeight = 0; for (const [id, count] of tally) { if (count <= 0) continue; units += count * corruptionFor(id).potency; const p = corruptionFor(id).potency; if (p > 0) mixWeight += count * p; } units = Math.max(0, units); const total = escalation(units); if (mixWeight <= 0) return { targets, units, total }; for (const [id, count] of tally) { if (count <= 0) continue; const { potency, weights } = corruptionFor(id); if (potency <= 0) continue; const share = (count * potency) / mixWeight; for (const p of PARAMS) { targets[p] += (weights[p] ?? 0) * share; } } for (const p of PARAMS) targets[p] = Math.min(1, targets[p] * total); return { targets, units, total }; }