diff --git a/fktry/src/screen/corruptionMap.test.ts b/fktry/src/screen/corruptionMap.test.ts new file mode 100644 index 0000000..cafc561 --- /dev/null +++ b/fktry/src/screen/corruptionMap.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { escalation, resolveLedger, corruptionFor, ESCALATION_ITEMS } from './corruptionMap'; +import { PARAMS } from './params'; + +const tally = (o: Record) => new Map(Object.entries(o)); +/** The param carrying the most pressure — "what does the player actually SEE". */ +const dominant = (t: Record) => + PARAMS.reduce((a, b) => (t[a] >= t[b] ? a : b)); + +describe('escalation curve', () => { + it('is 0 when nothing has shipped and 1 at the tuned item count', () => { + expect(escalation(0)).toBe(0); + expect(escalation(-5)).toBe(0); + expect(escalation(ESCALATION_ITEMS)).toBeCloseTo(1, 5); + }); + + it('clamps above the tuned count', () => { + expect(escalation(10_000)).toBe(1); + }); + + it('is monotonic and front-loaded: the first 10 ships outrun the last 10', () => { + let prev = -1; + for (let n = 0; n <= 120; n += 5) { + const e = escalation(n); + expect(e).toBeGreaterThanOrEqual(prev); + prev = e; + } + const early = escalation(10) - escalation(0); + const late = escalation(100) - escalation(90); + expect(early).toBeGreaterThan(late * 5); + }); +}); + +describe('item -> artifact legibility', () => { + it('40 chroma slurry reads as chroma damage specifically', () => { + const { targets } = resolveLedger(tally({ 'chroma-slurry': 40 })); + expect(dominant(targets)).toBe('chroma'); + expect(targets.chroma).toBeGreaterThan(0.5); + }); + + it('melt reads as melt, not generic mush', () => { + const { targets } = resolveLedger(tally({ melt: 40 })); + expect(dominant(targets)).toBe('melt'); + expect(targets.melt).toBeGreaterThan(targets.mosh); + }); + + it('hf-dust reads as static', () => { + expect(dominant(resolveLedger(tally({ 'hf-dust': 40 })).targets)).toBe('noise'); + }); + + it('mixing shifts the blend toward whatever dominates the shipping', () => { + const mixed = resolveLedger(tally({ 'chroma-slurry': 38, 'hf-dust': 2 })).targets; + expect(dominant(mixed)).toBe('chroma'); + expect(mixed.noise).toBeGreaterThan(0); // the dust is still visible, just outvoted + }); + + it('unknown items degrade to a generic noise bump instead of vanishing', () => { + const { targets, total } = resolveLedger(tally({ 'megaglitch-prototype': 20 })); + expect(total).toBeGreaterThan(0); + expect(dominant(targets)).toBe('noise'); + expect(corruptionFor('megaglitch-prototype').potency).toBeGreaterThan(0); + }); +}); + +describe('codex signatures read as themselves', () => { + it.each([ + ['static-canister', 'noise'], // bottled analog snow + ['judder-cams', 'freeze'], // 3:2 pulldown, machined + ['macroblock-bricks', 'mosh'], // the quantizer's confession + ['false-color-speckle', 'chroma'], + ['mosquito-swarm', 'noise'], // literal mosquito noise + ['v-hold-roll', 'roll'], + ['silver-frames', 'burn'], // celluloid + ['bloom-concentrate', 'mosh'], // motion compounded on itself + ])('%s reads as %s', (item, param) => { + expect(dominant(resolveLedger(tally({ [item]: 40 })).targets)).toBe(param); + }); + + it('grades chroma subsampling by how much colour was thrown away', () => { + expect(corruptionFor('chroma-420').potency).toBeGreaterThan(corruptionFor('chroma-422').potency); + expect(corruptionFor('chroma-422').potency).toBeGreaterThan(corruptionFor('chroma-slurry').potency); + }); + + it('rates laser-primary gamut paint far above house-gray', () => { + expect(corruptionFor('gamut-paint-rec2020').potency).toBeGreaterThan( + corruptionFor('gamut-paint-rec709').potency * 3, + ); + }); +}); + +describe('survives whatever LANE-DATA ships', () => { + it('every item in data/items.json produces sane params', async () => { + const items = (await import('../../data/items.json')).default as Array<{ id: string }>; + expect(items.length).toBeGreaterThan(0); + for (const { id } of items) { + const { targets, total } = resolveLedger(tally({ [id]: 25 })); + expect(Number.isFinite(total), `${id} total`).toBe(true); + for (const p of PARAMS) { + expect(Number.isFinite(targets[p]), `${id}.${p}`).toBe(true); + expect(targets[p], `${id}.${p}`).toBeGreaterThanOrEqual(0); + expect(targets[p], `${id}.${p}`).toBeLessThanOrEqual(1); + } + } + }); +}); + +describe('anchor slabs sanitize', () => { + it('shipping I-frames pulls corruption back down', () => { + const dirty = resolveLedger(tally({ melt: 20 })).total; + const cleaned = resolveLedger(tally({ melt: 20, 'anchor-slab': 5 })).total; + expect(cleaned).toBeLessThan(dirty); + }); + + it('enough anchors fully sterilize THE SCREEN', () => { + const { total, targets } = resolveLedger(tally({ melt: 10, 'anchor-slab': 50 })); + expect(total).toBe(0); + for (const p of PARAMS) expect(targets[p]).toBe(0); + }); + + it('anchors alone never drive corruption negative', () => { + const { units, total } = resolveLedger(tally({ 'anchor-slab': 99 })); + expect(units).toBe(0); + expect(total).toBe(0); + }); +}); + +describe('param hygiene', () => { + it('never emits a param outside 0..1, even shipping everything at once', () => { + const everything = tally({ + melt: 500, 'chroma-slurry': 500, 'hf-dust': 500, 'delta-wafer': 500, + 'gop-crate': 500, 'luma-bars': 500, 'coefficient-pack': 500, 'mdat-ore': 500, + }); + const { targets } = resolveLedger(everything); + for (const p of PARAMS) { + expect(targets[p]).toBeGreaterThanOrEqual(0); + expect(targets[p]).toBeLessThanOrEqual(1); + } + }); + + it('an empty ledger is fully sterile', () => { + const { targets, total } = resolveLedger(tally({})); + expect(total).toBe(0); + for (const p of PARAMS) expect(targets[p]).toBe(0); + }); +}); diff --git a/fktry/src/screen/corruptionMap.ts b/fktry/src/screen/corruptionMap.ts new file mode 100644 index 0000000..0c3c83e --- /dev/null +++ b/fktry/src/screen/corruptionMap.ts @@ -0,0 +1,133 @@ +/** + * 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 } }, + 'coefficient-pack': { potency: 0.7, weights: { freeze: 0.55, noise: 0.35, tear: 0.3 } }, + '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 } }, + 'macroblock-bricks': { potency: 1.1, weights: { mosh: 1.0 } }, + 'ringing-halos': { potency: 0.9, weights: { noise: 0.5, tear: 0.45 } }, + 'static-canister': { potency: 1.0, weights: { noise: 1.0 } }, + 'moire-crystal': { potency: 1.1, weights: { chroma: 0.6, noise: 0.5, tear: 0.35 } }, + '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: { noise: 0.55, tear: 0.5, freeze: 0.3 } }, +}; + +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 }; +}