glytch/fktry/src/screen/corruptionMap.ts
type-two ed823a09ff [screen] three artifact classes the original 8 params cannot express
block: 8x8 quantization on a lattice locked to screen space, so the grid stays
rigid while melt drags the picture underneath it. ring: Gibbs halos, gradient
sampled out to 7px so they ring OUT from edges rather than clinging to them,
oscillating parallel to the edge. moire: two near-identical lattices beating,
with rainbow sheen.

PARAMS 8 -> 11; the ledger, easing, wobble and stats all iterate PARAMS, so the
new classes inherit the existing machine. Wires bricks/coeff-packs to block,
halos/gibbs-wraiths to ring, crystal to moire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:22:32 +10:00

137 lines
6.3 KiB
TypeScript

/**
* 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<Record<Param, number>>;
}
/**
* 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<string, ItemCorruption> = {
// ---- 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<string, number>): 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 };
}