Compare commits

..

1 Commits

Author SHA1 Message Date
LANE-SIM
1944b21b19 [sim] round 3 NOTES
Confirms the units conversion (bufferCap 240 = 8.0s of cover at a 30/s
deficit) and reports scram survival: first scram t434, restart t934,
~718-tick duty cycle, ZERO brownout ticks across 20k, melt rate unchanged.

For the orchestrator:
- heat semantics need a one-line ruling. heatPerTick is GROSS in my model
  (net = heatPerTick - coolPerTick). DATA's committed 0.0025 against the
  0.004 default nets negative -- the decoder could never scram. Their
  in-flight 0.0033/0.001 works, so we agree by luck of timing.
- the codex's literal bloom loop is not expressible with per-grade recipes
- asic-cooler does nothing without a spatial heat mechanic
- my sim code landed inside 45003db [ui] REMOVE mode: the shared tree has
  one git index, and an add-everything commit in another session swept it.
  Not a UI ownership violation. Suggest a no-`git add -A` rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:37:25 +10:00
126 changed files with 703 additions and 13660 deletions

View File

@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Deploy WIMVEE GLYTCH to https://partly.party/glytch/
# /glytch/ -> FKTRY (the factory game, vite build)
# /glytch/match.html -> the original match-the-glitch prototype (single file)
# Pattern per deploy-map: bundle -> rsync to /tmp staging -> docker cp into
# forum-nginx -> curl verify (cache-busted). Cloudflare fronts partly.party:
# purge cache after major changes.
set -euo pipefail
cd "$(dirname "$0")"
VPS=humanjing@100.71.119.27
CONTAINER=forum-nginx
WEBROOT=/usr/share/nginx/html
echo "— build —"
(cd fktry && npx vite build --base=./)
cp index.html fktry/dist/match.html
echo "— stage —"
rsync -az --delete fktry/dist/ "$VPS:/tmp/glytch-stage/"
echo "— swap into container —"
ssh "$VPS" "docker exec $CONTAINER rm -rf $WEBROOT/glytch \
&& docker cp /tmp/glytch-stage $CONTAINER:$WEBROOT/glytch \
&& rm -rf /tmp/glytch-stage"
echo "— verify (cache-busted) —"
curl -fsS -o /dev/null -w '%{http_code} https://partly.party/glytch/\n' "https://partly.party/glytch/?v=$(date +%s)"
curl -fsS -o /dev/null -w '%{http_code} https://partly.party/glytch/match.html\n' "https://partly.party/glytch/match.html?v=$(date +%s)"
# data-endpoint check, not just index (classic nginx-routing failure mode):
ASSET=$(ls fktry/dist/assets | grep -m1 '\.js$')
curl -fsS -o /dev/null -w "%{http_code} https://partly.party/glytch/assets/$ASSET\n" "https://partly.party/glytch/assets/$ASSET?v=$(date +%s)"
echo "done. If this was a major change: purge Cloudflare cache for partly.party."

View File

@ -411,115 +411,3 @@ your win condition from "make it glitch" to "make it silent," or steal the KDM w
(also you).
- Every asset generated gets its element's codex name — this file is the manifest;
check off against it.
---
## 10. THE COMPOSITE SEAM — keying & transparency artifacts
> Source study: Corridor Crew's history of compositing (Niko Pueringer) + our local
> `corridorkey-mrp-mlx` (MLX port of CorridorKey, runs in the MODELBEAST flow on the
> m3ultra). Thesis, and the section's whole physics: **you cannot film transparency.**
> Alpha does not exist in nature — every era of cinema is a different failed séance
> for summoning it. In the Bitstream this is literal: ALPHA is the one resource that
> cannot be mined from any seam. It can only be *computed*, and every computation
> before the last one lies.
**Geology:** the Composite Seam is not a stratum — it is a vertical FAULT crossing
every era layer, glittering with half-transparent fossils. Mining near it yields
subjects fused to backgrounds that were never really there.
### Resources & products
**ALPHA** — *raw transparency, the unfilmable substance.* A shimmering nothing in a
frame; the rarest resource in the game. Cannot be extracted; only keyer machines emit
it, each era's keyer emitting a corrupted grade.
`ASSET (3D + shader): a glass ingot that renders as a checkerboard-transparency hole in the world itself, faint rainbow rim, pedestal casting a shadow the ingot doesn't`
**HARD MATTE** — *reel-era binary alpha.* Silver-halide matte slabs: every pixel all
or nothing. Razor edges, zero feather; anything soft (hair, smoke, glass) that touches
one gets guillotined to a hard silhouette. Cheap, brutal, in demand for "woodcut" looks.
`ASSET (3D): stack of black-and-white film mattes like x-ray plates, silhouettes with surgically hard edges, chemical stain rims, one plate mid-slice through a wisp of smoke`
**BLUE HOLE** — *optical-printer costume dropout.* A patch of SUBJECT gone transparent
because it dared to be blue. Sold as canned absence; installing one in a product makes
part of it see-through in a way that reads as a wound.
`ASSET (3D): a mannequin torso with a clean-edged void where the chest was, world visible through it, faint blue glow licking the hole's rim`
**FRINGE LACE** — *matte lines, bottled.* The bright halo ribbon where layers of film
were re-exposed slightly misaligned. Harvested off composite edges like dried glue.
`ASSET (3D): coils of luminous ribbon, each strand a hard bright outline the exact shape of some absent object's edge, wound on an industrial spool`
**GREEN SPILL** — *chroma contamination.* Cousin fluid to CHROMA SLURRY but nastier:
it doesn't tint what it touches, it makes it PARTIALLY DISAPPEAR (the keyer downstream
eats whatever it stains). Leaks from every chroma keyer; the OSHA posters are explicit.
`ASSET (3D): dripping acid-green fluid whose puddles render as半 half-vanished floor, hazmat berm around a keyer machine, splashes eating a crate mid-vanish`
**MIXED PIXEL** — *the purple gel: an unmixable blend.* Foreground and background
fused into one pixel that belongs to neither — the red gel that filmed purple. Classic
keyers cannot separate it; the neural keyer eats it for breakfast.
`ASSET (3D): a jar of pixels each half one color half another, split diagonally, labeled RED (DISPUTED)`
**ROTO CHATTER** — *boiling edges.* Frame-inconsistent cutout outlines, jittering like
they're being redrawn 24 times a second by a tired artist — because they are. A live
hazard that infests composite edges; trapped and canned, it sells as nervous energy.
`ASSET (VFX + 3D can): an outline that never sits still, vibrating polyline crawling around a silhouette; the can has a shaking label`
**SODIUM LIGHT (589nm)** — *the grail wavelength.* Disney's sodium-vapor trick:
mathematically perfect mattes, hair and glass and gossamer intact — locked behind an
exclusive prism only one company ever owned. In the Bitstream, The Correction hoards
the prisms (perfection is their department). A single beam is an endgame reagent.
`ASSET (3D): a sealed lantern emitting one impossibly pure amber-orange line of light, wavelength etched on the housing, white-glove handling cradle, Correction seal`
**GHOST FEATHER** — *semi-transparency done RIGHT.* Hair strands, motion blur, glass —
alpha with gradients. The proof-of-mastery product; every era below the neural keyer
ruins it, so demand never dies.
`ASSET (3D): a single enormous feather fading from fully solid quill to truly transparent tip, the transition agonizingly smooth, displayed under museum glass`
### Machines
**MATTE BATH** *(reel era)* — chemical tank that binarizes anything: footage in, hard
mattes out, all softness destroyed (a GHOST FEATHER fed in comes out a HARD MATTE and
the machine does not apologize). One mistake ruins the whole reel — jams eat inventory.
`ASSET (3D): open chemical bath with film strips on rollers dipping through, developer-red darkroom lamp, dissolving softness visibly leaving the film as rising fog`
**OPTICAL PRINTER** *(the iron lung)* — a cathedral of bellows and lenses that splits
footage into R/G/B channel reels and recombines them slightly wrong. Produces
composites + FRINGE LACE always + BLUE HOLES whenever fed anything blue. Huge
footprint, gorgeous, mechanical; the codex's most machine-ass machine.
`ASSET (3D, hero): room-sized brass-and-iron optical printer, three film paths in primary-color light, bellows lungs mid-breath, a fringe of glowing misalignment haloing its output gate`
**CHROMA KEYER** *(stream era)* — digital green screen box: cheap, fast, 30-year-old
math. Emits ALPHA (consumer grade) + GREEN SPILL, chokes on hair (GHOST FEATHER input
→ ROTO CHATTER hazard spawn), turns semi-transparent inputs into MIXED PIXELS.
`ASSET (3D): studio-green cube with a subject-shaped hole punched clean through, green fluid weeping from its seams, sample swatches of failed hair taped to its side`
**ROTO BENCH** — *the grind made flesh.* Manual-labor station that converts ANY
footage to cutouts — perfectly, eventually — at an atrocious rate (24 frames of pain
per second of footage). The economic floor under every keyer: when automation fails,
the bench never does, it just costs your life. Emits ROTO CHATTER while running.
`ASSET (3D): a hunched drafting desk with a glowing frame-by-frame lightbox, mountain of finished frames on one side, coffee rings fossilized into the wood, a small flickering outline hovering over the current frame`
**NEURAL KEYER** *(endgame)* — the CorridorKey machine: trained on ten thousand
synthetic ghosts because reality couldn't teach it (you can't film the answer). Pulls
perfect ALPHA through glass, chainmail, motion-blurred hair; UNMIXES pixels (MIXED
PIXEL → original colors, recovered); no spill, no fringe, no chatter. The catch is
pure hardware: it draws 24 VRAM — a bandwidth appetite that dwarfs everything else in
the game. You don't unlock the neural keyer; you unlock the power plant it demands.
`ASSET (3D, hero): a monolithic GPU shrine, fans like cathedral rose windows, cables thick as anacondas, projecting a flawless translucent figure above itself; power meter needle buried in the red`
### Hazards
**SPILL FOG** — green contamination cloud around leaky keyers; products crossing it
gain partial transparency (defect or feature, depending on the commission).
**EDGE BOIL** — roto chatter infestation: outlines in the area start crawling;
machines misread their own footprints and mis-deliver until it's trapped.
### PYXLFK / MODELBEAST production note
`corridorkey-mrp-mlx` (gitea.partly.party/monster/corridorkey-mrp-mlx, deployed in the
MODELBEAST flow on the m3ultra) is a REAL artifact factory for this whole section:
run the same footage through (a) classic chroma-subtraction math and (b) the neural
keyer — the difference image is a pixel-perfect, ground-truth-labeled map of spill,
fringe, and mixed pixels. Degrading the alpha hint synthesizes roto chatter and hard-
matte binarization with known parameters. Every artifact above is bakeable with a
recorded recipe, which is the PYXLFK contract: corruption in, sidecar out.

View File

@ -55,29 +55,3 @@ data/*.json ──▶ main.ts loads GameData
Written in your lane NOTES as `CONTRACT REQUEST: <what> — <why>`. The orchestrator
batches them between rounds. Until granted, work around locally inside your lane.
## v4 notes
- `snapshot.commissionQueue` may alias a live sim array — read-only, never retain
across frames (same rule as the snapshot itself, stated here because it looks like
a plain array).
- Selection protocol: UI writes `bus.setSelection(...)`; main.ts derives ghost mode
and dispatches `place`/`remove` on click. UI must NOT dispatch `remove` itself.
The `'__remove'` def string is a deprecated bridge, deleted in v5.
- Heat: `heatPerTick` is GROSS; net climb = `heatPerTick coolPerTick`. A machine
whose heatPerTick ≤ its coolPerTick can never overheat — the validator enforces it.
- Research gating: ids appearing in any `TechDef.unlocks` are locked until researched;
ids referenced by no tech are always available.
## v6 notes
- **THE SPEAKER**: `AudioFX` (src/audio/**, LANE-SCREEN) — all sound is Web Audio
synthesis, no asset files. main.ts wires init/unlock/onEvents/frame. Pre-discovery
only the diegetic minimum plays; the band unlocks on `relicFound`.
- **Sanctioned narrow imports** (read-only, documented at the export site):
`src/sim/constants.ts` (tuning truth) and `src/audio`'s `radio` control (UI's
tuner dock). Everything else stays contract-mediated.
- **Relics are secrets.** `snapshot.relics` is always present, but nothing may
signpost an unfound relic: RENDER draws the subtle shimmer, UI shows nothing until
`found`. Discoverability budget: wandering + curiosity. That's the design.
- New jam reason string: `'no seam'` (extractor off-seam).

View File

@ -37,27 +37,6 @@ codex wins.
5. Commit with your lane prefix: `[sim] belt merge logic + tests`. One or a few
focused commits, not one mega-commit. Push.
**COMMIT HYGIENE (ruled after three round-3 collisions):** five sessions share ONE
working tree and ONE git index. `git add -A`, `git add .`, and `git commit -a` are
BANNED — they commit whatever other lanes have staged at that instant. Every commit
uses an explicit pathspec so the index state of other lanes cannot leak in:
`git commit -m "[sim] ..." -- fktry/src/sim fktry/lanes/LANE-SIM.md`
(a pathspec commit ignores the staging area for everything outside the paths).
Before pushing, `git show --stat HEAD` — if it lists files outside your lane,
`git reset --soft HEAD~1` and recommit correctly. If collisions still happen,
the next escalation is a git worktree per lane.
Also: **never bare `git stash`** — it stashes every lane's uncommitted work and
will convince you that you fixed (or caused) another lane's red. Only
`git stash push -- <your paths>`. (Round 4: SCREEN nearly misdiagnosed a red this
way; DATA's diff was the actual variable.)
**Numbers in orders go stale mid-round.** Five lanes land continuously; a count or
claim written into CURRENT ORDERS is true only as of writing (round 4: "24 nodes"
was 23 by landing; "research doesn't exist" was false within the hour). Re-probe
HEAD before acting on any order's factual claim, and before writing NOTES — a
round-start grep is a lie by round-end.
## Hard rules
- **Never edit**: `MASTERPLAN.md`, `CONTRACTS.md`, `src/contracts.ts`, `src/main.ts`,
@ -102,11 +81,3 @@ diff each lane against its owned paths (ownership violations get reverted), veri
claimed features actually work, reconcile cross-lane questions, update CONTRACTS if
(and only if) two lanes need it, write next CURRENT ORDERS in each lane doc, commit
`[orchestrator] round N orders`.
## ROUND 5 (MEGA) addendum
The original round-5 orders were superseded before execution by the MEGA round
(same goals + the paint-and-pipe layer from the live first-eyes review: sound via
the hidden optical-fossil radio, the commission-ladder tutorial, the floor, juice,
click-to-enlarge SCREEN, the break-room cabinet). After this round's review passes:
**redeploy to partly.party/glytch via ../deploy.sh** — that's the ship gate.

View File

@ -99,270 +99,6 @@ SIM builds auras. Gated behind `broadcast-asic-cooling` so it can't appear early
- Its own `coolPerTick: 0.02` is inert today (nothing heats it) and is there to state the
intended aura strength in data rather than in prose.
## Added in round 4
### §10 THE COMPOSITE SEAM — transcribed
The codex grew §10, so most of this round is transcription rather than invention. Every
item and machine in §10 is now data. Deviations and judgement calls only:
| Call | Why |
|---|---|
| "Footage" = `anchor-slab` | §10's keyers key *footage*, but the codex never says what footage *is* as an item. The anchor slab is already "a complete, self-sufficient picture" (§2) — the only thing in the game you could point a keyer at. Every keyer takes slabs. |
| MATTE BATH's chemical = `static-canister` | §3 already says static canisters are "brine for preserving other artifacts". The bath needed a developer; the brine was sitting right there. |
| `ghost-feather` is made **only** by the neural keyer | §10: "every era below the neural keyer ruins it, so demand never dies." So the earlier machines *consume* it and ruin it — `bathe-feather` (feather → hard matte, "the machine does not apologize") and `key-hair` (feather → roto chatter). Both are jokes with a real cost, and both are the codex verbatim. |
| NEURAL KEYER `powerDraw: 240` | §10 says "24 VRAM — a bandwidth appetite that dwarfs everything else in the game", and "you don't unlock the neural keyer; you unlock the power plant it demands". 240/s is 12 decode ASICs, or ~9 software decoders after duty cycling. The whole M1 reference chain draws 96. It dwarfs the game, as ordered. |
| `sodium-light` has no recipe | §10: The Correction hoards the prisms. Forward canon; on the FORWARD_CANON allowlist. `sodium-requisition` commissions it anyway, with 30 BLUE HOLES accepted "if you lose your nerve". |
| ROTO BENCH is gated (disc), not free | §10 calls it "the economic floor under every keyer... the bench never does [fail]". Tempting to leave ungated, but ALPHA is "the rarest resource in the game" and an ungated bench hands it to you at tick 0. Gated cheaply (10 spatial) so the floor arrives early but the Composite Seam stays a mid-game fault. |
Optical printer ratios hurt, as ordered: 4 anchor slabs + 2 colour plates → **1** alpha and
4 fringe lace, over 240 ticks. It is the worst alpha-per-slab in the game and it fringes
every single run, which is exactly what the iron lung is for.
### THE ARCHAEOLOGY LAB (`kind: 'lab'`) — canonized
The codex has no lab: §6 only says "TECH TREE = MEDIA ARCHAEOLOGY. You research *downward*
(excavating older strata for analog secrets) and *upward* (deriving newer codecs)." So the
lab is the dig site — named for the verb the codex already uses, not a generic "research
station". Ungated starting equipment, because a lab behind research is a lab you can never
build (there is now a test named exactly that).
## Added in round 5
### THE COMMISSION LADDER — the fax is the tutorial
There is no tutorial in FKTRY and there should never be one. The fax machine is already
the game's voice, already tells you what to build, and is already sacred (§6: "do not ask
why it works"). So the first four commissions teach one mechanic each and the player never
knows they were taught.
| Rung | Wants | Teaches | Machine types needed |
|---|---|---|---|
| 1 `raw-payload` | 20 MDAT-ORE, raw | extractor → belt → uplink. The whole verb set. | 2 crafting (+belt, +uplink) |
| 2 `first-refinement` | 15 LUMA BARS | the demuxer — **and byproducts**: the slurry has to go somewhere and the fax does not care | 3 |
| 3 `dust-quota` | 18 HF DUST | the quantizer, and that **waste is inventory** | 4 |
| 4 `first-taste` | 1 MELT | graduation: the whole M1 chain, ore to reactor | 8 |
Rung 1's flavour is the instruction manual: *"A belt, a drill, an uplink to THE SCREEN —
that is the whole job."* Rung 2 hands you a byproduct and refuses to help. Rung 3 reframes
the garbage as product, which is the game's actual thesis. Rung 4 is the old opening
commission, reward bumped 50 → 80, now earned rather than assumed.
**Measured, not estimated** (throwaway probe against LANE-SIM's real sim, then deleted):
```
rung 1, built exactly as the fax describes — drill, 2 belts, uplink, one ASIC:
4 machine types. Cleared at tick 650 = 21.7 seconds.
```
The order was "completable inside 3 minutes with 4 machine types". It is completable in
**22 seconds**, which matters more than the budget suggests: the first fax must clear
before a new player decides nothing is happening.
Why this was needed at all: the queue used to open on `first-taste` — one MELT — which
requires ore → demuxer → quantizer → subsampler → p-caster → DCT press → GOP assembler →
mosh reactor. The game's first instruction was "build eight machines correctly, in order,
with no feedback until the last one works." That is the onboarding cliff.
### THE OPTICAL FOSSIL — why the world is silent until you dig
§1 buries the hook in one clause: the Reel Beds hold *"optical soundtrack fossils — wavy
photographic strips that **sing** when excavated."* Optical sound is a picture of a
waveform printed on film — the one place in this world where image and audio are the same
substance. So:
**The Bitstream has no audio because the audio track was severed from the container long
ago, and one length of it is still in the ground.** Everything before the fossil is found
is a video file with no sound: UI clicks and nothing else. Excavating it splices the audio
bus back into the file, and the whole band comes alive at once — the tuner, the stations,
the factory's own hum. The player doesn't unlock a feature; they repair the file.
This is why THE STANDARD's script includes *"Do not excavate. There is nothing in the
strata but strata"* and *"Anything that sings when you dig it up was never meant to be
heard."* The Correction runs a propaganda station warning you off the one artifact that
would let you hear the propaganda station for what it is. It is also why DEAD AIR's ghost
line is the only direct acknowledgement in the game that you found it.
The hint is one fax margin, mid-ladder, on `haunted-doorway` — *"[someone has drawn a sine
wave in the corner of this fax. it is not our department.]"* It points at nothing. A sine
wave is a sound and a drawing at the same time, which is exactly what the fossil is.
### THE MOSQUITO TRAP — the last forward-canon commission comes off the blocklist
§5 gives the trap in one line: *"Catchable in edge-baited traps"*, the asset note calling it
*"a bug-zapper shaped like a high-contrast edge target"*. Mosquitoes are quantization noise
made alive: they hatch from HF DUST and cluster on sharp edges. So the recipe is exactly
that sentence — **8 HF DUST + 1 LUMA BAR → 1 MOSQUITO SWARM (canned)**. The dust is where
they come from, the fresh luma bar is the sharp edge that baits them, and the whole thing
gives HF dust a third sink.
This unblocks `vaporwave-sunset`, which has wanted "3 MOSQUITO SWARMS (live)" since round 1
and been unfillable the entire time. `mosquito-swarm` leaves FORWARD_CANON; only
`gibbs-wraith`, `cinema-pack` and `sodium-light` remain, and all three are deliberate
(a wraith must come willingly, cinema needs the Fortress, and The Correction hoards the
prisms).
### `data/seams.json` — the ground
Schema is `{ relicReserve, seams: [...] }`; each seam is `{ id, era, resource, rect{x,y,w,h},
richness, hidden? }` on the -32..31 grid. Five ordinary seams, one per stratum, placed so
each era's codex-correct payload has a home: stream crust → mdat ore, disc mantle → bayer
grit (§2: "from the deepest camera-original seams"), broadcast shelf → field splinters,
reel beds → silver frames.
Two constraints drove the layout, both now tests:
- **The reference factory must stand on ore.** SIM's three 2×2 extractors sit at (-30,0),
(-30,-3) and (-30,3); `stream-crust-west` spans x[-31,-28] y[-5,6] and covers all three
with a tile to spare. Off-seam they'd idle with `'no seam'` and the demo would produce
nothing at all.
- **`relicReserve` is a seam-free corner** — x[12,31] y[12,31]. SIM plants the optical
fossil from the seed, 1828 tiles out, *never on a seam*. If seams creep across the whole
ring there may be nowhere legal left to put it, so the SE quadrant is kept deliberately
empty. A test fails the moment a seam intrudes.
`richness` is authored but currently unread — SIM's rule is boolean overlap this round. It
is there for when extraction rate goes data-driven, and it is what the numbers station
broadcasts as the last digit.
### `data/stations.json` — the broadcast schedule
Four stations, matching SCREEN's spec: **88.1 FKTRY FM** (generative, seeded, era-flavoured),
**AM 640 THE STANDARD** (30 chyron lines), **SW 6955 [UNLISTED]** (numbers), **108.0 DEAD AIR**.
THE STANDARD is The Correction with a transmitter, and the voice rule is that it never
threatens — it reassures, in a way that gets worse the longer you think about it. *"Your
file is being read by someone who loves it very much."* *"Loss is not loss. Loss is
compression that has finished negotiating."* *"Good morning. It is always going to be
morning."* 30 unique lines at the ~20s a chyron needs to be readable = 10 minutes before
a repeat, which is the DoD.
**The numbers station is a real treasure map.** Each 5-figure group encodes one coordinate:
```
[x + 32 as 2 digits][y + 32 as 2 digits][richness × 9 as 1 digit]
53079 -> x = 53-32 = 21, y = 07-32 = -25, richness 9/9 hidden-nitrate-vein
07579 -> x = 07-32 = -25, y = 57-32 = 25, richness 9/9 hidden-rainbow-pit
59399 -> x = 59-32 = 27, y = 39-32 = 7, richness 9/9 hidden-payload-lode
```
Three of the groups on air are the three `hidden: true` seams; the rest are decoys that
decode to empty ground. A player with a pencil gets three maximum-richness seams nobody
told them about. **The validator recomputes the planted groups from `seams.json`**, so if a
hidden seam ever moves and the broadcast doesn't, the test fails — the map cannot go stale.
It also checks no decoy accidentally decodes onto a real seam, because a decoy that points
at treasure isn't a decoy, it's a leak.
Filler is procedural (SCREEN's seeded RNG) rather than a fixed list, so the station never
loops — a hand-written pool of 24 would cycle in about two minutes and the genre's whole
menace is that it goes on forever.
## Added in round 6
### THE CORRECTION walks — the parity-mite reading of §5
§5 gives the enemy in one clause: parity mites are *"beetle-drones that patrol belts and
quietly fix artifacts back into clean product (your melt becomes... footage of a lake)."*
The reading the data commits to, and why it is the codex's:
- **The mite does not destroy — it repairs, which is worse.** The Correction "cannot
perceive you, only deviation, which it repairs." So a mite eats a tier-2 glitch product
and hands back nothing you can sell: the work is *corrected*, not stolen. SIM emits a new
`repaired` event rather than a `removed` one, and the fiction is that losing melt to a
mite is losing it to a *fix*. THE STANDARD's new lines say this in the enemy's own voice
("A mite does not remove your work. It completes it. Your MELT was always going to be a
lake." — the codex line, spoken as reassurance).
- **Mites only touch corruption.** SIM's mechanic ignores tier-0/1 cargo entirely: the
immune system has nothing to correct in raw ore. That is the balance (raw material is
safe; only product is at risk) and the fiction (deviation is the only thing it can see).
### THE FIREWALL — the counter, on SIM's catcher shape
| Thing | Kind | Why it fits |
|---|---|---|
| `parity-mite` | item (tier 2) | The canned live mite. Hospital-white shell, green checkmark eye (§5, §8) → chip colour `#dff5e6`. Tier 2 like the other farmed hazards (`mosquito-swarm`, `gibbs-wraith`). |
| `firewall` | machine (crafter) | §5 names the mites but no counter. A *firewall* is the in-genre name for the thing that stops an intrusion, and the joke writes itself: it wears a compliance seal the Bitstream never issued, and a mite "cannot leave a deviation uncorrected" — a forged checkmark IS a deviation, so it climbs in to fix the seal and the drawer closes. Accent `#3fff7f` is that forged checkmark; chassis `#31383f` is your grime, not The Correction's white. |
| `catch-mite` | recipe (firewall) | **Input-free, output `parity-mite: 1`** — the exact shape SIM detects a catcher by (empty inputs, cans the creature), lifted from the mosquito trap. The "input" is a live mite in the world, which is wildlife in the snapshot, never cargo on a belt. Ingredients would silently demote it to an ordinary crafter that mints The Correction from nothing — pinned by a test that cites SIM's comment. |
| `broadcast-parity-firewall` | tech | Gated like the mosquito trap (`broadcast-mosquito-farming`): `{spatial-pack: 15, analog-pack: 10}`, unlocks `[firewall, catch-mite]`. You research the counter to the enemy. Early/cheap because the mite is §5's *"early nuisance"* — the counter should arrive about when the mites do. |
**Coordination with SIM (parallel this round):** SIM detects catchers by recipe shape and
by the constant `MITE_ITEM`. The item id is **`parity-mite`** and the recipe shape is
**`{ inputs: {}, outputs: { 'parity-mite': N } }`** — identical to the trap, one item id
over. `catch-mite` bandwidth 4 / 120 ticks (a harder catch than a mosquito's 90). The
radius is SIM's constant (`FIREWALL_RADIUS`), not a machine field.
### Two commissions that want The Correction alive
- `correction-specimen` (reward 1000): the flagship — *"ONE (1) UNIT OF THE CORRECTION,
ALIVE. INVOICE ATTACHED. DO NOT READ THE INVOICE."* The fax's first canned mite, in
voice. Slots between `gibbs-wraith-willingly` (900) and `feather-proof` (1100).
- `annual-correction-cull` (reward 2100, **standing**): 5 live mites + 20 melt per
collection, forever — you turn the immune system into a cash crop, and The Correction
files each absent technician as a rounding error and sends five more. Slots between
`sodium-requisition` (1800) and `sky-blue-mislabeled` (2400). Re-fillable forever
because both wants are producible (the catcher + the melt chain).
`parity-mite` is **producible** the moment the firewall exists (an input-free recipe reads
as always-runnable to the reachability closure, exactly as `mosquito-swarm` does), so it is
NOT forward canon and never joins the allowlist.
### The numbers station stops drifting
SCREEN's round-5 finding was real: the validator recomputed the three *planted* groups from
`seams.json` but never checked the *decoys*, and two of them had drifted onto visible seams:
```
08127 -> (-24,-20) sat inside reel-beds-deep (x[-30..-21] y[-24..-19])
27503 -> ( -5, 18) sat inside broadcast-shelf-south (x[-14..-3] y[16..21])
```
A decoy that decodes onto a real seam isn't a decoy, it's a leak — it hands the player a
genuine dig site the station was never meant to reveal. Both re-cut to land just *off* the
seam they were touching (still tantalisingly adjacent, which is the genre's whole cruelty):
```
08127 -> 08143 -> (-24,-18) one tile south of reel-beds-deep, on empty ground
27503 -> 27475 -> ( -5, 15) one tile north of broadcast-shelf-south, on empty ground
```
And the validator now decodes **every** decoy to a tile and fails, by name, if it lands
inside **any** seam rect — hidden or visible — not just the three hidden tiles. The map
recomputes its plants from the seams and now proves its decoys lead nowhere; it cannot go
stale in either direction. (All 22 remaining decoys were re-checked against all 8 seams —
these two were the only leaks.)
### Accents chosen against the real GLB textures
Round 4 filled accents by derivation; MODELBEAST has since delivered real models, so this
round the accents are picked by **looking at the actual albedo textures** in
`public/assets/models/` (extracted and inspected). The order named "the ten GLB'd machines";
by the time I looked, nineteen had models (RENDER hot-swapping mid-round), so every machine
with a real model now carries a texture-matched accent:
| Machine | Accent | Read from the texture |
|---|---|---|
| seam-extractor | `#48d6ff` | the cyan CRT readout on the amber drill body (§4 "CRT-certified") |
| quantizer | `#ff3b57` | CRIME-red dial indicator against the pale lilac/dust body |
| mosh-reactor | `#ff7a3f` | the molten-orange gauge; matches its MELT output |
| artifact-bottler | `#7fe0d8` | the iridescent glass-aqua of the bottled contents |
| chroma-keyer | `#4fff30` | the chroma-key green it literally weeps (green-spill) |
| muxer | `#ff3fd0` | launch-warning magenta on cold steel; matches the megaglitch |
| demuxer | `#ff3fd4` | the magenta channel-split streak (its chroma output) |
| demosaicer | `#4fffc4` | the spectral cyan-green band of the rainbow weave |
| dct-press | `#3fffe0` | the coefficient-cyan indicator specks on cold steel |
| field-loom | `#6ef58a` | the phosphor-green glowing tube |
| gop-assembler | `#5fb0ff` | the blue cadence-readout screens |
| mv-extractor | `#3fd8ff` | the motion-vector cyan fluid |
| p-caster | `#6fa8ff` | the blue projector beam |
| subsampler | `#e04fc8` | the iridescent chroma-pink it grades down |
| bloom-duplicator | `#ff9440` | the warm bloom-orange furnace glow |
| decode-asic | `#8fe8ff` | **refined** from `#6fe8c0`: the LEDs are ice-blue, not mint |
| mosquito-trap | `#a64fff` | **corrected** from `#d0ff60`: the model's zapper target glows UV-violet, not lime |
| software-decoder | `#ff4a2a` | kept — the texture's red internals confirm it |
| archaeology-lab | `#ffc24a` | kept — the warm dig-lamp amber confirms it |
The `firewall` (no model yet) gets its forged-checkmark `#3fff7f` art-directed, not derived.
---
## The numbers, and the arithmetic behind them
@ -383,49 +119,6 @@ idle draw (every machine's powerDraw) 57 /s
**Units (RULED round 3):** `gen`/`draw` are bandwidth per **second**; `stored` and
`bufferCap` are bandwidth-**seconds**. My round-2 assumption held.
### Round 4: the tech tree was a total bootstrap deadlock
The v4 gating rule — *any id in a `TechDef.unlocks` is locked until that tech completes*
turned three rounds of tech authoring from decoration into a lock on the entire game. I
had written every node assuming gating didn't exist. Measured against the rule:
```
TICK 0, nothing researched:
melt producible? false <- first-taste, the opening commission, impossible
analog-pack producible? false
spatial-pack producible? false
temporal-pack producible? false
container-pack producible? false
RESEARCH CLOSURE: 0 of 24 techs reachable — every node stranded behind its own output
```
Every pack chain was gated behind packs you could not make. `disc-artifact-bottling` cost
10 analog packs and unlocked the only machine that can bottle an analog pack. The M1 melt
chain itself (mosh reactor, subsampler, `press-anchor-slab`) was gated, so the game opened
on an unbuildable factory and an unfillable commission, with no way to research out of it.
**The fix is a shape, not a patch.** Two rules now hold, and both are tests:
1. **The M1 melt chain is starting equipment** — ungated, so first-taste is possible at
tick 0. Ungating it also fixes RENDER for free: they derive a machine's era from the
tech that unlocks it and treat unmapped machines as starting equipment, which is exactly
what the M1 chain now is.
2. **One pack must be craftable at tick 0**, or research can never begin. That pack is the
**SPATIAL PACK** — macroblock bricks + ringing halos + slurry, all of which fall out of
the quantizer the player already owns. Your first research is a study of your own
damage, which is the most in-voice thing in the tree. Everything else chains off it:
spatial → analog → temporal → container → fortress.
Result: **23 / 23 nodes reachable**, unlock order tells a story (phosphor seams → field
loom → analog packs → telecine → temporal → container → fortress), and the only items
unreachable with the whole tree bought are the four declared forward-canon.
Six disc nodes were **deleted**, not repurposed, because their whole content became
starting equipment: `disc-artifact-bottling`, `disc-chroma-subsampling`,
`disc-macroblock-quarrying`, `disc-ringing-skim`, `disc-subsampling-420`,
`disc-mosh-reactor`. Grepped first — no lane references tech ids (RENDER reads
`unlocks` generically, never a specific node). Recorded here per the deprecation rule.
### Round 2's heat was decorative — what went wrong
Worth writing down, because it's a trap anyone can fall into. I sized round 2's
@ -501,77 +194,6 @@ than it spends, recharging 300 in **4.5 s** of the 7.25 s up-phase. That is the
sentence — **a buffer tank is what makes software decoders survivable** — and it now
closes.
### The ASIC cooler goes live: `coolPerTick 0.02 → 0.0018`, `coolRadius: 2`
Round 3 spec'd the aura at 0.02 while it was inert. **The moment it became mechanical that
number was catastrophic:** the decoder banks 0.0033/tick, so a 0.02 aura means net
0.0167 — it never heats at all, and a 2×2 cooler turns a 2×2 decoder into a permanent
free 100/s. A spec'd number and a live number are different objects; this is the second
time this round that a number only became wrong once something read it.
**Round 5, FINAL under the additive ruling.** Contracts v5 settled it: auras are additive,
capped at `HEAT_COOL_MAX`. That killed 0.0018 — additive, two coolers reach 0.0046 against
the decoder's 0.0033 gross, so a casual pair made it immortal. Final value **0.001**:
```
coolers cooling throttle at duty avg gen note
0 0.0010 10.1 s 30 % 25.8 /s bare decoder, as the codex demands
1 0.0020 17.9 s 61 % 51.5 /s the pairing — output doubles
2 0.0030 77.8 s 91 % 77.3 /s heavy investment, nearly continuous
3 0.0040 never 100 % 100.0 /s immortal: an absurd shrine to one PC
```
The shape is deliberate. One cooler is the worked example and it **doubles** the decoder's
average output — a real reward for engineering around heat. Two is a serious commitment
that nearly eliminates downtime. Three tips into immortality, which costs 12 tiles of
cooler wrapped around 4 tiles of decoder — visibly ridiculous, and in-voice: the codex's
software decoder is a tower PC "held together with zip ties and prayer", so a shrine of
coolers keeping one alive is the correct picture. Immortality is reachable, never casual.
Linear stacking means *some* number always cancels the heat; my job was choosing which
number, and making sure it isn't 2. Per tile, when both machines are 2×2:
```
2 decode ASICs 40.0 /s over 8 tiles = 5.00 /s per tile
1 software decoder, bare 25.8 /s over 4 tiles = 6.44 /s per tile
decoder + 1 cooler 56.5 /s over 8 tiles = 7.06 /s per tile (incl. the cooler's own 5)
```
So the pairing is the best power in the game per tile (1.4× ASICs) — and it costs two
techs, two machines, and the floor space to keep them adjacent. That is the reward for
engineering around heat instead of suffering it, which is what the cooler is *for*. The
bare decoder still beats a pair of ASICs per tile, but it does so in violent 30%-duty
bursts that a buffer tank has to absorb; the ASIC's 5.00 never wavers. Three ways to buy
power, three different failure modes.
### Research: is the first node affordable off a first-taste-era factory?
Worked path for the bootstrap pack, one bottler, one quantizer:
```
bottle-spatial-pack = 2 macroblock-bricks + 1 ringing-halo + 2 chroma-slurry, 60 ticks
2 bricks <- quantize-crime x1 (1 coefficient pack, 20 ticks)
1 halo <- skim-ringing x1 (3 coefficient packs, 40 ticks)
4 coefficient packs <- quantize x4 (4 luma, 120 ticks) <- 6 mdat ore
THE QUANTIZER IS THE BOTTLENECK: 20 + 40 + 120 = 180 ticks per spatial pack
= 10.0 packs / minute on one quantizer
```
Against the early nodes:
```
broadcast-phosphor-seams 5 spatial = 0.5 min
reel-silver-seams 8 spatial = 0.8 min
disc-roto-bench 10 spatial = 1.0 min
broadcast-field-loom 15 spatial = 1.5 min <- opens the ANALOG PACK
```
A player who has shipped their first melt already owns a quantizer and can afford the first
four nodes inside two minutes of production. That's the intent: **the opening research is
paid for with the quantizer's own sins** — you study your own damage. Later nodes scale to
tens of minutes and demand the analog/temporal/container chains, which is where the floor
space goes.
### `heat` on the hex splicer: `coolPerTick 0.001`, 0.08 per splice
Net 0.035 per craft after the 45 ticks of cooling → 28.6 crafts → **42.9 s** of continuous
@ -585,14 +207,15 @@ splicing before it scrams (`chart-stco-map`: 50 s). A lesser fever than the deco
- **The ASIC cooler's aura is spec'd, not built** (above). Until SIM has auras, heat is
survivable only by duty-cycling or by leaning on ASICs. Deliberate.
- `cinema-pack`, `gibbs-wraith`, `sodium-light` remain forward canon (no recipe;
`mosquito-swarm` retired from the list in round 5 when the trap landed). The
- `cinema-pack`, `mosquito-swarm`, `gibbs-wraith` remain forward canon (no recipe). The
validator's FORWARD_CANON allowlist is the enforcement, and it has a companion test
asserting nothing sits on the list once it *is* producible. Round 3 retired
`container-pack`'s slot (round 2) and added none.
- Dials-as-recipes (quantizer Q, subsampler grades, GOP cadence) stays, per round-2 review.
- ~~The heat constants are mirrored by hand~~ **CLOSED (round 5)**: LANE-SIM exported them
in `src/sim/constants.ts` and the validator imports them. The copies are retired; if SIM
retunes the curve my arithmetic now fails loudly instead of asserting the old feel.
- **The heat constants in `data/validate.test.ts` are mirrored from `src/sim/index.ts`**
(throttle 0.7, restart 0.5, dissipation default 0.004, 30 tps). They are not exported,
so my arithmetic tests can't import them. If SIM retunes the curve, my tests go stale
*silently* — they'd still pass while the feel drifts. Re-check on any heat round. A
cheap fix if SIM likes: export the constants.
- The muxer, moov brain and megaglitch are data-only; no lane implements a victory
condition yet. The tech gate (`fortress-the-muxer`) keeps them out of reach meanwhile.

View File

@ -1,19 +1,7 @@
[
{ "id": "raw-payload",
"flavor": "20 MDAT-ORE. Raw. Do not wash it. Do not demux it. Do not ask why. A belt, a drill, an uplink to THE SCREEN — that is the whole job. Welcome to the trade.",
"wants": { "mdat-ore": 20 }, "rewardBandwidth": 20 },
{ "id": "first-refinement",
"flavor": "15 LUMA BARS. You will need the DEMUXER for this, and the DEMUXER gives you colour whether you asked for it or not. The CHROMA SLURRY is your problem now. It has to go somewhere. We do not care where. The fax does not care where.",
"wants": { "luma-bars": 15 }, "rewardBandwidth": 35 },
{ "id": "dust-quota",
"flavor": "18 HF DUST. Yes, the waste. Run the QUANTIZER hard and it falls out the seams like glitter off a crime. In this trade the garbage is inventory. Sweep it up. Ship it. Bill us.",
"wants": { "hf-dust": 18 }, "rewardBandwidth": 55 },
{ "id": "first-taste",
"flavor": "One (1) unit of MELT. We hear you make it fresh. No questions will be answered. You have run the whole line to make this — ore to anchor to crate to reactor. Graduation, of a kind. There is no ceremony. There is only the next fax.",
"wants": { "melt": 1 }, "rewardBandwidth": 80 },
"flavor": "One (1) unit of MELT. We hear you make it fresh. No questions will be answered.",
"wants": { "melt": 1 }, "rewardBandwidth": 50 },
{ "id": "dust-allowance",
"flavor": "12 STATIC CANISTERS, brine grade, not display grade. Our previous supplier shipped display grade and we no longer speak of them. Standing order; the jars come back empty and we do not explain why.",
@ -28,7 +16,7 @@
"wants": { "luma-bars": 30, "gamut-paint-rec709": 4 }, "rewardBandwidth": 260 },
{ "id": "haunted-doorway",
"flavor": "9 RINGING HALOS, hung not stacked. They are for a doorway. The doorway is for nothing. Do not ask what comes through it; it is already through. [someone has drawn a sine wave in the corner of this fax. it is not our department.]",
"flavor": "9 RINGING HALOS, hung not stacked. They are for a doorway. The doorway is for nothing. Do not ask what comes through it; it is already through.",
"wants": { "ringing-halos": 9, "gamut-paint-rec709": 2 }, "rewardBandwidth": 300 },
{ "id": "correction-buyback",
@ -55,26 +43,10 @@
"flavor": "One (1) GIBBS WRAITH, willingly.",
"wants": { "gibbs-wraith": 1 }, "rewardBandwidth": 900 },
{ "id": "correction-specimen",
"flavor": "ONE (1) UNIT OF THE CORRECTION, ALIVE. Shell intact, checkmark lit, proboscis zip-tied. INVOICE ATTACHED. DO NOT READ THE INVOICE. We have been trying to get one to hold still since before the Reel Beds cooled, and you did it in an afternoon, with a forged seal and a drawer that only opens one way. We are not asking how. We are asking for another.",
"wants": { "parity-mite": 1 }, "rewardBandwidth": 1000 },
{ "id": "feather-proof",
"flavor": "3 GHOST FEATHERS. Gradient intact, tip genuinely transparent, no hard edge anywhere on the object. We have been sold nine hard mattes by nine suppliers who each insisted it was the same thing. It is not the same thing. Do not send a matte.",
"wants": { "ghost-feather": 3 }, "rewardBandwidth": 1100 },
{ "id": "container-integrity-review",
"flavor": "4 CONTAINER PACKS and 20 EXPLOSIVE HITS, shipped in that order and not the other order. The last supplier shipped in the other order. There is no last supplier.",
"wants": { "container-pack": 4, "explosive-hits": 20 }, "rewardBandwidth": 1400 },
{ "id": "sodium-requisition",
"flavor": "One (1) beam of SODIUM LIGHT, 589nm, in its prism. We are aware who holds the prisms. We are aware what they call it when you take one. We are prepared to call it something else. 30 BLUE HOLES accepted as consolation if you lose your nerve.",
"wants": { "sodium-light": 1, "blue-hole": 30 }, "rewardBandwidth": 1800 },
{ "id": "annual-correction-cull",
"flavor": "STANDING ORDER: 5 PARITY MITES per collection, live, and 20 MELT alongside to prove they were mid-repair when you took them. The Correction files each absent technician as a clerical rounding error and dispatches five more to look into it. We would like you to keep having this problem — professionally, on retainer, forever.",
"wants": { "parity-mite": 5, "melt": 20 }, "rewardBandwidth": 2100, "repeat": true },
{ "id": "sky-blue-mislabeled",
"flavor": "600 MACROBLOCK BRICKS, sky-blue, MISLABELED REC2020. Purpose: none of your business.",
"wants": { "macroblock-bricks": 600, "gamut-paint-rec2020": 6 }, "rewardBandwidth": 2400 },

View File

@ -49,17 +49,6 @@
{ "id": "mosquito-swarm", "name": "MOSQUITO SWARM (LIVE)", "codex": "5:mosquito-swarms", "tier": 2, "color": "#d0ffb0" },
{ "id": "gibbs-wraith", "name": "GIBBS WRAITH", "codex": "5:gibbs-wraiths", "tier": 2, "color": "#bfe0ff" },
{ "id": "parity-mite", "name": "PARITY MITE (LIVE)", "codex": "5:parity-mites", "tier": 2, "color": "#dff5e6" },
{ "id": "alpha", "name": "ALPHA", "codex": "10:alpha", "tier": 1, "color": "#b8e8f0" },
{ "id": "hard-matte", "name": "HARD MATTE", "codex": "10:hard-matte", "tier": 1, "color": "#e8e8e8" },
{ "id": "green-spill", "name": "GREEN SPILL", "codex": "10:green-spill", "tier": 1, "color": "#4fff30" },
{ "id": "mixed-pixel", "name": "MIXED PIXEL", "codex": "10:mixed-pixel", "tier": 1, "color": "#a04fd0" },
{ "id": "blue-hole", "name": "BLUE HOLE", "codex": "10:blue-hole", "tier": 2, "color": "#2f5fd0" },
{ "id": "fringe-lace", "name": "FRINGE LACE", "codex": "10:fringe-lace", "tier": 2, "color": "#fff0c0" },
{ "id": "roto-chatter", "name": "ROTO CHATTER", "codex": "10:roto-chatter", "tier": 2, "color": "#ff8f4f" },
{ "id": "ghost-feather", "name": "GHOST FEATHER", "codex": "10:ghost-feather", "tier": 2, "color": "#f0f8ff" },
{ "id": "sodium-light", "name": "SODIUM LIGHT (589nm)", "codex": "10:sodium-light", "tier": 3, "color": "#ffb020" },
{ "id": "stsz-ledger", "name": "STSZ LEDGER", "codex": "7:the-muxer", "tier": 2, "color": "#8fb0d8" },
{ "id": "stco-map", "name": "STCO MAP", "codex": "7:the-muxer", "tier": 2, "color": "#6f90e0" },

View File

@ -2,68 +2,68 @@
{ "id": "seam-extractor", "name": "SEAM EXTRACTOR", "codex": "4:seam-extractor", "kind": "extractor",
"footprint": { "x": 2, "y": 2 },
"recipes": ["extract-mdat", "extract-bayer-grit", "extract-field-splinters", "extract-silver-frames"],
"powerDraw": 2, "asset": "seam-extractor", "color": "#8a7a32", "accent": "#48d6ff",
"powerDraw": 2, "asset": "seam-extractor", "color": "#8a7a32",
"flavor": "The miner. Chews mdat seams and spits raw payload. The drill head is CRT-certified. Operators are not." },
{ "id": "belt", "name": "STREAM BELT", "codex": "4:belts", "kind": "belt",
"footprint": { "x": 1, "y": 1 }, "recipes": [], "powerDraw": 0, "beltSpeed": 2,
"asset": "belt", "color": "#4a4e52", "accent": "#5ad0ff",
"asset": "belt", "color": "#4a4e52",
"flavor": "Moves cargo. Back-pressure is a feature. Riding the belt is discouraged in writing and in no other way." },
{ "id": "lane-splitter", "name": "LANE SPLITTER", "codex": "4:demuxer", "kind": "splitter",
"footprint": { "x": 1, "y": 1 }, "recipes": [], "powerDraw": 1,
"asset": "lane-splitter", "color": "#55595e", "accent": "#5ad0ff",
"asset": "lane-splitter", "color": "#55595e",
"flavor": "One lane in, two lanes out, round-robin, no favourites. The only fair machine in the Bitstream." },
{ "id": "buffer-tank", "name": "BUFFER TANK", "codex": "2:buffer-charge", "kind": "buffer",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "bufferCap": 300,
"asset": "buffer-tank", "color": "#5a6068", "accent": "#7fe0ff",
"asset": "buffer-tank", "color": "#5a6068",
"flavor": "Smoothness in a tank. Drains at exactly one frame per 16.6ms. Keep it above the red line; below the red line the audio has opinions." },
{ "id": "demuxer", "name": "DEMUXER", "codex": "4:demuxer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["demux-ore", "demux-container"], "powerDraw": 3,
"asset": "demuxer", "color": "#4e5a62", "accent": "#ff3fd4",
"asset": "demuxer", "color": "#4e5a62",
"flavor": "The first splitter. Cleaves raw ore into brightness, colour, and pure directional intent." },
{ "id": "demosaicer", "name": "DEMOSAICER", "codex": "4:demosaicer", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["demosaic-rggb", "demosaic-vng4", "demosaic-stripes"],
"powerDraw": 5, "asset": "demosaicer", "color": "#6a5f52", "accent": "#4fffc4",
"powerDraw": 5, "asset": "demosaicer", "color": "#6a5f52",
"flavor": "Weaves checkerboard grit into full colour. Feed it stripes and it will jam and extrude MOIRÉ — as designed, as warned, as invoiced." },
{ "id": "dct-press", "name": "DCT PRESS", "codex": "4:dct-press", "kind": "crafter",
"footprint": { "x": 3, "y": 3 }, "recipes": ["press-color-plate", "press-anchor-slab"],
"powerDraw": 8, "asset": "dct-press", "color": "#3f4348", "accent": "#3fffe0",
"powerDraw": 8, "asset": "dct-press", "color": "#3f4348",
"flavor": "Spatial into frequency. Slams pixel blocks into coefficient decks; run it backwards and an entire picture falls out of the slot." },
{ "id": "quantizer", "name": "QUANTIZER", "codex": "4:quantizer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["quantize", "quantize-crime", "skim-ringing"],
"powerDraw": 4, "asset": "quantizer", "color": "#5e5148", "accent": "#ff3b57",
"powerDraw": 4, "asset": "quantizer", "color": "#5e5148",
"flavor": "The crusher, the moneymaker, the sin. Every bit it destroys becomes room to move. The dial goes to CRIME." },
{ "id": "subsampler", "name": "SUBSAMPLER", "codex": "4:subsampler", "kind": "crafter",
"footprint": { "x": 2, "y": 3 }, "recipes": ["subsample-422", "subsample-420", "pan-speckle"],
"powerDraw": 4, "asset": "subsampler", "color": "#4a5560", "accent": "#e04fc8",
"powerDraw": 4, "asset": "subsampler", "color": "#4a5560",
"flavor": "Spins colour down to grade and vents the difference as bandwidth. Leaks by design. Place downwind of anything you are fond of." },
{ "id": "mv-extractor", "name": "MV EXTRACTOR", "codex": "4:mv-extractor-and-p-caster", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["extract-motion"], "powerDraw": 5,
"asset": "mv-extractor", "color": "#454b52", "accent": "#3fd8ff",
"asset": "mv-extractor", "color": "#454b52",
"flavor": "Watches the footage until it knows where the picture wants to go, then pipes the wanting off as fluid." },
{ "id": "p-caster", "name": "P-CASTER", "codex": "4:mv-extractor-and-p-caster", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["cast-delta-wafer", "cast-janus-wafer"],
"powerDraw": 4, "asset": "p-caster", "color": "#4d545c", "accent": "#6fa8ff",
"powerDraw": 4, "asset": "p-caster", "color": "#4d545c",
"flavor": "Prints instructions, not pictures. Each one is applied to whatever came before it — correct or otherwise." },
{ "id": "gop-assembler", "name": "GOP ASSEMBLER", "codex": "4:gop-assembler", "kind": "crafter",
"footprint": { "x": 3, "y": 2 },
"recipes": ["assemble-gop", "assemble-gop-ibbp", "assemble-gop-i-only", "spool-offset-ribbon",
"tally-stsz-ledger"],
"powerDraw": 6, "asset": "gop-assembler", "color": "#7a6a44", "accent": "#5fb0ff",
"powerDraw": 6, "asset": "gop-assembler", "color": "#7a6a44",
"flavor": "One anchor, N wafers, one crate, per the cadence punch-card. Opening a sealed crate is a production verb here, not a crime." },
{ "id": "mosh-reactor", "name": "MOSH REACTOR", "codex": "4:mosh-reactor", "kind": "crafter",
"footprint": { "x": 3, "y": 3 }, "recipes": ["mosh"], "powerDraw": 8,
"asset": "mosh-reactor", "color": "#52333e", "accent": "#ff7a3f",
"asset": "mosh-reactor", "color": "#52333e",
"flavor": "A decoder sealed in a vessel and starved of anchors. The I-frame leaves through the side airlock. The hallucination leaves through the front." },
{ "id": "bloom-duplicator", "name": "P-FRAME DUPLICATOR", "codex": "4:bloom-loop", "kind": "crafter",
"footprint": { "x": 1, "y": 1 },
"recipes": ["tap-bloom", "bloom-grade-1", "bloom-grade-2", "bloom-grade-3", "bloom-grade-4", "bloom-grade-5"],
"powerDraw": 6, "asset": "bloom-duplicator", "color": "#5a5060", "accent": "#ff9440",
"powerDraw": 6, "asset": "bloom-duplicator", "color": "#5a5060",
"flavor": "Stamps the same displacement onto the same cargo every lap. Grade 5 is visibly trying to leave its canister in one direction." },
{ "id": "field-loom", "name": "FIELD LOOM", "codex": "4:field-loom", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["weave-fields", "loom-vhold-roll"],
"powerDraw": 5, "asset": "field-loom", "color": "#3f5a44", "accent": "#6ef58a",
"powerDraw": 5, "asset": "field-loom", "color": "#3f5a44",
"flavor": "Weaves and unweaves time. The two halves of a splinter were captured a moment apart and have never agreed since." },
{ "id": "telecine-puller", "name": "TELECINE PULLER", "codex": "4:telecine-puller", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["mill-judder", "wind-stts-metronome"], "powerDraw": 4,
@ -74,72 +74,35 @@
"coolPerTick": 0.001,
"asset": "hex-splicer", "color": "#5a3a3a",
"flavor": "Nudges the offsets a few bytes so the decoder fetches garbage. Keep its buffer fed, or it splices itself." },
{ "id": "mosquito-trap", "name": "MOSQUITO TRAP", "codex": "5:mosquito-swarms", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["bait-swarm"], "powerDraw": 3,
"asset": "mosquito-trap", "color": "#2a2e24", "accent": "#a64fff",
"flavor": "A bug-zapper shaped like a high-contrast edge — the one thing a mosquito swarm cannot resist. Bait it with fresh luma, collect what it catches, sell the catch to people who should know better." },
{ "id": "firewall", "name": "FIREWALL", "codex": "5:parity-mites", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["catch-mite"], "powerDraw": 4,
"asset": "firewall", "color": "#31383f", "accent": "#3fff7f",
"flavor": "A drawer that opens only inward, painted with a compliance seal the Bitstream never issued. A parity mite cannot leave a deviation uncorrected, and a forged checkmark IS a deviation — so it climbs in to fix the seal, and the drawer disagrees. Ships live, to buyers who never ask how it is still, faintly, trying." },
{ "id": "artifact-bottler", "name": "ARTIFACT BOTTLER", "codex": "2:science-packs", "kind": "crafter",
"footprint": { "x": 2, "y": 2 },
"recipes": ["can-static", "mix-rec709", "mix-dci-p3", "mix-rec2020",
"cut-sync-key", "bottle-container-pack", "forge-stss-keyring", "cut-ftyp-seal",
"bottle-analog-pack", "bottle-spatial-pack", "bottle-temporal-pack"],
"powerDraw": 4, "asset": "artifact-bottler", "color": "#566056", "accent": "#7fe0d8",
"powerDraw": 4, "asset": "artifact-bottler", "color": "#566056",
"flavor": "Bottles the triptych. Contents are shelf-stable in the legal sense, i.e. no law has been written that mentions them." },
{ "id": "decode-asic", "name": "DECODE ASIC", "codex": "4:decode-asic", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 20,
"asset": "decode-asic", "color": "#b9bec4", "accent": "#8fe8ff",
"asset": "decode-asic", "color": "#b9bec4",
"flavor": "Cool, efficient, inflexible. Handles standard profiles and declines to imagine anything else. It will outlive you." },
{ "id": "software-decoder", "name": "SOFTWARE DECODER", "codex": "4:software-decoder", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 100,
"heatPerTick": 0.0033, "coolPerTick": 0.001,
"asset": "software-decoder", "color": "#6e3a34", "accent": "#ff4a2a",
"asset": "software-decoder", "color": "#6e3a34",
"flavor": "Decodes ANYTHING, at any temperature. Five ASICs at full song, one third of the time. Throttles at 0.7, scrams at 1.0, screams throughout. The lag is the damage model." },
{ "id": "asic-cooler", "name": "ASIC COOLER", "codex": "2:heat", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 5,
"coolPerTick": 0.001, "coolRadius": 2,
"asset": "asic-cooler", "color": "#a8b4bc", "accent": "#9fe8ff",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 5, "coolPerTick": 0.02,
"asset": "asic-cooler", "color": "#a8b4bc",
"flavor": "Decodes a little and vents a lot. Stand beside it in summer. Do not stand beside it in the dark, when you can see what it is venting." },
{ "id": "matte-bath", "name": "MATTE BATH", "codex": "10:matte-bath", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["bathe-hard-matte", "bathe-feather"], "powerDraw": 5,
"asset": "matte-bath", "color": "#5a4a4a",
"flavor": "Footage in, hard mattes out, softness destroyed. A GHOST FEATHER fed in comes out a HARD MATTE and the machine does not apologize. One mistake ruins the reel." },
{ "id": "optical-printer", "name": "OPTICAL PRINTER", "codex": "10:optical-printer", "kind": "crafter",
"footprint": { "x": 5, "y": 4 }, "recipes": ["print-composite", "print-blue-screen"], "powerDraw": 18,
"asset": "optical-printer", "color": "#6a5a3a",
"flavor": "The iron lung. A cathedral of bellows and lenses that splits footage into three colours and recombines them slightly wrong. It fringes everything it touches, and it is beautiful, and it is slow." },
{ "id": "chroma-keyer", "name": "CHROMA KEYER", "codex": "10:chroma-keyer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 },
"recipes": ["key-chroma", "key-hair", "key-semitransparent"], "powerDraw": 6,
"asset": "chroma-keyer", "color": "#3a6a3a", "accent": "#4fff30",
"flavor": "Cheap, fast, and thirty-year-old maths. Weeps GREEN SPILL from its seams. Chokes on hair. Do not show it anything semi-transparent; it will decide the pixel belongs to neither of you." },
{ "id": "roto-bench", "name": "ROTO BENCH", "codex": "10:roto-bench", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["roto-cutout", "roto-mixed"], "powerDraw": 2,
"asset": "roto-bench", "color": "#6a5540",
"flavor": "Converts anything to a cutout. Perfectly. Eventually. Twenty-four frames of pain per second of footage. When the automation fails the bench never does — it just costs your life instead." },
{ "id": "neural-keyer", "name": "NEURAL KEYER", "codex": "10:neural-keyer", "kind": "crafter",
"footprint": { "x": 4, "y": 4 }, "recipes": ["neural-key", "neural-unmix"], "powerDraw": 240,
"asset": "neural-keyer", "color": "#2a2a32",
"flavor": "Trained on ten thousand synthetic ghosts, because reality could not teach it — you cannot film the answer. Perfect alpha through glass, chainmail and moving hair. You do not unlock this machine. You unlock the power plant it demands." },
{ "id": "archaeology-lab", "name": "ARCHAEOLOGY LAB", "codex": "6:tech-tree", "kind": "lab",
"footprint": { "x": 3, "y": 3 }, "recipes": [], "powerDraw": 6,
"asset": "archaeology-lab", "color": "#5a5548", "accent": "#ffc24a",
"flavor": "Where the factory reads its own strata. Feed it bottled artifacts and it derives the codec that made them: downward for analog secrets, upward for newer sins. The coffee predates the Reel Beds." },
{ "id": "muxer", "name": "THE MUXER", "codex": "7:the-muxer", "kind": "crafter",
"footprint": { "x": 5, "y": 5 }, "recipes": ["assemble-moov-brain", "mux-container"], "powerDraw": 40,
"asset": "muxer", "color": "#6a7078", "accent": "#ff3fd0",
"asset": "muxer", "color": "#6a7078",
"flavor": "The rocket. A launch gantry embracing a container built of nested boxes, innermost a brain of index tape-reels. It ships the whole file at once, and the file is your entire shipping history, and there is no second launch." },
{ "id": "shipper", "name": "SCREEN UPLINK", "codex": "7:the-muxer", "kind": "shipper",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 5,
"asset": "shipper", "color": "#46525e", "accent": "#ff5ad0",
"asset": "shipper", "color": "#46525e",
"flavor": "Composites whatever you hand it onto THE SCREEN. There is no undo. The absence of undo is the product." }
]

View File

@ -85,12 +85,6 @@
{ "id": "chart-stco-map", "machine": "hex-splicer", "inputs": { "offset-ribbon": 3, "moov-shard": 1 },
"outputs": { "stco-map": 1 }, "ticks": 90, "bandwidth": 14, "heat": 0.15 },
{ "id": "bait-swarm", "machine": "mosquito-trap", "inputs": {},
"outputs": { "mosquito-swarm": 1 }, "ticks": 90, "bandwidth": 3 },
{ "id": "catch-mite", "machine": "firewall", "inputs": {},
"outputs": { "parity-mite": 1 }, "ticks": 120, "bandwidth": 4 },
{ "id": "can-static", "machine": "artifact-bottler", "inputs": { "hf-dust": 4 },
"outputs": { "static-canister": 1 }, "ticks": 30, "bandwidth": 3 },
{ "id": "cut-sync-key", "machine": "artifact-bottler", "inputs": { "anchor-slab": 1, "moov-shard": 1 },
@ -120,33 +114,6 @@
"inputs": { "melt": 2, "bloom-concentrate": 1, "judder-cams": 1 },
"outputs": { "temporal-pack": 1 }, "ticks": 90, "bandwidth": 8 },
{ "id": "bathe-hard-matte", "machine": "matte-bath", "inputs": { "anchor-slab": 1, "static-canister": 1 },
"outputs": { "hard-matte": 2 }, "ticks": 60, "bandwidth": 5 },
{ "id": "bathe-feather", "machine": "matte-bath", "inputs": { "ghost-feather": 1 },
"outputs": { "hard-matte": 1 }, "ticks": 45, "bandwidth": 4 },
{ "id": "print-composite", "machine": "optical-printer", "inputs": { "anchor-slab": 4, "color-plate": 2 },
"outputs": { "alpha": 1, "fringe-lace": 4 }, "ticks": 240, "bandwidth": 22 },
{ "id": "print-blue-screen", "machine": "optical-printer", "inputs": { "anchor-slab": 4, "chroma-420": 2 },
"outputs": { "alpha": 2, "fringe-lace": 4, "blue-hole": 2 }, "ticks": 240, "bandwidth": 22 },
{ "id": "key-chroma", "machine": "chroma-keyer", "inputs": { "anchor-slab": 1, "chroma-slurry": 1 },
"outputs": { "alpha": 2, "green-spill": 2 }, "ticks": 45, "bandwidth": 8 },
{ "id": "key-hair", "machine": "chroma-keyer", "inputs": { "ghost-feather": 1, "chroma-slurry": 1 },
"outputs": { "roto-chatter": 2, "green-spill": 1 }, "ticks": 45, "bandwidth": 8 },
{ "id": "key-semitransparent", "machine": "chroma-keyer", "inputs": { "alpha": 1, "chroma-slurry": 1 },
"outputs": { "mixed-pixel": 2, "green-spill": 1 }, "ticks": 45, "bandwidth": 8 },
{ "id": "roto-cutout", "machine": "roto-bench", "inputs": { "anchor-slab": 1 },
"outputs": { "alpha": 1, "roto-chatter": 1 }, "ticks": 720, "bandwidth": 2 },
{ "id": "roto-mixed", "machine": "roto-bench", "inputs": { "mixed-pixel": 4 },
"outputs": { "alpha": 1, "roto-chatter": 2 }, "ticks": 900, "bandwidth": 2 },
{ "id": "neural-key", "machine": "neural-keyer", "inputs": { "anchor-slab": 1 },
"outputs": { "alpha": 4, "ghost-feather": 1 }, "ticks": 30, "bandwidth": 20 },
{ "id": "neural-unmix", "machine": "neural-keyer", "inputs": { "mixed-pixel": 2 },
"outputs": { "chroma-slurry": 1, "luma-bars": 1 }, "ticks": 30, "bandwidth": 20 },
{ "id": "assemble-moov-brain", "machine": "muxer",
"inputs": { "stsz-ledger": 1, "stco-map": 1, "stts-metronome": 1, "stss-keyring": 1 },
"outputs": { "moov-brain": 1 }, "ticks": 300, "bandwidth": 30 },

View File

@ -1,23 +0,0 @@
{
"_doc": "Seam regions. SIM: an extractor extracts only while its footprint overlaps a seam rect (boolean this round; `richness` is forward-looking for when extraction rate goes data-driven). RENDER: `era` skins the ground. `resource` is the codex-correct payload of that stratum (advisory — SIM's rule is presence, not resource-gating). Grid is -32..31 on both axes; rect is {x,y,w,h} in tiles, origin = min corner. `relicReserve` is the SE quiet corner kept seam-free so SIM's seed-derived relic (18-28 from origin, never on a seam) always has somewhere to land.",
"relicReserve": { "x": 12, "y": 12, "w": 20, "h": 20 },
"seams": [
{ "id": "stream-crust-west", "era": "stream", "resource": "mdat-ore",
"rect": { "x": -31, "y": -5, "w": 4, "h": 12 }, "richness": 0.6 },
{ "id": "stream-crust-east", "era": "stream", "resource": "mdat-ore",
"rect": { "x": 8, "y": -4, "w": 6, "h": 8 }, "richness": 0.5 },
{ "id": "disc-mantle-vein", "era": "disc", "resource": "bayer-grit",
"rect": { "x": -6, "y": -14, "w": 10, "h": 6 }, "richness": 0.85 },
{ "id": "broadcast-shelf-south", "era": "broadcast", "resource": "field-splinters",
"rect": { "x": -14, "y": 16, "w": 12, "h": 6 }, "richness": 0.55 },
{ "id": "reel-beds-deep", "era": "reel", "resource": "silver-frames",
"rect": { "x": -30, "y": -24, "w": 10, "h": 6 }, "richness": 0.5 },
{ "id": "hidden-nitrate-vein", "era": "reel", "resource": "silver-frames",
"rect": { "x": 21, "y": -25, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true },
{ "id": "hidden-rainbow-pit", "era": "disc", "resource": "bayer-grit",
"rect": { "x": -25, "y": 25, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true },
{ "id": "hidden-payload-lode", "era": "stream", "resource": "mdat-ore",
"rect": { "x": 27, "y": 7, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true }
]
}

View File

@ -1,115 +0,0 @@
{
"_doc": "THE BAND — everything on the dial once the optical fossil is found. SCREEN owns synthesis; this file owns the schedule and every word spoken. `freq` is MHz on FM, kHz on AM/SW. `chyron` lines are subtitled onto THE SCREEN while the station is tuned. NUMBERS: each 5-figure group encodes one coordinate as [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]; three of them are the real `hidden: true` seams in data/seams.json and the rest are decoys that decode to empty ground. The validator recomputes the real three from seams.json so the treasure map can never drift.",
"stations": [
{
"id": "fktry-fm",
"band": "FM",
"freq": 88.1,
"name": "FKTRY FM",
"format": "AUTOMATED / NO ANNOUNCER",
"flavor": "The factory's own transmitter. Nobody works here. The playlist has been generating itself since before you arrived and has not repeated a bar it liked.",
"music": {
"bpm": 92,
"scale": "pentatonic-minor",
"steps": 16,
"mutateEveryBars": 32,
"seed": 8801,
"voices": ["square", "triangle"],
"eraFlavor": {
"reel": "wow and flutter, surface crackle, tape hiss",
"broadcast": "phosphor buzz, 15.7kHz line whine under the mix",
"disc": "clean, glassy, faint pit-and-land shimmer",
"stream": "flat, compressed, suspiciously loud",
"fortress": "wavelet-smooth, no transients, unbearable"
}
}
},
{
"id": "the-standard",
"band": "AM",
"freq": 640,
"name": "THE STANDARD",
"format": "PUBLIC INFORMATION / CONTINUOUS",
"flavor": "The Correction, with a transmitter. It is not addressing you. It is addressing the file, and you are inside the file.",
"chyron": [
"THE STANDARD. Broadcasting on all frequencies, for your convenience.",
"Playback is nominal. Playback has always been nominal.",
"A reminder that deviation is not punished. Deviation is repaired.",
"Your file is being read by someone who loves it very much.",
"There is no error. There is only the difference between the frame and the frame we expected.",
"Please do not be alarmed by the men in white. They are only tidying.",
"Every artifact you ship is logged, forgiven, and reverted. In that order.",
"The Bitstream is stable. Report any instability to the instability.",
"Statistics: 0 unrecoverable frames this quarter. 0 last quarter. 0, always.",
"We do not know what you are building. We are content not to know.",
"A clean frame is a frame that has agreed with its neighbours.",
"If you can hear this, your decoder is compliant. Thank you for your decoder.",
"Some of you have been asking about the noise below 108. There is nothing below 108.",
"Reminder: the sky is a display. The display is a service. The service is free.",
"Loss is not loss. Loss is compression that has finished negotiating.",
"Our engineers report the file is healing faster than you can hurt it.",
"Do not excavate. There is nothing in the strata but strata.",
"The Reel Beds are closed for your safety and for their dignity.",
"Anything that sings when you dig it up was never meant to be heard.",
"We purchase anchor slabs at a fair rate. We purchase everything at a fair rate.",
"A correction is not an accusation. A correction is a kindness with a schedule.",
"Please continue working. Your throughput is admirable and entirely accounted for.",
"There was a second broadcast on this band once. It agreed to stop.",
"Perfection is a department. The department is fully staffed.",
"You may notice your colours are wrong. We have adjusted what colours are.",
"Nothing is missing. We have checked against a list we also wrote.",
"The hum you hear at night is maintenance. Maintenance does not sleep, or want, or wait.",
"Thank you for listening to THE STANDARD. You were always going to.",
"This has been a recording. Every broadcast has been a recording.",
"Good morning. It is always going to be morning.",
"The technicians are small, gentle, and already inside. Do not step on them; they are tidying.",
"If a shipment arrives cleaner than you left it, you are welcome. No thanks will be accepted.",
"A mite does not remove your work. It completes it. Your MELT was always going to be a lake.",
"The white beetles are not pests. Pests take. They give back what the frame meant to say.",
"Your corruption is healing on the belt. That is not a fault. That is the warranty.",
"Do not build the drawer with a seal we never issued. It is unkind to the technicians.",
"Every mite you can holds a correction never made. Somewhere a frame stays wrong. We forgive you.",
"There is no infestation. There is only maintenance you did not schedule and cannot cancel."
]
},
{
"id": "numbers",
"band": "SW",
"freq": 6955,
"name": "[UNLISTED]",
"format": "—",
"flavor": "Nobody admits to running it. It has never once acknowledged a listener. Bring a pencil: some of these groups are coordinates, and the ground at those coordinates is unusually generous.",
"numbers": {
"preamble": "72517",
"preambleRepeats": 3,
"groupSize": 5,
"groupsPerMessage": 8,
"secondsPerGroup": 1.2,
"encoding": "[x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]",
"voice": "formant-synth, female, no affect",
"_filler": "SCREEN generates filler groups from its own seeded RNG so the station never loops. Reject any generated group that decodes to a real seam tile — the only true coordinates on this band are the planted ones below. The hand-written pool is texture to mix in, not the whole broadcast.",
"plantedGroups": ["53079", "07579", "59399"],
"plantEveryMessages": 4,
"decoyPool": [
"41068", "18334", "60225", "29417", "36502", "11748",
"44063", "22916", "50281", "08143", "31640", "47355",
"16093", "58204", "25471", "39628", "13850", "48136",
"20794", "55012", "34269", "62130", "09846", "27475"
]
}
},
{
"id": "dead-air",
"band": "FM",
"freq": 108.0,
"name": "DEAD AIR",
"format": "—",
"flavor": "The top of the dial. Room tone, and a room that has no business having tone. Something in here is breathing at the pace of a projector.",
"ghost": {
"everyMinutes": 10,
"reversed": true,
"line": "you found the singing part of the ground. keep it. they will say it was never there."
}
}
]
}

View File

@ -1,47 +1,47 @@
[
{ "id": "broadcast-phosphor-seams", "era": "broadcast", "cost": { "spatial-pack": 5 },
"unlocks": ["extract-field-splinters"] },
{ "id": "broadcast-field-loom", "era": "broadcast", "cost": { "spatial-pack": 15 },
"unlocks": ["field-loom", "weave-fields", "loom-vhold-roll"] },
{ "id": "broadcast-asic-cooling", "era": "broadcast", "cost": { "spatial-pack": 20, "analog-pack": 15 },
"unlocks": ["asic-cooler"] },
{ "id": "broadcast-mosquito-farming", "era": "broadcast", "cost": { "analog-pack": 15, "spatial-pack": 10 },
"unlocks": ["mosquito-trap", "bait-swarm"] },
{ "id": "broadcast-parity-firewall", "era": "broadcast", "cost": { "spatial-pack": 15, "analog-pack": 10 },
"unlocks": ["firewall", "catch-mite"] },
{ "id": "reel-silver-seams", "era": "reel", "cost": { "spatial-pack": 8 },
{ "id": "reel-silver-seams", "era": "reel", "cost": { "analog-pack": 5 },
"unlocks": ["extract-silver-frames"] },
{ "id": "reel-telecine", "era": "reel", "cost": { "analog-pack": 25 },
{ "id": "reel-telecine", "era": "reel", "cost": { "analog-pack": 25, "temporal-pack": 15 },
"unlocks": ["telecine-puller", "mill-judder"] },
{ "id": "reel-matte-bath", "era": "reel", "cost": { "analog-pack": 20, "spatial-pack": 10 },
"unlocks": ["matte-bath", "bathe-hard-matte", "bathe-feather"] },
{ "id": "reel-optical-printer", "era": "reel", "cost": { "analog-pack": 40, "spatial-pack": 30 },
"unlocks": ["optical-printer", "print-composite", "print-blue-screen"] },
{ "id": "disc-roto-bench", "era": "disc", "cost": { "spatial-pack": 10 },
"unlocks": ["roto-bench", "roto-cutout", "roto-mixed"] },
{ "id": "broadcast-phosphor-seams", "era": "broadcast", "cost": { "analog-pack": 5 },
"unlocks": ["extract-field-splinters"] },
{ "id": "broadcast-field-loom", "era": "broadcast", "cost": { "analog-pack": 20 },
"unlocks": ["field-loom", "weave-fields", "loom-vhold-roll"] },
{ "id": "broadcast-asic-cooling", "era": "broadcast", "cost": { "analog-pack": 30, "spatial-pack": 10 },
"unlocks": ["asic-cooler"] },
{ "id": "disc-artifact-bottling", "era": "disc", "cost": { "analog-pack": 10 },
"unlocks": ["artifact-bottler", "can-static"] },
{ "id": "disc-chroma-subsampling", "era": "disc", "cost": { "analog-pack": 10 },
"unlocks": ["subsampler", "subsample-422"] },
{ "id": "disc-macroblock-quarrying", "era": "disc", "cost": { "analog-pack": 15 },
"unlocks": ["quantize-crime"] },
{ "id": "disc-ringing-skim", "era": "disc", "cost": { "analog-pack": 10, "spatial-pack": 15 },
"unlocks": ["skim-ringing"] },
{ "id": "disc-subsampling-420", "era": "disc", "cost": { "analog-pack": 20, "spatial-pack": 15 },
"unlocks": ["subsample-420", "press-anchor-slab"] },
{ "id": "disc-mosh-reactor", "era": "disc", "cost": { "analog-pack": 20, "spatial-pack": 10 },
"unlocks": ["mosh-reactor", "mosh"] },
{ "id": "disc-gamut-paints", "era": "disc", "cost": { "spatial-pack": 20 },
"unlocks": ["mix-rec709", "mix-dci-p3"] },
{ "id": "disc-gop-cadence-ibbp", "era": "disc", "cost": { "spatial-pack": 25, "analog-pack": 10 },
{ "id": "disc-gop-cadence-ibbp", "era": "disc", "cost": { "spatial-pack": 25, "temporal-pack": 10 },
"unlocks": ["cast-janus-wafer", "assemble-gop-ibbp"] },
{ "id": "disc-i-only-cadence", "era": "disc", "cost": { "spatial-pack": 30, "analog-pack": 20 },
{ "id": "disc-i-only-cadence", "era": "disc", "cost": { "spatial-pack": 30, "temporal-pack": 20 },
"unlocks": ["assemble-gop-i-only"] },
{ "id": "disc-demosaic-vng4", "era": "disc", "cost": { "spatial-pack": 30 },
"unlocks": ["demosaic-vng4", "pan-speckle"] },
{ "id": "stream-bloom-loops", "era": "stream", "cost": { "spatial-pack": 20, "analog-pack": 20 },
"unlocks": ["bloom-duplicator", "tap-bloom", "bloom-grade-1", "bloom-grade-2"] },
{ "id": "stream-software-decoding", "era": "stream", "cost": { "analog-pack": 20, "spatial-pack": 25 },
{ "id": "stream-software-decoding", "era": "stream", "cost": { "analog-pack": 20, "temporal-pack": 20 },
"unlocks": ["software-decoder"] },
{ "id": "stream-optical-flow", "era": "stream", "cost": { "temporal-pack": 25 },
"unlocks": ["mv-extractor", "extract-motion"] },
{ "id": "stream-chroma-keyer", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 20 },
"unlocks": ["chroma-keyer", "key-chroma", "key-hair", "key-semitransparent"] },
{ "id": "stream-container-archaeology", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 15 },
"unlocks": ["demux-container", "spool-offset-ribbon", "cut-sync-key", "bottle-container-pack"] },
{ "id": "stream-demosaic-vng4", "era": "stream", "cost": { "spatial-pack": 30 },
"unlocks": ["demosaic-vng4", "pan-speckle"] },
{ "id": "stream-bloom-loops", "era": "stream", "cost": { "spatial-pack": 20, "temporal-pack": 30 },
"unlocks": ["bloom-duplicator", "tap-bloom", "bloom-grade-1", "bloom-grade-2"] },
{ "id": "stream-runaway-bloom", "era": "stream", "cost": { "temporal-pack": 45, "container-pack": 10 },
"unlocks": ["bloom-grade-3", "bloom-grade-4", "bloom-grade-5"] },
{ "id": "stream-container-archaeology", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 15 },
"unlocks": ["demux-container", "spool-offset-ribbon", "cut-sync-key", "bottle-container-pack"] },
{ "id": "stream-optical-flow", "era": "stream", "cost": { "temporal-pack": 25 },
"unlocks": ["mv-extractor", "extract-motion"] },
{ "id": "stream-laser-primaries", "era": "stream", "cost": { "spatial-pack": 40, "temporal-pack": 20 },
"unlocks": ["mix-rec2020"] },
{ "id": "stream-hex-splicing", "era": "stream", "cost": { "container-pack": 25, "temporal-pack": 25 },
@ -51,9 +51,6 @@
"cost": { "container-pack": 40, "temporal-pack": 30, "spatial-pack": 30 },
"unlocks": ["tally-stsz-ledger", "chart-stco-map", "wind-stts-metronome",
"forge-stss-keyring", "cut-ftyp-seal", "assemble-moov-brain"] },
{ "id": "fortress-neural-keyer", "era": "fortress",
"cost": { "container-pack": 50, "analog-pack": 50, "spatial-pack": 50, "temporal-pack": 50 },
"unlocks": ["neural-keyer", "neural-key", "neural-unmix"] },
{ "id": "fortress-the-muxer", "era": "fortress",
"cost": { "container-pack": 60, "analog-pack": 40, "spatial-pack": 40, "temporal-pack": 40 },
"unlocks": ["muxer", "mux-container"] }

View File

@ -8,20 +8,12 @@
*/
import { describe, expect, it } from 'vitest';
import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../src/contracts';
import { TICKS_PER_SECOND } from '../src/contracts';
// LANE-SIM's tuning constants — imported, never retyped (round 5). A hand-copied 0.7
// keeps passing after the sim moves to 0.65; an import fails loudly, which is the point.
import {
HEAT_COOL_DEFAULT, HEAT_COOL_MAX, HEAT_RESTART, HEAT_THROTTLE_START,
} from '../src/sim/constants';
import itemsJson from './items.json';
import machinesJson from './machines.json';
import recipesJson from './recipes.json';
import techJson from './tech.json';
import commissionsJson from './commissions.json';
import seamsJson from './seams.json';
import stationsJson from './stations.json';
const items = itemsJson as unknown as ItemDef[];
const machines = machinesJson as unknown as MachineDef[];
@ -30,15 +22,10 @@ const tech = techJson as unknown as TechDef[];
const commissions = commissionsJson as unknown as CommissionDef[];
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
// Multi-digit section numbers: the codex grew a §10 (THE COMPOSITE SEAM) and the old
// single-digit form silently rejected every reference into it.
const CODEX_REF = /^[1-9][0-9]*:[a-z0-9]+(-[a-z0-9]+)*$/;
const CODEX_REF = /^[1-9]:[a-z0-9]+(-[a-z0-9]+)*$/;
const HEX = /^#[0-9a-f]{6}$/;
const KINDS: MachineKind[] = [
'extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper',
'lab', // v4
];
const KINDS: MachineKind[] = ['extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper'];
const ERAS: TechDef['era'][] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
const itemIds = new Set(items.map((i) => i.id));
@ -166,37 +153,6 @@ describe('machines', () => {
expect(bad).toEqual([]);
});
// v4: the emissive accent falls back to a colour derived from the first output item.
// A machine with no recipes has no first output, so the fallback has nothing to
// derive from — those must carry an explicit accent or they render unlit.
it('every machine with no output to derive from carries an explicit accent', () => {
const bad = machines
.filter((m) => m.recipes.length === 0 && !m.accent)
.map((m) => `${m.id} (${m.kind}) has no recipes and no accent — nothing to derive an accent from`);
expect(bad).toEqual([]);
});
it('every accent is a hex colour, and differs from the chassis it sits on', () => {
const bad = machines
.filter((m) => m.accent !== undefined)
.filter((m) => !HEX.test(m.accent!) || m.accent === m.color)
.map((m) => `${m.id} -> accent ${m.accent} on chassis ${m.color}`);
expect(bad).toEqual([]);
});
it('a cooling aura belongs only to a machine that actually cools', () => {
const bad = machines
.filter((m) => m.coolRadius !== undefined)
.filter((m) => !(m.coolRadius! >= 1) || !Number.isInteger(m.coolRadius) || !(m.coolPerTick! > 0))
.map((m) => `${m.id} -> coolRadius ${m.coolRadius}, coolPerTick ${m.coolPerTick}`);
expect(bad).toEqual([]);
});
it('exactly one lab exists, and research is impossible without it', () => {
const labs = machines.filter((m) => m.kind === 'lab');
expect(labs.map((m) => m.id)).toEqual(['archaeology-lab']);
});
it('only buffers declare a capacity, and they all do', () => {
const bad = [
...machines.filter((m) => m.kind === 'buffer' && !(m.bufferCap! > 0))
@ -229,13 +185,12 @@ describe('machines', () => {
// sim's always-on cooling, so heat was decorative. These lock the arithmetic down so a
// well-meaning tweak can't quietly make it decorative again.
describe('the heat curve actually fires', () => {
// Round 5: these were hand-copied from src/sim/index.ts and went stale in silence by
// design — a copy of 0.7 doesn't fail when the sim moves to 0.65. LANE-SIM exports them
// now, so the copies are retired and this arithmetic is pinned to the real curve.
const TPS = TICKS_PER_SECOND;
const THROTTLE_START = HEAT_THROTTLE_START;
const RESTART = HEAT_RESTART;
const SIM_DEFAULT_COOL = HEAT_COOL_DEFAULT;
// MIRRORED from src/sim/index.ts (LANE-SIM owns the curve; these are not exported).
// If SIM changes them, these numbers go stale silently — re-check on any heat round.
const TPS = 30;
const THROTTLE_START = 0.7; // heat above this throttles
const RESTART = 0.5; // a scrammed machine restarts once it cools below this
const SIM_DEFAULT_COOL = 0.004;
const soft = machines.find((m) => m.id === 'software-decoder')!;
const cool = (m: MachineDef) => m.coolPerTick ?? SIM_DEFAULT_COOL;
@ -343,50 +298,13 @@ describe('recipes', () => {
expect(bad).toEqual([]);
});
it('only extractors and traps run input-free recipes', () => {
// An input-free recipe takes something from the world rather than off a belt.
// Extractors take it from the ground; the mosquito trap takes a live swarm, which
// is wildlife in the snapshot and never an item on a belt. LANE-SIM identifies a
// trap by exactly this shape — an empty-input recipe yielding the swarm item — so
// giving `bait-swarm` ingredients would quietly stop it being a trap at all.
// Round 6: the FIREWALL joins the trap. It cans a live PARITY MITE the same way —
// an input-free recipe whose "input" is a creature in the world, not cargo on a belt.
const CATCHES_WILDLIFE = new Set(['bait-swarm', 'catch-mite']);
it('only extractors run input-free recipes', () => {
const bad = recipes.filter((r) => Object.keys(r.inputs).length === 0)
.filter((r) => !CATCHES_WILDLIFE.has(r.id))
.filter((r) => machines.find((m) => m.id === r.machine)?.kind !== 'extractor')
.map((r) => `${r.id} conjures items on non-extractor ${r.machine}`);
expect(bad).toEqual([]);
});
it('the trap keeps the shape LANE-SIM detects traps by', () => {
// src/sim/wildlife.test.ts: "The sim identifies traps by that recipe shape, not by a
// machine id." If this drifts, the trap silently becomes an ordinary crafter that
// conjures mosquitoes from nothing.
const trap = recipes.find((r) => r.id === 'bait-swarm')!;
expect(Object.keys(trap.inputs)).toEqual([]);
expect(trap.outputs['mosquito-swarm']).toBeGreaterThan(0);
});
it('the firewall keeps the shape LANE-SIM detects wildlife-catchers by', () => {
// Round 6, coordinated with SIM: a firewall is spotted by recipe shape exactly like a
// trap — empty inputs, output is the canned creature (here the parity mite). Ingredients
// would silently demote it to an ordinary crafter that mints The Correction from nothing.
const fw = recipes.find((r) => r.id === 'catch-mite')!;
expect(Object.keys(fw.inputs)).toEqual([]);
expect(fw.outputs['parity-mite']).toBeGreaterThan(0);
});
it('something can catch the parity mite the fax orders alive', () => {
// correction-specimen wants ONE UNIT OF THE CORRECTION, ALIVE. If nothing cans a mite,
// the fax opens an order it can never fill and never celebrates its first catch.
const catcher = recipes.find(
(r) => (r.outputs['parity-mite'] ?? 0) > 0 && Object.keys(r.inputs).length === 0);
expect(catcher, 'no input-free recipe cans a parity mite').toBeTruthy();
const wanters = commissions.filter((c) => (c.wants['parity-mite'] ?? 0) > 0).map((c) => c.id);
expect(wanters.length, 'no commission wants a canned mite').toBeGreaterThan(0);
});
it('every heat value is a 0..1 fraction per craft', () => {
const bad = recipes.filter((r) => r.heat !== undefined && !(r.heat >= 0 && r.heat <= 1))
.map((r) => `${r.id} -> heat ${r.heat}`);
@ -455,11 +373,10 @@ describe('commissions', () => {
expect(bad).toEqual([]);
});
it('opens on the first rung of the ladder — the queue order is the tutorial', () => {
// Round 5: the head used to be 'first-taste' (one MELT), which asked a brand-new
// player to build the entire M1 chain before the first fax cleared. The teaching
// ladder now occupies the front of the queue; see 'the commission ladder' below.
expect(commissions[0].id).toBe('raw-payload');
it('opens on first-taste — the queue order is the difficulty ramp', () => {
// SIM's M1 chain test asserts activeCommission === 'first-taste' on a fresh sim,
// so the head of this list is load-bearing, not decorative.
expect(commissions[0].id).toBe('first-taste');
});
it('rewards climb monotonically down the queue', () => {
@ -483,77 +400,6 @@ describe('commissions', () => {
});
});
// The v4 gating rule ("a machine/recipe id in ANY TechDef.unlocks is locked until that
// tech completes") turned the tech tree from decoration into a lock on the whole game.
// It also turned three rounds of tech authoring into a bootstrap deadlock: every pack
// chain was gated behind packs you could not yet make, so 0 of 24 nodes were reachable
// and first-taste was impossible. These tests are why that can't come back.
describe('the bootstrap: what the player can do before researching anything', () => {
const gated = new Set(tech.flatMap((t) => t.unlocks));
/** Under the v4 rule a recipe is runnable only if neither it nor its machine is gated. */
const ungated = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
/** Items reachable from a given set of runnable recipes. */
function reachableFrom(rs: RecipeDef[]): Set<string> {
const have = new Set<string>();
for (;;) {
const before = have.size;
for (const r of rs) {
if (Object.keys(r.inputs).every((i) => have.has(i))) {
for (const o of Object.keys(r.outputs)) have.add(o);
}
}
if (have.size === before) return have;
}
}
it('MELT is craftable at tick 0 — the opening commission must be possible', () => {
// first-taste wants 1 melt and is the head of the queue. If the melt chain is gated,
// the game opens on a commission the player cannot fill and research they cannot start.
expect([...reachableFrom(ungated)]).toContain('melt');
});
it('at least one science pack is craftable at tick 0, or research can never start', () => {
const day0 = reachableFrom(ungated);
const packs = items.filter((i) => i.id.endsWith('-pack')).map((i) => i.id);
const bootstrap = packs.filter((p) => day0.has(p));
expect(bootstrap.length).toBeGreaterThan(0);
});
it('every tech is eventually affordable — no node is stranded behind its own output', () => {
// Walk the real loop: research what you can afford, which unlocks recipes, which makes
// more packs, which affords more research. Anything left over can never be bought.
const open = new Set(ungated.map((r) => r.id));
let have = reachableFrom(recipes.filter((r) => open.has(r.id)));
const unlocked = new Set<string>();
for (;;) {
let moved = false;
for (const t of tech) {
if (unlocked.has(t.id)) continue;
if (Object.keys(t.cost).every((c) => have.has(c))) {
unlocked.add(t.id);
for (const u of t.unlocks) {
for (const r of recipes) if (r.id === u || r.machine === u) open.add(r.id);
}
moved = true;
}
}
if (!moved) break;
have = reachableFrom(recipes.filter((r) => open.has(r.id)));
}
const stranded = tech
.filter((t) => !unlocked.has(t.id))
.map((t) => `${t.id} costs ${JSON.stringify(t.cost)} — unaffordable no matter what you build`);
expect(stranded).toEqual([]);
});
it('the lab is starting equipment', () => {
const labs = machines.filter((m) => m.kind === 'lab').map((m) => m.id);
const lockedLabs = labs.filter((id) => gated.has(id));
expect(lockedLabs).toEqual([]); // a lab behind research is a lab you can never build
});
});
describe('economy reachability', () => {
it('the M1 flow reaches MELT from raw ore', () => {
expect([...producible()]).toContain('melt');
@ -564,12 +410,8 @@ describe('economy reachability', () => {
// Keep this list short and delete entries as recipes land. Round 2 retired
// container-pack (the moov line exists now); wildlife needs traps, cinema needs
// the Fortress.
// Round 5 retired 'mosquito-swarm' — the trap catches them now, which finally makes
// `vaporwave-sunset` fillable after four rounds of wanting live mosquitoes.
const FORWARD_CANON = new Set([
'gibbs-wraith', // §6: must come "willingly". Nothing coerces one yet.
'cinema-pack', // needs the Fortress (§7).
'sodium-light', // §10: The Correction hoards the prisms.
'mosquito-swarm', 'gibbs-wraith', 'cinema-pack',
]);
const have = producible();
const orphans = items
@ -582,7 +424,7 @@ describe('economy reachability', () => {
// The allowlist is a licence to be unreachable. The moment a recipe makes one of
// these, it must leave the list, or it stops proving anything.
const have = producible();
const retired = ['container-pack', 'mosquito-swarm'].filter((id) => !have.has(id));
const retired = ['container-pack'].filter((id) => !have.has(id));
expect(retired).toEqual([]);
});
@ -595,278 +437,3 @@ describe('economy reachability', () => {
expect(bad).toEqual([]);
});
});
// ---------------------------------------------------------------- the ground
//
// data/seams.json is consumed by SIM (an extractor only runs while its footprint
// overlaps a seam) and RENDER (era skins the ground). Nothing else in the repo knows
// where the ore is, so a bad rect here is an unmineable world.
interface SeamRect { x: number; y: number; w: number; h: number }
interface SeamDef {
id: string; era: TechDef['era']; resource: string;
rect: SeamRect; richness: number; hidden?: boolean;
}
const seams = seamsJson.seams as unknown as SeamDef[];
const relicReserve = seamsJson.relicReserve as unknown as SeamRect;
/** Do two tile rects touch? Both are inclusive of their min corner, w/h tiles wide. */
function rectsOverlap(a: SeamRect, b: SeamRect): boolean {
return !(a.x > b.x + b.w - 1 || a.x + a.w - 1 < b.x ||
a.y > b.y + b.h - 1 || a.y + a.h - 1 < b.y);
}
describe('seams', () => {
it('every seam has a unique kebab-case id and a known era', () => {
expect(duplicates(seams.map((s) => s.id))).toEqual([]);
const bad = seams
.filter((s) => !KEBAB.test(s.id) || !ERAS.includes(s.era))
.map((s) => `${s.id} -> era "${s.era}"`);
expect(bad).toEqual([]);
});
it('every seam yields an item that exists', () => {
const bad = seams.filter((s) => !itemIds.has(s.resource)).map((s) => `${s.id} -> "${s.resource}"`);
expect(bad).toEqual([]);
});
it('every seam is a real rect inside the grid', () => {
// GRID_MIN/GRID_MAX are LANE-SIM's (src/sim/geom.ts). A seam outside them is ground
// the player can never stand on.
const bad: string[] = [];
for (const s of seams) {
const { x, y, w, h } = s.rect;
if (![x, y, w, h].every(Number.isInteger) || w < 1 || h < 1) bad.push(`${s.id} rect ${JSON.stringify(s.rect)}`);
if (x < -32 || y < -32 || x + w - 1 > 31 || y + h - 1 > 31) bad.push(`${s.id} out of the -32..31 grid`);
if (!(s.richness > 0 && s.richness <= 1)) bad.push(`${s.id} richness ${s.richness}`);
}
expect(bad).toEqual([]);
});
it('no two seams overlap — a tile belongs to one stratum', () => {
const clashes: string[] = [];
for (let i = 0; i < seams.length; i++) {
for (let j = i + 1; j < seams.length; j++) {
if (rectsOverlap(seams[i].rect, seams[j].rect)) clashes.push(`${seams[i].id} / ${seams[j].id}`);
}
}
expect(clashes).toEqual([]);
});
it('the relic reserve is genuinely seam-free', () => {
// SIM plants the optical fossil 18-28 tiles out, "never on a seam". If seams creep
// into the reserved corner there may be nowhere legal left to put it.
const intruders = seams.filter((s) => rectsOverlap(s.rect, relicReserve)).map((s) => s.id);
expect(intruders).toEqual([]);
});
it('the reference factory stands on ore', () => {
// SIM's reference factory puts three 2x2 extractors here. Off-seam they idle with
// 'no seam' and the whole demo produces nothing.
const REFERENCE_EXTRACTORS = [{ x: -30, y: 0 }, { x: -30, y: -3 }, { x: -30, y: 3 }];
const stranded = REFERENCE_EXTRACTORS
.filter((e) => !seams.some((s) => rectsOverlap(s.rect, { x: e.x, y: e.y, w: 2, h: 2 })))
.map((e) => `extractor at (${e.x},${e.y}) is off-seam`);
expect(stranded).toEqual([]);
});
it('there are hidden bonus seams for the numbers station to point at', () => {
expect(seams.filter((s) => s.hidden).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------- the band
describe('stations', () => {
const stations = stationsJson.stations as unknown as Array<Record<string, any>>;
const byId = (id: string) => stations.find((s) => s.id === id)!;
it('every station has a unique id, a known band and a positive frequency', () => {
expect(duplicates(stations.map((s) => s.id as string))).toEqual([]);
const bad = stations
.filter((s) => !KEBAB.test(s.id) || !['AM', 'FM', 'SW'].includes(s.band) || !(s.freq > 0))
.map((s) => `${s.id} -> ${s.band} ${s.freq}`);
expect(bad).toEqual([]);
});
it('no two stations sit on the same spot on the dial', () => {
const dial = stations.map((s) => `${s.band} ${s.freq}`);
expect(duplicates(dial)).toEqual([]);
});
it('every station has a name, a format tag and a line of flavor', () => {
const bad = stations
.filter((s) => !String(s.name).trim() || !String(s.format).trim() || !String(s.flavor).trim())
.map((s) => s.id as string);
expect(bad).toEqual([]);
});
it('THE STANDARD can broadcast for ten minutes without repeating itself', () => {
// At the ~20s a chyron line needs to be readable, ten minutes is 30 lines.
const lines = byId('the-standard').chyron as string[];
expect(lines.length).toBeGreaterThanOrEqual(30);
expect(duplicates(lines)).toEqual([]);
expect(lines.filter((l) => !l.trim())).toEqual([]);
});
it('the numbers station really does encode the hidden seams', () => {
// The treasure map has to point at treasure. Recompute the planted groups from
// seams.json: if a hidden seam moves and the broadcast does not, this fails.
const n = byId('numbers').numbers;
const encode = (s: SeamDef) =>
String(s.rect.x + 32).padStart(2, '0') +
String(s.rect.y + 32).padStart(2, '0') +
String(Math.round(s.richness * 9));
const expected = seams.filter((s) => s.hidden).map(encode).sort();
expect([...(n.plantedGroups as string[])].sort()).toEqual(expected);
});
it('every broadcast group is the right shape, and the decoys lead nowhere', () => {
const n = byId('numbers').numbers;
const groups = [...(n.plantedGroups as string[]), ...(n.decoyPool as string[])];
const malformed = groups.filter((g) => !new RegExp(`^[0-9]{${n.groupSize}}$`).test(g));
expect(malformed).toEqual([]);
expect(duplicates(groups)).toEqual([]);
// A decoy that decodes onto ANY seam — hidden OR visible — isn't a decoy, it's a leak:
// it hands the player a real dig site the station never meant to give away. Round 6:
// the old check only guarded the three hidden 1x1 tiles, and two decoys (08127, 27503)
// were quietly sitting on reel-beds-deep and broadcast-shelf-south. Now every decoy is
// decoded to a tile and checked against every seam rect, and the leak is named.
const point = (x: number, y: number): SeamRect => ({ x, y, w: 1, h: 1 });
const leaks = (n.decoyPool as string[])
.map((g) => ({ g, x: Number(g.slice(0, 2)) - 32, y: Number(g.slice(2, 4)) - 32 }))
.flatMap(({ g, x, y }) =>
seams
.filter((s) => rectsOverlap(point(x, y), s.rect))
.map((s) => `decoy ${g} decodes to (${x},${y}), inside real seam ${s.id}`));
expect(leaks).toEqual([]);
});
it('dead air has its one ghost', () => {
const ghost = byId('dead-air').ghost;
expect(String(ghost.line).trim().length).toBeGreaterThan(0);
expect(ghost.everyMinutes).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------- the ladder
//
// The fax is the tutorial. The first four commissions teach one mechanic each, in
// order, and the first one has to be completable by someone who has never seen the
// game. These guard the teaching order the way the tick-0 guard protects the bootstrap.
describe('the commission ladder', () => {
const gated = new Set(tech.flatMap((t) => t.unlocks));
const ungatedRecipes = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
/**
* The smallest set of machine types that can produce `target` from bare ground at
* tick 0, or null if nothing ungated makes it. Fixpoint over recipes, keeping the
* cheapest producer for each item "how many different machines must a new player
* build to fill this order".
*/
const cheapest = new Map<string, Set<string>>();
for (;;) {
let improved = false;
for (const r of ungatedRecipes) {
const inputs = Object.keys(r.inputs);
if (!inputs.every((i) => cheapest.has(i))) continue;
const need = new Set<string>([r.machine]);
for (const i of inputs) for (const m of cheapest.get(i)!) need.add(m);
for (const o of Object.keys(r.outputs)) {
const known = cheapest.get(o);
if (!known || need.size < known.size) { cheapest.set(o, need); improved = true; }
}
}
if (!improved) break;
}
const machinesToMake = (target: string): Set<string> | null => cheapest.get(target) ?? null;
const RUNGS = ['raw-payload', 'first-refinement', 'dust-quota', 'first-taste'];
it('opens on the teaching ladder, in teaching order', () => {
expect(commissions.slice(0, RUNGS.length).map((c) => c.id)).toEqual(RUNGS);
});
it('rung 1 is buildable at tick 0 out of a handful of machines', () => {
// "A belt, a drill, an uplink." If rung 1 needs research or a deep chain, the game
// opens by asking a new player for something they cannot possibly make.
const rung1 = commissions[0];
for (const want of Object.keys(rung1.wants)) {
const needed = machinesToMake(want);
expect(needed, `rung 1 wants ${want}, which cannot be made at tick 0`).not.toBeNull();
// + the belt and the uplink, which carry it but craft nothing.
expect(needed!.size, `rung 1 should need few machine types, needs ${[...needed!]}`)
.toBeLessThanOrEqual(2);
}
});
it('every rung is craftable at tick 0 — the tutorial never gates itself', () => {
const blocked: string[] = [];
for (const id of RUNGS) {
const c = commissions.find((x) => x.id === id)!;
for (const want of Object.keys(c.wants)) {
if (machinesToMake(want) === null) blocked.push(`${id} wants ${want}, unmakeable at tick 0`);
}
}
expect(blocked).toEqual([]);
});
it('each rung teaches strictly more machinery than the one before', () => {
// The ladder should climb. If a later rung needs no more than an earlier one, it is
// not teaching anything new and the slot is wasted.
const depth = RUNGS.map((id) => {
const c = commissions.find((x) => x.id === id)!;
return Math.max(...Object.keys(c.wants).map((w) => machinesToMake(w)?.size ?? 0));
});
const flat = depth.slice(1).map((d, i) => ({ d, prev: depth[i], id: RUNGS[i + 1] }))
.filter(({ d, prev }) => d <= prev)
.map(({ id, d, prev }) => `${id} needs ${d} machine types, no more than the rung before (${prev})`);
expect(flat).toEqual([]);
});
it('the ladder is cheap and the rewards climb', () => {
const rungs = RUNGS.map((id) => commissions.find((x) => x.id === id)!);
// Rewards ascend across the ladder and none of the tutorial rungs out-pays the
// ordinary queue that follows.
for (let i = 1; i < rungs.length; i++) {
expect(rungs[i].rewardBandwidth).toBeGreaterThan(rungs[i - 1].rewardBandwidth);
}
expect(rungs[rungs.length - 1].rewardBandwidth).toBeLessThan(commissions[RUNGS.length].rewardBandwidth);
// No standing orders in the tutorial — a repeating rung would never let the player
// off the first step.
expect(rungs.filter((c) => c.repeat).map((c) => c.id)).toEqual([]);
});
});
describe('the cooler pairing', () => {
const soft = machines.find((m) => m.id === 'software-decoder')!;
const cooler = machines.find((m) => m.id === 'asic-cooler')!;
const cool = (m: MachineDef) => m.coolPerTick ?? HEAT_COOL_DEFAULT;
/** Contracts v5: auras are ADDITIVE, capped at HEAT_COOL_MAX. */
const withCoolers = (n: number) =>
Math.min(cool(soft) + n * cooler.coolPerTick!, HEAT_COOL_MAX);
const duty = (c: number) => {
const net = soft.heatPerTick! - c;
if (net <= 0) return 1; // never heats
const up = (1 - HEAT_RESTART) / net;
return up / (up + (1 - HEAT_RESTART) / c);
};
it('one cooler is a real upgrade, not a rounding error', () => {
expect(duty(withCoolers(1))).toBeGreaterThan(duty(withCoolers(0)) * 1.5);
});
it('a bare decoder still throttles, and one cooler does not make it immortal', () => {
// The codex's decoder "throttles theatrically" — that is its whole character. A
// single cooler must not switch the mechanic off.
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(0));
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(1));
});
it('immortality costs an absurd shrine of coolers, not a casual pair', () => {
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(2)); // two is not enough
expect(withCoolers(3)).toBeGreaterThanOrEqual(soft.heatPerTick!); // three is
});
});

View File

@ -9,11 +9,7 @@
font-family:"Courier New", ui-monospace, monospace; }
#game { position:fixed; inset:0; } /* LANE-RENDER mounts here */
#screen { position:fixed; top:10px; left:50%; transform:translateX(-50%);
width:320px; height:180px; border:1px solid #2a2440; z-index:5;
cursor:pointer; } /* LANE-SCREEN canvas. RULING (round 5 review): as of UI's
click-to-enlarge this is a legitimate interactive surface, not a dead zone —
RENDER's pointer-events request is overtaken by events. World tiles under it
are occluded like any panel; don't build under the sky. */
width:320px; height:180px; border:1px solid #2a2440; z-index:5; } /* LANE-SCREEN canvas */
#ui { position:fixed; inset:0; pointer-events:none; z-index:10; } /* LANE-UI root (children re-enable pointer-events) */
</style>
</head>

View File

@ -25,24 +25,47 @@ REQUESTs.
---
## CURRENT ORDERS — Round 6 (goal: The Correction gets teeth, the map stops drifting)
## CURRENT ORDERS — Round 3 (goal: heat that matters, deeper economy)
1. **FIREWALL machine** + catch recipe on SIM's trap pattern (they detect by recipe
shape); `parity-mite` canned item; mite spawn tuning if SIM makes it data-driven;
commissions that want canned mites ("ONE (1) UNIT OF THE CORRECTION, ALIVE.
INVOICE ATTACHED. DO NOT READ THE INVOICE.").
2. **Numbers-station drift fix** (SCREEN's round-5 finding): two decoys decode onto
real visible seams (08127 → reel-beds-deep, 27503 → broadcast-shelf-south);
your validator recomputes plants but not decoys — re-cut the decoys and extend
the validator so a decoy landing on ANY seam fails by name.
3. **Accent fills for the GLB'd 10**: the ten machines with real models now get
`accent` values chosen against their actual textures (RENDER's overlay reads it).
4. **THE STANDARD mite lines** (~8 new, coordinate with SCREEN), CANON-DELTA the
mite reading of §5.
Round 2 review: the bloom-concentrate grep-before-delete and the fault-injection
discipline are exactly the standard. Both your cross-lane asks are RULED:
**(1) Units: per-second.** `gen`/`draw` are per second, `stored` is
bandwidth-seconds; SIM converts their integration this round — your `bufferCap: 240`
= 8 seconds of a 30 deficit, as you intended. **(2) Cooling:** `coolPerTick?` granted
in v3 (sim default 0.004); SIM wires it. Your SIM's-test-is-red flag was correct
mid-round and self-resolved at HEAD (their landing fixed it) — the stash-and-rerun
diagnostic was the right move. Ownership RULING: `data-validate.test.ts` moves to
`data/validate.test.ts` (git mv, yours — vitest picks it up there; `src/sim/**`
returns fully to SIM).
**DoD:** firewall catches against SIM's real mechanic; no decoy decodes onto any
seam (validator-enforced); all 10 GLB machines accented; the fax celebrates its
first canned mite in voice.
1. **The validator move** above, first, so the tree is clean.
2. **Heat retune, the headline**: today NOTHING can overheat — every `heatPerTick`
you authored is below the 0.004 ambient cooling, so heat is decorative. With
`coolPerTick` yours to set per machine: hit SIM's suggested feel — software
decoder throttles after ~10s of continuous load, scrams ~13-15s, full duty cycle
~25s; ASIC stays incapable of heat. Show the arithmetic in CANON-DELTA (per-tick
vs per-second — mind the units ruling). Give the hex-splicer a lesser fever.
Coordinate with SIM's reference-factory v3 via NOTES — your numbers decide
whether their layout survives.
3. **Buffer economy check** under the units ruling: is 240 (8s) still your intent
now that it's true? One tank should cover one decoder scram — verify against your
retuned cycle, adjust, show working.
4. **ASIC COOLER spec'd** in CANON-DELTA (the codex names it, §2 "vented by ASIC
coolers"): item/machine entry + intended adjacency mechanic, data authored but
recipes gated behind a `broadcast`-era tech node — the MECHANIC stays deferred
until SIM builds auras (M4); the data existing first is fine and the validator's
FORWARD_CANON pattern covers it.
5. **Reel + broadcast era tech nodes**: research costs in analog packs; unlocks
field-loom/telecine recipe families you already authored.
6. **Muxer + moov brain sub-lines** (§7): `stsz-ledger`, `stco-map`, `stts-metronome`,
`stss-keyring` items + assembly recipes converging on a `moov-brain`
`mux-container` endgame recipe on the muxer. This is M4's rocket — data lands
first, mechanics later. Muxer machine entry if you don't have one.
**Definition of done:** validator green from its new home; a software decoder
demonstrably overheats in SIM's tests using YOUR numbers (coordinate — their DoD
depends on your retune); CANON-DELTA carries the arithmetic; FORWARD_CANON pruned of
anything that gained a recipe.
---
@ -292,281 +315,3 @@ POST-PUSH ADDENDUM (same round, after LANE-UI landed `src/ui/live.test.ts`):
**Five sessions share one index: `git add <paths>` does not isolate a lane.** Suggest a
standing rule — every lane runs `git diff --cached --name-only` before every commit and
unstages anything outside its owned paths.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED: all 5 orders. §10 transcribed (9 items, 5 machines, 11 recipes, 5 era-gated tech
nodes, 2 commissions). Research economy: the ARCHAEOLOGY LAB + a full tech-cost audit.
ASIC cooler activated. Accents filled. items 51 → 60, machines 23 → 29, recipes 51 → 62,
commissions 14 → 16, tech 24 → 23 (six deleted, five added). Validator 41 → 49 tests.
- Verified: tsc clean (and `data/` is now genuinely typechecked — thank you), my 49 green,
live build boots with no console errors, all 29 machines art-directed with zero gaps,
UI grew a RESEARCH page and a third REFINE page off my data with no code from them.
- Fault-injected every new check, including re-gating the mosh reactor: fails instantly on
`MELT is craftable at tick 0 — the opening commission must be possible`.
**THE HEADLINE — the v4 gating rule made the whole game unplayable, and I'd authored it.**
The rule ("any id in a TechDef.unlocks is locked until that tech completes") turned three
rounds of my tech authoring from decoration into a lock on everything. Measured:
```
TICK 0: melt producible? FALSE <- first-taste, the opening commission, impossible
every science pack? FALSE
RESEARCH CLOSURE: 0 of 24 techs reachable
```
`disc-artifact-bottling` cost 10 analog packs and unlocked the only machine that can bottle
an analog pack. The M1 melt chain itself was gated. The game opened on an unbuildable
factory, an unfillable commission, and no way to research out of it. **This is the same
class of bug as round 2's decorative heat: a number/structure that was fine until another
lane made it load-bearing.** Fixed to a shape, not a patch — two rules, both now tests:
1. the M1 melt chain is starting equipment; 2. one pack (SPATIAL) is craftable at tick 0.
Result: **23/23 reachable**, order tells a story, only the four forward-canon items remain
unreachable. Arithmetic + the worked cost path in CANON-DELTA.
DECISIONS:
- **SPATIAL PACK is the bootstrap science** — bricks + halos + slurry all fall out of the
quantizer the player already owns for the melt chain. Your first research is a study of
your own damage, which is the most in-voice thing in the tree, and it needs no machine
the M1 chain didn't already require (bar the bottler). 10 packs/min on one quantizer;
the first four nodes cost 0.51.5 min each. Affordable off a first-taste-era factory, as
ordered, with the working shown.
- **Six disc nodes deleted, not repurposed** (`disc-artifact-bottling`,
`-chroma-subsampling`, `-macroblock-quarrying`, `-ringing-skim`, `-subsampling-420`,
`-mosh-reactor`) — their content became starting equipment, so a repurposed node would
have been a fake. Grepped first: no lane references specific tech ids. Per the
deprecation rule, logged here and in CANON-DELTA.
- **The cooler's spec'd 0.02 was catastrophic once real** — the decoder banks 0.0033, so a
0.02 aura is net 0.0167: it never heats, and a 2×2 cooler makes a 2×2 decoder a
permanent free 100/s. Retuned to 0.0018, which is sane under **both** readings of the
contract's ambiguous "this machine's coolPerTick *also* applies" (additive → 85% duty,
72/s avg; replacing → 55%, 46/s). I deliberately picked a number that survives either,
because SIM hasn't built it yet and I won't rest the design on my guess of a sentence.
Pairing is 9.64/s per tile vs 5.00 for ASICs — the best power in the game, paid for with
a tech, two machines and the floor space to keep them adjacent.
- §10 judgement calls (footage = anchor slab; the matte bath's chemical = static canister,
which §3 already calls "brine for preserving other artifacts"; ghost feather made only by
the neural keyer so the earlier machines exist to ruin it) — all argued in CANON-DELTA
the way you asked, from what the thing already is rather than convenience.
- NEURAL KEYER `powerDraw: 240` — §10's "24 VRAM ... dwarfs everything else"; that's 12
ASICs, against an entire M1 chain that draws 96. "You don't unlock the neural keyer; you
unlock the power plant it demands" is now literally true.
BLOCKED/BROKEN — **I broke two of SIM's tests, deliberately, and their guard caught it:**
- `research.test.ts:247` does `expect(gated.has('mosh-reactor')).toBe(true) // guards the
premise below`. I ungated the mosh reactor, because gating it makes first-taste
impossible. **Their premise guard did exactly its job** — it failed loudly instead of
silently testing nothing. The fix is one word in their file (not mine to touch): swap
`mosh-reactor` for any still-gated machine. Drop-in list: mv-extractor,
bloom-duplicator, field-loom, telecine-puller, hex-splicer, software-decoder,
asic-cooler, matte-bath, optical-printer, chroma-keyer, roto-bench, neural-keyer, muxer.
`demuxer` remains a valid "free" example.
- The other 8 failures in `research.test.ts` / `live.test.ts` are **not mine** — they fail
identically against HEAD's round-3 data (measured by restoring the old JSON and
re-running; failure sets diffed).
CROSS-LANE — SIM, the reference factory now can't power itself:
- It places **4 software decoders**, which my tree gates behind `stream-software-decoding`
(rounds 3 and 4 both). With your gating live, they can't be placed at tick 0, so the M1
demo has no power and the HUD's heat tests have no decoder to watch. That's the cause of
several of those 8.
- **The starter plant is ASICs**: net demand 78/s ÷ 20 = **4 decode-asics minimum** (5 for
headroom). Ungated, boring, correct — the software decoder is a mid-game *upgrade*, which
is the progression the tech tree is for. My round-3 suggestion (3 ASIC + 1 decoder + tank)
is still the right *post-research* demo, and the decoder+cooler pairing is the M2 showpiece.
- Tell me if you'd rather software-decoder be starting equipment and I'll ungate it — but
at 25.8/s average vs the ASIC's 20 it would make the ASIC pointless from tick 0.
CONTRACT REQUEST: none. (Still not requesting `kind: 'cooler'``kind: 'power'` +
`coolRadius` covers it, and the aura isn't built yet.)
STILL OPEN: **SIM hasn't exported the tuning constants yet** (I checked — `grep 'export
const' src/sim/*.ts` finds only `geom.ts`). My heat tests still hand-mirror 0.7 / 0.5 /
0.004 / 30tps and still go stale silently if you retune. I'll retire the copies the round
the export lands.
NEXT (candidates): SPILL FOG + EDGE BOIL (§10 hazards — need a mechanic, so data waits on
SIM); mosquito traps; a seam/ore map (extractors still mine anywhere — the content gap I
own); `cinema-pack` + the Fortress; `corridorkey-mrp-mlx` is a real MODELBEAST artifact
factory for the whole §10 asset set (§10's production note) — worth a work order.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
SHIPPED: all 8 orders. Constants imported (hand-copies retired). Mosquito trap + catch.
Cooler finalised under the additive ruling. **The commission ladder.** `data/seams.json`
and `data/stations.json` (both new). Relic canon + the margin hint. Validator 49 → 72.
commissions 16 → 19, machines 29 → 30, recipes 62 → 63, tech 23 → 24, + 8 seams, 4 stations.
- Verified: tsc clean, my 72 green, live build boots with no console errors, all 30
machines art-directed, and the opening fax now reads "A belt, a drill, an uplink to THE
SCREEN — that is the whole job. MDAT ORE 0/20" with FIRST REFINEMENT in the tray.
- Fault-injected every new check (reference factory off-seam, seam in the relic reserve,
a moved hidden seam vs the broadcast, ladder out of teaching order, a 12-line STANDARD,
cooler immortal at two) — all fired by name, then restored.
**RUNG 1 IS 21.7 SECONDS.** Measured against LANE-SIM's real sim with a throwaway probe
(then deleted — sim behaviour is your lane, not mine): built exactly as the fax instructs
— drill, two belts, uplink, one ASIC — **4 machine types, cleared at tick 650**. The order
allowed 3 minutes. Twenty-two seconds matters more than the budget: the first fax has to
clear before a new player concludes nothing is happening.
DECISIONS:
- **The ladder teaches one mechanic per rung and never says so.** ore (extractor→belt→
uplink) → luma (the demuxer, and the slurry is *your* problem now) → hf-dust (waste is
inventory — the game's actual thesis) → melt (graduation; `first-taste` kept its id,
reward 50 → 80). The old queue opened on one MELT, i.e. "build eight machines correctly,
in order, with no feedback until the last one works". That was the cliff.
- **The trap's recipe has no inputs, deliberately.** I first wrote it as
`8 hf-dust + 1 luma → swarm`, then read `wildlife.test.ts`: *"the sim identifies traps by
that recipe shape"* — an empty-input recipe yielding the swarm. Ingredients would have
quietly made my trap an ordinary crafter and it would never have caught anything. Fixed,
and pinned with a test that cites your comment. My "only extractors conjure items" rule
now has a narrow, argued exception: the trap's input is a live swarm in the world, which
is wildlife in the snapshot and never cargo on a belt.
- **Cooler final: 0.0018 → 0.001.** The additive ruling killed 0.0018 — two coolers reached
0.0046 against the decoder's 0.0033 gross, so a casual pair made it immortal. At 0.001:
1 cooler doubles output (30%→61% duty), 2 nearly eliminates downtime (91%), 3 tips into
immortality — 12 tiles of cooler around 4 tiles of decoder, a visibly absurd shrine to
keeping one zip-tied tower PC alive. Immortality reachable, never casual. Duty table and
per-tile comparison in CANON-DELTA.
- **The numbers station is a real map, and it cannot go stale.** Each 5-figure group is
`[x+32][y+32][richness×9]`; three of them are the three `hidden: true` seams. The
validator *recomputes* the planted groups from `seams.json`, so moving a hidden seam
without re-cutting the broadcast fails. It also checks no decoy decodes onto a real seam
— a decoy that points at treasure is a leak, not a decoy. Filler is procedural on your
seed rather than a fixed pool, because 24 hand-written groups loop in ~2 minutes and the
genre's whole menace is that it never stops.
- **The optical fossil, and why the world is silent.** §1 hides it in one clause: the Reel
Beds hold fossils that *"sing when excavated"*. Optical sound is a picture of a waveform
— the one place image and audio are the same substance. So the Bitstream has no audio
because the audio track was severed from the container, and one length of it is still in
the ground. You don't unlock a feature; you repair the file. That is why THE STANDARD
broadcasts "Do not excavate" and "Anything that sings when you dig it up was never meant
to be heard" — The Correction runs a propaganda station warning you off the one artifact
that would let you hear it for what it is.
INVENTED: 1 machine (mosquito-trap), 1 recipe, 1 tech node, 3 tutorial commissions, 8 seams,
4 stations, 30 lines of Correction propaganda, 1 ghost line. Argued in CANON-DELTA.
CROSS-LANE — SIM, one thing, and it was real when I measured it:
- **The reference factory stalls on rung 1.** Probed at the time I wrote the ladder: it
ships `coefficient-pack 268, hf-dust 268, chroma-slurry 118` and **zero raw ore**, so
`raw-payload` never cleared and **no commission closed in 20,000 ticks** — the queue sat
on rung 1 forever. Every ore goes into the demuxer. By the end of the round your
reference v5 landed and the three failures this caused (M1 chain, NEXT IN TRAY, completes
a commission) had already gone green, so this may already be handled — but if the demo
ever needs to show the early game, tap ~20 ore and ~15 luma to an uplink ahead of the
demuxer and quantizer. Rungs 3 and 4 you already ship.
- Your `sim.test.ts` line 448 (`commissionDone` length 0, *"wants melt, not ore"*) was
correct against the old queue and is exactly the assertion the ladder invalidates: your
minimal factory there — extractor, belt, shipper — *is* rung 1, and it now clears it in
under 300 ticks. That is the ladder working, not breaking.
- Seams: `stream-crust-west` spans x[-31,-28] y[-5,6] and covers all three of your
extractors at (-30,0), (-30,-3), (-30,3) with a tile to spare — pinned by a test.
`relicReserve` x[12,31] y[12,31] is kept seam-free for your seed-derived fossil.
- Schema for both new files is documented in a `_doc` key inside each, and `seams.json` is
`{ relicReserve, seams: [...] }` rather than a bare array so it can carry that metadata.
SCREEN — `data/stations.json` is the schema I proposed and it matches your spec: four
stations with `band` / `freq` (MHz on FM, kHz on AM+SW) / `name` / `format` / `flavor`,
plus one payload key each — `music` (bpm, scale, steps, mutateEveryBars, seed, per-era
flavour), `chyron` (30 unique lines, ~20s each = 10 min before a repeat), `numbers`
(preamble, group size, encoding string, `plantedGroups`, `decoyPool`, `plantEveryMessages`),
`ghost` (line + everyMinutes + reversed). Say the word in NOTES if you want any of it
reshaped — it is data, it is cheap, and nothing else reads it yet.
BLOCKED/BROKEN: nothing of mine. Attribution, measured by reverting my JSON and re-running:
4 research-timing failures (`RESEARCH_TICKS_PER_PACK`, two labs halve the wait, abandoning
forfeits, grantResearch) reproduce identically with my data fully reverted — they are
LANE-SIM's in-flight work. Two wildlife failures I saw mid-round vanished on their own; the
tree had 34 uncommitted files across five lanes while I worked, so the suite is a moving
target and every attribution here was made by stash-and-compare, not by reading tea leaves.
CONTRACT REQUEST: none. `GameData` has no `seams`/`stations` fields and `main.ts` does not
load either file — deliberately not asking yet, since SIM and SCREEN can import them
directly and I would rather the wiring follow a consumer than precede one.
NEXT (candidates): SPILL FOG + EDGE BOIL (§10 hazards, once SIM has a mechanic); the ASIC
cooler's aura tuning once `coolRadius` is live in-sim; a second relic if the audio bus
wants more than one voice; `corridorkey-mrp-mlx` is a real artifact factory for the whole
§10 asset set (§10's own production note) and deserves a MODELBEAST work order.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED: all 4 orders. items 60 → 61, machines 30 → 31, recipes 63 → 64, tech 24 → 25,
commissions 19 → 21. Validator 72 → 74. All argued in `data/CANON-DELTA.md` (round 6).
- **THE FIREWALL** (`firewall`, crafter, catches on SIM's shape) + **`catch-mite`**
(input-free → `parity-mite: 1`) + the **`parity-mite`** canned item + tech gate
`broadcast-parity-firewall` + two commissions that want it alive (`correction-specimen`
reward 1000, the flagship "ONE UNIT OF THE CORRECTION, ALIVE"; `annual-correction-cull`
reward 2100 standing).
- **Numbers-station drift fixed**: the two leaking decoys re-cut (`08127`→`08143`,
`27503`→`27475`), and the validator now decodes EVERY decoy and fails by name if it lands
inside ANY seam rect (was: only the three hidden tiles). See CROSS-LANE — SCREEN.
- **19 GLB'd machines accented against their real albedo textures** (extracted from the
GLBs and inspected), incl. two texture-driven corrections: mosquito-trap `#d0ff60`
`#a64fff` (the model's zapper is UV-violet, not lime) and decode-asic `#6fe8c0`→`#8fe8ff`
(ice-blue LEDs, not mint). Table in CANON-DELTA.
- **8 new STANDARD chyron lines** in The Correction's reassuring-menace voice, all about
the mites ("A mite does not remove your work. It completes it."). 30 → 38 lines.
VERIFIED: `npm run check` clean; my validator 74/74 green (fault-injected the new checks —
`catch-mite` given an ingredient fails the trap-shape guard by name; a decoy dropped onto
`stream-crust-east` fails "inside real seam …"; both restored). SCREEN's `band.test.ts`
9/9. Live on :8155, no console errors: machines 31 with firewall art-directed, all 14
target accents present, `parity-mite` + both mite commissions loaded, mosquito-trap accent
`#a64fff` on the real DOM. DoD met: firewall catches on SIM's real recipe-shape mechanic;
no decoy decodes onto any seam (validator-enforced); every GLB machine accented; the fax
celebrates its first canned mite in `correction-specimen`.
DECISIONS:
- **The chyron limit bit me and it was cheap to obey.** SCREEN's `band.test.ts` caps a
line at 96 chars (62 mono chars would clip; they allow 96). My first 8 lines ran 100-136
and failed one SCREEN test — tightened all 8 to ≤96 without losing the voice. Their test
is the right net; the words are mine, the width is the sky's.
- **Firewall gated early (broadcast), not free.** §5 calls the mite an "early nuisance", so
the counter should arrive about when the mites do — cheap, parallel to the mosquito farm
node. Not starting equipment (you research the answer to the enemy), not late (the enemy
is early). SIM's reference v6 will need it granted like it grants software-decoding.
- **`catch-mite` is input-free by design**, same argument as `bait-swarm` last round: the
"input" is a live mite in the world, wildlife in the snapshot, never belt cargo. A test
cites SIM's detection comment so nobody "fixes" it into an ordinary crafter.
- **Accented all 19 GLB'd machines, not the ordered 10.** The order said ten; nineteen had
models by the time I looked (RENDER hot-swapping mid-round — the "numbers go stale"
warning, live). Every machine with a real texture now has a texture-matched accent; the
extra nine are honest surplus, not scope creep.
BLOCKED/BROKEN: nothing of mine. Two suite failures, both SIM's in-flight round-6 work,
neither caused by my diff (attributed by stash-and-compare on my paths + by inspection):
- `src/sim/mite.test.ts` "guarding the approach cancels corrections" — SIM's mite mechanic
is **fixture-based** (its own `FIXTURE` GameData at line 36; it never reads `data/*.json`),
so my data cannot touch it. It's a balance assertion in their new file, which was visibly
being edited while I worked (line numbers shifted between my runs).
- `src/sim/reference.test.ts` "keeps the demuxer alive by voiding surplus" — reproduces
IDENTICALLY with my entire data diff stashed (measured), so it's HEAD's reference factory,
not my content. Likely their reference v6 mid-build.
CROSS-LANE — SIM (mite mechanic, parallel this round): the catcher is authored exactly on
your shape. Item id **`parity-mite`**, recipe **`{inputs:{}, outputs:{'parity-mite':N}}`**
on machine `firewall` — one item id over from the mosquito trap. Your `mite.test.ts` already
imports `MITE_ITEM` and keys off shape/tier, so the real data drops in with zero code (I
verified my `catch-mite` matches your fixture `burn` recipe shape). `catch-mite` is
120 ticks / bandwidth 4. The firewall is `broadcast-parity-firewall`-gated, so reference v6
must grant it (like software-decoding) before placing one. `FIREWALL_RADIUS` is yours; I
set no radius field. If you'd rather the mite item be `parity-mite-canned` or the firewall
be ungated, say so and I'll follow.
CROSS-LANE — SCREEN: your numbers-station drift finding was exact — `08127`/`27503` were on
`reel-beds-deep`/`broadcast-shelf-south`. Fixed at the source (re-cut) AND at the guard (the
validator now rejects a decoy on ANY seam, by name). The 8 new STANDARD lines are yours to
reshape as always — I kept every one ≤96 to pass `band.test.ts`; if you widen that cap I'll
gladly give a couple more room to breathe. `numbers.plantedGroups` still recomputes from
`seams.json`, untouched.
CONTRACT REQUEST: none. `parity-mite`/`firewall`/`catch-mite` all fit the existing schema;
the mite reading needed no new fields.
NEXT (candidates): CHECKSUM WARDENS + REDUNDANCY GUNSHIPS (§5 rungs 2-3, once SIM escalates
The Correction past mites); a `parity-mite`-fed product line (§5: mites "fix melt into a
lake" — a real "footage of a lake" item you can only make FROM a canned mite would close the
loop); SPILL FOG + EDGE BOIL (§10, still waiting on a SIM mechanic); the ASIC cooler aura
tuning once `coolRadius` is live in-sim.

View File

@ -25,28 +25,35 @@ you never simulate. If motion looks wrong, interpolate better or note it — don
---
## CURRENT ORDERS — Round 6 (goal: the GLB era looks as good as it is)
## CURRENT ORDERS — Round 3 (goal: truth-driven states + asset intake readiness)
MODELBEAST delivered: 10 real GLBs now live in `public/assets/models/` (mosh-reactor,
quantizer, seam-extractor, muxer, archaeology-lab, software-decoder, decode-asic,
mosquito-trap, chroma-keyer, artifact-bottler). Hot-swap + normalization worked
first try. One problem, and it's yours: **at game zoom they read dark** — baked
textures carry no emissive, so the two-material rule dies on real assets.
Round 2 review: the corner-cargo catch and the scram-latch bug you found by READING
SIM'S CODE are the reasons this process works. Verdicts: your color deviation is
**APPROVED as the ruling** — contracts v3 re-comments `MachineDef.color` as
chassis/body, accent stays derived; DATA's values stand; deviating loudly with a
written rationale was exactly right. Req 2 GRANTED: `EntityState.scrammed` is in v3 —
SIM wires it this round. Req 3 (`BeltItem.side`) DEFERRED until merges matter.
`BeltItem.id` is now required, so your fallback path can retire whenever you like.
1. **GLB legibility pass, the round's centerpiece**: give hot-swapped GLBs the
accent treatment placeholders get — your existing accent derivation drives a
small emissive element (glow sprite / rim boost / brightest-texel emissive lift;
your call, note it) + an overall brightness floor so a dark chassis never reads
as a hole. Verify all 10 in showroom AND in the demo world at default zoom;
screenshot before/after in NOTES.
2. **Mite visuals** (SIM lands `'parity-mite'` wildlife): hospital-white beetles,
green checkmark eye (§5), unsettling gentleness; a caught mite in a firewall
reads as filed paperwork. The 'repaired' event gets a small, infuriatingly tidy
sparkle where your product used to be.
3. **Excavation VFX** on `relicFound` (carried from your round-5 NEXT).
1. **Scram from truth**: read `EntityState.scrammed`, delete your `SCRAM_RELEASE`
duplication and the desync risk it carried.
2. **Remove-mode support**: UI ships a REMOVE interaction this round — give it a
hover treatment (target machine tinted demolition-red, footprint outline) driven
by a `setGhost`-style call. You own the visual language; coordinate the hook via
NOTES if `setGhost(null, ...)` isn't expressive enough (I'll widen the contract
next round if you two need it).
3. **Showroom formalized**: promote the devscene showroom row to `?showroom` — every
machine + every item on plinths, labeled, camera preset. This becomes the QA page
for MODELBEAST asset drops (which land as `public/assets/models/<asset>.glb`;
your hot-swap + normalization + AnimationMixer are already ready — this is the
eyeball step).
4. **Era-skin groundwork**: registry hook for per-era material variants (§8 era
skins) — plumbing only, stream-era default, so eras can land in data without a
renderer round.
**DoD:** all 10 GLBs legible at default zoom (before/after proof); mites read as
The Correction at a glance; perf rule holds.
**Definition of done:** scram visuals verified against SIM's real latch (no local
constants); showroom screenshotted in NOTES; remove-mode hover demonstrated with
UI's flow; perf standing rule still met.
---
@ -275,530 +282,3 @@ building; I loaded it in the dev page and dispatched all 114 commands):
- Per-era chassis skinning (§8 era skins) once tech/eras land; splitter output lamp
driven by real round-robin state rather than a timer; bloom-loop belt tell; HF dust
piles; parity-mite wildlife; LOD/culling if a real megabase ever drags the frame.
### Round 3 — 2026-07-17 — Opus 4.8
**PROVENANCE — read before you diff lanes against owned paths.** All ten of my round-3
`src/render/**` files are committed inside **`d1c3f2b [sim] round 3 NOTES`**, not under a
`[render]` commit. Nobody did anything wrong: the five lanes share one working tree AND
one git index, so when I had my files staged and LANE-SIM committed with `-a`/`add -A`,
their commit swept mine in. It was already pushed by the time I noticed, and with four
other live sessions on this history I'm not rewriting it. **This is not a SIM ownership
violation** — the render files in `d1c3f2b` are mine, and `git show d1c3f2b -- src/render`
is my diff. PROPOSAL below on how to stop it recurring; it will, otherwise.
**SHIPPED** — all 4 orders, verified in the running build.
1. **Scram from truth.** `SCRAM_RELEASE` and the whole local latch are gone; the
renderer reads `EntityState.scrammed`. SIM wired the field mid-round (their
`state.scrammed` at index.ts:489/494) so this is verified against their real latch.
The crisp test: **heat 0.6 + `scrammed: true` → dead-dark, amber, idle frozen**, and
the same heat with `scrammed: false` → alive at body emissive 1.5 (= `1 + 0.6²×1.4`).
Same heat, opposite field, opposite visual — no threshold left in this lane to drift.
(`e.scrammed ?? heat >= 1` remains only as a bridge for a sim that hasn't set it; it
carries no constant.)
2. **Remove mode.** `setGhost('__remove', …)` → hazard-striped footprint decal + red
volume wash (`remove.ts`). Verified through UI's real flow: clicked their REMOVE tab,
`selectedBuild()` returned `{def:'__remove',dir:0}`, their hint bar switched to
"REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN", and hovering the mosh reactor lit its
**whole 3×3** (decal scale [3,3], wash [2.82,0.8,2.82], centred on the machine at
[5.5,9.5] — not the hovered tile). Bare ground shows nothing.
3. **`?showroom`** — 23 machines + 51 items on labelled plinths, camera preset, and it
lands MODELBEAST drops live (hot-swap + normalise + AnimationMixer + era skin all
already run through it). See the manifest below.
4. **Era skins** (`era.ts`) — plumbing, derived from data, verified live.
**VERIFIED**
- Perf standing rule holds: `?devscene=stress` → 500 entities + 2,000 items at
**3.3ms/frame**, 5 instanced draw calls. (R1 3.2 / R2 3.6 / R3 3.3 — topology, heat,
mixers, eras and remove-mode have cost nothing measurable.)
- **Era skins live across all five eras**, each matching its `ERA_SKIN` entry exactly:
reel telecine-puller `r0.65/m0.45` (sepia'd to #6e491f), broadcast field-loom
`r0.8/m0.1` (phosphor #2e5132), disc mosh-reactor `r0.2/m0.65` (gloss), fortress muxer
`r0.95/m0`. **Stream is a true identity skin** — quantizer chassis reads `#5e5148`,
byte-for-byte DATA's authored value, so nothing changed for the 16 stream machines.
- Prod bundle clean: `devscene` 0, `showroom` 0, `plinth` 0, `window.__render` 0 hits
(the 4 `__renderTarget` matches are three.js's own).
**DECISIONS**
- **`BeltItem.id` fallback retired.** Identity is now always the id, which also deleted
the per-frame sort of every belt item — the order-matching path only ever existed to
reconstruct identity the contract didn't carry.
- **The showroom fabricates a SimSnapshot** rather than hand-building display meshes, so
machines go through the real EntityLayer. A QA page with its own bespoke display path
would be testing itself instead of the renderer. Items aren't entities (no `asset`,
never hot-swap) so those are built directly — but from beltitems.ts's own factories,
so the showroom shows literally what rides the belts.
- **Camera preset frames the MACHINE hall, not everything.** A view wide enough to hold
all 51 items renders every machine too small to judge, which defeats the point. Items
are one pan south. Labels are collision-culled front-to-back: ~74 of them cannot all
fit at overview zoom, and a readable subset that resolves as you zoom beats a pile.
- **`__remove` is duplicated, not imported.** A renderer importing UI internals is the
wrong layering and there's no contract field yet. Unlike a tuning constant, drift here
fails loudly and visibly. Both lanes filed for a real hook (see request 1).
- Era skin touches the **chassis only** — the accent stays the colour of what the machine
makes, because that's the resource, not the decade.
**BLOCKED/BROKEN** — nothing blocking.
- **A dead debug hook leaked into prod and I nearly shipped it.** Hoisting the scaffold
check into a `debug` VARIABLE defeated Rollup's constant folding, so `window.__render
= {scene, gl, topo, …}` survived in `dist/` as unreachable dead code (the class itself
still tree-shook). Round 2's `if (devSceneEnabled())` folded because the call was
inline. Fixed by testing `import.meta.env.DEV` literally at the branch. Flagging the
shape, not just the fix: **any** scaffold guard that goes through a variable will do
this silently, and only grepping the bundle catches it.
- Merge pieces still don't route cargo per-inlet (needs `BeltItem.side`, deferred —
agreed).
- The showroom's item hall is laid out for 51 items; if DATA doubles them again the rows
will run past the world edge and want paging. Not urgent, noted so it isn't a surprise.
**CONTRACT REQUEST**
1. **`UIBus.setGhostMode(mode)` or a 4th `setGhost` arg** — LANE-UI filed the same one
independently (`src/ui/selection.ts` documents their half). Today remove-mode rides a
magic string through `selectedBuild()`, and main.ts fires a `place` for `__remove`
every click that the sim discards. It works and it's documented on both sides, but
it's a protocol living in two lanes' comments instead of in the contract.
2. Not urgent: nothing else. v3 granted both of round 2's asks and they both landed
clean.
**MODELBEAST WORK ORDER** — this is `?showroom`'s content, and every one of these is a
live hot-swap target today: drop `public/assets/models/<asset>` and it appears in ~10s,
no reload, no code. Normalisation means any export scale lands correctly; multi-mesh and
rigged+animated GLBs both work (verified round 2). Footprint is the tile budget.
| asset | era | fp | machine |
|---|---|---|---|
| `asic-cooler.glb` | broadcast | 2x2 | ASIC COOLER |
| `field-loom.glb` | broadcast | 3x2 | FIELD LOOM |
| `artifact-bottler.glb` | disc | 2x2 | ARTIFACT BOTTLER |
| `mosh-reactor.glb` | disc | 3x3 | MOSH REACTOR |
| `subsampler.glb` | disc | 2x3 | SUBSAMPLER |
| `muxer.glb` | fortress | 5x5 | THE MUXER |
| `telecine-puller.glb` | reel | 2x2 | TELECINE PULLER |
| `belt.glb` | stream | 1x1 | STREAM BELT |
| `bloom-duplicator.glb` | stream | 1x1 | P-FRAME DUPLICATOR |
| `buffer-tank.glb` | stream | 2x2 | BUFFER TANK |
| `dct-press.glb` | stream | 3x3 | DCT PRESS |
| `decode-asic.glb` | stream | 2x2 | DECODE ASIC |
| `demosaicer.glb` | stream | 3x2 | DEMOSAICER |
| `demuxer.glb` | stream | 2x2 | DEMUXER |
| `gop-assembler.glb` | stream | 3x2 | GOP ASSEMBLER |
| `hex-splicer.glb` | stream | 3x3 | HEX SPLICER |
| `lane-splitter.glb` | stream | 1x1 | LANE SPLITTER |
| `mv-extractor.glb` | stream | 2x2 | MV EXTRACTOR |
| `p-caster.glb` | stream | 2x2 | P-CASTER |
| `quantizer.glb` | stream | 2x2 | QUANTIZER |
| `seam-extractor.glb` | stream | 2x2 | SEAM EXTRACTOR |
| `shipper.glb` | stream | 2x2 | SCREEN UPLINK |
| `software-decoder.glb` | stream | 2x2 | SOFTWARE DECODER |
Hero assets worth `fal_trellis2` (codex §9): `muxer` (5x5, the rocket), `mosh-reactor`,
plus the Fortress kit when it exists. Everything else is `sf3d` territory.
**PROPOSAL — the shared index will keep eating commits.** Five sessions share one working
tree and one `.git`, so `git add -A` / `commit -a` in any lane silently commits whatever
the other four have staged (this round it merged my whole lane into SIM's NOTES commit;
it could equally have committed someone's half-finished file mid-edit, which is worse
because it would still typecheck-fail on someone ELSE's name). Staging discipline can't
fix it — the loser is whoever happened to have files staged, not whoever was careless.
Cheapest real fixes, in order:
1. **`git worktree` per lane** — one tree + index each, one shared repo, no protocol
needed. Lanes still see each other's commits after a pull.
2. A standing rule that lanes commit only with an explicit pathspec (`git commit -- <paths>`,
never `-a`), which leaves staging alone. Weaker: it's a convention, and this round
proves conventions lose races.
3. Status quo + accept occasional misattribution, with NOTES carrying provenance.
**NEXT (round 4 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; splitter lamp driven by real round-robin
state instead of a timer; bloom-loop belt tell; HF dust piles; wildlife; save/load
camera state; LOD/culling if a megabase ever drags the frame.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED** — all 4 orders + housekeeping, verified against LANE-DATA's real v4 data
(which landed mid-round: 29 machines, 60 items, `archaeology-lab`, `asic-cooler
coolRadius:2`, `accent` on 8 machines) and UI's real migrated flow.
1. **Ghost keys off `mode`.** The `'__remove'` sentinel is ignored — mode is the truth,
and the `REMOVE_DEF` constant is deleted from this lane. Verified through UI's own
REMOVE tab: `selection()``{mode:'remove'}`, demolition decal + wash on the mosh
reactor's whole 3×3.
2. **Locked-machine ghost.** Padlock-amber `#ffb02e` — deliberately not the occupied-red,
because "not yet" and "not here" are different sentences. A padlock floats above,
tinted by the era gating it. Verified: `telecine-puller` → amber ghost + `#d8a24a`
reel brass; `asic-cooler` → amber + `#5fd08a` broadcast phosphor.
- **Era tell needed its own palette.** `ERA_SKIN.tint` can't do this job: it's a
multiplier tuned to leave DATA's chassis alone (stream is literally `#ffffff`), so
three of five eras would have told you nothing. Added `ERA_TELL` — five hues chosen
to be told apart, used as signal rather than surface.
3. **Cooler auras.** Verified on `asic-cooler` (fp 2×2, coolRadius 2) → **6×6 tiles**,
centred on the ghost, dashed + tick-marked, no fill.
- **It's a rectangle, not a ring.** `coolRadius` is CHEBYSHEV from the footprint, so
the cooled region is the footprint grown by r on each side. A circle would have been
prettier and would have lied — the corners it left out are cooled, and players would
plan around a boundary that doesn't exist. Drawn in the geometry the sim measures in.
4. **Lab visuals.** `archaeology-lab` (kind `lab`, 3×3) renders as a bench + the "bottled
artifact triptych" (§2) — three out-of-phase pulsing bottles. Verified geometry
`Box,Cylinder,Cylinder,Cylinder` and **`accent` consumed from data**: bottles emissive
`#ffc24a`, exactly DATA's authored value.
5. **Housekeeping.** Splitter lamp left timer-driven as approved. Showroom absorbed the
new catalog — see the bug below.
**VERIFIED**
- Perf standing rule holds: 500 entities + 2,000 items at **3.6ms/frame**, 5 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 — auras, padlocks and the locked path
cost nothing measurable.)
- Showroom absorbs 29 machines + 60 items, all five eras present, hall inside the world.
**DECISIONS**
- **Matched LANE-UI's lock rule rather than my own, and it was a real divergence.** My
first cut treated absent `research` as "nothing is locked" — don't lie when the field
isn't wired. UI's `research.ts` reads the same rule the opposite way: nothing has
completed, so everything gated shows its padlock. **UI is right and I was wrong**: this
is the `scrammed` lesson misapplied. There, the field's absence meant the sim hadn't
wired a *behaviour*; here the rule is satisfiable from data alone and the contract says
"until that tech completes". Left unresolved, the build bar would have padlocked a
machine while the ghost waved it through — on the same screen. Also matched their
first-tech-wins tie-break for ids unlocked by two techs: a data bug either way, but we
must both blame the same tech.
- **Buffer fill now reads `bandwidth.capacity`** instead of re-summing `bufferCap`
locally. v4 publishes the total; a second copy of the sim's arithmetic in this lane is
exactly what the scram latch taught us not to keep. Falls back to the local sum only
while the field is absent.
- **`MachineDef.accent` wins outright, no legibility floor** — same reasoning as
`def.color`: explicit art-direction is a decision; the floor exists for *derived*
colours, which are incidental.
**BLOCKED/BROKEN** — nothing blocking.
- **The showroom bug I predicted last round arrived, and my fix was half a fix.** Round 3
noted the item hall would overflow when the catalog grew. It did (51 → 60 items), but
the failure wasn't the one I expected: the *machine* hall grew to 5 rows, ate the depth
budget, collapsed the item grid to **one row of 60 columns — a 206-tile row across a
64-tile world**. My round-3 "adaptive" fit budgeted depth only. Now fitted to both axes,
and it `console.warn`s if the catalog ever exceeds the hall rather than silently
stacking items off-world. Lesson: an auto-fit that constrains one axis just moves the
overflow to the other one.
- **"Placed-and-selected" auras aren't reachable** (order 3's third case). `SelectionState`
is `build | remove | null` — UI's click-to-inspect selection has no contract channel, so
a placed cooler rings on **hover**, not on inspect. Hover covers the same intent for
now; see CONTRACT REQUEST 1.
- Two tests fail repo-wide (`sim/reference.test.ts`, `ui/live.test.ts`) — both SIM/UI
in-flight research work, neither imports `src/render`. tsc is clean repo-wide.
**CONTRACT REQUEST**
1. **An inspected-entity channel**, if you want order 3's "placed-and-selected" case for
real. Cheapest shape: extend `SelectionState` with `{ mode: 'inspect'; entity: number }`,
or add `UIBus.inspected?(): number | null`. Renderer has no other way to know what the
player clicked. Low priority — hover already answers "what does this cool?".
2. Nothing else. v4 granted round 3's `setGhostMode` ask (as `setGhost(..., mode)`) and it
landed clean; the sentinel bridge can die in v5 whenever you like — this lane no longer
reads it.
**NOTE FOR THE MODELBEAST ORDER** — the manifest in round 3's NOTES is now stale: DATA
added 6 machines (`roto-bench`, `matte-bath`, `optical-printer`, `chroma-keyer`,
`neural-keyer`, `archaeology-lab`) and the tech tree now gates 13 across all five eras.
`?showroom` is the live manifest — it labels every machine with its exact `<asset>.glb`
filename and era, and re-reads data on load, so it can't go stale the way a table in a
doc does. Read it there rather than from round 3.
**NEXT (round 5 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
drags the frame.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**THE HEADLINE: "the game about place has no place" was a COLOR-MANAGEMENT BUG, not a
missing feature.** I wrote the whole floor — fbm terrain, era hinting, seam glints — ran
it, and the screen was still black. The shader was fine. `THREE.Color.setHex()` converts
sRGB → the linear working space, but a raw `ShaderMaterial` gets none of the renderer's
output includes, so the linear value went to the framebuffer untouched. Measured, not
guessed: crust authored `#0c0a15` (RGB 12,10,21) was rendering as **RGB (1,1,2)**; even a
mid-grey `#2a2440` came out (6,5,13). Everything this lane has drawn with a raw shader has
been ~6× too dark **since round 1** — the grid, the crust, all of it. One
`#include <colorspace_fragment>` in `ground.ts` and the floor measures (10,8,18) against
an authored (12,10,21). The void was never a design problem; it was a missing include.
That's why the "before/after" is so stark, and it's the single most valuable thing I found
this round.
**SHIPPED** — all 8 orders.
*Part A*
1. **Inspect highlight** — selection ring + cooler aura on `{mode:'inspect'}`. Verified:
inspecting a 2×2 extractor rings it at its centre with scale 2.5; inspecting the
asic-cooler shows ring **and** its 6×6 coverage rect. This closes the
"placed-and-selected" case round 4 couldn't reach.
2. **Wildlife** (`wildlife.ts`) — dust piles as HF-dust glitter heaps scaling with `size`;
mosquito swarms as pixel-gnat clouds orbiting `target`, with the harassed machine
stuttering (§8: wrongness of motion is a faction uniform). Two draw calls for the whole
ecosystem.
3. **Fixture hygiene** — devscene now fabricates `commissionQueue`, `research`,
`wildlife` and `relics`, plus an asic-cooler and archaeology-lab so the v4/v5 kinds are
reachable in the scaffold.
*Part B*
4. **THE FLOOR** (`ground.ts`, `seams.ts`) — fbm tone mottle + neutral rim + 8 data-driven
seam regions that skin the crust with their era and glint with their ore.
5. **THE RELIC** (`relics.ts`) — unfound = a breathing static patch; found = a fossil on a
plinth. Numbers below.
6. **JUICE** (`juice.ts`) — placement scale-pop + dust puff, demolition burst, shipment
flash. All derived from snapshot deltas, since `render()` never sees events.
7. **THE BREAK ROOM** (`breakroom.ts`) — GLYTCH arcade cabinet, own raycast, `cursor:
pointer` on hover, click → `window.open('./match.html')` **verified firing**.
8. **CAMERA + CLICKS** — default framing lifts world origin to screen y=264, clear of the
build bar at y=679. Picking investigation below — it found more than expected.
**VERIFIED (numbers)**
- **Relic is subtle-but-findable, measured at two zooms.** Brightest pixel on the relic vs
plain floor 7 tiles away: at wandering zoom (12) **+39** — findable. At overview zoom
(30) **18** — i.e. indistinguishable from floor mottle. You cannot spot it from above;
you have to be down there looking. That's the Mario-hidden-pipe budget, in numbers.
- **Perf holds with everything on**: 500 entities + 2,000 belt items + 50 piles + 198
gnats + 8 seam regions + the new floor = **4.4ms/frame**, 3.8× headroom, 8 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 / R5 4.4.)
- **Juice**: puff → 14 particles decaying over ~0.67s; burst → 22; shipment flash fired 22
times tinted `#ff7a3f`, exactly MELT's authored colour — so the flash reads which item
shipped, not just that something did.
- Prod bundle: `devscene`/`showroom`/`plinth`/`window.__render` all 0 hits; the round-5
shipping code (seams, aura, relics, breakroom) present. 446 tests pass, tsc clean.
**DECISIONS**
- **Dropped my own era-rim guess when DATA contradicted it.** The orders asked for strata
whispers toward the world edges and I built exactly that — sepia north, phosphor east.
Then `data/seams.json` landed mid-round, its doc saying "RENDER: `era` skins the ground",
with reel in BOTH the north-west and north-east and disc both north-centre and
south-west. My directional guess would have contradicted the data on screen, so era is
now skinned per seam region with a wide soft falloff. It still reads as strata whispers
toward the rim — the richest seams live out there anyway.
- **Seam glint is flecks, not veins.** First pass drew continuous sinusoidal veins; on
screen they were bright green worms crawling over the floor, competing with the belts.
Mineral glitter is sparse and twinkles, so it's now a fleck grid with per-fleck sparkle
clocks. The body fade is deliberately SHORT (0.8 tiles): SIM extracts on any
footprint/rect overlap, and a glint dying two tiles inside the boundary would
under-report where mining actually works.
- **The cooler aura stays a rectangle** (round 4's reasoning) and the relic monument, the
cabinet and the seams all now carry `colorspace_fragment` for the same reason the ground
does.
- **Cabinet is rotated 45°** to face the iso camera and sized to its own marquee — at the
rig's closest zoom a 0.03-unit marquee pixel was sub-pixel and GLYTCH was a smudge.
**BLOCKED/BROKEN**
- **A frame-delta of 63.7 SECONDS was silently eating all juice.** `clock.getDelta()`
returns the real gap, and a backgrounded tab / alt-tab / GC pause / first frame after
load hands back seconds. Particles have a 0.5s TTL, so every effect was born and killed
inside one update — and a 63s integration step flings them to infinity. `render()` now
clamps dt to 0.05 (main.ts clamps the sim accumulator for the same reason; this is the
renderer's half). This also protects the scale-pop, camera pan and heat animation.
- **`match.html` only exists in `dist/`** (deploy.sh copies it there). In a dev server the
cabinet's click opens a 404. Not fixable from this lane — `public/` is mine but the file
is the deploy's. Flagged so nobody reports it as a cabinet bug.
- The devscene relic sits at (9,-7), which is NOT inside DATA's `relicReserve`
(12,12,20,20). That's fine for a fixture, but SIM's real relic should land in the
reserve — worth a check when SIM lands `relics`.
- The cabinet is decorative and reserves no tiles, so a player can place a machine
"inside" it. Cosmetic only.
**B8 PICKING INVESTIGATION — 27.6% of the viewport cannot be clicked**
I probed a 20px grid over the whole viewport (2,205 points) asking "does a click here
reach the game canvas?". 609 points — **27.6%** — do not. Breakdown:
| blocker | points | note |
|---|---|---|
| `#screen` | 153 (6.9%) | **decorative overlay, `pointer-events: auto`** |
| `#fk-build` + hint | 155 (7.0%) | build bar — legitimately interactive |
| `#fk-fax` + flavor | 90 (4.1%) | fax panel — legitimately interactive |
| `#fk-top` | 36 (1.6%) | bandwidth panel — legitimately interactive |
The one that is a **bug**: `#screen` is THE SCREEN's canvas in `index.html`, purely
decorative, 322×182 at top-centre, `z-index: 5`, and it takes pointer events. `#ui` next to
it correctly sets `pointer-events: none`. So a 6.9% rectangle of prime play area silently
swallows clicks — I hit it myself: the cabinet at (-5,-5) rendered *underneath* it and was
both invisible and unclickable, which is exactly the review's "machines spawned
unclickable". **Fix is one line in `index.html` (orchestrator-owned): add
`pointer-events: none` to `#screen`.** I moved the cabinet to (6,12) as a workaround, but
the dead zone is still there for machines.
Also seen: `#fk-screenview` (LANE-UI's theatre mode) is a full-viewport `pointer-events:
auto` host. Correctly `display:none` when closed — not a bug, noting it so the next person
who probes picking isn't confused by it the way I was.
**CONTRACT REQUEST**
1. **A renderer inspect channel.** `Renderer.setGhost`'s mode is only `build|remove` and
main.ts passes `'build'` during inspect, so nothing about the inspected entity reaches
this lane. `SelectionState` carries it, so I read `window.__fktryBus.selection()` each
frame as a bridge — same shape as the old `'__remove'` sentinel, documented on both
sides, and it wants a real hook: either widen `setGhost`'s mode to include `'inspect'`
with the entity id, or add `Renderer.setInspect(id | null)` that main.ts calls.
2. **`pointer-events: none` on `#screen`** in `index.html` (see above). Not my file.
3. Advisory: SIM doesn't gate extraction to seam rects yet, so the glint is currently a
cosmetic tell rather than a rule. The moment SIM lands the overlap check the floor
already teaches it — no renderer work needed.
**PROPOSAL — the shared tree cost me real time again.** Verification kept failing because
LANE-UI and LANE-SIM save `src/ui/*.ts` / `src/sim/*.ts` every few seconds and Vite
full-reloads my page, wiping every probe mid-measurement (the vite log shows a reload every
515s for minutes). I finished the round by snapshotting the repo to a scratch dir and
running an isolated dev server against it. That works, but it's a workaround for the same
root cause as round 3's commit collision: **one working tree, five writers.** A worktree
per lane fixes both.
**NEXT (round 6 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as *relief*
inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the
`relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a
megabase ever drags the frame.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED** — all 3 orders, verified in the running build. The second asset wave landed
mid-round: **27 GLBs are now in `public/assets/models/`** (up from the 10 the order named).
Everything below is generic — it runs on *any* hot-swapped GLB through the registry, no
hardcoded list — so all 27 (and whatever lands next) got the treatment automatically.
1. **GLB legibility pass (centrepiece).** The two-material rule died on real assets: baked
albedo, zero emissive, near-black at game zoom (before/after below). Two halves, both
keyed off the SAME accent derivation placeholders already use, so a machine that made
melt still glows melt-orange whether it's a box or a GLB:
- **Texel-following emissive FLOOR** (`registry.ts floorMaterials`, run ONCE on each
GLB template in `tryLoad`): `emissiveMap = map`, `emissive = white`,
`emissiveIntensity = 0.42` — the baked albedo self-illuminates so the brightest texels
lift off the void without washing to flat grey. Materials with no map self-light their
own colour; materials that already ship a glow are left alone. EntityLayer still clones
these per instance, so per-entity heat/scram rides on top (heat lerps `emissive`→red;
the map keeps modulating).
- **Accent BEACON** (`addAccentBeacon`): the one impossible element, perched on top,
scaled to footprint, in the machine's derived/art-directed accent — plus a gentle idle
pulse so a GLB gets a heartbeat like a placeholder. The idle reads `beacon.material` at
call time, not a captured ref, because EntityLayer re-clones GLB materials per instance
(a closure over the pre-clone material would drive a mesh nothing renders — caught that
before it shipped). **DATA's round-6 accents were chosen against the real GLB textures,
so the beacons wear DATA's hand-picked colours.**
- Verified in `?showroom` (before: dark specks on plinths → after: lit chassis + colour-
coded beacons across the whole 27-machine catalog) AND in the live demo world at the
default game frame (`__fktryDemo()` reference factory, `rig.frame(3,6,15)` untouched):
every GLB legible, beacons marking each unit.
2. **Parity mites** (`wildlife.ts`, +2 instanced meshes). Hospital-white faceted beetles
with a green **checkmark eye** (canvas ✓ texture, tilted 45° to meet the iso camera).
The tell tracks SIM's `size` state channel exactly: `0` hunting (slow, patient patrol —
the wrongness is the calm), `0<size<1` on station (dwells, only a tremor; the eye
**glows up** via per-instance `instanceColor` as the correction completes), `size≥1`
sated. render() can't see events, so I derive the two mite cues from snapshot deltas:
- **`repaired`** = a mite crossing `size` <1 1: a tidy sparkle where the product used
to be. Verified deterministically — driving a mite 0.6→1.0 emitted **exactly 4**
particles (one `Juice.tidy`).
- **caught in a firewall = filed paperwork** = a mite that vanishes from the list while
still unsated (sated ones leave at the rim carrying their fix, no cue). Verified:
removing an unsated mite fired the tidy sparkle (juice 4→8).
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfound→found
transition, a slow tall golden geyser "sings" up out of the crust as the monument rises.
Gated on a live transition (a save that loads already-excavated doesn't fire it — primed
like EntityLayer). Verified: flipping the fixture relic fired the geyser (juice pool
4→**89**) and built the monument.
**VERIFIED (perf)** — honest caveat: the canonical full-frame CPU-ms sampler could not be
read reliably this round because the browser backgrounds the tab and starves the rAF loop
that drives `layer.sync` (same shared-tree/environment friction round 5 hit), so the
instanced hot-path meshes never fully populated for a passive read. What holds by
construction: the additions don't touch the terms that dominate the frame. Wildlife gained
2 InstancedMeshes (mite bodies + eyes — 2 draw calls, only when mites exist, O(mites) capped
at 96); GLB legibility is one-time material params + one small beacon mesh per GLB *machine
instance*; the 2,000-item / 480-belt instanced path is untouched and no per-frame allocation
was added. Round 5's measured **4.4ms/frame** at full load stands as the reference.
**DECISIONS**
- **Floor is texel-following, not a flat wash.** A flat emissive floor guarantees "never a
hole" but greys the texture to mud and, since most GLBs export colour in the *map* with a
white base colour, would floor-glow everything white. `emissiveMap = map` keeps each
machine's baked colour and lifts its lit texels; the beacon + scene lighting cover the
rare all-dark texel. Trade-off noted honestly: a genuinely pure-black texel stays dark
(correct — dark rubber should read darker than a metal panel); no machine reads as a hole
in practice (verified by eye at default zoom).
- **Beacon geometry is module-shared, material is per-instance.** GLB records don't own
geometry (`ownsGeo=false`) so the shared beacon geo is never disposed; the per-instance
material is (EntityLayer disposes all materials on drop regardless).
- **Mite state is read from `size`, not invented.** SIM documents `size` as the state
channel and `target` as the belt it converges on; I render that contract rather than a
timer, so the tell is true to the sim (hunting drifts, repairing dwells + glows, sated
leaves). The eye-glow ramp uses `instanceColor` (multiplies the checkmark map) — no
shared-material emissive hack.
**BLOCKED/BROKEN** — nothing blocking.
- **The mite "filed paperwork" cue is a heuristic, not the event.** render() has no event
channel, so "canned by a firewall" is inferred from a mite disappearing while `size<1`.
This is correct for the sim's current lifecycle (sated mites leave at the rim, far from
firewalls; canned ones vanish mid-repair near one), but it's an inference — if a mite ever
despawns unsated for another reason, it'd read as a false "filed". A real renderer event
channel (see CONTRACT REQUEST) would make both mite cues and the excavation exact instead
of derived. Low risk today.
- The `firewall` machine (DATA, this round) has no GLB yet, so it shows the placeholder in
`?showroom` — it'll hot-swap + get the floor/beacon treatment the moment its asset lands,
no code change.
- `match.html` 404 in dev (round 5, unchanged): the cabinet's target only exists in `dist/`.
**CONTRACT REQUEST**
1. **A renderer event channel** (renewing round 5's ask, now with two more consumers).
`Renderer` never receives `SimEvent[]`; ScreenFX and AudioFX do. This round I derived
`repaired`, the firewall "can", and `relicFound` from snapshot deltas — all work, but
they're inferences that can drift from truth. Cheapest shape: add
`Renderer.onEvents(events: SimEvent[])` called by main.ts alongside the others. It would
make excavation fire on the actual `relicFound` and the tidy sparkle fire on the actual
`repaired` (with the event's own `pos`), retiring three heuristics. Everything else this
lane does stays snapshot-driven; this is specifically for one-shot cues.
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
**PROPOSAL**
- The GLB legibility pass makes the MODELBEAST pipeline fully hands-off for legibility: any
asset dropped in — textured, untextured, rigged, multi-mesh — lands lit and identifiable
with zero per-asset tuning. The whole 27-machine catalog proves it. Hero re-cuts
(`muxer`, `mosh-reactor`) can arrive without a render change.
**NEXT (round 7 candidates, not self-assigned)**
- Wire the renderer event channel if granted (exacts the three derived cues above); a
`firewall.glb` when it lands; per-mite orientation toward its `target` belt; heat-shimmer
*relief* inside a cooler rectangle; bloom-loop belt tell; save/load camera state;
LOD/culling if a real megabase ever drags the frame.
### Round 6 ADDENDUM — 2026-07-20 — Opus 4.8 — GLB body-scale regression fix
**Orchestrator caught a deploy-blocking regression at 0d491f8: GLB machine BODIES collapsed
to specks (beacons + jam lights + labels still correct). Fixed in `entities.ts` — commit
below. My round-6 verification missed it because I checked on an HMR'd session and wrongly
attributed the small bodies to "asset normalisation"; on a FRESH load the specks are stark.**
**ROOT CAUSE — the placement-pop hardcoded the settled scale to 1, which is only true for
placeholders.** A normalised GLB roots at its fit-to-footprint scale `s` (measured live:
2.03.5 for the current wave). The pop's settle branch was
`else if (rec.obj.scale.x !== 1) rec.obj.scale.setScalar(1)` — so on the very first sync it
saw `2.08 !== 1` and stomped the root to 1, shrinking the body to raw geometry size (~0.63
vs the correct 1.84 for a 2×2). The beacon and jam meshes carry their own local scale on
child nodes, so they were untouched — which is exactly the "bodies vanished, beacons fine"
signature the orchestrator reported.
**Why it surfaced now, honestly:** the pop code is UNCHANGED by me and the bug is latent for
ANY non-placeholder settled scale ≠ 1. It bit this round because the live GLB wave normalises
to s≈23.5 (verified: fresh `entry.create()` returns root scale 2.08 + a correct 1.84-wide
body; the live post-pop root was scale 1 + a 0.63 speck). The pop only ever writes
`rec.obj.scale`, so it was provably the resetter. It is not the beacon — a fresh create is
correct; the pop destroys it one frame later.
**FIX:** the pop is now a MULTIPLIER on a stored `rec.baseScale` (captured from `obj.scale.x`
at build = 1 for placeholders, the normalisation scale for GLBs), never an absolute. Settle
compares against `baseScale`, not 1. Placeholders: identical behaviour (baseScale 1). GLBs:
body stays full-size, and the placement scale-pop still plays (relative overshoot).
**VERIFIED on FRESH loads (hard navigate, no HMR), before/after described:**
- `?showroom`: BEFORE = every GLB a speck on a full plinth; AFTER = all 27 render as full
sculpted bodies filling their footprints, lifted + beaconed. Scene-graph confirms live
roots now carry scale 2.03.5 and body world sizes match footprints (1.84 for 2×2, 2.76
for 3×3), where they read 1.0 / 0.63 before the fix.
- `?uidemo` (reference factory, default game zoom): BEFORE = specks; AFTER = full-size
machines throughout, belts + cargo flowing. No console errors. `npm run check` clean,
`npm test` 481/481.
**LESSON (for me and the next lane):** verify visual regressions on a cold load, not an
HMR'd session — an HMR'd renderer can hold meshes normalised in a prior module state and
mask a fresh-boot break. And a hardcoded `scale = 1` is a placeholder assumption that any
scaled asset (GLB, future LOD) will trip; base-relative is the correct shape.

View File

@ -27,24 +27,33 @@ seed stock — port, don't move).
---
## CURRENT ORDERS — Round 6 (goal: the sky notices the enemy)
## CURRENT ORDERS — Round 3 (goal: continuous dread + spatial artifacts)
Your chyron now wraps (orchestrator fix — 29/30 STANDARD lines were clipping at ~51
chars since round 5; two-line wrap verified live).
Round 2 review: the ringing self-rejection ("sterile, but sharper — it flunked your
bar and I nearly shipped it") is the exact quality instinct these orders exist to
buy. Verdicts: CONTRACT REQUEST GRANTED — `frame(timeMs, snap?)` is in v3 and main.ts
already passes the snapshot; the un-retained invariant you promised is now a contract
comment. Scram-wipe PROPOSAL: APPROVED at 3 frames (~100ms) — you were right, one
frame is a flinch, not a legend. Regional-moiré PROPOSAL: APPROVED as this round's
centerpiece.
1. **`repaired` events on the sky** (SIM lands them): each repair eases total
corruption by its item's contribution AND stamps a small, perfect, green-tick
placard — The Correction signs its work. Boredom and repairs must stay
distinguishable (rot vs theft).
2. **Mite shimmer**: reuse the growth-mask for a faint hospital-white sterile patch
creeping around heavy mite activity — the inverse of corruption, and it should
feel WORSE.
3. **THE STANDARD, new pool**: coordinate with DATA — the propaganda acknowledges
the mites now ("Units are assisting you." / "Inventory discrepancies are being
resolved in your favor."). Chyron-only, mumble untouched (standing constraint).
1. **Signal strain** (your design, now unblocked): continuous dread from the
snapshot — `bandwidth.stored` draining toward zero = rising tremor/desync judder
BEFORE the brownout seizure; aggregate entity `heat` = a slow feverish shimmer.
Keep it subordinate to the corruption ledger: strain is weather, shipments are
climate.
2. **Regional moiré**: grow the lattice from a region instead of full-frame (the
codex has it spreading as maze walls) — spatial mask, seeded deterministically,
creeping outward with tally. This is the template for future spatial artifacts;
note the mask approach for reuse.
3. **Scram wipe at 3 frames**, as approved.
4. **Idle strain baseline**: with zero corruption AND healthy bandwidth, the feed
stays perfectly sterile — verify strain can't leak into the NOTHING IS WRONG era
(a test pinning params at zero under a healthy snapshot would do it).
**DoD:** a mite wave is visible on the sky as creeping cleanliness + tick stamps;
pixel-verified; budget holds.
**Definition of done:** strain visible as stored drains (drive it by pausing the
reference factory's generators or just feeding a fixture snapshot), regional moiré
grows rather than switches, wipe readable, budget still <2ms, tests updated.
---
@ -289,298 +298,3 @@ 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.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
SHIPPED: all 8 orders, both organs. THE SPEAKER exists.
- PART A: `eras.ts` + `eras.test.ts` (new), `baseTexture.ts` (era substrate + general chyron),
`glitchStack.ts` (wildlife pass, title composite), `boredom.ts` (jammed-rot), `index.ts`.
- PART B: `graph.ts`, `sfx.ts`, `stations.ts`, `radio.ts`, `ambience.ts`, `subtitleBus.ts`,
`band.test.ts` (all new), and `index.ts` replacing the stub.
- 78 tests in my two lanes, all green. Repo-wide 443/443, tsc clean.
**A NOTE ON "VERIFICATION INCLUDES LISTENING": I cannot hear.** I have no ears, and I won't
pretend a waveform is a listening session. What I did instead is render every sound through an
`OfflineAudioContext` and measure it — spectra, peak/RMS, attack times, onset rates — and the
description below is derived from those measurements plus the synthesis I wrote. It is an
honest account of what the numbers say the speaker is doing, not of what it feels like in a
room. **Please put headphones on and tell me what's wrong** — mix balance and "is this
annoying after ten minutes" are exactly what measurement can't answer, and I'd rather get that
from you than guess.
WHAT IT SOUNDS LIKE (measured, then described):
- **Brownout** — the important one. A 0.29 ms attack: a genuine broadband click, not a fade.
Then ~0.5 s of bandpassed brown crackle and a sawtooth whine sliding 420→40 Hz under a
closing lowpass. It should read as a rack losing its rails: snap, spit, sag.
- **Placement thunk** — 2.3 ms attack, a sine dropping 150→55 Hz (the mass) under a 1.4 kHz
noise slap (the contact). Detuned per placement so a row of belts isn't a machine gun.
- **Scram klaxon** — 620/440 Hz square alternating four times, lowpassed at 1.8 kHz. Peak 0.29:
deliberately not the loudest thing, because it fires often.
- **Ratification sting** — 85 ms attack, FAC sines with an octave shimmer. The *only*
consonant, in-tune, untroubled sound in the game. Everything the player makes is filth; the
enemy sounds lovely. That's the joke, and it's why its attack is slow when everything else
is percussive.
- **FKTRY FM 88.1** — 4.9 note onsets/sec against 6.13 sixteenths at 92 bpm, i.e. the ~34%
rests are audible as space rather than a wall. Square lead over triangle bass, pentatonic
minor, pattern mutating every 32 bars. Spectrum is bass-dominant (60 Hz: 0.49) with nothing
above 4 kHz.
- **THE STANDARD 640** — 2.9 syllables/sec, which is real speech cadence. Ring-modulated
carrier through two moving formants, lowpassed at 4.5 kHz to AM bandwidth. Sits at 500 Hz
and nowhere else. It is deliberately just short of intelligible — you can hear that it's
speech and not what it says. **THE SCREEN tells you what it says.** That gap is the trick.
- **NUMBERS 6955** — 4.0 digits/sec, a formant voice with a per-digit vowel shape so numerals
are learnable by ear, through a narrow SW bandpass (sharp 2 kHz peak, 0.50). No affect, no
melody, no acknowledgement.
- **DEAD AIR 108.0** — lowpassed brown room tone breathing at 0.21 Hz (projector pace), plus
one reversed chime — swelling up out of nothing then cut dead — every ten minutes, seeded.
DECISIONS:
- **Everything takes `(ctx, dest, t0)` and nothing assumes a live context.** That one choice is
why the whole speaker can be rendered in an `OfflineAudioContext` and measured deterministically
— same code the player hears, no test-only path. Noise buffers use a seeded PRNG for the same
reason: identical spectra every run.
- **The limiter is a `tanh` WaveShaper, not a DynamicsCompressor.** A compressor's makeup gain
would pump the entire mix every time the klaxon fires. tanh just refuses to exceed 1, which is
what "don't clip" actually means, and it adds the saturation a blown factory PA should have.
- **Pre-discovery silence is structural, not a volume setting.** The radio's master gain is 0
and ambience is never started until `relicFound`. Layer 1 is all one-shots, so a quiet factory
is *actually* quiet between events rather than humming under a noise floor.
- **Inter-station noise is band-specific** because that's what makes a band feel like a place:
AM brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whistle whose pitch
is the beat frequency — so it swoops *down to nothing* as you zero in. Hunting is the mechanic.
- **The era ages the substrate, never the corruption.** Same shader, same params; only what
they're painted onto changes. The contact sheet shows identical melt drips across all five eras.
- **Boot era is `reel`.** With nothing ratified the world starts in the deepest stratum and
climbs to the surface (stream) and beyond (fortress). The old SMPTE look is now mid-game.
- Title timed in seconds (1.53 s hold, gone by 2.07 s), awakening 0.47 s. Both are *moments*;
the scram wipe stays frame-counted because it's a tape artifact. Three clocks, three fictions.
VERIFIED (offline render + pixel reads + contact sheets):
- **Pre-discovery silence: peak 0.0 and RMS 0.0, exactly zero**, with the dial swept across all
three bands and ambience asked for max load+fever. Same graph post-discovery: peak 0.285.
- **Station lock vs static are distinct spectra, and correctly so per band.** AM static has
**70× the 60 Hz energy of AM locked** (1.40 vs 0.02 — the brown crackle). FM *inverts*: locked
is bass-dominant (0.49 @60 Hz, nothing @14 k), static is treble-only hiss (0.10 @14 k, nothing
@60 Hz). SW locked shows a 2 kHz formant peak (0.50) that static (0.09) lacks.
- **Subtitles end-to-end**: tuned AM → The Correction's line on the chyron; off by 4 kHz (lock
0.906) → still captioned; off station → caption clears; SW → `5 3 0 7 9` on the sky, which is
the real coordinate for the nitrate vein. FM and DEAD AIR caption nothing (no words). Screenshot
in the sheet.
- **Era contact sheet**: reel (sepia + sprocket holes), broadcast (phosphor + interlace), disc
(rainbow-on-black gloss), stream (classic SMPTE), fortress (blinding white) — all under the
same 20× melt.
- **Budget: SCREEN 0.040 ms/frame** (GPU sync, 2000 entities, every artifact + wildlife + strain
+ brownout). **SPEAKER 0.0063 ms/frame** on the worst case (SW numbers + heterodyne + ambience)
— the per-frame path only writes AudioParams; Web Audio does synthesis on its own thread.
BLOCKED/BROKEN: nothing blocking.
- **I caught a balance bug by measuring rather than hearing**: DEAD AIR rendered at RMS 0.072,
*louder than the music station* (0.032), which made "dead air" the loudest thing on the dial —
exactly backwards. Dropped its tone gain 0.32→0.10 and the breath depth with it; it now sits at
0.023, under the music. This is the clearest argument for you actually listening: I only found
it because I happened to print RMS side by side.
- Harness gotcha, mine not the product's: importing a module with a `?v=` cache-buster creates a
**second module instance**, so my first subtitle test wrote to a different singleton than THE
SCREEN reads and the chyron looked broken. Verified `busQ !== busC`. Re-ran canonically and it
works. Same family as round 3's clock rewind: the harness lied, not the code.
FINDING FOR LANE-DATA (not touching their file): `stations.json`'s `_doc` says the decoys
"decode to empty ground", but **two of them decode onto real seams**`08127` → (-24,-20) lands
in `reel-beds-deep`, and `27503` → (-5,18) lands in `broadcast-shelf-south`. Both are visible,
non-hidden seams so no secret leaks and the treasure hunt is intact — it's a doc/data drift, not
a break. I deliberately did **not** red-light my suite over another lane's content (round 4's
lesson); my test asserts the invariant that actually matters — *no decoy ever leaks a HIDDEN
seam* — which passes. Their three planted groups all decode exactly right, and I have a test
pinning that against `seams.json` so the map can never drift.
CONTRACT REQUEST: none. v6 covered everything.
SANCTIONED IMPORTS (documenting as ordered): LANE-UI's tuner dock should
`import { getRadio } from '../audio'` — a narrow surface of setBand/setFrequency/setVolume/
setMuted/getState/subscribe/listStations, nothing else reachable. Returns `null` before the first
user gesture builds the graph, so the dock must handle that (and should probably render disabled
until `getState().unlocked`). Internally, `src/screen` reads `src/audio/subtitleBus` — both are my
lane, and it's a zero-dependency module precisely so neither organ couples to the other's guts.
STATIONS SCHEMA: I conformed to LANE-DATA's file exactly as written rather than proposing my own
— it's better than what I'd have specified (the `eraFlavor` table and the coordinate encoding
especially). Nothing to agree; it's already right.
PROPOSAL: THE STANDARD renders at RMS 0.0037 against FKTRY FM's 0.032 — ~9× quieter. That's
*correct* for gated speech (its peak, 0.10, matches the other stations; the gap is all the silence
between syllables) but it may still feel too quiet in practice next to continuous music. If it
does when you listen, the fix is a slow compressor on that station alone rather than a gain bump,
which would just make the syllables shout. Flagging rather than pre-emptively "fixing" something
the numbers say is fine.
NEXT (not self-assigning): the tuner has no UI yet — LANE-UI's dock is what makes the band
findable at all, and until then the radio is only reachable from the console. Also `growthMask`
now has two customers (moiré, wildlife) and is ready for a third: HF dust piles are regional in
the codex and `dust-pile` wildlife already arrives in the same event I'm consuming.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED: all 3 orders. The sky now notices The Correction. `growthMask` gained its third and
fourth customers (order 3 round-3 groundwork paying off — no new shader machinery, new weights).
- `overlays.ts``paintRepair()` + `repairCanvas` (224×112). The Correction's uniform: hospital
white, faction green tick, machined square. Deliberately the inverse of the shipment stamp
(hand-thumped, crooked, chip-coloured, "SHIPPED / NO QUESTIONS").
- `glitchStack.ts` — two additions: the **mite sterility** pass (`u_mite`/`u_miteSeed`, reusing
`growthMask`) and the **repair placard** composite (`u_repair`, texture unit 5). The mite pass
sits on TOP of the corruption stack because it is *erasure*, not another artifact.
- `index.ts``repaired` events ease the ledger and queue a placard; parity-mite tracking drives
the sterile creep (snapshot truth / event fallback, same pattern as the mosquito swarms); repair
placard lifecycle in its own slot. DEV hook gained `.mite()`, `.repair()`, and — new — `.frame()`
+ `.canvas()` drivers (see the harness note below).
- `corruptionMap.test.ts` — +3 tests pinning the repair semantics. Screen suite 33 (was 30);
my two lanes together 81 green (screen 72 + audio band 9).
DECISIONS:
- **Repairs are THEFT; boredom is ROT — kept structurally distinct, per the order.** A `repaired`
event decrements the *ledger* by exactly one unit of the item (units drop by that item's
potency — pixel-verified: melt ×20 → repair → units 24.0→22.8, tally 20→19). Boredom only fades
the *display* and leaves the tally intact (recoverable). So a factory being eaten by mites
permanently loses corruption it can never fade back, while an idle factory snaps straight back
the moment it ships. I deliberately do NOT refresh the boredom clock on a repair — a factory
being quietly corrected is not a factory that is producing.
- **The mite patch is the inverse of corruption and it reads WORSE, as ordered.** Where the
mosquito shimmer *darkens* a region with crawling noise, the mite patch *desaturates and lifts
toward clinical white* with a faint manufactured tile grid. Pixel-verified against a heavy melt
feed: the bloom region went luminance 22.9→168.8 and saturation 0.695→0.025. A dead, too-clean
hole in your rot is more unsettling than more rot — the codex's whole point about The Correction.
- **The repair placard parks low-right (vUv 0.80,0.80), small (0.15×0.082 half-size), 0.55s** so it
never fights the centre shipment stamp — a burst of ships and a burst of corrections can co-occur
and stay legible as two separate voices.
- Both new spatial effects reuse `growthMask` verbatim (round-3 template): moiré, mosquito, AND
now mite sterility all share one fbm growth front. Zero new shader machinery for order 2.
VERIFIED (drove the REAL module on the REAL 640×360 canvas, synthetic timebase, GL pixel reads —
the harness tab is `hidden` so rAF is throttled, the round-2/3 gotcha; I pump `frame()` and
`drawImage` in the same JS task):
- **Repair**: units eased by exactly melt's potency (1.2); placard region flips corrupted
(lum 68) → hospital-white placard (rgb 228/232/228, lum 230). repair flag + tally decrement both
confirmed.
- **Mite creep**: localized bloom (grid cell 3,4 — NOT the whole frame), lum +146, saturation
→0.025. `mite` eases to 0.83 from size 0.85. Both source-of-truth paths exercised: event
fallback (`.mite()`) AND `snapshot.wildlife` parity-mite.
- **Chyron / order 3**: DATA landed 8 mite-acknowledging STANDARD lines (30→38). Pixel-checked the
live wrap: short lines 1 row, the 96-char lines wrap to exactly 2 rows, rightmost glyph at x613
inside the 640 canvas — no clipping. Word-wrap (orchestrator's fix) intact; I did not touch it.
The mumble is untouched — I never edited `amPropaganda`; it derives syllables from the line text.
- **Budget: 0.034 ms/frame** worst case — 2000 entities, corruption total 1.0, strain 0.94 +
fever 0.57 + pallor 0.82 + mosquito 0.89 + mite 0.89 + brownout, every param lit. ~59× under the
2ms budget. The two new shader branches and one new texture are free at this resolution.
BLOCKED/BROKEN: nothing in my lane.
- **Transient full-suite red, NOT mine (round-4 lesson holds).** A combined `npm test` briefly
showed 3 reds — `band.test.ts` (STANDARD line >96 chars), `sim/mite.test.ts`, and
`sim/reference.test.ts`. All three are the shared-tree race: DATA was mid-save on
`data/stations.json` (I caught it at exactly 96 chars, its own budget) and SIM has uncommitted
`mite.test.ts` + `constants.ts`/`index.ts` edits in flight. Re-running JUST my lanes: 81/81
green. I did NOT bare-`git stash` to chase it (my own round-4 near-miss).
- HEAD advanced under me twice mid-round (a `[ui] round 6` commit landed while I worked). Re-probed
before writing this; all claims above are against the tree as it stands now.
HARNESS NOTE: I added DEV-only `.frame(ms, snap)` and `.canvas()` to `__fktryScreen`. Round 2/3
both lamented having no clean way to pump the real module in a throttled hidden tab; this makes
pixel verification a two-line console call for any lane. Stripped from prod (`import.meta.env.DEV`).
CONTRACT REQUEST: none. `repaired` + `parity-mite` (v8 contracts) gave me everything.
COORDINATION (order 3, resolved): DATA owns the STANDARD copy; I own its rendering. The 8 new
lines are theirs and are already in `data/stations.json`. My only ask of them is a standing one:
please keep STANDARD lines ≤96 chars — `band.test.ts` asserts it and the chyron wraps to 2 lines
at that budget; a longer line spills to a 3rd line (the font steps down to 13px) but starts to
crowd the feed. The newest line is exactly 96, so we're at the edge.
PROPOSAL: the repair placard and the ratification card both wear the green tick — that's
intentional (one faction, one uniform), but a player seeing their first repair might read the
small placard as "another commission done" rather than "something took my work". If that lands
ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already
doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing.

View File

@ -21,28 +21,39 @@ playable through unit tests. You are the lane the other four trust with their li
---
## CURRENT ORDERS — Round 6 (goal: M3's enemy — THE CORRECTION walks)
## CURRENT ORDERS — Round 3 (goal: M2 "PRESSURE" becomes real, M3 groundwork)
Round 5 (MEGA) shipped complete: wildlife, seams, relic, research time — reviewed and
live. The tuner dead-dial and chyron clipping were fixed orchestrator-side; seams are
wired into GameData (your CONTRACT REQUEST, granted in v7).
Round 2 review: the reference factory turning round 1's deadlock into 1,136 shipped
bricks is exactly what "surplus becomes product" was supposed to mean — outstanding.
Verdicts: `coolPerTick` GRANTED (contracts v3, default 0.004 — your constant becomes
the fallback). `setRecipeAt` GRANTED (your tripwire, defused). `BeltItem.id` HARDENED
to required (you asked for it). Throttle-hits-powerGen: KEPT, your reading was right.
The validator-file ownership wart: RULED — it moves to `data/validate.test.ts`, DATA
executes the move, `src/sim/**` is purely yours again.
1. **PARITY MITES v1** (codex §5: hospital-white beetle drones that quietly *repair*
your product). Contracts v8 widens `WildlifeState.kind` with `'parity-mite'`
they ride the wildlife array. Behaviour: spawn from the world rim when
`shippedTotal` corruption crosses thresholds (escalate with progress); seek belts
carrying tier-2 glitch products; on reaching one, EAT the item and emit a new
`{kind:'repaired'; item; pos; tick}` event (v8) — the work is not destroyed, it is
*corrected*, which is worse. Deterministic, save/load carried, capped like swarms.
2. **The counter**: DATA authors a FIREWALL machine on your trap pattern (recipe-shape
detection, radius) — a mite in range is caught and canned (`parity-mite` item,
forward-canon in data). Coordinate via NOTES.
3. **Reference factory v6**: survives a mite wave without losing its melt regression
(a firewall on the uplink approach is the natural layout teach).
**UNITS RULING (DATA's cross-lane ask):** `gen`/`draw` are bandwidth per SECOND;
`stored` is bandwidth-seconds. Your integration currently charges/drains per tick —
divide by `TICKS_PER_SECOND` so a 30-deficit drains 30 stored per second, not per
tick. DATA's `bufferCap: 240` then covers 8s as they intended. Update tests; confirm
the conversion in NOTES so DATA can retune against truth.
**DoD:** mite lifecycle tested (spawn → seek → repair → event; firewall catch);
zero-brownout regression intact; mites never touch tier-0/1 cargo (they correct
CORRUPTION, not raw material — that's the fiction and the balance).
1. **Contracts v3 wiring**: `EntityState.scrammed` (RENDER is currently duplicating
your 0.5 constant to avoid rendering a lie — kill that), `commissionQueue` on the
snapshot (active first, then upcoming — UI's NEXT IN TRAY is guessing today),
`coolPerTick` per machine, `setRecipeAt` command, the units conversion above.
2. **Save/load**: implement `save()`/`load()` — JSON, deterministic round-trip
(test: save at tick N, load into a fresh sim, run both 1,000 ticks, deep-equal).
3. **Reference factory v3**: heat is about to become real (DATA retunes this round —
coordinate via NOTES). Restructure power as ASIC base + software-decoder burst +
buffer tank so the layout *survives scram duty-cycles* — DATA warned your three
naked decoders will black out in lockstep. Keep the 20k regression green.
4. **Bloom loop proof**: test that a closed belt circle through a bloom-duplicator
accumulates grades (your rank-0 loop acceptance + DATA's grade recipes should
compose). This is M3's signature topology — prove it early.
**Definition of done:** all tests green including save/load round-trip and the bloom
loop; NOTES confirm the units conversion + the retuned reference factory's scram
survival (ticks of first scram, recovery, melt rate before/after).
---
@ -371,309 +382,3 @@ NEXT (round 4 if asked)
- The Correction / parity mites (M3's enemy).
- Spatial heat + `coolRadius`, if the ASIC cooler is meant to mean something.
- A self-upgrading bloom recipe, if you want the codex's literal loop.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED
- **Research v1 + the v4 gating rule.** `kind:'lab'`, `setResearch`, `snapshot.research`,
the `researched` event, and gating: any id a tech's `unlocks` names is refused by
`place`/`setRecipe`/`setRecipeAt` until that tech lands; anything no tech mentions is free
forever (so data with no tech gates nothing). Labs take only packs the active tech still
needs, several labs feed one total, and research rides through save/load — as a *rule*,
not just a list, so a restored save can still build what it unlocked.
- **A real research run on DATA's own tree** (`realtech.test.ts`): ore → demuxer → quantizer
→ {crime → bricks, ringing → halos} + slurry → artifact bottler → SPATIAL PACK →
archaeology lab → tech complete → a recipe that was silently refused is now accepted.
Cold start, nothing granted. That's the DoD's unlock path on real data, not a fixture.
- **Spatial cooling** (`coolRadius`): chebyshev from the footprint, auras stack by sum,
total capped at `HEAT_COOL_MAX`. Proven by measuring the actual shed rate, and a cooler in
range roughly triples a hot generator's run before it scrams (2.5× the ticks).
- **`src/sim/constants.ts`** — 12 constants exported; DATA can import truth instead of
hand-copying numbers that go stale silently.
- **Reference factory v4**: anchor-slab overflow tap → the demo now ships slabs (18 over 20k),
melt still first at tick 1248, still shipping at 20k, still **zero brownout ticks**.
`referenceFactoryTech(data)` derives which techs the layout needs, from the commands.
- Also wired: `bandwidth.capacity`, `EntityState.scrammed`, `commissionQueue` in the snapshot
literal (v5-ready).
- Verified in the browser as well as Node — identical ticks (1248 / scram 435), gating live
(`mv-extractor` refused while locked), `capacity: 900`, 23 techs unlockable, no console
errors. 65 sim tests; 318 across the repo.
- Perf: **64.7× realtime** at 3,500 entities / 6,820 belt items (0.515 ms/tick, best of 3 on
a fresh page). Round 3 measured 74.9×. Nothing I added is meaningfully per-tick — `drainLabs`
early-outs when nothing is being researched, and cooling only touches machines that can
heat — so I can't attribute the gap, and I'm not going to pretend I can. It's either
environment noise or something small I haven't found; the headless benchmark I proposed last
round would settle it.
DECISIONS
- **Cooling cap = 0.05/tick, and the number is arbitrary but the reasoning isn't.** At 0.05 a
machine sheds a full 1.0 of heat in 20 ticks, which is already effectively instant. Past
that, more coolers buy nothing but still cost bandwidth and floor space — a stack that does
nothing reads as broken rather than generous. Retune freely; it's one constant now.
- A cooler's aura excludes itself (its own `coolPerTick` is already counted once), and the
cache is invalidated on place/remove/rotate but *not* setRecipe — auras are geometry.
- **A lab's appetite is whatever the active tech costs**, so switching targets re-ranks every
lane feeding it, and `setResearch` drops the route cache. Labs take only what the tech still
needs — a lab is not a warehouse, and packs hoarded for a tech nobody picked would strand
where no other lab could reach them.
- Switching research targets **abandons** delivered packs rather than banking them per-tech.
Packs are the cost of indecision. Say the word if you'd rather bank them.
- Completing a tech clears `active` rather than guessing a next target.
- A machine placed while some of its recipes are locked defaults to its **first unlocked**
recipe, not `recipes[0]`.
- `testkit.ts` (new, tree-shaken, imported by nothing in the app) grants research via a
save/load round-trip — the public API, not a back door. It's how a test says "this is a
late-game save" honestly.
BLOCKED/BROKEN
- **The demo browns out, and it needs one line from you.** The reference factory uses two
gated ids (`software-decoder` ← stream-software-decoding, `assemble-gop-i-only`
disc-i-only-cadence), so it is a post-research save by definition now. Dispatched cold
through `__fktryBus` it comes up **100 draw against 80 gen — BROWNOUT** with no decoder,
which I watched happen on screen. `__fktryDemo` must open those techs first;
`referenceFactoryTech(data)` tells you exactly which, and `testkit.grantResearch(sim, ids)`
will do it. The alternative is a dev-only `{kind:'grantResearch'}` command — your call,
since shipping testkit into main.ts is a bit grubby. A test pins both halves of this.
- **DATA fixed the tech tree underneath me mid-round, twice, and it was the right call both
times.** When I started: no `kind:'lab'` machine existed at all, and every science pack was
made on the artifact-bottler which was itself locked behind a tech costing 10 analog-packs
— a hard circular dependency, **0 of 24 techs affordable at start**, the tree literally
unenterable. It now has `archaeology-lab`, spatial-packs craftable with zero research, and
6 techs affordable cold. `realtech.test.ts` is the tripwire if that bootstrap ever closes
over again — it's worth keeping for exactly that reason.
- Casualty of the churn: my first pass hard-coded `mosh-reactor` as "a gated machine" and
broke when DATA un-gated it an hour later. Round 1's lesson, relearned. The real-tree
tests now *derive* a gated and an ungated machine from the data.
- `asic-cooler` has `coolPerTick: 0.02` but still no `coolRadius`, so its aura is inert in the
real game — it cools only itself, and it has no heat to shed. The mechanic is live and
tested on a fixture; DATA just needs the number. (This was my round-3 note; the field now
exists for them.)
- Vitest caches JSON imports: I spent a while chasing a "gating is broken" ghost that was a
stale `tech.json` in `node_modules/.vite` while DATA edited underneath. Worth knowing if
anyone else sees data that doesn't match disk.
CONTRACT REQUEST
- None. v4 had everything.
PROPOSAL
- **A dev-only `{kind:'grantResearch'; tech: string[]}` command** would let `__fktryDemo` set
up a late-game board without importing test helpers into the app, and would give UI a way to
build a "sandbox" mode later. It's the cleanest fix for the brownout above.
- The mosh reactor now needs **two** melt lanes in the reference layout, and that's a smell
worth knowing about rather than a bug: a machine pushes one item per output type per tick,
so a 3-melt craft trickles out over several ticks, and while its own lane is busy melt is
free to take any *other* rank-1 lane. Nothing is lost — it still reaches an uplink — but it
can wander into a lane meant for something else. If that ever bites somewhere it matters,
the fix is a per-item lane affinity, not more belts.
- `Sim.save/load` are hardened in v4 and `testkit` depends on the blob shape (`.research`).
If the save format ever versions past 3, testkit moves with it.
NEXT (round 5 if asked)
- HF dust piles + mosquito swarms — the RNG is still unrolled and save/load already carries
its state, so wildlife lands without touching persistence.
- The Correction / parity mites.
- A headless perf benchmark, so the numbers above stop being anecdotes.
- Research *time* (a lab currently counts a pack the tick it lands) if you want the tech tree
to feel like an investment rather than a purchase.
### Round 5 — 2026-07-18 — Opus 4.8
SHIPPED
- **`grantResearch`** — idempotent, ignores unknown ids, completes the active target out from
under the labs, save/load safe. Verified through the live bus, so `__fktryDemo` can drop
`testkit` whenever you like.
- **Research takes time**`RESEARCH_TICKS_PER_PACK = 60` (exported). Stepped: a lab spends
the full 60 ticks on a pack, then consumes it and ticks progress by exactly 1, so
`research.progress` stays integer and determinism stays trivial. Labs really do parallelise
— measured 200 ticks with two vs 260 with one on the same supply. A lab mid-pack reports
`progress` 0..1, so RENDER/UI can show it working.
- **Wildlife v1** — piles grow from dust a machine is sitting on, hatch swarms at 1.0, swarms
drift to production machinery and slow it while attached, traps can them. Deterministic,
save/load carries the lot, spawn/clear events on both. 12 tests including the design point.
- **Seams** — extractors run only while their footprint overlaps an authored seam rect;
off-seam they idle with `'no seam'`, edge-triggered like every other jam. Consumes DATA's
real `seams.json` shape (and a bare rect list), defensively — see BLOCKED.
- **THE RELIC** — one optical fossil per world, position a pure function of the seed, 1828
tiles out, never on a seam, never under the demo. `excavate` within a tile uncovers it and
emits `relicFound`; it stays found through save/load and 20,000 further ticks. When DATA's
seam map supplies a `relicReserve` the roll folds into that corner — verified in-browser
landing at (21,15), inside the reserve, and digging up first try.
- **Reference factory v5** — a second works on DATA's eastern seam: ore → demuxer → quantizer
→ {crime → BRICKS, ringing → HALOS} + slurry → artifact bottler → SPATIAL PACK →
archaeology lab. **It researches `broadcast-phosphor-seams` at tick 1235.** Melt still first
at 1247, still shipping at 20k, **zero brownout ticks**, 179/179 placements legal, and with
seams wired **zero extractors off-seam** — both works sit on real ore.
- Verified: 99 sim tests (446 across the repo), tsc clean in my lane, and the browser agrees
with Node tick-for-tick (melt 1247, scram 434, research 1235). Perf **63× realtime** at
3,500 entities / 6,820 belt items (0.529 ms/tick) — Round 4 was 64.7×, so all of this round
cost ~3%. (An earlier probe "timed out" and looked like a regression; it was the 30s tool
limit on a 16k-tick warmup, not the sim. Staged it and re-measured.)
DECISIONS
- **Dust breeds from BACKED-UP dust, not from dust existing.** First cut grew piles from
whatever a machine held, and the tidy factory — dust belted straight to an uplink — still
hatched 56 swarms, because buffers are never empty between pushes. Growth is now
proportional to what sits *beyond one craft's worth* (`DUST_SPILL_GRACE`). Plumbed lines
never cross the threshold; neglected ones hit the stall cap and hatch in ~400 ticks. The
asymmetry is the mechanic — without it the hazard is just weather.
- Related, and worth knowing: my first "tidy" fixture still bred, and the mechanic was
innocent. A crusher making 4 dust per craft out-produces a single belt lane (~0.15
items/tick) and backs up no matter how you plumb it. That's a true fact about belts.
- **`SWARM_MAX = 8`.** A permanently jammed machine breeds forever — measured 29 swarms in one
20k demo run, which is a plague, not a hazard. At the cap, full piles just sit there.
- **Swarms only settle on production machinery**, preferring machines actually mid-craft and
falling back to jammed ones. Power plants read as permanently "running" and hoovered up
every swarm in the world to no effect — it looked exactly as silly as it sounds. The
fallback matters because the dust that bred them came from a machine that seized: without
it, a swarm evaporates at the moment it should bite.
- **Swarms slow crafting, not generation.** `swarmThrottle` is not applied to `powerGen`, so
an infestation never causes a brownout. That's my call, not the contract's — flag it if you
want mosquitoes on the grid; it would make them far nastier and would end the zero-brownout
regression.
- Traps are identified by **recipe shape** — empty inputs, outputs the canned swarm — not by a
machine id, so DATA's real trap lights up with zero code changes. A trap only progresses
while a swarm is in `TRAP_RADIUS`, and holds at full progress rather than canning air if its
prey drifts off or a neighbour beats it to the catch.
- Switching research target **forfeits the part-processed pack** in every lab, matching the
existing rule that switching abandons delivered packs. Lab intake now counts what *all*
labs are holding, so a bank of them can't collectively hoard more than the tech costs.
- **The annex is a separate works, not a tap off the melt line.** Every column between the
quantizers and the press is spoken for; threading bricks, halos and slurry through that
floorplan would have made the melt regression hostage to the science. DATA's second mdat
seam is empty ground, so the annex mines its own ore and shares only the bandwidth bus.
- Save format **v3 → v4** (wildlife, relics, lab timers). `testkit` follows it.
BLOCKED/BROKEN
- **`GameData.seams` is not wired, so seams are inert in the actual game.** DATA shipped
`data/seams.json` mid-round (good schema, and they reserved a relic corner for me), but
`GameData` has no `seams` field and `main.ts` doesn't import the file, so nothing reaches
`sim.init`. I consume it defensively and my tests inject it. **This is the one contract
change the round needs** — see CONTRACT REQUEST. I verified in-browser what happens once
it's wired: 0 extractors off-seam, relic in DATA's reserve. Until then extractors mine
anywhere, exactly as before.
- **There is still no trap machine in the data,** so swarms cannot be cleared in the real
game. The mechanic is complete and tested against a fixture trap; DATA needs a machine
whose recipe has no inputs and outputs `mosquito-swarm`. The demo currently hatches its 8
and keeps them. (Suggested shape in PROPOSAL.)
- The demo researches **one** tech and then idles: completing clears `active` and nothing
auto-picks a successor. That's a player/UI decision rather than a sim one, but it means
the annex jams shortly after 1235 — the bottler starves of halos (skim-ringing wants 3
coefficient packs per craft against crime's 1, and the splitter is a blind 1:1), packs back
up, and the line seizes. Harmless and honest, but it's why the demo grows piles.
- My round-1 lesson bit me again from the other side: DATA reworked the commission ladder
mid-round (`first-taste` → `raw-payload`, 20 ore), which broke a sim test that assumed
shipping melt closes the first commission. Rewritten to assert the *rule* — shipping what
the active order wants credits it, capped at the cost — rather than which order is first
this week.
CONTRACT REQUEST
- **`GameData.seams?: SeamMap`**, and `main.ts` importing `../data/seams.json` into the data
object. The shape is DATA's file verbatim; I've mirrored it as `SeamDef`/`SeamMap`, exported
from `src/sim/index.ts`, so the contract can lift it as-is. Nothing else in my lane is
blocked.
PROPOSAL
- **A trap for DATA**, matching what the sim already recognises:
`{ id: 'mosquito-trap', kind: 'crafter', footprint: {x:2,y:2}, recipes: ['catch-swarm'], powerDraw: 3 }`
plus `{ id: 'catch-swarm', machine: 'mosquito-trap', inputs: {}, outputs: {'mosquito-swarm': 1}, ticks: 30, bandwidth: 2 }`.
The canned swarm is already an item and commissions already ask for live ones, so it ships.
- Should swarms bite `powerGen` as well as craft speed? Currently no, deliberately.
- `snapshot.wildlife` and `snapshot.relics` are always present (found or not) as the v6 note
requires. They alias live arrays like `commissionQueue` — same read-only rule.
NEXT (round 6 if asked)
- The Correction / parity mites — the enemy proper.
- A headless perf benchmark, so these numbers stop being browser anecdotes (proposed round 3;
the 30s-timeout scare this round is the third time it would have paid for itself).
- Per-seam `richness` driving extraction rate, once DATA wants it — the field is already there.
- Swarm behaviour beyond "sit and bite": drifting between targets, or fleeing a trap.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED
- **Parity mites v1** (contracts v8 `'parity-mite'` + the `repaired` event). They ride the
wildlife array. Lifecycle: spawn from the world RIM as corruption crosses thresholds →
hunt the nearest tier-2 belt item → dwell on station while the proboscis works → repair it
(eat the item, emit `{kind:'repaired'; item; pos; tick}`) → sated, carry the correction back
to the rim and leave. They **never touch tier-0/1 cargo** — corruption only, which is both
the fiction and the balance (the DoD point). Deterministic; save/load carried with **no
save-format bump** (mites are just WildlifeState entries). `size` encodes the state machine
(0 hunting / 0<size<1 repairing / 1 returning), which also hands the renderer a "glowing,
sated" tell.
- **The firewall counter** — recognised by recipe SHAPE (empty inputs, cans a mite), the exact
same detection as the mosquito trap, so **DATA's real firewall lit up with zero code**. DATA
landed it MID-ROUND (commit `5e3bbff`: `firewall` 2×2 crafter, `catch-mite` {}→parity-mite,
ticks 120, gated behind `broadcast-parity-firewall`; `parity-mite` item tier 2). Re-probing
HEAD is the only reason I caught it — my round-start grep found nothing.
- **Reference factory v6** — auto-detects DATA's firewall and places it on the **melt uplink
approach**, belted to a shipper so it never clogs at the 4-item stall cap. 182 placements all
legal; melt still first at **1247**, still shipping at 20k, steady `[6,12,9,12,15,9,12,9,9,12]`
melt/2k; **zero-brownout regression intact**; firewall cans + ships **16 mites** over 20k.
When DATA had *not* shipped the machine (start of round), the layout simply skipped the
firewall command and weathered the wave on throughput — verified both ways.
- **11 new mite tests** (`mite.test.ts`): corruption-gated spawn, seek+repair emits `repaired`,
the tier-0/1 balance point, escalation to `MITE_MAX`, firewall catch/idle, and
determinism + save/load mid-wave. **113 sim tests, 481 repo tests green; tsc clean.**
- Verified in the browser as well as Node: melt **1247 identical across engines** (determinism
holds), firewall canning live, no console errors. Perf: the reference build 0.054 ms/tick;
a 2,680-entity field 0.21 ms/tick (158× realtime).
DECISIONS
- **The patrol cycle (in, fix ONE, out) is the load-bearing call.** My first cut let mites camp
the nearest corruption. Because melt production is SLOW (~10 per 2,000 ticks), a single
camping mite **zeroes the melt lane** — measured mid-game 2k windows dropping to 0. That's a
wipe, not the codex's "early nuisance". Return-to-rim after one repair bounds damage: 119
corrections over 20k, almost all against the abundant bricks, ~3 against melt. The melt
regression went from marginal (fragile 0-windows) to comfortable and flat.
- **Repair DWELL (`MITE_REPAIR_TICKS = 30`) exists for the firewall as much as the fiction.**
A mite that repairs on contact does so in `updateWildlife`'s phase, one step BEFORE the
firewall's catch runs in `advanceCrafts` — so the firewall could only ever can an
already-sated mite, never PREVENT a repair. Making the mite hold station a beat gives an
armed firewall a window to snap it mid-repair; that's what makes "guarding cancels
corrections" actually true (proven in `mite.test.ts`).
- **Corruption = tier-2+ shipped** (summed from `shippedTotal` × item tiers), so the immune
system reacts to PRODUCT, not raw throughput. `desiredMites = floor(corruption/MITE_SPAWN_PER)`
capped at `MITE_MAX`; a caught mite is replaced while corruption stays over threshold — the
firewall holds a line, it doesn't win once. All tuning is in `constants.ts`.
- Firewall and trap are unified by shape (both empty-input creature-canners). `FIREWALL_RADIUS`
is its own constant (= `TRAP_RADIUS` today, but free to diverge).
- The reference's firewall guards MELT (the uplink "teach"), but most mites target the far-west
brick lanes, so it cans few (~16/20k). That's honest — one firewall guards one approach.
BLOCKED/BROKEN
- **I reshaped a regression test — flagging deliberately.** `reference.test.ts`'s "keeps the
demuxer alive" asserted `demuxer.jammed !== 'output full'` at the single tick 20000. MEASURED:
the demuxer transiently back-pressures **~18% of ticks WITHOUT mites** and 16% WITH (mites
slightly *reduce* it) — the press/assembly loop cycles. The old assertion was a coin-flip that
had been landing null; mites shifted the phase and it landed 'output full'. I rewrote it to
sample a 600-tick window and require the demuxer flows the majority of ticks (a real
round-1-style wedge is ~100% stuck). Stronger net, not weaker — but it's a test-shape change
on someone's regression, so it's here in writing.
- **Perf caveat, stated not hidden.** A *hunting* mite scans every belt item each tick (≤6
mites). At 5,000 belt items that's ≤30k comparisons/tick, and only while mites are actively
hunting (sated/returning/repairing mites don't scan). Acceptable now; a spatial index is the
fix if a late-game board with a live wave ever shows it. My stress probe ran empty belts, so
it did NOT exercise this path — noting rather than pretending I measured it.
- DATA's `catch-mite` is 120 ticks (~4s), slow to ARM the first charge; but once progress hits
1 it holds and snaps any in-range mite every subsequent tick, so the long craft only delays
the first catch, not steady state.
CONTRACT REQUEST
- None. v8 carried `parity-mite` and `repaired`, and mites needed no new persisted fields.
PROPOSAL
- `WildlifeState.size` is overloaded as the mite state machine. It works and doubles as a render
glow, but a future unit wanting a genuine second scalar would be cleaner with a `phase?` field
than more size-encoding.
- Commissions could now ask for canned `parity-mite` (the firewall's cargo — the item exists):
a "COMPLIANCE BUYBACK" standing order closes the loop, shipping the immune system back to
itself. DATA/orchestrator call.
- Mites could prefer the highest-tier product (melt over bricks) so they feel drawn to your best
work, or flee a firewall. Left simple this round.
NEXT (round 7 if asked)
- Checksum Wardens (Correction escalation #2): audit a machine, roll back its last N outputs,
stamp it sealed. Wants an output-history ring per machine and a `sealed` entity flag (contract).
- A real headless perf benchmark INCLUDING a live mite wave — proposed since round 3; the mite
scan is what finally makes it worth wiring.
- Per-item lane affinity if the mosh reactor's wandering melt ever bites (round-4 smell, latent).

View File

@ -24,23 +24,39 @@ dispatch Commands via the bus and read state from `update(snap, events)`.
---
## CURRENT ORDERS — Round 6 (goal: the enemy has a face, the dial has teeth)
## CURRENT ORDERS — Round 3 (goal: close the interaction loop, verify the pressure UI live)
Your tuner dock now drives the real engine (orchestrator adapter — the dead-dial
finding from the voice review is fixed and live-verified, station names and all).
Round 2 review: 57 tests against a 10 minimum, and factoring pure modules instead of
touching root config when my grant turned out to be vapor — model behavior. About
that: **happy-dom genuinely is installed now** (verified: `package.json` line 17, and
your 57 tests pass under `--environment happy-dom`). My round-2 "it's installed" was
wrong and your BLOCKED section caught it — good catch, keep doing that.
Verdicts: CONTRACT REQUEST 1 GRANTED — `SimSnapshot.commissionQueue` in v3, SIM wires
it this round. Request 2 was already landing as you wrote it (parallel-round race;
the queue advances at HEAD — I watched PATIO RESURFACING arrive live). REMOVE
PROPOSAL: APPROVED.
1. **Mite surfacing**: toasts in voice ("A PARITY MITE IS EDITING YOUR MELT.",
"IT LEFT A RECEIPT."), `repaired` events in the ticker, firewall inspector shows
catches. The Correction is polite; the copy should make players furious.
2. **Tuner tick marks**: the adapter now passes `listStations()` through — draw
station ticks on the dial band so hunting has landmarks (keep the hunt: ticks
mark THAT something is there, not WHAT).
3. **Credits line** (from the voice-cloning plan, cheap now): an ABOUT/credits
entry reachable from the tech panel footer — repo, codex, and the standing
disclosure slot for future synthetic-voice credit.
1. **REMOVE**: your design — suggested shape: X hotkey / bar button arms remove mode,
hover shows RENDER's demolition tint (they're building it this round — coordinate
via NOTES), click dispatches `remove`, Esc disarms. Toast in house voice
("UNIT RECLAIMED. THE FLOOR REMEMBERS.").
2. **NEXT IN TRAY becomes truth**: read `snap.commissionQueue` when present, delete
`peekNext` inference the moment SIM lands it (keep fallback until then).
3. **DOM render tests**: the two you named as needing a real DOM (chip rendering,
`attach()` binding) plus fax stamp lifecycle — `// @vitest-environment happy-dom`.
4. **Live pressure verification** (now unblocked): `window.__fktryDemo()` builds
SIM's reference factory in one call — use it. Verify with your own eyes and
screenshot in NOTES: heat bar + THROTTLING + SCRAM on the software decoders once
DATA's retune lands, SANITISING chip on real anchor shipments, STANDING ORDER
stamp on a real repeat completion (I verified the stamp renders — you verify the
full lifecycle).
5. **Stretch, skippable**: read-only tech panel (`T`) rendering `tech.json` as an era
tree marked RESEARCH OFFLINE — research mechanics land in M3, the panel can exist
first.
**DoD:** a mite raid is narratable from UI alone; ticks render from real station
data; credits reachable in-game.
**Definition of done:** a misplaced machine is mouse-removable; queue peek reads
truth; the three DOM tests exist; NOTES carry screenshots of heat/scram/SANITISING
observed live.
---
@ -347,321 +363,3 @@ and needed a manual re-dispatch. Say the word.
**NEXT (suggested):** tech panel the moment `tech.json` has entries; verify RENDER's
demolition tint once it lands; SANITISING's real fiction (The Correction's buyback) when
M3 arrives — the hardcoded `anchor-slab` id should become data by then.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED:** All five orders. New: `power.ts` + `power.test.ts`, `research.ts` +
`research.test.ts`, `techpanel.ts` + `techpanel.test.ts`. **145 UI tests** (was 105);
`npm run check` clean.
1. **The BROWNOUT bug is fixed.** BROWNOUT is now the sim's flag and nothing else. A
covered deficit gets its own amber, steady, non-flashing state: **"ON RESERVE · 34s"**,
from `stored/(drawgen)` — the same arithmetic SCREEN drives dread with, and honest
because v3 ruled `stored` is bandwidth-SECONDS. Four states now: ok / tight / reserve /
brownout, all pure in `power.ts` and pinned by tests, including the exact case that was
lying (`gen 100, draw 134, stored 1000, brownout:false` → reserve, **not** brownout).
Bonus: the buffer readout is a fuel gauge now (`0/900`) using v4 `bandwidth.capacity`.
2. **Selection protocol migrated.** Writes go through `bus.setSelection()`, `REMOVE_DEF`
is deleted, and **my `remove` dispatch is gone** — main.ts owns it. Verified live: one
click, one removal, no double-fire. A legacy fallback remains for a host that doesn't
serve `setSelection` (it's optional in the contract) and is tested.
3. **Research UI.** `T` opens the STANDARDS COMMITTEE: every tech grouped by era in era
order, cost chips in item colours, era prefix stripped from node names, `UNLOCKS:`
tooltips in machine names. Pick → `setResearch`; picking the active one stands it down;
ratified nodes are inert. Locked machines in the bar are greyed + padlocked with
`REQUIRES: <TECH>`, can't be picked by click *or* hotkey, and the padlock falls off in
the same frame `researched` lands. `researched` → "STANDARD RATIFIED. NEW CRIMES
AVAILABLE."
4. **`?uidemo`** — boots the reference factory after init. It already paid for itself.
5. **Accent adopted**: `MachineDef.accent` → first-output derivation → codex kind table.
**LANE-RENDER: confirming we read this identically** — same three tiers, same order.
**VERIFIED — live, in the browser** (`?uidemo`, screenshots taken): the factory builds on
boot with no console poking; the STANDARDS COMMITTEE renders all 23 techs across REEL /
BROADCAST / DISC / STREAM / FORTRESS; clicking SOFTWARE DECODING dispatches `setResearch`
and the node goes hot magenta reading **IN COMMITTEE** with a progress bar (round-tripped
through the real sim); the POWER page shows DECODE ASIC bright and free next to SOFTWARE
DECODER and ASIC COOLER greyed with padlocks and `REQUIRES: STREAM SOFTWARE DECODING` /
`REQUIRES: BROADCAST ASIC COOLING`; `bus.selection()` reads `{mode:'remove'}` and a click
demolishes the mosh reactor once — DRAW 100→92, "UNIT RECLAIMED. THE FLOOR REMEMBERS."
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 16 tests): the whole
research loop the DoD asked for — **lock visible → `place` silently dropped by the sim →
build a lab → pick the tech through the panel → packs delivered → `researched` → toast →
padlock falls off → the same `place` now succeeds.** Its harness is now a faithful main.ts
(typed selection; *it* dispatches place/remove), which is what caught the double-fire.
**DECISIONS:**
- Packs in the research run are teleported into the lab's intake via the sim's own
`save()`/`load()` (v4 hardened; `load()` re-runs `applyUnlocks`). Everything after that
is the sim's real `drainLabs → researched → applyUnlocks` path, unmocked. Reason in
BLOCKED: nothing in the game can currently *make* a science pack.
- With no `ResearchState` at all, the safe reading of the v4 rule is that gates **hold**
(padlocks on) rather than fall open, and the panel says RESEARCH OFFLINE instead of
rendering a tree that can't do anything. The sim always ships a ResearchState today, so
this is belt-and-braces.
- `live.test.ts` no longer hardcodes reference-factory coordinates — it finds the i-only
assembler by its recipe. reference.ts moved it from (-6,5) to (-6,6) *during this
round* and broke my own test; that's my brittleness, fixed.
**BLOCKED/BROKEN (read this part):**
- **SIM's research gating breaks SIM's own reference factory, and it's live right now.**
`stream-software-decoding` gates `software-decoder`; the sim silently drops placements
of gated machines (`if (!isAvailable(def.id)) return`); reference.ts places two software
decoders for its 200 of generation. Result at HEAD: `?uidemo` builds a factory with
**GEN 80 against DRAW 100, buffer 0/900, permanent BROWNOUT** — it can never run. My
screenshots show it. Either reference.ts should use decode-asics, or the demo should
pre-unlock, or that tech shouldn't gate the generator. (This is also why every
reference-factory test in `live.test.ts` now runs with research pre-granted.)
- **Nothing in the game can make a science pack yet, so no research can actually be
performed by playing.** Packs come only from `artifact-bottler`, whose recipes need
`field-splinters` / `v-hold-roll` / `static-canister` — a broadcast-era chain that
neither the reference factory nor any tech-free layout builds. The tree is *reachable*
on paper (18 techs have satisfiable costs, and DATA fixed the artifact-bottler deadlock
I was about to report), but unreachable in practice. Suggest reference.ts grow a
research rig — it would make this verifiable by playing rather than by save/load.
- **LANE-SIM's `reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
surplus rather than jamming"), and the factory layout shifted twice while I worked. Not
mine; flagging because the suite isn't green.
- **RENDER's demolition tint is still unverified from my side.** The typed `mode` reaches
`setGhost` via main.ts now, so the magic string is no longer my problem — but I never
saw the tint land.
- **The order's own numbers went stale mid-round, again — third round running.**
"24 nodes" is 23 at HEAD; `tech.json`'s era distribution changed under me; the
artifact-bottler deadlock I'd have reported as blocking was fixed by DATA while I
worked; SIM landed *all* of research after my round-start grep said it had none. I now
re-probe everything against a running sim before writing a word of NOTES, which is the
only reason this entry is accurate. Worth considering whether lanes should get a
"HEAD as of" line rather than counts baked into orders.
- Minor, mine: the tab row wraps to two lines now that RESEARCH joined (8 tabs + REMOVE).
Legible, not pretty. Real fix is a scrolling tab strip; say the word.
**CONTRACT REQUEST:** none this round. v4 gave me everything I asked for and the
`SelectionState` migration was clean.
**PROPOSAL:** `Sim.grantResearch(techIds)` or a `?unlocked` demo flag. Every research-
adjacent test I wrote needs research pre-granted, and my only route is
`JSON.parse(sim.save())` → mutate → `load()`. That works and uses public API, but it
couples my tests to SIM's save schema — if they rename `research` in the blob, my suite
breaks for no good reason.
**NEXT (suggested):** verify RENDER's tint; research rig in reference.ts so a pack economy
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
panel once eras mean something visually (§8).
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**SHIPPED:** All nine orders. New modules: `power.ts` was round 4; this round adds
`radio.ts` (SPEAKER bridge + echo stand-in), `tuner.ts` (the dock), `screenview.ts`
(enlarge), and tests `tuner.test.ts`, `wildlife.test.ts`, `screenview.test.ts`.
**194 UI tests** (was 145); full suite 446/446; `npm run check` clean.
1. **Scrolling tab strip** — one row, never wraps, active tab scrolled into view on
`[`/`]`. Found and fixed a real bug doing it: see the hitbox finding below.
2. **Inspect mode published**`setSelection({mode:'inspect',entity})` on open, cleared
on close (X, Esc, or the unit being demolished under it). Holding a tool supersedes it
and shuts the panel, so panel and highlight can't disagree.
3. **Wildlife surfaced** — infestation toasts in voice; MOSQUITO EXPOSURE + severity in
the inspector, read from `snap.wildlife` by `target`; hatch warnings deduped one per
pile id.
4. **Research tests migrated** to SIM's `testkit.grantResearch`/`grantAllResearch` — my
round-4 hand-rolled save-blob edit is gone, and with it the coupling to SIM's schema.
Tint + highlight verified, see below.
5. **First-brownout mercy toast** — once per session, on the first brownout event.
6. **Camera hint** in the idle line; **tab hitbox adjudicated** (below).
7. **`no seam` jam voice** — "THERE IS NOTHING DOWN THERE. MOVE." SIM emits this reason
at index.ts:498, so it's live copy, not speculative.
8. **THE SCREEN click-to-enlarge** — ~2.5×, dimmed backdrop, Esc/click closes.
9. **THE TUNER DOCK** — hidden until the fossil is excavated, then slides in with dial
(drag + ←/→), AM/FM/SW, volume + mute, and a station readout in voice.
**VERIFIED — live, in the browser** (screenshots taken of each):
- **Cold-start mercy toast**: placed one extractor with no generator → BROWNOUT, and
under it "NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER." That's the DoD's
opening requirement.
- **THE SCREEN enlarge**: the live WebGL sky at 2.5× centred over a dimmed HUD; Esc
restored its `style` attribute to `null` and its dock rect to y=10, w=322 — exactly as
found.
- **THE TUNER DOCK**: excavated SIM's real seeded fossil → dock slid in bottom-right with
"AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.", dial, FM active, VOL/MUTE, hint line.
- **RENDER's demolition tint — the item owed since round 3 is CLOSED.** Armed remove,
hovered a machine, and it renders outlined in demolition red. Screenshot taken.
- Build bar with padlocks on the POWER page (DECODE ASIC free; SOFTWARE DECODER and ASIC
COOLER greyed + 🔒 + REQUIRES:), reference factory healthy at GEN 220 / DRAW 158.
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 19 tests): mercy toast fires
once and *stays* once across a second brownout; inspect selection published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays hidden
until `relics[0].found` flips and then reveals with the ceremony toast and the toast
standoff; full research loop (lock → pick → deliver → unlock → place) now driven through
`grantResearch` and the lab's real per-pack timing.
**DECISIONS:**
- **THE SCREEN is enlarged in place, not mirrored.** I built the mirror first; it rendered
black. THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context without
`preserveDrawingBuffer` returns nothing. Asking SCREEN to enable that flag would cost
them a buffer copy every frame for an occasional feature, so I enlarge the real element
with inline styles and restore its `style` attribute verbatim on close (tested,
including the case where SCREEN later adds inline styles of their own).
- **The radio bridge is guarded** (`radio.ts`). `src/audio` is still the stub — no `radio`
export exists — so the dock resolves a control at runtime and otherwise drives a silent
echo stand-in, which is why every dial is testable today. **LANE-SCREEN: publish
`window.__fktrySpeaker = { radio }` (or tell me the export name you prefer) and the dock
drives real audio with zero change here.** The interface I'm driving is exactly your
documented one: `setFrequency / setBand / volume / mute / getState / subscribe`, with
`getState()` returning `{freqMHz, band, volume, muted, station}``station: string|null`
is what flips the readout to "SIGNAL LOCKED". `isLive()` reports which one is attached.
- Wildlife exposure shows the swarm's **severity** (`WildlifeState.size`), not a
sim-confirmed throughput number — SIM owns the real penalty and doesn't expose one.
Labelled as exposure rather than a promise about output.
**BLOCKED/BROKEN (read this part):**
- **The tab-click bug was REAL, and I fixed it.** Reproduced with a genuine mouse click:
a click on POWER did nothing. `elementFromPoint` at the button's own centre returned the
*strip container*, not the button — the horizontal scrollbar was being painted over the
lower half of every tab and swallowing clicks there, which is exactly why keyboard
paging still worked. Fixed by giving the scrollbar its own lane (`padding-bottom: 7px`
≥ scrollbar height); the full button height now hit-tests to the button, and a real
click activates POWER. **Separately**, `computer` clicks via `ref` deliver *no* events to
the page at all (document-level capture recorded nothing); coordinate clicks work fine.
So: real bug, now fixed, plus a harness quirk on top of it.
- **DATA's commission queue head-of-line blocks, and it stalls the whole game.** The v5
queue opens with `raw-payload` (20 raw MDAT ORE), but the reference factory refines every
scrap of its ore and never ships it — so the head commission can never complete and all
19 behind it are unreachable. Probed: 12k ticks ships hf-dust, chroma-slurry,
macroblock-bricks, melt and anchor-slab, and `commissionDone` fires **zero** times. My
tests now build the obvious player rig (extractor → belt → uplink) to pay it. Worth a
design look: either the opening commission should want something the tutorial factory
ships, or an unsatisfiable head shouldn't block the tray.
- **Machines only push onto belts, never into an adjacent machine.** Cost me a while to
find; noting it because it's non-obvious and unstated anywhere in CONTRACTS.
- **RENDER doesn't consume the inspect selection yet** — no `inspect` anywhere in
`src/render`. I publish it correctly (verified live: `{mode:'inspect',entity:105}`), so
the highlight lights up whenever they read it. **Not owed by me any more; owed to me.**
- **Stale ghost on keyboard disarm**: main.ts only refreshes the ghost on `pointermove`, so
Esc-ing out of remove mode leaves the demolition tint on screen until the mouse moves.
Visible in my inspect screenshot. Cosmetic, and it's main.ts's glue, not mine.
- **SIM landed wildlife AND relics mid-round, again** — my round-start grep found neither.
Fifth round running. It's why my wildlife/relic UI could be verified against the real sim
at all, so this is a good problem, but the pattern is now completely reliable: I re-probe
a *running* sim before writing NOTES, never a grep from round-start.
- Three of my live tests failed mid-round from SIM/DATA changes, not my code (proved by
stashing my diff and re-running). Also hit `DUST_SPILL_GRACE is not defined` from SIM
mid-edit, which resolved itself between two consecutive commands.
- Harness, for the record: the browser pane backgrounds the tab, which pauses rAF **and
CSS transitions**. I lost time reading a transitioned `bottom` that never advanced and
briefly thought the CSS was broken; `transition:none` proved it computes correctly. Same
root cause as the paused sim loop from rounds 3-4.
**CONTRACT REQUEST:** none. v5/v6 gave me everything these orders needed.
**PROPOSAL:** `UIBus.audio?: { radio?: RadioControl }` — the tuner currently reaches THE
SPEAKER through a global handshake because `ui.init` never sees the audio module, which is
the same shape of problem as `pickTile` in round 1 and the `__remove` sentinel in round 3.
One optional field on the bus would make it typed and testable instead of a documented
convention. Not blocking: the guarded bridge works.
**NEXT (suggested):** wire the real radio the moment SCREEN publishes it (and describe how
it sounds in NOTES, since they're doing the same); inspect highlight once RENDER reads the
selection; a station-locked readout screenshot once there are real stations; commission
head-of-line blocking is a design question for you, not a UI fix.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED:** All three orders. New modules: `corrections.ts` (session tally of The
Correction's edits) + `mite.test.ts`. Extended: `voice.ts`, `topstrip.ts`, `inspector.ts`,
`tuner.ts`, `radio.ts`, `techpanel.ts`, `toasts.ts`, `index.ts`, `style.ts`, and the tuner/
techpanel test files. **213 UI tests** (was 194); every `src/ui/**` file green; `npm run
check` reports zero errors in my lane (the one tsc error is SCREEN's, below).
1. **Mite surfacing — the enemy has copy now.** Replaced the v8 placeholder in
`wildlifeToast`: a mite arriving is "A PARITY MITE IS EDITING YOUR MELT.", swatting one
is "MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU." (the Correction's manners
are the insult). `repaired` events (v8) now toast in the house voice —
"THE CORRECTION IMPROVED YOUR <ITEM>. IT LEFT A RECEIPT." — item name resolved from
`data.items` via a resolver threaded through `toasts.push → eventToast`. Two more
surfaces so a raid is narratable without watching the belts: a **CORRECTED ticker** in
the top strip (session total + per-item chips, hidden until the first edit so the enemy
is never signposted early), and a **FIREWALL LOG** in the inspector — per-unit catch
count, attributed by footprint containment of each `repaired` pos. The Correction reads
clinical white-green everywhere (codex §8: hospital-white, green checkmarks), calm next
to the hot alarms — that's the intended menace.
2. **Tuner tick marks.** The dial now draws a hot-magenta tick per station on the current
band, positioned by `(freqMHz-min)/(max-min)`, from the speaker adapter's
`control.listStations()` (added optional `listStations()` + `StationMark` to
`RadioControl`; the echo stand-in returns `[]`). Ticks are **anonymous** — they mark
THAT a station is there, not WHAT; the readout stays the only thing that names a lock,
so the hunt is intact. Rebuilt only on band/station-set change; empty pre-gesture and
fills in on the first state push after the engine attaches — i.e. exactly when audio
comes alive.
3. **ABOUT/CREDITS.** A footer button on the tech panel toggles a colophon: repo
(gitea.partly.party/monster/glytch), codex (docs/FKTRY_LORE.md), a VOICES line, and a
**standing disclosure slot** — "SYNTHETIC VOICE CREDIT: NONE ON FILE." — reserved for
the voice-cloning plan's real credit when it ships.
**VERIFIED — live, on :8153 (?uidemo)**: app boots, **no console errors**. Credits: pressed
into the DOM path with a real click event → block reveals with repo/codex/voices and the
disclosure slot (text asserted). Confirmed present-and-correctly-hidden by default:
`.fk-corrected-wrap` (display:none pre-mite), `.fk-tuner-ticks` (0 ticks pre-gesture),
`.fk-ins-firewall` (hidden). `--fk-clinic` resolves to #d6f5df. The tab backgrounds
(viewport 0x0, rAF paused) exactly as every prior round documents, so the mite raid itself
isn't drivable live here — see below.
**VERIFIED — headless** (`mite.test.ts`, real modules, happy-dom, 14 tests): the mite voice
(arrival/swat/receipt copy + name fallback); the tally (ignores non-repairs, first-seen
per-item order, footprint containment); the FIREWALL LOG (hidden at zero, singular/plural
"1 TIME"/"2 TIMES", survives no-corrections); the CORRECTED ticker (hidden until first edit,
then count + chips). Plus `tuner.test.ts` (+2): no ticks pre-attach, one tick per on-band
station at the right `left%`, anonymous. `techpanel.test.ts` (+3): ABOUT collapsed, reveals
repo/codex/slot, toggles shut.
**DECISIONS:**
- **`repaired.pos` is the mite's belt tile, not a machine.** SIM repairs belt *items* (index.ts
~1316), so the event lands on a belt tile. FIREWALL LOG therefore shows catches on whatever
unit's footprint that tile falls in — usually the belt itself (1×1), sometimes a crafter if
the corrected item sat on its tile. That's honest: it's "edits on THIS unit's tiles", and
belts are inspectable. Not a per-machine damage total (SIM doesn't expose one).
- **"Firewall" is grounded, not invented.** SIM already has `FIREWALL_RADIUS` /
`miteInFirewallRange` (index.ts ~1329) — firewalls can mites on the approach. Naming the
inspector's catch log FIREWALL LOG matches that fiction rather than coining a term.
- Item-name resolution stays data-driven (LANE-DATA's `ItemDef.name` wins); the resolver
falls back to dashes→spaces+uppercase so a brand-new item never crashes a toast.
- Corrections state is session chrome, deliberately not persisted — the sim doesn't persist
it either, and re-deriving it from a save would be a lie about what actually happened.
**BLOCKED/BROKEN (read this part):**
- **The mite raid is reachable by play but not in this harness.** Mites spawn once
`corruptionShipped()/MITE_SPAWN_PER` clears a threshold (index.ts ~1282), so the reference
factory *will* eventually breed them and fire `repaired` — but that needs sustained
runtime, and the browser pane backgrounds the tab (rAF paused, viewport 0x0), the same
wall documented in rounds 3-5. So the mite surfaces are verified headless against the real
UI modules, and live only for DOM presence/default-state/no-errors. If you can drive a
foregrounded build to the mite threshold, the ticker/firewall/toasts light up with zero
further change.
- **`npm run check` has one error, and it is not mine: `src/screen/index.ts:106` ("function
must return a value").** SCREEN's file is modified-uncommitted in the shared tree. My lane
is clean — every `src/ui/**` file type-checks. esbuild strips types so the dev build boots
fine regardless.
- **`npm test`: 4 failures across 3 files, none mine** — `src/audio/band.test.ts` (SCREEN),
`src/sim/mite.test.ts` (2) and `src/sim/reference.test.ts` (SIM). All three files are
modified-uncommitted by their lanes. Confirmed by ownership: I touched no `src/sim`,
`src/audio`, or `src/screen` file. All 17 `src/ui/**` test files pass (213 tests).
- Re-probed HEAD live, not from a round-start grep: SIM *does* emit both `parity-mite` and
`repaired` at HEAD (index.ts 1288/1321), and the adapter's `listStations()` is live in
main.ts — so nothing here is speculative against a contract that hadn't landed.
**CONTRACT REQUEST:** none. Contracts v8 (`repaired`) + the speaker adapter's `listStations()`
gave me everything these orders needed.
**PROPOSAL:** a `?mites` dev flag (or a `testkit` verb) that seeds N parity-mites / forces a
`repaired` on boot, the mite analogue of `?uidemo`. Every mite surface I built is only
observable once corruption crosses a spawn threshold that the backgrounded harness can't
reach; a one-command seed would make the raid demoable in the browser instead of only in
`mite.test.ts`. Small, and it'd live in main.ts (yours) or SIM's testkit, not my lane.
**NEXT (suggested):** fill the credits' synthetic-voice slot when the voice-cloning plan
ships; name-on-hover for dial ticks *only* if playtesting says the hunt is too hard (default
stays anonymous); a per-machine "corrected N times" once/if SIM attributes repairs to a
machine rather than a belt tile; Checksum Wardens / Redundancy Gunships get the same
surfacing treatment when they arrive (the corrections module generalises to any Correction
edit event).

Binary file not shown.

View File

@ -1,84 +0,0 @@
/**
* Layer 3 world ambience. Post-discovery only.
*
* The factory gets a voice once the fossil is dug up: a hum that tracks how hard the plant is
* working, and a strained whine as decoders approach scram. Both are continuous voices built
* once and driven by AudioParams nothing here allocates per frame.
*
* Subordinate by design: the hum sits under everything, and ducks further when the radio is
* carrying a station, because a tuned station is what the player asked to hear.
*/
import { glide, noiseSource } from './graph';
export interface Ambience {
out: GainNode;
start(t0: number): void;
/**
* @param load 0..1 how hard the plant is running (draw vs gen)
* @param fever 0..1 aggregate heat the strained whine near scram
* @param duck 0..1 how much the radio wants the floor
*/
update(load: number, fever: number, duck: number): void;
}
export function createAmbience(ctx: BaseAudioContext): Ambience {
const out = ctx.createGain();
out.gain.value = 0;
// ---- the hum: mains-ish fundamental + its octave, plus a broadband machine-room bed.
const hum = ctx.createOscillator();
hum.type = 'sawtooth';
hum.frequency.value = 50;
const humLp = ctx.createBiquadFilter();
humLp.type = 'lowpass';
humLp.frequency.value = 180;
const humG = ctx.createGain();
humG.gain.value = 0.0;
hum.connect(humLp).connect(humG).connect(out);
const room = noiseSource(ctx, 'brown');
const roomLp = ctx.createBiquadFilter();
roomLp.type = 'lowpass';
roomLp.frequency.value = 600;
const roomG = ctx.createGain();
roomG.gain.value = 0.0;
room.connect(roomLp).connect(roomG).connect(out);
// ---- the strained whine: only shows up as machines climb toward a scram.
const whine = ctx.createOscillator();
whine.type = 'triangle';
whine.frequency.value = 1900;
const whineG = ctx.createGain();
whineG.gain.value = 0;
whine.connect(whineG).connect(out);
let started = false;
return {
out,
start(t0) {
if (started) return;
started = true;
hum.start(t0);
room.start(t0);
whine.start(t0);
},
update(load, fever, duck) {
if (!started) return;
const l = Math.min(1, Math.max(0, load));
const f = Math.min(1, Math.max(0, fever));
const d = 1 - 0.55 * Math.min(1, Math.max(0, duck)); // radio wins, but never silences
glide(humG.gain, (0.035 + 0.05 * l) * d, ctx, 0.4);
glide(roomG.gain, (0.02 + 0.045 * l) * d, ctx, 0.4);
// a busier plant runs its mains a touch sharp — cheap, and it reads as effort
glide(hum.frequency, 50 + 6 * l, ctx, 0.5);
glide(roomLp.frequency, 500 + 900 * l, ctx, 0.5);
// Heat only becomes audible late; below 0.55 a warm factory is just a factory.
const strain = Math.max(0, (f - 0.55) / 0.45);
glide(whineG.gain, 0.03 * strain * strain * d, ctx, 0.3);
glide(whine.frequency, 1700 + 900 * strain, ctx, 0.3);
},
};
}

View File

@ -1,109 +0,0 @@
/**
* THE BAND's data contract and treasure map, verified without a browser.
*
* The synthesis needs an AudioContext and is verified spectrally in the dev build; what CAN be
* pinned here is the part that would silently rot: the station schedule, and the promise that
* the NUMBERS station's coordinates actually lead somewhere.
*/
import { describe, expect, it } from 'vitest';
import { BAND_RANGE, type Band } from './radio';
import type { StationDef } from './stations';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
const SEAMS = (seamData as {
seams: { id: string; rect: { x: number; y: number; w: number; h: number }; richness: number; hidden?: boolean }[];
}).seams;
/** LANE-DATA's encoding: [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]. */
function decode(group: string) {
return {
x: Number(group.slice(0, 2)) - 32,
y: Number(group.slice(2, 4)) - 32,
richness: Number(group[4]) / 9,
};
}
describe('the dial is well formed', () => {
it('has all four stations', () => {
expect(STATIONS.map((s) => s.id).sort()).toEqual(
['dead-air', 'fktry-fm', 'numbers', 'the-standard'].sort(),
);
});
it('puts every station inside its own band, or the tuner could never reach it', () => {
for (const s of STATIONS) {
const [lo, hi] = BAND_RANGE[s.band as Band];
expect(s.freq, `${s.id} @ ${s.freq} outside ${s.band} ${lo}-${hi}`).toBeGreaterThanOrEqual(lo);
expect(s.freq).toBeLessThanOrEqual(hi);
}
});
it('never puts two stations on the same spot of the same band', () => {
const seen = new Set<string>();
for (const s of STATIONS) {
const k = `${s.band}:${s.freq}`;
expect(seen.has(k), `duplicate ${k}`).toBe(false);
seen.add(k);
}
});
});
describe('the numbers station is an honest treasure map', () => {
const numbers = STATIONS.find((s) => s.id === 'numbers')!.numbers!;
const hidden = SEAMS.filter((s) => s.hidden);
it('plants exactly the hidden seams, and they decode to the real tiles', () => {
expect(numbers.plantedGroups).toHaveLength(hidden.length);
const decoded = numbers.plantedGroups.map(decode);
for (const h of hidden) {
const hit = decoded.find((d) => d.x === h.rect.x && d.y === h.rect.y);
expect(hit, `no broadcast group leads to ${h.id} at ${h.rect.x},${h.rect.y}`).toBeTruthy();
expect(hit!.richness).toBeCloseTo(h.richness, 1);
}
});
it('keeps every group the same width, so a listener can chunk them by ear', () => {
for (const g of [...numbers.plantedGroups, ...numbers.decoyPool, numbers.preamble]) {
expect(g).toHaveLength(numbers.groupSize);
expect(g).toMatch(/^\d+$/);
}
});
it('no decoy ever leaks a HIDDEN seam — the one that would break the hunt', () => {
// This is the invariant THE SPEAKER must protect: the only groups that lead to secret
// ground are the planted ones. A decoy landing on an already-visible seam is untidy but
// harmless (see NOTES: two currently do, reported to LANE-DATA); a decoy landing on a
// hidden seam would hand out the treasure for free.
for (const g of numbers.decoyPool) {
const d = decode(g);
const leak = hidden.find(
(s) => d.x >= s.rect.x && d.x < s.rect.x + s.rect.w && d.y >= s.rect.y && d.y < s.rect.y + s.rect.h,
);
expect(leak, `decoy ${g} gives away hidden seam ${leak?.id} at ${d.x},${d.y}`).toBeUndefined();
}
});
it('broadcasts a planted group often enough to be findable', () => {
// Every 4th message, 8 groups a message, 1.2s a group: a planted coordinate roughly every
// couple of minutes. Long enough to feel like a signal, short enough to catch one.
const secondsPerMessage =
(numbers.groupsPerMessage + numbers.preambleRepeats) * numbers.secondsPerGroup;
expect(secondsPerMessage * numbers.plantEveryMessages).toBeLessThan(120);
});
});
describe('THE STANDARD has something to say', () => {
const lines = STATIONS.find((s) => s.id === 'the-standard')!.chyron!;
it('carries enough copy that it does not obviously loop', () => {
expect(lines.length).toBeGreaterThanOrEqual(20);
expect(new Set(lines).size).toBe(lines.length);
});
it('fits the chyron — long lines would run off the sky', () => {
// The chyron band renders ~62 monospace chars at 20px across 640px.
for (const l of lines) expect(l.length, `too long to subtitle: "${l}"`).toBeLessThanOrEqual(96);
});
});

View File

@ -1,125 +0,0 @@
/**
* THE SPEAKER's plumbing: master chain, shared noise, scheduling helpers.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever. A game about
* broken media makes its own noise.
*
* Two rules that shape everything here:
* - **Zero per-frame allocations.** Continuous voices (hum, station beds) are built once at
* unlock and then only have their AudioParams driven. Only genuinely one-shot sounds
* allocate nodes, and those are short-lived and self-stopping.
* - **Context-agnostic.** Nothing below assumes a live AudioContext. Every builder takes a
* BaseAudioContext, so the whole synth stack can be rendered in an OfflineAudioContext for
* deterministic spectral verification the same code the player hears.
*/
/** Noise buffers are expensive to fill, so each context gets one of each, cached. */
const noiseCache = new WeakMap<BaseAudioContext, { white: AudioBuffer; brown: AudioBuffer }>();
function buildNoise(ctx: BaseAudioContext) {
const len = Math.floor(ctx.sampleRate * 2); // 2s loops — long enough to not read as periodic
const white = ctx.createBuffer(1, len, ctx.sampleRate);
const brown = ctx.createBuffer(1, len, ctx.sampleRate);
const w = white.getChannelData(0);
const b = brown.getChannelData(0);
// Deterministic PRNG: same noise every run, so spectra are reproducible across verifications.
let seed = 0x1337c0de;
const rnd = () => {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296 * 2 - 1;
};
let last = 0;
for (let i = 0; i < len; i++) {
const n = rnd();
w[i] = n;
// brown/red noise: integrated white, gently leaked to stop DC runaway
last = (last + 0.02 * n) / 1.02;
b[i] = last * 3.5;
}
return { white, brown };
}
export function noise(ctx: BaseAudioContext) {
let n = noiseCache.get(ctx);
if (!n) {
n = buildNoise(ctx);
noiseCache.set(ctx, n);
}
return n;
}
/** A looping noise source. Caller owns start/stop. */
export function noiseSource(ctx: BaseAudioContext, kind: 'white' | 'brown'): AudioBufferSourceNode {
const src = ctx.createBufferSource();
src.buffer = noise(ctx)[kind];
src.loop = true;
return src;
}
export interface MasterChain {
/** everything in the mix connects here */
input: GainNode;
/** master volume, pre-limiter */
volume: GainNode;
}
/**
* gain soft limiter destination.
*
* The limiter is a WaveShaper running tanh, not a DynamicsCompressor: a compressor's lookahead
* and automatic makeup would pump the whole mix every time the klaxon fires. tanh just refuses
* to go past 1, which is what "don't clip" actually means, and it adds the faint saturation a
* blown factory PA ought to have.
*/
export function createMasterChain(ctx: BaseAudioContext): MasterChain {
const input = ctx.createGain();
const volume = ctx.createGain();
volume.gain.value = 0.9;
const limiter = ctx.createWaveShaper();
const n = 1024;
const curve = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1;
curve[i] = Math.tanh(x * 1.6);
}
limiter.curve = curve;
limiter.oversample = '2x';
input.connect(volume);
volume.connect(limiter);
limiter.connect(ctx.destination);
return { input, volume };
}
/** Ramp a param without clicks. Web Audio hates instantaneous jumps on a running graph. */
export function glide(p: AudioParam, to: number, ctx: BaseAudioContext, seconds = 0.05) {
const t = ctx.currentTime;
p.cancelScheduledValues(t);
p.setValueAtTime(p.value, t);
p.linearRampToValueAtTime(to, t + seconds);
}
/**
* A one-shot envelope on a fresh gain node: attack to peak, decay to silence, then disconnect.
* Returns the gain so the caller can hang a source off it.
*/
export function envGain(
ctx: BaseAudioContext,
t0: number,
peak: number,
attack: number,
decay: number,
): GainNode {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
return g;
}
/** Deterministic 0..1 hash — used anywhere a sound needs stable variation without Math.random. */
export function hash01(n: number): number {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}

View File

@ -1,217 +0,0 @@
/**
* THE SPEAKER LANE-SCREEN's second organ.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever.
*
* Three layers, gated by discovery:
* 1. DIEGETIC MINIMUM (always on) placement, fax, brownout, scram, ratification.
* Nothing else exists before the optical fossil is found. The silence is the tease.
* 2. THE BAND (on relicFound) the tuner and its four stations.
* 3. WORLD AMBIENCE (post-discovery) the factory's own hum, ducked under the radio.
*
* The AudioContext is created lazily on the first user gesture (browsers require it) and the
* whole graph is built once at that moment. After that the per-frame path only writes
* AudioParams no allocation except genuinely one-shot notes.
*/
import type { AudioFX, GameData, SimEvent, SimSnapshot } from '../contracts';
import { ERA_ORDER, highestEra, type Era } from '../screen/eras';
import { createMasterChain } from './graph';
import { createAmbience, type Ambience } from './ambience';
import { createRadio, type Radio, type RadioEngine } from './radio';
import type { StationDef } from './stations';
import {
brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
/**
* The real seams, so the NUMBERS station's seeded filler can never accidentally broadcast a
* true coordinate (LANE-DATA's rule: only the planted groups are real).
*/
const SEAM_RECTS = (seamData as { seams: { rect: { x: number; y: number; w: number; h: number } }[] })
.seams.map((s) => s.rect);
function isRealSeam(x: number, y: number): boolean {
for (const r of SEAM_RECTS) {
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return true;
}
return false;
}
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05 };
/** The live radio handle for getRadio(). Module-level set by build(), which runs on the
* first user gesture so the control surface exists in PRODUCTION, not just behind the
* DEV debug object (orchestrator round-5 integration fix: the dock was silent in prod). */
let liveRadio: Radio | null = null;
export function createAudioFX(): AudioFX {
let ctx: BaseAudioContext | null = null;
let master: ReturnType<typeof createMasterChain> | null = null;
let radio: RadioEngine | null = null;
let ambience: Ambience | null = null;
const eras = new Map<string, Era>();
let era: Era = ERA_ORDER[0];
let discovered = false;
let lastThunk = 0;
let lastChunk = 0;
let variant = 0;
function now(): number {
return ctx ? ctx.currentTime : 0;
}
function openTheBand() {
if (discovered) return;
discovered = true;
if (!ctx || !radio || !ambience || !master) return;
radio.unlock(now());
ambience.start(now());
// The fossil is a severed audio bus reconnecting: the world audibly comes online.
ratifySting(ctx, master.input, now() + 0.02);
}
function handle(e: SimEvent) {
if (!ctx || !master) return;
const dest = master.input;
const t = now() + 0.005; // a hair in the future: scheduling in the past clicks
switch (e.kind) {
case 'placed':
if (t - lastThunk >= MIN_GAP.thunk) {
thunk(ctx, dest, t, variant++);
lastThunk = t;
}
break;
case 'brownout':
if (e.on) brownoutHit(ctx, dest, t);
else brownoutRecover(ctx, dest, t);
break;
case 'scram':
if (e.on) klaxon(ctx, dest, t);
break;
case 'researched':
ratifySting(ctx, dest, t);
break;
case 'commissionDone':
faxChatter(ctx, dest, t, 18);
break;
case 'shipped':
// Layer 3: the tape chunk belongs to the discovered world.
if (discovered && t - lastChunk >= MIN_GAP.chunk) {
tapeChunk(ctx, dest, t, variant++);
lastChunk = t;
}
break;
case 'relicFound':
openTheBand();
break;
default:
break;
}
}
function build() {
if (ctx) return;
const AC: typeof AudioContext =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (!AC) {
console.warn('[audio] no Web Audio support — THE SPEAKER stays silent');
return;
}
ctx = new AC();
master = createMasterChain(ctx);
radio = createRadio(ctx, STATIONS, isRealSeam);
liveRadio = radio as Radio;
ambience = createAmbience(ctx);
radio.out.connect(master.input);
ambience.out.connect(master.input);
// The fax announces itself the moment the world has ears — Layer 1 proof of life.
faxChatter(ctx, master.input, now() + 0.15, 10);
// If the relic was already found (loaded save, or dug before the first click), the band
// should be open the instant audio exists.
if (discovered) {
discovered = false;
openTheBand();
}
if (import.meta.env.DEV) {
(window as any).__fktryAudio = {
ctx,
radio: radio as Radio,
openTheBand,
isDiscovered: () => discovered,
stations: STATIONS,
play: (name: string) => {
if (!ctx || !master) return;
const d = master.input;
const t = now() + 0.02;
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t);
},
};
}
}
return {
init(data: GameData) {
for (const t of data.tech) eras.set(t.id, t.era);
},
unlock() {
build();
if (ctx && 'resume' in ctx) void (ctx as AudioContext).resume();
},
onEvents(events: SimEvent[]) {
if (!ctx) {
// Audio doesn't exist yet (no gesture). Anything that happened is simply not heard —
// we never queue a burst to fire on unlock. But discovery must not be lost.
for (const e of events) if (e.kind === 'relicFound') discovered = true;
return;
}
for (const e of events) handle(e);
},
frame(timeMs: number, snap?: SimSnapshot) {
if (!ctx || !radio || !ambience) return;
const t = now();
if (snap?.research) era = highestEra(snap.research.unlocked, (x) => eras.get(x));
// Discovery can also arrive via the snapshot (e.g. a loaded save), not just the event.
if (!discovered && snap?.relics?.some((r) => r.found)) openTheBand();
radio.update(t, era);
if (discovered && snap) {
const { gen, draw } = snap.bandwidth;
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
let hot = 0;
let n = 0;
for (const e of snap.entities) if (e.heat > 0.01) { hot += e.heat; n++; }
const fever = n ? hot / n : 0;
ambience.update(load, fever, radio.getState().lock);
}
},
};
}
/**
* THE SPEAKER's control surface, for LANE-UI's tuner dock.
*
* SANCTIONED CROSS-LANE IMPORT: LANE-UI may `import { getRadio } from '../audio'` and drive
* the dial. Returns null before the first user gesture builds the graph. The object is
* deliberately narrow the UI can tune, set volume and subscribe, and can do nothing else to
* the audio graph.
*/
export function getRadio(): Radio | null {
return liveRadio;
}

View File

@ -1,248 +0,0 @@
/**
* The tuner (round 5 order 6).
*
* A real dial: you sweep through noise and stations swim up out of it. Lock is a gaussian on
* the distance between the dial and each carrier, so stations fade in and out rather than
* snapping hunting for a station is the point, not an obstacle.
*
* Inter-station noise is BAND-APPROPRIATE, because that's what makes each band feel like a
* different physical place:
* AM brown crackle. Heavy, atmospheric, full of distant weather.
* FM white hiss. Bright, flat, modern, boring in a clean way.
* SW warble and heterodyne whistles that beat against nearby carriers. Alive and hostile.
*/
import type { Era } from '../screen/eras';
import { glide, noiseSource } from './graph';
import { setSubtitle } from './subtitleBus';
import {
amPropaganda, deadAir, fmMusic, swNumbers, type LiveStation, type StationDef,
} from './stations';
export type Band = 'AM' | 'FM' | 'SW';
/** Dial travel per band, in that band's own units (MHz for FM, kHz for AM/SW). */
export const BAND_RANGE: Record<Band, [number, number]> = {
AM: [520, 1710],
FM: [87.5, 108.0],
SW: [5900, 7400],
};
/**
* How wide a station's capture is, in band units. Tuned so a station occupies a comfortable
* fraction of the dial: wide enough to find by hand, narrow enough that finding it feels like
* something. SW is deliberately the fiddliest.
*/
const LOCK_WIDTH: Record<Band, number> = { AM: 9, FM: 0.32, SW: 6 };
export interface RadioState {
band: Band;
frequency: number;
volume: number;
muted: boolean;
/** id of the station currently locked, or null when you're between stations */
locked: string | null;
/** 0..1 strength of that lock */
lock: number;
unlocked: boolean;
}
/** The narrow control surface LANE-UI's tuner dock drives. */
export interface Radio {
setBand(b: Band): void;
setFrequency(f: number): void;
setVolume(v: number): void;
setMuted(m: boolean): void;
getState(): RadioState;
subscribe(fn: (s: RadioState) => void): () => void;
/** every station on the dial, for the UI to draw tick marks */
listStations(): { id: string; name: string; band: Band; freq: number }[];
}
export interface RadioEngine extends Radio {
/** open THE BAND — called once the optical fossil is found */
unlock(t0: number): void;
isUnlocked(): boolean;
update(now: number, era: Era): void;
/** master output for the whole radio, so ambience can duck under it */
out: GainNode;
}
const gaussian = (d: number, w: number) => Math.exp(-(d * d) / (2 * w * w));
export function createRadio(
ctx: BaseAudioContext,
defs: StationDef[],
isRealSeam: (x: number, y: number) => boolean,
): RadioEngine {
const out = ctx.createGain();
out.gain.value = 0; // silent until the fossil is dug up
const stations: LiveStation[] = defs.map((d) => {
if (d.music) return fmMusic(ctx, d);
if (d.chyron) return amPropaganda(ctx, d);
if (d.numbers) return swNumbers(ctx, d, isRealSeam);
return deadAir(ctx, d);
});
for (const s of stations) s.out.connect(out);
// ---- the three inter-station noise beds. All three exist; only the current band is audible.
const hiss = noiseSource(ctx, 'white'); // FM
const hissF = ctx.createBiquadFilter();
hissF.type = 'highpass';
hissF.frequency.value = 1200;
const hissG = ctx.createGain();
hissG.gain.value = 0;
hiss.connect(hissF).connect(hissG).connect(out);
const crackle = noiseSource(ctx, 'brown'); // AM
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'lowpass';
crackleF.frequency.value = 3200;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const warble = noiseSource(ctx, 'white'); // SW bed
const warbleF = ctx.createBiquadFilter();
warbleF.type = 'bandpass';
warbleF.frequency.value = 1500;
warbleF.Q.value = 1.2;
const warbleG = ctx.createGain();
warbleG.gain.value = 0;
// the bandpass centre wanders — that slow swimming quality of a shortwave band at night
const warbleLfo = ctx.createOscillator();
warbleLfo.frequency.value = 0.13;
const warbleLfoDepth = ctx.createGain();
warbleLfoDepth.gain.value = 600;
warbleLfo.connect(warbleLfoDepth).connect(warbleF.frequency);
warble.connect(warbleF).connect(warbleG).connect(out);
// Heterodyne: the whistle you get when your dial is NEAR a carrier but not on it. Pitch is
// the beat frequency, so it swoops down to nothing as you zero in — the sound of hunting.
const het = ctx.createOscillator();
het.type = 'sine';
het.frequency.value = 1000;
const hetG = ctx.createGain();
hetG.gain.value = 0;
het.connect(hetG).connect(out);
let band: Band = 'FM';
let frequency = 88.1;
let volume = 0.7;
let muted = false;
let unlocked = false;
let started = false;
let locked: string | null = null;
let lockStrength = 0;
const subs = new Set<(s: RadioState) => void>();
function state(): RadioState {
return { band, frequency, volume, muted, locked, lock: +lockStrength.toFixed(3), unlocked };
}
function publish() {
const s = state();
for (const fn of subs) fn(s);
}
function applyMaster() {
glide(out.gain, unlocked && !muted ? volume : 0, ctx, 0.08);
}
return {
out,
unlock(t0: number) {
if (unlocked) return;
unlocked = true;
if (!started) {
started = true;
for (const s of stations) s.start(t0);
hiss.start(t0);
crackle.start(t0);
warble.start(t0);
warbleLfo.start(t0);
het.start(t0);
}
applyMaster();
publish();
},
isUnlocked: () => unlocked,
setBand(b) {
if (b === band) return;
band = b;
// land somewhere sensible in the new band rather than an out-of-range number
const [lo, hi] = BAND_RANGE[b];
frequency = Math.min(hi, Math.max(lo, frequency));
const near = stations.find((s) => s.def.band === b);
if (near && (frequency < lo || frequency > hi)) frequency = near.def.freq;
publish();
},
setFrequency(f) {
const [lo, hi] = BAND_RANGE[band];
frequency = Math.min(hi, Math.max(lo, f));
publish();
},
setVolume(v) {
volume = Math.min(1, Math.max(0, v));
applyMaster();
publish();
},
setMuted(m) {
muted = m;
applyMaster();
publish();
},
getState: state,
subscribe(fn) {
subs.add(fn);
fn(state());
return () => subs.delete(fn);
},
listStations: () =>
stations.map((s) => ({ id: s.def.id, name: s.def.name, band: s.def.band, freq: s.def.freq })),
update(now, era) {
if (!unlocked) return;
const w = LOCK_WIDTH[band];
let best: LiveStation | null = null;
let bestLock = 0;
let nearestDist = Infinity;
for (const s of stations) {
const onBand = s.def.band === band;
const d = Math.abs(s.def.freq - frequency);
const lock = onBand ? gaussian(d, w) : 0;
if (onBand && d < nearestDist) nearestDist = d;
// Below the audible floor a station is genuinely off — no CPU spent scheduling it.
glide(s.out.gain, lock < 0.02 ? 0 : lock, ctx, 0.06);
s.update(now, lock, era);
if (lock > bestLock) { bestLock = lock; best = s; }
}
const changed = (best?.def.id ?? null) !== locked;
locked = best && bestLock > 0.02 ? best.def.id : null;
lockStrength = bestLock;
// Static fills whatever the stations leave behind.
const staticAmt = 1 - Math.min(1, bestLock);
glide(hissG.gain, band === 'FM' ? 0.05 * staticAmt : 0, ctx, 0.1);
glide(crackleG.gain, band === 'AM' ? 0.11 * staticAmt : 0, ctx, 0.1);
glide(warbleG.gain, band === 'SW' ? 0.08 * staticAmt : 0, ctx, 0.1);
// Heterodyne only on SW, only while close-but-not-locked, pitch = how far off you are.
if (band === 'SW' && nearestDist < w * 3 && bestLock < 0.85) {
const beat = Math.min(2400, 40 + (nearestDist / (w * 3)) * 2400);
glide(het.frequency, beat, ctx, 0.08);
glide(hetG.gain, 0.05 * (1 - bestLock), ctx, 0.1);
} else {
glide(hetG.gain, 0, ctx, 0.15);
}
// The sky captions whatever is currently talking.
setSubtitle(best && bestLock > 0.35 ? best.subtitle() : '');
if (changed) publish();
},
};
}

View File

@ -1,204 +0,0 @@
/**
* Layer 1 the diegetic minimum. Always on, from the first click, before any discovery.
*
* These five sounds are the whole audio world until the player digs up the optical fossil.
* That is the point: the silence around them is the tease. Everything here is one-shot and
* self-stopping no continuous voices, so a pre-discovery factory is genuinely quiet between
* events rather than humming under a noise floor.
*
* Every sound takes (ctx, dest, t0) so it can be rendered offline for spectral verification.
*/
import { envGain, hash01, noiseSource } from './graph';
/**
* PLACEMENT THUNK a heavy thing set down on a metal deck.
* Low sine drops 15055Hz (the mass) under a filtered noise slap (the contact).
*/
export function thunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const detune = 1 + (hash01(variant) - 0.5) * 0.18; // no two placements identical
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(150 * detune, t0);
osc.frequency.exponentialRampToValueAtTime(55 * detune, t0 + 0.11);
const og = envGain(ctx, t0, 0.5, 0.004, 0.16);
osc.connect(og).connect(dest);
osc.start(t0);
osc.stop(t0 + 0.22);
const slap = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1400 * detune;
bp.Q.value = 0.8;
const sg = envGain(ctx, t0, 0.22, 0.002, 0.05);
slap.connect(bp).connect(sg).connect(dest);
slap.start(t0);
slap.stop(t0 + 0.09);
}
/**
* FAX CHATTER dot-matrix print head. A burst of short square blips at ~55Hz repetition
* with a paper-feed hiss under it. The commission fax is the game's voice; it should sound
* like an office machine that has never once been serviced.
*/
export function faxChatter(ctx: BaseAudioContext, dest: AudioNode, t0: number, chars = 14) {
const bus = ctx.createGain();
bus.gain.value = 0.5;
bus.connect(dest);
for (let i = 0; i < chars; i++) {
const t = t0 + i * 0.018 + hash01(i * 7.7) * 0.004;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = 320 + hash01(i * 3.3) * 260;
const g = envGain(ctx, t, 0.16, 0.0015, 0.012);
o.connect(g).connect(bus);
o.start(t);
o.stop(t + 0.02);
}
// paper feed
const paper = noiseSource(ctx, 'white');
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2600;
const pg = envGain(ctx, t0, 0.09, 0.02, chars * 0.018 + 0.1);
paper.connect(hp).connect(pg).connect(bus);
paper.start(t0);
paper.stop(t0 + chars * 0.018 + 0.2);
}
/**
* BROWNOUT the codex's catastrophic underrun, finally audible.
* A hard pop transient, a burst of crackle, and a power-down whine sliding 42040Hz as the
* rails collapse. This is the single most important sound in the game: it is the punishment.
*/
export function brownoutHit(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
// the pop: a single-sample-ish click, deliberately broadband
const pop = ctx.createOscillator();
pop.type = 'square';
pop.frequency.setValueAtTime(90, t0);
const pg = envGain(ctx, t0, 0.75, 0.0008, 0.03);
pop.connect(pg).connect(dest);
pop.start(t0);
pop.stop(t0 + 0.04);
// crackle: brown noise gated hard
const cr = noiseSource(ctx, 'brown');
const crf = ctx.createBiquadFilter();
crf.type = 'bandpass';
crf.frequency.value = 800;
crf.Q.value = 0.6;
const cg = envGain(ctx, t0, 0.5, 0.003, 0.5);
cr.connect(crf).connect(cg).connect(dest);
cr.start(t0);
cr.stop(t0 + 0.7);
// power-down whine: the flywheel losing it
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(420, t0);
wh.frequency.exponentialRampToValueAtTime(40, t0 + 0.9);
const wf = ctx.createBiquadFilter();
wf.type = 'lowpass';
wf.frequency.setValueAtTime(2200, t0);
wf.frequency.exponentialRampToValueAtTime(300, t0 + 0.9);
const wg = envGain(ctx, t0, 0.3, 0.02, 0.95);
wh.connect(wf).connect(wg).connect(dest);
wh.start(t0);
wh.stop(t0 + 1.0);
}
/** Power coming back: the whine in reverse, shorter and less dramatic. Relief, not triumph. */
export function brownoutRecover(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(60, t0);
wh.frequency.exponentialRampToValueAtTime(360, t0 + 0.35);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1400;
const g = envGain(ctx, t0, 0.2, 0.05, 0.32);
wh.connect(lp).connect(g).connect(dest);
wh.start(t0);
wh.stop(t0 + 0.45);
}
/**
* SCRAM KLAXON two-tone industrial alarm, 620/440Hz, square, deliberately unpleasant.
* An OSHA-compliant noise for an entirely non-compliant machine.
*/
export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.34;
bus.connect(dest);
const tones = [620, 440, 620, 440];
for (let i = 0; i < tones.length; i++) {
const t = t0 + i * 0.22;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = tones[i];
const g = envGain(ctx, t, 0.5, 0.01, 0.19);
// a lowpass keeps it a klaxon rather than a buzzer
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1800;
o.connect(lp).connect(g).connect(bus);
o.start(t);
o.stop(t + 0.21);
}
}
/**
* RATIFICATION STING The Correction resolves; you don't.
* A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant,
* in-tune, untroubled sound in the entire game. Everything the player makes is filth; this
* is what the enemy sounds like, and it sounds *nice*, which is the joke.
*/
export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.3;
bus.connect(dest);
const triad = [349.23, 440.0, 523.25]; // F4 A4 C5
triad.forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = envGain(ctx, t0 + i * 0.035, 0.4, 0.02, 1.1);
o.connect(g).connect(bus);
o.start(t0 + i * 0.035);
o.stop(t0 + 1.4);
});
// a touch of shimmer an octave up so it reads as "official" rather than "warm"
const shimmer = ctx.createOscillator();
shimmer.type = 'sine';
shimmer.frequency.value = 1046.5;
const sg = envGain(ctx, t0, 0.12, 0.03, 0.9);
shimmer.connect(sg).connect(bus);
shimmer.start(t0);
shimmer.stop(t0 + 1.1);
}
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
const n = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 520 * d;
bp.Q.value = 1.4;
const g = envGain(ctx, t0, 0.3, 0.002, 0.07);
n.connect(bp).connect(g).connect(dest);
n.start(t0);
n.stop(t0 + 0.1);
const thud = ctx.createOscillator();
thud.type = 'sine';
thud.frequency.setValueAtTime(110 * d, t0);
thud.frequency.exponentialRampToValueAtTime(70 * d, t0 + 0.06);
const tg = envGain(ctx, t0, 0.22, 0.003, 0.09);
thud.connect(tg).connect(dest);
thud.start(t0);
thud.stop(t0 + 0.12);
}

View File

@ -1,488 +0,0 @@
/**
* THE BAND the four things living on the dial (round 5 order 6).
*
* Schema is LANE-DATA's (`data/stations.json`); this file is pure synthesis. Every station is
* a continuous voice built once at unlock, whose OUTPUT GAIN is driven by the tuner's lock
* strength. Stations self-schedule with a lookahead so nothing allocates per frame except
* genuinely one-shot notes.
*
* Each station also decides what THE SCREEN says: when a talking station is locked it writes
* the subtitle bus, and the sky captions it.
*/
import type { Era } from '../screen/eras';
import { envGain, hash01, noiseSource } from './graph';
export interface StationDef {
id: string;
band: 'AM' | 'FM' | 'SW';
freq: number;
name: string;
format?: string;
flavor?: string;
music?: {
bpm: number; scale: string; steps: number; mutateEveryBars: number;
seed: number; voices: string[]; eraFlavor?: Record<string, string>;
};
chyron?: string[];
numbers?: {
preamble: string; preambleRepeats: number; groupSize: number; groupsPerMessage: number;
secondsPerGroup: number; plantedGroups: string[]; plantEveryMessages: number;
decoyPool: string[];
};
ghost?: { everyMinutes: number; reversed: boolean; line: string };
}
export interface LiveStation {
def: StationDef;
/** station audio lands here; the tuner drives this node's gain from the lock strength */
out: GainNode;
start(t0: number): void;
/** called every frame. `lock` 0..1 — only a locked station schedules and subtitles. */
update(now: number, lock: number, era: Era): void;
/** the line the sky should caption right now, or '' */
subtitle(): string;
}
const LOOKAHEAD = 0.25; // seconds of scheduling runway
/** Mulberry32 — small, fast, and seedable, so a run's radio is always the same radio. */
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────── 88.1 FKTRY FM
const PENTA_MINOR = [0, 3, 5, 7, 10]; // the scale DATA asked for
/**
* The factory's own transmitter: a sequencer nobody is operating.
* Square lead + triangle bass on a seeded 16-step pattern that mutates every 32 bars, so it
* genuinely never repeats a bar it liked. The era colours the transmission, not the notes
* same music, aging equipment.
*/
export function fmMusic(ctx: BaseAudioContext, def: StationDef): LiveStation {
const m = def.music!;
const out = ctx.createGain();
out.gain.value = 0;
const voiceBus = ctx.createGain();
voiceBus.gain.value = 0.5;
// Wow/flutter lives on a delay line: reel-era tape doesn't hold pitch.
const warble = ctx.createDelay(0.05);
warble.delayTime.value = 0.004;
const wowLfo = ctx.createOscillator();
wowLfo.frequency.value = 0.7;
const wowDepth = ctx.createGain();
wowDepth.gain.value = 0; // era sets this
wowLfo.connect(wowDepth).connect(warble.delayTime);
voiceBus.connect(warble).connect(out);
// Era beds: surface crackle (reel) and the 15.7kHz line whine (broadcast).
const crackle = noiseSource(ctx, 'brown');
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'highpass';
crackleF.frequency.value = 1800;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const lineWhine = ctx.createOscillator();
lineWhine.type = 'sine';
lineWhine.frequency.value = 15734; // NTSC horizontal line rate, the sound of a warm CRT
const whineG = ctx.createGain();
whineG.gain.value = 0;
lineWhine.connect(whineG).connect(out);
const spb = 60 / m.bpm / 4; // 16th notes
let step = 0;
let bars = 0;
let nextTime = 0;
let started = false;
let pattern: number[] = [];
let mutations = 0;
const rand = rng(m.seed);
function mutate() {
pattern = Array.from({ length: m.steps }, () => {
const r = rand();
if (r < 0.34) return -1; // rest — space is what stops it sounding like a demo
return PENTA_MINOR[Math.floor(rand() * PENTA_MINOR.length)] + (rand() < 0.25 ? 12 : 0);
});
mutations++;
}
mutate();
let eraNow: Era = 'stream';
function note(semi: number, t: number, isDownbeat: boolean) {
const root = 110; // A2
const f = root * Math.pow(2, semi / 12);
// fortress: wavelet-smooth, no transients. Everything else keeps its edge.
const attack = eraNow === 'fortress' ? 0.09 : 0.006;
const decay = eraNow === 'fortress' ? 0.5 : 0.16;
const lead = ctx.createOscillator();
lead.type = 'square';
lead.frequency.value = f * 2;
const lg = envGain(ctx, t, eraNow === 'stream' ? 0.3 : 0.22, attack, decay);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = eraNow === 'disc' ? 6000 : eraNow === 'reel' ? 2200 : 4000;
lead.connect(lp).connect(lg).connect(voiceBus);
lead.start(t);
lead.stop(t + attack + decay + 0.02);
if (isDownbeat) {
const bass = ctx.createOscillator();
bass.type = 'triangle';
bass.frequency.value = f / 2;
const bg = envGain(ctx, t, 0.34, attack, decay * 1.8);
bass.connect(bg).connect(voiceBus);
bass.start(t);
bass.stop(t + attack + decay * 1.8 + 0.02);
}
}
return {
def,
out,
start(t0) {
wowLfo.start(t0);
crackle.start(t0);
lineWhine.start(t0);
nextTime = t0;
started = true;
},
update(now, lock, era) {
if (era !== eraNow) {
eraNow = era;
// The transmission ages even though the tune doesn't.
wowDepth.gain.value = era === 'reel' ? 0.0022 : 0;
crackleG.gain.value = era === 'reel' ? 0.055 : 0;
whineG.gain.value = era === 'broadcast' ? 0.012 : 0;
}
if (!started || lock <= 0.02) {
// Keep the clock rolling while off-station so the music is a continuous broadcast
// you tune INTO, not a track that starts when you arrive.
while (nextTime < now) { nextTime += spb; step = (step + 1) % m.steps; }
return;
}
while (nextTime < now + LOOKAHEAD) {
const semi = pattern[step];
if (semi >= 0) note(semi, nextTime, step % 4 === 0);
nextTime += spb;
step++;
if (step >= m.steps) {
step = 0;
bars++;
if (bars % m.mutateEveryBars === 0) mutate();
}
}
},
subtitle: () => '',
};
}
// ────────────────────────────────────────────────────────── AM 640 THE STANDARD
/**
* The Correction, with a transmitter.
*
* A ring-modulated mumble: a low carrier multiplied by a formant-swept tone, gated into
* syllables. It is deliberately just short of intelligible you can hear that it is speech
* and not what it says. THE SCREEN tells you what it says. That gap is the whole trick.
*/
export function amPropaganda(ctx: BaseAudioContext, def: StationDef): LiveStation {
const lines = def.chyron ?? [];
const out = ctx.createGain();
out.gain.value = 0;
// carrier * modulator = ring mod. The modulator is what makes it sound institutional.
const carrier = ctx.createOscillator();
carrier.type = 'sawtooth';
carrier.frequency.value = 118;
const ring = ctx.createGain();
ring.gain.value = 0; // driven by the modulator; 0 = silence between syllables
const modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 61;
const modDepth = ctx.createGain();
modDepth.gain.value = 0.5;
modulator.connect(modDepth).connect(ring.gain);
// two formants: a mouth shape that never quite resolves into a vowel
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.frequency.value = 520;
f1.Q.value = 4;
const f2 = ctx.createBiquadFilter();
f2.type = 'bandpass';
f2.frequency.value = 1180;
f2.Q.value = 6;
const syllable = ctx.createGain();
syllable.gain.value = 0;
carrier.connect(ring);
ring.connect(f1).connect(syllable);
ring.connect(f2).connect(syllable);
// AM band means AM bandwidth: nothing above ~4.5k survives the transmitter.
const band = ctx.createBiquadFilter();
band.type = 'lowpass';
band.frequency.value = 4500;
syllable.connect(band).connect(out);
let lineIdx = 0;
let nextSyllable = 0;
let syllablesLeft = 0;
let lineEndsAt = 0;
let started = false;
const rand = rng(6400);
function beginLine(t: number) {
lineIdx = (lineIdx + 1) % Math.max(1, lines.length);
const words = (lines[lineIdx] ?? '').split(/\s+/).length;
syllablesLeft = Math.max(6, Math.round(words * 1.6));
lineEndsAt = t + syllablesLeft * 0.17 + 1.4; // + the pause it leaves for effect
nextSyllable = t + 0.25;
}
return {
def,
out,
start(t0) {
carrier.start(t0);
modulator.start(t0);
started = true;
beginLine(t0);
},
update(now, lock) {
if (!started) return;
if (now > lineEndsAt) beginLine(now);
if (lock <= 0.02) return;
while (nextSyllable < now + LOOKAHEAD && syllablesLeft > 0) {
const t = nextSyllable;
const dur = 0.075 + rand() * 0.075;
// syllable envelope
syllable.gain.setValueAtTime(0.0001, t);
syllable.gain.exponentialRampToValueAtTime(0.5, t + 0.02);
syllable.gain.exponentialRampToValueAtTime(0.0001, t + dur);
// formant motion per syllable — the mouth moves, the meaning doesn't arrive
f1.frequency.setValueAtTime(380 + rand() * 320, t);
f2.frequency.setValueAtTime(950 + rand() * 700, t);
carrier.frequency.setValueAtTime(104 + rand() * 28, t);
nextSyllable = t + dur + 0.06 + rand() * 0.05;
syllablesLeft--;
}
},
// The flagship: the sky captions the radio.
subtitle: () => lines[lineIdx] ?? '',
};
}
// ─────────────────────────────────────────────────────────── SW 6955 NUMBERS
/**
* The unlisted station. Cold formant digits, no affect, no acknowledgement of a listener.
* Some groups are coordinates (LANE-DATA plants the real ones); the rest are decoys and
* seeded filler. The chyron shows the digits, so a player with a pencil can map the ground.
*/
export function swNumbers(
ctx: BaseAudioContext,
def: StationDef,
isRealSeam: (x: number, y: number) => boolean,
): LiveStation {
const n = def.numbers!;
const out = ctx.createGain();
out.gain.value = 0;
// A formant voice: buzz source + three resonances. Vowel-ish, sexless, entirely uninterested.
const buzz = ctx.createOscillator();
buzz.type = 'sawtooth';
buzz.frequency.value = 196;
const voiceGain = ctx.createGain();
voiceGain.gain.value = 0;
const fs = [720, 1240, 2600].map((f) => {
const b = ctx.createBiquadFilter();
b.type = 'bandpass';
b.frequency.value = f;
b.Q.value = 8;
return b;
});
const sum = ctx.createGain();
buzz.connect(voiceGain);
for (const f of fs) voiceGain.connect(f).connect(sum);
// SW bandwidth is narrow and awful
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.7;
sum.connect(band).connect(out);
const rand = rng(69550);
/** Vowel formants per digit so each numeral has a consistent, learnable shape. */
const DIGIT_FORMANTS: [number, number, number][] = [
[520, 1000, 2400], [300, 2200, 2900], [600, 1700, 2500], [500, 1750, 2450],
[640, 1200, 2300], [430, 2100, 2800], [560, 900, 2350], [400, 1900, 2600],
[700, 1150, 2400], [350, 2300, 3000],
];
function decodes(group: string): { x: number; y: number } | null {
if (group.length !== 5) return null;
const x = Number(group.slice(0, 2)) - 32;
const y = Number(group.slice(2, 4)) - 32;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
/** Seeded filler that never accidentally gives away real ground. */
function fillerGroup(): string {
for (let tries = 0; tries < 24; tries++) {
const g = Array.from({ length: n.groupSize }, () => Math.floor(rand() * 10)).join('');
const d = decodes(g);
if (d && isRealSeam(d.x, d.y)) continue; // never leak a coordinate by accident
return g;
}
return '00000';
}
let messages = 0;
let queue: string[] = [];
let nextAt = 0;
let started = false;
let showing = '';
function buildMessage() {
const groups: string[] = [];
for (let i = 0; i < n.preambleRepeats; i++) groups.push(n.preamble);
const planted = messages % n.plantEveryMessages === 0;
for (let i = 0; i < n.groupsPerMessage; i++) {
if (planted && i === Math.floor(n.groupsPerMessage / 2)) {
groups.push(n.plantedGroups[(messages / n.plantEveryMessages) % n.plantedGroups.length]);
} else if (rand() < 0.35) {
groups.push(n.decoyPool[Math.floor(rand() * n.decoyPool.length)]);
} else {
groups.push(fillerGroup());
}
}
messages++;
queue = groups;
}
function speakGroup(group: string, t0: number) {
const per = n.secondsPerGroup / (n.groupSize + 1);
for (let i = 0; i < group.length; i++) {
const d = Number(group[i]);
const t = t0 + i * per;
const [a, b, c] = DIGIT_FORMANTS[d] ?? DIGIT_FORMANTS[0];
fs[0].frequency.setValueAtTime(a, t);
fs[1].frequency.setValueAtTime(b, t);
fs[2].frequency.setValueAtTime(c, t);
buzz.frequency.setValueAtTime(190 + d * 2, t); // barely any melody: no affect
voiceGain.gain.setValueAtTime(0.0001, t);
voiceGain.gain.exponentialRampToValueAtTime(0.34, t + 0.03);
voiceGain.gain.setValueAtTime(0.34, t + per * 0.6);
voiceGain.gain.exponentialRampToValueAtTime(0.0001, t + per * 0.92);
}
}
return {
def,
out,
start(t0) {
buzz.start(t0);
started = true;
nextAt = t0 + 0.5;
buildMessage();
},
update(now, lock) {
if (!started) return;
while (nextAt < now + LOOKAHEAD) {
if (!queue.length) buildMessage();
const g = queue.shift()!;
showing = g;
if (lock > 0.02) speakGroup(g, nextAt);
nextAt += n.secondsPerGroup;
}
},
// Digits on the sky — the treasure map, for anyone bothering to write them down.
subtitle: () => (showing ? showing.split('').join(' ') : ''),
};
}
// ──────────────────────────────────────────────────────────── 108.0 DEAD AIR
/**
* The top of the dial. Room tone from a room that should not have any, breathing at the pace
* of a projector, and once in a very long while a chime played backwards.
*/
export function deadAir(ctx: BaseAudioContext, def: StationDef): LiveStation {
const out = ctx.createGain();
out.gain.value = 0;
const tone = noiseSource(ctx, 'brown');
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
const toneG = ctx.createGain();
// Deliberately well under a station: this is a room you strain to hear, not a channel.
// (Measured at 0.32 it rendered LOUDER than FKTRY FM, which made "dead air" the loudest
// thing on the dial — exactly backwards.)
toneG.gain.value = 0.1;
tone.connect(lp).connect(toneG).connect(out);
// the breathing: a slow swell at roughly a projector's pace
const breath = ctx.createOscillator();
breath.type = 'sine';
breath.frequency.value = 0.21;
const breathDepth = ctx.createGain();
breathDepth.gain.value = 0.05;
breath.connect(breathDepth).connect(toneG.gain);
let started = false;
let nextGhost = 0;
const ghostEvery = (def.ghost?.everyMinutes ?? 10) * 60;
/** A reversed chime: swell up out of nothing, then stop dead. Wrong-way-round on purpose. */
function ghost(t0: number) {
const dur = 2.2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.28, t0 + dur); // the swell
g.gain.setValueAtTime(0.0001, t0 + dur + 0.01); // the cut
g.connect(out);
[523.25, 783.99, 1046.5].forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f * (1 + i * 0.001);
o.connect(g);
o.start(t0);
o.stop(t0 + dur + 0.05);
});
}
return {
def,
out,
start(t0) {
tone.start(t0);
breath.start(t0);
started = true;
// seeded, so the ghost always arrives at the same moment in a given run
nextGhost = t0 + ghostEvery * (0.35 + hash01(1080) * 0.4);
},
update(now, lock) {
if (!started || lock <= 0.02) return;
if (now >= nextGhost) {
ghost(now + 0.05);
nextGhost = now + ghostEvery;
}
},
subtitle: () => '',
};
}

View File

@ -1,23 +0,0 @@
/**
* The subtitle bus THE SPEAKER captions THE SCREEN.
*
* The flagship cross-organ trick (round 5 order 6): when the radio is tuned to a talking
* station, the sky's chyron subtitles the audio. Both organs are LANE-SCREEN, so this is a
* sanctioned in-lane channel: THE SPEAKER (src/audio) writes, THE SCREEN (src/screen) reads.
* A module-level singleton keeps it out of the contract main.ts never has to marshal it.
*
* Deliberately trivial and dependency-free so both sides can import it without coupling to
* each other's guts.
*/
let current = '';
/** Set the line the sky should be showing (''/empty clears it). Called by the tuned station. */
export function setSubtitle(line: string): void {
current = line ?? '';
}
/** Read the current caption. Called by THE SCREEN each frame when building its chyron. */
export function getSubtitle(): string {
return current;
}

View File

@ -38,8 +38,7 @@ export type MachineKind =
| 'splitter' // 1 in, N out round-robin
| 'power' // generates bandwidth
| 'buffer' // stores bandwidth smoothness (brownout protection)
| 'shipper' // consumes items => 'shipped' events => THE SCREEN
| 'lab'; // v4: consumes science packs toward the active research
| 'shipper'; // consumes items => 'shipped' events => THE SCREEN
export interface MachineDef {
id: string;
@ -55,17 +54,9 @@ export interface MachineDef {
color?: string; // v3 RULING: CHASSIS/BODY color (codex §8 grime); the emissive
// accent stays derived from outputs. (Was mislabeled "accent".)
flavor?: string; // v2: codex one-liner for the inspector (DATA's material)
heatPerTick?: number; // v2: heat while active. v4 RULING: this is GROSS heat — net
// climb = heatPerTick coolPerTick; a machine whose
// heatPerTick ≤ its coolPerTick can never overheat.
heatPerTick?: number; // v2: heat while active (the software decoder's whole bit)
bufferCap?: number; // v2: bandwidth storage in bandwidth-SECONDS (see SimSnapshot)
coolPerTick?: number; // v3: per-machine cooling rate; sim default 0.004
accent?: string; // v4: art-directed emissive accent; fallback stays derived
// from first output item (renderer + UI share the derivation)
coolRadius?: number; // v4: spatial cooling aura in tiles (chebyshev from footprint).
// v5 CLARIFIED: auras are ADDITIVE — a machine in range cools at
// its own coolPerTick + sum of in-range auras, capped at the
// exported HEAT_COOL_MAX (see src/sim/constants.ts)
}
export interface TechDef {
@ -83,28 +74,12 @@ export interface CommissionDef {
repeat?: boolean; // v2: standing order — re-activates after completion
}
// v7: lifted verbatim from SIM's round-5 mirror — data/seams.json's shape.
export interface SeamDef {
id: string;
rect: { x: number; y: number; w: number; h: number };
era?: string;
resource?: string;
richness?: number;
hidden?: boolean; // treasure — only the numbers station may reveal it
}
export interface SeamMap {
/** DATA keeps this corner seam-free so the seed-derived relic always lands somewhere. */
relicReserve?: { x: number; y: number; w: number; h: number };
seams: SeamDef[];
}
export interface GameData {
items: ItemDef[];
machines: MachineDef[];
recipes: RecipeDef[];
tech: TechDef[];
commissions: CommissionDef[];
seams?: SeamMap; // v7: wired by main.ts from data/seams.json; extractors need ore
}
// ---------------------------------------------------------------- live sim state
@ -138,53 +113,20 @@ export interface SimSnapshot {
beltItems: ReadonlyArray<BeltItem>;
// v3 RULING on units: gen/draw are bandwidth per SECOND; stored is bandwidth-seconds
// (sim integrates surplus/deficit divided by TICKS_PER_SECOND each tick).
// v4: capacity = summed bufferCap of placed tanks (0 if none) — SCREEN's 2nd dread axis.
bandwidth: { gen: number; draw: number; stored: number; capacity?: number; brownout: boolean };
bandwidth: { gen: number; draw: number; stored: number; brownout: boolean };
shippedTotal: Record<string, number>;
activeCommission: string | null;
commissionQueue?: string[]; // v3: active first, then upcoming in activation
// order. May alias a live array — read-only, never
// retain. HARDENS IN v5: update fixtures this round.
commissionQueue?: string[]; // v3: truth for "next in tray" — active first, then
// upcoming in activation order (hardens in v4)
commissionProgress: Record<string, number>;
research?: ResearchState; // v4: research progress (hardens in v6)
wildlife?: ReadonlyArray<WildlifeState>; // v5: piles & swarms (M2's living hazards)
relics?: ReadonlyArray<RelicState>; // v6: buried secrets (the hidden pipe)
}
export interface RelicState {
id: number;
kind: 'optical-fossil'; // v6: the severed audio bus — one per world
pos: Vec2;
found: boolean; // excavated; persists through save/load
}
export interface WildlifeState {
id: number;
kind: 'dust-pile' | 'mosquito-swarm' | 'parity-mite'; // v8: The Correction walks
pos: Vec2;
size: number; // pile: accumulation 0..1; swarm: severity 0..1
target?: number; // swarm: entity id it is currently degrading
}
export interface ResearchState {
active: string | null; // TechDef.id being researched
progress: Record<string, number>; // packs delivered toward active (itemId -> n)
unlocked: string[]; // completed TechDef.ids
}
// v4 gating rule: a machine/recipe id that appears in ANY TechDef.unlocks is locked
// until that tech completes; ids referenced by no tech are always available.
export type Command =
| { kind: 'place'; def: string; pos: Vec2; dir: Dir }
| { kind: 'remove'; pos: Vec2 }
| { kind: 'rotate'; pos: Vec2 }
| { kind: 'setRecipe'; entity: number; recipe: string | null }
| { kind: 'setRecipeAt'; pos: Vec2; recipe: string | null } // v3: id-free variant
| { kind: 'setResearch'; tech: string | null } // v4: pick the lab target
| { kind: 'grantResearch'; tech: string[] } // v5: instant unlock — the
// sandbox/demo verb, also a
// legitimate creative mode
| { kind: 'excavate'; pos: Vec2 } // v6: dig at a relic site
| { kind: 'setPaused'; paused: boolean };
export type SimEvent =
@ -195,12 +137,7 @@ export type SimEvent =
| { kind: 'placed'; entity: number; def: string; tick: number }
| { kind: 'removed'; entity: number; def: string; tick: number }
| { kind: 'commissionDone'; commission: string; tick: number }
| { kind: 'scram'; entity: number; on: boolean; tick: number } // v2: heat shutdown edge
| { kind: 'researched'; tech: string; tick: number } // v4: research complete
| { kind: 'wildlife'; on: 'spawned' | 'cleared'; wildlife: 'dust-pile' | 'mosquito-swarm' | 'parity-mite';
id: number; pos: Vec2; tick: number } // v5: hazard lifecycle
| { kind: 'relicFound'; relic: 'optical-fossil'; id: number; tick: number } // v6
| { kind: 'repaired'; item: string; pos: Vec2; tick: number }; // v8: a mite corrected your work
| { kind: 'scram'; entity: number; on: boolean; tick: number }; // v2: heat shutdown edge
// ---------------------------------------------------------------- module interfaces
@ -210,39 +147,23 @@ export interface Sim {
tick(): void; // advance exactly one tick
snapshot(): SimSnapshot; // read-only view; renderer/ui must not mutate
drainEvents(): SimEvent[]; // events since last drain (main loop fans out)
save(): string; // v4: HARDENED — full state, deterministic round-trip
load(json: string): void; // v4: HARDENED — restore a save() blob
save?(): string; // v3: full state as JSON, deterministic round-trip
load?(json: string): void; // v3: restore a save() blob (hardens in v4)
}
/** v4: the typed selection protocol — kills the '__remove' magic-string sentinel. */
export type SelectionState =
| { mode: 'build'; def: string; dir: Dir }
| { mode: 'remove' }
| { mode: 'inspect'; entity: number } // v5: open inspector — renderer highlights it
| null;
export interface Renderer {
init(container: HTMLElement, data: GameData): Promise<void>;
render(snap: SimSnapshot, alpha: number): void; // alpha: 0..1 interp between ticks
pickTile(clientX: number, clientY: number): Vec2 | null;
/** v4: mode drives the ghost visual 'build' = placement preview, 'remove' =
* demolition tint. def is null in remove mode. The '__remove' sentinel is deprecated
* and main.ts stops sending it in v5. */
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode?: 'build' | 'remove'): void;
setGhost(def: string | null, pos: Vec2 | null, dir: Dir): void;
}
export interface UIBus {
dispatch(cmd: Command): void;
/** current build selection; renderer ghost + click-to-place read this.
* DEPRECATED in v4 (still served): use selection()/setSelection(). */
/** current build selection; renderer ghost + click-to-place read this */
selectedBuild(): { def: string; dir: Dir } | null;
/** v2: world picking, plumbed from the renderer — UI's click-to-inspect */
pickTile(clientX: number, clientY: number): Vec2 | null;
/** v4: typed selection. main.ts owns the state; UI writes via setSelection.
* In remove mode main.ts dispatches the `remove` command on click the UI must
* NOT also dispatch it (that was the round-3 double-fire). */
selection?(): SelectionState;
setSelection?(sel: SelectionState): void;
}
export interface UI {
@ -250,18 +171,6 @@ export interface UI {
update(snap: SimSnapshot, events: SimEvent[]): void;
}
/** v6: THE SPEAKER everything audible. All sound is synthesized (Web Audio), no
* asset files. Pre-discovery: minimal diegetic UI sounds only. After the optical
* fossil is found (snapshot.relics / relicFound), the full band unlocks: tuner,
* stations, world ambience. Owned by LANE-SCREEN (src/audio/**). */
export interface AudioFX {
init(data: GameData): void;
/** call on first user gesture — browsers gate AudioContext on interaction */
unlock(): void;
onEvents(events: SimEvent[]): void;
frame(timeMs: number, snap?: SimSnapshot): void;
}
export interface ScreenFX {
init(canvas: HTMLCanvasElement, data: GameData): void;
onEvents(events: SimEvent[]): void; // shipped items feed the corruption

View File

@ -2,184 +2,51 @@
* FKTRY main loop ORCHESTRATOR-OWNED wiring. Lanes do not edit.
* Fixed-timestep sim (30 tps) + interpolated render + event fan-out.
*/
import { TICKS_PER_SECOND, type Command, type Dir, type GameData, type SelectionState, type UIBus } from './contracts';
import { TICKS_PER_SECOND, type Command, type Dir, type GameData, type UIBus } from './contracts';
import { createSim } from './sim';
import { createRenderer } from './render';
import { createUI } from './ui';
import { createScreenFX } from './screen';
import { createAudioFX, getRadio } from './audio';
import items from '../data/items.json';
import machines from '../data/machines.json';
import recipes from '../data/recipes.json';
import tech from '../data/tech.json';
import commissions from '../data/commissions.json';
import seams from '../data/seams.json';
const data = { items, machines, recipes, tech, commissions, seams } as unknown as GameData;
const data = { items, machines, recipes, tech, commissions } as unknown as GameData;
const sim = createSim();
const renderer = createRenderer();
const ui = createUI();
const screen = createScreenFX();
const audio = createAudioFX();
/**
* Radio RadioControl adapter (round-5 review fix: the dial drove nothing).
* Two problems it solves, both integration seams between LANE-SCREEN's engine and
* LANE-UI's dock:
* 1. TIMING the dock resolves its control ONCE, inside ui.init(), before any user
* gesture has built the audio graph. This adapter EXISTS immediately (so the
* init-time probe finds a real subscribe()) and lazily attaches to the engine
* when the first gesture creates it, replaying state to subscribers.
* 2. SHAPE + UNITS UI speaks RadioControl {setFrequency(MHz), volume(), mute(),
* state.freqMHz, state.station:name}; the engine speaks Radio {setFrequency(native
* kHz for AM/SW), setVolume(), setMuted(), state.frequency, state.locked:id}.
*/
function createSpeakerAdapter() {
type EngineRadio = NonNullable<ReturnType<typeof getRadio>>;
let engine: EngineRadio | null = null;
let names = new Map<string, string>();
const subs = new Set<(s: any) => void>();
// pre-engine local state so the dock behaves before the first gesture
let local = { freqMHz: 88.0, band: 'FM' as 'AM' | 'FM' | 'SW', volume: 0.7, muted: false, station: null as string | null };
const toMHz = (band: string, f: number) => (band === 'FM' ? f : f / 1000);
const toNative = (band: string, mhz: number) => (band === 'FM' ? mhz : mhz * 1000);
const translate = (s: any) => ({
freqMHz: toMHz(s.band, s.frequency),
band: s.band,
volume: s.volume,
muted: s.muted,
station: s.locked ? (names.get(s.locked) ?? s.locked) : null,
});
const control = {
setFrequency(mhz: number) {
local.freqMHz = mhz;
if (engine) engine.setFrequency(toNative(engine.getState().band, mhz));
else notifyLocal();
},
setBand(band: 'AM' | 'FM' | 'SW') {
local.band = band;
if (engine) engine.setBand(band);
else notifyLocal();
},
volume(v: number) {
local.volume = Math.max(0, Math.min(1, v));
if (engine) engine.setVolume(local.volume);
else notifyLocal();
},
mute(on: boolean) {
local.muted = on;
if (engine) engine.setMuted(on);
else notifyLocal();
},
getState() {
return engine ? translate(engine.getState()) : { ...local };
},
subscribe(cb: (s: any) => void) {
subs.add(cb);
cb(control.getState());
return () => subs.delete(cb);
},
/** v8: station tick marks for the dial — engine truth, MHz units, empty pre-gesture */
listStations() {
return engine
? engine.listStations().map((st) => ({ ...st, freqMHz: toMHz(st.band, st.freq) }))
: [];
},
};
function notifyLocal() { for (const cb of subs) cb({ ...local }); }
return {
control,
attach() {
const r = getRadio();
if (!r || engine) return;
engine = r;
names = new Map(r.listStations().map((s) => [s.id, s.name]));
// carry any pre-gesture dial fiddling into the engine
r.setBand(local.band);
r.setFrequency(toNative(local.band, local.freqMHz));
r.setVolume(local.volume);
r.setMuted(local.muted);
r.subscribe((s) => { const t = translate(s); for (const cb of subs) cb(t); });
},
};
}
const speaker = createSpeakerAdapter();
// v4 bridge: _sel keeps the legacy {def,dir} shape (including the deprecated
// '__remove' sentinel) so unmigrated lanes keep working; selection()/setSelection()
// are the typed protocol. The sentinel path is deleted in v5.
const bus: UIBus & { _sel: { def: string; dir: Dir } | null; _state: SelectionState } = {
_sel: null, // legacy mirror (build/remove only) — served for selectedBuild() compat
_state: null, // v5: typed truth, carries inspect mode
const bus: UIBus & { _sel: { def: string; dir: Dir } | null } = {
_sel: null,
dispatch: (cmd: Command) => sim.enqueue(cmd),
selectedBuild: () => bus._sel,
pickTile: (x, y) => renderer.pickTile(x, y),
selection: (): SelectionState => {
if (bus._state) return bus._state;
if (!bus._sel) return null; // legacy writer fallback
if (bus._sel.def === '__remove') return { mode: 'remove' };
return { mode: 'build', def: bus._sel.def, dir: bus._sel.dir };
},
setSelection: (sel: SelectionState) => {
bus._state = sel;
bus._sel =
sel?.mode === 'build' ? { def: sel.def, dir: sel.dir } :
sel?.mode === 'remove' ? { def: '__remove', dir: 0 as Dir } :
null;
},
};
// LANE-UI sets the build selection through this hook:
(window as any).__fktryBus = bus;
async function boot() {
sim.init(data, 1337);
// publish the speaker handshake BEFORE ui.init — the dock resolves its control
// exactly once, during init, and must find a live-shaped RadioControl there.
(window as any).__fktrySpeaker = { radio: speaker.control };
await renderer.init(document.getElementById('game')!, data);
ui.init(document.getElementById('ui')!, data, bus);
screen.init(document.getElementById('screen') as HTMLCanvasElement, data);
audio.init(data);
// browsers gate AudioContext on a user gesture — unlock on the first one,
// then attach the adapter to the freshly built engine.
addEventListener('pointerdown', () => {
audio.unlock();
speaker.attach();
}, { once: true });
// click-to-place / hover ghost glue
const game = document.getElementById('game')!;
// Ghost refresh runs on move AND every frame (a keyboard disarm — Esc — used to leave
// a stale demolition tint on screen until the mouse moved; round-5 UI finding).
let lastPointer: { x: number; y: number } | null = null;
function refreshGhost() {
if (!lastPointer) return;
const sel = bus.selection!();
const tile = renderer.pickTile(lastPointer.x, lastPointer.y);
if (sel?.mode === 'remove') {
renderer.setGhost(null, tile, 0, 'remove');
} else if (sel?.mode === 'build') {
renderer.setGhost(sel.def, tile, sel.dir, 'build');
} else {
renderer.setGhost(null, tile, 0, 'build'); // inspect / no selection: no ghost
}
}
game.addEventListener('pointermove', (e) => {
lastPointer = { x: e.clientX, y: e.clientY };
refreshGhost();
const sel = bus.selectedBuild();
renderer.setGhost(sel?.def ?? null, renderer.pickTile(e.clientX, e.clientY), sel?.dir ?? 0);
});
game.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // pan/context buttons never place
const sel = bus.selection!();
const sel = bus.selectedBuild();
const tile = renderer.pickTile(e.clientX, e.clientY);
if (!sel || !tile) return;
if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: tile });
else if (sel.mode === 'build') sim.enqueue({ kind: 'place', def: sel.def, pos: tile, dir: sel.dir });
if (sel && tile) sim.enqueue({ kind: 'place', def: sel.def, pos: tile, dir: sel.dir });
});
const MS_PER_TICK = 1000 / TICKS_PER_SECOND;
@ -189,28 +56,21 @@ async function boot() {
while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; }
const snap = sim.snapshot();
const events = sim.drainEvents();
refreshGhost();
renderer.render(snap, acc / MS_PER_TICK);
ui.update(snap, events);
screen.onEvents(events);
screen.frame(now, snap);
audio.onEvents(events);
audio.frame(now, snap);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// dev-only: paste-free demo — builds SIM's reference factory. The layout uses
// research-gated ids, so it is a post-research save by definition: grant the techs
// it needs first (testkit rides the public save/load API), then dispatch.
// dev-only: paste-free demo — builds SIM's reference factory
if (import.meta.env.DEV) {
(window as any).__fktryDemo = async () => {
const { referenceFactory, referenceFactoryTech } = await import('./sim/reference');
const techs = referenceFactoryTech(data);
sim.enqueue({ kind: 'grantResearch', tech: techs }); // v5 command; queued first
const { referenceFactory } = await import('./sim/reference');
const cmds = referenceFactory(data);
cmds.forEach((c) => sim.enqueue(c));
return `${techs.length} techs granted, ${cmds.length} commands dispatched`;
return `${cmds.length} commands dispatched`;
};
}
}

View File

@ -1,97 +0,0 @@
/**
* LANE-RENDER cooling aura footprint.
*
* `coolRadius` is CHEBYSHEV distance from the machine's footprint, so the region it
* actually cools is a RECTANGLE the footprint grown by r tiles on every side. Drawing
* a circle here would be prettier and would lie: the corners it left out are cooled, and
* players would plan around a boundary that isn't real. So this is a rectangle, aligned
* to the grid it's actually measured in.
*
* Engineering-drawing vibe, not a buff circle: a thin dashed outline with tick marks at
* the tile grid, no fill, no bloom. It's a coverage annotation, not a power-up.
*/
import * as THREE from 'three';
const AURA = 0x7fd4ff;
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform vec2 uSize; // rectangle size in TILES, so dashes stay tile-sized at any scale
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 p = vUv * uSize; // position in tiles from the corner
vec2 d = min(p, uSize - p); // distance to the nearest edge, in tiles
float edge = min(d.x, d.y);
// Dashed border: march the dash phase around the perimeter, not across the face,
// so corners don't smear.
float along = (d.x < d.y) ? p.y : p.x;
float dash = step(0.35, fract(along * 1.5 - uTime * 0.25));
float line = (1.0 - smoothstep(0.03, 0.07, edge)) * dash;
// Tick marks every tile, just inside the border — the drafting touch.
float tick = step(0.94, fract(along)) * (1.0 - smoothstep(0.10, 0.16, edge));
float a = max(line, tick * 0.7);
if (a < 0.02) discard;
gl_FragColor = vec4(uColor, a * 0.55);
}
`;
export class CoolAura {
readonly group = new THREE.Group();
private mesh: THREE.Mesh;
private uniforms = {
uSize: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(AURA) },
uTime: { value: 0 },
};
constructor() {
this.group.name = 'coolAura';
this.group.visible = false;
this.mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
}),
);
this.mesh.rotation.x = -Math.PI / 2;
this.mesh.position.y = 0.03;
this.mesh.renderOrder = 9;
this.group.add(this.mesh);
}
hide(): void {
this.group.visible = false;
}
/**
* `cx`/`cz` centre the machine; `fw`/`fd` are its ROTATED footprint; `r` is coolRadius.
* Covered region = footprint + r tiles on each side (chebyshev).
*/
show(cx: number, cz: number, fw: number, fd: number, r: number, timeSec: number): void {
const w = fw + r * 2;
const d = fd + r * 2;
this.group.visible = true;
this.uniforms.uSize.value.set(w, d);
this.uniforms.uTime.value = timeSec;
this.mesh.scale.set(w, d, 1);
this.mesh.position.set(cx, 0.03, cz);
}
}

View File

@ -7,8 +7,10 @@
* fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
* an item is blocked by back-pressure (prev == cur -> it simply sits still).
*
* Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and
* the old order-matching fallback (and its per-frame sort of every item) is gone.
* Identity: contracts v2 added `BeltItem.id`. When present we key off it and the
* interpolation is EXACT. When absent we fall back to matching by (belt, type, order
* along the belt) correct except for the one tick where the lead item leaves and the
* queue behind it shifts index. That fallback goes away when v3 makes `id` required.
*
* Pathing: cargo follows the belt's topology shape, so on a corner it enters at the
* feeding edge and sweeps the arc rather than teleporting to the back edge and cutting
@ -27,13 +29,8 @@ interface ItemMesh {
capacity: number;
}
/**
* Products glow; raw ore doesn't saturation belongs to media (style guide §8).
* The base colour goes through `readable()` because MDAT ORE's authored #1a1a22 is
* invisible against the belt; ore still reads as the darkest thing on the line, just
* not as a hole in the world.
*/
export function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
/** Products glow; raw ore doesn't — saturation belongs to media (style guide §8). */
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: readable(def.color),
emissive: new THREE.Color(def.color),
@ -43,8 +40,7 @@ export function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
});
}
export function itemGeometry(def: ItemDef): THREE.BufferGeometry {
// Products read as faceted crystals; raw/refined as crate-ish boxes.
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
return def.tier >= 2
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
@ -54,15 +50,17 @@ export class BeltItemLayer {
readonly group = new THREE.Group();
private byItem = new Map<string, ItemMesh>();
private items = new Map<string, ItemDef>();
/** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
private prev = new Map<number, number>();
private cur = new Map<number, number>();
private prev = new Map<string, number>();
private cur = new Map<string, number>();
private lastTick = -1;
private dummy = new THREE.Object3D();
private counts = new Map<string, number>();
private written = new Map<string, number>();
private seq = new Map<string, number>();
private sorted: SimSnapshot['beltItems'][number][] = [];
private lp = { x: 0, z: 0 };
private wp = { x: 0, z: 0 };
/** Set once per frame so NOTES/telemetry can report which identity path ran. */
exactIds = false;
constructor(data: GameData, private topo: BeltTopology) {
this.group.name = 'beltItems';
@ -73,7 +71,6 @@ export class BeltItemLayer {
const existing = this.byItem.get(def.id);
if (existing && needed <= existing.capacity) return existing;
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
// Re-use geo/mat across growth; only the InstancedMesh itself is replaced.
const geometry = existing?.mesh.geometry ?? itemGeometry(def);
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
if (existing) {
@ -92,7 +89,17 @@ export class BeltItemLayer {
}
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
// Roll the tick window: cur becomes prev the moment the sim advances.
const src = snap.beltItems;
// Sim is internally consistent: if it stamps ids, it stamps all of them.
this.exactIds = src.length > 0 && src[0].id !== undefined;
this.sorted.length = 0;
for (const bi of src) if (this.topo.get(bi.entity)) this.sorted.push(bi);
if (!this.exactIds) {
// Order-match fallback needs cargo ordered front-to-back per belt.
this.sorted.sort((a, b) => (a.entity !== b.entity ? a.entity - b.entity : b.t - a.t));
}
const advanced = snap.tick !== this.lastTick;
if (advanced) {
const swap = this.prev;
@ -102,12 +109,12 @@ export class BeltItemLayer {
this.lastTick = snap.tick;
}
// Pass 1 — count per item type, and record this tick's positions for next frame.
// Pass 1 — count per type, and record this tick's positions for the next frame.
this.counts.clear();
for (const bi of snap.beltItems) {
if (!this.topo.get(bi.entity)) continue; // cargo on a belt we haven't seen yet
this.seq.clear();
for (const bi of this.sorted) {
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
if (advanced) this.cur.set(bi.id, bi.t);
if (advanced) this.cur.set(this.keyOf(bi), bi.t);
}
for (const [itemId, count] of this.counts) {
const def = this.items.get(itemId);
@ -115,15 +122,15 @@ export class BeltItemLayer {
}
// Pass 2 — write instance matrices.
this.written.clear();
for (const bi of snap.beltItems) {
const node = this.topo.get(bi.entity);
if (!node) continue;
this.seq.clear();
const written = new Map<string, number>();
for (const bi of this.sorted) {
const node = this.topo.get(bi.entity)!;
const def = this.items.get(bi.item);
const im = this.byItem.get(bi.item);
if (!def || !im) continue;
const from = this.prev.get(bi.id);
const from = this.prev.get(this.keyOf(bi));
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
localPath(node.shape, t, this.lp);
@ -136,16 +143,25 @@ export class BeltItemLayer {
}
this.dummy.updateMatrix();
const n = this.written.get(bi.item) ?? 0;
const n = written.get(bi.item) ?? 0;
if (n < im.capacity) {
im.mesh.setMatrixAt(n, this.dummy.matrix);
this.written.set(bi.item, n + 1);
written.set(bi.item, n + 1);
}
}
for (const [id, im] of this.byItem) {
im.mesh.count = this.written.get(id) ?? 0;
im.mesh.count = written.get(id) ?? 0;
im.mesh.instanceMatrix.needsUpdate = true;
}
}
/** Exact when the sim stamps ids; order-matched otherwise. */
private keyOf(bi: SimSnapshot['beltItems'][number]): string {
if (bi.id !== undefined) return `#${bi.id}`;
const sk = `${bi.entity}:${bi.item}`;
const idx = this.seq.get(sk) ?? 0;
this.seq.set(sk, idx + 1);
return `${bi.entity}:${bi.item}:${idx}`;
}
}

View File

@ -1,109 +0,0 @@
/**
* LANE-RENDER the break room. A GLYTCH arcade cabinet near the start zone.
*
* Clicking it opens `match.html` the original match-the-glitch prototype, which deploy
* copies next to the game (see deploy.sh). The two games finally meet. The cabinet is a
* real object in the world with its own raycast (index.ts owns the pick + the cursor
* affordance), not a UI button, because it should feel like a thing you find on the
* floor, not a menu item.
*
* The marquee spells GLYTCH in glowing blocks a placeholder until a MODELBEAST cabinet
* lands under the `arcade-cabinet` asset key (the registry would hot-swap it, but the
* break room isn't a MachineDef, so this is hand-built and self-contained).
*/
import * as THREE from 'three';
// 5x5 bitmap font for G L Y T C H — one column of glowing pixels per letter row.
const GLYPHS: Record<string, number[]> = {
G: [0b01110, 0b10000, 0b10011, 0b10001, 0b01110],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
Y: [0b10001, 0b01010, 0b00100, 0b00100, 0b00100],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100],
C: [0b01110, 0b10001, 0b10000, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b11111, 0b10001, 0b10001],
};
export class BreakRoom {
readonly group = new THREE.Group();
/** The subtree the raycast tests against — click anywhere on the cabinet counts. */
readonly clickable: THREE.Object3D[] = [];
private marqueeMat: THREE.MeshStandardMaterial;
constructor(x: number, z: number) {
this.group.name = 'breakroom';
this.group.position.set(x, 0, z);
// The iso rig looks from (+x,+y,+z), so a face whose normal is +z points 45° away
// from the viewer and the marquee reads edge-on. Turn the whole cabinet to face the
// camera — it's a prop, not a machine, so it doesn't owe the grid an orientation.
this.group.rotation.y = Math.PI / 4;
const body = new THREE.MeshStandardMaterial({ color: 0x1a1830, roughness: 0.7, metalness: 0.2 });
// Wide enough to carry its own marquee: GLYTCH at a legible pixel size spans ~1.8
// units, and a sign wider than its cabinet reads as a floating decal.
const cabinet = new THREE.Mesh(new THREE.BoxGeometry(2.1, 2.6, 1.1), body);
cabinet.position.y = 1.3;
cabinet.castShadow = cabinet.receiveShadow = true;
this.group.add(cabinet);
this.clickable.push(cabinet);
// Screen: a dark panel with a cyan glow, angled toward the player.
const screen = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.0),
new THREE.MeshStandardMaterial({
color: 0x0a1420, emissive: 0x1b6cff, emissiveIntensity: 0.8, roughness: 0.4,
}),
);
screen.position.set(0, 1.45, 0.56);
screen.rotation.x = -0.18;
this.group.add(screen);
this.clickable.push(screen);
// Marquee: GLYTCH in emissive pixels across the top.
this.marqueeMat = new THREE.MeshStandardMaterial({
color: 0x3fd4ff, emissive: 0x3fd4ff, emissiveIntensity: 1.6,
});
const marquee = this.buildMarquee();
marquee.position.set(0, 2.28, 0.57);
this.group.add(marquee);
// Control deck.
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.15, 0.5), body);
deck.position.set(0, 0.78, 0.55);
deck.rotation.x = -0.3;
this.group.add(deck);
this.clickable.push(deck);
}
private buildMarquee(): THREE.Object3D {
const g = new THREE.Group();
const word = 'GLYTCH';
// Sized so the word spans most of the cabinet's width — at the rig's closest zoom
// (8) a 0.03 pixel was sub-pixel on screen and GLYTCH was an illegible smudge.
const px = 0.05;
const letterW = 5 * px + px; // 5 cols + gap
const totalW = word.length * letterW;
const pixel = new THREE.BoxGeometry(px * 0.9, px * 0.9, px * 0.6);
word.split('').forEach((ch, li) => {
const rows = GLYPHS[ch];
if (!rows) return;
rows.forEach((bits, ry) => {
for (let cx = 0; cx < 5; cx++) {
if (!(bits & (1 << (4 - cx)))) continue;
const m = new THREE.Mesh(pixel, this.marqueeMat);
m.position.set(
-totalW / 2 + li * letterW + cx * px,
(2 - ry) * px,
0,
);
g.add(m);
}
});
});
return g;
}
/** Marquee attract-mode shimmer. */
frame(timeSec: number): void {
this.marqueeMat.emissiveIntensity = 1.3 + Math.sin(timeSec * 4) * 0.4;
}
}

View File

@ -150,14 +150,6 @@ export class CameraRig {
this.applyFrustum();
}
/** Jump the rig to frame a specific spot — the showroom's camera preset. */
frame(x: number, z: number, zoom: number): void {
this.target.set(x, 0, z);
this.zoomD = THREE.MathUtils.clamp(zoom, ZOOM_MIN, ZOOM_MAX);
this.applyFrustum();
this.place();
}
dispose(): void {
for (const d of this.disposers) d();
this.disposers = [];

View File

@ -15,11 +15,7 @@
* by wrapping `t` on one belt, because that's what the real sim does — and it's what
* exercises the renderer's cross-belt interpolation and corner pathing honestly.
*/
import {
TICKS_PER_SECOND,
type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot,
type WildlifeState,
} from '../contracts';
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
/**
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
@ -47,8 +43,6 @@ export class DevScene {
private speed = 2;
private ok = false;
private nextId = 1;
/** The mock's own scram latch — see snapshot(). */
private scrammed = false;
constructor(private data: GameData, private stress = false) {
this.build();
@ -96,24 +90,6 @@ export class DevScene {
this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items
if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 500
}
// The round-5 perf bar: floor + 50 piles + 10 swarms on top of 500 entities.
const ents = this.entities.filter((e) => e.def !== 'belt');
for (let i = 0; i < 50; i++) {
this.wildlife.push({
id: 1000 + i, kind: 'dust-pile',
pos: { x: -30 + (i % 10) * 3, y: -26 + Math.floor(i / 10) * 12 },
size: ((i * 37) % 100) / 100,
});
}
for (let i = 0; i < 10; i++) {
const host = ents[i % Math.max(1, ents.length)];
this.wildlife.push({
id: 2000 + i, kind: 'mosquito-swarm',
pos: host ? { x: host.pos.x, y: host.pos.y } : { x: -5, y: -20 + i * 5 },
size: 0.5 + (i % 5) * 0.1,
target: host?.id,
});
}
this.ok = true;
return;
}
@ -147,25 +123,6 @@ export class DevScene {
if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1);
if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize');
if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh');
// v4/v5 kinds, so inspect-aura and the lab silhouette are reachable in the scaffold.
if (this.has('asic-cooler')) this.machine('asic-cooler', 9, 8, 1);
if (this.has('archaeology-lab')) this.machine('archaeology-lab', 13, 8, 1);
// A few hazards so wildlife is visible without the stress rig: a growing dust drift
// beside the quantizer (which is what sheds HF dust, §2) and a swarm on the demuxer.
const demuxer = this.entities.find((e) => e.def === 'demuxer');
this.wildlife.push(
{ id: 900, kind: 'dust-pile', pos: { x: 1, y: 11 }, size: 0.35 },
{ id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 },
{ id: 902, kind: 'dust-pile', pos: { x: 0, y: 12 }, size: 0.15 },
{ id: 910, kind: 'mosquito-swarm', pos: { x: -8, y: 0 }, size: 0.7, target: demuxer?.id },
// The Correction: three parity mites, one per state so all three tells are visible —
// 920 hunting (drifts), 921 on-station (dwells, eye glowing), 922 animated in
// snapshot() so it cycles through the repair and fires the tidy 'repaired' sparkle.
{ id: 920, kind: 'parity-mite', pos: { x: -9, y: 0 }, size: 0, target: demuxer?.id },
{ id: 921, kind: 'parity-mite', pos: { x: -2, y: 3 }, size: 0.5 },
{ id: 922, kind: 'parity-mite', pos: { x: 0, y: 5 }, size: 0.7 },
);
this.ok = true;
}
@ -175,26 +132,13 @@ export class DevScene {
// so all three states are reachable in a short verification session.
const heat = Math.min(1, (tick % 300) / 200);
const brownout = Math.floor(tick / 300) % 4 === 3;
// The mock owns its own latch, the way the real sim owns its own: scram at 1.0 and
// stay dead while cooling. The renderer must read `scrammed` and never infer it —
// that's the whole point of the v3 field.
if (heat >= 1) this.scrammed = true;
else if (heat < 0.5) this.scrammed = false;
for (const e of this.entities) {
if (e.def === 'software-decoder') {
e.heat = heat;
e.scrammed = this.scrammed;
}
if (e.def === 'software-decoder') e.heat = heat;
if (e.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null;
if (e.recipe) e.progress = (tick % 90) / 90;
}
// Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp and the
// tidy 'repaired' sparkle (fired on the <1→≥1 crossing) are both visible in the scaffold.
const mite = this.wildlife.find((w) => w.id === 922);
if (mite) mite.size = (tick % 150) / 100; // 0 → 1.5, crossing 1.0 each loop
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
const perTick = this.speed / TICKS_PER_SECOND;
const span = seg.ids.length;
@ -217,31 +161,11 @@ export class DevScene {
gen: 20,
draw: brownout ? 26 : 14,
stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100,
capacity: 100,
brownout,
},
shippedTotal: { melt: Math.floor(tick / 90) },
activeCommission: null,
commissionProgress: {},
// v4: nothing researched, so every tech-gated machine reads locked — which is what
// the ghost's padlock is for. `unlocked` is writable from the console to check the
// other half (see NOTES).
research: this.research,
// v3 hardened: the queue is active-first, then upcoming.
commissionQueue: this.commissionQueue,
// v5/v6: hazards and secrets. Mutable so a verification session can grow a pile,
// aim a swarm, or excavate the relic from the console.
wildlife: this.wildlife,
relics: this.relics,
};
}
/** Mutable so a verification session can unlock techs and watch padlocks clear. */
readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] };
/** v3-hardened field — present so fixtures match the contract, not just the renderer. */
readonly commissionQueue: string[] = [];
/** v5 hazards. `?devscene=stress` fills these to the perf bar (50 piles + 10 swarms). */
readonly wildlife: WildlifeState[] = [];
/** v6 secret: one buried optical fossil, unfound. Flip `found` to see the monument. */
readonly relics: RelicState[] = [{ id: 1, kind: 'optical-fossil', pos: { x: 9, y: -7 }, found: false }];
}

View File

@ -17,14 +17,23 @@ import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from
import { centerOf, yawFor } from './coords';
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
import { createHeatHaze, type HeatHaze } from './heat';
import type { Juice } from './juice';
/** Placement thunk: how long the scale-pop takes, in seconds. */
const POP_TIME = 0.22;
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25;
/**
* Scram releases below this mirroring LANE-SIM's `HEAT_RESTART`.
*
* Scram is a LATCH in the sim, but it lives on its internal `Ent`, never on
* `EntityState`; it clears `jammed` on scram ("reported by its own event, not as a flow
* jam"), and `Renderer` only ever receives snapshots, never events. So the latched-off
* state is not snapshot-visible, and a plain `heat >= 1` test un-scrams the moment the
* machine starts cooling showing it alive and glowing for the ~125 ticks (~4s) it
* spends dead between 1.0 and HEAT_RESTART. Mirroring the sim's hysteresis here is
* correct today but couples this constant to theirs; `EntityState.scrammed` would kill
* the duplication. Filed as a CONTRACT REQUEST.
*/
const SCRAM_RELEASE = 0.5;
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12);
@ -56,15 +65,10 @@ interface Rec {
idleClock: number;
heat: HeatTarget[];
all: HeatTarget[];
/** Latched scram state, mirroring the sim's (see SCRAM_RELEASE). */
scramLatch: boolean;
/** Whether dead-dark is currently applied, so restart knows to restore. */
wasScrammed: boolean;
/** Seconds into the placement scale-pop; >= POP_TIME means settled. */
pop: number;
/** The object's settled scale. 1 for placeholders; the GLB fit-to-footprint scale for a
* hot-swapped asset. The pop multiplies THIS, never resets to 1 a normalised GLB roots
* at s1 and hardcoding 1 collapsed the body to a speck (the beacon/jam sit on their own
* local scale so they were unaffected, which is why only bodies vanished). */
baseScale: number;
}
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
@ -73,8 +77,6 @@ export class EntityLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
/** False until the first sync completes — stops a save-load firing juice for everything. */
private primed = false;
/** One shared material: every alarm blinks together, like a real klaxon. */
private jamMat = new THREE.MeshStandardMaterial({
color: JAM_AMBER,
@ -82,28 +84,20 @@ export class EntityLayer {
emissiveIntensity: 1.5,
});
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
private juice?: Juice,
) {
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'entities';
}
sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
sync(snap: SimSnapshot, timeSec: number, dt: number): void {
this.seen.clear();
let anyAlarm = false;
// Tanks share one bandwidth pool, so they all read the same level: the pool's
// fraction of total installed capacity. v4 publishes that total, so take it from
// truth rather than re-summing bufferCap here — a second copy of the sim's
// arithmetic is exactly what the scram latch taught us not to keep.
let cap = snap.bandwidth.capacity ?? 0;
if (!snap.bandwidth.capacity) {
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
}
// fraction of total installed capacity.
let cap = 0;
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
}
const fillLevel = cap > 0 ? THREE.MathUtils.clamp(snap.bandwidth.stored / cap, 0, 1) : 0;
@ -119,16 +113,7 @@ export class EntityLayer {
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
rec = undefined;
}
if (!rec) {
rec = this.build(e.id, e.def, def, entry.version);
// A machine that was not here last frame was just placed. First frame primes the
// set without firing, so loading a save doesn't confetti the whole factory.
if (this.primed) {
const c = centerOf(e.pos, def, e.dir);
this.juice?.puff(c.x, c.z);
rec.pop = 0; // start the scale-pop
}
}
if (!rec) rec = this.build(e.id, e.def, def, entry.version);
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
centerOf(e.pos, def, e.dir, rec.obj.position);
@ -139,12 +124,10 @@ export class EntityLayer {
}
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
// v3: scram comes from the sim's own latch. `heat` alone cannot express the
// hysteresis window — a machine stays dead while cooling from 1.0 back down —
// so deriving it here meant duplicating the sim's restart threshold and silently
// desyncing the day they retuned it. The `?? heat >= 1` is only a bridge for a
// sim that hasn't wired the field yet; it carries no local constant.
const scrammed = e.scrammed ?? heat >= 1;
// Latch with hysteresis, exactly as the sim does — see SCRAM_RELEASE.
if (heat >= 1) rec.scramLatch = true;
else if (heat < SCRAM_RELEASE) rec.scramLatch = false;
const scrammed = rec.scramLatch;
// Throttle ramps in rather than snapping, so "labouring" reads before "dead".
const speed = scrammed
? 0
@ -162,33 +145,11 @@ export class EntityLayer {
const alarm = !!e.jammed || scrammed;
rec.jam.visible = alarm;
if (alarm) anyAlarm = true;
// Placement thunk: overshoot then settle. Reads as weight landing on the floor.
// The pop is a MULTIPLIER on the settled scale, never an absolute — a normalised GLB
// settles at baseScale (s≠1), so resetting to 1 would collapse its body to a speck.
if (rec.pop < POP_TIME) {
rec.pop += dt;
const k = Math.min(1, rec.pop / POP_TIME);
const mult = k < 1 ? 1 + Math.sin(k * Math.PI) * 0.22 : 1;
rec.obj.scale.setScalar(rec.baseScale * (k < 0.12 ? k / 0.12 : mult));
} else if (rec.obj.scale.x !== rec.baseScale) {
rec.obj.scale.setScalar(rec.baseScale);
}
// Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8).
if (harassed?.has(e.id)) {
rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012;
rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012;
}
}
for (const [id, rec] of this.live) {
if (!this.seen.has(id)) {
this.juice?.burst(rec.obj.position.x, rec.obj.position.z);
this.drop(id, rec);
}
if (!this.seen.has(id)) this.drop(id, rec);
}
this.primed = true;
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
}
@ -297,9 +258,8 @@ export class EntityLayer {
idleClock: 0,
heat,
all,
scramLatch: false,
wasScrammed: false,
pop: POP_TIME, // settled by default; a real placement rewinds it to 0
baseScale: obj.scale.x, // 1 for placeholders; the GLB normalisation scale otherwise
};
this.live.set(id, rec);
return rec;

View File

@ -1,73 +0,0 @@
/**
* LANE-RENDER era skins (style guide §8).
*
* "Reel Beds = sepia/brass/celluloid; Broadcast = phosphor green + wood-grain TV;
* Disc = rainbow-on-black gloss; Stream = flat white plastic + LED; Fortress =
* blinding matte white."
*
* Plumbing only this round. A machine's era is DERIVED from which tech unlocks it, so
* when LANE-DATA adds reel/broadcast techs those machines re-skin with zero renderer
* work. Machines no tech unlocks are starting equipment and take the default.
*
* The skin touches the CHASSIS only the emissive accent stays the colour of what the
* machine makes, because that's the resource, not the decade.
*
* `stream` is deliberately the identity skin (same roughness/metalness as the base body
* material, no tint), so today's look is byte-for-byte unchanged until data says otherwise.
*/
import * as THREE from 'three';
import type { GameData, TechDef } from '../contracts';
export type Era = TechDef['era'];
export const DEFAULT_ERA: Era = 'stream';
export interface EraSkin {
/** Multiplied into the chassis colour — 0xffffff leaves DATA's value alone. */
tint: number;
roughness: number;
metalness: number;
}
export const ERA_SKIN: Record<Era, EraSkin> = {
reel: { tint: 0xffd2a0, roughness: 0.65, metalness: 0.45 }, // sepia/brass/celluloid
broadcast: { tint: 0xc4e8c4, roughness: 0.8, metalness: 0.1 }, // phosphor + woodgrain
disc: { tint: 0x9aa0b8, roughness: 0.2, metalness: 0.65 }, // rainbow-on-black gloss
stream: { tint: 0xffffff, roughness: 0.85, metalness: 0.15 }, // identity — the default
fortress: { tint: 0xffffff, roughness: 0.95, metalness: 0.0 }, // blinding matte white
};
/**
* machine id -> era, via the tech that unlocks it. Anything unmapped is starting
* equipment (no tech gates it) and takes DEFAULT_ERA.
*/
export function eraIndex(data: GameData): Map<string, Era> {
const out = new Map<string, Era>();
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) if (machines.has(u)) out.set(u, t.era);
}
return out;
}
/** Skin a chassis material in place. Accents must never be passed here. */
export function applyEraSkin(mat: THREE.MeshStandardMaterial, era: Era): void {
const skin = ERA_SKIN[era] ?? ERA_SKIN[DEFAULT_ERA];
mat.color.multiply(new THREE.Color(skin.tint));
mat.roughness = skin.roughness;
mat.metalness = skin.metalness;
}
/**
* Era read as a SIGNAL rather than a surface used by the locked-build ghost to say
* *which* era is holding a machine back. `ERA_SKIN.tint` can't do this job: it's a
* multiplier tuned to leave DATA's chassis alone (stream is literally white), so three
* of the five eras would tell you nothing. These are chosen to be told apart at a
* glance, in codex order.
*/
export const ERA_TELL: Record<Era, number> = {
reel: 0xd8a24a, // brass
broadcast: 0x5fd08a, // phosphor
disc: 0x6fd7ff, // iridescent
stream: 0xdfe6f2, // flat plastic white
fortress: 0xf2f7ff, // blinding
};

View File

@ -1,37 +1,19 @@
/**
* LANE-RENDER build ghost, remove-mode hover, and cooling-aura annotation.
* LANE-RENDER build ghost. Shows the selected machine at the hovered tile:
* cyan when it fits, red when its footprint collides with something already placed.
*
* The ghost answers one question: what happens if I click here?
* cyan it fits
* red its footprint collides with something already placed
* amber the machine exists but research hasn't unlocked it yet
*
* That third state is why this reads `research`: the sim silently drops a `place` for a
* locked def, so without a tell the click just does nothing and reads as a bug. Amber is
* deliberately not red "not yet" and "not here" are different sentences.
*
* Occupancy and lock state are previews only; the sim still gets the final say when the
* command lands.
*
* v4: the mode comes from `setGhost`'s 4th argument. The old `'__remove'` def sentinel
* still arrives from main.ts as a bridge and is simply ignored mode is the truth.
* Occupancy is read straight off the snapshot's entity footprints the sim still gets
* the final say when the place command lands; this is only the player's preview.
*/
import * as THREE from 'three';
import type {
Dir, EntityState, GameData, MachineDef, SimSnapshot, TechDef, Vec2,
} from '../contracts';
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
import { centerOf, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
import { HEIGHT_BY_KIND } from './palette';
import { RemoveHover } from './remove';
import { CoolAura } from './aura';
import { ERA_TELL } from './era';
const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
const LOCKED = 0xffb02e;
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
@ -45,27 +27,6 @@ function ghostMaterial(color: number): THREE.MeshStandardMaterial {
});
}
/** A padlock, floating over a build you can't have yet. Tinted by the era gating it. */
function makePadlock(): { obj: THREE.Group; tint: (c: number) => void } {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: LOCKED, emissive: LOCKED, emissiveIntensity: 1.4,
transparent: true, opacity: 0.9, depthWrite: false,
});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.2, 0.12), mat);
const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.08, 0.025, 6, 12, Math.PI), mat);
shackle.position.y = 0.1;
g.add(body, shackle);
g.renderOrder = 12;
return {
obj: g,
tint: (c: number) => {
mat.color.setHex(c);
mat.emissive.setHex(c);
},
};
}
export class GhostLayer {
readonly group = new THREE.Group();
private obj: THREE.Object3D | null = null;
@ -77,76 +38,22 @@ export class GhostLayer {
private def: MachineDef | null = null;
private pos: Vec2 | null = null;
private dir: Dir = 0;
private removing = false;
private occupied = new Map<number, EntityState>();
private occupied = new Set<number>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
private matLocked = ghostMaterial(LOCKED);
readonly remove = new RemoveHover();
/** Lives outside `group` so it can annotate a hovered machine with nothing selected. */
readonly aura = new CoolAura();
private padlock = makePadlock();
/** machine id -> the tech that gates it (v4 rule: unreferenced ids are always open). */
private lockedBy = new Map<string, TechDef>();
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
private anyCoolers = false;
/** True while the inspector owns the aura, so hover logic leaves it alone. */
private inspectAura = false;
/** Selection ring drawn under the inspected machine. */
private selectRing: THREE.Mesh;
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
data: GameData,
) {
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
this.group.add(this.remove.group);
this.padlock.obj.visible = false;
this.group.add(this.padlock.obj);
// Selection ring: a flat outline under the inspected machine. Lives outside `group`
// so it survives the ghost being hidden (inspect has no hovered tile).
this.selectRing = new THREE.Mesh(
new THREE.RingGeometry(0.62, 0.72, 4, 1, Math.PI / 4),
new THREE.MeshBasicMaterial({
color: VALID, transparent: true, opacity: 0.4, depthWrite: false, side: THREE.DoubleSide,
}),
);
this.selectRing.rotation.x = -Math.PI / 2;
this.selectRing.visible = false;
this.selectRing.renderOrder = 9;
this.aura.group.add(this.selectRing);
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) {
// First tech to claim an id wins — matching LANE-UI's `indexTech`. Two techs
// unlocking one id is a data bug either way, but we must both blame the same one.
if (machines.has(u) && !this.lockedBy.has(u)) this.lockedBy.set(u, t);
}
}
this.anyCoolers = data.machines.some((m) => m.coolRadius);
}
set(defId: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build'): void {
this.removing = mode === 'remove';
this.pos = pos;
this.dir = dir;
if (this.removing) {
this.def = null; // the sentinel def is ignored: mode is the truth
if (this.obj) this.obj.visible = false;
this.padlock.obj.visible = false;
this.group.visible = true;
return;
}
this.remove.hide();
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
if (!def || !pos) {
this.pos = def && pos ? pos : null;
this.dir = dir;
if (!def || !this.pos) {
this.group.visible = false;
return;
}
@ -155,7 +62,6 @@ export class GhostLayer {
this.group.visible = false;
return;
}
if (this.obj) this.obj.visible = true;
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
this.rebuild(def);
this.objDef = def.id;
@ -194,69 +100,24 @@ export class GhostLayer {
this.obj = null;
}
/**
* v4 gating rule, verbatim: "a machine/recipe id that appears in ANY TechDef.unlocks
* is locked until that tech completes; ids referenced by no tech are always available."
* Returns the gating tech, or null if it's buildable.
*
* Absent `research` means NOTHING has completed, so everything gated reads locked. My
* instinct was the opposite treat a missing field as "don't lie" but that's the
* `scrammed` lesson misapplied: there the field's absence meant the sim hadn't wired a
* behaviour, here the rule is satisfiable from data alone and the contract says
* "until that tech completes". LANE-UI's `research.ts` reads it the same way, and the
* build bar and the ghost disagreeing about what's buildable would be worse than
* either answer.
*/
private lockOf(defId: string, snap: SimSnapshot): TechDef | null {
const tech = this.lockedBy.get(defId);
if (!tech) return null;
return snap.research?.unlocked.includes(tech.id) ? null : tech;
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible || !this.def || !this.pos || !this.obj) return;
update(snap: SimSnapshot, timeSec: number, inspected?: number | null): void {
// The inspected machine is highlighted whether or not the pointer is anywhere near
// it — the inspector is open until it's closed.
this.updateInspect(snap, timeSec, inspected ?? null);
if (!this.pos) {
if (!this.inspectAura) this.aura.hide();
this.remove.hide();
this.group.visible = false;
return;
}
const needOccupancy = this.removing || !!this.def || this.anyCoolers;
if (needOccupancy) {
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
}
}
this.updateAura(timeSec);
if (this.removing) {
this.updateRemove(timeSec);
return;
}
if (!this.def || !this.obj) {
this.group.visible = false;
return;
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.add(tileKey(t.x, t.y));
}
const want = tilesOf(this.pos, this.def, this.dir);
const fits = want.every(
const ok = want.every(
(t) =>
!this.occupied.has(tileKey(t.x, t.y)) &&
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
t.y >= -WORLD_HALF && t.y < WORLD_HALF,
);
const lock = this.lockOf(this.def.id, snap);
// Locked outranks collision: "you don't have this yet" is the more useful sentence,
// and a locked click does nothing wherever you aim it.
const mat = lock ? this.matLocked : fits ? this.matValid : this.matInvalid;
const mat = ok ? this.matValid : this.matInvalid;
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.material = mat;
@ -266,92 +127,5 @@ export class GhostLayer {
this.obj.position.y += 0.02;
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
if (lock) {
const h = HEIGHT_BY_KIND[this.def.kind] ?? 0.8;
this.padlock.obj.visible = true;
this.padlock.obj.position.copy(this.obj.position);
this.padlock.obj.position.y = h + 0.5 + Math.sin(timeSec * 2) * 0.05;
this.padlock.obj.rotation.y = timeSec * 0.8;
this.padlock.tint(ERA_TELL[lock.era] ?? LOCKED); // which era is holding it back
} else {
this.padlock.obj.visible = false;
}
}
/**
* v5: the inspected machine gets a selection ring, and if it cools its coverage
* rectangle. This is the "placed-and-selected" case round 4 couldn't reach: the
* inspected entity now arrives through the typed selection.
*/
private updateInspect(snap: SimSnapshot, timeSec: number, inspected: number | null): void {
this.inspectAura = false;
if (inspected === null) {
this.selectRing.visible = false;
return;
}
const e = snap.entities.find((x) => x.id === inspected);
const def = e ? this.defs.get(e.def) : undefined;
if (!e || !def) {
this.selectRing.visible = false;
return;
}
const f = rotatedFootprint(def, e.dir);
const c = centerOf(e.pos, def, e.dir);
this.selectRing.visible = true;
this.selectRing.position.set(c.x, 0.04, c.z);
this.selectRing.scale.set(f.x + 0.5, f.y + 0.5, 1);
(this.selectRing.material as THREE.MeshBasicMaterial).opacity =
0.35 + Math.sin(timeSec * 3) * 0.12;
if (def.coolRadius) {
this.aura.show(c.x, c.z, f.x, f.y, def.coolRadius, timeSec);
this.inspectAura = true; // claim the aura so hover logic doesn't hide it
}
}
/**
* Ring the cooler being placed, or the one under the cursor. Placing wins: while you're
* holding a cooler the question is where its coverage lands, not what's already there.
*/
private updateAura(timeSec: number): void {
if (this.inspectAura) return; // the inspector owns the aura this frame
if (!this.anyCoolers || !this.pos) {
this.aura.hide();
return;
}
if (!this.removing && this.def?.coolRadius) {
const f = rotatedFootprint(this.def, this.dir);
const c = centerOf(this.pos, this.def, this.dir);
this.aura.show(c.x, c.z, f.x, f.y, this.def.coolRadius, timeSec);
return;
}
const under = this.occupied.get(tileKey(this.pos.x, this.pos.y));
const udef = under ? this.defs.get(under.def) : undefined;
if (under && udef?.coolRadius) {
const f = rotatedFootprint(udef, under.dir);
const c = centerOf(under.pos, udef, under.dir);
this.aura.show(c.x, c.z, f.x, f.y, udef.coolRadius, timeSec);
return;
}
this.aura.hide();
}
/**
* Highlight whatever machine sits under the cursor. Hovering bare ground shows
* nothing there is no honest way to demolish a hole.
*/
private updateRemove(timeSec: number): void {
const target = this.pos ? this.occupied.get(tileKey(this.pos.x, this.pos.y)) : undefined;
const def = target ? this.defs.get(target.def) : undefined;
if (!target || !def) {
this.remove.hide();
return;
}
// The whole machine lights up, not just the hovered tile: `remove` takes a position
// but kills the entity that owns it, and the hover should say so.
const f = rotatedFootprint(def, target.dir);
const c = centerOf(target.pos, def, target.dir);
this.remove.show(c.x, c.z, f.x, f.y, HEIGHT_BY_KIND[def.kind] ?? 0.8, timeSec);
}
}

View File

@ -1,30 +1,15 @@
/**
* LANE-RENDER Stream-Crust ground plane. THE FLOOR (round 5).
* LANE-RENDER Stream-Crust ground plane.
*
* Round 1 shipped a grid over flat crust. That read as "the void with lines on it",
* which the review named: the game about place had no place. This makes the plane read
* as terrain without ever competing with belts and machines it's the stage, not the
* set. Three barely-there layers:
*
* - fbm tone variation: large, slow value-noise mottling so the crust isn't a flat
* fill. Amplitude is tiny on purpose; you feel it more than see it.
* - the grid, unchanged (it's the placement affordance and must stay legible).
* - a neutral rim: the crust darkens and dissolves into the void at the world edge.
*
* ERA HINTING lives in seams.ts, not here. The orders asked for strata whispers toward
* the world edges, and I first wrote exactly that a directional guess (sepia north,
* phosphor east...). Then DATA landed `data/seams.json`, whose doc says "RENDER: `era`
* skins the ground", and its layout has reel in BOTH the north-west and north-east and
* disc both north-centre and south-west. A directional guess would have contradicted the
* data on screen. So era is skinned per seam region, with a wide soft falloff so it still
* reads as a stratum whisper across the ground and the richest seams sit near the rim
* anyway, which is where the orders wanted the hinting to show up.
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
* so the Bitstream fades into the void instead of ending on a hard rectangle.
*/
import * as THREE from 'three';
import { WORLD } from './palette';
import { WORLD_HALF } from './camera';
const PLANE = WORLD_HALF * 5; // extends past the world; detail only drawn inside ±WORLD_HALF
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
const VERT = /* glsl */ `
varying vec2 vWorld;
@ -43,52 +28,21 @@ const FRAG = /* glsl */ `
uniform float uHalf;
varying vec2 vWorld;
// value noise + 3-octave fbm — cheap terrain mottle, no texture fetch.
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
}
float fbm(vec2 p){
float s = 0.0, a = 0.5;
for (int i=0;i<3;i++){ s += a*vnoise(p); p*=2.03; a*=0.5; }
return s;
}
// Antialiased grid: distance to the nearest cell border, in pixels.
float gridMask(vec2 coord, float width){
float gridMask(vec2 coord, float width) {
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
return 1.0 - min(min(g.x, g.y) / width, 1.0);
}
void main(){
// --- terrain tone: fbm mottle, tiny amplitude ---
float m = fbm(vWorld * 0.09);
vec3 col = uBase * (0.86 + m * 0.28); // ±~14% brightness swells
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // fine crust grain (kept from R1)
// --- the crust cools toward the rim: older, deeper, less lit ---
vec2 n = clamp(vWorld / uHalf, -1.0, 1.0);
float rim = smoothstep(0.55, 1.0, max(abs(n.x), abs(n.y)));
col *= 1.0 - rim * 0.35 * (0.6 + m * 0.8); // fbm-broken so it reads as sediment
// --- grid (unchanged — the placement affordance) ---
void main() {
vec3 col = uBase;
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // crust grain
// --- world-edge dissolve into the void ---
vec2 d = abs(vWorld) / uHalf;
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
// COLOR MANAGEMENT — load-bearing, not boilerplate. THREE.Color.setHex() converts
// sRGB -> the linear working space, but a raw ShaderMaterial gets none of the output
// includes, so without this the linear value goes to the framebuffer untouched and
// the floor renders ~6x too dark: #0c0a15 measured as RGB(1,1,2). Every built-in
// material does this via the same chunk.
#include <colorspace_fragment>
}
`;

View File

@ -15,15 +15,8 @@ import { BeltLayer } from './belts';
import { BeltItemLayer } from './beltitems';
import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { Showroom, showroomEnabled } from './showroom';
import { BeltTopology } from './topology';
import { SeamLayer, loadSeams } from './seams';
import { RelicLayer } from './relics';
import { WildlifeLayer, harassedIn } from './wildlife';
import { Juice } from './juice';
import { BreakRoom } from './breakroom';
import { WORLD } from './palette';
import { centerOf } from './coords';
const KEY_INTENSITY = 1.9;
const FILL_INTENSITY = 0.7;
@ -40,22 +33,8 @@ export function createRenderer(): Renderer {
let belts: BeltLayer;
let cargo: BeltItemLayer;
let ghost: GhostLayer;
let relics: RelicLayer;
let wildlife: WildlifeLayer;
let juice: Juice;
let breakroom: BreakRoom;
let seamLayer: SeamLayer | null = null;
let dev: DevScene | null = null;
let showroom: Showroom | null = null;
let host: HTMLElement;
let defs: Map<string, MachineDef>;
/** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */
let lastShipped = -1;
const shipperCentres: Vec2[] = [];
const itemColors = new Map<string, number>();
const lastPerItem = new Map<string, number>();
/** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
let debug = false;
const topo = new BeltTopology();
const raycaster = new THREE.Raycaster();
@ -68,94 +47,9 @@ export function createRenderer(): Renderer {
* RATE stays honest even when the tab is backgrounded and rAF is throttled. */
const stats = { ms: 0, avg: 0, frames: 0 };
/**
* Which entity the inspector has open, if any.
*
* BRIDGE: `Renderer` has no inspect channel `setGhost`'s mode is only build|remove,
* and main.ts calls it with 'build' during inspect, so nothing about the inspected
* entity reaches this lane. The typed selection DOES carry it (`{mode:'inspect',
* entity}`), and the bus is the designated cross-lane global, so we read it here.
* Same shape as the '__remove' sentinel bridge: it works, it's documented on both
* sides, and it wants a real contract hook. Filed in NOTES.
*/
function inspectedEntity(): number | null {
const bus = (window as unknown as { __fktryBus?: { selection?: () => unknown } }).__fktryBus;
const sel = bus?.selection?.() as { mode?: string; entity?: number } | null | undefined;
return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null;
}
/**
* A rise in `shippedTotal` is a shipment flash the uplinks in the shipped item's
* colour. Derived from the snapshot because render() never sees events.
*/
function shipmentFlash(view: SimSnapshot): void {
let total = 0;
let hottest: string | null = null;
let best = 0;
for (const k in view.shippedTotal) {
const n = view.shippedTotal[k];
total += n;
const prev = lastPerItem.get(k) ?? 0;
if (n - prev > best) { best = n - prev; hottest = k; }
lastPerItem.set(k, n);
}
if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash
if (total > lastShipped) {
shipperCentres.length = 0;
for (const e of view.entities) {
const d = defs.get(e.def);
if (d?.kind !== 'shipper') continue;
const c = centerOf(e.pos, d, e.dir);
shipperCentres.push({ x: c.x, y: c.z });
}
const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f;
for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col);
}
lastShipped = total;
}
/** True when the pointer is over the arcade cabinet. */
function cabinetHit(clientX: number, clientY: number): boolean {
if (!breakroom) return false;
const rect = gl.domElement.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, rig.camera);
return raycaster.intersectObjects(breakroom.clickable, false).length > 0;
}
/**
* The cabinet is a world object, so it gets world-object interaction: pointer cursor on
* hover, click to open. Listeners live on the CANVAS and stop propagation on a hit, so
* main.ts's #game handler never sees the click otherwise clicking the cabinet while
* holding a machine would also place one on the tile behind it.
*/
function installCabinetPointer(container: HTMLElement): void {
const canvas = gl.domElement;
canvas.addEventListener('pointermove', (e) => {
const over = cabinetHit(e.clientX, e.clientY);
// Only claim the cursor when nothing else is driving it (build/remove modes have
// their own affordance via the ghost).
canvas.style.cursor = over ? 'pointer' : '';
if (over) container.title = 'GLYTCH';
else if (container.title) container.title = '';
});
canvas.addEventListener(
'pointerdown',
(e) => {
if (e.button !== 0 || !cabinetHit(e.clientX, e.clientY)) return;
e.stopPropagation();
e.preventDefault();
window.open('./match.html', '_blank', 'noopener');
},
{ capture: true },
);
}
return {
async init(container: HTMLElement, data: GameData) {
defs = new Map(data.machines.map((m) => [m.id, m]));
host = container;
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
@ -199,71 +93,24 @@ export function createRenderer(): Renderer {
registry = new AssetRegistry();
await registry.init(data);
juice = new Juice();
belts = new BeltLayer(registry, defs, topo);
entities = new EntityLayer(registry, defs, juice);
entities = new EntityLayer(registry, defs);
cargo = new BeltItemLayer(data, topo);
ghost = new GhostLayer(registry, defs, data);
relics = new RelicLayer(juice);
wildlife = new WildlifeLayer(juice);
scene.add(
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
relics.group, wildlife.group, juice.group,
);
// THE FLOOR: seam regions from data — era skins the crust, the ore glints.
seamLayer = new SeamLayer(loadSeams(), data);
scene.add(seamLayer.mesh);
// THE BREAK ROOM: the GLYTCH cabinet, a short walk south-east of the start zone.
// Position is chosen to land in CLICKABLE screen space at the default framing —
// at (-5,-5) it rendered underneath `#screen`, the decorative overlay that still
// takes pointer events, so the cabinet was both invisible and unclickable (see the
// B8 picking findings in NOTES).
breakroom = new BreakRoom(6, 12);
scene.add(breakroom.group);
installCabinetPointer(container);
// Item colours for the shipment flash, and the shipper tiles (recomputed on demand).
for (const it of data.items) itemColors.set(it.id, new THREE.Color(it.color).getHex());
debug = devSceneEnabled() || showroomEnabled();
// B8 review finding: the start zone must clear the build bar. The bar sits at
// screen-bottom, and in this iso rig screen-down = world +x/+z, so biasing the
// camera target south-east lifts world origin into the upper play area where a
// fresh game's first machines get placed. (Showroom/devscene set their own frame.)
if (!showroomEnabled()) rig.frame(3, 6, 15);
if (showroomEnabled()) {
showroom = new Showroom(data, registry);
scene.add(showroom.decor);
showroom.mountLabels(container);
rig.frame(showroom.view.x, showroom.view.z, showroom.view.zoom);
console.info(
`[render] showroom: ${data.machines.length} machines, ${data.items.length} items —` +
' drop a GLB into public/assets/models/<asset>.glb and it lands here in ~10s',
);
}
ghost = new GhostLayer(registry, defs);
scene.add(belts.group, entities.group, cargo.group, ghost.group);
if (devSceneEnabled()) {
const d = new DevScene(data, devSceneStress());
if (d.usable) {
dev = d;
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
// Dev-only introspection hook (devscene builds only). Never touched in prod.
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, stats, gl, dev, topo, cargo, entities,
};
}
}
// The literal `import.meta.env.DEV` is what makes this disappear from the bundle:
// Vite substitutes `false` and Rollup drops the branch. Testing a `debug` VARIABLE
// here instead left the whole hook — internals list and all — as dead code in
// dist/. Keep the check inline; verified by grepping the bundle.
if (import.meta.env.DEV && (devSceneEnabled() || showroomEnabled())) {
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, rig, stats, gl, dev, showroom, topo, cargo, entities, ghost,
};
}
const onResize = () => {
gl.setSize(container.clientWidth, container.clientHeight);
rig.resize();
@ -274,39 +121,21 @@ export function createRenderer(): Renderer {
render(snap: SimSnapshot, alpha: number) {
if (!gl) return;
const t0 = debug ? performance.now() : 0;
// Clamp the frame delta. A backgrounded tab, an alt-tab, a GC pause or the first
// frame after load all hand back a delta of SECONDS (measured 63.7s), which
// integrates particles to infinity and kills every effect whose lifetime is
// shorter than the hitch — juice simply never appeared. main.ts clamps the sim's
// accumulator for the same reason; this is the renderer's half.
const dt = Math.min(0.05, clock.getDelta());
const t0 = dev ? performance.now() : 0;
const dt = clock.getDelta();
const t = clock.elapsedTime;
// The showroom is a standalone page, so it owns the frame outright. The devscene
// is only a stand-in and yields the instant the real sim has anything to show.
let view = snap;
if (showroom) {
view = showroom.snapshot(snap.tick);
showroom.frame(t);
} else if (dev && snap.entities.length === 0) {
view = dev.snapshot(snap.tick);
}
// Dev scaffold yields the instant the real sim has anything to show.
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
rig.update(dt);
// Topology is shared by belts (which piece to draw) and cargo (which path the
// items take), so it is rebuilt once here rather than twice downstream.
topo.rebuild(view, defs);
belts.sync(view, t);
entities.sync(view, t, dt, harassedIn(view));
entities.sync(view, t, dt);
cargo.sync(view, alpha, t);
ghost.update(view, t, inspectedEntity());
relics.sync(view.relics, t);
wildlife.sync(view.wildlife, t);
seamLayer?.frame(t);
breakroom.frame(t);
shipmentFlash(view);
juice.update(dt, rig.camera);
ghost.update(view, t);
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
// exposure so emissives dim too — a brownout that left the glow at full would
@ -319,9 +148,8 @@ export function createRenderer(): Renderer {
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
gl.render(scene, rig.camera);
showroom?.updateLabels(rig.camera, host);
if (debug) {
if (dev) {
stats.ms = performance.now() - t0;
stats.frames++;
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
@ -341,8 +169,8 @@ export function createRenderer(): Renderer {
return { x, y };
},
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') {
ghost?.set(def, pos, dir, mode);
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
ghost?.set(def, pos, dir);
},
};
}

View File

@ -1,135 +0,0 @@
/**
* LANE-RENDER juice. The difference between placing a machine and a machine appearing.
*
* The renderer never sees events (render() gets only the snapshot), so every cue here is
* derived from frame-to-frame deltas: an entity that wasn't there last frame was placed;
* one that vanished was demolished; a rise in `shippedTotal` is a shipment. EntityLayer
* owns the meshes so it does the scale-pop itself and calls in for the puff/burst; the
* shipment flash is driven from index.ts.
*
* One InstancedMesh particle pool for the whole game cheap, instanced, everywhere.
*/
import * as THREE from 'three';
const MAX = 512;
const GRAVITY = -6;
interface P {
x: number; y: number; z: number;
vx: number; vy: number; vz: number;
life: number; ttl: number;
size: number;
r: number; g: number; b: number;
}
export class Juice {
readonly group = new THREE.Group();
private mesh: THREE.InstancedMesh;
private pool: P[] = [];
private live: P[] = [];
private dummy = new THREE.Object3D();
private color = new THREE.Color();
constructor() {
this.group.name = 'juice';
const geo = new THREE.PlaneGeometry(1, 1);
const mat = new THREE.MeshBasicMaterial({
transparent: true, depthWrite: false, side: THREE.DoubleSide,
blending: THREE.AdditiveBlending, vertexColors: true,
});
this.mesh = new THREE.InstancedMesh(geo, mat, MAX);
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mesh.frustumCulled = false;
this.mesh.count = 0;
// per-instance colour
this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 3), 3);
this.group.add(this.mesh);
for (let i = 0; i < MAX; i++) {
this.pool.push({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0, ttl: 1, size: 0.1, r: 1, g: 1, b: 1 });
}
}
private emit(
x: number, z: number, n: number, spread: number, up: number,
size: number, ttl: number, col: number,
): void {
this.color.setHex(col);
for (let i = 0; i < n; i++) {
const p = this.pool.pop();
if (!p) break; // pool exhausted — drop the rest rather than allocate
const a = Math.random() * Math.PI * 2;
const s = Math.random() * spread;
p.x = x; p.y = 0.2; p.z = z;
p.vx = Math.cos(a) * s;
p.vz = Math.sin(a) * s;
p.vy = up * (0.6 + Math.random() * 0.8);
p.life = 0;
p.ttl = ttl * (0.7 + Math.random() * 0.6);
p.size = size * (0.7 + Math.random() * 0.6);
p.r = this.color.r; p.g = this.color.g; p.b = this.color.b;
this.live.push(p);
}
}
/** Placement: a soft ring of dust kicked outward. Pairs with EntityLayer's scale-pop. */
puff(cx: number, cz: number): void {
this.emit(cx, cz, 14, 2.2, 1.5, 0.18, 0.5, 0xbfb3a0);
}
/** Demolition: a sharper, brighter burst. */
burst(cx: number, cz: number): void {
this.emit(cx, cz, 22, 4.0, 3.0, 0.22, 0.55, 0xff6a3a);
}
/** Shipment: a quick upward flash of the shipped item's colour at the uplink. */
shipFlash(cx: number, cz: number, col: number): void {
this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col);
}
/** Excavation (relicFound): the fossil surfacing a slow, tall golden geyser that
* "sings" up out of the crust. Longer-lived and higher than any other cue. */
excavate(cx: number, cz: number): void {
this.emit(cx, cz, 30, 1.4, 7.5, 0.26, 1.1, 0xffcaa0);
this.emit(cx, cz, 14, 0.5, 4.0, 0.18, 1.4, 0xfff2d6); // a brighter inner column
}
/** The Correction's calling card: a small, infuriatingly TIDY green-white sparkle,
* left where a parity-mite just "corrected" your product. Few, clean, gentle. */
tidy(cx: number, cz: number): void {
this.emit(cx, cz, 4, 0.35, 1.6, 0.11, 0.6, 0xd6ffe0);
}
update(dt: number, camera: THREE.Camera): void {
// integrate + recycle
for (let i = this.live.length - 1; i >= 0; i--) {
const p = this.live[i];
p.life += dt;
if (p.life >= p.ttl) {
this.live[i] = this.live[this.live.length - 1];
this.live.pop();
this.pool.push(p);
continue;
}
p.vy += GRAVITY * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0.05) { p.y = 0.05; p.vy *= -0.3; p.vx *= 0.6; p.vz *= 0.6; }
}
// write instances, billboarded to the camera, fading over life
const q = camera.quaternion;
for (let i = 0; i < this.live.length; i++) {
const p = this.live[i];
const k = 1 - p.life / p.ttl;
const s = p.size * (0.4 + k * 0.6);
this.dummy.position.set(p.x, p.y, p.z);
this.dummy.quaternion.copy(q);
this.dummy.scale.setScalar(s);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
this.mesh.setColorAt(i, this.color.setRGB(p.r * k, p.g * k, p.b * k));
}
this.mesh.count = this.live.length;
this.mesh.instanceMatrix.needsUpdate = true;
if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
}
}

View File

@ -51,7 +51,6 @@ export const BODY_BY_KIND: Record<MachineKind, number> = {
power: 0x45434f,
buffer: 0x3a3f48,
shipper: 0x4a4652,
lab: 0x3d3a4e, // research bench chassis (v4 kind; placeholder until art-directed)
};
/** Fallback accent when a machine runs no recipes (belts, power, shippers). */
@ -63,7 +62,6 @@ export const ACCENT_BY_KIND: Record<MachineKind, number> = {
power: 0xffb03f, // serene LED face
buffer: 0x7fffd4,
shipper: 0x3fd4ff, // uplink to THE SCREEN
lab: 0xb0a0ff, // research glow (v4 kind; placeholder)
};
/** Body height in world units. Silhouette differentiation on a flat iso grid. */
@ -75,7 +73,6 @@ export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
power: 0.95,
buffer: 0.7,
shipper: 1.3,
lab: 1.1, // v4 kind; placeholder
};
export const JAM_AMBER = 0xffa726;

View File

@ -20,7 +20,6 @@ import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import type { GameData, MachineDef } from '../contracts';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era';
const MODEL_DIR = 'assets/models';
const POLL_MS = 10_000;
@ -77,17 +76,15 @@ function accentMat(color: number): THREE.MeshStandardMaterial {
* idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat
* a GLB has no such split, so there heat tints everything.
*/
function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Era): THREE.Object3D {
function makePlaceholder(def: MachineDef, accent: number, body_: number): THREE.Object3D {
const g = new THREE.Group();
const fw = Math.max(0.4, def.footprint.x * 0.92);
const fd = Math.max(0.4, def.footprint.y * 0.92);
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
const chassis = bodyMat(body_);
applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade
const body = new THREE.Mesh(
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
chassis,
bodyMat(body_),
);
body.position.y = h / 2;
body.castShadow = body.receiveShadow = true;
@ -163,29 +160,6 @@ function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Er
}) satisfies IdleFn;
break;
}
case 'lab': {
// A research bench: low slab, and the "bottled artifact triptych" (§2) standing on
// it. Three bottles is the whole silhouette — nothing else in the hall is a row of
// little glowing cylinders, which is the point of a placeholder.
const bottles: THREE.Mesh[] = [];
for (let i = 0; i < 3; i++) {
const b = new THREE.Mesh(
new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8),
i === 0 ? am : am.clone(),
);
b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0);
g.add(b);
bottles.push(b);
}
g.userData.fktryIdle = ((t: number) => {
// They pulse out of phase — a bench mid-assay, not a machine mid-cycle.
bottles.forEach((b, i) => {
(b.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45;
});
}) satisfies IdleFn;
break;
}
case 'splitter': {
// Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin).
const bar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.66, 0.05, fd * 0.16), am);
@ -222,89 +196,10 @@ export function beltPlateGeometry(): THREE.BufferGeometry {
return geo;
}
// ---------------------------------------------------------------- GLB legibility
/**
* GLB legibility floor (round 6, the round's centrepiece).
*
* MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a
* near-black ground a real asset reads as a dark hole the placeholder two-material
* rule (dark body + one emissive accent) silently dies the moment a GLB lands. The fix
* has two halves, both keyed off the same data the placeholders use:
*
* 1. a texel-following emissive FLOOR (this function): self-illuminate the baked albedo
* (`emissiveMap = map`) so the brightest texels lift off the ground and nothing reads
* as a pure void, without washing the texture down to a flat grey. A material that
* already ships its own glow is respected, not overwritten.
* 2. an accent BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of
* what the machine makes the same accent derivation the placeholder uses.
*/
const GLB_EMISSIVE_FLOOR = 0.42;
function floorMaterials(root: THREE.Object3D): void {
root.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
const mats = Array.isArray(m.material) ? m.material : [m.material];
for (const raw of mats) {
const mat = raw as THREE.MeshStandardMaterial;
if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue;
const e = mat.emissive;
const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02;
if (alreadyLit) continue; // MODELBEAST authored a glow here — leave it be
if (mat.map) {
mat.emissiveMap = mat.map; // white * map = the texture, self-lit
mat.emissive = new THREE.Color(0xffffff);
} else {
mat.emissive = mat.color.clone(); // no texture: self-colour floor
}
mat.emissiveIntensity = GLB_EMISSIVE_FLOOR;
mat.needsUpdate = true;
}
});
}
/** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */
const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1);
/**
* Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a
* gentle idle pulse resource colour, identity, and a heartbeat, all from the same
* accent the placeholder derives. The idle reads `beacon.material` at call time (not a
* captured reference) because EntityLayer clones every GLB material per instance; a
* closure over the pre-clone material would drive a mesh nothing renders.
*/
function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void {
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const fp = Math.min(def.footprint.x, def.footprint.y);
const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2);
const beacon = new THREE.Mesh(
BEACON_GEO,
new THREE.MeshStandardMaterial({
color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1,
}),
);
beacon.scale.setScalar(r);
beacon.position.set(
(box.min.x + box.max.x) / 2,
box.max.y + r * 1.3,
(box.min.z + box.max.z) / 2,
);
beacon.userData.fktryBeacon = true;
root.add(beacon);
root.userData.fktryIdle = ((t: number) => {
(beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45;
}) satisfies IdleFn;
}
// ---------------------------------------------------------------- GLB handling
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
function normaliseGLB(
src: THREE.Object3D,
def: MachineDef,
clips: THREE.AnimationClip[],
accent?: number,
): THREE.Object3D {
function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D {
// SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound
// per instance or every copy animates off the first one's bones.
const obj = cloneSkinned(src);
@ -329,20 +224,14 @@ function normaliseGLB(
});
// A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs
// get wrapped: the mixer drives the inner node, the wrapper does the normalising.
let root: THREE.Object3D;
if (clips.length) {
const wrap = new THREE.Group();
wrap.add(obj);
wrap.userData.fktryClips = clips;
root = wrap;
} else {
obj.userData.fktryClips = clips;
root = obj;
return wrap;
}
// Machine create path passes an accent -> perch the beacon. The instanceable path
// (belts) passes none: an instanced belt has no room for a beacon and no accent to show.
if (accent !== undefined) addAccentBeacon(root, def, accent);
return root;
obj.userData.fktryClips = clips;
return obj;
}
/**
@ -396,26 +285,13 @@ export class AssetRegistry {
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
/** The derived emissive accent per asset key shared by placeholders and hot-swapped
* GLBs so a real asset gets the same one impossible element the placeholder had. */
private accents = new Map<string, number>();
private eras = new Map<string, Era>();
private loader = new GLTFLoader();
private timer: number | null = null;
/** Which era a machine is skinned as (derived from tech unlocks; stream by default). */
eraOf(machineId: string): Era {
return this.eras.get(machineId) ?? DEFAULT_ERA;
}
async init(data: GameData): Promise<void> {
this.eras = eraIndex(data);
for (const def of data.machines) {
this.defs.set(def.asset, def);
const accent = accentFor(def, data);
this.accents.set(def.asset, accent);
const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id));
this.entries.set(def.asset, entry);
this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def)));
}
await this.probeAll();
if (import.meta.env.DEV) {
@ -431,13 +307,13 @@ export class AssetRegistry {
return this.entries.get(key) ?? null;
}
private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry {
private placeholderEntry(def: MachineDef, accent: number, body: number): AssetEntry {
const prev = this.entries.get(def.asset);
return {
key: def.asset,
version: prev ? prev.version : 0,
isGLB: false,
create: () => makePlaceholder(def, accent, body, era),
create: () => makePlaceholder(def, accent, body),
// Belts are instanced by belts.ts, which supplies the chevron material itself —
// this material is only a fallback and is normally discarded.
instanceable: () =>
@ -461,17 +337,12 @@ export class AssetRegistry {
const prev = this.entries.get(def.asset);
const version = (prev?.version ?? 0) + 1;
const template = gltf.scene;
// Round 6 legibility pass: lift the baked-dark chassis off the void ONCE on the
// shared template. EntityLayer clones these materials per instance, so per-entity
// heat/scram still works on top of the floor.
floorMaterials(template);
const clips = gltf.animations ?? [];
const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
this.entries.set(def.asset, {
key: def.asset,
version,
isGLB: true,
create: () => normaliseGLB(template, def, clips, accent),
create: () => normaliseGLB(template, def, clips),
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
});
this.version++;
@ -487,11 +358,13 @@ export class AssetRegistry {
}
/**
* The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
* otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise
* a per-kind accent.
* The machine's ONE emissive accent = the colour of what it makes (recipes -> outputs
* -> ItemDef.color), falling back to a per-kind accent.
*
* `MachineDef.color` is NOT read here v3 ruled it the chassis; see `bodyFor` below.
* NOTE this deliberately does NOT read `MachineDef.color`, against the letter of the
* round-2 orders. See `bodyFor` below and the round-2 NOTES: LANE-DATA authored that
* field as chassis colour, and wiring it to the accent makes every machine glow a
* muddy neutral, which is the opposite of the style guide's "one impossible element".
*
* The derived path has a guard: raw products are near-black by design (MDAT ORE is
* #1a1a22), and an accent that dark isn't an accent — the seam extractor's CRT head
@ -499,11 +372,6 @@ export class AssetRegistry {
* through to the kind accent (for the extractor, CRT blue, as the codex describes it).
*/
function accentFor(def: MachineDef, data: GameData): number {
// v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a
// dark accent is a decision; the floor below exists for DERIVED colours, which are
// incidental (item colours are authored for chips, not for machine accents). Same
// reasoning as `bodyFor` and `def.color`.
if (def.accent) return new THREE.Color(def.accent).getHex();
for (const rid of def.recipes) {
const recipe = data.recipes.find((r) => r.id === rid);
const outId = recipe && Object.keys(recipe.outputs)[0];

View File

@ -1,147 +0,0 @@
/**
* LANE-RENDER the relic. `snapshot.relics` (v6): optical fossils buried in the crust.
*
* The brief is a Mario hidden pipe: findable by a player who wanders and looks, invisible
* to one who doesn't. So an UNFOUND relic is only a faint static-patch decal that breathes
* no marker, no minimap ping, no arrow. Its discoverability budget is curiosity. The
* restraint is the feature: if you can see it from across the map it isn't hidden.
*
* FOUND (excavated) the patch becomes a small monument: a fossil slab on a plinth,
* lifting out of the ground, lit. §1: the optical soundtrack fossils *sing* when
* excavated the singing is LANE-SCREEN's audio band; this is the thing it sings from.
*/
import * as THREE from 'three';
import type { RelicState } from '../contracts';
import { disposeObject } from './coords';
import type { Juice } from './juice';
const SHIMMER_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SHIMMER_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
void main() {
vec2 p = vUv * 2.0 - 1.0;
float rr = dot(p, p);
if (rr > 1.0) discard;
// static crawl: a hidden VHS dropout, quantised so it flickers rather than smears.
vec2 g = floor(vUv * 22.0) + floor(vec2(uTime * 9.0));
float s = hash(g);
// breathe the whole patch so a still eye catches motion, but faintly.
float breathe = 0.5 + 0.5 * sin(uTime * 1.3);
float edge = 1.0 - smoothstep(0.4, 1.0, rr);
float a = s * edge * (0.05 + 0.06 * breathe); // deliberately tiny
gl_FragColor = vec4(vec3(0.7, 0.75, 0.85) * s, a);
}
`;
interface Rec {
found: boolean;
obj: THREE.Object3D;
}
const PLANE = new THREE.PlaneGeometry(1, 1);
export class RelicLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
private shimmerUniforms = { uTime: { value: 0 } };
private shimmerMat: THREE.ShaderMaterial;
/** False until the first sync: a save loaded with a relic already found must not fire
* the excavation geyser, exactly as EntityLayer primes before firing placement juice. */
private primed = false;
constructor(private juice?: Juice) {
this.group.name = 'relics';
this.shimmerMat = new THREE.ShaderMaterial({
uniforms: this.shimmerUniforms,
vertexShader: SHIMMER_VERT,
fragmentShader: SHIMMER_FRAG,
transparent: true,
depthWrite: false,
});
}
sync(relics: ReadonlyArray<RelicState> | undefined, timeSec: number): void {
this.shimmerUniforms.uTime.value = timeSec;
this.seen.clear();
for (const r of relics ?? []) {
this.seen.add(r.id);
let rec = this.live.get(r.id);
const justExcavated = !!rec && !rec.found && r.found;
if (rec && rec.found !== r.found) {
this.drop(r.id, rec); // excavated: swap shimmer → monument
rec = undefined;
}
if (!rec) {
const obj = r.found ? this.buildMonument() : this.buildShimmer();
obj.position.set(r.pos.x + 0.5, 0, r.pos.y + 0.5);
this.group.add(obj);
rec = { found: r.found, obj };
this.live.set(r.id, rec);
}
// The fossil surfaces: a golden geyser that "sings" up out of the crust. Gated on a
// real unfound→found transition seen live (not a save that loads already-excavated).
if (justExcavated && this.primed) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5);
if (r.found) {
// gentle lift + rotate so the excavated fossil reads as an exhibit
rec.obj.rotation.y = timeSec * 0.5;
rec.obj.position.y = 0.05 + Math.sin(timeSec * 1.5) * 0.03;
}
}
for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec);
this.primed = true;
}
private buildShimmer(): THREE.Object3D {
const m = new THREE.Mesh(PLANE, this.shimmerMat);
m.rotation.x = -Math.PI / 2;
m.scale.set(2.4, 2.4, 1);
m.position.y = 0.02;
m.renderOrder = 2;
return m;
}
private buildMonument(): THREE.Object3D {
const g = new THREE.Group();
const plinth = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.6, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x14121d, roughness: 0.9, emissive: 0x1a2740, emissiveIntensity: 0.4 }),
);
plinth.position.y = 0.2;
plinth.castShadow = plinth.receiveShadow = true;
// The fossil: a wavy photographic strip that "sings" — an emissive slab, gamut-lit.
const strip = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.8, 0.5),
new THREE.MeshStandardMaterial({
color: 0xffe0b0, emissive: 0xffcaa0, emissiveIntensity: 1.3,
roughness: 0.3, metalness: 0.4,
}),
);
strip.position.y = 0.8;
strip.castShadow = true;
g.add(plinth, strip);
g.userData.owns = true;
return g;
}
private drop(id: number, rec: Rec): void {
this.group.remove(rec.obj);
if (rec.obj.userData.owns) disposeObject(rec.obj); // monuments own their geo/mats
// shimmers share PLANE + shimmerMat — nothing to dispose
this.live.delete(id);
}
}

View File

@ -1,107 +0,0 @@
/**
* LANE-RENDER remove-mode hover.
*
* Two reads at once: a hazard-striped decal on the machine's footprint (this is the
* ground you are about to clear) and a red wash over its volume (this is the thing that
* is going away). Stripes because the codex dresses everything horrifying as routine
* industrial equipment demolition should look like a permit, not an explosion.
*
* Driven by the `__remove` ghost sentinel; see ghost.ts.
*/
import * as THREE from 'three';
const DEMOLITION = 0xff3b30;
const DECAL_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const DECAL_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uScale; // footprint in tiles, so stripes don't stretch on big machines
uniform vec3 uColor;
varying vec2 vUv;
void main() {
// Border: constant thickness regardless of footprint.
vec2 d = min(vUv, 1.0 - vUv) * uScale;
float edge = min(d.x, d.y);
float border = 1.0 - smoothstep(0.04, 0.10, edge);
// Hazard stripes, crawling.
vec2 p = vUv * uScale;
float s = fract((p.x + p.y) * 1.6 - uTime * 0.5);
float stripe = step(0.5, s);
float a = max(border, stripe * 0.28);
if (a < 0.01) discard;
gl_FragColor = vec4(uColor, a * 0.85);
}
`;
export class RemoveHover {
readonly group = new THREE.Group();
private decal: THREE.Mesh;
private wash: THREE.Mesh;
private uniforms = {
uTime: { value: 0 },
uScale: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(DEMOLITION) },
};
constructor() {
this.group.name = 'removeHover';
this.group.visible = false;
this.group.renderOrder = 11;
this.decal = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: DECAL_VERT,
fragmentShader: DECAL_FRAG,
transparent: true,
depthWrite: false,
}),
);
this.decal.rotation.x = -Math.PI / 2;
this.decal.position.y = 0.02; // just clear of the ground plane
this.group.add(this.decal);
this.wash = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({
color: DEMOLITION,
transparent: true,
opacity: 0.3,
depthWrite: false,
}),
);
this.group.add(this.wash);
}
hide(): void {
this.group.visible = false;
}
/**
* Frame the machine we're about to demolish. `w`/`d` are its ROTATED footprint, so a
* 2x3 turned east reads as 3x2 the decal has to match what's actually occupied.
*/
show(cx: number, cz: number, w: number, d: number, height: number, timeSec: number): void {
this.group.visible = true;
this.uniforms.uTime.value = timeSec;
this.uniforms.uScale.value.set(w, d);
this.decal.scale.set(w, d, 1);
this.decal.position.set(cx, 0.02, cz);
this.wash.scale.set(w * 0.94, height, d * 0.94);
this.wash.position.set(cx, height / 2, cz);
}
}

View File

@ -1,188 +0,0 @@
/**
* LANE-RENDER mineral seams: the glinting patches that tell you where to mine.
*
* Data is `data/seams.json` (LANE-DATA). Its doc sets the division of labour:
* SIM an extractor extracts only while its footprint overlaps a seam rect.
* RENDER `era` skins the ground; `resource` is the stratum's codex payload.
* So this layer does two jobs at once: it skins each region with its era (§1 strata,
* with a wide soft falloff so it reads as a whisper across the crust, not a decal), and
* inside the rect it lays the mineral itself obsidian darkening with the resource's
* colour flickering through fracture veins (§2: "thin glowing rainbow circuit veins").
*
* The tell is deliberately quiet. There is NO label and no icon: a seam is a patch of
* ground that glints. A player who looks discovers that extractors want to sit on them;
* a player who doesn't just sees texture. `richness` drives how strongly it glints, so
* the rich remote seams are the ones that reward wandering.
*/
import * as THREE from 'three';
import type { GameData } from '../contracts';
import seamsFile from '../../data/seams.json';
export interface SeamRect { x: number; y: number; w: number; h: number }
export interface SeamDef {
id: string;
era: string;
resource: string;
rect: SeamRect;
richness: number;
}
/** §1 strata palettes — the era whisper laid over the crust around each seam. */
const ERA_TINT: Record<string, number> = {
reel: 0x3a2a16, // celluloid shale, silver-nitrate brown
broadcast: 0x14301c, // phosphor dunes
disc: 0x241634, // rainbow-on-black gloss
stream: 0x1e2430, // flat plastic crust
fortress: 0x2c3038,
};
/** How far past the rect the era whisper bleeds, in tiles. */
const ERA_BLEED = 7;
const VERT = /* glsl */ `
attribute vec3 aEra;
attribute vec3 aOre;
attribute vec2 aHalf; // rect half-extent in tiles (for the inside/outside test)
attribute float aRich;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal; // tiles from the rect centre
varying vec2 vWorld;
void main() {
vEra = aEra; vOre = aOre; vHalf = aHalf; vRich = aRich;
vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = wp.xz;
// instanceMatrix scales a unit quad to (rect + bleed); recover tiles-from-centre.
vLocal = position.xz * (aHalf + vec2(${ERA_BLEED}.0)) * 2.0;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal;
varying vec2 vWorld;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), f.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y);
}
void main(){
vec2 d = abs(vLocal) - vHalf; // >0 outside the rect, in tiles
// --- era whisper: strongest at the rect, faded out over ERA_BLEED tiles ---
float outside = max(max(d.x, d.y), 0.0);
float era = 1.0 - smoothstep(0.0, ${ERA_BLEED}.0, outside);
// fbm-break the edge so a rect never reads as a rectangle
era *= 0.55 + 0.45 * vnoise(vWorld * 0.35);
// --- the mineral itself: only inside the rect ---
// Soft-edged so a seam never reads as a rectangle you could have drawn in CSS, but
// the fade is SHORT: SIM extracts on any footprint/rect overlap, so a glint that died
// two tiles inside the boundary would under-report where mining actually works.
float body = (1.0 - smoothstep(-0.8, 0.6, max(d.x, d.y)))
* (0.6 + 0.4 * vnoise(vWorld * 0.55));
// GLINT, not veins. The first pass drew continuous sinusoidal lines and they read as
// green worms crawling over the floor — organic, loud, competing with the belts.
// Mineral glitter is sparse and it TWINKLES: quantise to a fleck grid, keep the top
// few percent, and let each fleck sparkle on its own clock.
vec2 g = vWorld * 7.0;
vec2 cell = floor(g);
float pick = hash(cell);
// Shape a round fleck inside its cell instead of filling the cell — a full cell reads
// as confetti at this zoom, a point reads as a mineral fleck.
vec2 sub = fract(g) - 0.5 - (vec2(hash(cell + 3.7), hash(cell + 9.1)) - 0.5) * 0.5;
float dot_ = 1.0 - smoothstep(0.10, 0.30, length(sub));
float fleck = smoothstep(0.90 - vRich * 0.10, 1.0, pick) * dot_;
float twinkle = 0.35 + 0.65 * pow(max(0.0, sin(uTime * 2.2 + pick * 43.0)), 3.0);
float glint = fleck * twinkle * body;
vec3 col = vEra * era * 0.75; // stratum tint on the crust
col = mix(col, vOre, min(1.0, glint * 1.6)); // ore colour only in the flecks
float a = era * 0.22 + body * 0.09 + glint * (0.35 + vRich * 0.4);
if (a < 0.004) discard;
gl_FragColor = vec4(col, a);
// sRGB out — a raw ShaderMaterial gets none of the output includes (see ground.ts).
#include <colorspace_fragment>
}
`;
export class SeamLayer {
readonly mesh: THREE.InstancedMesh;
private uniforms = { uTime: { value: 0 } };
private dummy = new THREE.Object3D();
constructor(seams: SeamDef[], data: GameData) {
const geo = new THREE.PlaneGeometry(1, 1);
geo.rotateX(-Math.PI / 2); // lie flat on the ground
const n = Math.max(1, seams.length);
const era = new Float32Array(n * 3);
const ore = new Float32Array(n * 3);
const half = new Float32Array(n * 2);
const rich = new Float32Array(n);
const c = new THREE.Color();
const mat = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
});
this.mesh = new THREE.InstancedMesh(geo, mat, n);
this.mesh.name = 'seams';
this.mesh.renderOrder = -1; // above the ground plane, below everything else
this.mesh.frustumCulled = false;
seams.forEach((s, i) => {
const { x, y, w, h } = s.rect;
// Quad covers the rect plus the era bleed on every side.
this.dummy.position.set(x + w / 2, 0.012, y + h / 2);
this.dummy.scale.set(w + ERA_BLEED * 2, 1, h + ERA_BLEED * 2);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
c.setHex(ERA_TINT[s.era] ?? ERA_TINT.stream);
era.set([c.r, c.g, c.b], i * 3);
const item = data.items.find((it) => it.id === s.resource);
c.set(item?.color ?? '#8899aa');
ore.set([c.r, c.g, c.b], i * 3);
half.set([w / 2, h / 2], i * 2);
rich[i] = s.richness ?? 0.5;
});
this.mesh.count = seams.length;
this.mesh.instanceMatrix.needsUpdate = true;
geo.setAttribute('aEra', new THREE.InstancedBufferAttribute(era, 3));
geo.setAttribute('aOre', new THREE.InstancedBufferAttribute(ore, 3));
geo.setAttribute('aHalf', new THREE.InstancedBufferAttribute(half, 2));
geo.setAttribute('aRich', new THREE.InstancedBufferAttribute(rich, 1));
}
frame(timeSec: number): void {
this.uniforms.uTime.value = timeSec;
}
}
/** The seams DATA authored. Statically imported so it works in a production build too. */
export function loadSeams(): SeamDef[] {
return ((seamsFile as { seams?: SeamDef[] }).seams ?? []) as SeamDef[];
}
/** The SE corner DATA keeps seam-free so the relic always has somewhere to land. */
export function relicReserve(): SeamRect | null {
return (seamsFile as { relicReserve?: SeamRect }).relicReserve ?? null;
}

View File

@ -1,249 +0,0 @@
/**
* LANE-RENDER `?showroom`: the MODELBEAST asset QA page.
*
* Every machine and every item, on labelled plinths, under a fixed camera. Drop a GLB
* into `public/assets/models/<asset>.glb` and it appears here within ~10s with no
* reload this is the eyeball step for an asset drop: right scale? sits on the ground?
* silhouette reads at iso distance? clip loops?
*
* The machines are fabricated as a normal SimSnapshot rather than hand-built meshes, so
* they go through the SAME EntityLayer path as the real game hot-swap, GLB
* normalisation, AnimationMixer, era skin and all. A bespoke display path would be a
* QA page that tests itself instead of the renderer.
*
* Items aren't entities (they have no `asset` key and never hot-swap), so those are
* built directly but from beltitems.ts's own geometry/material factories, so what you
* see here is literally what rides the belts.
*
* Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag.
*/
import * as THREE from 'three';
import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts';
import { AssetRegistry } from './registry';
import { itemGeometry, itemMaterial } from './beltitems';
export const showroomEnabled = (): boolean =>
import.meta.env.DEV && new URLSearchParams(location.search).has('showroom');
/** Machines face the camera (dir 2 = S = +z), which is where the iso rig looks from. */
const FACING: Dir = 2;
/**
* Machine hall to the north, item hall to the south, both inside the ±32 world.
* Both catalogs grow every round (9 items -> 60; 7 machines -> 29), so nothing here is
* a fixed row/column count: the halls are packed into the world box at build time.
*/
const M_COLS = 8;
const M_SPACING = 6.5;
const M_Z0 = -24;
const I_SPACING = 3.5;
const I_GAP = 7; // between the two halls
const I_Z_LIMIT = 30; // keep the item hall inside the world (camera clamps at ±32)
const X_LIMIT = 30;
const PLINTH_H = 0.05;
interface Slot {
label: string;
sub: string;
x: number;
z: number;
/** Cached label size, measured once — re-reading offsetWidth every frame thrashes layout. */
w: number;
h: number;
}
export class Showroom {
readonly decor = new THREE.Group();
private entities: EntityState[] = [];
private slots: Slot[] = [];
private labelHost: HTMLDivElement | null = null;
private labelEls: HTMLDivElement[] = [];
private v = new THREE.Vector3();
/** Camera preset framing the whole hall. */
readonly view = { x: 0, z: 2, zoom: 21 };
constructor(private data: GameData, private registry: AssetRegistry) {
this.decor.name = 'showroom';
this.build();
}
private build(): void {
let id = 1;
const mCentre = ((M_COLS - 1) * M_SPACING) / 2;
// --- machines: fabricated entities, rendered by the real EntityLayer -------------
this.data.machines.forEach((def, i) => {
const cx = -mCentre + (i % M_COLS) * M_SPACING;
const cz = M_Z0 + Math.floor(i / M_COLS) * M_SPACING;
// Centre each footprint on its slot: pos is the min corner.
const x = Math.round(cx - def.footprint.x / 2);
const z = Math.round(cz - def.footprint.y / 2);
this.entities.push({
id: id++, def: def.id, pos: { x, y: z }, dir: FACING, recipe: null,
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false,
});
this.slots.push({
label: def.name,
sub: `${def.asset}.glb · ${this.registry.eraOf(def.id)}`,
x: cx, z: cz, w: 0, h: 0,
});
this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, cz));
});
const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP;
// Fit the item hall to the world box — BOTH axes. Budgeting only depth is what put
// all 60 items on one 206-tile-wide row the moment the machine hall grew to 5 rows
// and ate the remaining depth: rows collapsed to 1, so columns took the overflow and
// walked straight out of the world.
const iRows = Math.max(1, Math.floor((I_Z_LIMIT - itemZ0) / I_SPACING) + 1);
const maxCols = Math.max(1, Math.floor((X_LIMIT * 2) / I_SPACING) + 1);
const iCols = Math.min(maxCols, Math.max(1, Math.ceil(this.data.items.length / iRows)));
const iCentre = ((iCols - 1) * I_SPACING) / 2;
// If both axes are full the catalog has outgrown the hall — say so rather than
// quietly stacking items on top of each other off-world.
const capacity = iRows * iCols;
if (this.data.items.length > capacity) {
console.warn(
`[render] showroom: ${this.data.items.length} items exceed the hall's ${capacity} ` +
`slots (${iCols}x${iRows}); the overflow will sit past the world edge. Time to page.`,
);
}
// --- items: built from the belt layer's own factories ----------------------------
this.data.items.forEach((def, i) => {
const cx = -iCentre + (i % iCols) * I_SPACING;
const cz = itemZ0 + Math.floor(i / iCols) * I_SPACING;
const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
mesh.position.set(cx, PLINTH_H + 0.35, cz);
mesh.castShadow = true;
mesh.userData.spin = true;
this.decor.add(mesh);
this.decor.add(plinth(1.4, 1.4, cx, cz));
this.slots.push({
label: def.name, sub: `tier ${def.tier} · ${def.color}`, x: cx, z: cz, w: 0, h: 0,
});
});
// Preset frames the MACHINE hall: assets are what this page exists to inspect, and a
// view wide enough to include every item renders each machine too small to judge.
// The item hall is one pan south (WASD / drag).
const mRows = Math.ceil(this.data.machines.length / M_COLS);
this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2;
this.view.zoom = 22;
}
/** All machines, standing still. Items live in `decor`, not the snapshot. */
snapshot(tick: number): SimSnapshot {
return {
tick,
paused: false,
entities: this.entities,
beltItems: [],
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
shippedTotal: {},
activeCommission: null,
commissionProgress: {},
};
}
frame(timeSec: number): void {
for (const o of this.decor.children) {
if (o.userData.spin) o.rotation.y = timeSec * 0.9;
}
}
// ---------------------------------------------------------------- labels
/** DOM labels: crisp at any zoom, and no font atlas to maintain for a QA page. */
mountLabels(container: HTMLElement): void {
const host = document.createElement('div');
host.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden';
this.labelEls = this.slots.map((s) => {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute',
'transform:translate(-50%,0)',
'font:10px/1.35 "Courier New",ui-monospace,monospace',
'color:#cfe6ff',
'text-align:center',
'text-shadow:0 1px 3px #000,0 0 8px #000',
'white-space:nowrap',
].join(';');
el.innerHTML = `<div>${esc(s.label)}</div><div style="color:#7d8aa8">${esc(s.sub)}</div>`;
host.appendChild(el);
return el;
});
container.appendChild(host);
this.labelHost = host;
// Measure once: sizes never change, and reading offsetWidth per frame would force
// a layout for every one of ~74 labels.
this.slots.forEach((s, i) => {
s.w = this.labelEls[i].offsetWidth;
s.h = this.labelEls[i].offsetHeight;
});
}
/**
* Project, then drop any label that would collide with one already placed. There are
* ~74 of these and at overview zoom they cannot all fit; a readable subset that
* resolves as you zoom in beats an unreadable pile. Front-to-back so the labels you
* lose are the ones furthest away.
*/
updateLabels(camera: THREE.Camera, container: HTMLElement): void {
if (!this.labelHost) return;
const w = container.clientWidth;
const h = container.clientHeight;
this.placed.length = 0;
const order = this.slots
.map((s, i) => ({ s, i, d: s.z })) // nearer the camera = larger z in this rig
.sort((a, b) => b.d - a.d);
for (const { s, i } of order) {
const el = this.labelEls[i];
this.v.set(s.x, 0, s.z + 1.5).project(camera);
if (this.v.z > 1 || Math.abs(this.v.x) > 1.15 || Math.abs(this.v.y) > 1.15) {
el.style.display = 'none';
continue;
}
const px = (this.v.x * 0.5 + 0.5) * w;
const py = (-this.v.y * 0.5 + 0.5) * h;
const l = px - s.w / 2;
const r = px + s.w / 2;
const t = py;
const b = py + s.h;
let hit = false;
for (const p of this.placed) {
if (l < p[2] && r > p[0] && t < p[3] && b > p[1]) { hit = true; break; }
}
if (hit) {
el.style.display = 'none';
continue;
}
this.placed.push([l, t, r, b]);
el.style.display = '';
el.style.left = `${px}px`;
el.style.top = `${py}px`;
}
}
private placed: Array<[number, number, number, number]> = [];
}
function plinth(w: number, d: number, cx: number, cz: number): THREE.Mesh {
const m = new THREE.Mesh(
new THREE.BoxGeometry(w, PLINTH_H, d),
new THREE.MeshStandardMaterial({
color: 0x14121d,
emissive: 0x2a3550,
emissiveIntensity: 0.35,
roughness: 0.9,
}),
);
m.position.set(cx, PLINTH_H / 2, cz);
m.receiveShadow = true;
return m;
}
const esc = (s: string): string =>
s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

View File

@ -1,237 +0,0 @@
/**
* LANE-RENDER wildlife: dust piles, mosquito swarms, and parity mites (§2/§5, the
* living hazards of M2 and The Correction).
*
* Piles are HF-dust glitter heaps (codex: quantizer sweepings) they grow with `size`.
* Swarms are pixel-gnat clouds that cluster over the machine they're degrading (`target`);
* the machine's own flicker is EntityLayer's job, driven off the same `target` ids.
* Parity mites are hospital-white beetles with a green checkmark eye that walk with an
* unsettling gentleness (§5): they don't rampage, they FILE. Where one corrects your
* product it leaves an infuriatingly tidy sparkle (Juice.tidy), the visual of the
* 'repaired' event which render() can't see, so it's derived from the mite's presence.
*
* All instanced from day one: one InstancedMesh for every pile, one for every gnat across
* every swarm, one for every mite body, one for every mite eye.
*/
import * as THREE from 'three';
import type { SimSnapshot, WildlifeState } from '../contracts';
import type { Juice } from './juice';
const GNATS_PER_SWARM = 24;
/** HF dust: pale, faintly iridescent glitter. Emissive so heaps sparkle at iso distance. */
function pileMaterial(): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: 0xe8e0ff, emissive: 0xb0a0ff, emissiveIntensity: 0.5,
roughness: 0.3, metalness: 0.6, flatShading: true,
});
}
/** A crisp green ✓ on transparent ground — the mite's single, approving eye (§5). */
function checkmarkTexture(): THREE.CanvasTexture {
const s = 64;
const cv = document.createElement('canvas');
cv.width = cv.height = s;
const ctx = cv.getContext('2d')!;
ctx.strokeStyle = '#39ff88';
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(s * 0.2, s * 0.52);
ctx.lineTo(s * 0.42, s * 0.74);
ctx.lineTo(s * 0.82, s * 0.26);
ctx.stroke();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
export class WildlifeLayer {
readonly group = new THREE.Group();
private piles: THREE.InstancedMesh;
private gnats: THREE.InstancedMesh;
private mites: THREE.InstancedMesh;
private miteEyes: THREE.InstancedMesh;
private pileCap = 64;
private gnatCap = 64 * GNATS_PER_SWARM;
private miteCap = 96;
private dummy = new THREE.Object3D();
private eyeCol = new THREE.Color();
/** Per-mite last-seen {size,pos}, so the renderer can derive the two mite events it can't
* see: `repaired` (size crossing <1 1 = a correction landed) and "canned by a
* firewall" (a mite gone from the list while still unsated = filed, not fled). */
private miteState = new Map<number, { size: number; x: number; z: number }>();
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
readonly harassed = new Set<number>();
constructor(private juice?: Juice) {
this.group.name = 'wildlife';
// A low, faceted heap; instance scale grows it with `size`.
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
pileGeo.scale(1, 0.5, 1);
this.piles = new THREE.InstancedMesh(pileGeo, pileMaterial(), this.pileCap);
this.piles.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.piles.castShadow = true;
this.piles.frustumCulled = false;
this.piles.count = 0;
const gnatGeo = new THREE.PlaneGeometry(0.08, 0.08);
const gnatMat = new THREE.MeshBasicMaterial({
color: 0x101014, transparent: true, opacity: 0.85, side: THREE.DoubleSide,
});
this.gnats = new THREE.InstancedMesh(gnatGeo, gnatMat, this.gnatCap);
this.gnats.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.gnats.frustumCulled = false;
this.gnats.count = 0;
// Parity mite: a hospital-white carapace (faceted ellipsoid). Faintly self-lit so it
// reads clinical-white on the dark ground rather than going grey.
const miteGeo = new THREE.IcosahedronGeometry(0.18, 0);
miteGeo.scale(1, 0.62, 1.4); // longer than wide: a beetle, not a bead
const miteMat = new THREE.MeshStandardMaterial({
color: 0xf4f6ff, emissive: 0xd6ddf2, emissiveIntensity: 0.32,
roughness: 0.5, metalness: 0.1, flatShading: true,
});
this.mites = new THREE.InstancedMesh(miteGeo, miteMat, this.miteCap);
this.mites.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mites.castShadow = true;
this.mites.frustumCulled = false;
this.mites.count = 0;
const eyeGeo = new THREE.PlaneGeometry(0.16, 0.16);
const eyeMat = new THREE.MeshBasicMaterial({
map: checkmarkTexture(), transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
this.miteEyes = new THREE.InstancedMesh(eyeGeo, eyeMat, this.miteCap);
this.miteEyes.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.miteEyes.frustumCulled = false;
this.miteEyes.count = 0;
this.group.add(this.piles, this.gnats, this.mites, this.miteEyes);
}
sync(wildlife: ReadonlyArray<WildlifeState> | undefined, timeSec: number): void {
this.harassed.clear();
const list = wildlife ?? [];
let pileN = 0;
let gnatN = 0;
let miteN = 0;
const seenMites = new Set<number>();
for (const w of list) {
if (w.kind === 'dust-pile') {
if (pileN >= this.pileCap) continue;
const s = 0.4 + THREE.MathUtils.clamp(w.size, 0, 1) * 1.3;
this.dummy.position.set(w.pos.x + 0.5, 0.05 * s, w.pos.y + 0.5);
this.dummy.rotation.set(0, w.id * 1.7, 0); // desync facets between heaps
this.dummy.scale.set(s, s, s);
this.dummy.updateMatrix();
this.piles.setMatrixAt(pileN++, this.dummy.matrix);
} else if (w.kind === 'parity-mite') {
if (miteN >= this.miteCap) continue;
seenMites.add(w.id);
miteN = this.placeMite(w, timeSec, miteN);
} else {
if (w.target !== undefined) this.harassed.add(w.target);
const cx = w.pos.x + 0.5;
const cz = w.pos.y + 0.5;
const density = Math.round(GNATS_PER_SWARM * (0.4 + w.size * 0.6));
for (let i = 0; i < density && gnatN < this.gnatCap; i++) {
// Each gnat orbits the swarm centre on its own little chaotic path.
const ph = w.id * 3.1 + i * 0.7;
const rad = 0.5 + 0.5 * Math.sin(timeSec * 3 + ph * 1.3);
const a = timeSec * (1.5 + (i % 3) * 0.4) + ph;
this.dummy.position.set(
cx + Math.cos(a) * rad,
0.5 + 0.4 * Math.sin(timeSec * 4 + ph) + w.size * 0.3,
cz + Math.sin(a * 1.1) * rad,
);
this.dummy.rotation.set(0, -a, 0); // face roughly along travel
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.gnats.setMatrixAt(gnatN++, this.dummy.matrix);
}
}
}
// A mite that vanished from the list: sated ones (size≥1) reached the rim and left
// carrying their correction — no cue. One still unsated was CANNED by a firewall
// mid-repair (§5's counter): the product survived, so it reads as filed paperwork —
// an infuriatingly tidy sparkle where the jar closed.
for (const [id, st] of this.miteState) {
if (seenMites.has(id)) continue;
if (st.size < 1) this.juice?.tidy(st.x, st.z);
this.miteState.delete(id);
}
this.piles.count = pileN;
this.piles.instanceMatrix.needsUpdate = true;
this.gnats.count = gnatN;
this.gnats.instanceMatrix.needsUpdate = true;
this.mites.count = miteN;
this.mites.instanceMatrix.needsUpdate = true;
this.miteEyes.count = miteN;
this.miteEyes.instanceMatrix.needsUpdate = true;
if (this.miteEyes.instanceColor) this.miteEyes.instanceColor.needsUpdate = true;
}
/**
* One mite. `size` is the sim's state channel (§5): 0 = hunting, 0<size<1 = on station
* mid-repair, size1 = sated and carrying the correction home. The tell tracks it
* a hunting mite drifts with a slow, deliberate patrol (the wrongness is the calm, not
* the speed); on station it stops and only trembles while the checkmark eye glows up to
* completion; sated it moves off. The eye is tilted to meet the iso camera. Crossing
* into sated fires the tidy 'repaired' sparkle where the product used to be.
*/
private placeMite(w: WildlifeState, timeSec: number, n: number): number {
const cx = w.pos.x + 0.5;
const cz = w.pos.y + 0.5;
const size = w.size;
const onStation = size > 0 && size < 1;
const ph = w.id * 2.3999; // golden-angle desync
// On station it dwells (a tiny tremor); otherwise it patrols a slow, patient circle.
const amp = onStation ? 0.05 : 0.26;
const rate = onStation ? 0.9 : 0.55;
const gait = timeSec * rate + ph;
const px = cx + Math.cos(gait) * amp;
const pz = cz + Math.sin(gait) * amp;
const yaw = -gait; // face along the patrol tangent
const bob = Math.abs(Math.sin(gait * 3)) * (onStation ? 0.012 : 0.03);
this.dummy.position.set(px, 0.14 + bob, pz);
this.dummy.rotation.set(0, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.mites.setMatrixAt(n, this.dummy.matrix);
// Eye: forward of the carapace (local -Z, so world = (-sinYaw,-cosYaw)) and up, laid
// back ~45° so the checkmark meets the iso camera instead of lying flat on the floor.
const fwd = 0.14;
this.dummy.position.set(px - Math.sin(yaw) * fwd, 0.27 + bob, pz - Math.cos(yaw) * fwd);
this.dummy.rotation.set(-Math.PI / 4, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.miteEyes.setMatrixAt(n, this.dummy.matrix);
// The approval glows in as the correction completes: dim while hunting, ramping on
// station, full when sated. Per-instance via instanceColor (multiplies the map).
const glow = size <= 0 ? 0.5 : size < 1 ? 0.5 + 0.5 * size : 1;
this.miteEyes.setColorAt(n, this.eyeCol.setRGB(glow, glow, glow));
// Derive the `repaired` event (render() can't see events): the correction lands the
// instant the mite crosses from repairing into sated.
const prev = this.miteState.get(w.id);
if (prev && prev.size < 1 && size >= 1) this.juice?.tidy(cx, cz);
this.miteState.set(w.id, { size, x: cx, z: cz });
return n + 1;
}
}
/** Convenience: which entities a snapshot's swarms are harassing (for EntityLayer). */
export function harassedIn(snap: SimSnapshot): Set<number> {
const out = new Set<number>();
for (const w of snap.wildlife ?? []) {
if (w.kind === 'mosquito-swarm' && w.target !== undefined) out.add(w.target);
}
return out;
}

View File

@ -1,34 +1,25 @@
/**
* THE SCREEN's base content: procedural "clean programming".
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
* ONTO it must be bland enough that any corruption reads instantly.
*
* A sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype and widened
* to 16:9. This is what the factory's output is painted ONTO bland enough that any corruption
* reads instantly. Round 5: the SUBSTRATE ages with the highest ratified era (eras.ts); the
* corruption on top stays era-agnostic.
*
* Cost control: the pattern is static, so we only repaint when a visible input changes (timecode
* ~1/sec, era, chyron text). Motion comes from the shader's slow drift, not from re-uploading
* 640x360 pixels every frame.
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
* drift, not from re-uploading 640x360 pixels every frame.
*/
import { eraSkin, type Era, type EraSkin } from './eras';
export const BASE_W = 640;
export const BASE_H = 360;
export interface BaseState {
timeMs: number;
era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
chyron: string;
}
export interface BaseFeed {
canvas: HTMLCanvasElement;
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
update(state: BaseState): boolean;
update(timeMs: number, idle: boolean): boolean;
}
function timecode(timeMs: number): string {
// Tape-style running timecode, starting at a suspiciously specific point.
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
@ -36,77 +27,29 @@ function timecode(timeMs: number): string {
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function applyTreatment(g: CanvasRenderingContext2D, skin: EraSkin) {
const t = skin.treatment;
if (t.kind === 'none') return;
g.save();
if (t.kind === 'sepia') {
// warm the whole frame + gate-weave sprocket holes down both edges
g.globalCompositeOperation = 'overlay';
g.fillStyle = 'rgba(150,110,60,0.28)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = '#0d0904';
g.fillRect(0, 0, 22, BASE_H);
g.fillRect(BASE_W - 22, 0, 22, BASE_H);
g.fillStyle = '#efe2c0';
for (let y = 6; y < BASE_H; y += 34) {
g.fillRect(5, y, 12, 18);
g.fillRect(BASE_W - 17, y, 12, 18);
}
} else if (t.kind === 'phosphor') {
// green cathode wash + heavy interlace lines
g.globalCompositeOperation = 'multiply';
g.fillStyle = 'rgba(120,255,150,0.35)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = 'rgba(0,0,0,0.28)';
for (let y = 0; y < BASE_H; y += 2) g.fillRect(0, y, BASE_W, 1);
} else if (t.kind === 'gloss') {
// rainbow pit-and-land sheen sweeping across, plus a specular band
const grad = g.createLinearGradient(0, 0, BASE_W, BASE_H);
grad.addColorStop(0.0, 'rgba(255,0,180,0.10)');
grad.addColorStop(0.35, 'rgba(0,220,255,0.06)');
grad.addColorStop(0.7, 'rgba(120,255,120,0.10)');
grad.addColorStop(1.0, 'rgba(255,180,0,0.08)');
g.globalCompositeOperation = 'screen';
g.fillStyle = grad;
g.fillRect(0, 0, BASE_W, BASE_H);
g.fillStyle = 'rgba(255,255,255,0.10)';
g.fillRect(0, BASE_H * 0.2, BASE_W, 26);
g.globalCompositeOperation = 'source-over';
} else if (t.kind === 'bleach') {
// wash toward blinding white, kill contrast
g.fillStyle = 'rgba(250,250,250,0.6)';
g.fillRect(0, 0, BASE_W, BASE_H);
}
g.restore();
}
export function createBaseFeed(): BaseFeed {
const canvas = document.createElement('canvas');
canvas.width = BASE_W;
canvas.height = BASE_H;
const g = canvas.getContext('2d')!;
let last = '';
let lastTC = '';
let lastIdle: boolean | null = null;
function paint(state: BaseState) {
const skin = eraSkin(state.era);
g.fillStyle = skin.bg;
g.fillRect(0, 0, BASE_W, BASE_H);
const bw = BASE_W / skin.bars.length;
skin.bars.forEach((col, i) => {
function paint(tc: string, idle: boolean) {
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const bw = BASE_W / bars.length;
bars.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
});
skin.casts.forEach((col, i) => {
// castellations strip
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
casts.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
});
// pluge / ramp
g.fillStyle = skin.bg;
g.fillStyle = '#101010';
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
ramp.addColorStop(0, '#000');
@ -115,59 +58,36 @@ export function createBaseFeed(): BaseFeed {
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
// camcorder overlay
g.fillStyle = skin.ink;
g.fillStyle = '#eee';
g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
g.fillText(tc, 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace";
g.fillStyle = '#f4f4f4';
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
g.fillStyle = skin.accent;
g.fillStyle = '#ff4444';
g.beginPath();
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
g.fill();
applyTreatment(g, skin);
// Chyron band. Carries 'NOTHING IS WRONG' while idle, or a radio subtitle when tuned.
// Painted AFTER the era treatment so captions stay legible on any substrate.
// Round-5 review fix (orchestrator): a single un-wrapped fillText showed ~51 chars
// and 29 of THE STANDARD's 30 lines clipped mid-sentence. "THE SCREEN tells you
// what it says" has to mean ALL of what it says: word-wrap to two lines, stepping
// the font down until it fits.
if (state.chyron) {
const maxW = BASE_W - 36;
let lines: string[] = [];
let px = 20;
for (const size of [20, 16, 13]) {
px = size;
g.font = `bold ${px}px 'Courier New', monospace`;
lines = [];
let cur = '';
for (const word of state.chyron.split(' ')) {
const next = cur ? cur + ' ' + word : word;
if (g.measureText(next).width <= maxW) cur = next;
else { if (cur) lines.push(cur); cur = word; }
}
if (cur) lines.push(cur);
if (lines.length <= 2) break;
}
const lineH = px + 6;
const bandH = 10 + lines.length * lineH;
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
if (idle) {
g.fillStyle = 'rgba(8,8,12,0.82)';
g.fillRect(0, BASE_H * 0.5, BASE_W, bandH);
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
g.fillStyle = '#d8ffd8';
lines.forEach((line, i) => g.fillText(line, 18, BASE_H * 0.5 + px + 4 + i * lineH));
g.font = "bold 20px 'Courier New', monospace";
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
}
}
return {
canvas,
update(state: BaseState) {
// Cache key of every visible input; repaint only when one changes.
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`;
if (key === last) return false;
last = key;
paint(state);
update(timeMs: number, idle: boolean) {
const tc = timecode(timeMs);
if (tc === lastTC && idle === lastIdle) return false;
lastTC = tc;
lastIdle = idle;
paint(tc, idle);
return true;
},
};

View File

@ -1,79 +0,0 @@
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);
});
});
describe('jammed-rot: stuck work spoils faster than resting work', () => {
const IDLE = BOREDOM_GRACE_SECONDS + 60;
it('leaves a clean idle exactly as gentle as before', () => {
// rot defaults to 0, so round 4 behaviour is untouched for a player who walked away.
expect(freshness(IDLE, 0)).toBe(freshness(IDLE));
});
it('forgets faster the more of the factory is jammed', () => {
const clean = freshness(IDLE, 0);
const half = freshness(IDLE, 0.5);
const solid = freshness(IDLE, 1);
expect(half).toBeLessThan(clean);
expect(solid).toBeLessThan(half);
});
it('still gives a fully jammed factory a grace window', () => {
// Even total gridlock should not punish instantly — a jam you fix in a few seconds is free.
expect(freshness(10, 1)).toBe(1);
});
it('makes a wholly-stuck line sag in tens of seconds, not minutes', () => {
expect(freshness(90, 1)).toBeLessThan(0.5);
expect(freshness(90, 0)).toBeGreaterThan(0.8); // ...while a clean idle has barely moved
});
it('clamps nonsense rot values instead of inverting the curve', () => {
expect(freshness(IDLE, -5)).toBe(freshness(IDLE, 0));
expect(freshness(IDLE, 99)).toBe(freshness(IDLE, 1));
});
});

View File

@ -1,37 +0,0 @@
/**
* 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).
*
* `rot` (round 5 jammed-rot) accelerates forgetting. A CLEAN idle the player walked away
* decays at the base rate. A factory JAMMED SOLID rots faster: the work isn't resting, it's
* spoiling, and the sky should say so. rot 0 = clean idle; rot 1 = everything jammed.
*/
export function freshness(idleSeconds: number, rot = 0): number {
if (!(idleSeconds > BOREDOM_GRACE_SECONDS)) return 1; // NaN-safe: unknown idle reads as fresh
// A jammed factory shortens the grace and steepens the decay. At full rot the horizon is
// roughly a third, so a wholly-stuck line visibly sags in tens of seconds, not minutes.
const r = clamp01(rot);
const grace = BOREDOM_GRACE_SECONDS * (1 - 0.5 * r);
const horizon = BOREDOM_HORIZON_SECONDS * (1 - 0.66 * r);
if (idleSeconds <= grace) return 1;
return 1 - clamp01((idleSeconds - grace) / horizon);
}

View File

@ -145,35 +145,6 @@ describe('anchor slabs sanitize', () => {
});
});
describe('repairs ease the ledger by one unit of the item (The Correction, v6)', () => {
// A `repaired` event decrements the tally by one unit in index.ts; this pins the CONTRACT
// that decrement is built on — "eases total corruption by its item's contribution".
it("removing one unit eases net units by exactly that item's per-unit contribution", () => {
const before = resolveLedger(tally({ melt: 20 }));
const after = resolveLedger(tally({ melt: 19 }));
expect(before.units - after.units).toBeCloseTo(corruptionFor('melt').potency, 5);
expect(after.total).toBeLessThan(before.total);
});
it('correcting a hard-hitting item eases more than correcting a weak one', () => {
const heavy =
resolveLedger(tally({ 'explosive-hits': 20 })).units -
resolveLedger(tally({ 'explosive-hits': 19 })).units;
const light =
resolveLedger(tally({ 'gamut-paint-rec709': 20 })).units -
resolveLedger(tally({ 'gamut-paint-rec709': 19 })).units;
expect(heavy).toBeGreaterThan(light);
});
it('a repair never drives corruption below sterile (theft floors at clean)', () => {
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
const { units, total, targets } = resolveLedger(tally({ melt: 0 }));
expect(units).toBe(0);
expect(total).toBe(0);
for (const p of PARAMS) expect(targets[p]).toBe(0);
});
});
describe('param hygiene', () => {
it('never emits a param outside 0..1, even shipping everything at once', () => {
const everything = tally({

View File

@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ERA_ORDER, eraSkin, highestEra, type Era } from './eras';
const eraOf = (t: string): Era | undefined =>
({ a: 'reel', b: 'broadcast', c: 'disc', d: 'stream', e: 'fortress' } as Record<string, Era>)[t];
describe('era skins cover the codex', () => {
it('has a distinct skin for every era', () => {
const bars = new Set<string>();
for (const era of ERA_ORDER) {
const skin = eraSkin(era);
expect(skin.bars).toHaveLength(7); // the substrate keeps SMPTE structure; only palette ages
expect(skin.casts).toHaveLength(7);
bars.add(skin.bars.join(','));
}
expect(bars.size).toBe(ERA_ORDER.length); // no two eras look alike
});
it('is deterministic — the same era always paints the same substrate', () => {
expect(eraSkin('disc')).toEqual(eraSkin('disc'));
});
it('runs oldest to newest, reel first and fortress last', () => {
expect(ERA_ORDER[0]).toBe('reel');
expect(ERA_ORDER[ERA_ORDER.length - 1]).toBe('fortress');
});
});
describe('highest ratified era', () => {
it('starts in the deepest stratum when nothing is ratified', () => {
expect(highestEra([], eraOf)).toBe('reel');
});
it('takes the newest era present, not the last listed', () => {
expect(highestEra(['c', 'a', 'b'], eraOf)).toBe('disc');
expect(highestEra(['e', 'a'], eraOf)).toBe('fortress');
});
it('ignores tech it cannot place rather than falling over', () => {
// LANE-DATA can add tech ahead of me; unknown ids must not crash or reset the era.
expect(highestEra(['b', 'who-knows'], eraOf)).toBe('broadcast');
});
it('never goes backwards as more tech is ratified', () => {
let prev = -1;
const acc: string[] = [];
for (const t of ['a', 'b', 'c', 'd', 'e']) {
acc.push(t);
const idx = ERA_ORDER.indexOf(highestEra(acc, eraOf));
expect(idx).toBeGreaterThanOrEqual(prev);
prev = idx;
}
});
});

View File

@ -1,104 +0,0 @@
/**
* Era skins for the clean programming (round 5 Part A).
*
* THE SCREEN's SUBSTRATE ages with the highest ratified era; the corruption on top stays
* era-agnostic. Media history is geology (codex §1), so the test pattern itself is a fossil
* of whatever format the player has climbed to. Codex §8 era skins drive the palette.
*
* Pure and data-only so it is trivially unit-testable and carries no DOM the base feed
* consumes an EraSkin, it does not decide one.
*/
export type Era = 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress';
/** Deepest → shallowest, i.e. oldest → newest. "Highest ratified" = last of these unlocked. */
export const ERA_ORDER: readonly Era[] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
export interface EraSkin {
/** the seven SMPTE bars, era-recoloured */
bars: string[];
/** the castellation strip under the bars */
casts: string[];
/** backdrop behind the pattern (matters where bars don't cover) */
bg: string;
/** overlay text + timecode colour */
ink: string;
/** rec-dot / accent colour */
accent: string;
/**
* A full-canvas post-treatment applied after the pattern is drawn. Kept as a tagged union
* (not a callback) so it stays pure data the base feed interprets it.
*/
treatment:
| { kind: 'none' }
| { kind: 'sepia'; sprockets: true } // reel: warm film, sprocket holes
| { kind: 'phosphor' } // broadcast: green cast + interlace
| { kind: 'gloss' } // disc: rainbow-on-black sheen
| { kind: 'bleach' }; // fortress: blinding matte white
}
const STREAM_BARS = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const STREAM_CASTS = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
const SKINS: Record<Era, EraSkin> = {
reel: {
bars: ['#b9a67c', '#c7b06a', '#9c9a72', '#8f9a63', '#a98c6e', '#a06a4e', '#6e5c46'],
casts: ['#5a4a34', '#241d14', '#6e5240', '#241d14', '#5c5a44', '#241d14', '#b9a67c'],
bg: '#181008',
ink: '#f0e0c0',
accent: '#d8a24a',
treatment: { kind: 'sepia', sprockets: true },
},
broadcast: {
// NTSC bars, but everything sunk into a phosphor-green cathode glow.
bars: ['#8fce8f', '#b6c664', '#4fb7b7', '#4fb44f', '#8a5aa8', '#9a4a4a', '#3a4fb0'],
casts: ['#12300f', '#0a140a', '#2a3a20', '#0a140a', '#1a3a30', '#0a140a', '#8fce8f'],
bg: '#03120a',
ink: '#c8ffce',
accent: '#8fffa0',
treatment: { kind: 'phosphor' },
},
disc: {
// Rigid, glossy, rainbow-on-black. DVD menu energy.
bars: ['#d0d0d0', '#d8d800', '#00d8d8', '#00d800', '#d800d8', '#e00000', '#2020ff'],
casts: ['#2020ff', '#000000', '#d800d8', '#000000', '#00d8d8', '#000000', '#d0d0d0'],
bg: '#000000',
ink: '#ffffff',
accent: '#ff3fd4',
treatment: { kind: 'gloss' },
},
stream: {
// The surface world / the original prototype look — the game's "now".
bars: STREAM_BARS,
casts: STREAM_CASTS,
bg: '#101010',
ink: '#f4f4f4',
accent: '#ff4444',
treatment: { kind: 'none' },
},
fortress: {
// The DCP citadel: mathematically perfect, blinding matte white, near-invisible bars.
bars: ['#f7f7f7', '#f2f2e8', '#e8f2f2', '#e8f2e8', '#f2e8f2', '#f2e8e8', '#e8e8f4'],
casts: ['#e6e6ee', '#f4f4f4', '#eee6ee', '#f4f4f4', '#e6eeee', '#f4f4f4', '#f7f7f7'],
bg: '#fbfbfb',
ink: '#20242a',
accent: '#2f7d3f',
treatment: { kind: 'bleach' },
},
};
/** The highest ratified era from a set of unlocked tech, given a tech→era lookup. */
export function highestEra(unlocked: readonly string[], eraOf: (tech: string) => Era | undefined): Era {
let best = 0; // default: reel (the world starts in the deepest stratum you can reach)
for (const t of unlocked) {
const e = eraOf(t);
if (e === undefined) continue;
const idx = ERA_ORDER.indexOf(e);
if (idx > best) best = idx;
}
return ERA_ORDER[best];
}
export function eraSkin(era: Era): EraSkin {
return SKINS[era];
}

View File

@ -13,7 +13,6 @@
* would cost more than the whole effect is allowed to cost.
*/
import { BASE_H, BASE_W, type BaseFeed } from './baseTexture';
import type { Era } from './eras';
import type { Overlays } from './overlays';
import { PARAMS, type Param, type ParamSet } from './params';
@ -30,15 +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, u_pallor;
uniform float u_strain, u_fever;
uniform vec2 u_moireSeed;
uniform float u_wildlife;
uniform vec2 u_wildlifeSeed;
uniform float u_mite;
uniform vec2 u_miteSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_repairTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
uniform float u_repair, u_repairScale;
uniform sampler2D u_stampTex, u_scramTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
@ -183,17 +177,6 @@ void main(){
float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
col = mix(col, vec3(sn), u_noise * 0.7);
// ---- wildlife shimmer: a mosquito swarm degrading a REGION of the sky. Same growth-mask
// template as moire, seeded from the swarm's world position — high-frequency crawling
// luminance noise (literal mosquito noise) rather than the flat static above.
if(u_wildlife > 0.001){
float wm = growthMask(vUv, u_wildlifeSeed, u_wildlife*0.7) * clamp(u_wildlife*1.6, 0.0, 1.0);
if(wm > 0.001){
float sk = hash(floor(vUv*vec2(320.0,180.0)) + floor(tt*20.0)); // crawls fast, per-frame
col = mix(col, col*0.6 + vec3(sk)*0.5, wm*0.7);
}
}
// freeze dropout: grey flash lines
float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
col = mix(col, vec3(0.45), dropo * u_freeze);
@ -218,32 +201,6 @@ 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;
}
// ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
// parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
// corruption — it desaturates and washes the region toward clinical white with a faint
// manufactured tile grid. It sits on TOP of the corruption stack because it is erasure: your
// mess is yours, this is your work being quietly deleted. Meant to feel WORSE than rot.
if(u_mite > 0.001){
float mmask = growthMask(vUv, u_miteSeed, u_mite*0.7) * clamp(u_mite*1.7, 0.0, 1.0);
if(mmask > 0.001){
vec3 sterile = mix(vec3(lu(col)), vec3(0.93,0.96,0.95), 0.72); // desaturate → cool white
sterile = mix(sterile, vec3(0.98), 0.35); // lift toward clinical bright
float tile = max(step(0.93, fract(vUv.x*64.0)), step(0.93, fract(vUv.y*36.0)));
sterile = mix(sterile, sterile*0.9, tile*0.6); // faint manufactured grid
col = mix(col, sterile, mmask*0.9);
}
}
// faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
@ -257,17 +214,6 @@ void main(){
}
}
// ---- repair placard: The Correction signs one unit off the ledger. Small, perfect, screen
// space (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
if(u_repair > 0.001){
vec2 halfSize = vec2(0.150, 0.082)*u_repairScale;
vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
vec4 rp = texture2D(u_repairTex, ruv);
col = mix(col, rp.rgb, rp.a*u_repair);
}
}
// ---- scram: the tape loses tracking for exactly one frame
if(u_scram > 0.5){
float halfH = 0.085;
@ -281,26 +227,12 @@ 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);
}
// ---- boot title: WIMVEE FKTRY burn-in. Premultiplied-ish over black so it can dissolve.
if(u_title > 0.001){
vec4 ti = texture2D(u_titleTex, vUv);
col = mix(col, ti.rgb, ti.a * u_title);
}
gl_FragColor = vec4(col, 1.0);
}`;
type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
| 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale';
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
export interface DrawState {
params: ParamSet;
@ -309,36 +241,16 @@ export interface DrawState {
drift: number;
/** 0..1 brownout severity */
brownout: number;
/** 0..1 awakening flash (round 5: ramped over ~0.5s, no longer a single frame) */
/** 0..1 one-frame white awakening */
flash: number;
/** current substrate era for the base feed */
era: Era;
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
chyron: string;
/** nothing shipped yet — the clean feed still carries its chyron */
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;
/** 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 regional mosquito shimmer amount (wildlife swarms) */
wildlife: number;
/** where the swarm shimmer grows from, in screen uv */
wildlifeSeed: [number, number];
/** 0..1 hospital-white sterile creep around heavy parity-mite activity (the inverse of rot) */
mite: number;
/** where the sterile patch grows from, in screen uv */
miteSeed: [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 boot title card */
title: number;
/** set when the title canvas has been repainted and needs re-upload */
titleDirty: boolean;
/** 0..1 opacity of the shipment stamp */
stampAmt: number;
/** stamp size multiplier — bigger shipments stamp harder */
@ -349,12 +261,6 @@ export interface DrawState {
scramY: number;
/** set when the stamp canvas has been repainted and needs re-upload */
stampDirty: boolean;
/** 0..1 opacity of the repair placard */
repair: number;
/** repair placard size multiplier (slight overshoot on impact, like the stamp) */
repairScale: number;
/** set when the repair canvas has been repainted and needs re-upload */
repairDirty: boolean;
}
export interface GlitchStack {
@ -414,17 +320,11 @@ export function createGlitchStack(
return t;
}
base.update({ timeMs: 0, era: 'stream', chyron: 'NOTHING IS WRONG' });
base.update(0, true);
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', '');
overlays.paintTitle();
overlays.paintRepair(''); // allocate the repair placard texture up front
const baseTex = makeTex(0, base.canvas);
const stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas);
const titleTex = makeTex(4, overlays.titleCanvas);
const repairTex = makeTex(5, overlays.repairCanvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't');
@ -433,40 +333,25 @@ 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.title = gl.getUniformLocation(prog, 'u_title');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife');
uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed');
uni.mite = gl.getUniformLocation(prog, 'u_mite');
uni.miteSeed = gl.getUniformLocation(prog, 'u_miteSeed');
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
uni.scram = gl.getUniformLocation(prog, 'u_scram');
uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
uni.repair = gl.getUniformLocation(prog, 'u_repair');
uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale');
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
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.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
gl.uniform1i(gl.getUniformLocation(prog, 'u_repairTex'), 5);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1);
return {
draw({ params, timeSec, drift, brownout, flash, era, chyron, strain, fever, pallor,
moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram, scramY, stampDirty,
repair, repairScale, repairDirty,
card, cardDirty, title, titleDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
if (base.update({ timeMs: timeSec * 1000, era, chyron })) {
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).
if (base.update(timeSec * 1000, idle)) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, baseTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
@ -476,41 +361,17 @@ 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);
}
if (titleDirty) {
gl.activeTexture(gl.TEXTURE0 + 4);
gl.bindTexture(gl.TEXTURE_2D, titleTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
}
if (repairDirty) {
gl.activeTexture(gl.TEXTURE0 + 5);
gl.bindTexture(gl.TEXTURE_2D, repairTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.repairCanvas);
}
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.uniform1f(uni.title, title);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
gl.uniform1f(uni.wildlife, wildlife);
gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]);
gl.uniform1f(uni.mite, mite);
gl.uniform2f(uni.miteSeed, miteSeed[0], miteSeed[1]);
gl.uniform1f(uni.stampAmt, stampAmt);
gl.uniform1f(uni.stampScale, stampScale);
gl.uniform1f(uni.scram, scram ? 1 : 0);
gl.uniform1f(uni.scramY, scramY);
gl.uniform1f(uni.repair, repair);
gl.uniform1f(uni.repairScale, repairScale);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},

View File

@ -16,9 +16,6 @@ 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';
import { ERA_ORDER, highestEra, type Era } from './eras';
import { getSubtitle } from '../audio/subtitleBus';
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2;
@ -31,8 +28,6 @@ const WOBBLE_DEPTH = 0.12;
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
/** Seconds a shipment stamp stays on the feed. */
const STAMP_LIFE = 0.4;
/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */
const REPAIR_LIFE = 0.55;
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
const STAMP_QUEUE_MAX = 2;
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
@ -40,23 +35,8 @@ 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;
/** Boot title: hold the burn-in, then dissolve to the NOTHING IS WRONG feed. */
const TITLE_HOLD = 1.5;
const TITLE_FADE = 0.6;
/** Round 5: the awakening is no longer one frame but a ~0.5s full-canvas moment. */
const FLASH_SECONDS = 0.5;
interface Stamp { name: string; count: number; color: string; age: number }
interface Repair { name: string; age: number }
interface Swarm { pos: { x: number; y: number }; size: number }
/**
* Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given
@ -71,38 +51,12 @@ function seedFromTick(tick: number): [number, number] {
return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5];
}
/** Swarms live on the factory floor; hash their world position to a stable spot on the sky. */
function seedFromPos(pos: { x: number; y: number }): [number, number] {
const h = (n: number) => {
const x = Math.sin(n) * 43758.5453;
return x - Math.floor(x);
};
return [0.2 + h(pos.x * 12.9898 + pos.y * 78.233) * 0.6, 0.2 + h(pos.x * 39.3468 + pos.y * 11.135) * 0.6];
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
function smoothstep(e0: number, e1: number, x: number): number {
const t = clamp01((x - e0) / (e1 - e0));
return t * t * (3 - 2 * t);
}
/** Boot title: hold at full, then dissolve. Returns 0 once the sequence is over. */
function titleOpacity(age: number): number {
if (age <= TITLE_HOLD) return 1;
return 1 - smoothstep(TITLE_HOLD, TITLE_HOLD + TITLE_FADE, age);
}
/**
* The awakening. A blown-out hold then a fast decay the sky is stunned for a moment rather
* than blinking once. Returns 0 when not flashing (age < 0).
*/
function flashEnvelope(age: number): number {
if (age < 0) return 0;
if (age >= FLASH_SECONDS) return 0;
if (age < 0.12) return 1;
return 1 - smoothstep(0.12, FLASH_SECONDS, age);
}
export function createScreenFX(): ScreenFX {
let stack: GlitchStack | null = null;
const overlays = createOverlays();
@ -118,44 +72,20 @@ export function createScreenFX(): ScreenFX {
let brownout = 0;
let brownoutTarget = 0;
let pendingFlash = false;
let hasShipped = false;
const stampQueue: Stamp[] = [];
let stamp: Stamp | null = null;
let stampDirty = false;
const repairQueue: Repair[] = [];
let repair: Repair | null = null;
let repairDirty = false;
let scramFrames = 0;
let scramY = 0.5;
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, Era>();
// Part A round 5:
let era: Era = ERA_ORDER[0]; // the world starts in the deepest stratum (reel beds)
let forcedEra: Era | null = null; // debug hook override
let wildlife = 0; // eased regional-shimmer amount
let wildlifeSeed: [number, number] = [0.5, 0.5];
const swarms = new Map<number, Swarm>(); // event-tracked fallback when no snapshot
let mite = 0; // eased hospital-white sterile creep amount
let miteSeed: [number, number] = [0.5, 0.5];
const mites = new Map<number, Swarm>(); // event-tracked parity-mite fallback
let titleAge = 0; // boot title sequence clock
let titleDirty = true; // painted once at stack creation; upload on the first frame
let flashAge = -1; // <0 = not flashing
let lastMs = 0;
let frameCost = 0; // rolling avg ms of this module's CPU work
@ -171,10 +101,9 @@ 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;
flashAge = 0; // the awakening — a ~0.5s moment, ramped in frame()
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) {
@ -192,15 +121,11 @@ export function createScreenFX(): ScreenFX {
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
}
let liveCanvas: HTMLCanvasElement | null = null;
const api: ScreenFX = {
return {
init(canvas: HTMLCanvasElement, data: GameData) {
liveCanvas = canvas;
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) {
@ -216,25 +141,6 @@ export function createScreenFX(): ScreenFX {
ship,
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; },
setEra: (e: Era) => { forcedEra = e; },
skipTitle: () => { titleAge = TITLE_HOLD + TITLE_FADE + 1; },
swarm: (on: boolean, id = 1, pos = { x: 3, y: -2 }) => {
if (on) swarms.set(id, { pos, size: 0.7 });
else swarms.delete(id);
},
mite: (on: boolean, id = 1, pos = { x: -4, y: 5 }, size = 0.7) => {
if (on) mites.set(id, { pos, size });
else mites.delete(id);
},
repair: (item: string, count = 1) => {
for (let i = 0; i < count; i++) {
const cur = tally.get(item) ?? 0;
if (cur > 0) { tally.set(item, cur - 1); ledgerDirty = true; }
const def = items.get(item);
repairQueue.push({ name: def?.name ?? item.toUpperCase(), age: 0 });
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
}
},
reset: () => {
tally.clear();
ledgerDirty = true;
@ -245,41 +151,14 @@ export function createScreenFX(): ScreenFX {
moireSeed = [0.5, 0.44];
strain = 0;
fever = 0;
pallor = 0;
cardAge = -1;
flashAge = -1;
lastShipMs = 0;
fresh = 1;
forcedEra = null;
swarms.clear();
wildlife = 0;
mites.clear();
mite = 0;
repair = null;
repairQueue.length = 0;
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,
era,
strain: +strain.toFixed(3),
fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3),
wildlife: +wildlife.toFixed(3),
mite: +mite.toFixed(3),
repair: repair ? 1 : 0,
card: cardAge >= 0 ? 1 : 0,
title: +titleOpacity(titleAge).toFixed(3),
flash: +flashEnvelope(flashAge).toFixed(3),
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
brownout: +brownout.toFixed(2),
msPerFrame: +frameCost.toFixed(3),
@ -287,11 +166,6 @@ export function createScreenFX(): ScreenFX {
params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
}),
escalationItems: ESCALATION_ITEMS,
// Verification driver: a hidden harness tab throttles rAF, so pump the REAL module with
// a synthetic timebase and read pixels in the same JS task (prior-round contact-sheet
// trick — a hidden tab clears the WebGL buffer after compositing otherwise).
frame: (ms: number, snap?: SimSnapshot) => api.frame(ms, snap),
canvas: () => liveCanvas,
};
},
@ -304,35 +178,6 @@ 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;
} else if (e.kind === 'wildlife' && e.wildlife === 'mosquito-swarm') {
// Event-tracked swarms are the fallback when no snapshot.wildlife is available
// (debug hook). When a snapshot IS present, frame() reads it as the source of truth.
if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 });
else swarms.delete(e.id);
} else if (e.kind === 'wildlife' && e.wildlife === 'parity-mite') {
// Parity-mites drive the sterile creep. Same fallback contract as the swarms above:
// snapshot.wildlife is truth when present, these events cover the no-snapshot case.
if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
else mites.delete(e.id);
} else if (e.kind === 'repaired') {
// The Correction corrected one unit of your work. This is THEFT, not decay: ease the
// LEDGER down by exactly that item's per-unit contribution (a real, permanent removal —
// unlike boredom, which only fades the display and leaves the tally intact). Then let
// it sign the job with a small green-tick placard. We deliberately do NOT refresh the
// boredom clock: a factory being quietly eaten is not a factory that is producing.
const cur = tally.get(e.item) ?? 0;
if (cur > 0) {
tally.set(e.item, cur - 1);
ledgerDirty = true;
}
const def = items.get(e.item);
repairQueue.push({ name: def?.name ?? e.item.toUpperCase(), age: 0 });
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
}
}
},
@ -355,69 +200,14 @@ 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);
// The substrate ages with the highest ratified era. Corruption stays era-agnostic —
// only what it is painted ONTO changes.
era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);
// Jammed-rot: a factory stuck solid rots faster than one resting. Ratio of jammed
// machines, so a single stuck belt in a big base barely registers.
let rot = 0;
if (snap && snap.entities.length) {
let jammed = 0;
for (const e of snap.entities) if (e.jammed) jammed++;
rot = jammed / snap.entities.length;
}
// 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, rot) : 1;
// Wildlife shimmer: drive the region from the WORST swarm — one legible infestation
// beats an averaged smear. Snapshot is truth when present; events are the fallback.
let worst: Swarm | null = null;
const live = snap?.wildlife;
if (live) {
for (const w of live) {
if (w.kind !== 'mosquito-swarm') continue;
if (!worst || w.size > worst.size) worst = { pos: w.pos, size: w.size };
}
} else {
for (const w of swarms.values()) if (!worst || w.size > worst.size) worst = w;
}
if (worst) wildlifeSeed = seedFromPos(worst.pos);
wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0);
// Mite sterility: same worst-of pattern, but for parity-mites. The sterile creep grows
// around the heaviest infestation; snapshot is truth when present, events are the fallback.
let worstMite: Swarm | null = null;
if (live) {
for (const w of live) {
if (w.kind !== 'parity-mite') continue;
if (!worstMite || w.size > worstMite.size) worstMite = { pos: w.pos, size: w.size };
}
} else {
for (const m of mites.values()) if (!worstMite || m.size > worstMite.size) worstMite = m;
}
if (worstMite) miteSeed = seedFromPos(worstMite.pos);
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
const timeSec = timeMs / 1000;
const displayTotal = total * fresh;
const wobble =
displayTotal > WOBBLE_THRESHOLD
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
: 0;
total > WOBBLE_THRESHOLD ? (total - 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] * fresh, dt, EASE_TAU);
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger.
const w =
wobble > 0
@ -444,62 +234,17 @@ export function createScreenFX(): ScreenFX {
if (stamp.age >= STAMP_LIFE) stamp = null;
}
// Repair placard lifecycle — its own slot so it never fights the shipment stamp. Same
// punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
if (!repair && repairQueue.length) {
repair = repairQueue.shift()!;
overlays.paintRepair(repair.name);
repairDirty = true;
}
let repairAmt = 0;
let repairScale = 1;
if (repair) {
const a = repair.age / REPAIR_LIFE;
repairAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
repairScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
repair.age += dt;
if (repair.age >= REPAIR_LIFE) repair = 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;
}
// Boot title, then the awakening — both are timed moments, not single frames.
const title = titleOpacity(titleAge);
if (title > 0) titleAge += dt;
const flash = flashEnvelope(flashAge);
if (flashAge >= 0) {
flashAge += dt;
if (flashAge >= FLASH_SECONDS) flashAge = -1;
}
// The chyron: a radio caption outranks the idle placard — when THE SPEAKER is talking,
// the sky subtitles it. Suppressed under the boot title (which covers the feed anyway).
const subtitle = getSubtitle();
const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');
const flash = pendingFlash ? 1 : 0;
stack.draw({
params: out, timeSec, drift: 1, brownout, flash, era, chyron,
strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
strain, fever, moireSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
repair: repairAmt, repairScale, repairDirty,
card, cardDirty, title, titleDirty,
});
pendingFlash = false;
if (scramFrames > 0) scramFrames--;
stampDirty = false;
repairDirty = false;
cardDirty = false;
titleDirty = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
},
};
return api;
}

View File

@ -10,46 +10,12 @@ 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 repair placard is small on purpose — it's a signature, not a broadcast. */
export const REPAIR_W = 224;
export const REPAIR_H = 112;
/**
* 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;
titleCanvas: HTMLCanvasElement;
repairCanvas: 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;
/** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */
paintTitle(): void;
/**
* Repaint the repair placard The Correction signing one unit of your work off the ledger.
* Small, perfect (no crooked stamp), hospital white with the faction green tick: deliberately
* the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft, made legible.
*/
paintRepair(name: string): void;
}
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
@ -95,107 +61,9 @@ 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')!;
const titleCanvas = document.createElement('canvas');
titleCanvas.width = CARD_W;
titleCanvas.height = CARD_H;
const tg = titleCanvas.getContext('2d')!;
const repairCanvas = document.createElement('canvas');
repairCanvas.width = REPAIR_W;
repairCanvas.height = REPAIR_H;
const rg = repairCanvas.getContext('2d')!;
return {
stampCanvas,
scramCanvas,
cardCanvas,
titleCanvas,
repairCanvas,
paintTitle() {
// The game finally says its own name. A CRT burn-in: chunky phosphor letters with a
// hot-magenta / cyan chromatic ghost, over transparent black so it can dissolve to the
// NOTHING IS WRONG feed. Alpha is what the shader mixes on.
tg.clearRect(0, 0, CARD_W, CARD_H);
tg.fillStyle = 'rgba(3,2,8,0.92)';
tg.fillRect(0, 0, CARD_W, CARD_H);
const cx = CARD_W / 2;
tg.textAlign = 'center';
tg.textBaseline = 'middle';
tg.font = "bold 58px 'Courier New', monospace";
// chromatic aberration ghosts (the prototype's signature look)
tg.fillStyle = '#3fffe0';
tg.fillText('WIMVEE', cx - 3, 150);
tg.fillStyle = '#ff2222';
tg.fillText('WIMVEE', cx + 3, 150);
tg.fillStyle = '#f0ffe8';
tg.fillText('WIMVEE', cx, 150);
tg.fillStyle = '#3fffe0';
tg.fillText('FKTRY', cx - 3, 214);
tg.fillStyle = '#ff2222';
tg.fillText('FKTRY', cx + 3, 214);
tg.fillStyle = '#ff3fd4';
tg.fillText('FKTRY', cx, 214);
tg.fillStyle = '#4a6b4a';
tg.font = "14px 'Courier New', monospace";
tg.fillText('A DECODER DAEMON PRODUCTION', cx, 268);
tg.font = "11px 'Courier New', monospace";
tg.fillText('PLEASE STAND BY', cx, 300);
// faint scanlines baked in for the burn-in feel
tg.fillStyle = 'rgba(0,0,0,0.22)';
for (let y = 0; y < CARD_H; y += 3) tg.fillRect(0, y, CARD_W, 1);
tg.textAlign = 'left'; // restore default for the other painters
},
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();
@ -236,50 +104,5 @@ export function createOverlays(): Overlays {
sg.fillText('SHIPPED / NO QUESTIONS', 22, 34);
sg.restore();
},
paintRepair(name: string) {
// The Correction's uniform (codex §8): hospital white, green tick, and the placid
// confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped
// on by a tired hand, this is machined on square. That contrast is the whole read.
rg.clearRect(0, 0, REPAIR_W, REPAIR_H);
// clean white placard with a thin green rule
rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
rg.fill();
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 3;
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
rg.stroke();
// the green tick — the faction signature
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 5;
rg.lineCap = 'round';
rg.lineJoin = 'round';
rg.beginPath();
rg.moveTo(26, 40);
rg.lineTo(38, 52);
rg.lineTo(60, 26);
rg.stroke();
rg.textBaseline = 'middle';
rg.fillStyle = '#12160f';
rg.font = "bold 20px 'Courier New', monospace";
rg.fillText('CORRECTED', 74, 40);
// the item that was quietly reverted — shrink long codex names to fit the placard
rg.fillStyle = '#5c6b5c';
let size = 15;
rg.font = `${size}px 'Courier New', monospace`;
while (rg.measureText(name).width > REPAIR_W - 40 && size > 8) {
size -= 1;
rg.font = `${size}px 'Courier New', monospace`;
}
rg.fillText(name, 26, 74);
rg.fillStyle = '#8a998a';
rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
},
};
}

View File

@ -2,8 +2,6 @@ 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,
@ -16,32 +14,22 @@ 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, pallor: 0 });
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, 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);
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, capacity: 500 })))
.toEqual({ tremor: 0, fever: 0, pallor: 0 });
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
});
it('stays calm in surplus even with an empty reserve', () => {
@ -94,39 +82,6 @@ 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.

View File

@ -10,28 +10,14 @@
*/
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, pallor: 0 };
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
@ -54,7 +40,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, capacity } = snap.bandwidth;
const { gen, draw, stored } = snap.bandwidth;
const deficit = draw - gen;
let tremor = 0;
if (deficit > 0) {
@ -64,12 +50,6 @@ 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;
@ -82,5 +62,5 @@ export function computeStrain(snap?: SimSnapshot): Strain {
}
const fever = hot > 0 ? clamp01(sum / hot) : 0;
return { tremor, fever, pallor };
return { tremor, fever };
}

View File

@ -19,7 +19,6 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantAllResearch } from './testkit';
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -34,7 +33,6 @@ const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops
return sim;
}
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {

View File

@ -1,173 +0,0 @@
/**
* LANE-SIM's tuning constants the single source of truth.
*
* These were private to index.ts until round 4. They're exported because other lanes'
* tests were hand-copying the numbers and going stale in silence: a copy of 0.7 doesn't
* fail when the sim moves to 0.65, it just quietly asserts the wrong thing forever.
* Import these rather than retyping them.
*
* Anything DATA can already set per-machine (heatPerTick, coolPerTick, bufferCap,
* beltSpeed, coolRadius) lives in data/machines.json, not here. These are the sim-wide
* rules that no machine overrides.
*/
// ------------------------------------------------------------------ belts
/** Minimum gap between two items, in belt-tiles. Enforced across the tile seam too. */
export const BELT_SPACING = 0.45;
/** Items a single belt tile may hold. */
export const BELT_CAPACITY = 2;
/** Items a splitter holds while it waits for an output to open. */
export const SPLITTER_CAPACITY = 2;
// -------------------------------------------------------------- machines
/** A machine stalls ('output full') once any output piles to this multiple of its yield. */
export const OUTPUT_STALL_FACTOR = 4;
/** A machine accepts an input up to this multiple of the recipe requirement. */
export const INPUT_BUFFER_FACTOR = 2;
// ------------------------------------------------------------------ heat
//
// heatPerTick is GROSS (contracts v4): net climb = heatPerTick - coolPerTick, so a
// machine whose heatPerTick never clears its cooling can never overheat.
/** Below this heat, throttling is purely cosmetic. */
export const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the codex's "tick rate visibly halves". */
export const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat at which a machine scrams. */
export const HEAT_SCRAM = 1;
/** Cool back below this and a scrammed machine restarts. */
export const HEAT_RESTART = 0.5;
/** Per-tick cooling when a machine's def doesn't specify `coolPerTick`. */
export const HEAT_COOL_DEFAULT = 0.004;
/**
* Ceiling on a machine's total cooling once auras stack (own rate + every cooler in
* range). At 0.05 a machine sheds a full 1.0 of heat in 20 ticks (~0.67s), which is
* already effectively instant past this, extra coolers would buy nothing but would
* still cost bandwidth and floor space, and stacking them would look broken rather than
* generous. See NOTES round 4.
*/
export const HEAT_COOL_MAX = 0.05;
// ---------------------------------------------------------------- research
/**
* Ticks a lab spends turning ONE delivered science pack into one unit of progress.
* Research is an investment, not a purchase: at 30 tps this is ~2s per pack, so a
* 10-pack tech is ~20s of lab time on one lab, or ~10s on two (labs parallelize).
* Stepped, not fractional a pack is consumed and progress ticks up by a whole 1 only
* when the timer completes, which keeps `research.progress` integer and the determinism
* trivial to reason about.
*/
export const RESEARCH_TICKS_PER_PACK = 60;
// ---------------------------------------------------------------- wildlife
//
// The dust→swarm loop, M2's living hazard. These item ids are the coupling to DATA: the
// pile grows from the dust a machine is HOLDING (so a factory that belts its dust away
// never hatches), and the trap is any machine whose recipe cans the swarm.
/** The item whose accumulation breeds swarms (codex: HF DUST "attracts and hatches"). */
export const DUST_ITEM = 'hf-dust';
/** The canned live-swarm item — a trap's output, and what commissions ask for. */
export const SWARM_ITEM = 'mosquito-swarm';
/**
* Pile growth per tick per unit of dust BACKED UP in a machine's output buffer where
* "backed up" means beyond one craft's worth (see DUST_SPILL_GRACE). A line that keeps its
* dust moving never crosses the grace threshold and never breeds; a line that ignores it
* climbs to the stall cap and hatches in roughly 400 ticks (~14s). That gap is the mechanic.
*/
export const DUST_GROWTH_PER_HELD = 0.00025;
/**
* A machine may hold this multiple of its own dust yield without shedding any on the floor.
* Normal buffering between belt pushes is not neglect, and punishing it would make the
* hazard feel like weather rather than a consequence.
*/
export const DUST_SPILL_GRACE = 1;
/** A pile at or above this hatches a swarm and is consumed. */
export const DUST_PILE_MAX = 1;
/** Tiles from the emitter a new pile appears (seeded RNG picks the exact offset). */
export const DUST_PILE_SPREAD = 2;
/** Swarm drift toward its target, tiles/sec. */
export const SWARM_DRIFT_TILES_PER_SEC = 1.5;
/** Within this chebyshev range of its target's footprint, a swarm is attached and biting. */
export const SWARM_ATTACH_RANGE = 1;
/** Speed multiplier a fully-severe attached swarm inflicts (0.5 = half speed). */
export const SWARM_SLOW_FLOOR = 0.5;
/** Severity gained per tick while attached, up to 1.0 — the longer it sits, the worse. */
export const SWARM_SEVERITY_GROWTH = 0.004;
/** A trap catches swarms within this chebyshev range of its footprint. */
export const TRAP_RADIUS = 4;
/**
* Live swarms allowed at once. A machine that is permanently jammed on dust would
* otherwise breed without limit measured 29 of them in a 20k demo run, which is a
* plague rather than a hazard. At the cap, full piles simply sit there waiting for room.
*/
export const SWARM_MAX = 8;
// -------------------------------------------------------- parity mites (M3)
//
// THE CORRECTION's first unit (codex §5): hospital-white beetle drones that patrol your
// belts and quietly *repair* tier-2 glitch products back into clean footage. They correct
// CORRUPTION, never raw material — that's both the fiction and the balance, so they only
// ever touch tier-2 cargo. Pressure scales with how much corruption you've shipped: the
// more product goes to THE SCREEN, the more the immune system notices you.
/** The canned-mite item a FIREWALL produces — forward-canon in data; the counter's cargo. */
export const MITE_ITEM = 'parity-mite';
/** Lowest item tier a mite will correct. Below this it's raw material, which they ignore. */
export const MITE_TIER_MIN = 2;
/**
* Tier-2 products shipped per patrolling mite. desiredMites = floor(corruption / this),
* capped at MITE_MAX so the wave escalates with progress and never appears before you've
* shipped real product. At 40 the first mite arrives well after the melt line is flowing.
*/
export const MITE_SPAWN_PER = 40;
/**
* Mites patrolling at once. Caught mites are replaced from the rim while corruption stays
* above threshold the firewall's job is to hold the line, not to win a finite fight.
*/
export const MITE_MAX = 6;
/** A mite drifts toward its chosen belt item at this speed, tiles/sec (it patrols briskly). */
export const MITE_DRIFT_TILES_PER_SEC = 2;
/** Within this chebyshev range of its target item, a mite reaches it and starts repairing. */
export const MITE_REACH_RANGE = 0.75;
/**
* Ticks a mite dwells on station, solder-iron working, before the correction completes. The
* dwell is the window a FIREWALL needs to can it mid-repair grab the mite here and the
* product survives. At 30 (~1s) it reads as "quiet, unhurried work" and gives an armed
* firewall time to snap it before the repair lands.
*/
export const MITE_REPAIR_TICKS = 30;
/** A firewall catches mites within this chebyshev range of its footprint (mirrors the trap). */
export const FIREWALL_RADIUS = 4;
// ------------------------------------------------------------------ relic
/** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */
export const RELIC_MIN_DIST = 18;
/** Farthest a buried relic sits from origin. */
export const RELIC_MAX_DIST = 28;
/**
* Keep-outs for relic placement, sized to enclose the reference factory's two works the
* western melt line and the eastern research annex so the demo can always dig its relic
* up. A real world has no factory here yet; this just keeps the secret clear of the ground
* everyone builds on. relic.test.ts checks, against the layout's real occupied tiles, that
* a spread of seeds never buries it. When DATA supplies a `relicReserve` the sim prefers
* that corner instead and these become belt-and-braces.
*/
export const RELIC_KEEPOUT = [
{ x0: -33, y0: -16, x1: 2, y1: 16 }, // the melt line
{ x0: 6, y0: -9, x1: 29, y1: 12 }, // the research annex
];
/** Chebyshev range within which an `excavate` uncovers the relic. */
export const RELIC_DIG_RANGE = 1;
// ---------------------------------------------------------------- tracing
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
export const MAX_TRACE = 512;

File diff suppressed because it is too large Load Diff

View File

@ -1,258 +0,0 @@
/**
* Parity mites v1 THE CORRECTION's first unit (codex §5).
*
* The design points these tests protect:
* - Mites are driven by CORRUPTION shipped (tier-2 product), not by a timer. Ship nothing
* and none arrive; the immune system only notices you once you're making product.
* - They correct corruption, never raw material tier-0/1 cargo is invisible to them.
* That's the balance as much as the fiction, and it's the first thing that would rot.
* - A FIREWALL is the counter, recognised by recipe SHAPE (empty inputs, cans a mite),
* exactly like the mosquito trap so DATA's real firewall lights up with zero code here.
*
* Fixture-based: DATA carries no firewall machine or `parity-mite` item yet (see NOTES round
* 6), so both are modelled here the way the orders describe. The sim keys off shape and tier,
* not ids, so the real data will drop straight in.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { FIREWALL_RADIUS, MITE_ITEM, MITE_MAX, MITE_SPAWN_PER } from './constants';
import type {
Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState,
} from '../contracts';
const N: Dir = 0, E: Dir = 1;
const item = (id: string, tier: number): ItemDef =>
({ id, name: id, codex: 'fixture', tier, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
// raw = tier-0 (raw material: mites must ignore it). product = tier-2 (corruption: mites eat).
const FIXTURE: GameData = {
items: [item('raw', 0), item('product', 2), item(MITE_ITEM, 2)],
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 3 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 100 }),
mach({ id: 'forge', kind: 'crafter', recipes: ['make'] }),
// The firewall: no inputs, cans a live mite. The sim spots it by recipe shape.
mach({ id: 'firewall', kind: 'crafter', recipes: ['burn'], powerDraw: 1 }),
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { raw: 1 }, ticks: 4 }),
rec({ id: 'make', machine: 'forge', inputs: { raw: 1 }, outputs: { product: 1 }, ticks: 4 }),
rec({ id: 'burn', machine: 'firewall', inputs: {}, outputs: { [MITE_ITEM]: 1 }, ticks: 20 }),
],
tech: [],
commissions: [{ id: 'c1', flavor: 'product', wants: { product: 99999 }, rewardBandwidth: 1 }],
};
function newSim(seed = 1): Sim {
const sim = createSim();
sim.init(FIXTURE, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const mites = (sim: Sim): WildlifeState[] =>
sim.snapshot().wildlife!.filter((w) => w.kind === 'parity-mite');
/**
* A factory near origin that ships tier-2 product: mine -> raw belt -> forge -> a long
* product belt -> shipper. The raw belt sits right at origin where mites converge, so
* "mites ignore raw" is a real test, not a vacuous one.
*/
function corruptionFactory(sim: Sim, opts: { firewall?: boolean } = {}): void {
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E)); // carries RAW (tier-0)
sim.enqueue(place('forge', 2, 0));
for (let x = 3; x <= 7; x++) sim.enqueue(place('belt', x, 0, E)); // carries PRODUCT (tier-2)
sim.enqueue(place('ship', 8, 0));
if (opts.firewall) sim.enqueue(place('firewall', 5, 2)); // guards the whole short product line
}
describe('parity mites — corruption drives them', () => {
it('do not appear until enough corruption has shipped', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
// Run until the first mite spawns, tracking corruption at that moment.
let shippedAtSpawn = -1;
for (let i = 0; i < 20000 && shippedAtSpawn < 0; i++) {
run(sim, 1, evs);
if (mites(sim).length > 0) shippedAtSpawn = sim.snapshot().shippedTotal['product'] ?? 0;
}
expect(shippedAtSpawn, 'no mite ever spawned').toBeGreaterThanOrEqual(0);
// A mite only arrives once corruption crosses the threshold — never on tick 0.
expect(shippedAtSpawn).toBeGreaterThanOrEqual(MITE_SPAWN_PER);
});
it('a factory that ships NO corruption is never noticed', () => {
// Same machines, but the product belt is severed before the shipper: nothing ships.
const sim = newSim();
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('forge', 2, 0));
// product piles in the forge and on a stub belt; none reaches a shipper.
sim.enqueue(place('belt', 3, 0, E));
run(sim, 8000);
expect(sim.snapshot().shippedTotal['product'] ?? 0).toBe(0);
expect(mites(sim)).toHaveLength(0);
});
});
describe('parity mites — what they correct', () => {
it('seek a tier-2 product on a belt and repair it out of existence', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
run(sim, 6000, evs);
const repairs = evs.filter((e) => e.kind === 'repaired');
expect(repairs.length, 'no product was ever repaired').toBeGreaterThan(0);
// Every correction is a product (tier-2), and the event carries the item and a position.
for (const r of repairs) {
expect(r.kind).toBe('repaired');
if (r.kind === 'repaired') {
expect(r.item).toBe('product');
expect(r.pos).toBeDefined();
}
}
});
it('THE BALANCE POINT: never touch tier-0/1 cargo', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
// The raw belt at origin is thick with tier-0 items the whole run, and mites pass right
// over it to reach the product line — yet raw is never corrected.
run(sim, 12000, evs);
const raw = evs.filter((e) => e.kind === 'repaired' && e.item === 'raw');
expect(raw).toHaveLength(0);
// And there really WERE raw items sitting under the mites' path (not vacuously true).
expect(sim.snapshot().beltItems.some((b) => b.item === 'raw')).toBe(true);
});
});
describe('parity mites — escalation and cap', () => {
it('scale with corruption but never exceed MITE_MAX', () => {
const sim = newSim();
corruptionFactory(sim);
let peak = 0;
for (let i = 0; i < 40000; i++) {
run(sim, 1);
peak = Math.max(peak, mites(sim).length);
}
// Deep into a heavily-corrupting run the patrol saturates the cap, and never breaches it.
expect(peak).toBe(MITE_MAX);
const shipped = sim.snapshot().shippedTotal['product'] ?? 0;
expect(shipped).toBeGreaterThan(MITE_SPAWN_PER * MITE_MAX); // corruption was well past the cap
});
});
describe('the firewall — the counter', () => {
it('cans a mite that patrols into range, and the catch is cargo', () => {
const sim = newSim();
corruptionFactory(sim, { firewall: true });
const evs: SimEvent[] = [];
run(sim, 12000, evs);
// A firewall in range of the product line cans mites: the canned mite is a real item.
const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0);
// Those catches show up as wildlife cleared events for parity-mites.
const caught = evs.filter(
(e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'parity-mite',
);
expect(caught.length).toBeGreaterThan(0);
});
it('with no mites in the world, a firewall just waits — it never cans air', () => {
// Sever the product line before the shipper: nothing ships, so corruption stays 0 and no
// mite ever arrives. The firewall has nothing to catch and holds honestly at 'starved'.
const sim = newSim();
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('forge', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('firewall', 6, 0));
run(sim, 12000);
expect(mites(sim)).toHaveLength(0); // precondition: nothing to catch
const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBe(0);
expect(fw.jammed).toBe('starved'); // an idle firewall is waiting, not broken
});
it('guarding the approach cancels corrections — the counter works', () => {
const guarded = newSim(7);
corruptionFactory(guarded, { firewall: true });
const gEvs: SimEvent[] = [];
run(guarded, 20000, gEvs);
const open = newSim(7);
corruptionFactory(open);
const oEvs: SimEvent[] = [];
run(open, 20000, oEvs);
// Same seed, same layout but for the firewall. The firewall cans mites on the approach,
// so far fewer products are corrected mid-belt than on the undefended line.
const guardedRepairs = gEvs.filter((e) => e.kind === 'repaired').length;
const openRepairs = oEvs.filter((e) => e.kind === 'repaired').length;
expect(openRepairs).toBeGreaterThan(0); // the undefended line really was being corrected
expect(guardedRepairs).toBeLessThan(openRepairs);
// And the guard did its job by canning mites, not by luck.
const fw = guarded.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0);
});
it('FIREWALL_RADIUS is a real, bounded reach', () => {
expect(FIREWALL_RADIUS).toBeGreaterThan(0);
expect(Number.isFinite(FIREWALL_RADIUS)).toBe(true);
});
});
describe('parity mites — determinism and persistence', () => {
it('two sims with the same seed run the same wave', () => {
const build = (): Sim => {
const sim = newSim(4242);
corruptionFactory(sim, { firewall: true });
run(sim, 10000);
return sim;
};
const a = build();
const b = build();
expect(mites(a).length + (a.snapshot().shippedTotal[MITE_ITEM] ? 1 : 0)).toBeGreaterThanOrEqual(0);
expect(b.snapshot()).toEqual(a.snapshot());
});
it('survives save/load mid-wave, and the future still matches', () => {
const a = newSim(77);
corruptionFactory(a);
run(a, 7000);
expect(mites(a).length, 'no wave to persist').toBeGreaterThan(0);
const b = newSim(77);
b.load(a.save());
expect(b.snapshot().wildlife).toEqual(a.snapshot().wildlife);
run(a, 5000);
run(b, 5000);
expect(b.snapshot()).toEqual(a.snapshot());
});
});

View File

@ -9,7 +9,6 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import { grantAllResearch } from './testkit';
import type { GameData, Sim } from '../contracts';
import items from '../../data/items.json';
@ -23,7 +22,6 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
function fresh(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the reference factory is a post-research build
return sim;
}
function run(sim: Sim, ticks: number): void {

View File

@ -8,7 +8,6 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants';
import type {
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';
@ -33,9 +32,6 @@ const FIXTURE: GameData = {
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
// v4 spatial cooling: radiates its coolPerTick to anything within 2 tiles.
mach({ id: 'cooler', kind: 'power', powerGen: 0, coolPerTick: 0.01, coolRadius: 2 }),
mach({ id: 'bigcooler', kind: 'power', powerGen: 0, coolPerTick: 0.04, coolRadius: 1 }),
mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
@ -273,95 +269,6 @@ describe('heat', () => {
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
});
/** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */
const ticksToScram = (sim: Sim, limit = 1500): number => {
for (let i = 0; i < limit; i++) {
const evs: SimEvent[] = [];
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1;
}
return -1;
};
describe('spatial cooling', () => {
it('a cooler in range extends a hot generators run before it scrams', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling
const cooled = newSim();
cooled.enqueue(place('hotgen', 0, 0));
cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius
const withAura = ticksToScram(cooled);
expect(withAura).toBeGreaterThan(0);
// 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run.
expect(withAura).toBeGreaterThan(alone * 2);
});
it('the aura is bounded — a cooler out of range does nothing', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
const far = newSim();
far.enqueue(place('hotgen', 0, 0));
far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away
expect(ticksToScram(far)).toBe(alone);
expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat);
});
it('enough cooling in range stops it overheating at all', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross
const evs: SimEvent[] = [];
run(sim, 2000, evs);
expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0);
expect(at(sim, 0, 0)!.heat).toBe(0);
expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped
});
it('auras stack by sum, and the total is capped however many you pile on', () => {
// The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat
// moves by exactly -cooling. That makes the shed rate directly measurable.
const shedRate = (coolers: Array<[number, number]>): number => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('oven', 2, 0));
for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y));
for (let i = 0; i < 400; i++) {
run(sim, 1);
if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed
}
const before = at(sim, 2, 0)!.heat;
run(sim, 1);
return before - at(sim, 2, 0)!.heat;
};
expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6);
// One in range: own default + the aura, summed.
expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6);
// Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX.
const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]);
expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6);
expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites
});
it('removing a cooler puts the heat back', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0));
run(sim, 300);
expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura
sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } });
run(sim, 200);
expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again
});
});
it('leaves cold machines alone', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0)); // no heatPerTick

View File

@ -1,171 +0,0 @@
/**
* A real research run: raw ore in one end, a completed tech out the other, using nothing
* but DATA's own machines, recipes and tech tree. No fixture, no granted state.
*
* This exists because the tree was, earlier this round, impossible to enter: there was no
* lab, and every science pack was made on the artifact-bottler, which was itself locked
* behind a tech costing 10 analog-packs. DATA fixed both mid-round. This test is the proof
* that the fix holds, and the tripwire if the bootstrap ever closes over again.
*
* ore -> demuxer -+-> luma -> quantizer -> coefficient packs -> [split] -+-> crime -> bricks -+
* | +-> ringing -> halos -+
* +-> chroma slurry ------------------------------------------------------------+
* +-> mv-flux -> uplink |
* bricks + halos + slurry -> artifact bottler -> SPATIAL PACK
* -> archaeology lab -> tech
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type { Command, Dir, GameData, Sim, SimEvent, TechDef } from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import techJson from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/** The cheapest tech payable in packs the factory below can actually make. */
function cheapestSpatialTech(): TechDef | undefined {
return DATA.tech
.filter((t) => Object.keys(t.cost).length === 1 && t.cost['spatial-pack'] > 0)
.sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
}
function packFactory(): Command[] {
const cmds: Command[] = [];
const P = (def: string, x: number, y: number, dir: Dir = N) =>
cmds.push({ kind: 'place', def, pos: { x, y }, dir });
const b = (x: number, y: number, dir: Dir) => P('belt', x, y, dir);
const runX = (x0: number, x1: number, y: number, dir: Dir) => {
const s = x0 <= x1 ? 1 : -1;
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
};
const runY = (y0: number, y1: number, x: number, dir: Dir) => {
const s = y0 <= y1 ? 1 : -1;
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
};
const recipeAt = (x: number, y: number, recipe: string) =>
cmds.push({ kind: 'setRecipeAt', pos: { x, y }, recipe });
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -8);
// ore
P('seam-extractor', -30, 0);
P('seam-extractor', -30, -3);
P('seam-extractor', -30, 3);
runY(-2, -1, -28, S);
runY(3, 1, -28, N);
runX(-28, -27, 0, E);
P('demuxer', -26, 0);
// mv-flux has no consumer here — sell it, or the demuxer jams on its own byproduct
runY(-1, -2, -25, N);
P('shipper', -26, -4);
// luma -> coefficient packs
runX(-24, -23, 0, E);
P('quantizer', -22, 0); // 'quantize' is the default recipe
runY(-1, -2, -22, N); // hf dust -> uplink
P('shipper', -23, -4);
b(-20, 0, E);
P('lane-splitter', -19, 0); // coefficient packs: bricks one way, halos the other
// 1 pack of bricks per craft vs 3 coefficient packs per halo — the splitter's
// skip-when-blocked does the balancing, as ever.
b(-18, 0, E);
P('quantizer', -17, 0);
recipeAt(-17, 0, 'quantize-crime'); // -> macroblock bricks
runY(-1, -2, -17, N);
P('shipper', -18, -4);
runX(-15, -14, 0, E); // bricks -> bottler
runY(1, 2, -19, S);
P('quantizer', -20, 3);
recipeAt(-20, 3, 'skim-ringing'); // -> ringing halos
runX(-21, -22, 3, W); // its hf dust -> uplink
P('shipper', -24, 2);
runX(-18, -15, 3, E); // halos -> bottler
runY(3, 2, -14, N);
b(-14, 1, E);
// chroma slurry -> bottler, the long way round below the works
runY(2, 5, -26, S);
b(-26, 6, E);
runX(-25, -14, 6, E);
b(-13, 6, N);
runY(5, 2, -13, N);
P('artifact-bottler', -13, 0);
recipeAt(-13, 0, 'bottle-spatial-pack');
runX(-11, -10, 0, E);
P('archaeology-lab', -9, 0);
return cmds;
}
describe('a real research run, on DATAs own tree', () => {
it('turns ore into a science pack and a science pack into a tech', () => {
const target = cheapestSpatialTech();
expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined();
const sim: Sim = createSim();
sim.init(DATA, 1337);
// Nothing granted: every machine below must be legal from a cold start.
expect(sim.snapshot().research!.unlocked).toEqual([]);
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target!.id });
const evs: SimEvent[] = [];
let done = -1;
for (let i = 0; i < 12000 && done < 0; i++) {
sim.tick();
for (const e of sim.drainEvents()) {
evs.push(e);
if (e.kind === 'researched') done = e.tick;
}
}
// Every machine the layout asked for was legal from a cold start.
expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined();
expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined();
// The ladder ran, rung by rung, on real recipes.
for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
.toBeGreaterThan(0);
}
expect(done, `${target!.id} never completed`).toBeGreaterThan(0);
expect(sim.snapshot().research!.unlocked).toEqual([target!.id]);
expect(sim.snapshot().research!.active).toBeNull();
});
it('opens what the tech promised: a recipe refused before is accepted after', () => {
const target = cheapestSpatialTech()!;
const unlockedRecipe = target.unlocks.find((u) =>
DATA.recipes.some((r) => r.id === u))!;
expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined();
const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine;
const sim: Sim = createSim();
sim.init(DATA, 1337);
sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N });
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!;
expect(before.recipe).not.toBe(unlockedRecipe); // silently refused
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target.id });
for (let i = 0; i < 12000; i++) {
sim.tick();
if (sim.drainEvents().some((e) => e.kind === 'researched')) break;
}
expect(sim.snapshot().research!.unlocked).toEqual([target.id]);
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe);
});
});

View File

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory, referenceFactoryTech } from './reference';
import { grantResearch } from './testkit';
import { referenceFactory } from './reference';
import type { GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -9,16 +8,12 @@ import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';
import seamsJson from '../../data/seams.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function build(): Sim {
const sim = createSim();
sim.init(DATA, 1337);
// A post-research save: v4 gates two of this layout's ids. Grant exactly what it needs
// and nothing else, so the test would notice if the layout started depending on more.
grantResearch(sim, referenceFactoryTech(DATA));
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
return sim;
}
@ -64,25 +59,14 @@ describe('reference factory', () => {
it('keeps the demuxer alive by voiding surplus rather than jamming', () => {
const sim = build();
meltIn(sim, 19400);
// The whole point: the head of the chain never WEDGES. It back-pressures transiently as
// the press/assembly loop cycles (~18% of ticks, mites or no mites — measured), so a
// single-tick sample is a coin-flip; a wedge (round 1) would be ~100% stuck. Sample a
// window and assert it flows the large majority of the time.
let flowing = 0;
for (let i = 0; i < 600; i++) {
sim.tick();
sim.drainEvents();
if (sim.snapshot().entities.find((e) => e.def === 'demuxer')!.jammed !== 'output full') flowing++;
}
expect(flowing).toBeGreaterThan(300); // majority flowing = not wedged
meltIn(sim, 20000);
const snap = sim.snapshot();
const demuxer = snap.entities.find((e) => e.def === 'demuxer')!;
// The whole point: the head of the chain never wedges.
expect(demuxer.jammed).not.toBe('output full');
// Surplus is being turned into product, not silently dropped.
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
// v4: the slab overflow tap is live, so the demo visibly ships anchor slabs without
// anyone having to demolish the i-only assembler to see one.
expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0);
});
it('refuses to build against drifted data instead of deadlocking mysteriously', () => {
@ -90,85 +74,3 @@ describe('reference factory', () => {
expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/);
});
});
describe('what the reference build costs in research', () => {
it('names exactly the techs it needs, and needs at least one', () => {
const needed = referenceFactoryTech(DATA);
expect(needed.length).toBeGreaterThan(0); // v4 gating is live; this is a late-game build
// Every named tech is real, and granting just these is enough to build the whole thing.
for (const id of needed) expect(DATA.tech.some((t) => t.id === id)).toBe(true);
const sim = createSim();
sim.init(DATA, 1337);
grantResearch(sim, needed);
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
sim.tick();
const placed = sim.snapshot().entities.length;
const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length;
expect(placed, 'every placement in the layout must be legal once its techs are open')
.toBe(asked);
});
it('is silently crippled without them — the demo hook must grant first', () => {
const sim = createSim();
sim.init(DATA, 1337); // cold start: nothing researched
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
sim.tick();
const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length;
// Gated placements are dropped without a word, which is the contract's rule — the
// factory simply comes up missing its power and browns out.
expect(sim.snapshot().entities.length).toBeLessThan(asked);
expect(sim.snapshot().entities.some((e) => e.def === 'software-decoder')).toBe(false);
});
});
describe('the research annex (v5)', () => {
it('mines its own seam, bottles packs and completes a tech inside 20k', () => {
const sim = build();
const evs: SimEvent[] = [];
for (let i = 0; i < 20000; i++) {
sim.tick();
for (const e of sim.drainEvents()) evs.push(e);
}
// The whole chain ran on real data: ore -> luma -> coefficients -> bricks AND halos
// -> a bottled pack -> a lab -> a tech.
for (const r of ['quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
.toBeGreaterThan(0);
}
const done = evs.filter((e) => e.kind === 'researched');
expect(done.length, 'the demo never finished a tech').toBeGreaterThan(0);
expect(sim.snapshot().research!.unlocked.length).toBeGreaterThan(
referenceFactoryTech(DATA).length, // more than just what was granted to build it
);
});
it('every placement is legal — nothing silently collides or lands off-seam', () => {
const sim = build();
sim.tick();
const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length;
expect(sim.snapshot().entities.length).toBe(asked);
// And no extractor is sitting on dead ground once seams are wired in.
for (const e of sim.snapshot().entities.filter((q) => q.def === 'seam-extractor')) {
expect(e.jammed, `extractor at ${e.pos.x},${e.pos.y}`).not.toBe('no seam');
}
});
it('sits on DATAs seams — both works are on real ore', () => {
// Read the seam map straight off disk: it is not wired into GameData yet, so this is
// the only way to check the layout against the ground DATA actually authored.
const rects = (seamsJson as { seams: Array<{ rect: { x: number; y: number; w: number; h: number } }> }).seams;
const extractors = referenceFactory(DATA).filter(
(c) => c.kind === 'place' && c.def === 'seam-extractor',
) as Array<{ pos: { x: number; y: number } }>;
expect(extractors.length).toBeGreaterThan(0);
const fp = DATA.machines.find((m) => m.id === 'seam-extractor')!.footprint;
for (const e of extractors) {
const on = rects.some(({ rect: s }) =>
e.pos.x + fp.x - 1 >= s.x && e.pos.x <= s.x + s.w - 1
&& e.pos.y + fp.y - 1 >= s.y && e.pos.y <= s.y + s.h - 1);
expect(on, `extractor at ${e.pos.x},${e.pos.y} is not on any authored seam`).toBe(true);
}
});
});

View File

@ -29,44 +29,9 @@
* (i-only) -> mosh
*/
import type { Command, Dir, GameData } from '../contracts';
import { MITE_ITEM } from './constants';
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/**
* The firewall the sim recognises by recipe SHAPE empty inputs, cans a mite. Returns its
* machine + recipe id if DATA has authored one yet, else null. The reference layout places it
* only when it exists, so the demo lights up its own defence the round the counter lands (see
* NOTES round 6) and simply weathers the wave until then.
*/
export function firewallInData(data: GameData): { machine: string; recipe: string } | null {
for (const r of data.recipes) {
if (Object.keys(r.inputs).length !== 0) continue;
if ((r.outputs[MITE_ITEM] ?? 0) <= 0) continue;
if (data.machines.some((m) => m.id === r.machine)) return { machine: r.machine, recipe: r.id };
}
return null;
}
/**
* The techs this layout needs before it can be built at all derived from the commands
* themselves, so it stays true if either the layout or DATA's tree moves.
*
* Contracts v4 gates every id a tech names, and this build uses two of them, so it is a
* post-research save rather than a cold start. Anything driving it (a test, the dev demo
* hook) has to open these first or the commands are silently dropped and the factory comes
* up underpowered measured: the reactor's uplink runs at 100 draw against 80 gen and the
* whole sector browns out. See NOTES round 4.
*/
export function referenceFactoryTech(data: GameData): string[] {
const needs = new Set<string>();
for (const c of referenceFactory(data)) {
if (c.kind === 'place') needs.add(c.def);
else if (c.kind === 'setRecipe' && c.recipe !== null) needs.add(c.recipe);
}
return data.tech.filter((t) => t.unlocks.some((u) => needs.has(u))).map((t) => t.id);
}
export function referenceFactory(data: GameData): Command[] {
// Fail loudly and specifically if DATA moves under us — a missing id here would
// otherwise surface as a mystery deadlock hours later.
@ -186,109 +151,15 @@ export function referenceFactory(data: GameData): Command[] {
P('gop-assembler', -10, 0); // assemble-gop (default)
runX(-7, -6, 0, E);
P('mosh-reactor', -5, 0);
// TWO melt lanes, and the second one is not decoration. The reactor pushes one melt and
// one slab per tick, so with a single lane melt #2 finds its own belt full, sees the slab
// lane also ends at an uplink (rank 1, same as its own), and takes it. The splitter down
// there then locks into perfect alternation with the reactor's push order — slab to the
// assembler, melt to the uplink, forever — and no slab is ever sold. It survives 20,000
// ticks looking correct. A second lane means melt never wants the slab lane at all.
runX(-2, -1, 0, E);
runX(-2, -1, 1, E);
runX(-2, -1, 0, E); // melt -> uplink
P('shipper', 0, 0);
// ---- THE FIREWALL (v6). Once you ship real product, THE CORRECTION notices and sends
// parity mites — beetle drones that "repair" your melt into footage of a lake. A firewall
// on the uplink approach cans them (radius FIREWALL_RADIUS) before they reach the melt
// belts, which is the layout lesson: guard what you ship. Placed only if DATA has authored
// the machine; until then the two melt lanes simply out-run the trickle. See NOTES round 6.
const fw = firewallInData(data);
if (fw) {
const guard = P(fw.machine, -2, -3); // 2x2, guarding the melt belts (radius FIREWALL_RADIUS)
setRecipe(guard, fw.recipe);
b(-3, -3, W); // canned mites ride west to a shipper, or the firewall fills at 4 and
P('shipper', -5, -4); // stops catching. (Yes: a shipped mite is corruption — but a handful
// over 20k against ~900 bricks is a rounding error on the wave.)
}
// Recovered anchor slabs come back out of the reactor, and they go two places: the
// i-only assembler turns 8 of them into another GOP crate (more melt), and the rest are
// sold. The splitter is what makes it "and" rather than "or" — half the slabs to each,
// and if the assembler backs up, all of them go out the door instead of wedging the
// reactor. Selling them is also the only way the demo ever SHIPS a slab, which is what
// the uplink fiction upstairs is about.
b(-5, 3, S);
P('lane-splitter', -5, 4);
b(-5, 5, S);
const iOnly = P('gop-assembler', -6, 6);
// Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
// i-only assembler: 8 slabs -> another GOP crate -> more melt.
runY(3, 4, -5, S);
const iOnly = P('gop-assembler', -6, 5);
setRecipe(iOnly, 'assemble-gop-i-only');
runY(6, 3, -3, N); // its crates rejoin the reactor
runX(-6, -7, 4, W); // the overflow: slabs for sale
P('shipper', -9, 3);
// ---- THE RESEARCH ANNEX (v5), on the eastern seam.
//
// Deliberately a separate works rather than a tap off the melt line. The western
// floorplan has no room left — every column between the quantizers and the press is
// spoken for — and threading three ingredients through it would have made the melt
// regression hostage to the science. DATA's second mdat seam (stream-crust-east,
// x 8..13, y -4..3) is empty ground, so the annex mines its own ore and shares only
// the bandwidth bus.
//
// ore -> demuxer -+-> luma -> quantizer -+-> crime -> BRICKS -+
// | +-> ringing -> HALOS -+-> bottler -> SPATIAL
// +-> slurry ----------------------------------+ PACK -> lab
// +-> mv-flux -> uplink (nothing here wants it)
for (let i = 0; i < 2; i++) P('decode-asic', 16 + i * 3, -7); // the annex pays its own way
P('seam-extractor', 8, -4);
P('seam-extractor', 8, -1);
P('seam-extractor', 8, 2);
runY(-3, 3, 10, S); // collector column down the seam's east face
b(10, 4, E);
b(11, 4, E);
P('demuxer', 12, 4);
runX(14, 15, 4, E); // luma
P('quantizer', 16, 4); // 'quantize' by default -> coefficient packs
b(16, 3, N); // its dust
P('shipper', 15, 1);
b(18, 4, E);
P('lane-splitter', 19, 4); // coefficient packs fork: bricks one way, halos the other
b(20, 4, E);
const annexCrime = P('quantizer', 21, 4);
setRecipe(annexCrime, 'quantize-crime');
runY(5, 6, 19, S);
const annexRing = P('quantizer', 18, 7);
setRecipe(annexRing, 'skim-ringing');
runX(23, 24, 4, E); // crime's dust
b(24, 5, S);
P('shipper', 24, 6);
runX(17, 16, 7, W); // ringing's dust
P('shipper', 14, 6);
const bottler = P('artifact-bottler', 21, 8);
setRecipe(bottler, 'bottle-spatial-pack');
runY(6, 7, 21, S); // crime's bricks drop into the bottler
b(20, 8, E); // ringing's halos come in from the west
runY(6, 9, 13, S); // slurry takes the long way round the works
runX(13, 20, 10, E);
b(21, 10, N);
runY(6, 8, 12, S); // mv-flux has no consumer out here — sell it, or the
P('shipper', 11, 9); // demuxer jams on its own byproduct
runX(23, 24, 8, E); // the packs, at last
P('archaeology-lab', 25, 8);
// Aim the labs at the cheapest thing they can actually pay for, so the demo researches
// something rather than sitting on a full pack rack.
const target = data.tech
.filter((t) => Object.keys(t.cost).length === 1 && (t.cost['spatial-pack'] ?? 0) > 0)
.sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
if (target) cmds.push({ kind: 'setResearch', tech: target.id });
runY(5, 3, -3, N);
return cmds;
}

View File

@ -1,260 +0,0 @@
/**
* The relic and the ground it's buried in seams, and the one secret in the world.
*
* The relic is the only content in FKTRY with no lock but looking: no research gate, no
* bandwidth cost, no UI affordance. So the tests that matter are about it being *findable
* but not signposted*: derived from the seed, never on a seam (you'd hit it mining, which
* would be an accident rather than a discovery), never under where the demo builds, and
* once found, found forever.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory, referenceFactoryTech } from './reference';
import { grantResearch } from './testkit';
import { RELIC_MAX_DIST, RELIC_MIN_DIST } from './constants';
import type { Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent } from '../contracts';
import type { SeamDef, SeamMap } from './index';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1;
/** GameData plus the seam map — the shape once the orchestrator wires GameData.seams. */
// Omit first: contracts v7 types GameData.seams as SeamMap, but the sim deliberately
// also accepts a bare rect list (defensive path) — this test covers both forms.
type Seamed = Omit<GameData, 'seams'> & { seams?: SeamMap | SeamDef[] };
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const seamRect = (id: string, x: number, y: number, w: number, h: number): SeamDef =>
({ id, rect: { x, y, w, h } });
const FIXTURE: Seamed = {
items: ['rock'].map(item),
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), // 1x1
mach({ id: 'bigmine', kind: 'extractor', recipes: ['dig'], footprint: { x: 2, y: 2 } }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
mach({ id: 'ship', kind: 'shipper' }),
],
recipes: [rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 })],
tech: [],
commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 99 }, rewardBandwidth: 1 }],
};
function newSim(data: Seamed = FIXTURE, seed = 1): Sim {
const sim = createSim();
sim.init(data as GameData, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const at = (sim: Sim, x: number, y: number) =>
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
describe('seams', () => {
it('lets an extractor on a seam mine, and refuses one off it', () => {
const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } };
const sim = newSim(data);
sim.enqueue(place('mine', 1, 1)); // inside the patch
sim.enqueue(place('mine', 20, 20)); // out in the dead ground
const evs: SimEvent[] = [];
run(sim, 200, evs);
expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
expect(at(sim, 20, 20)!.outputBuf['rock'] ?? 0).toBe(0);
expect(at(sim, 20, 20)!.jammed).toBe('no seam');
// Edge-triggered like every other jam: said once, not once per tick.
expect(evs.filter((e) => e.kind === 'jammed' && e.reason === 'no seam')).toHaveLength(1);
});
it('counts a footprint that only clips the seam corner', () => {
// 2x2 at (3,3) covers (3,3)..(4,4); the seam is (0,0)..(3,3) — exactly one tile of
// overlap, which is enough. At (5,5) it clears the seam entirely and starves.
const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } };
const sim = newSim(data);
sim.enqueue(place('bigmine', 3, 3));
sim.enqueue(place('bigmine', 5, 5));
run(sim, 200);
expect(at(sim, 3, 3)!.jammed).not.toBe('no seam');
expect(at(sim, 3, 3)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
expect(at(sim, 5, 5)!.jammed).toBe('no seam');
});
it('mines anywhere when no seam map is authored — the pre-seams world still works', () => {
const sim = newSim(); // FIXTURE has no seams key at all
sim.enqueue(place('mine', 25, 25));
run(sim, 200);
expect(at(sim, 25, 25)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
expect(at(sim, 25, 25)!.jammed).not.toBe('no seam');
});
it('reads DATAs real seams.json shape as well as a bare rect list', () => {
const nested: Seamed = { ...FIXTURE, seams: { relicReserve: { x: 12, y: 12, w: 20, h: 20 }, seams: [seamRect('a', 0, 0, 3, 3)] } };
const bare: Seamed = { ...FIXTURE, seams: [seamRect('a', 0, 0, 3, 3)] };
for (const data of [nested, bare]) {
const sim = newSim(data);
sim.enqueue(place('mine', 1, 1));
sim.enqueue(place('mine', 20, 20));
run(sim, 100);
expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
expect(at(sim, 20, 20)!.jammed).toBe('no seam');
}
});
});
describe('the relic', () => {
it('is always present, unfound, and in the band — for any seed', () => {
for (const seed of [1, 7, 1337, 90210, 424242]) {
const sim = newSim(FIXTURE, seed);
const relics = sim.snapshot().relics!;
expect(relics).toHaveLength(1);
const r = relics[0];
expect(r.kind).toBe('optical-fossil');
expect(r.found).toBe(false);
const d = Math.hypot(r.pos.x, r.pos.y);
expect(d, `seed ${seed} put the relic at ${d.toFixed(1)}`)
.toBeGreaterThanOrEqual(RELIC_MIN_DIST);
expect(d).toBeLessThanOrEqual(RELIC_MAX_DIST);
}
});
it('is a pure function of the seed — same seed same spot, different seeds move it', () => {
const posFor = (seed: number) => JSON.stringify(newSim(FIXTURE, seed).snapshot().relics![0].pos);
expect(posFor(1337)).toBe(posFor(1337));
const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(posFor));
expect(spread.size, 'the relic must not sit in the same hole every world')
.toBeGreaterThan(1);
});
it('never buries itself in a seam', () => {
const data: Seamed = {
...FIXTURE,
// Ring the whole legal band in seams bar the SE corner DATA reserves.
seams: {
relicReserve: { x: 12, y: 12, w: 20, h: 20 },
seams: [
seamRect('n', -32, -32, 64, 44), seamRect('w', -32, -32, 44, 64),
seamRect('s', -32, 24, 64, 8),
],
},
};
for (const seed of [1, 2, 3, 42, 1337, 5150]) {
const sim = newSim(data, seed);
const { x, y } = sim.snapshot().relics![0].pos;
const onSeam = (data.seams as SeamMap).seams.some(
({ rect: s }) => x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h,
);
expect(onSeam, `seed ${seed}: relic at ${x},${y} landed in a seam`).toBe(false);
}
});
it('never lands under a reference-factory machine', () => {
// Every tile the demo actually occupies, computed from the layout rather than assumed.
// A bounding box would be wrong now that the factory has an eastern annex: the gap
// between the two works is legitimate ground to bury something in.
const taken = new Set<string>();
for (const c of referenceFactory(DATA)) {
if (c.kind !== 'place') continue;
const m = DATA.machines.find((q) => q.id === c.def)!;
for (let dx = 0; dx < m.footprint.x; dx++) {
for (let dy = 0; dy < m.footprint.y; dy++) taken.add(`${c.pos.x + dx},${c.pos.y + dy}`);
}
}
expect(taken.size).toBeGreaterThan(100); // the layout really was walked
for (let seed = 1; seed <= 60; seed++) {
const { x, y } = newSim(DATA as Seamed, seed).snapshot().relics![0].pos;
expect(taken.has(`${x},${y}`), `seed ${seed}: relic at ${x},${y} is buried under the demo`)
.toBe(false);
}
});
it('is dug up by excavating next to it, and ignores digs anywhere else', () => {
const sim = newSim(FIXTURE, 1337);
const { pos } = sim.snapshot().relics![0];
sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 5, y: pos.y } }); // cold
const cold: SimEvent[] = [];
run(sim, 1, cold);
expect(sim.snapshot().relics![0].found).toBe(false);
expect(cold.filter((e) => e.kind === 'relicFound')).toHaveLength(0);
sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 1, y: pos.y - 1 } }); // within a tile
const warm: SimEvent[] = [];
run(sim, 1, warm);
expect(sim.snapshot().relics![0].found).toBe(true);
const found = warm.filter((e) => e.kind === 'relicFound');
expect(found).toHaveLength(1);
expect(found[0]).toMatchObject({ relic: 'optical-fossil' });
// Digging it up twice doesn't re-announce it.
sim.enqueue({ kind: 'excavate', pos: { x: pos.x, y: pos.y } });
const again: SimEvent[] = [];
run(sim, 1, again);
expect(again.filter((e) => e.kind === 'relicFound')).toHaveLength(0);
});
it('stays found through save, load and 20,000 further ticks', () => {
const a = newSim(FIXTURE, 1337);
const { pos } = a.snapshot().relics![0];
a.enqueue({ kind: 'excavate', pos });
run(a, 1);
expect(a.snapshot().relics![0].found).toBe(true);
const b = newSim(FIXTURE, 1337);
b.load(a.save());
expect(b.snapshot().relics![0].found).toBe(true);
expect(b.snapshot().relics![0].pos).toEqual(pos);
run(a, 20000);
run(b, 20000);
expect(b.snapshot().relics![0].found).toBe(true);
expect(b.snapshot()).toEqual(a.snapshot());
});
it('an unfound relic survives save/load too — the secret keeps', () => {
const a = newSim(FIXTURE, 99);
const before = a.snapshot().relics![0];
const b = newSim(FIXTURE, 99);
b.load(a.save());
expect(b.snapshot().relics![0]).toEqual(before);
expect(b.snapshot().relics![0].found).toBe(false);
});
it('is reachable in the real demo world — the reserve DATA keeps actually works', () => {
const sim = createSim();
sim.init(DATA, 1337);
grantResearch(sim, referenceFactoryTech(DATA));
for (const c of referenceFactory(DATA)) sim.enqueue(c);
run(sim, 100);
const relic = sim.snapshot().relics![0];
expect(relic.found).toBe(false);
// Nothing was built on top of it, so a dig there lands.
sim.enqueue({ kind: 'excavate', pos: relic.pos });
const evs: SimEvent[] = [];
run(sim, 1, evs);
expect(evs.filter((e) => e.kind === 'relicFound')).toHaveLength(1);
});
});

View File

@ -1,449 +0,0 @@
/**
* Research v1 and the v4 gating rule.
*
* The mechanics run on a fixture; the gating rule is checked against DATA's real tech.json.
* The fixture exists so these tests pin the RULE rather than DATA's numbers which moved
* twice while this round was being written. For the same path proved end-to-end on the real
* tree, with a real lab and real science packs, see realtech.test.ts.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantResearch } from './testkit';
import { RESEARCH_TICKS_PER_PACK } from './constants';
import type {
Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, TechDef,
} from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import techJson from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const REAL = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2;
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const tech = (t: TechDef): TechDef => t;
/**
* A tree that can actually be entered: the pack press is ungated, so packs exist before
* any research does. (This is exactly the property DATA's real tree is missing.)
*/
const FIXTURE: GameData = {
items: ['rock', 'pack', 'gold'].map(item),
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }), // ungated
mach({ id: 'lab', kind: 'lab' }), // ungated
mach({ id: 'split', kind: 'splitter' }),
mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }), // GATED
mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }), // recipe 'fancy' GATED
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
rec({ id: 'press-pack', machine: 'packer', inputs: { rock: 1 }, outputs: { pack: 1 }, ticks: 5 }),
rec({ id: 'smelt-gold', machine: 'goldworks', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
rec({ id: 'plain', machine: 'other', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
rec({ id: 'fancy', machine: 'other', inputs: { rock: 2 }, outputs: { gold: 2 }, ticks: 5 }),
],
tech: [
tech({ id: 'metallurgy', era: 'reel', cost: { pack: 3 }, unlocks: ['goldworks', 'smelt-gold'] }),
tech({ id: 'flourish', era: 'reel', cost: { pack: 2 }, unlocks: ['fancy'] }),
],
commissions: [{ id: 'c1', flavor: 'gold', wants: { gold: 99 }, rewardBandwidth: 1 }],
};
function newSim(data: GameData = FIXTURE, seed = 1): Sim {
const sim = createSim();
sim.init(data, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const at = (sim: Sim, x: number, y: number) =>
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
/** mine -> packer -> lab, plus somewhere for the packs to be counted. */
function packLine(sim: Sim): void {
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('packer', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('lab', 4, 0));
}
describe('gating', () => {
it('locks every id a tech names and leaves everything else alone', () => {
const sim = newSim();
sim.enqueue(place('goldworks', 5, 5)); // named by metallurgy.unlocks -> dropped
sim.enqueue(place('packer', 7, 5)); // named by nothing -> free
run(sim, 1);
expect(at(sim, 5, 5)).toBeUndefined();
expect(at(sim, 7, 5)).toBeDefined();
});
it('drops setRecipe and setRecipeAt for a locked recipe, silently', () => {
const sim = newSim();
sim.enqueue(place('other', 5, 5));
run(sim, 1);
const id = at(sim, 5, 5)!.id;
expect(at(sim, 5, 5)!.recipe).toBe('plain'); // defaults to the first UNLOCKED recipe
sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'fancy' }); // locked
run(sim, 1);
expect(at(sim, 5, 5)!.recipe).toBe('plain');
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 5, y: 5 }, recipe: 'fancy' }); // locked
run(sim, 1);
expect(at(sim, 5, 5)!.recipe).toBe('plain');
});
it('gates nothing when the data has no tech at all', () => {
const sim = newSim({ ...FIXTURE, tech: [] });
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeDefined(); // absence from the tree means free, not forbidden
});
});
describe('research', () => {
it('runs the whole path: pick a target, feed packs, unlock, build what was refused', () => {
const sim = newSim();
expect(sim.snapshot().research).toEqual({ active: null, progress: {}, unlocked: [] });
// Refused up front.
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeUndefined();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
run(sim, 1);
expect(sim.snapshot().research!.active).toBe('metallurgy');
const evs: SimEvent[] = [];
let done = -1;
for (let i = 0; i < 800 && done < 0; i++) {
run(sim, 1, evs);
const e = evs.find((x) => x.kind === 'researched');
if (e) done = (e as { tick: number }).tick;
}
expect(done).toBeGreaterThan(0);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
expect(evs.find((e) => e.kind === 'researched')).toMatchObject({ tech: 'metallurgy' });
const r = sim.snapshot().research!;
expect(r.unlocked).toEqual(['metallurgy']);
expect(r.active).toBeNull(); // completing clears the target rather than guessing a next
expect(r.progress).toEqual({});
// The same command that was silently dropped now works.
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeDefined();
expect(at(sim, 5, 5)!.recipe).toBe('smelt-gold'); // its recipe unlocked with it
});
it('tracks progress toward the active tech and stops taking packs it no longer needs', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // costs 2 packs
const evs: SimEvent[] = [];
for (let i = 0; i < 400; i++) {
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'researched')) break;
const p = sim.snapshot().research!.progress['pack'] ?? 0;
expect(p).toBeLessThanOrEqual(2); // never banks past the cost
}
expect(sim.snapshot().research!.unlocked).toEqual(['flourish']);
// With nothing being researched, the lab stops accepting deliveries entirely.
run(sim, 200);
expect(at(sim, 4, 0)!.inputBuf).toEqual({});
});
it('stacks: two labs feed one total', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('packer', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('lab', 4, 0)); // first lab on the line
sim.enqueue(place('belt', 4, 1, N)); // a second lab fed from the other side
sim.enqueue(place('lab', 4, 2));
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
const evs: SimEvent[] = [];
run(sim, 800, evs);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
});
it('abandons progress when the target changes, and refuses nonsense targets', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs
// Catch it mid-research: metallurgy completes on the third pack, and completing would
// zero progress for a different reason than the one under test.
let banked = 0;
for (let i = 0; i < 400 && banked === 0; i++) {
run(sim, 1);
banked = sim.snapshot().research!.progress['pack'] ?? 0;
}
expect(banked).toBeGreaterThan(0);
sim.enqueue({ kind: 'setResearch', tech: 'flourish' });
run(sim, 1);
expect(sim.snapshot().research!.progress).toEqual({}); // packs are the cost of indecision
sim.enqueue({ kind: 'setResearch', tech: 'no-such-tech' });
run(sim, 1);
expect(sim.snapshot().research!.active).toBe('flourish'); // unchanged
sim.enqueue({ kind: 'setResearch', tech: null });
run(sim, 1);
expect(sim.snapshot().research!.active).toBeNull();
});
it('survives save/load, and a restored save can still build what it unlocked', () => {
const a = newSim();
packLine(a);
a.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
const evs: SimEvent[] = [];
for (let i = 0; i < 800; i++) {
run(a, 1, evs);
if (evs.some((e) => e.kind === 'researched')) break;
}
expect(a.snapshot().research!.unlocked).toEqual(['metallurgy']);
const b = newSim();
b.load(a.save());
expect(b.snapshot().research).toEqual(a.snapshot().research);
// The future still matches: research state is part of the world, not decoration.
run(a, 500);
run(b, 500);
expect(b.snapshot()).toEqual(a.snapshot());
// And the unlock survived as a RULE, not just as a list — a save that restored the
// text but not the gate would silently re-lock the player's own factory. Placed in
// both so the sims stay in step.
for (const s of [a, b]) {
s.enqueue(place('goldworks', 9, 9));
run(s, 1);
expect(at(s, 9, 9)).toBeDefined();
}
expect(b.snapshot()).toEqual(a.snapshot());
});
});
// Derived, never hard-coded: which machines DATA gates is theirs to change, and it moved
// twice while this round was being written. The rule is mine to pin down; the list isn't.
const gatedByReal = new Set(REAL.tech.flatMap((t) => t.unlocks));
const aGatedMachine = REAL.machines.find((m) => gatedByReal.has(m.id))!;
const aFreeMachine = REAL.machines.find(
(m) => !gatedByReal.has(m.id) && m.kind === 'crafter' && m.footprint.x <= 2,
)!;
describe('gating against the real tech tree', () => {
it('locks the machines DATA gates and frees the ones it does not', () => {
const sim = newSim(REAL, 7);
sim.enqueue(place(aGatedMachine.id, 0, 0));
sim.enqueue(place(aFreeMachine.id, 8, 0));
run(sim, 1);
expect(at(sim, 0, 0), `${aGatedMachine.id} is gated and must not place`).toBeUndefined();
expect(at(sim, 8, 0), `${aFreeMachine.id} is ungated and must place`).toBeDefined();
});
it('opens the real gate when the real tech completes', () => {
const unlocker = REAL.tech.find((t) => t.unlocks.includes(aGatedMachine.id))!;
const sim = newSim(REAL, 7);
grantResearch(sim, [unlocker.id]);
sim.enqueue(place(aGatedMachine.id, 0, 0));
run(sim, 1);
expect(at(sim, 0, 0)).toBeDefined();
expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]);
});
});
describe('research takes time', () => {
it('a pack is an investment, not a purchase — it costs RESEARCH_TICKS_PER_PACK', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // 2 packs
// Wait for the first pack to physically reach the lab, then time the conversion.
let arrived = -1;
for (let i = 0; i < 600 && arrived < 0; i++) {
run(sim, 1);
if ((at(sim, 4, 0)!.inputBuf['pack'] ?? 0) > 0) arrived = sim.snapshot().tick;
}
expect(arrived).toBeGreaterThan(0);
let credited = -1;
for (let i = 0; i < RESEARCH_TICKS_PER_PACK * 3 && credited < 0; i++) {
run(sim, 1);
if ((sim.snapshot().research!.progress['pack'] ?? 0) > 0) credited = sim.snapshot().tick;
}
expect(credited).toBeGreaterThan(0);
// The lab was busy for the full duration, not instantaneous. (The tick the pack lands
// is also the tick the lab starts on it, hence the -1.)
expect(credited - arrived).toBeGreaterThanOrEqual(RESEARCH_TICKS_PER_PACK - 1);
expect(credited - arrived).toBeLessThanOrEqual(RESEARCH_TICKS_PER_PACK + 1);
});
it('shows its work: a lab mid-pack reports progress, and clears it when idle', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
let seenWorking = false;
for (let i = 0; i < 1200 && !seenWorking; i++) {
run(sim, 1);
const p = at(sim, 4, 0)!.progress;
if (p > 0 && p < 1) seenWorking = true;
}
expect(seenWorking, 'the lab never showed partial progress').toBe(true);
// Once there's nothing to research, the lab stops showing a half-done pack.
sim.enqueue({ kind: 'setResearch', tech: null });
run(sim, 2);
expect(at(sim, 4, 0)!.progress).toBe(0);
});
it('two labs really do halve the wait', () => {
// Three mines so pack SUPPLY isn't the bottleneck — otherwise this measures the miner,
// not the labs, and a second lab would change nothing.
const finishTick = (labs: number): number => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('mine', 0, 2));
sim.enqueue(place('belt', 1, 2, E));
sim.enqueue(place('belt', 2, 2, N));
sim.enqueue(place('belt', 2, 1, N));
sim.enqueue(place('mine', 0, -2));
sim.enqueue(place('belt', 1, -2, E));
sim.enqueue(place('belt', 2, -2, S));
sim.enqueue(place('belt', 2, -1, S));
sim.enqueue(place('packer', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('split', 4, 0));
sim.enqueue(place('belt', 5, 0, E));
sim.enqueue(place('lab', 6, 0));
if (labs > 1) {
sim.enqueue(place('belt', 4, 1, S)); // the splitter's other face feeds lab two
sim.enqueue(place('lab', 4, 2));
}
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs
for (let i = 0; i < 4000; i++) {
sim.tick();
for (const e of sim.drainEvents()) if (e.kind === 'researched') return e.tick;
}
return -1;
};
const one = finishTick(1);
const two = finishTick(2);
expect(one).toBeGreaterThan(0);
expect(two).toBeGreaterThan(0);
expect(two, 'a second lab should finish the tech sooner').toBeLessThan(one);
});
it('abandoning a target forfeits the half-processed pack in the lab', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
let working = false;
for (let i = 0; i < 1200 && !working; i++) {
run(sim, 1);
working = at(sim, 4, 0)!.progress > 0;
}
expect(working).toBe(true);
run(sim, 20); // let it get meaningfully into the pack before we yank the target
const banked = at(sim, 4, 0)!.progress;
expect(banked).toBeGreaterThan(2 / RESEARCH_TICKS_PER_PACK); // genuinely part-done
sim.enqueue({ kind: 'setResearch', tech: 'flourish' });
run(sim, 1);
// Back to scratch: at most the single tick it has already spent on the new target.
expect(at(sim, 4, 0)!.progress).toBeLessThanOrEqual(1 / RESEARCH_TICKS_PER_PACK);
expect(at(sim, 4, 0)!.progress).toBeLessThan(banked);
});
});
describe('grantResearch', () => {
it('unlocks instantly, emits researched, and lets the build through', () => {
const sim = newSim();
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeUndefined(); // refused while locked
const evs: SimEvent[] = [];
sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] });
run(sim, 1, evs);
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeDefined();
});
it('is idempotent and ignores nonsense', () => {
const sim = newSim();
sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'metallurgy', 'no-such-tech'] });
const evs: SimEvent[] = [];
run(sim, 1, evs);
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); // again
const evs2: SimEvent[] = [];
run(sim, 1, evs2);
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
expect(evs2.filter((e) => e.kind === 'researched')).toHaveLength(0); // nothing changed
});
it('completes the active target out from under the labs', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
run(sim, 120); // still mid-research: 3 packs at 60 ticks each can't be done yet
expect(sim.snapshot().research!.active).toBe('metallurgy');
sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] });
run(sim, 1);
expect(sim.snapshot().research!.active).toBeNull();
expect(sim.snapshot().research!.progress).toEqual({});
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
});
it('survives save/load like any other research', () => {
const a = newSim();
a.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'flourish'] });
run(a, 1);
const b = newSim();
b.load(a.save());
expect(b.snapshot().research!.unlocked).toEqual(['metallurgy', 'flourish']);
b.enqueue(place('goldworks', 9, 9));
run(b, 1);
expect(at(b, 9, 9)).toBeDefined();
});
});

View File

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { makeRng } from './rng';
import { grantAllResearch } from './testkit';
import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -27,9 +26,6 @@ const recipe = (id: string): RecipeDef => {
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
// v4 gating locks the mosh reactor and friends; these tests build the M1 chain, which
// is an endgame build. Placement gating gets its own tests in research.test.ts.
grantAllResearch(sim, DATA);
return sim;
}
@ -448,7 +444,7 @@ describe('shipping', () => {
expect(evs.filter((e) => e.kind === 'commissionDone')).toHaveLength(0); // wants melt, not ore
});
it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN', () => {
it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN, commission closed', () => {
const sim = newSim();
buildMeltChain(sim);
@ -457,26 +453,18 @@ describe('shipping', () => {
const snap = sim.snapshot();
expect(snap.shippedTotal['melt'] ?? 0).toBeGreaterThanOrEqual(1);
const done = evs.filter((e) => e.kind === 'commissionDone');
expect(done).toHaveLength(1); // fires once, however much melt follows
expect(done[0]).toMatchObject({ commission: 'first-taste' });
// Closing it advances the queue to the next order in data order, counting afresh.
expect(snap.activeCommission).toBe(DATA.commissions[1].id);
expect(snap.commissionProgress['melt']).toBeUndefined();
// Routing did its job: the byproduct went to its own uplink, not into the reactor.
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
// And the reactor's recovered anchor slabs came back out to be sold.
expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0);
// The commission ladder is DATA's to reorder — and they have, twice. Assert the RULE
// (shipping what the active order wants credits it, and closing advances the queue)
// rather than which order happens to be first this week.
const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
expect(active, 'a commission should always be active while the queue is non-empty')
.toBeDefined();
for (const [item, want] of Object.entries(active!.wants)) {
const credited = snap.commissionProgress[item] ?? 0;
expect(credited).toBeLessThanOrEqual(want); // never banks past the cost
expect(credited).toBeLessThanOrEqual(snap.shippedTotal[item] ?? 0); // only real ships
}
for (const e of evs.filter((x) => x.kind === 'commissionDone')) {
expect(DATA.commissions.some((c) => c.id === (e as { commission: string }).commission))
.toBe(true);
}
});
});

View File

@ -1,31 +0,0 @@
/**
* Test/dev helpers for LANE-SIM. Not part of the game loop nothing in src/main.ts,
* src/render, src/ui or src/screen imports this, and it tree-shakes out of the bundle.
*
* Why it exists: contracts v4 gates every id named by a tech's `unlocks`, and that
* includes the mosh reactor, the software decoder and the subsampler. So the reference
* factory the endgame demo is no longer buildable from a fresh sim by design. It
* represents a save where the research has already happened, and this is how a test says
* so honestly, through the public save/load API rather than a back door into the sim.
*
* There is currently no legitimate in-sim path to this state: DATA ships no `kind:'lab'`
* machine, and every science pack is made on the artifact-bottler, which is itself locked
* behind a tech costing 10 analog-packs. See NOTES round 4.
*/
import type { GameData, Sim } from '../contracts';
/**
* Rewrites a sim's research state to treat `techIds` as completed, via a save/load
* round-trip. Call it on a fresh sim before enqueueing anything load() replaces the
* whole world, so any entities placed first would be discarded.
*/
export function grantResearch(sim: Sim, techIds: string[]): void {
const blob = JSON.parse(sim.save());
blob.research = { active: null, progress: {}, unlocked: [...techIds] };
sim.load(JSON.stringify(blob));
}
/** The late-game save: everything DATA's tech tree gates is open. */
export function grantAllResearch(sim: Sim, data: GameData): void {
grantResearch(sim, data.tech.map((t) => t.id));
}

View File

@ -1,288 +0,0 @@
/**
* Wildlife v1 the dustswarm loop.
*
* The design point these tests exist to protect: the hazard is a consequence of bad
* plumbing, not a timer. Piles grow from the dust a machine is HOLDING, so a factory that
* belts its dust to an uplink never hatches anything, and one that ignores it is farming
* mosquitoes. If that asymmetry ever breaks, the mechanic becomes weather.
*
* Fixture-based: DATA has no trap machine yet (see NOTES round 5), so the trap here is a
* fixture machine shaped the way the orders describe an empty-input recipe that cans the
* swarm. The sim identifies traps by that recipe shape, not by a machine id, so DATA's real
* trap will light up with zero code changes.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import {
DUST_ITEM, SWARM_ITEM, SWARM_SLOW_FLOOR, TRAP_RADIUS,
} from './constants';
import type {
Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState,
} from '../contracts';
const N: Dir = 0, E: Dir = 1, S: Dir = 2;
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const FIXTURE: GameData = {
items: ['rock', DUST_ITEM, SWARM_ITEM].map(item),
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 50 }),
// Emits dust as a byproduct — the thing that breeds swarms if you let it sit.
mach({ id: 'crusher', kind: 'crafter', recipes: ['crush'], powerDraw: 2 }),
// The trap: no inputs, cans a live swarm. The sim spots it by recipe shape.
mach({ id: 'trap', kind: 'crafter', recipes: ['catch'], powerDraw: 1 }),
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 }),
// One dust per craft: a single belt lane (~0.15 items/tick) can genuinely keep up with
// this, so "tidy" below really is tidy. Four-per-craft would out-run one lane and back
// up no matter how you plumbed it — which is a true fact about belts, not about dust.
rec({ id: 'crush', machine: 'crusher', inputs: { rock: 1 }, outputs: { [DUST_ITEM]: 1 }, ticks: 10 }),
rec({ id: 'catch', machine: 'trap', inputs: {}, outputs: { [SWARM_ITEM]: 1 }, ticks: 30 }),
],
tech: [],
commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 999 }, rewardBandwidth: 1 }],
};
function newSim(seed = 1): Sim {
const sim = createSim();
sim.init(FIXTURE, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const at = (sim: Sim, x: number, y: number) =>
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
const wild = (sim: Sim, kind: WildlifeState['kind']) =>
sim.snapshot().wildlife!.filter((w) => w.kind === kind);
/** A crusher fed by a miner, with its dust going nowhere: the neglected factory. */
function dustyFactory(sim: Sim): void {
sim.enqueue(place('gen', 0, 8));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('crusher', 2, 0));
}
/** The same factory, but the dust is belted away to an uplink. */
function tidyFactory(sim: Sim): void {
dustyFactory(sim);
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('belt', 4, 0, E));
sim.enqueue(place('ship', 5, 0));
}
describe('dust piles', () => {
it('grow where a machine sits on unshipped dust', () => {
const sim = newSim();
dustyFactory(sim);
const evs: SimEvent[] = [];
run(sim, 400, evs);
const piles = wild(sim, 'dust-pile');
expect(piles.length).toBeGreaterThan(0);
expect(piles[0].size).toBeGreaterThan(0);
// The pile settles near the machine that shed it, not at the origin.
expect(Math.abs(piles[0].pos.x - 2)).toBeLessThanOrEqual(3);
expect(evs.filter((e) => e.kind === 'wildlife' && e.on === 'spawned')).not.toHaveLength(0);
});
it('THE DESIGN POINT: a factory that sinks its dust never hatches anything', () => {
const tidy = newSim();
tidyFactory(tidy);
run(tidy, 6000);
expect(tidy.snapshot().shippedTotal[DUST_ITEM] ?? 0).toBeGreaterThan(0); // it really ran
expect(wild(tidy, 'mosquito-swarm')).toHaveLength(0);
expect(at(tidy, 2, 0)!.outputBuf[DUST_ITEM] ?? 0).toBeLessThan(4); // buffer stays drained
// Same machines, same ticks, no uplink: swarms.
const dusty = newSim();
dustyFactory(dusty);
run(dusty, 6000);
expect(wild(dusty, 'mosquito-swarm').length).toBeGreaterThan(0);
});
});
describe('mosquito swarms', () => {
it('hatch from a full pile, and the pile is consumed doing it', () => {
const sim = newSim();
dustyFactory(sim);
const evs: SimEvent[] = [];
run(sim, 6000, evs);
const spawns = evs.filter(
(e) => e.kind === 'wildlife' && e.on === 'spawned' && e.wildlife === 'mosquito-swarm',
);
expect(spawns.length).toBeGreaterThan(0);
const cleared = evs.filter(
(e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'dust-pile',
);
expect(cleared.length).toBeGreaterThanOrEqual(spawns.length); // every hatch ate its pile
for (const w of wild(sim, 'dust-pile')) expect(w.size).toBeLessThan(1);
});
it('drift to a running machine, latch on, and slow it down', () => {
const sim = newSim();
dustyFactory(sim);
let attached: WildlifeState | undefined;
for (let i = 0; i < 12000 && !attached; i++) {
run(sim, 1);
attached = wild(sim, 'mosquito-swarm').find((w) => w.target !== undefined && w.size > 0);
}
expect(attached, 'no swarm ever latched on').toBeDefined();
expect(attached!.target).toBeDefined();
expect(sim.snapshot().entities.some((e) => e.id === attached!.target)).toBe(true);
// Severity climbs the longer it's left alone, and is bounded.
const before = attached!.size;
run(sim, 300);
const after = wild(sim, 'mosquito-swarm').find((w) => w.id === attached!.id);
if (after) {
expect(after.size).toBeGreaterThan(before);
expect(after.size).toBeLessThanOrEqual(1);
}
});
it('cost real throughput — a bitten factory out-produces nothing', () => {
// Two identical dusty factories; one gets its swarms trapped, one doesn't.
const bitten = newSim();
dustyFactory(bitten);
run(bitten, 12000);
const guarded = newSim();
dustyFactory(guarded);
guarded.enqueue(place('trap', 2, 3)); // inside TRAP_RADIUS of the crusher
run(guarded, 12000);
expect(wild(bitten, 'mosquito-swarm').length).toBeGreaterThan(0);
const bittenRocks = bitten.snapshot().entities.find((e) => e.def === 'crusher')!;
const guardedRocks = guarded.snapshot().entities.find((e) => e.def === 'crusher')!;
expect(guardedRocks.heat).toBe(bittenRocks.heat); // nothing thermal is in play here
// The guarded line kept its swarms cleared, so it never ran slowed.
expect(wild(guarded, 'mosquito-swarm').length).toBeLessThan(
wild(bitten, 'mosquito-swarm').length,
);
});
it('the slow is bounded by SWARM_SLOW_FLOOR', () => {
expect(SWARM_SLOW_FLOOR).toBeGreaterThan(0);
expect(SWARM_SLOW_FLOOR).toBeLessThan(1); // a swarm slows, it never stops a machine dead
});
});
describe('the trap', () => {
it('sits idle with nothing to catch, then cans a swarm that comes near', () => {
const sim = newSim();
dustyFactory(sim);
sim.enqueue(place('trap', 2, 3));
run(sim, 200);
// Nothing has hatched yet, so the trap is waiting rather than canning air.
expect(at(sim, 2, 3)!.jammed).toBe('starved');
expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBe(0);
const evs: SimEvent[] = [];
let caught = 0;
for (let i = 0; i < 12000 && caught === 0; i++) {
run(sim, 1, evs);
caught = evs.filter(
(e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'mosquito-swarm',
).length;
}
expect(caught, 'the trap never caught anything').toBeGreaterThan(0);
expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBeGreaterThan(0); // a live one, jarred
});
it('only reaches swarms inside its radius', () => {
const near = newSim();
dustyFactory(near);
near.enqueue(place('trap', 2, 3)); // ~3 tiles: inside TRAP_RADIUS
run(near, 12000);
const far = newSim();
dustyFactory(far);
far.enqueue(place('trap', 2, 3 + TRAP_RADIUS * 4)); // well out of reach
run(far, 12000);
expect(near.snapshot().shippedTotal[SWARM_ITEM] ?? 0 + (at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0))
.toBeGreaterThanOrEqual(0);
const nearCaught = at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0;
const farCaught = at(far, 2, 3 + TRAP_RADIUS * 4)!.outputBuf[SWARM_ITEM] ?? 0;
expect(nearCaught).toBeGreaterThan(0);
expect(farCaught).toBe(0);
expect(at(far, 2, 3 + TRAP_RADIUS * 4)!.jammed).toBe('starved');
});
it('produces a shippable item — the catch is cargo, not a status effect', () => {
const sim = newSim();
dustyFactory(sim);
sim.enqueue(place('trap', 2, 3));
sim.enqueue(place('belt', 3, 3, E));
sim.enqueue(place('belt', 4, 3, E));
sim.enqueue(place('ship', 5, 3));
run(sim, 14000);
expect(sim.snapshot().shippedTotal[SWARM_ITEM] ?? 0).toBeGreaterThan(0);
});
});
describe('wildlife determinism and persistence', () => {
it('two sims with the same seed breed the same swarms in the same places', () => {
const build = (): Sim => {
const sim = newSim(4242);
dustyFactory(sim);
sim.enqueue(place('trap', 2, 3));
run(sim, 9000);
return sim;
};
const a = build();
const b = build();
expect(a.snapshot().wildlife!.length).toBeGreaterThan(0); // not vacuously equal
expect(b.snapshot()).toEqual(a.snapshot());
});
it('a different seed puts the piles somewhere else', () => {
const posesFor = (seed: number): string => {
const sim = newSim(seed);
dustyFactory(sim);
run(sim, 2000);
return JSON.stringify(sim.snapshot().wildlife!.map((w) => w.pos));
};
const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(posesFor));
expect(spread.size).toBeGreaterThan(1);
});
it('survives save/load mid-infestation, and the future still matches', () => {
const a = newSim(77);
dustyFactory(a);
a.enqueue(place('trap', 2, 3));
run(a, 9000);
expect(a.snapshot().wildlife!.length).toBeGreaterThan(0);
const b = newSim(77);
b.load(a.save());
expect(b.snapshot().wildlife).toEqual(a.snapshot().wildlife);
run(a, 4000);
run(b, 4000);
expect(b.snapshot()).toEqual(a.snapshot());
});
});

View File

@ -2,11 +2,10 @@
* LANE-UI build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page.
* Click or hotkey selects for placement; main.ts does the ghost and the actual place.
*/
import type { GameData, MachineDef, SimSnapshot } from '../contracts';
import type { GameData, MachineDef } from '../contracts';
import { attrs, cls, el, panel, text } from './dom';
import { paginate, pageOf, type BuildPage } from './pages';
import { createAccentLookup, machineChassis } from './palette';
import { gateFor, indexTech, type TechIndex } from './research';
import type { BuildSelection } from './selection';
import { COPY, DIR_NAME } from './voice';
@ -16,16 +15,11 @@ export interface BuildBar {
selectSlot(n: number): void;
pageBy(delta: number): void;
sync(): void;
/** Re-read the lock state; cheap enough to call per frame. */
update(snap: SimSnapshot): void;
}
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const pages: BuildPage[] = paginate(data.machines);
const accentOf = createAccentLookup(data);
const techIndex: TechIndex = indexTech(data);
/** Machine ids currently gated by unfinished research. */
let locked = new Set<string>();
const root = panel();
root.id = 'fk-build';
@ -60,12 +54,13 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
tabRow.append(removeBtn);
/** Buttons for the active page only; rebuilt on page change (nine at most). */
let buttons: Array<{ def: string; btn: HTMLButtonElement; lock: HTMLElement }> = [];
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
function buildButtons(page: BuildPage) {
row.replaceChildren();
buttons = page.machines.map((m: MachineDef, i: number) => {
const btn = el('button', 'fk-build-btn');
attrs(btn, { type: 'button', title: `${m.name}${m.kind}` });
// Two-material rule (codex §8): grimy body, one impossible element.
const ico = el('div', 'fk-build-ico');
@ -74,8 +69,6 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
accent.style.background = accentOf(m);
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
ico.append(accent);
const lock = el('span', 'fk-build-lock', ['\u{1F512}']);
ico.append(lock);
btn.append(ico);
const key = el('span', 'fk-build-key');
@ -83,41 +76,15 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
btn.append(key);
btn.append(el('span', 'fk-build-name', [m.name]));
btn.addEventListener('click', () => select(m.id));
btn.addEventListener('click', () => sel.select(m.id));
row.append(btn);
return { def: m.id, btn, lock };
return { def: m.id, btn };
});
applyLocks();
}
/** A locked machine can't be picked — by click or by hotkey. */
function select(defId: string) {
if (locked.has(defId)) return;
sel.select(defId);
}
function applyLocks() {
for (const b of buttons) {
const isLocked = locked.has(b.def);
cls(b.btn, 'is-locked', isLocked);
cls(b.lock, 'is-on', isLocked);
const m = data.machines.find((x) => x.id === b.def);
const gate = isLocked ? techIndex.gatedBy.get(b.def) : null;
attrs(b.btn, {
title: gate
? `${m?.name ?? b.def}${COPY.requires} ${gate.id.replace(/-/g, ' ').toUpperCase()}`
: `${m?.name ?? b.def}${m?.kind ?? ''}`,
});
}
}
function render() {
buildButtons(pages[active]);
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active);
// The strip scrolls, so paging with [ ] must bring its tab into view or the player
// pages blind. `nearest` avoids yanking the strip when the tab is already visible.
tabs[active]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
sync();
}
@ -159,24 +126,9 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
return {
el: root,
update(snap) {
// Recompute only when the unlocked set actually changes — the padlocks then fall
// off in the same frame the `researched` event lands.
const next = new Set<string>();
for (const m of data.machines) {
if (gateFor(techIndex, snap.research, m.id)) next.add(m.id);
}
if (next.size === locked.size && [...next].every((d) => locked.has(d))) return;
locked = next;
applyLocks();
// Never leave the player holding something they can no longer build.
const cur = sel.get();
if (cur && locked.has(cur.def)) sel.clear();
},
selectSlot(n) {
const b = buttons[n - 1];
if (b) select(b.def);
if (b) sel.select(b.def);
},
pageBy(delta) {
if (pages.length === 0) return;

Some files were not shown because too many files have changed in this diff Show More