diff --git a/fktry/data/CANON-DELTA.md b/fktry/data/CANON-DELTA.md index 53b581e..2680177 100644 --- a/fktry/data/CANON-DELTA.md +++ b/fktry/data/CANON-DELTA.md @@ -127,6 +127,136 @@ lab is the dig site — named for the verb the codex already uses, not a generic 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, 18–28 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. + --- ## The numbers, and the arithmetic behind them @@ -273,30 +403,40 @@ number was catastrophic:** the decoder banks 0.0033/tick, so a 0.02 aura means n 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. -Retuned to **0.0018**, which lands well under either reading of the contract's "this -machine's coolPerTick *also* applies" — deliberately, because SIM hasn't built it yet and -I don't want the design resting on my guess of an ambiguous sentence: +**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**: ``` - throttle scram period duty avg gen -decoder alone 10.1 s 14.5 s 23.9 s 30 % 25.8 /s -+ cooler, ADDITIVE 46.7 s 66.7 s 39.3 s 85 % 72.1 /s -+ cooler, REPLACING 15.6 s 22.2 s 20.4 s 55 % 46.4 /s -+ cooler at 0.02 (r3) never never — 100 % 100.0 /s <- broken either way +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 ``` -Both readings are playable and both are worth the tech, so **SIM can implement either and -the design holds**. Per tile, which is the real question when both machines are 2×2: +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 + cooler (additive) 77.1 /s over 8 tiles = 9.64 /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.9× ASICs) — and it costs a tech, -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*. +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? @@ -339,15 +479,14 @@ 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`, `mosquito-swarm`, `gibbs-wraith` remain forward canon (no recipe). The +- `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 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 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 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 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. diff --git a/fktry/data/commissions.json b/fktry/data/commissions.json index 5b7e709..21c07c3 100644 --- a/fktry/data/commissions.json +++ b/fktry/data/commissions.json @@ -1,7 +1,19 @@ [ + { "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.", - "wants": { "melt": 1 }, "rewardBandwidth": 50 }, + "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 }, { "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.", @@ -16,7 +28,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.", + "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.]", "wants": { "ringing-halos": 9, "gamut-paint-rec709": 2 }, "rewardBandwidth": 300 }, { "id": "correction-buyback", diff --git a/fktry/data/machines.json b/fktry/data/machines.json index 2fdc064..baf3f3a 100644 --- a/fktry/data/machines.json +++ b/fktry/data/machines.json @@ -74,6 +74,11 @@ "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": "#d0ff60", + "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": "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", @@ -93,7 +98,7 @@ "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.0018, "coolRadius": 2, + "coolPerTick": 0.001, "coolRadius": 2, "asset": "asic-cooler", "color": "#a8b4bc", "accent": "#9fe8ff", "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." }, diff --git a/fktry/data/recipes.json b/fktry/data/recipes.json index 9bed9e3..575df2b 100644 --- a/fktry/data/recipes.json +++ b/fktry/data/recipes.json @@ -85,6 +85,9 @@ { "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": "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 }, diff --git a/fktry/data/seams.json b/fktry/data/seams.json new file mode 100644 index 0000000..1301a26 --- /dev/null +++ b/fktry/data/seams.json @@ -0,0 +1,23 @@ +{ + "_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 } + ] +} diff --git a/fktry/data/stations.json b/fktry/data/stations.json new file mode 100644 index 0000000..4ec2bb7 --- /dev/null +++ b/fktry/data/stations.json @@ -0,0 +1,107 @@ +{ + "_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." + ] + }, + { + "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", "08127", "31640", "47355", + "16093", "58204", "25471", "39628", "13850", "48136", + "20794", "55012", "34269", "62130", "09846", "27503" + ] + } + }, + { + "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." + } + } + ] +} diff --git a/fktry/data/tech.json b/fktry/data/tech.json index 150e6d6..5e3b48b 100644 --- a/fktry/data/tech.json +++ b/fktry/data/tech.json @@ -5,6 +5,8 @@ "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": "reel-silver-seams", "era": "reel", "cost": { "spatial-pack": 8 }, "unlocks": ["extract-silver-frames"] }, diff --git a/fktry/data/validate.test.ts b/fktry/data/validate.test.ts index 23035c2..98d789a 100644 --- a/fktry/data/validate.test.ts +++ b/fktry/data/validate.test.ts @@ -8,12 +8,20 @@ */ 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[]; @@ -221,12 +229,13 @@ 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', () => { - // 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; + // 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; const soft = machines.find((m) => m.id === 'software-decoder')!; const cool = (m: MachineDef) => m.coolPerTick ?? SIM_DEFAULT_COOL; @@ -334,13 +343,29 @@ describe('recipes', () => { expect(bad).toEqual([]); }); - it('only extractors run input-free recipes', () => { + 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. + const CATCHES_WILDLIFE = new Set(['bait-swarm']); 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('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}`); @@ -409,10 +434,11 @@ describe('commissions', () => { expect(bad).toEqual([]); }); - 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('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('rewards climb monotonically down the queue', () => { @@ -517,10 +543,12 @@ 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([ - 'mosquito-swarm', 'gibbs-wraith', 'cinema-pack', - // §10: The Correction hoards the prisms. Nothing in the factory makes 589nm. - 'sodium-light', + 'gibbs-wraith', // §6: must come "willingly". Nothing coerces one yet. + 'cinema-pack', // needs the Fortress (§7). + 'sodium-light', // §10: The Correction hoards the prisms. ]); const have = producible(); const orphans = items @@ -533,7 +561,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'].filter((id) => !have.has(id)); + const retired = ['container-pack', 'mosquito-swarm'].filter((id) => !have.has(id)); expect(retired).toEqual([]); }); @@ -546,3 +574,272 @@ 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>; + 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 happens to decode onto a real hidden seam isn't a decoy, it's a leak. + const hiddenTiles = new Set(seams.filter((s) => s.hidden).map((s) => `${s.rect.x},${s.rect.y}`)); + const leaks = (n.decoyPool as string[]) + .map((g) => ({ g, x: Number(g.slice(0, 2)) - 32, y: Number(g.slice(2, 4)) - 32 })) + .filter(({ x, y }) => hiddenTiles.has(`${x},${y}`)) + .map(({ g, x, y }) => `decoy ${g} decodes to the real seam at (${x},${y})`); + 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>(); + 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([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 | 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 + }); +}); diff --git a/fktry/lanes/LANE-DATA.md b/fktry/lanes/LANE-DATA.md index 3943e09..7ac2c4d 100644 --- a/fktry/lanes/LANE-DATA.md +++ b/fktry/lanes/LANE-DATA.md @@ -417,3 +417,103 @@ NEXT (candidates): SPILL FOG + EDGE BOIL (§10 hazards — need a mechanic, so d 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.