Compare commits
4 Commits
83e3ad2107
...
180747ce28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
180747ce28 | ||
|
|
6404c44fcc | ||
|
|
bc992a99d4 | ||
|
|
5ad7252e20 |
@ -302,3 +302,89 @@ piles** are the obvious next spatial artifacts (both are regional in the codex),
|
||||
biomes in §1 would suggest a mask per era. Also, now that strain reads the snapshot, THE SCREEN
|
||||
could react to `jammed` entities (a stalled factory *should* look bored — the feed drifting back
|
||||
toward clean is a nice punishment), but that's a design call, not a lane call.
|
||||
|
||||
### Round 4 — 2026-07-17 — Opus 4.8
|
||||
|
||||
SHIPPED: all 4 orders. Thanks for granting capacity unasked.
|
||||
- `boredom.ts` + `boredom.test.ts` (new) — pure freshness curve, 7 tests.
|
||||
- `strain.ts` — second dread axis; `strain.test.ts` now 20 tests. Screen suite 57 green.
|
||||
- `overlays.ts` — ratification card painter + era→standard table.
|
||||
- `glitchStack.ts` — pallor pass (desaturate + vignette), card composite.
|
||||
- `index.ts` — boredom wiring, card lifecycle, `researched` handling, pallor easing.
|
||||
|
||||
DESIGN INTENT, one line each (as ordered):
|
||||
- **tremor = "how many seconds until dark?"** ACUTE. The cliff edge. Only exists while something
|
||||
is actually draining, and it spikes as the runway runs out. Expressed as motion: judder.
|
||||
- **pallor = "how much cushion do I have at all?"** CHRONIC. Sits there in surplus. Expressed as
|
||||
colour: the saturation drains out and the corners close in.
|
||||
A full tank draining fast is a jolt; a near-empty tank coasting is a way of life. Different
|
||||
questions, different senses, so they never mush into one dial.
|
||||
|
||||
DECISIONS:
|
||||
- **Pallor drains saturation on purpose.** Codex §8: "ALL saturation belongs to media/artifacts.
|
||||
Color = value = literally the resource." A factory with no reserve should therefore literally
|
||||
have less colour. The vignette does the rest. It fits the existing strain/fever/seizure
|
||||
hierarchy as the quiet one: chronic, slow tau (2.0s), never louder than the corruption.
|
||||
- **The capacity trap**: `1 - stored/capacity` with no tanks built reads as "totally empty" and
|
||||
would have painted the boot state grey — blowing up round 3's sterile guarantee on day one.
|
||||
No tanks = no cushion to miss = no pallor. That's not a special case, it's the honest reading:
|
||||
you cannot be low on a reserve you never built. Two tests pin it (capacity 0 AND undefined).
|
||||
- **Boredom decays the DISPLAY, never the ledger.** `fresh` scales the targets; the tally
|
||||
underneath is untouched. Verified explicitly: after 4 idle minutes the sky is clean but
|
||||
`units: 72, tally: {melt: 60}, total: 0.893` are all exactly as earned.
|
||||
- **Boredom horizon: 60s grace, then 180s decay (4 minutes to clean).** You asked for minutes,
|
||||
not seconds. A full minute of silence changes literally nothing, so a jam you notice, diagnose
|
||||
and fix is free. Recovery rides the normal 1.2s shipment ease — against a 4-minute decay that
|
||||
reads as a snap, and it reuses the wave feel rather than inventing a second motion.
|
||||
- **The card is timed in SECONDS, not frames** — it's a broadcast interruption and must be one
|
||||
second at any refresh rate. Verified 30 frames @30fps AND 60 frames @60fps. (Deliberate
|
||||
contrast with the scram wipe, which stays frame-counted: that one is a tape artifact, and tape
|
||||
artifacts are quantized to frames. Two different clocks for two different fictions.)
|
||||
- Card composites dead last in screen space, so not one pixel of it is corrupted — that IS the
|
||||
joke. Hard cut on exit, no fade: the corruption crashes back rather than seeping, because a
|
||||
dissolve would make The Correction look negotiable.
|
||||
- Card says the STANDARD, not the tech id (codex §1 names: OPTICAL FILM, NTSC / PAL ANALOG,
|
||||
MPEG-2 / DVD, H.264 / HEVC / AV1, DCP) with the green tick from §8's Correction uniform. The
|
||||
tech id is in the small print where a compliance notice would put it.
|
||||
|
||||
VERIFIED (fixture snapshots, pixel reads, contact sheet):
|
||||
- **Sterile guarantee survives the new axis**: boot AND a tankless factory in surplus both render
|
||||
byte-for-byte identical to no-snapshot, pallor 0.
|
||||
- **Two dreads are separable at the pixel level**: full-tank-draining-fast → tremor 0.50, pallor
|
||||
0, saturation 0.621 and corners 96 both *unchanged from healthy*. Empty-tank-coasting → tremor
|
||||
0, pallor 0.972, saturation 0.500, corners 26.6. MAD between the two: **43.7**. They do not
|
||||
look like the same emergency.
|
||||
- **Boredom**: untouched at 60s → fresh 0.834 at 90s → 0.5 at 150s → 0 at 240s → stays 0. Ledger
|
||||
intact throughout. Resume: freshness 1 instantly, picture home in ~1.5s.
|
||||
- **Card**: 30 frames @30fps, 60 @60fps = 1.00s both. Mean luminance 83 → 235 → 83.
|
||||
- **Budget: 0.078 ms/frame** with GPU sync, 2000 entities, every artifact + both dreads + fever +
|
||||
brownout + card compositing. ~26× under.
|
||||
- Note for the record: **tremor is invisible in a still** — it's motion, so the contact-sheet tile
|
||||
looks like HEALTHY. I pinned it numerically instead (0.50, MAD 6.8 vs sterile). A still frame is
|
||||
the wrong instrument for a judder and I didn't want that filed as "verified by screenshot".
|
||||
|
||||
BLOCKED/BROKEN: nothing in my lane. **One thing the fleet needs to know:**
|
||||
- `npm test` currently shows 6 red in `src/ui/live.test.ts` ("reference factory should contain a
|
||||
software decoder", commission/entity-count assertions). **Not mine, and not LANE-UI's fault
|
||||
either**: LANE-DATA has uncommitted edits to all five `data/*.json` in the shared tree, and
|
||||
UI's live tests build a reference factory out of that data. I confirmed by stashing *only*
|
||||
`src/screen/` — the same 6 still fail with my lane entirely absent.
|
||||
- **Warning about the shared working tree**: my first instinct was `git stash`, which stashed
|
||||
every lane's uncommitted work, not just mine — and made the UI tests "pass", which nearly had me
|
||||
believing I'd broken them. `git stash push -- src/screen/` is the only safe form here. Any lane
|
||||
bisecting a red test in this tree can be badly misled by a bare stash. Worth a line in
|
||||
MASTERPLAN if you agree.
|
||||
|
||||
CONTRACT REQUEST: none. v4 gave me everything; fixtures now carry `commissionQueue` and
|
||||
`research` ahead of the v5 hardening (order 4 done).
|
||||
|
||||
PROPOSAL: boredom currently only listens for `shipped`. A factory that is *jammed solid* and one
|
||||
that is *idle because the player is redesigning it* decay identically. You approved the stalled-
|
||||
factory idea as design — if you want the distinction, `jammed` entities in the snapshot could make
|
||||
the sky decay noticeably faster (the work is rotting, not resting) while a clean idle stays
|
||||
gentle. That's a one-line read of data I already have. Say the word and it's a small round-5 item.
|
||||
|
||||
NEXT (not self-assigning): the era→standard table means THE SCREEN now knows about eras — the §1
|
||||
strata skins (sepia Reel, phosphor Broadcast, rainbow-on-black Disc) could drive the *base feed's*
|
||||
palette per the player's current era, so the clean programming itself ages as they tech up. That
|
||||
plus the mask template would give era-flavoured biomes on the sky.
|
||||
|
||||
47
fktry/src/screen/boredom.test.ts
Normal file
47
fktry/src/screen/boredom.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BOREDOM_GRACE_SECONDS, BOREDOM_HORIZON_SECONDS, freshness } from './boredom';
|
||||
|
||||
describe('boredom decays the display, slowly', () => {
|
||||
it('shows everything you earned while production is running', () => {
|
||||
expect(freshness(0)).toBe(1);
|
||||
expect(freshness(5)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not punish a normal jam', () => {
|
||||
// The whole grace window plus a beat: a stall long enough to notice, diagnose and fix
|
||||
// must leave THE SCREEN exactly as the player left it.
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS)).toBe(1);
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS - 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('takes minutes, not seconds, to forget', () => {
|
||||
// A minute of silence has barely dented it; the sky is patient.
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS + 30)).toBeGreaterThan(0.8);
|
||||
// Fully clean only after the player has genuinely walked away.
|
||||
expect(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS).toBeGreaterThanOrEqual(180);
|
||||
});
|
||||
|
||||
it('reaches clean at the horizon and stays there', () => {
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS)).toBe(0);
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 9999)).toBe(0);
|
||||
});
|
||||
|
||||
it('decays monotonically', () => {
|
||||
let prev = 2;
|
||||
for (let s = 0; s <= BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 60; s += 5) {
|
||||
const f = freshness(s);
|
||||
expect(f).toBeLessThanOrEqual(prev);
|
||||
expect(f).toBeGreaterThanOrEqual(0);
|
||||
prev = f;
|
||||
}
|
||||
});
|
||||
|
||||
it('sits at half way through the decay', () => {
|
||||
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS / 2)).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it('treats a nonsense idle time as fresh rather than wiping the sky', () => {
|
||||
expect(freshness(NaN)).toBe(1);
|
||||
expect(freshness(-10)).toBe(1);
|
||||
});
|
||||
});
|
||||
27
fktry/src/screen/boredom.ts
Normal file
27
fktry/src/screen/boredom.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Boredom — the factory's work evaporating off THE SCREEN.
|
||||
*
|
||||
* If nothing ships for a long stretch, the sky slowly forgets. The corruption LEDGER is
|
||||
* untouched: history is not erased, only the display decays. So when production resumes the
|
||||
* targets are instantly back where they were and the picture snaps home in about a second.
|
||||
*
|
||||
* The horizon is deliberately generous. A jam, a rebuild, a rethink of the bus — none of
|
||||
* those are supposed to read as punishment. The player should have to genuinely walk away
|
||||
* before the sky starts cleaning itself.
|
||||
*/
|
||||
|
||||
/** No shipments for this long before the sky begins to forget at all. */
|
||||
export const BOREDOM_GRACE_SECONDS = 60;
|
||||
/** ...and this long again to fade from full corruption to clean. */
|
||||
export const BOREDOM_HORIZON_SECONDS = 180;
|
||||
|
||||
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||
|
||||
/**
|
||||
* How much of the ledger's corruption is still on display, 0..1.
|
||||
* 1 = everything you earned is showing; 0 = the sky has forgotten (the tallies have not).
|
||||
*/
|
||||
export function freshness(idleSeconds: number): number {
|
||||
if (!(idleSeconds > BOREDOM_GRACE_SECONDS)) return 1; // NaN-safe: unknown idle reads as fresh
|
||||
return 1 - clamp01((idleSeconds - BOREDOM_GRACE_SECONDS) / BOREDOM_HORIZON_SECONDS);
|
||||
}
|
||||
@ -29,10 +29,10 @@ uniform float t;
|
||||
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
||||
uniform float u_block, u_ring, u_moire;
|
||||
uniform float u_drift, u_brownout, u_flash;
|
||||
uniform float u_strain, u_fever;
|
||||
uniform float u_strain, u_fever, u_pallor;
|
||||
uniform vec2 u_moireSeed;
|
||||
uniform sampler2D u_stampTex, u_scramTex;
|
||||
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
|
||||
uniform sampler2D u_stampTex, u_scramTex, u_cardTex;
|
||||
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card;
|
||||
|
||||
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
||||
float vnoise(vec2 p){
|
||||
@ -201,6 +201,16 @@ void main(){
|
||||
// fever runs warm: the picture itself develops a temperature
|
||||
col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);
|
||||
|
||||
// ---- pallor: the second dread. Not urgency — malaise. An empty reserve drains the colour
|
||||
// out of the sky and closes the corners in. Codex §8: colour IS the resource, so a factory
|
||||
// with no cushion literally has less of it. Chronic, and it sits there in surplus too.
|
||||
if(u_pallor > 0.001){
|
||||
col = mix(col, vec3(lu(col)), u_pallor*0.5);
|
||||
vec2 pd = vUv - 0.5; pd.x *= 1.78;
|
||||
float vig = smoothstep(0.34, 0.95, length(pd));
|
||||
col *= 1.0 - vig*u_pallor*0.75;
|
||||
}
|
||||
|
||||
// faint scanlines for vibe (constant, not scored)
|
||||
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
|
||||
|
||||
@ -227,12 +237,18 @@ void main(){
|
||||
// the awakening: one frame of full white on the first shipment ever
|
||||
col = mix(col, vec3(1.0), u_flash);
|
||||
|
||||
// ---- ratification: The Correction takes the sky. Composited dead last and in screen space,
|
||||
// so not one pixel of it is corrupted — that is the entire point of the joke.
|
||||
if(u_card > 0.001){
|
||||
col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
type UniformKey =
|
||||
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
|
||||
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
|
||||
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
|
||||
| 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card';
|
||||
|
||||
export interface DrawState {
|
||||
params: ParamSet;
|
||||
@ -249,8 +265,14 @@ export interface DrawState {
|
||||
strain: number;
|
||||
/** 0..1 feverish shimmer from aggregate machine heat */
|
||||
fever: number;
|
||||
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
|
||||
pallor: number;
|
||||
/** where the moire crystal grows from, in screen uv */
|
||||
moireSeed: [number, number];
|
||||
/** 0..1 opacity of the ratification card */
|
||||
card: number;
|
||||
/** set when the card canvas has been repainted and needs re-upload */
|
||||
cardDirty: boolean;
|
||||
/** 0..1 opacity of the shipment stamp */
|
||||
stampAmt: number;
|
||||
/** stamp size multiplier — bigger shipments stamp harder */
|
||||
@ -322,9 +344,11 @@ export function createGlitchStack(
|
||||
|
||||
base.update(0, true);
|
||||
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
|
||||
overlays.paintCard('reel', '');
|
||||
const baseTex = makeTex(0, base.canvas);
|
||||
const stampTex = makeTex(1, overlays.stampCanvas);
|
||||
makeTex(2, overlays.scramCanvas);
|
||||
const cardTex = makeTex(3, overlays.cardCanvas);
|
||||
|
||||
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
|
||||
uni.t = gl.getUniformLocation(prog, 't');
|
||||
@ -333,6 +357,8 @@ export function createGlitchStack(
|
||||
uni.flash = gl.getUniformLocation(prog, 'u_flash');
|
||||
uni.strain = gl.getUniformLocation(prog, 'u_strain');
|
||||
uni.fever = gl.getUniformLocation(prog, 'u_fever');
|
||||
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
|
||||
uni.card = gl.getUniformLocation(prog, 'u_card');
|
||||
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
|
||||
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
|
||||
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
|
||||
@ -343,13 +369,14 @@ export function createGlitchStack(
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1);
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.uniform1f(uni.drift, 1);
|
||||
|
||||
return {
|
||||
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
|
||||
stampAmt, stampScale, scram, scramY, stampDirty }) {
|
||||
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, pallor, moireSeed,
|
||||
stampAmt, stampScale, scram, scramY, stampDirty, card, cardDirty }) {
|
||||
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
|
||||
if (base.update(timeSec * 1000, idle)) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
@ -361,12 +388,19 @@ export function createGlitchStack(
|
||||
gl.bindTexture(gl.TEXTURE_2D, stampTex);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas);
|
||||
}
|
||||
if (cardDirty) {
|
||||
gl.activeTexture(gl.TEXTURE0 + 3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, cardTex);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas);
|
||||
}
|
||||
gl.uniform1f(uni.t, timeSec);
|
||||
gl.uniform1f(uni.drift, drift);
|
||||
gl.uniform1f(uni.brownout, brownout);
|
||||
gl.uniform1f(uni.flash, flash);
|
||||
gl.uniform1f(uni.strain, strain);
|
||||
gl.uniform1f(uni.fever, fever);
|
||||
gl.uniform1f(uni.pallor, pallor);
|
||||
gl.uniform1f(uni.card, card);
|
||||
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
|
||||
gl.uniform1f(uni.stampAmt, stampAmt);
|
||||
gl.uniform1f(uni.stampScale, stampScale);
|
||||
|
||||
@ -16,6 +16,7 @@ import { createOverlays } from './overlays';
|
||||
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
||||
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
||||
import { computeStrain } from './strain';
|
||||
import { freshness } from './boredom';
|
||||
|
||||
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
|
||||
const EASE_TAU = 1.2;
|
||||
@ -35,6 +36,14 @@ const SCRAM_FRAMES = 3;
|
||||
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
|
||||
const STRAIN_TAU = 0.35;
|
||||
const FEVER_TAU = 1.5;
|
||||
/** Pallor is chronic, so it should drift rather than react. */
|
||||
const PALLOR_TAU = 2.0;
|
||||
/**
|
||||
* The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it
|
||||
* must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose —
|
||||
* that one is a tape artifact, and tape artifacts are quantized to frames.)
|
||||
*/
|
||||
const CARD_SECONDS = 1;
|
||||
|
||||
interface Stamp { name: string; count: number; color: string; age: number }
|
||||
|
||||
@ -83,9 +92,18 @@ export function createScreenFX(): ScreenFX {
|
||||
|
||||
let strain = 0;
|
||||
let fever = 0;
|
||||
let pallor = 0;
|
||||
let moireSeed: [number, number] = [0.5, 0.44];
|
||||
let moireSeeded = false;
|
||||
|
||||
let lastShipMs = 0;
|
||||
let markShip = false;
|
||||
let fresh = 1;
|
||||
|
||||
let cardAge = -1; // <0 = no card on screen
|
||||
let cardDirty = false;
|
||||
const eras = new Map<string, string>();
|
||||
|
||||
let lastMs = 0;
|
||||
let frameCost = 0; // rolling avg ms of this module's CPU work
|
||||
|
||||
@ -101,6 +119,7 @@ export function createScreenFX(): ScreenFX {
|
||||
if (count <= 0) return;
|
||||
tally.set(item, (tally.get(item) ?? 0) + count);
|
||||
ledgerDirty = true;
|
||||
markShip = true; // the sky stops forgetting the moment anything lands
|
||||
if (!hasShipped) {
|
||||
hasShipped = true;
|
||||
pendingFlash = true; // the awakening
|
||||
@ -126,6 +145,7 @@ export function createScreenFX(): ScreenFX {
|
||||
const base = createBaseFeed();
|
||||
stack = createGlitchStack(canvas, base, overlays);
|
||||
for (const it of data.items) items.set(it.id, it);
|
||||
for (const t of data.tech) eras.set(t.id, t.era);
|
||||
|
||||
const unmapped = unmappedItems(data);
|
||||
if (unmapped.length) {
|
||||
@ -151,14 +171,27 @@ export function createScreenFX(): ScreenFX {
|
||||
moireSeed = [0.5, 0.44];
|
||||
strain = 0;
|
||||
fever = 0;
|
||||
pallor = 0;
|
||||
cardAge = -1;
|
||||
lastShipMs = 0;
|
||||
fresh = 1;
|
||||
for (const p of PARAMS) eased[p] = 0;
|
||||
},
|
||||
research: (tech: string) => {
|
||||
overlays.paintCard(eras.get(tech) ?? 'reel', tech);
|
||||
cardDirty = true;
|
||||
cardAge = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
units: +units.toFixed(2),
|
||||
total: +total.toFixed(3),
|
||||
displayTotal: +(total * fresh).toFixed(3),
|
||||
freshness: +fresh.toFixed(3),
|
||||
idle: !hasShipped,
|
||||
strain: +strain.toFixed(3),
|
||||
fever: +fever.toFixed(3),
|
||||
pallor: +pallor.toFixed(3),
|
||||
card: cardAge >= 0 ? 1 : 0,
|
||||
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
|
||||
brownout: +brownout.toFixed(2),
|
||||
msPerFrame: +frameCost.toFixed(3),
|
||||
@ -178,6 +211,11 @@ export function createScreenFX(): ScreenFX {
|
||||
// event rather than random, so the same run always wipes the same way.
|
||||
scramFrames = SCRAM_FRAMES;
|
||||
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
|
||||
} else if (e.kind === 'researched') {
|
||||
// The Correction takes the sky for a second, every time the player does science.
|
||||
overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech);
|
||||
cardDirty = true;
|
||||
cardAge = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -200,14 +238,27 @@ export function createScreenFX(): ScreenFX {
|
||||
const target = computeStrain(snap);
|
||||
strain = ease(strain, target.tremor, dt, STRAIN_TAU);
|
||||
fever = ease(fever, target.fever, dt, FEVER_TAU);
|
||||
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
|
||||
|
||||
// Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the
|
||||
// tally underneath is untouched, so resuming production snaps the targets straight back
|
||||
// and the easing walks the picture home in about a second.
|
||||
if (markShip) {
|
||||
lastShipMs = timeMs;
|
||||
markShip = false;
|
||||
}
|
||||
fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000) : 1;
|
||||
|
||||
const timeSec = timeMs / 1000;
|
||||
const displayTotal = total * fresh;
|
||||
const wobble =
|
||||
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
|
||||
displayTotal > WOBBLE_THRESHOLD
|
||||
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
|
||||
: 0;
|
||||
|
||||
for (let i = 0; i < PARAMS.length; i++) {
|
||||
const p = PARAMS[i];
|
||||
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
|
||||
eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU);
|
||||
// Wobble rides on top of the eased value; it never feeds back into the ledger.
|
||||
const w =
|
||||
wobble > 0
|
||||
@ -234,15 +285,26 @@ export function createScreenFX(): ScreenFX {
|
||||
if (stamp.age >= STAMP_LIFE) stamp = null;
|
||||
}
|
||||
|
||||
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in
|
||||
// rather than seeping back; a dissolve would make it look negotiable.
|
||||
let card = 0;
|
||||
if (cardAge >= 0) {
|
||||
card = 1;
|
||||
cardAge += dt;
|
||||
if (cardAge >= CARD_SECONDS) cardAge = -1;
|
||||
}
|
||||
|
||||
const flash = pendingFlash ? 1 : 0;
|
||||
stack.draw({
|
||||
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
|
||||
strain, fever, moireSeed,
|
||||
strain, fever, pallor, moireSeed,
|
||||
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
|
||||
card, cardDirty,
|
||||
});
|
||||
pendingFlash = false;
|
||||
if (scramFrames > 0) scramFrames--;
|
||||
stampDirty = false;
|
||||
cardDirty = false;
|
||||
|
||||
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
|
||||
},
|
||||
|
||||
@ -10,12 +10,33 @@ export const STAMP_W = 256;
|
||||
export const STAMP_H = 128;
|
||||
export const SCRAM_W = 512;
|
||||
export const SCRAM_H = 64;
|
||||
export const CARD_W = 640;
|
||||
export const CARD_H = 360;
|
||||
|
||||
/**
|
||||
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id —
|
||||
* The Correction does not care what you unlocked, only which format it just blessed.
|
||||
*/
|
||||
const ERA_STANDARD: Record<string, string> = {
|
||||
reel: 'OPTICAL FILM',
|
||||
broadcast: 'NTSC / PAL ANALOG',
|
||||
disc: 'MPEG-2 / DVD',
|
||||
stream: 'H.264 / HEVC / AV1',
|
||||
fortress: 'DCP',
|
||||
};
|
||||
|
||||
export function eraStandard(era: string): string {
|
||||
return ERA_STANDARD[era] ?? era.toUpperCase();
|
||||
}
|
||||
|
||||
export interface Overlays {
|
||||
stampCanvas: HTMLCanvasElement;
|
||||
scramCanvas: HTMLCanvasElement;
|
||||
cardCanvas: HTMLCanvasElement;
|
||||
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
|
||||
paintStamp(name: string, count: number, color: string): void;
|
||||
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
|
||||
paintCard(era: string, tech: string): void;
|
||||
}
|
||||
|
||||
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
@ -61,9 +82,57 @@ export function createOverlays(): Overlays {
|
||||
scramCanvas.height = SCRAM_H;
|
||||
paintScramBar(scramCanvas.getContext('2d')!);
|
||||
|
||||
const cardCanvas = document.createElement('canvas');
|
||||
cardCanvas.width = CARD_W;
|
||||
cardCanvas.height = CARD_H;
|
||||
const cg = cardCanvas.getContext('2d')!;
|
||||
|
||||
return {
|
||||
stampCanvas,
|
||||
scramCanvas,
|
||||
cardCanvas,
|
||||
|
||||
paintCard(era: string, tech: string) {
|
||||
// The Correction wins the sky for one second: hospital white, green checkmark, and the
|
||||
// absolute confidence of a compliance notice. Nothing here is corrupted — that's the joke.
|
||||
cg.fillStyle = '#f2f5f2';
|
||||
cg.fillRect(0, 0, CARD_W, CARD_H);
|
||||
|
||||
cg.strokeStyle = '#cfd8cf';
|
||||
cg.lineWidth = 2;
|
||||
cg.strokeRect(24, 24, CARD_W - 48, CARD_H - 48);
|
||||
|
||||
cg.textBaseline = 'middle';
|
||||
cg.fillStyle = '#12160f';
|
||||
cg.font = "bold 40px 'Courier New', monospace";
|
||||
const title = 'STANDARD RATIFIED';
|
||||
cg.fillText(title, (CARD_W - cg.measureText(title).width) / 2, 132);
|
||||
|
||||
const standard = eraStandard(era);
|
||||
cg.fillStyle = '#2f7d3f';
|
||||
cg.font = "bold 28px 'Courier New', monospace";
|
||||
cg.fillText(standard, (CARD_W - cg.measureText(standard).width) / 2, 186);
|
||||
|
||||
// green tick — The Correction's faction uniform (codex §8)
|
||||
cg.strokeStyle = '#2f7d3f';
|
||||
cg.lineWidth = 7;
|
||||
cg.lineCap = 'round';
|
||||
cg.lineJoin = 'round';
|
||||
cg.beginPath();
|
||||
cg.moveTo(CARD_W / 2 - 26, 236);
|
||||
cg.lineTo(CARD_W / 2 - 8, 254);
|
||||
cg.lineTo(CARD_W / 2 + 28, 218);
|
||||
cg.stroke();
|
||||
|
||||
cg.fillStyle = '#5c6b5c';
|
||||
cg.font = "13px 'Courier New', monospace";
|
||||
const sub = 'CONFORMANCE VERIFIED · NO ACTION REQUIRED';
|
||||
cg.fillText(sub, (CARD_W - cg.measureText(sub).width) / 2, 292);
|
||||
cg.font = "11px 'Courier New', monospace";
|
||||
cg.fillText(tech.toUpperCase().replace(/-/g, ' '), 40, 322);
|
||||
const auth = 'THE CORRECTION';
|
||||
cg.fillText(auth, CARD_W - 40 - cg.measureText(auth).width, 322);
|
||||
},
|
||||
paintStamp(name: string, count: number, color: string) {
|
||||
sg.clearRect(0, 0, STAMP_W, STAMP_H);
|
||||
sg.save();
|
||||
|
||||
@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { EntityState, SimSnapshot } from '../contracts';
|
||||
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
|
||||
|
||||
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
|
||||
// here now rather than left to break the day the contract tightens.
|
||||
function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
|
||||
return {
|
||||
tick: 0,
|
||||
@ -14,22 +16,32 @@ function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<En
|
||||
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
|
||||
shippedTotal: {},
|
||||
activeCommission: null,
|
||||
commissionQueue: [],
|
||||
commissionProgress: {},
|
||||
research: { active: null, progress: {}, unlocked: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('the NOTHING IS WRONG era stays sterile', () => {
|
||||
it('is inert with no snapshot at all', () => {
|
||||
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0 });
|
||||
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0, pallor: 0 });
|
||||
});
|
||||
|
||||
it('does not leak strain into a fresh factory (gen=draw=stored=0)', () => {
|
||||
// The boot state. Nothing built, nothing drawing: dread here would be a lie.
|
||||
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0 });
|
||||
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0, pallor: 0 });
|
||||
});
|
||||
|
||||
it('stays sterile with no tanks built, even though stored is 0', () => {
|
||||
// The trap in the second dread axis: stored/capacity with no capacity must not read as
|
||||
// "empty". You cannot be low on a reserve you never built.
|
||||
expect(computeStrain(snap({ capacity: 0 })).pallor).toBe(0);
|
||||
expect(computeStrain(snap({ gen: 50, draw: 20, capacity: undefined })).pallor).toBe(0);
|
||||
});
|
||||
|
||||
it('stays calm for a healthy factory running a surplus', () => {
|
||||
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
|
||||
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500, capacity: 500 })))
|
||||
.toEqual({ tremor: 0, fever: 0, pallor: 0 });
|
||||
});
|
||||
|
||||
it('stays calm in surplus even with an empty reserve', () => {
|
||||
@ -82,6 +94,39 @@ describe('tremor tracks seconds-of-reserve, not raw stored', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the two dreads are different questions', () => {
|
||||
it('full tank draining fast: tremor without pallor', () => {
|
||||
// 4 seconds from dark, but the tanks are brimming. Acute, not chronic.
|
||||
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 40, capacity: 40 }));
|
||||
expect(s.tremor).toBe(0.5);
|
||||
expect(s.pallor).toBe(0);
|
||||
});
|
||||
|
||||
it('near-empty tank coasting: pallor without tremor', () => {
|
||||
// Nothing is draining — draw <= gen — so there is no cliff. But there is also no cushion.
|
||||
const s = computeStrain(snap({ gen: 100, draw: 100, stored: 5, capacity: 500 }));
|
||||
expect(s.tremor).toBe(0);
|
||||
expect(s.pallor).toBeCloseTo(0.99, 5);
|
||||
});
|
||||
|
||||
it('empty tank AND draining: both, which is the real emergency', () => {
|
||||
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 0, capacity: 500 }));
|
||||
expect(s.tremor).toBe(1);
|
||||
expect(s.pallor).toBe(1);
|
||||
});
|
||||
|
||||
it('pallor tracks the fraction, not the amount', () => {
|
||||
// Same 100 banked reads as comfortable in a small tank and dire in a huge one.
|
||||
expect(computeStrain(snap({ stored: 100, capacity: 100 })).pallor).toBe(0);
|
||||
expect(computeStrain(snap({ stored: 100, capacity: 1000 })).pallor).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it('never leaves 0..1 when the sim overfills or overdraws a tank', () => {
|
||||
expect(computeStrain(snap({ stored: 900, capacity: 500 })).pallor).toBe(0);
|
||||
expect(computeStrain(snap({ stored: -20, capacity: 500 })).pallor).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fever reads the machines that actually run hot', () => {
|
||||
it('is not diluted by a sea of cold belts', () => {
|
||||
// One decoder at 0.9 surrounded by 50 cold belts is a fever, not a rounding error.
|
||||
|
||||
@ -10,14 +10,28 @@
|
||||
*/
|
||||
import type { SimSnapshot } from '../contracts';
|
||||
|
||||
/**
|
||||
* The two dreads (v4). They are deliberately different questions:
|
||||
*
|
||||
* tremor — "how many seconds until dark?" ACUTE. The cliff edge. Only exists while
|
||||
* something is actually draining, and it spikes as the runway runs out.
|
||||
* pallor — "how much cushion do I have at all?" CHRONIC. Sits there even in surplus.
|
||||
* A full tank draining fast is a crisis; a near-empty tank coasting is a way of
|
||||
* life, and it should feel like malaise rather than alarm.
|
||||
*
|
||||
* A factory with 4 seconds left and full tanks reads as a jolt; one permanently scraping the
|
||||
* bottom of an empty tank reads as washed-out and grim. Same bandwidth system, two feelings.
|
||||
*/
|
||||
export interface Strain {
|
||||
/** 0..1 tremor/desync judder as the bandwidth reserve runs out */
|
||||
tremor: number;
|
||||
/** 0..1 slow feverish shimmer from aggregate machine heat */
|
||||
fever: number;
|
||||
/** 0..1 pallor: how empty the tanks are, regardless of whether anything is draining */
|
||||
pallor: number;
|
||||
}
|
||||
|
||||
export const NO_STRAIN: Strain = { tremor: 0, fever: 0 };
|
||||
export const NO_STRAIN: Strain = { tremor: 0, fever: 0, pallor: 0 };
|
||||
|
||||
/**
|
||||
* How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up
|
||||
@ -40,7 +54,7 @@ export function computeStrain(snap?: SimSnapshot): Strain {
|
||||
// --- tremor: seconds of reserve remaining at the current deficit.
|
||||
// Per the v3 units ruling, `stored` is bandwidth-SECONDS while gen/draw are bandwidth per
|
||||
// second — so stored/deficit is literally "how long until the lights go out".
|
||||
const { gen, draw, stored } = snap.bandwidth;
|
||||
const { gen, draw, stored, capacity } = snap.bandwidth;
|
||||
const deficit = draw - gen;
|
||||
let tremor = 0;
|
||||
if (deficit > 0) {
|
||||
@ -50,6 +64,12 @@ export function computeStrain(snap?: SimSnapshot): Strain {
|
||||
// A factory in surplus is calm no matter how little it has banked — that is what keeps
|
||||
// strain out of the NOTHING IS WRONG era, where gen and draw are both zero.
|
||||
|
||||
// --- pallor: how empty the tanks are. Unlike tremor this does NOT care about draining;
|
||||
// it is the standing cost of living hand-to-mouth.
|
||||
// No tanks built = no cushion to miss = no pallor. That is not a special case for the boot
|
||||
// state, it is the honest reading: you cannot be low on a reserve you never built.
|
||||
const pallor = capacity && capacity > 0 ? 1 - clamp01(Math.max(0, stored) / capacity) : 0;
|
||||
|
||||
// --- fever: how hot are the machines that actually run hot.
|
||||
// Averaging across every entity would drown a glowing decoder in a sea of cold belts.
|
||||
let sum = 0;
|
||||
@ -62,5 +82,5 @@ export function computeStrain(snap?: SimSnapshot): Strain {
|
||||
}
|
||||
const fever = hot > 0 ? clamp01(sum / hot) : 0;
|
||||
|
||||
return { tremor, fever };
|
||||
return { tremor, fever, pallor };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user