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>
137 lines
6.3 KiB
TypeScript
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 };
|
|
}
|