[screen] corruption ledger: item -> artifact mapping + escalation curve
Mix and total are separate axes: what you ship picks the artifact, how much you have ever shipped picks the intensity. That separation is what makes 40 chroma slurry read as chroma specifically instead of generic damage. Covers the full 40-item codex LANE-DATA landed; unknown ids degrade to a generic noise bump, with a test walking data/items.json to keep it that way. anchor-slab has negative potency and sanitizes THE SCREEN (see NOTES). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3dd423fe2b
commit
2982862e83
145
fktry/src/screen/corruptionMap.test.ts
Normal file
145
fktry/src/screen/corruptionMap.test.ts
Normal file
@ -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<string, number>) => new Map(Object.entries(o));
|
||||
/** The param carrying the most pressure — "what does the player actually SEE". */
|
||||
const dominant = (t: Record<string, number>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
133
fktry/src/screen/corruptionMap.ts
Normal file
133
fktry/src/screen/corruptionMap.ts
Normal file
@ -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<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 } },
|
||||
'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<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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user