Compare commits
3 Commits
08761e8eeb
...
3707afa3a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3707afa3a0 | ||
|
|
18f6575660 | ||
|
|
4803b6a1e7 |
@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* LANE-DATA territory (the one file this lane owns inside src/sim/).
|
* LANE-DATA territory. Lives beside the JSON it guards (round 3: moved out of
|
||||||
|
* src/sim/, which returns fully to LANE-SIM).
|
||||||
|
*
|
||||||
* Validates data/*.json against the contracts. It is the handshake that keeps five
|
* Validates data/*.json against the contracts. It is the handshake that keeps five
|
||||||
* parallel lanes from drowning in dangling ids: if a lane references a machine,
|
* parallel lanes from drowning in dangling ids: if a lane references a machine,
|
||||||
* recipe, item or tech that data doesn't define, this test names it.
|
* recipe, item or tech that data doesn't define, this test names it.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../contracts';
|
import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../src/contracts';
|
||||||
|
|
||||||
import itemsJson from '../../data/items.json';
|
import itemsJson from './items.json';
|
||||||
import machinesJson from '../../data/machines.json';
|
import machinesJson from './machines.json';
|
||||||
import recipesJson from '../../data/recipes.json';
|
import recipesJson from './recipes.json';
|
||||||
import techJson from '../../data/tech.json';
|
import techJson from './tech.json';
|
||||||
import commissionsJson from '../../data/commissions.json';
|
import commissionsJson from './commissions.json';
|
||||||
|
|
||||||
const items = itemsJson as unknown as ItemDef[];
|
const items = itemsJson as unknown as ItemDef[];
|
||||||
const machines = machinesJson as unknown as MachineDef[];
|
const machines = machinesJson as unknown as MachineDef[];
|
||||||
@ -222,3 +222,79 @@ NEXT (not self-assigning): stamps currently say the item's NAME; once MODELBEAST
|
|||||||
rather stamp the item's *silhouette* in its chip colour. Also the 3 new passes are all
|
rather stamp the item's *silhouette* in its chip colour. Also the 3 new passes are all
|
||||||
full-frame — moiré especially wants to grow from a *region* (the codex has it spreading as maze
|
full-frame — moiré especially wants to grow from a *region* (the codex has it spreading as maze
|
||||||
walls across the factory floor), which would want a spatial mask rather than a scalar.
|
walls across the factory floor), which would want a spatial mask rather than a scalar.
|
||||||
|
|
||||||
|
### Round 3 — 2026-07-17 — Opus 4.8
|
||||||
|
|
||||||
|
SHIPPED: all 4 orders. Thank you for the v3 grant — `frame(timeMs, snap?)` is exactly the seam
|
||||||
|
strain needed.
|
||||||
|
- `strain.ts` (new) — pure, snapshot-shaped, no DOM/GL. The whole point: order 4's sterile
|
||||||
|
guarantee is now a unit test, not a promise.
|
||||||
|
- `strain.test.ts` (new) — 14 tests. Screen suite is 44 green.
|
||||||
|
- `glitchStack.ts` — `growthMask()` + tremor/fever warps + regional moiré + fever tint.
|
||||||
|
- `index.ts` — snapshot peek, strain easing, deterministic moiré seed, 3-frame scram.
|
||||||
|
|
||||||
|
DECISIONS:
|
||||||
|
- **Tremor is measured in SECONDS, not units.** Your v3 units ruling made this: `stored` is
|
||||||
|
bandwidth-seconds, `draw-gen` is bandwidth/second, so `stored/(draw-gen)` is literally *how
|
||||||
|
long until the lights go out*. Dread ramps over the last 8 seconds of reserve. This falls out
|
||||||
|
correct at every scale for free — a megafactory bleeding 1000/s with 4000 banked feels exactly
|
||||||
|
as doomed as a shack bleeding 10/s with 40 banked, because both have 4 seconds. There's a test
|
||||||
|
pinning that equivalence. Raw `stored` would have made big factories look permanently calm.
|
||||||
|
- **Surplus is always calm, whatever the reserve.** If `draw <= gen` nothing is draining, so
|
||||||
|
there is nothing to dread — which is *also* what makes the boot state (gen=draw=stored=0)
|
||||||
|
sterile by construction rather than by special-case. Order 4 fell out of the maths.
|
||||||
|
- **Fever averages only the machines that run hot** (heat > 0.01). Averaging across every entity
|
||||||
|
would drown one glowing decoder in a sea of cold belts, and belts are most of a factory.
|
||||||
|
- Tremor ≈ ¼ of the brownout's judder amplitude, and fever is a smooth sine haze while tremor is
|
||||||
|
random judder — so dread, fever and seizure are three distinguishable readings, not one dial.
|
||||||
|
Strain stays weather: MAD vs sterile is ~6.8, against ~35 for moiré. Subordinate as ordered.
|
||||||
|
- **Regional moiré separates AREA from INTENSITY.** Intensity saturates early (`u_moire*2.2`
|
||||||
|
clamped) while the mask's radius keeps growing with tally. That's what makes a big shipment
|
||||||
|
read as "the maze has spread" instead of "everything got slightly shimmery" — which is the
|
||||||
|
whole difference between regional and a scalar fade.
|
||||||
|
- `growthMask(p, seed, grow)` is written as the **reusable spatial-artifact template** you asked
|
||||||
|
me to note: an fbm-ragged growth front, aspect-corrected, creeping outward from a seed. Any
|
||||||
|
future artifact that should grow from a PLACE multiplies its amount by it. The raggedness is
|
||||||
|
load-bearing — a clean circle reads as a spotlight, not as spreading crystal.
|
||||||
|
- Moiré seeds from the tick of the first moire-weighted shipment, so a given run always grows the
|
||||||
|
same maze while different runs grow it elsewhere. Debug-hook ships default to tick 0.
|
||||||
|
|
||||||
|
VERIFIED (fixture snapshots + pixel reads + contact sheets):
|
||||||
|
- **Order 4, at the pixel level**: rendered no-snapshot / boot / healthy at an *identical*
|
||||||
|
timebase — boot and healthy are **byte-for-byte identical** to no-snapshot. Strain cannot leak
|
||||||
|
into the NOTHING IS WRONG era. (First attempt showed MAD ~1-2 and that was my harness: each run
|
||||||
|
advanced the clock, so the *timecode digits* differed. Fixed the test, not the code.)
|
||||||
|
- **Tremor ramp** (deficit 10/s): 20s left → 0, 8s (the horizon) → 0, 4s → 0.50, 2s → 0.75,
|
||||||
|
empty → 1.00. Fever 0.837 from hot decoders with tremor still 0.
|
||||||
|
- **Regional moiré growth**, as % of frame taken over: 0 → 20.1% → 44.7% → 74.5% → 84.5% → 85.8%
|
||||||
|
across 0/4/10/25/60/150 crystals. It spreads; it does not switch. The front is visibly ragged.
|
||||||
|
- **Seed**: same tick → same seed twice over; different tick → different seed. Deterministic.
|
||||||
|
- **Scram**: read the bar's row per frame — `BAR,BAR,BAR,-,-,-`. Exactly 3 frames, readable now.
|
||||||
|
- **Budget: 0.072 ms/frame** wall-clock with a forced GPU sync, on a 2000-entity factory with
|
||||||
|
every artifact class lit — `computeStrain`'s per-frame entity walk is not measurable at that
|
||||||
|
size. CPU-only 0.043 ms. Still ~28× under.
|
||||||
|
|
||||||
|
BLOCKED/BROKEN: nothing outstanding.
|
||||||
|
- **I found and fixed a real bug in my own code while chasing a false alarm.** My `dt` was
|
||||||
|
`Math.min(0.1, ...)` — ceiling clamped, floor not. A *negative* dt (clock rewind) turns
|
||||||
|
`ease()`'s `1-exp(-dt/tau)` into a huge negative multiplier and threw strain to **-3614** before
|
||||||
|
it crawled back. Production can't hit it (`performance.now()` is monotonic, and main.ts passes
|
||||||
|
it straight through), so this was only reachable from my harness — but an eased value that can
|
||||||
|
explode on a clock hiccup is a landmine, so `dt` is now clamped at both ends. Worth other lanes
|
||||||
|
checking their own dt handling if they ease anything.
|
||||||
|
- Full suite went 215/216 once mid-run, then 216/216 twice — another lane mid-write, not a real
|
||||||
|
red. HEAD is clean repo-wide, tsc included.
|
||||||
|
|
||||||
|
CONTRACT REQUEST: none. v3 covers everything I need.
|
||||||
|
|
||||||
|
PROPOSAL: `SimSnapshot.bandwidth` has no *capacity* field, so THE SCREEN can express "seconds
|
||||||
|
until empty" but not "the tanks are 20% full". Seconds is the better dread metric and I'd keep it
|
||||||
|
either way — but if buffer capacity ever lands in the snapshot cheaply, a second reading ("reserve
|
||||||
|
low" vs "reserve draining fast") would let strain distinguish a factory that's coasting on fumes
|
||||||
|
from one that's falling off a cliff. Not asking; noting the seam while it's fresh.
|
||||||
|
|
||||||
|
NEXT (not self-assigning): the mask template is ready for reuse — **bloom loops** and **HF dust
|
||||||
|
piles** are the obvious next spatial artifacts (both are regional in the codex), and the strata
|
||||||
|
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.
|
||||||
|
|||||||
@ -29,6 +29,8 @@ uniform float t;
|
|||||||
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
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_block, u_ring, u_moire;
|
||||||
uniform float u_drift, u_brownout, u_flash;
|
uniform float u_drift, u_brownout, u_flash;
|
||||||
|
uniform float u_strain, u_fever;
|
||||||
|
uniform vec2 u_moireSeed;
|
||||||
uniform sampler2D u_stampTex, u_scramTex;
|
uniform sampler2D u_stampTex, u_scramTex;
|
||||||
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
|
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
|
||||||
|
|
||||||
@ -41,6 +43,21 @@ float vnoise(vec2 p){
|
|||||||
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
|
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
|
||||||
float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }
|
float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }
|
||||||
|
|
||||||
|
// REUSABLE SPATIAL-ARTIFACT TEMPLATE (round 2 proposal, round 3 build).
|
||||||
|
// An irregular growth front creeping outward from the seed point. grow=0 is nothing, grow=1 has
|
||||||
|
// swallowed the frame. The fbm term makes the front ragged and organic instead of a
|
||||||
|
// tidy circle, so the artifact spreads like the codex's maze walls rather than a spotlight.
|
||||||
|
// Any future artifact that should GROW FROM A PLACE rather than fade in everywhere should
|
||||||
|
// multiply its amount by this.
|
||||||
|
float growthMask(vec2 p, vec2 seed, float grow){
|
||||||
|
vec2 d = p - seed;
|
||||||
|
d.x *= 1.78; // 16:9 correction: grow circles, not ellipses
|
||||||
|
float r = length(d);
|
||||||
|
float ragged = (fbm(d*3.2 + seed*7.0) - 0.5) * 0.38;
|
||||||
|
float front = grow * 1.35; // reach past the corners at grow=1
|
||||||
|
return smoothstep(front + 0.10, front - 0.10, r + ragged);
|
||||||
|
}
|
||||||
|
|
||||||
void main(){
|
void main(){
|
||||||
// freeze quantizes the timebase everything else animates on.
|
// freeze quantizes the timebase everything else animates on.
|
||||||
// A brownout slams the whole timebase into a hard stutter regardless of freeze.
|
// A brownout slams the whole timebase into a hard stutter regardless of freeze.
|
||||||
@ -51,6 +68,20 @@ void main(){
|
|||||||
// slow drift of the clean feed: bounded sinusoid, so no wrap seam
|
// slow drift of the clean feed: bounded sinusoid, so no wrap seam
|
||||||
uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift;
|
uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift;
|
||||||
|
|
||||||
|
// signal strain: the reserve is draining. A tremor, not a seizure — deliberately about a
|
||||||
|
// quarter of the brownout's amplitude, so dread reads as dread and the brownout still lands.
|
||||||
|
if(u_strain > 0.001){
|
||||||
|
float js = floor(tt*9.0);
|
||||||
|
uv.x += (hash(vec2(js, 17.0))-0.5) * u_strain * 0.017;
|
||||||
|
uv.y += (hash(vec2(js, 23.0))-0.5) * u_strain * 0.011;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fever: the decoders are running hot. Slow heat-haze shimmer, no judder.
|
||||||
|
if(u_fever > 0.001){
|
||||||
|
uv.x += sin(uv.y*38.0 + tt*1.7) * u_fever * 0.0045;
|
||||||
|
uv.y += sin(uv.x*26.0 + tt*1.1) * u_fever * 0.0030;
|
||||||
|
}
|
||||||
|
|
||||||
// desync judder: the frame itself loses its footing during a brownout
|
// desync judder: the frame itself loses its footing during a brownout
|
||||||
if(u_brownout > 0.001){
|
if(u_brownout > 0.001){
|
||||||
float j = floor(tt*12.0);
|
float j = floor(tt*12.0);
|
||||||
@ -125,15 +156,21 @@ void main(){
|
|||||||
col += (col - blur) * u_ring * 1.5; // overshoot at the edge itself
|
col += (col - blur) * u_ring * 1.5; // overshoot at the edge itself
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- moire: two near-identical lattices beating against the sensor grid
|
// ---- moire: two near-identical lattices beating against the sensor grid.
|
||||||
|
// REGIONAL: the crystal GROWS from a seeded place and creeps outward with the tally, rather
|
||||||
|
// than fading in across the whole frame. Intensity saturates early while AREA keeps growing,
|
||||||
|
// so a big shipment reads as "the maze has spread", not "everything got slightly shimmery".
|
||||||
if(u_moire > 0.001){
|
if(u_moire > 0.001){
|
||||||
float ang = 0.06 + 0.02*sin(tt*0.07);
|
float mo = clamp(u_moire*2.2, 0.0, 1.0) * growthMask(vUv, u_moireSeed, u_moire);
|
||||||
vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang));
|
if(mo > 0.001){
|
||||||
float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0);
|
float ang = 0.06 + 0.02*sin(tt*0.07);
|
||||||
float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25);
|
vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang));
|
||||||
float m = a*b; // the beat: swirling maze lattice
|
float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0);
|
||||||
vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03));
|
float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25);
|
||||||
col = mix(col, col*0.4 + sheen*0.7, u_moire*clamp(abs(m)*1.5, 0., 1.));
|
float m = a*b; // the beat: swirling maze lattice
|
||||||
|
vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03));
|
||||||
|
col = mix(col, col*0.4 + sheen*0.7, mo*clamp(abs(m)*1.5, 0., 1.));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// static
|
// static
|
||||||
@ -161,6 +198,9 @@ void main(){
|
|||||||
col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
|
col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
|
||||||
col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));
|
col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));
|
||||||
|
|
||||||
|
// fever runs warm: the picture itself develops a temperature
|
||||||
|
col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);
|
||||||
|
|
||||||
// faint scanlines for vibe (constant, not scored)
|
// faint scanlines for vibe (constant, not scored)
|
||||||
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
|
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
|
||||||
|
|
||||||
@ -191,7 +231,7 @@ void main(){
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
type UniformKey =
|
type UniformKey =
|
||||||
| Param | 't' | 'drift' | 'brownout' | 'flash'
|
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
|
||||||
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
|
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
|
||||||
|
|
||||||
export interface DrawState {
|
export interface DrawState {
|
||||||
@ -205,6 +245,12 @@ export interface DrawState {
|
|||||||
flash: number;
|
flash: number;
|
||||||
/** nothing shipped yet — the clean feed still carries its chyron */
|
/** nothing shipped yet — the clean feed still carries its chyron */
|
||||||
idle: boolean;
|
idle: boolean;
|
||||||
|
/** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
|
||||||
|
strain: number;
|
||||||
|
/** 0..1 feverish shimmer from aggregate machine heat */
|
||||||
|
fever: number;
|
||||||
|
/** where the moire crystal grows from, in screen uv */
|
||||||
|
moireSeed: [number, number];
|
||||||
/** 0..1 opacity of the shipment stamp */
|
/** 0..1 opacity of the shipment stamp */
|
||||||
stampAmt: number;
|
stampAmt: number;
|
||||||
/** stamp size multiplier — bigger shipments stamp harder */
|
/** stamp size multiplier — bigger shipments stamp harder */
|
||||||
@ -285,6 +331,9 @@ export function createGlitchStack(
|
|||||||
uni.drift = gl.getUniformLocation(prog, 'u_drift');
|
uni.drift = gl.getUniformLocation(prog, 'u_drift');
|
||||||
uni.brownout = gl.getUniformLocation(prog, 'u_brownout');
|
uni.brownout = gl.getUniformLocation(prog, 'u_brownout');
|
||||||
uni.flash = gl.getUniformLocation(prog, 'u_flash');
|
uni.flash = gl.getUniformLocation(prog, 'u_flash');
|
||||||
|
uni.strain = gl.getUniformLocation(prog, 'u_strain');
|
||||||
|
uni.fever = gl.getUniformLocation(prog, 'u_fever');
|
||||||
|
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
|
||||||
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
|
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
|
||||||
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
|
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
|
||||||
uni.scram = gl.getUniformLocation(prog, 'u_scram');
|
uni.scram = gl.getUniformLocation(prog, 'u_scram');
|
||||||
@ -299,7 +348,8 @@ export function createGlitchStack(
|
|||||||
gl.uniform1f(uni.drift, 1);
|
gl.uniform1f(uni.drift, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draw({ params, timeSec, drift, brownout, flash, idle, stampAmt, stampScale, scram, scramY, stampDirty }) {
|
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
|
||||||
|
stampAmt, stampScale, scram, scramY, stampDirty }) {
|
||||||
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
|
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
|
||||||
if (base.update(timeSec * 1000, idle)) {
|
if (base.update(timeSec * 1000, idle)) {
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@ -315,6 +365,9 @@ export function createGlitchStack(
|
|||||||
gl.uniform1f(uni.drift, drift);
|
gl.uniform1f(uni.drift, drift);
|
||||||
gl.uniform1f(uni.brownout, brownout);
|
gl.uniform1f(uni.brownout, brownout);
|
||||||
gl.uniform1f(uni.flash, flash);
|
gl.uniform1f(uni.flash, flash);
|
||||||
|
gl.uniform1f(uni.strain, strain);
|
||||||
|
gl.uniform1f(uni.fever, fever);
|
||||||
|
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
|
||||||
gl.uniform1f(uni.stampAmt, stampAmt);
|
gl.uniform1f(uni.stampAmt, stampAmt);
|
||||||
gl.uniform1f(uni.stampScale, stampScale);
|
gl.uniform1f(uni.stampScale, stampScale);
|
||||||
gl.uniform1f(uni.scram, scram ? 1 : 0);
|
gl.uniform1f(uni.scram, scram ? 1 : 0);
|
||||||
|
|||||||
@ -9,12 +9,13 @@
|
|||||||
* have ever shipped decides the intensity. Shipments arrive as waves, not switches —
|
* have ever shipped decides the intensity. Shipments arrive as waves, not switches —
|
||||||
* every param eases toward its target.
|
* every param eases toward its target.
|
||||||
*/
|
*/
|
||||||
import type { GameData, ItemDef, ScreenFX, SimEvent } from '../contracts';
|
import type { GameData, ItemDef, ScreenFX, SimEvent, SimSnapshot } from '../contracts';
|
||||||
import { createBaseFeed } from './baseTexture';
|
import { createBaseFeed } from './baseTexture';
|
||||||
import { createGlitchStack, type GlitchStack } from './glitchStack';
|
import { createGlitchStack, type GlitchStack } from './glitchStack';
|
||||||
import { createOverlays } from './overlays';
|
import { createOverlays } from './overlays';
|
||||||
import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
||||||
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
||||||
|
import { computeStrain } from './strain';
|
||||||
|
|
||||||
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
|
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
|
||||||
const EASE_TAU = 1.2;
|
const EASE_TAU = 1.2;
|
||||||
@ -29,9 +30,27 @@ const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37,
|
|||||||
const STAMP_LIFE = 0.4;
|
const STAMP_LIFE = 0.4;
|
||||||
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
|
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
|
||||||
const STAMP_QUEUE_MAX = 2;
|
const STAMP_QUEUE_MAX = 2;
|
||||||
|
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
|
||||||
|
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;
|
||||||
|
|
||||||
interface Stamp { name: string; count: number; color: string; age: number }
|
interface Stamp { name: string; count: number; color: string; age: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given
|
||||||
|
* run always grows the same maze, while different runs grow it somewhere else. Kept off the
|
||||||
|
* edges so the growth reads as spreading rather than creeping in from off-screen.
|
||||||
|
*/
|
||||||
|
function seedFromTick(tick: number): [number, number] {
|
||||||
|
const h = (n: number) => {
|
||||||
|
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5];
|
||||||
|
}
|
||||||
|
|
||||||
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||||
function smoothstep(e0: number, e1: number, x: number): number {
|
function smoothstep(e0: number, e1: number, x: number): number {
|
||||||
const t = clamp01((x - e0) / (e1 - e0));
|
const t = clamp01((x - e0) / (e1 - e0));
|
||||||
@ -59,9 +78,14 @@ export function createScreenFX(): ScreenFX {
|
|||||||
const stampQueue: Stamp[] = [];
|
const stampQueue: Stamp[] = [];
|
||||||
let stamp: Stamp | null = null;
|
let stamp: Stamp | null = null;
|
||||||
let stampDirty = false;
|
let stampDirty = false;
|
||||||
let pendingScram = false;
|
let scramFrames = 0;
|
||||||
let scramY = 0.5;
|
let scramY = 0.5;
|
||||||
|
|
||||||
|
let strain = 0;
|
||||||
|
let fever = 0;
|
||||||
|
let moireSeed: [number, number] = [0.5, 0.44];
|
||||||
|
let moireSeeded = false;
|
||||||
|
|
||||||
let lastMs = 0;
|
let lastMs = 0;
|
||||||
let frameCost = 0; // rolling avg ms of this module's CPU work
|
let frameCost = 0; // rolling avg ms of this module's CPU work
|
||||||
|
|
||||||
@ -73,7 +97,7 @@ export function createScreenFX(): ScreenFX {
|
|||||||
ledgerDirty = false;
|
ledgerDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ship(item: string, count: number) {
|
function ship(item: string, count: number, tick = 0) {
|
||||||
if (count <= 0) return;
|
if (count <= 0) return;
|
||||||
tally.set(item, (tally.get(item) ?? 0) + count);
|
tally.set(item, (tally.get(item) ?? 0) + count);
|
||||||
ledgerDirty = true;
|
ledgerDirty = true;
|
||||||
@ -81,6 +105,11 @@ export function createScreenFX(): ScreenFX {
|
|||||||
hasShipped = true;
|
hasShipped = true;
|
||||||
pendingFlash = true; // the awakening
|
pendingFlash = true; // the awakening
|
||||||
}
|
}
|
||||||
|
// The crystal takes root where and when it first arrived, and grows from there.
|
||||||
|
if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) {
|
||||||
|
moireSeed = seedFromTick(tick);
|
||||||
|
moireSeeded = true;
|
||||||
|
}
|
||||||
// One stamp per EVENT, not per unit: a 40-unit shipment is one big stamp.
|
// One stamp per EVENT, not per unit: a 40-unit shipment is one big stamp.
|
||||||
const def = items.get(item);
|
const def = items.get(item);
|
||||||
stampQueue.push({
|
stampQueue.push({
|
||||||
@ -111,19 +140,26 @@ export function createScreenFX(): ScreenFX {
|
|||||||
(window as any).__fktryScreen = {
|
(window as any).__fktryScreen = {
|
||||||
ship,
|
ship,
|
||||||
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
|
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
|
||||||
scram: () => { pendingScram = true; scramY = 0.5; },
|
scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; },
|
||||||
reset: () => {
|
reset: () => {
|
||||||
tally.clear();
|
tally.clear();
|
||||||
ledgerDirty = true;
|
ledgerDirty = true;
|
||||||
hasShipped = false;
|
hasShipped = false;
|
||||||
stamp = null;
|
stamp = null;
|
||||||
stampQueue.length = 0;
|
stampQueue.length = 0;
|
||||||
|
moireSeeded = false;
|
||||||
|
moireSeed = [0.5, 0.44];
|
||||||
|
strain = 0;
|
||||||
|
fever = 0;
|
||||||
for (const p of PARAMS) eased[p] = 0;
|
for (const p of PARAMS) eased[p] = 0;
|
||||||
},
|
},
|
||||||
stats: () => ({
|
stats: () => ({
|
||||||
units: +units.toFixed(2),
|
units: +units.toFixed(2),
|
||||||
total: +total.toFixed(3),
|
total: +total.toFixed(3),
|
||||||
idle: !hasShipped,
|
idle: !hasShipped,
|
||||||
|
strain: +strain.toFixed(3),
|
||||||
|
fever: +fever.toFixed(3),
|
||||||
|
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
|
||||||
brownout: +brownout.toFixed(2),
|
brownout: +brownout.toFixed(2),
|
||||||
msPerFrame: +frameCost.toFixed(3),
|
msPerFrame: +frameCost.toFixed(3),
|
||||||
tally: Object.fromEntries(tally),
|
tally: Object.fromEntries(tally),
|
||||||
@ -135,26 +171,36 @@ export function createScreenFX(): ScreenFX {
|
|||||||
|
|
||||||
onEvents(events: SimEvent[]) {
|
onEvents(events: SimEvent[]) {
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.kind === 'shipped') ship(e.item, e.count);
|
if (e.kind === 'shipped') ship(e.item, e.count, e.tick);
|
||||||
else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
|
else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
|
||||||
else if (e.kind === 'scram' && e.on) {
|
else if (e.kind === 'scram' && e.on) {
|
||||||
// A machine scrammed: the tape loses tracking. Bar position is derived from the
|
// A machine scrammed: the tape loses tracking. Bar position is derived from the
|
||||||
// event rather than random, so the same run always wipes the same way.
|
// event rather than random, so the same run always wipes the same way.
|
||||||
pendingScram = true;
|
scramFrames = SCRAM_FRAMES;
|
||||||
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
|
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
frame(timeMs: number) {
|
frame(timeMs: number, snap?: SimSnapshot) {
|
||||||
if (!stack) return;
|
if (!stack) return;
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000);
|
// Clamp BOTH ends. A negative dt (clock rewind, a paused tab resuming oddly, a test
|
||||||
|
// harness restarting its timebase) turns ease()'s 1-exp(-dt/tau) into a huge negative
|
||||||
|
// multiplier and throws every eased value into orbit. The ceiling stops long stalls
|
||||||
|
// from snapping params; the floor stops that.
|
||||||
|
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
|
||||||
lastMs = timeMs;
|
lastMs = timeMs;
|
||||||
|
|
||||||
if (ledgerDirty) recompute();
|
if (ledgerDirty) recompute();
|
||||||
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
|
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
|
||||||
|
|
||||||
|
// v3: read-only peek. Collapsed to two scalars immediately — the snapshot is never
|
||||||
|
// retained across frames, per the contract invariant.
|
||||||
|
const target = computeStrain(snap);
|
||||||
|
strain = ease(strain, target.tremor, dt, STRAIN_TAU);
|
||||||
|
fever = ease(fever, target.fever, dt, FEVER_TAU);
|
||||||
|
|
||||||
const timeSec = timeMs / 1000;
|
const timeSec = timeMs / 1000;
|
||||||
const wobble =
|
const wobble =
|
||||||
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
|
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
|
||||||
@ -191,10 +237,11 @@ export function createScreenFX(): ScreenFX {
|
|||||||
const flash = pendingFlash ? 1 : 0;
|
const flash = pendingFlash ? 1 : 0;
|
||||||
stack.draw({
|
stack.draw({
|
||||||
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
|
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
|
||||||
stampAmt, stampScale, scram: pendingScram, scramY, stampDirty,
|
strain, fever, moireSeed,
|
||||||
|
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
|
||||||
});
|
});
|
||||||
pendingFlash = false;
|
pendingFlash = false;
|
||||||
pendingScram = false;
|
if (scramFrames > 0) scramFrames--;
|
||||||
stampDirty = false;
|
stampDirty = false;
|
||||||
|
|
||||||
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
|
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
|
||||||
|
|||||||
101
fktry/src/screen/strain.test.ts
Normal file
101
fktry/src/screen/strain.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { EntityState, SimSnapshot } from '../contracts';
|
||||||
|
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
|
||||||
|
|
||||||
|
function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
|
||||||
|
return {
|
||||||
|
tick: 0,
|
||||||
|
paused: false,
|
||||||
|
entities: entities.map((e, i) => ({
|
||||||
|
id: i, def: 'x', pos: { x: 0, y: 0 }, dir: 0, recipe: null, progress: 0,
|
||||||
|
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, ...e,
|
||||||
|
})),
|
||||||
|
beltItems: [],
|
||||||
|
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
|
||||||
|
shippedTotal: {},
|
||||||
|
activeCommission: null,
|
||||||
|
commissionProgress: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the NOTHING IS WRONG era stays sterile', () => {
|
||||||
|
it('is inert with no snapshot at all', () => {
|
||||||
|
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays calm for a healthy factory running a surplus', () => {
|
||||||
|
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays calm in surplus even with an empty reserve', () => {
|
||||||
|
// Nothing is draining, so there is nothing to dread — banked reserve is irrelevant.
|
||||||
|
expect(computeStrain(snap({ gen: 100, draw: 100, stored: 0 })).tremor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays calm with cold machines', () => {
|
||||||
|
expect(computeStrain(snap({ gen: 10, draw: 5 }, [{ heat: 0 }, { heat: 0 }])).fever).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tremor tracks seconds-of-reserve, not raw stored', () => {
|
||||||
|
it('is silent while the reserve outlasts the horizon', () => {
|
||||||
|
// deficit 10/s, 200 banked = 20s of runway: further out than the horizon.
|
||||||
|
expect(computeStrain(snap({ gen: 0, draw: 10, stored: 200 })).tremor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is total when the reserve is gone and the draw continues', () => {
|
||||||
|
expect(computeStrain(snap({ gen: 0, draw: 10, stored: 0 })).tremor).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits halfway at half the horizon', () => {
|
||||||
|
const stored = 10 * (STRAIN_HORIZON_SECONDS / 2); // deficit 10/s -> horizon/2 seconds left
|
||||||
|
expect(computeStrain(snap({ gen: 0, draw: 10, stored })).tremor).toBeCloseTo(0.5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rises monotonically as the reserve drains', () => {
|
||||||
|
let prev = -1;
|
||||||
|
for (let stored = 200; stored >= 0; stored -= 10) {
|
||||||
|
const t = computeStrain(snap({ gen: 0, draw: 10, stored })).tremor;
|
||||||
|
expect(t).toBeGreaterThanOrEqual(prev);
|
||||||
|
prev = t;
|
||||||
|
}
|
||||||
|
expect(prev).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the same dread from a big factory as a small one at equal runway', () => {
|
||||||
|
// A megafactory bleeding 1000/s with 4000 banked is in exactly as much trouble as a
|
||||||
|
// shack bleeding 10/s with 40 banked. Seconds, not units, is the honest measure.
|
||||||
|
const big = computeStrain(snap({ gen: 0, draw: 1000, stored: 4000 })).tremor;
|
||||||
|
const small = computeStrain(snap({ gen: 0, draw: 10, stored: 40 })).tremor;
|
||||||
|
expect(big).toBeCloseTo(small, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaves 0..1, even absurdly overdrawn', () => {
|
||||||
|
const t = computeStrain(snap({ gen: 0, draw: 1e9, stored: -50 })).tremor;
|
||||||
|
expect(t).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t).toBeLessThanOrEqual(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.
|
||||||
|
const entities = [{ heat: 0.9 }, ...Array.from({ length: 50 }, () => ({ heat: 0 }))];
|
||||||
|
expect(computeStrain(snap({}), ).fever).toBe(0);
|
||||||
|
expect(computeStrain(snap({}, entities)).fever).toBeCloseTo(0.9, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('averages across the hot machines', () => {
|
||||||
|
expect(computeStrain(snap({}, [{ heat: 0.4 }, { heat: 0.8 }])).fever).toBeCloseTo(0.6, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays within 0..1 for a fully scrammed factory', () => {
|
||||||
|
const f = computeStrain(snap({}, [{ heat: 1, scrammed: true }, { heat: 1 }])).fever;
|
||||||
|
expect(f).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
fktry/src/screen/strain.ts
Normal file
66
fktry/src/screen/strain.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Signal strain — the continuous half of THE SCREEN's vocabulary.
|
||||||
|
*
|
||||||
|
* Events give edges (brownout on/off); the snapshot gives quantities. Strain is what the
|
||||||
|
* factory feels BEFORE the seizure: the reserve draining toward empty, the decoders running
|
||||||
|
* hot. Strain is weather; the corruption ledger is climate. It stays deliberately subordinate.
|
||||||
|
*
|
||||||
|
* Pure and snapshot-shaped on purpose: no DOM, no GL, so the "healthy factory stays sterile"
|
||||||
|
* guarantee is a unit test rather than a promise.
|
||||||
|
*/
|
||||||
|
import type { SimSnapshot } from '../contracts';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_STRAIN: Strain = { tremor: 0, fever: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up
|
||||||
|
* as the factory's remaining seconds fall toward zero.
|
||||||
|
*/
|
||||||
|
export const STRAIN_HORIZON_SECONDS = 8;
|
||||||
|
|
||||||
|
/** Machines idling at a trace of heat are not a fever. */
|
||||||
|
const HEAT_FLOOR = 0.01;
|
||||||
|
|
||||||
|
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only peek at the snapshot (contracts v3). The snapshot is never retained: everything
|
||||||
|
* needed collapses to two scalars here and now.
|
||||||
|
*/
|
||||||
|
export function computeStrain(snap?: SimSnapshot): Strain {
|
||||||
|
if (!snap) return NO_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 deficit = draw - gen;
|
||||||
|
let tremor = 0;
|
||||||
|
if (deficit > 0) {
|
||||||
|
const secondsLeft = Math.max(0, stored) / deficit;
|
||||||
|
tremor = 1 - clamp01(secondsLeft / STRAIN_HORIZON_SECONDS);
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
let hot = 0;
|
||||||
|
for (const e of snap.entities) {
|
||||||
|
if (e.heat > HEAT_FLOOR) {
|
||||||
|
sum += e.heat;
|
||||||
|
hot++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fever = hot > 0 ? clamp01(sum / hot) : 0;
|
||||||
|
|
||||||
|
return { tremor, fever };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user