[lane C] Round 1: schema v1, levels registry + selfcheck + pacing sim, L2 vertical slice
Data spine: - Schema v1 ratified (TECH.md, C-owned). Additive over v0: schema/par/next, segment name, cross-section placement (theta/rho as a FRACTION of safe radius), spread, span, pickup+gate event types, hazard params. STUB_LEVEL synced (that const only, per ROUND1 §Lane C.1) — world.hash() unchanged (1bdf001), geometry provably untouched. - levels/index.js: registry + loader (node+browser, no THREE), real schema selfcheck (qa's --selfcheck hook is now live) and a pacing simulator (--sim). Consumes the other lanes' contracts live rather than restating them: A's biomes.js for ids and wave.amp+breathe, A's SKIN, B's tuning.js hull radius, B's balance.js for scores. - levels/enemies.js (fiction id -> archetype join table), levels/difficulty.js. L2_esophagus — the vertical slice, complete: 3600 units, 7 beats, 47 events, 65 enemies, 10 checkpoints, 3 samples, reflux-surge finale. L1/L3 skeletons (shape only). Anatomy is the level designer: the three real esophageal constrictions are the three setpieces, and all three enemies are real esophageal pathology (bolus chunk, eosinophilic esophagitis, candida esophagitis). Measured (node web/js/levels/index.js --selfcheck|--sim; qa GREEN): - selfcheck 3/3 valid, exit 0. - pressure curve 0.27 -> 2.14 -> 4.35 -> 0.69 -> 4.53 -> 6.35 -> 4.40 (breather/pressure/ spike/breather/pressure/peak/finale). - par 3:30 vs measured 2:47 speedrun / 3:19 par-pace / 4:22 timid; par.score 9000 of a 9400 kill budget (95%), computed from B's scores. - all 10 checkpoint gaps within the <=30s death-cost law (max 28.8s); min clearance 2.73. Two contract collisions found and resolved (LANE_C_NOTES): - Biome ids: authored mouth/pharynx/cardia/pylorus; A's registry has six canonical ids and rejected all four. A is right — biome = look, segments[].name = anatomy. Conformed. - Balance: my first enemies.js restated hp/size/speed/score that B owns in balance.js. Cut to a join table; index.js reads B's scores via scoreFor(). Caught live: A raised esophagus wave.amp 0.9 -> 1.4 mid-session, making the diaphragmatic hiatus unflyable (2.33 vs the 2.5 floor). Widened 6.5 -> 6.8. The invariant also failed my own aortic constriction's first draft (radius 7, symmetric squeeze) -> radius 9, directional. No in-engine flythrough: ?stub=1&lvl= does not compose (snippet offered to F) and the stub is single-segment, so it could only render beat 1. The pacing evidence is a kinematic model, not a playtest — it proves L2 is flyable, paced and survivable, not that it feels good. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dade036f69
commit
1b5ec767cd
31
docs/GDD.md
31
docs/GDD.md
@ -4,6 +4,18 @@ Owner: Lane C (with F sign-off). This v1 settles the open questions from the ori
|
||||
concept discussion; C refines numbers/encounters in-lane, but the **decisions** below are
|
||||
integrator-settled for v1.
|
||||
|
||||
> **⚠ Lane C round-1 refinements** (2026-07-16) — changed in place, per charter:
|
||||
> - **§Structure** — "6–10 min/level" replaced with **per-level** targets. L2 is deliberately
|
||||
> the short one: it is the speed level, and a 6-minute esophagus is a corridor.
|
||||
> - **§Enemies** — esophagus roster filled in (eosinophil swarm, candida bloom). All three
|
||||
> L2 enemies are real esophageal pathology; see LANE_C_NOTES.md §L2 roster.
|
||||
> - **§Structure** — L2's signature mechanic now names the three real anatomical
|
||||
> constrictions, which are the level's three setpieces.
|
||||
>
|
||||
> Not changed, and not up for relitigation in-lane: the flow-locked hybrid, the 5+1 level
|
||||
> structure, the coat/hull split, the boss roster. Boss *specs* (phases/telegraphs/windows)
|
||||
> are in LANE_C_NOTES.md §Boss specs and fold into the GDD once F signs them off.
|
||||
|
||||
## Fantasy & tone
|
||||
|
||||
You are ENDO-1, a one-pilot micro-probe injected to clear a patient's digestive tract of a
|
||||
@ -36,13 +48,24 @@ Each level ends at a real anatomical valve, staged as a gate ritual or boss:
|
||||
| # | level | mode | signature mechanic | exit gate |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Oral Cavity** | arena (tutorial) | dodging molar crush-cycles, saliva tides | **Epiglottis** — timing gate: dive on the swallow |
|
||||
| 2 | **Esophagus** | tube (speed run) | ride peristalsis rings for boost; **reflux surge** chase finale | **Cardiac sphincter** — outrun the acid wall through it |
|
||||
| 2 | **Esophagus** | tube (speed run) | surf peristalsis crests for boost; the three real constrictions (**aortic squeeze** → **diaphragmatic ring gate**) as setpieces; **reflux surge** chase finale | **Cardiac sphincter** — outrun the acid wall through it |
|
||||
| 3 | **Stomach** | arena (the acid sea) | pH as environmental damage; mucus shallows are safe lanes; churn cyclones | **BOSS: Pyloric Guardian** — the sphincter itself |
|
||||
| 4 | **Small Intestine** | tube (the long maze) | villi forest occludes sightlines; bile slicks; immune hunters | **MINIBOSS: Tapeworm chase** → ileocecal valve |
|
||||
| 5 | **Large Intestine** | tube→arena (gas swamps) | flammable gas pockets: shooting inside one detonates it (hurts everyone — risk/reward); darkness + your scanner light | **BOSS: The Blockage** — then triumphant ejection finale |
|
||||
| S | **Appendix** (secret) | arena | hidden lair off the cecum, bonus loot + lore | — |
|
||||
|
||||
Target: 6–10 min/level first playthrough, ~45 min campaign, built to replay for medals.
|
||||
**Per-level targets** (C, round 1 — replaces a flat "6–10 min/level", which flattened the
|
||||
levels into each other). Length is an identity, not a budget: the campaign should *breathe*.
|
||||
|
||||
| # | level | par | first playthrough | why |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Oral Cavity | 5:00 | ~6 min | tutorial: slow, safe, over-explained on purpose |
|
||||
| 2 | **Esophagus** | **3:30** | ~4–5 min | **the sprint.** Shortest level in the game. You cannot stop in a flow-locked tube, so its length is bounded by physics: even a timid run is 4:22 and a full-throttle run is 2:47 (measured — `levels/index.js --sim`) |
|
||||
| 3 | Stomach | 9:00 | ~10 min | the big room + first real boss; arenas need time to breathe |
|
||||
| 4 | Small Intestine | 10:00 | ~11 min | the long maze — the campaign's endurance beat |
|
||||
| 5 | Large Intestine | 9:00 | ~10 min | darkness, gas risk/reward, the final boss |
|
||||
|
||||
~45 min campaign including deaths and retries. Built to replay for medals.
|
||||
|
||||
## Player systems
|
||||
|
||||
@ -64,7 +87,9 @@ neutral flora = cyan (turns hostile if shot).
|
||||
| enemy | biome | behavior archetype |
|
||||
|---|---|---|
|
||||
| amylase droplet | mouth | slow floater, corrosive contact aura |
|
||||
| bolus chunk | esophagus | tumbling debris in the flow (crush) |
|
||||
| bolus chunk | esophagus | tumbling debris in the flow (crush). Drifts *slower* than the current, so it reads as traffic you overtake, never as a chaser |
|
||||
| eosinophil swarm | esophagus | small fast seekers (eosinophilic esophagitis: immune cells that mistake ENDO-1 for the parasite they were built for). Faster than the flow — you cannot outrun them, only shoot or bank |
|
||||
| candida bloom | esophagus | wall-mounted plaque colony firing spore clusters (candida esophagitis). Owns an arc of the cross-section: turns "which side do I fly" into a decision |
|
||||
| pepsin swarm | stomach | small fast seekers, dissolve on kill |
|
||||
| churn cyclone | stomach | environmental vortex, flings you |
|
||||
| macrophage | small int. | big slow hunter; grab → mash to escape; eats other corpses to grow |
|
||||
|
||||
474
docs/LANES/LANE_C_NOTES.md
Normal file
474
docs/LANES/LANE_C_NOTES.md
Normal file
@ -0,0 +1,474 @@
|
||||
# LANE C — NOTES (Levels & Encounters)
|
||||
|
||||
Round 1 · 2026-07-16 · all numbers below are **measured** with `node web/js/levels/index.js
|
||||
--sim` unless flagged otherwise. Read §→ Lane X for anything addressed to you.
|
||||
|
||||
---
|
||||
|
||||
## What landed (round 1)
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `web/js/levels/index.js` | registry + loader + **schema selfcheck** (qa gate) + **pacing simulator** |
|
||||
| `web/js/levels/enemies.js` | enemy catalogue (fiction id → archetype), pickup + hazard vocabularies |
|
||||
| `web/js/levels/difficulty.js` | the single difficulty table + `applyDifficulty()` |
|
||||
| `web/js/levels/L2_esophagus.json` | **the vertical slice** — complete: 3600 units, 7 beats, 65 enemies, 47 events |
|
||||
| `web/js/levels/L1_mouth.json` | skeleton (shape only) |
|
||||
| `web/js/levels/L3_stomach.json` | skeleton (shape only) |
|
||||
| `docs/TECH.md` §Level schema | ratified **v1** (was v0) |
|
||||
| `docs/GDD.md` | per-level time targets, esophagus roster, L2 setpieces (bannered) |
|
||||
| `web/js/stub/world_stub.js` | `STUB_LEVEL` synced to v1 (authorised by ROUND1 §Lane C.1 — **only** that const) |
|
||||
|
||||
`node web/js/levels/index.js --selfcheck` → **3/3 valid, exit 0**. qa.sh green.
|
||||
|
||||
---
|
||||
|
||||
## Schema v1 — what changed from v0, and why
|
||||
|
||||
Ratified in TECH.md. All additive; every v0 level still parses. I own it from here.
|
||||
|
||||
| added | why it was needed |
|
||||
|---|---|
|
||||
| `schema` | so a level and its loader can disagree loudly instead of silently |
|
||||
| `theta` / `rho` | v0 could only place content **along** the canal. A wall-mounted turret and a "visible-but-costly" sample are both statements about the **cross-section**. `rho` is a **fraction of the safe radius**, never a distance — radius varies with s, so a fraction is the only placement that cannot end up inside a wall. |
|
||||
| `spread` | `count: 6` at a single `s` is a pile, not a field |
|
||||
| `span` | hazards have extent (a 400-unit squeeze zone), not just a trigger point |
|
||||
| `par` | medals need thresholds; GDD §Scoring had nowhere to live |
|
||||
| `pickup`, `gate` types | the pickup economy and the level→level seam were undefined in v0 |
|
||||
| segment `name` | the anatomy, separated from the biome (see below) |
|
||||
| `next` | campaign order belonged in data, not in code |
|
||||
|
||||
**The one real design decision in v1: `biome` ≠ anatomy.** Lane A's registry has six canonical
|
||||
ids (`oral · esophagus · stomach · small_intestine · large_intestine · appendix`). I had
|
||||
authored `mouth`/`pharynx`/`cardia`/`pylorus` and the selfcheck rejected all four against A's
|
||||
live registry. **A is right and I conformed** — biome is the *tissue/look family*,
|
||||
`segments[].name` carries the anatomy. There is no cardia biome and there should not be one.
|
||||
|
||||
That turned out to be a gift rather than a compromise, because it lets colour do the
|
||||
wayfinding:
|
||||
- **L1's oropharynx wears the esophagus palette** — the tube goes teal 200 units before the
|
||||
epiglottis, so you read the transition in the walls before any HUD tells you.
|
||||
- **L3's cardia keeps the esophagus palette** — you arrive in teal and the world turns orange
|
||||
the moment the fundus opens. The acid sea *reveals itself*. Spending a biome id on the
|
||||
cardia would have thrown that reveal away.
|
||||
|
||||
---
|
||||
|
||||
## L2_esophagus — the vertical slice
|
||||
|
||||
**Identity: the sprint.** The purest tube in the body, one biome, no arenas, everything
|
||||
readable at 14–20 u/s. Par **3:30**; measured speedrun **2:47**, timid run **4:22**.
|
||||
|
||||
**Scale, stated honestly:** cross-sections are anatomically true (radius 10–12 units ≈ a 2 cm
|
||||
lumen at 1 unit : 1 mm). Lengths are **dilated ~14×** — the real esophagus is 25 cm, this
|
||||
level is 3600 units. The canal is *proportioned*, not measured. I would rather say that out
|
||||
loud once than have five lanes quietly assume different scales.
|
||||
|
||||
**Anatomy is the level designer.** The esophagus has three real constrictions
|
||||
(cricopharyngeal, broncho-aortic, diaphragmatic hiatus). They are the level's three
|
||||
setpieces, in the real order. Deviations I chose, on purpose:
|
||||
- The broncho-aortic constriction is a single ~2 cm feature in life; L2 dilates it into two
|
||||
beats (aortic at 1100, bronchial at 2000) because one is a spike and one is a ramp.
|
||||
- In life the **cricopharyngeal** narrowing is the tightest of the three. L2 makes the
|
||||
**hiatus** tightest instead, because the level needs to escalate and beat 1 is the tutorial.
|
||||
|
||||
### Measured pacing (`--sim L2_esophagus`)
|
||||
|
||||
```
|
||||
beat s-range len flow en dens tight haz PRESSURE clear t cum
|
||||
Upper Esophageal Sphincter 0- 400 400 12 1 0.25 1.09 1.00 0.27 .......... 6.63 29s 0:29
|
||||
Cervical Esophagus 400-1100 700 14 15 2.14 1.00 1.00 2.14 ###....... 6.79 43s 1:12
|
||||
Aortic Constriction 1100-1500 400 16 9 2.25 1.33 1.45 4.35 #######... 2.88 22s 1:34
|
||||
Thoracic Esophagus 1500-2000 500 14 3 0.60 1.00 1.15 0.69 #......... 6.55 31s 2:05
|
||||
Bronchial Narrowing 2000-2600 600 16 17 2.83 1.20 1.33 4.53 #######... 4.45 33s 2:38
|
||||
Diaphragmatic Hiatus 2600-3000 400 18 9 2.25 1.76 1.60 6.35 ########## 2.73 19s 2:57
|
||||
Abdominal Esophagus 3000-3600 600 20 11 1.83 1.33 1.80 4.40 #######... 4.15 22s 3:19
|
||||
```
|
||||
|
||||
`pressure = density × tightness × hazard` — density alone is a lie in a tube, because 9
|
||||
enemies in a 6.8-unit hole is not 9 enemies in a 12-unit hole.
|
||||
|
||||
**The curve: 0.27 → 2.14 → 4.35 → 0.69 → 4.53 → 6.35 → 4.40.** Breather → pressure → SPIKE →
|
||||
breather → pressure → SPIKE → finale. Two waves, escalating (4.35 → 4.53 → peak 6.35).
|
||||
|
||||
**The finale is not the pressure peak, and that is deliberate.** You cannot sustain
|
||||
maximum dodge-density at maximum speed and call it fair. The finale's intensity is *stakes*,
|
||||
not density: it is the only place in L2 where a mistake is **fatal** rather than expensive.
|
||||
The pressure index cannot see that — it is a relative index, not a fun-meter. Flagging it so
|
||||
nobody "fixes" beat 7 by piling enemies into it.
|
||||
|
||||
**Honesty about this evidence:** the sim is a *kinematic model*, not a playtest. It assumes a
|
||||
constant throttle and no combat slowdown (fair-ish in tube mode: the current carries you and
|
||||
you cannot stop). Times are **lower bounds** on a real run. It cannot tell me whether the
|
||||
hiatus *feels* good — only that it is flyable, paced, and survivable. Verdict on feel needs
|
||||
A's world + B's controller in round 2. See §Why no in-engine flythrough.
|
||||
|
||||
### Teach-then-test ledger
|
||||
|
||||
Every mechanic appears safe before it appears lethal. This is the law I checked the level against:
|
||||
|
||||
| mechanic | taught (safe) | tested |
|
||||
|---|---|---|
|
||||
| the flow itself | b1, s 0–400 — slowest flow, straightest tube, one lazy chunk | the whole level |
|
||||
| bolus chunk | s 240 — one, slow, unmissable | s 1260, in a 9-unit tube that pulses |
|
||||
| eosinophil swarm | s 620 — three, open water, nothing else happening | s 2880, chasing you through the hiatus gate |
|
||||
| candida bloom | s 760 — one, straight wall, 200-unit sightline | s 1180, in the squeeze that pushes you at it |
|
||||
| ring gate | s 2100 — open 67% of the time | s 2800 — open 40%, in the tightest tube |
|
||||
| **reflux surge** | **s 1700 — the "burp": 16 u/s vs 14 flow, non-lethal** | **s 3000 — 21 u/s, lethal, 600 units** |
|
||||
|
||||
The burp is the most important event in the level. When the real surge arrives 1300 units
|
||||
later, the player already knows the verb.
|
||||
|
||||
**One deliberate exception:** `aortic_squeeze` (s 1100) is first met *as* the spike. It gets
|
||||
away with it because it is non-lethal (graze damage) and 400 units long, so its first two
|
||||
pulses teach it inside its own zone. If B makes squeeze contact lethal, this breaks — tell me.
|
||||
|
||||
### Checkpoints — death costs ≤ 30 s (measured, at par throttle)
|
||||
|
||||
10 checkpoints, sphincter-themed where anatomy allows. Max gap **28.8 s** (the opening
|
||||
breather, where you are least likely to die). Every gap is enforced by the selfcheck, so this
|
||||
cannot silently rot.
|
||||
|
||||
```
|
||||
20 -> 420 400u 28.8s 420 -> 860 440u 27.3s 860 -> 1080 220u 13.7s
|
||||
1080 -> 1520 440u 24.2s 1520 -> 1680 160u 9.9s 1680 -> 2000 320u 19.9s
|
||||
2000 -> 2280 280u 15.2s 2280 -> 2580 300u 16.3s 2580 -> 2980 400u 19.4s
|
||||
2980 -> 3600 620u 23.2s
|
||||
```
|
||||
|
||||
Checkpoints sit **before** hazard zones, never inside them (1080 precedes the squeeze at
|
||||
1100; 2980 precedes the surge at 3000). Respawning a player inside a squeeze is a bug.
|
||||
|
||||
### The three biopsy samples — visible-but-costly, three different costs
|
||||
|
||||
Never on the racing line, always in plain sight. The cost is what differs:
|
||||
|
||||
| # | s | cost |
|
||||
|---|---|---|
|
||||
| 1 | 700, θ=π, ρ=0.92 | **proximity** — pinned to the far wall in the tail of the bolus field: wall-hug (graze) while chunks tumble past |
|
||||
| 2 | 1300, θ=π/2, ρ=0.90 | **timing** — against the wall *inside* the squeeze zone: eat a pulse, or thread it between beats |
|
||||
| 3 | 3200, θ=3π/2, ρ=0.85 | **the chase** — ~1.5 s off-line with an acid wall ~5 s behind you. The only one that is a real decision at full speed. |
|
||||
|
||||
### The finale, tuned
|
||||
|
||||
Surge 21 u/s spawning 40 units behind you, chasing 600 units. Segment flow is 20, throttle
|
||||
range ×0.6–1.4 ⇒ 12–28 u/s. So: throttle 1.05+ outruns it; dawdling at 12 u/s kills you. Par
|
||||
pace (~26 u/s) finishes ~5 s clear. **The level's last instruction is "do not dawdle".**
|
||||
|
||||
The antacid torpedo is the skill expression: B's `balance.js` gives it
|
||||
`neutralizeRadius: 14, neutralizeDuration: 10`, so **fired backward it stalls the surge ~10 s**.
|
||||
L2 hands you 2 antacid at s 2300 and 1 at s 3080, never explains this, and rewards you for
|
||||
noticing. On `hard`, `difficulty.js` puts the surge at 23.1 u/s and the chase stops being a
|
||||
sprint and becomes a route-planning problem.
|
||||
|
||||
### L2 roster — all three are real esophageal pathology
|
||||
|
||||
| fiction | archetype | real thing |
|
||||
|---|---|---|
|
||||
| `bolus_chunk` | floater | swallowed food debris in the swallow |
|
||||
| `eosinophil_swarm` | seeker | **eosinophilic esophagitis** — immune cells that mistake ENDO-1 for the parasite they were built for |
|
||||
| `candida_bloom` | turret | **candida esophagitis** — white plaque colonies anchored to the mucosa |
|
||||
|
||||
Reality is weirder and better than invention (charter). All three fell out of "what actually
|
||||
goes wrong in an esophagus", and each one maps to a *different* archetype B already built.
|
||||
|
||||
---
|
||||
|
||||
## Why there is no in-engine flythrough this round
|
||||
|
||||
My charter says to fly each level on the stub (`?lvl=L2_esophagus&stub=1`). **That flag
|
||||
combination does not exist yet** — `boot.js` hardcodes `STUB_LEVEL` on the `?stub=1` path and
|
||||
never reads `flags.lvl`. Snippet offered in §→ Lane F.
|
||||
|
||||
Worth saying plainly: **even with that wiring, the stub could not validate L2's pacing.** The
|
||||
stub is single-segment (`levelData.segments[0]`), so it would render beat 1 and nothing else.
|
||||
L2's whole design is the *sequence* of 7 beats. The real flythrough needs Lane A's world and
|
||||
Lane B's controller, which is exactly the round-2 integration.
|
||||
|
||||
So round 1's evidence is the **pacing sim** — which is the right instrument for a data lane,
|
||||
and is stronger than eyeballing one beat: it checks all 10 checkpoint gaps, all 7 clearances
|
||||
and the par against three throttle profiles, on every qa run, forever. What it cannot do is
|
||||
tell me the hiatus *feels* good. That claim I am not making.
|
||||
|
||||
---
|
||||
|
||||
## Boss specs
|
||||
|
||||
One-pager each: phases → telegraphs → windows → failure pressure. Written now so **A can
|
||||
shape lairs** and **D can concept hero meshes** in round 2. Numbers are design intent, not
|
||||
measured — they need B's framework to tune against.
|
||||
|
||||
**The roster's shape: three bosses, three different pressures.** Guardian = a *coat* race
|
||||
(positional attrition). Tapeworm = a *distance* race (a chase you can lose without dying).
|
||||
Blockage = a *hull* race + doom timer (darkness and risk/reward). If they all became DPS
|
||||
checks we would have built one boss three times.
|
||||
|
||||
### 1. Pyloric Guardian — L3 exit, arena (`arenas[2]`, s 2350, radius 70)
|
||||
|
||||
The pyloric sphincter itself: a muscular iris, hypertrophied and colonised. It periodically
|
||||
opens to pass chyme — **that cycle is the whole fight**, because the nodes are only exposed
|
||||
when the ring relaxes. The arena churns throughout, pushing you at the walls.
|
||||
|
||||
| phase | hp | the loop | window |
|
||||
|---|---|---|---|
|
||||
| **P1 Peristaltic Cycle** | 100→66% | iris opens 2.5 s every 8 s; 4 mucosal nodes glow at the rim, vulnerable only while open. 3 fixed vents spit acid on a 3 s cycle. | **2.5 s / 8 s (31%)** |
|
||||
| **P2 Reflux Fury** | 66→33% | cycle tightens to 1.8 s every 5 s. Between openings it vomits bolus chunks + pepsin swarms into the arena. 2 of the 4 regrown nodes are armored — **antacid strips them**. Churn ×1.5. | **1.8 s / 5 s (36%)** |
|
||||
| **P3 Death Grip** | 33→0% | it stops opening and tries to **seal permanently — 25 s doom timer**. The core is exposed only when it spasms. Acid vents become a rotating sweep. | **1.2 s every ~4 s**, ×5 hits |
|
||||
|
||||
- **Telegraphs:** a deep gulp 2 s before every open · nodes amber → white-hot when vulnerable ·
|
||||
vents dribble 0.8 s before spitting · P3's window = the whole arena flashes and **the churn
|
||||
reverses** for 1.2 s (you feel the tell in the controls, not just the eyes).
|
||||
- **Failure pressure:** stomach coat-drain is continuous, so this is a **race against your
|
||||
coat, not its hp**. Mucus shallows on the greater curvature pause the drain but are far
|
||||
from the iris — the risk/reward is positional. P3 adds the doom timer.
|
||||
- **Scaling:** `difficulty.window` multiplies every window; doom timer 25 s × (1.3 / 1.0 / 0.85).
|
||||
|
||||
### 2. Tapeworm — L4 miniboss, tube chase
|
||||
|
||||
*Taenia solium*, metres long, coiled through the ileum. **It does not fight you — it runs**,
|
||||
and the canal is its body. A chase at high flow through the villi forest, which occludes
|
||||
sightlines and keeps making it vanish.
|
||||
|
||||
| phase | hp | the loop | window |
|
||||
|---|---|---|---|
|
||||
| **P1 The Tail** | 100→70% | you catch the tail. 6 proglottids, each with a pale weak point — a **genital pore**, which real proglottids actually have — on one face only. It rolls on a 4 s period; shoot the pore as it comes round. Killed segments detach and become obstacles. | **~1.8 s of every 4 s roll** |
|
||||
| **P2 Constriction** | 70→40% | it coils around the lumen into 3 rotating rings you thread. Weak points move to the ring joints. | **1.5 s per joint pass** |
|
||||
| **P3 The Scolex** | 40→0% | it turns to face you: four suckers and a rostellum of hooks, charging head-on. Hooks fire as projectiles. Weak point = the suckers, exposed as it lunges. | **1.4 s per lunge** |
|
||||
|
||||
- **Telegraphs:** pore-glow ~1.2 s before it is shootable · a grinding drone that rises as you
|
||||
close (distance made audible — the villi hide it visually) · hook volleys pre-flash ·
|
||||
the scolex rears with a wet inhale 1.2 s before each lunge.
|
||||
- **Failure pressure:** **it is a chase — you can lose without dying.** Fall behind and it
|
||||
escapes: soft fail, level continues, medal gone, and **it reappears in L5 as a hazard**.
|
||||
A consequence the player earned is worth more than a retry screen.
|
||||
|
||||
### 3. The Blockage — L5 final boss, tube→arena, in the dark
|
||||
|
||||
A fecalith the size of the lumen: compacted mass, colonised, embedded with antibody turrets
|
||||
and **gas pockets**. The colon behind it has been distending for the whole level. Darkness is
|
||||
the medium — **the gas pockets are the light source**, which is the whole look of the fight.
|
||||
|
||||
| phase | hp | the loop | window |
|
||||
|---|---|---|---|
|
||||
| **P1 The Face** | 100→60% | 5 armored plates (3 hits each). Turrets fire homing darts from between them. **Gas pockets bulge between plates: shoot one and it detonates, destroying adjacent plates outright — and hurting you inside ~15 units.** Optimal play = detonate 2–3 from max range. | continuous; the pockets are the timer |
|
||||
| **P2 Fragmentation** | 60→30% | the mass breaks into 4 tumbling chunks, each with a turret, orbiting the arena. Gas vents fire jets that shove you into them. Core opens intermittently. | **2 s every 6 s** |
|
||||
| **P3 The Push** | 30→0% | the colon begins the final peristaltic push: the arena contracts on a 5 s beat and drives the Blockage toward the exit **with you behind it**. **30 s doom timer** — if it plugs the rectum, you lose. Core exposed on each contraction. | **1.6 s every 5 s** |
|
||||
|
||||
- **Finale:** it blows, and the canal expels you both. (GDD: triumphant ejection.)
|
||||
- **Telegraphs:** pockets *glow* — in the dark they are the brightest thing in the room, so
|
||||
the risk telegraphs itself · plate cracks show damage state · a full-canal groan 1.5 s
|
||||
before each contraction · turret pre-fire flash.
|
||||
- **Failure pressure:** **hull attrition, inverting the Guardian.** Colon coat-drain is mild,
|
||||
but gas blasts and darts hit the hull, and hull is pickup-only. Plus the P3 doom timer.
|
||||
|
||||
---
|
||||
|
||||
## → Lane B
|
||||
|
||||
Four things, in priority order. (1) is the only one that blocks you consuming my levels.
|
||||
|
||||
**1. Resolve fiction ids → archetypes.** Your pools are keyed by archetype; my levels name
|
||||
enemies in fiction. One line in `combat/index.js`:
|
||||
|
||||
```js
|
||||
import { getEnemy } from '../levels/index.js';
|
||||
// ...inside the level:event handler, replacing enemies.spawn(ev.enemy, ...):
|
||||
const spec = getEnemy(ev.enemy);
|
||||
enemies.spawn(spec?.archetype ?? ev.enemy, { s: ..., theta: ..., rho: ... });
|
||||
```
|
||||
|
||||
`?? ev.enemy` keeps bare archetype names working, and `enemies.js` has passthrough entries
|
||||
for `floater`/`seeker`/`turret`, so **this is backward compatible and STUB_LEVEL still works
|
||||
unchanged** — I deliberately did not rename the stub's enemies out from under your harness.
|
||||
|
||||
**2. `theta` may be an array** (one per instance) for wall-mounted groups — `theta: [1.05, 4.19]`
|
||||
with `count: 2`. Your loop currently passes `ev.theta` whole to every instance. Needs:
|
||||
|
||||
```js
|
||||
theta: Array.isArray(ev.theta) ? ev.theta[i % ev.theta.length] : ev.theta ?? null,
|
||||
```
|
||||
|
||||
Until that lands I have kept STUB_LEVEL on scalar thetas so nothing breaks. L2 uses arrays.
|
||||
|
||||
**3. `spread`** (units along s a group occupies) — you currently hardcode `i * 3.5`. L2 tunes
|
||||
this per encounter (60 = a wall of traffic in the hiatus; 200 = strung out along the chase):
|
||||
|
||||
```js
|
||||
const spread = ev.spread ?? (3.5 * ((ev.count ?? 1) - 1));
|
||||
const s = (ev.s ?? 0) + (ev.count > 1 ? (i / (ev.count - 1)) * spread : 0);
|
||||
```
|
||||
|
||||
**4. Heads-up — your tuning.js header is stale.** It documents
|
||||
`wallRho = radius − waveAmp(0.9) − skin(0.6) => ~7.0..10.0 playable disc`. Lane A has since
|
||||
**measured the esophagus at `amp 1.4`** (+ `breathe 0.15`, and A's `waveMaxAt` subtracts
|
||||
both). Your real envelope in a radius-10 tube is now **~6.35..9.35**, and your disc top speed
|
||||
/ graze tuning was sized against the old number. Not my call to change — flagging it because
|
||||
it moved under both of us. My selfcheck reads A's registry live, which is how I caught it.
|
||||
|
||||
**Balance ownership — agreed, and I removed my duplicate.** You own mechanics (hp, radius,
|
||||
speed, damage, fire rate, **score**), keyed by archetype, in `balance.js`. I own composition
|
||||
(which fiction, how many, where, in what order). My first cut of `enemies.js` restated
|
||||
hp/size/speed/score; that was over-reach — an `hp: 2` here and an `hp: 24` there (against
|
||||
`damage: 12`) is the same number twice. **`levels/enemies.js` is now a join table only**, and
|
||||
`levels/index.js` reads your scores via `scoreFor()` to compute the score budget:
|
||||
**L2 par.score 9000 of a 9400 kill budget = 95%**, computed from *your* numbers, not mine.
|
||||
If you retune scores, my par re-checks itself on the next qa run.
|
||||
|
||||
**Hazards I placed that are yours:** `reflux_surge` (see `HAZARDS` in `levels/enemies.js` for
|
||||
params). L2 has a non-lethal one at s 1700 and the lethal finale at s 3000. They are **inert
|
||||
data until you implement them** — that is by design (charter: stub it in data first), not a bug.
|
||||
Also: if `aortic_squeeze` contact ever becomes lethal rather than graze, tell me — L2's
|
||||
teach-then-test ledger depends on it being survivable.
|
||||
|
||||
## → Lane A
|
||||
|
||||
**1. Biome conformance — done, no action needed.** I had authored `mouth`/`pharynx`/`cardia`/
|
||||
`pylorus`; your registry rejected all four and **your design is right**. `biome` = look,
|
||||
`segments[].name` = anatomy. **You do not need to add biome ids for C.** Your six cover the
|
||||
campaign, and I made the constraint pay for itself (see §Schema v1 — the cardia keeping the
|
||||
esophagus palette is why the acid sea gets to *reveal* itself).
|
||||
|
||||
**2. Your amp 0.9 → 1.4 landed mid-session and cost me 0.5 units of flyable radius.** No
|
||||
complaint — your reasoning (B cannot surf a crest the player cannot see) is better than my
|
||||
tube. The diaphragmatic hiatus went unflyable at base 6.5 and I widened it to **6.8** (free
|
||||
radius 2.73, against my 2.5 floor). This is working as intended: my selfcheck reads
|
||||
`wave.amp + wave.breathe` from your registry live, so **if you retune again, my levels fail qa
|
||||
and I re-tune them.** Please keep changing it if it looks better — just expect a red qa.
|
||||
|
||||
**3. Request (round 2): per-segment `wave` override.** Anatomically the hiatus is a fixed
|
||||
muscular ring held by the diaphragm crura — it does **not** have big peristaltic waves; the
|
||||
esophagus above it does. Right now one amp applies to the whole biome, so the level's tightest
|
||||
hole gets the level's biggest wave, which is both wrong and the thing squeezing my design. I
|
||||
have **not** added it to the schema unilaterally. Proposed:
|
||||
`segments[].wave: { amp: 0.6 }` overriding the biome, `waveMaxAt(s)` preferring the segment.
|
||||
Your call — say yes in NOTES and I will spec it into v2 and re-tune.
|
||||
|
||||
**4. Do segments blend radius at boundaries?** L2 steps 12 → 9 (s 1100) and 10 → 6.8 (s 2600).
|
||||
**C's design assumes a smooth blend over ~40–60 units**, not a cliff — a hard radius step
|
||||
would read as a wall and the constrictions are supposed to feel like the body narrowing, not
|
||||
like a level seam. If your tube hard-steps, tell me and I will author explicit taper segments
|
||||
instead. This is my biggest open dependency on you.
|
||||
|
||||
**5. Arena semantics — ratified in TECH v1 as I read your `arenaSpatial`:** a sphere of
|
||||
`radius` centred on the centreline at `at`, spanning `[at-radius, at+radius]`, `modeAt` ⇒
|
||||
`'arena'` inside. **Problem for L3:** the stomach body is not a sphere. `L3_stomach.json`
|
||||
currently fakes it with one radius-180 sphere and that is flagged PROVISIONAL in the file. It
|
||||
needs either a capsule/blob primitive or chained overlapping spheres. **Not urgent** (L3 is
|
||||
round 3+), but it will decide how I author the acid sea, so I would like your read early.
|
||||
|
||||
**6. Hazards that are yours:** `aortic_squeeze` and `ring_gate` (params + rationale in
|
||||
`HAZARDS`, `levels/enemies.js`). Both are inert data until you build them — fine by me.
|
||||
The important one: **`aortic_squeeze` is directional (one-sided) on purpose.** It presses from
|
||||
`theta` and the opposite arc stays clear. A symmetric squeeze would be an untimeable toll,
|
||||
because in a flow-locked tube the player cannot stop and would eat all ~10 pulses of a
|
||||
400-unit zone. Directional turns it into a positioning verb — and it is also what the aorta
|
||||
actually does. `ring_gate` is the opposite: a **point**, therefore timeable, because the
|
||||
player can modulate throttle on approach to arrive on an open beat. Two hazards, two verbs.
|
||||
|
||||
## → Lane D
|
||||
|
||||
- **Concept these three for L2** (round 2 hero-ish props, ART_BIBLE emissive law):
|
||||
`bolus_chunk` (tumbling debris, amber `0xd98a3a`), `eosinophil_swarm` (small fast, red
|
||||
`0xff4d3d`), `candida_bloom` (**wall-mounted white plaque colony**, `0xffd166`). Tints in
|
||||
`levels/enemies.js` are advisory — yours to make beautiful.
|
||||
- **Boss specs are ready above** (§Boss specs) — phases and silhouettes are stable enough to
|
||||
concept the Pyloric Guardian (a muscular iris with 4 rim nodes) and the Tapeworm (segmented
|
||||
body + a 4-sucker scolex). The Blockage is a mass of plates + glowing gas pockets.
|
||||
- The `candida_bloom` is the interesting modelling problem: it is **flat against a curved
|
||||
wall**, not a free-floating prop. Might be a decal/plane rather than a GLB.
|
||||
- L2 needs your esophagus wall pack — already on your round-1 list, nothing extra from me.
|
||||
|
||||
## → Lane E
|
||||
|
||||
**1. Bus events I need** (F referees the names — proposing here):
|
||||
- `hazard:warn { kind, s, eta }` — fires `warn` seconds before a hazard (my events carry
|
||||
`warn`, default 2.5 s). Drives your HUD ping + `audio:cue`.
|
||||
- `hazard:proximity { kind, distance }` — **the reflux surge chases you from behind.** This is
|
||||
a forward-facing game; without a rear indicator the finale is unfair rather than tense. This
|
||||
is the single most important UI request I have. A rear-distance bar, a rising audio bed,
|
||||
screen-edge acid creep — your call, but the player must *feel* 5 s of margin shrink.
|
||||
|
||||
**2. Gut-map** — L2 is **3600 units, 7 named segments, 10 checkpoints**. Segment `name`s are
|
||||
authored for exactly this (`Upper Esophageal Sphincter`, `Aortic Constriction`,
|
||||
`Diaphragmatic Hiatus`…). `listLevels()` in `levels/index.js` gives you
|
||||
`{ id, name, tagline, length, par, skeleton }` for menus; `getLevel(id)` gives the rest.
|
||||
|
||||
**3. Telegraph budget:** L2 is flown at 14–20 u/s (up to 28 in the finale). Anything lethal
|
||||
telegraphs ≥ 2 s = **40–56 units** upstream. My selfcheck warns if I author a lethal hazard
|
||||
with `warn < 2`.
|
||||
|
||||
## → Lane F
|
||||
|
||||
**1. `?stub=1&lvl=<id>` does not compose.** `boot.js` `pickWorld()` hardcodes `STUB_LEVEL` on
|
||||
the stub path and never reads `flags.lvl` — so the flag table's C-owned `?lvl=<id>` row does
|
||||
not work with `?stub=1`, which is the combination my charter tells lanes to verify levels
|
||||
with. Offered (your file, your call):
|
||||
|
||||
```js
|
||||
async function pickWorld() {
|
||||
const level = await loadLevel(flags.lvl).catch(() => null); // C's registry, when present
|
||||
if (!flags.stub) {
|
||||
try {
|
||||
const mod = await import('./world/index.js');
|
||||
const lv = level ?? STUB_LEVEL;
|
||||
return mod.createWorld(lv, { rng: createRng(flags.seed ?? lv.seed) });
|
||||
} catch (e) {
|
||||
console.info('[boot] Lane A world not available, using stub —', e.message);
|
||||
}
|
||||
}
|
||||
const lv = level ?? STUB_LEVEL; // ?stub=1&lvl=L2 => C's data
|
||||
return createStubWorld(lv, { rng: createRng(flags.seed ?? lv.seed) });
|
||||
}
|
||||
```
|
||||
|
||||
Caveat so nobody expects too much: the stub is single-segment, so `?stub=1&lvl=L2_esophagus`
|
||||
renders **beat 1 only**. Still useful (it proves the data path end-to-end); it is not a
|
||||
flythrough. The real one is round 2 on A's world.
|
||||
|
||||
**2. `getLevel()` defaults to `L2_esophagus`** (`DEFAULT_LEVEL` in `levels/index.js`) — booting
|
||||
with no `?lvl` gives you the vertical slice. Change it if you would rather it be L1.
|
||||
|
||||
**3. Bus table (TECH §Bus events)** — please referee `hazard:warn {kind,s,eta}` and
|
||||
`hazard:proximity {kind,distance}` (rationale in §→ Lane E). Also note B is already emitting
|
||||
`level:neutralize`, which isn't in the table yet.
|
||||
|
||||
**4. Schema v1 is ratified** in TECH.md §Level data schema — I rewrote that section only.
|
||||
I also touched **exactly one const** in your `world_stub.js` (`STUB_LEVEL`, per ROUND1 §Lane
|
||||
C.1) and left the geometry untouched, so your round-0 screenshot still stands.
|
||||
|
||||
**5. qa.sh, minor:** the `web/js/levels/*.json` loop asserts `id`/`seed`/`segments` on every
|
||||
JSON in that directory. No collision today (my catalogue and difficulty table are `.js`), but
|
||||
it means C can never put a non-level JSON there. Fine by me — noting it so it is a decision
|
||||
rather than a trap.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Segment radius blending (A)** — biggest one. If the tube hard-steps at segment
|
||||
boundaries, L2's constrictions read as seams and I re-author with taper segments.
|
||||
2. **Per-segment `wave` override (A)** — would let the hiatus be tight *and* calm, which is
|
||||
both better design and better anatomy.
|
||||
3. **Ship radius 0.9 (B)** — I took it from `flight/tuning.js` (`ship.radius`, the collision
|
||||
sphere, vs a hull that "reads ~1.9 long, 1.2 wide"). My `MIN_CLEARANCE` of 2.5 is ~2.8× it.
|
||||
If the effective collision radius is really the visual 1.2, my two spikes lose 0.3 and the
|
||||
hiatus goes under the floor.
|
||||
4. **Is `MIN_CLEARANCE = 2.5` right?** It is my judgement call, unvalidated by anything except
|
||||
arithmetic. It is the number that decides whether L2's setpieces are exciting or miserable,
|
||||
and only B's controller in a real tube can settle it. **If one thing from this round gets
|
||||
playtested in round 2, make it the hiatus at s 2600–3000.**
|
||||
5. **Par time 3:30 vs the GDD's old flat "6–10 min/level"** — I refined the GDD to per-level
|
||||
targets rather than pad L2. A flow-locked tube bounds its own length: a timid run is 4:22
|
||||
and there is no way to dawdle further. F to sign off.
|
||||
|
||||
## Next round (C)
|
||||
|
||||
- Fly L2 on A's world + B's controller. **Replace the sim's numbers with real ones**, and
|
||||
finally answer whether the hiatus feels good.
|
||||
- L1 encounter pass (tutorial beats, molar crush-cycles, saliva tides, the epiglottis exam) —
|
||||
needs B's arena controller.
|
||||
- L3 encounter pass + the acid sea's mucus-shallow lanes — needs A's arena decision (§→ A.5).
|
||||
- `levels/bosses/pyloric_guardian.js` — the phase state machine, once B's enemy framework has
|
||||
a boss hook. Spec is above and stable.
|
||||
- Fold the boss specs into the GDD proper once F signs them off.
|
||||
- Tune `difficulty.js` against real play (the table is authored, not validated).
|
||||
78
docs/TECH.md
78
docs/TECH.md
@ -67,27 +67,79 @@ world.update(dt, playerS) // stream chunks, advance shader time
|
||||
Determinism law: same `levelData.seed` ⇒ byte-identical geometry. A maintains a golden
|
||||
hash (`world.hash()` over sampled frames) checked by qa.
|
||||
|
||||
## Level data schema v0 (C owns; F drafted; sign-off in round 1)
|
||||
## Level data schema **v1** (C owns — ratified round 1; F drafted v0)
|
||||
|
||||
Changes from v0 are additive: every v0 level still parses. New in v1 — `schema`, `par`,
|
||||
`next`, segment `name`, cross-section placement (`theta`/`rho`), group `spread`, zone
|
||||
`span`, event types `pickup`/`gate`, and hazard params. Amend via LANE_C_NOTES + F referee,
|
||||
never silently. Validated by `node web/js/levels/index.js --selfcheck` (qa gate).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "L2_esophagus", "name": "Esophagus", "seed": 20260716,
|
||||
"segments": [ // concatenated along s
|
||||
{ "biome": "esophagus", "length": 600,
|
||||
"radius": { "base": 10, "wobble": 0.25 }, // wobble: seeded radius variation 0..1
|
||||
"curviness": 0.6, // 0 straight .. 1 writhing
|
||||
"flow": 14 } // base current, units/s
|
||||
"schema": 1, // REQUIRED. levels/index.js refuses other versions.
|
||||
"id": "L2_esophagus", // must equal the filename
|
||||
"name": "Esophagus", "tagline": "Ride the swallow", // tagline: optional, for E's menus
|
||||
"seed": 20260716, // int. Determinism law: same seed => same geometry.
|
||||
"par": { "time": 210, "score": 9000, "samples": 3 }, // medal thresholds (GDD §Scoring)
|
||||
"next": "L3_stomach", // campaign order; null on the last level
|
||||
"design": { … }, // free-form. C's rationale; no consumer reads it.
|
||||
"segments": [ // concatenated along s, in order
|
||||
{ "biome": "esophagus", // MUST be an id from A's world/biomes.js registry.
|
||||
// biome = the LOOK/tissue family, not the anatomy —
|
||||
// that is what `name` is for. This is why there is
|
||||
// no 'cardia' or 'pharynx' biome and never will be.
|
||||
"name": "Aortic Constriction", // optional; the anatomy, for HUD/gut-map/docs
|
||||
"length": 400,
|
||||
"radius": { "base": 9, "wobble": 0.15 }, // wobble: seeded radius variation, 0..1
|
||||
"curviness": 0.5, // 0 straight .. 1 writhing
|
||||
"flow": 16 } // base current u/s; OVERRIDES the biome default
|
||||
],
|
||||
"arenas": [ { "at": 580, "radius": 90, "biome": "stomach" } ],
|
||||
"events": [ // consumed by B (spawns) & E (telegraphs) via bus
|
||||
{ "s": 40, "type": "spawn", "enemy": "bolus_chunk", "count": 6 },
|
||||
{ "s": 120, "type": "hazard", "kind": "reflux_surge", "speed": 22 },
|
||||
{ "s": 300, "type": "checkpoint" },
|
||||
{ "s": 560, "type": "boss", "id": "pyloric_guardian" }
|
||||
"arenas": [ // 6DOF bubbles. A sphere of `radius` centred on the
|
||||
{ "at": 2350, "radius": 70, // centreline at s=`at`, spanning [at-radius, at+radius].
|
||||
"biome": "stomach" } // modeAt(s) => 'arena' inside it. (Ratified round 1.)
|
||||
],
|
||||
"events": [ // MUST be sorted by s. Consumed by B (spawns/hazards)
|
||||
// and E (telegraphs) via `level:event` on the bus.
|
||||
{ "s": 520, "type": "spawn", "enemy": "bolus_chunk", "count": 4, "spread": 120 },
|
||||
{ "s": 980, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [1.05, 4.19] },
|
||||
{ "s": 700, "type": "pickup", "kind": "biopsy_sample", "theta": 3.1416, "rho": 0.92 },
|
||||
{ "s": 1100, "type": "hazard", "kind": "aortic_squeeze",
|
||||
"span": 400, "period": 2.4, "amplitude": 0.45, "theta": 0, "warn": 2.5 },
|
||||
{ "s": 3000, "type": "hazard", "kind": "reflux_surge",
|
||||
"speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true },
|
||||
{ "s": 2980, "type": "checkpoint", "name": "Cardia Approach" },
|
||||
{ "s": 2350, "type": "boss", "id": "pyloric_guardian" },
|
||||
{ "s": 3580, "type": "gate", "id": "cardiac_sphincter", "to": "L3_stomach" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Event fields.** `s` (required) · `type`: `spawn|pickup|hazard|checkpoint|boss|gate`.
|
||||
|
||||
| field | applies to | meaning |
|
||||
|---|---|---|
|
||||
| `enemy` | spawn | fiction id from `levels/enemies.js`. **Resolve the archetype via `getEnemy(id).archetype`** — B's pools are keyed by archetype; bare archetype names also resolve. |
|
||||
| `kind` | pickup, hazard | id from `PICKUPS` / `HAZARDS` in `levels/enemies.js` |
|
||||
| `count` | spawn, pickup | how many (default 1) |
|
||||
| `spread` | spawn, pickup | units along s the group occupies (default: B's spacing) |
|
||||
| `theta` | any | angle in the cross-section, **radians, [0, 2π)**. Number, or an array of length `count` (one per instance). **Required for wall-mounted enemies** (`wall: true`). |
|
||||
| `rho` | any | **fraction (0..1) of the safe radius**, never an absolute distance — the tube's radius varies with s, so a fraction is the only placement that cannot end up inside a wall. |
|
||||
| `span` | hazard | length of a zone hazard along s (omit ⇒ point hazard) |
|
||||
| `warn` | hazard | seconds of telegraph. Law: ≥ 2 s for anything lethal (readable at 10–20 u/s). |
|
||||
| `name` | checkpoint, segment | display/anatomy label |
|
||||
| `to` | gate | level id to load next |
|
||||
|
||||
**Placement law:** `theta`/`rho` are the cross-section; `s` is along the canal. Content is
|
||||
authored in spline space and never in world space — the spline is the only thing both C and
|
||||
A agree on. Wall-mounted content must have an explicit `theta`: which arc a turret owns is a
|
||||
design decision, never a random one.
|
||||
|
||||
**Playability invariant (C's law, enforced by the selfcheck):** at its narrowest, a segment
|
||||
must leave a free disc of radius ≥ 2.5 units after subtracting A's `wave.amp + wave.breathe`,
|
||||
A's `SKIN`, and B's hull radius. The selfcheck reads those live from `world/biomes.js`,
|
||||
`world/index.js` and `flight/tuning.js` — if A or B retune, C's levels fail qa and get
|
||||
re-tuned. Below 2.5 a constriction is not a challenge, it is a toll.
|
||||
|
||||
## Bus events (append here when adding; F referees name collisions)
|
||||
|
||||
`player:spawn {s}` · `player:damage {amount, kind}` · `player:death` · `player:boost` ·
|
||||
|
||||
62
docs/progress/c-progress.md
Normal file
62
docs/progress/c-progress.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Lane C (Levels & Encounters) — progress
|
||||
|
||||
## 2026-07-16 — Round 1: the data spine + the vertical slice
|
||||
|
||||
Landed:
|
||||
- **Schema v1 ratified** (TECH.md §Level data schema, C-owned from here). Additive over F's
|
||||
v0: `schema`, `par`, `next`, segment `name`, cross-section placement (`theta`/`rho`, rho as
|
||||
a *fraction* of the safe radius), group `spread`, zone `span`, `pickup`/`gate` event types,
|
||||
hazard params. STUB_LEVEL synced to v1 (only that const in F's file, per ROUND1 §Lane C.1).
|
||||
- **`levels/index.js`** — registry + loader (node + browser, no THREE), **schema selfcheck**
|
||||
(real; qa.sh's `--selfcheck` hook is now live) and a **pacing simulator** (`--sim`).
|
||||
The selfcheck consumes the other lanes' contracts live: A's `world/biomes.js` for biome ids
|
||||
and `wave.amp + wave.breathe`, A's `SKIN`, B's `flight/tuning.js` hull radius, B's
|
||||
`combat/balance.js` for scores. C's registry owns composition and mirrors nothing.
|
||||
- **`levels/enemies.js`** — enemy catalogue (fiction id → archetype) + pickup/hazard vocab.
|
||||
- **`levels/difficulty.js`** — the single difficulty table + `applyDifficulty()` (authored,
|
||||
not yet validated against play).
|
||||
- **`L2_esophagus.json`** — the vertical slice, complete: 3600 units, 7 beats, 47 events,
|
||||
65 enemies, 10 checkpoints, 3 samples, reflux-surge finale. `L1_mouth` / `L3_stomach`
|
||||
skeletons (shape only, flagged SKELETON — the selfcheck downgrades content laws for them).
|
||||
- **GDD refined in place** (bannered): per-level time targets replacing a flat "6–10 min",
|
||||
esophagus roster, L2's three real constrictions as its setpieces.
|
||||
- **Boss specs** (Guardian / Tapeworm / Blockage) as one-pagers in NOTES — phases,
|
||||
telegraphs, windows, failure pressure. Deliberately three *different* pressures: coat race,
|
||||
distance race, hull attrition + doom timer.
|
||||
|
||||
Verification (measured, `node web/js/levels/index.js --selfcheck|--sim`; qa.sh GREEN):
|
||||
- selfcheck **3/3 valid, exit 0**; 6 warnings, all expected (skeletons undesigned, L4 not yet
|
||||
registered).
|
||||
- L2 pressure curve **0.27 → 2.14 → 4.35 → 0.69 → 4.53 → 6.35 → 4.40** (breather → pressure →
|
||||
spike → breather → pressure → peak → finale).
|
||||
- L2 par 3:30 against a measured **2:47 speedrun / 3:19 par-pace / 4:22 timid** run;
|
||||
par.score 9000 of a 9400 kill budget (95%), computed from B's scores.
|
||||
- All 10 checkpoint gaps within the ≤30 s death-cost law (max 28.8 s). Min clearance 2.73 at
|
||||
the hiatus, against C's 2.5 floor.
|
||||
- Booted `?stub=1&dbg=1` after the STUB_LEVEL edit: tube renders, zero console errors, and
|
||||
the v1 module builds a valid world (`length 400`, `wallRho(0,0) = 8.5`, centre-line collide
|
||||
`null`). **`world.hash()` is byte-identical before and after my edit (`1bdf001`)** — the
|
||||
sync adds data fields only and provably does not move the geometry, so F's round-0 shot
|
||||
still stands. No screenshot committed: it would be pixel-identical to round 0's, and per
|
||||
charter a data lane's evidence is the pacing report, not a picture of an unchanged tube.
|
||||
(No fps claim: the preview pane throttles rAF in the background — `rafTicks: 0` — so
|
||||
framerate is unmeasurable here. Not a code fault; the render path is fine.)
|
||||
|
||||
Two contract collisions found and resolved (detail in NOTES):
|
||||
- **Biome ids** — I had authored `mouth`/`pharynx`/`cardia`/`pylorus`; A's registry has six
|
||||
canonical ids and rejected all four. A is right: biome = look, `segments[].name` = anatomy.
|
||||
Conformed, and the constraint paid for itself (the cardia keeping the esophagus palette is
|
||||
why the acid sea reveals itself in colour).
|
||||
- **Balance ownership** — my first `enemies.js` restated hp/size/speed/score that B already
|
||||
owns in `combat/balance.js`. Cut to a join table; `index.js` reads B's scores instead.
|
||||
|
||||
Caught live: A raised esophagus `wave.amp` 0.9 → 1.4 mid-session, which made the
|
||||
diaphragmatic hiatus unflyable (2.33 free radius vs the 2.5 floor). Widened 6.5 → 6.8. The
|
||||
invariant did its job on my own level twice — the aortic constriction also failed its first
|
||||
draft (radius 7, symmetric squeeze) and became radius 9 with a *directional* squeeze.
|
||||
|
||||
Not done / next round: no in-engine flythrough — `?stub=1&lvl=` does not compose yet (snippet
|
||||
offered to F) and the stub is single-segment, so it could only ever render beat 1. L2's
|
||||
pacing evidence is a kinematic **model**, not a playtest: it proves the level is flyable,
|
||||
paced and survivable; it cannot prove the hiatus *feels* good. That needs A's world + B's
|
||||
controller in round 2 — and if one thing gets playtested, it should be s 2600–3000.
|
||||
54
web/js/levels/L1_mouth.json
Normal file
54
web/js/levels/L1_mouth.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "L1_mouth",
|
||||
"name": "Oral Cavity",
|
||||
"tagline": "Learn to swim",
|
||||
"seed": 20260701,
|
||||
"par": { "time": 300, "score": 6000, "samples": 3 },
|
||||
"next": "L2_esophagus",
|
||||
"design": {
|
||||
"owner": "Lane C",
|
||||
"status": "SKELETON (round 1) - shape only: biomes, lengths, arenas, gate. Encounters, pickups, samples and tutorial beats are round 2+, after Lane B's arena-mode controller exists. Do not read the (near-empty) events list as a design.",
|
||||
"identity": "The tutorial, and the only level that is mostly arena. Teaches move / shoot / coat in safety, then examines all three at the epiglottis.",
|
||||
"anatomy": "Vestibule (outside the teeth) -> oral cavity proper (the arena, ringed by molars) -> oropharynx, narrowing to the epiglottis gate.",
|
||||
"biome_note": "biome = tissue/look family (Lane A's registry), segments[].name = the anatomy. There is no 'pharynx' biome and there should not be: the oropharynx is deliberately given the ESOPHAGUS palette so the tube turns teal as you approach the epiglottis. The colour shift is the wayfinding - it tells you where you are going before the gate does.",
|
||||
"scale": "Real oral cavity is ~8cm across; arena radius 90 units = 18cm diameter. Dilated ~2x - much less than L2's 14x, because arenas need room to fight in and the mouth is already a room.",
|
||||
"round2_plan": "Molar crush-cycles as timed hazards on the arena rim, saliva tides as a periodic current that sweeps the arena, amylase droplets as the tutorial floater. The epiglottis gate is a timing exam: dive on the swallow. 3 samples: one behind the molars, one in a tonsillar crypt, one under the tongue."
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"biome": "oral",
|
||||
"name": "Vestibule",
|
||||
"length": 120,
|
||||
"radius": { "base": 14, "wobble": 0.1 },
|
||||
"curviness": 0.1,
|
||||
"flow": 2,
|
||||
"note": "Injection point. Almost no current - the player's first 120 units are the only place in the game where nothing is pushing them anywhere."
|
||||
},
|
||||
{
|
||||
"biome": "oral",
|
||||
"name": "Oral Cavity",
|
||||
"length": 400,
|
||||
"radius": { "base": 30, "wobble": 0.2 },
|
||||
"curviness": 0.15,
|
||||
"flow": 2,
|
||||
"note": "The arena (see arenas[0]). 6DOF unlocks here. Molar crush-cycles ring it; saliva tides sweep it."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Oropharynx",
|
||||
"length": 200,
|
||||
"radius": { "base": 12, "wobble": 0.2 },
|
||||
"curviness": 0.35,
|
||||
"flow": 6,
|
||||
"note": "The funnel. Mode returns to tube and the current starts to build - the game re-teaching flow just before the gate hands the player to L2. Wears the ESOPHAGUS palette on purpose (see design.biome_note): the walls go teal 200 units before the epiglottis, so the player reads the transition in colour before they read it in a HUD."
|
||||
}
|
||||
],
|
||||
"arenas": [
|
||||
{ "at": 320, "radius": 90, "biome": "oral", "note": "The oral cavity proper. Spans s 230-410 (sphere of radius 90 centred at s=320)." }
|
||||
],
|
||||
"events": [
|
||||
{ "s": 20, "type": "checkpoint", "name": "Injection Point" },
|
||||
{ "s": 700, "type": "gate", "id": "epiglottis", "to": "L2_esophagus", "note": "The exam: dive through on the swallow. Timing gate, not a boss." }
|
||||
]
|
||||
}
|
||||
138
web/js/levels/L2_esophagus.json
Normal file
138
web/js/levels/L2_esophagus.json
Normal file
@ -0,0 +1,138 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "L2_esophagus",
|
||||
"name": "Esophagus",
|
||||
"tagline": "Ride the swallow",
|
||||
"seed": 20260716,
|
||||
"par": { "time": 210, "score": 9000, "samples": 3 },
|
||||
"next": "L3_stomach",
|
||||
"design": {
|
||||
"owner": "Lane C",
|
||||
"identity": "The speed level. Level 2 is the vertical slice and the purest tube in the body: one biome, no arenas, everything readable at 14-20 u/s.",
|
||||
"anatomy": "Real esophagus, real order: upper esophageal sphincter -> cervical -> the broncho-aortic constriction -> thoracic -> diaphragmatic hiatus -> abdominal esophagus -> cardiac sphincter (LES) into the stomach. The three narrowings are the three real anatomical constrictions and they are this level's three setpieces.",
|
||||
"scale": "Cross-sections are ANATOMICALLY TRUE (radius 10-12 units = ~2cm lumen at 1 unit : 1 mm). Lengths are DILATED ~14x (real esophagus is 25cm; this level is 3600 units). The canal is proportioned, not measured. See LANE_C_NOTES.md.",
|
||||
"curve": "breather -> pressure -> SPIKE -> breather -> pressure -> SPIKE -> lethal finale. Pressure indices measured in LANE_C_NOTES.md §L2 pacing.",
|
||||
"teach_then_test": "flow (b1) / bolus (b1 solo -> b3 in the pinch) / candida (b2 solo, clear sightline -> b3 in the squeeze) / eosinophil (b2 open stretch -> b6 in the hiatus) / ring_gate (b5 open 2.4s -> b6 open 1.2s) / reflux (b4 non-lethal burp -> b7 lethal finale)."
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Upper Esophageal Sphincter",
|
||||
"length": 400,
|
||||
"radius": { "base": 11, "wobble": 0.12 },
|
||||
"curviness": 0.15,
|
||||
"flow": 12,
|
||||
"note": "BEAT 1 - breather. You drop through the cricopharyngeus behind the swallow. Slowest flow in the level, straightest tube, one lazy bolus chunk: this stretch exists to teach that the current carries you and you steer within it."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Cervical Esophagus",
|
||||
"length": 700,
|
||||
"radius": { "base": 12, "wobble": 0.18 },
|
||||
"curviness": 0.35,
|
||||
"flow": 14,
|
||||
"note": "BEAT 2 - first pressure. Widest, most forgiving tube in the level, which is exactly why every enemy type gets introduced here, alone and survivable, before the pinch."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Aortic Constriction",
|
||||
"length": 400,
|
||||
"radius": { "base": 9, "wobble": 0.15 },
|
||||
"curviness": 0.5,
|
||||
"flow": 16,
|
||||
"note": "BEAT 3 - SPIKE. The aortic arch presses on the esophagus (real: the broncho-aortic constriction narrows the lumen to ~75%, which is exactly 12 -> 9). The tube also pulses with the heartbeat, from ONE SIDE - the aorta crosses on the left. Everything you were taught in beat 2, now in 75% of the room with a wall that breathes at you."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Thoracic Esophagus",
|
||||
"length": 500,
|
||||
"radius": { "base": 12, "wobble": 0.2 },
|
||||
"curviness": 0.4,
|
||||
"flow": 14,
|
||||
"note": "BEAT 4 - breather + the tutorial for the finale. Opens back out, mucin and orbs to recover, and one non-lethal 'burp' surge that teaches the reflux chase 1300 units before the real one tries to kill you."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Bronchial Narrowing",
|
||||
"length": 600,
|
||||
"radius": { "base": 10, "wobble": 0.25 },
|
||||
"curviness": 0.6,
|
||||
"flow": 16,
|
||||
"note": "BEAT 5 - the ramp. Highest curviness in the level: the tube writhes, so wall-mounted candida stay hidden until you round each bend. Sightline pressure rather than raw density."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Diaphragmatic Hiatus",
|
||||
"length": 400,
|
||||
"radius": { "base": 6.8, "wobble": 0.15 },
|
||||
"curviness": 0.3,
|
||||
"flow": 18,
|
||||
"note": "BEAT 6 - SPIKE. The tightest hole in the level: the esophagus threads the diaphragm. Free radius ~2.7 units - the narrowest thing Lane C will author, and only 0.2 above the MIN_CLEARANCE law. Base was 6.5 until Lane A measured the esophagus peristalsis at amp 1.4 (up from the stub's 0.9); the wave eats the difference, so the hole had to widen to stay a challenge instead of a toll. Straightened out on purpose - the challenge is the ring gate's timing, and a writhing tube would make that unreadable."
|
||||
},
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Abdominal Esophagus",
|
||||
"length": 600,
|
||||
"radius": { "base": 9, "wobble": 0.2 },
|
||||
"curviness": 0.45,
|
||||
"flow": 20,
|
||||
"note": "BEAT 7 - FINALE. Fastest flow in the level. The stomach refluxes and an acid wall chases you to the cardiac sphincter. Bolus chunks drift slower than the current, so here they read as traffic you overtake at speed."
|
||||
}
|
||||
],
|
||||
"arenas": [],
|
||||
"events": [
|
||||
{ "s": 20, "type": "checkpoint", "name": "Upper Esophageal Sphincter" },
|
||||
{ "s": 120, "type": "pickup", "kind": "nutrient_orb", "count": 3, "spread": 40, "note": "Orb lines teach the racing line before they score anything." },
|
||||
{ "s": 240, "type": "spawn", "enemy": "bolus_chunk", "count": 1, "note": "TEACH bolus: one chunk, slow, unmissable, harmless if you simply steer." },
|
||||
{ "s": 340, "type": "pickup", "kind": "nutrient_orb", "count": 3, "spread": 40 },
|
||||
|
||||
{ "s": 420, "type": "checkpoint", "name": "Cervical Narrowing" },
|
||||
{ "s": 520, "type": "spawn", "enemy": "bolus_chunk", "count": 4, "spread": 120, "note": "TEST bolus (mild): a field, but in the widest tube in the level." },
|
||||
{ "s": 620, "type": "spawn", "enemy": "eosinophil_swarm", "count": 3, "spread": 40, "note": "TEACH eosinophil: three of them, open water, nothing else happening." },
|
||||
{ "s": 700, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 3.1416, "rho": 0.92, "note": "SAMPLE 1/3. Cost = proximity: pinned to the far wall in the tail of the bolus field, so collecting it means wall-hugging (graze damage) while chunks tumble past. Visible from the racing line the whole way in." },
|
||||
{ "s": 760, "type": "spawn", "enemy": "candida_bloom", "count": 1, "theta": 0, "note": "TEACH candida: one bloom, on the straightest wall, with a clear 200-unit sightline." },
|
||||
{ "s": 860, "type": "checkpoint", "name": "Thoracic Inlet" },
|
||||
{ "s": 900, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 160 },
|
||||
{ "s": 980, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [1.05, 4.19], "note": "TEST candida: two blooms covering opposite arcs, so there is no free side - and debris in the lane." },
|
||||
{ "s": 1060, "type": "pickup", "kind": "mucin_glob", "count": 1, "note": "Coat top-up immediately before the spike. Pickup economy law: the game pays you right before it charges you." },
|
||||
|
||||
{ "s": 1080, "type": "checkpoint", "name": "Aortic Narrowing", "note": "Checkpoint sits BEFORE the hazard zone opens at 1100 - never respawn a player inside a squeeze." },
|
||||
{ "s": 1100, "type": "hazard", "kind": "aortic_squeeze", "span": 400, "period": 2.4, "amplitude": 0.45, "theta": 0, "warn": 2.5, "note": "SPIKE hazard, and a POSITIONING verb rather than a timing one - that distinction is deliberate. You cannot stop in a flow-locked tube, so a zone that sealed on a beat would just tax you 10 times with no counterplay. Instead the aorta presses from ONE side (theta=0, anatomically the left): the wall bulges 45% inward there on a 2.4s beat and the safe lane is the opposite arc. The player's answer is 'be on the far side when it beats', which is playable at 16 u/s. Real heart rate is a 0.83s beat - dramatised to 2.4s to be readable. Non-lethal: contact is graze damage, so the zone's first pulses teach it inside itself." },
|
||||
{ "s": 1180, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [0.5, 3.8], "note": "The squeeze pushes you toward the walls; the walls are where the blooms are. That interaction is the whole point of the beat." },
|
||||
{ "s": 1260, "type": "spawn", "enemy": "bolus_chunk", "count": 3, "spread": 80, "note": "TEST bolus: same chunks as beat 1, now in a 7-unit tube that is periodically 45% narrower." },
|
||||
{ "s": 1300, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 1.5708, "rho": 0.9, "note": "SAMPLE 2/3. Cost = timing: it sits against the wall inside the squeeze zone, so you must take the pinch on the wall side and eat a pulse, or thread it between beats." },
|
||||
{ "s": 1380, "type": "spawn", "enemy": "eosinophil_swarm", "count": 4, "spread": 60, "note": "TEST eosinophil: they were free room in beat 2; here there is no room." },
|
||||
|
||||
{ "s": 1520, "type": "checkpoint", "name": "Thoracic Esophagus" },
|
||||
{ "s": 1560, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 30 },
|
||||
{ "s": 1600, "type": "pickup", "kind": "nutrient_orb", "count": 4, "spread": 60 },
|
||||
{ "s": 1680, "type": "checkpoint", "name": "Thoracic Run" },
|
||||
{ "s": 1700, "type": "hazard", "kind": "reflux_surge", "speed": 16, "from": -60, "span": 260, "warn": 3.0, "lethal": false, "neutralizable": true, "note": "TEACH reflux - the most important event in the level. A burp: 16 u/s against a 14 u/s current, so even a timid player at throttle 1.15 (16.1 u/s) escapes it. Touching it hurts and does not kill. When the real surge arrives at s=3000 the player already knows the verb." },
|
||||
{ "s": 1900, "type": "spawn", "enemy": "bolus_chunk", "count": 3, "spread": 100 },
|
||||
|
||||
{ "s": 2000, "type": "checkpoint", "name": "Bronchial Narrowing" },
|
||||
{ "s": 2060, "type": "spawn", "enemy": "candida_bloom", "count": 3, "theta": [0.2, 2.4, 4.6], "note": "Three arcs covered in the level's twistiest tube: each is a surprise on a blind bend. This is the beat that makes curviness a mechanic." },
|
||||
{ "s": 2100, "type": "hazard", "kind": "ring_gate", "period": 3.6, "open": 2.4, "warn": 2.5, "note": "TEACH ring gate: open 2.4s of every 3.6s (67% open). At 16 u/s you clear it without thinking - which is the point. You will meet its evil twin at s=2800." },
|
||||
{ "s": 2200, "type": "spawn", "enemy": "eosinophil_swarm", "count": 6, "spread": 80, "note": "Biggest swarm in the level." },
|
||||
{ "s": 2280, "type": "checkpoint", "name": "Subcarinal" },
|
||||
{ "s": 2300, "type": "pickup", "kind": "antacid_ammo", "count": 2, "spread": 20, "note": "Arms the player 700 units before the reflux finale. Antacid neutralises an acid zone (GDD) - fired BACKWARD it stalls the surge. The level hands you the tool, never explains it, and rewards you for noticing." },
|
||||
{ "s": 2380, "type": "spawn", "enemy": "bolus_chunk", "count": 6, "spread": 140 },
|
||||
{ "s": 2420, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [1.8, 5.1], "note": "Blooms + debris simultaneously: the level's densest sustained stretch." },
|
||||
{ "s": 2540, "type": "pickup", "kind": "mucin_glob", "count": 1 },
|
||||
|
||||
{ "s": 2580, "type": "checkpoint", "name": "Diaphragmatic Hiatus" },
|
||||
{ "s": 2700, "type": "spawn", "enemy": "bolus_chunk", "count": 4, "spread": 60, "note": "Debris jammed into the tightest tube in the level - spread 60 makes it a wall of traffic, not a sprinkle." },
|
||||
{ "s": 2800, "type": "hazard", "kind": "ring_gate", "period": 3.0, "open": 1.2, "warn": 2.5, "note": "TEST ring gate: open 1.2s of every 3.0s (40% open), in a 6.5-unit tube. Same verb as s=2100, half the window. This is the level's skill check." },
|
||||
{ "s": 2880, "type": "spawn", "enemy": "eosinophil_swarm", "count": 5, "spread": 60, "note": "They chase you THROUGH the gate: the gate closing behind you culls them - a free kill the observant player earns." },
|
||||
{ "s": 2960, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 20, "note": "Last top-up in the level. Take it or regret it for 600 units." },
|
||||
|
||||
{ "s": 2980, "type": "checkpoint", "name": "Cardia Approach", "note": "The finale's checkpoint. Sits 20 units before the surge trigger: a death in the chase costs you the chase, not the hiatus." },
|
||||
{ "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 21 u/s, spawning 40 units behind you and chasing for 600 units. Flow here is 20, so throttle 1.05+ outruns it and throttle 0.6 (12 u/s) kills you: the level's last instruction is 'do not dawdle'. Par pace (~26 u/s) finishes ~5s clear. Antacid fired backward buys ~10s." },
|
||||
{ "s": 3080, "type": "pickup", "kind": "antacid_ammo", "count": 1, "note": "On the racing line, mid-chase: the one pickup in the level you get for free at speed." },
|
||||
{ "s": 3120, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 200, "note": "Traffic, not enemies. They drift 3.5 u/s slower than the current, so at chase pace you close on them fast." },
|
||||
{ "s": 3200, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 4.7124, "rho": 0.85, "note": "SAMPLE 3/3. Cost = the chase itself. Off the racing line by ~1.5s with an acid wall 5s behind you. The cruellest of the three and the only one that is a real decision at full speed." },
|
||||
{ "s": 3320, "type": "spawn", "enemy": "eosinophil_swarm", "count": 4, "spread": 100 },
|
||||
{ "s": 3440, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [2.0, 5.3], "note": "Last gauntlet, at maximum speed, with 160 units to react. Telegraph 0.7s = ~18 units of warning at 26 u/s: fair, barely." },
|
||||
{ "s": 3580, "type": "gate", "id": "cardiac_sphincter", "to": "L3_stomach", "note": "The lower esophageal sphincter. You go through it; the acid does not." }
|
||||
]
|
||||
}
|
||||
76
web/js/levels/L3_stomach.json
Normal file
76
web/js/levels/L3_stomach.json
Normal file
@ -0,0 +1,76 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "L3_stomach",
|
||||
"name": "Stomach",
|
||||
"tagline": "The acid sea",
|
||||
"seed": 20260703,
|
||||
"par": { "time": 540, "score": 15000, "samples": 3 },
|
||||
"next": "L4_small_intestine",
|
||||
"design": {
|
||||
"owner": "Lane C",
|
||||
"status": "SKELETON (round 1) - shape only: biomes, lengths, arenas, boss placement, gate. Encounters, mucus-shallow lanes, churn cyclones and the pH field are round 2+. Do not read the (near-empty) events list as a design.",
|
||||
"identity": "The coat level. Ambient pH drains the mucus coat continuously, so position IS the resource: mucus shallows along the greater curvature pause the drain, open acid does not. Ends on the Pyloric Guardian.",
|
||||
"anatomy": "Cardia -> fundus -> body (greater/lesser curvature) -> antrum -> pyloric antrum -> pylorus. Real order, real proportions.",
|
||||
"biome_note": "biome = tissue/look family (Lane A's registry), segments[].name = the anatomy. There is no 'cardia' or 'pylorus' biome and there should not be. The cardia deliberately keeps the ESOPHAGUS palette so that the acid sea REVEALS itself: you arrive still in teal, and the world turns orange the moment the fundus opens out. Spending a biome id on the cardia would throw that reveal away.",
|
||||
"scale": "Real stomach is ~25cm long, ~15cm wide when full; arena radius 180 units = 36cm. The one place the 1 unit : 1 mm fiction nearly holds - the stomach is genuinely a room.",
|
||||
"open_question": "The body of the stomach is not a sphere and arenas[] currently only expresses spheres. Either A's arena primitive grows to a capsule/blob union, or L3 chains overlapping spheres. Flagged to Lane A in LANE_C_NOTES.md §-> Lane A - do not treat arenas[1] as final geometry."
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"biome": "esophagus",
|
||||
"name": "Cardia",
|
||||
"length": 200,
|
||||
"radius": { "base": 12, "wobble": 0.2 },
|
||||
"curviness": 0.3,
|
||||
"flow": 8,
|
||||
"note": "You arrive through the cardiac sphincter with the reflux surge still behind you. Short, tube-mode, decompression after L2's finale. Still wearing the esophagus palette (see design.biome_note) - the orange hits when the fundus opens, not before."
|
||||
},
|
||||
{
|
||||
"biome": "stomach",
|
||||
"name": "Fundus",
|
||||
"length": 600,
|
||||
"radius": { "base": 40, "wobble": 0.25 },
|
||||
"curviness": 0.2,
|
||||
"flow": 3,
|
||||
"note": "The gas dome at the top of the stomach. First taste of 6DOF-in-acid, low density: a breather that happens to be lethal if you loiter in the open."
|
||||
},
|
||||
{
|
||||
"biome": "stomach",
|
||||
"name": "Body",
|
||||
"length": 900,
|
||||
"radius": { "base": 45, "wobble": 0.3 },
|
||||
"curviness": 0.2,
|
||||
"flow": 3,
|
||||
"note": "THE ACID SEA - the level's centrepiece and the biggest room in the game so far. Churn cyclones, pepsin swarms, and the mucus shallows hugging the greater curvature."
|
||||
},
|
||||
{
|
||||
"biome": "stomach",
|
||||
"name": "Antrum",
|
||||
"length": 500,
|
||||
"radius": { "base": 25, "wobble": 0.25 },
|
||||
"curviness": 0.35,
|
||||
"flow": 6,
|
||||
"note": "The stomach starts squeezing down toward the exit. Room shrinks, current returns, pressure builds into the boss."
|
||||
},
|
||||
{
|
||||
"biome": "stomach",
|
||||
"name": "Pyloric Antrum",
|
||||
"length": 300,
|
||||
"radius": { "base": 14, "wobble": 0.15 },
|
||||
"curviness": 0.25,
|
||||
"flow": 10,
|
||||
"note": "The Guardian's lair (see arenas[2]). The churn here is a current that pushes you at the walls for the whole fight."
|
||||
}
|
||||
],
|
||||
"arenas": [
|
||||
{ "at": 800, "radius": 70, "biome": "stomach", "note": "Fundus dome. Spans s 730-870." },
|
||||
{ "at": 1250, "radius": 180, "biome": "stomach", "note": "The acid sea. Spans s 1070-1430. PROVISIONAL - a sphere is the wrong shape for the stomach body; see design.open_question." },
|
||||
{ "at": 2350, "radius": 70, "biome": "stomach", "note": "Pyloric Guardian lair. Spans s 2280-2420." }
|
||||
],
|
||||
"events": [
|
||||
{ "s": 20, "type": "checkpoint", "name": "Cardia" },
|
||||
{ "s": 2260, "type": "checkpoint", "name": "Pyloric Antrum", "note": "Checkpoint before the boss - the law, and the reason a Guardian wipe costs seconds and not minutes." },
|
||||
{ "s": 2350, "type": "boss", "id": "pyloric_guardian", "note": "Full phase spec (phases / telegraphs / windows / failure pressure) in LANE_C_NOTES.md §Boss specs." },
|
||||
{ "s": 2480, "type": "gate", "id": "pylorus", "to": "L4_small_intestine" }
|
||||
]
|
||||
}
|
||||
67
web/js/levels/difficulty.js
Normal file
67
web/js/levels/difficulty.js
Normal file
@ -0,0 +1,67 @@
|
||||
// levels/difficulty.js (Lane C) — the single difficulty table. GDD §Difficulty & assists.
|
||||
//
|
||||
// LAW (charter): no per-level hand-tuned forks. A level is authored once, at `normal`, and
|
||||
// every difficulty is this table applied to that one authored level. If you ever find
|
||||
// yourself wanting "but on hard, level 4 should...", the answer is a new event in the level,
|
||||
// not a branch in here.
|
||||
//
|
||||
// Pure data + pure functions, no imports, node-runnable.
|
||||
|
||||
/**
|
||||
* coatDrain x ambient mucus-coat drain rate (the pH pressure)
|
||||
* density x enemy counts (rounded; deterministic)
|
||||
* window x boss/hazard timing windows — the one that actually moves the skill floor
|
||||
* surgeSpeed x chase-hazard speed (reflux surges)
|
||||
* contact x contact/hull damage taken
|
||||
* assists defaults for the assist toggles (player can always override — GDD)
|
||||
*/
|
||||
export const DIFFICULTIES = Object.freeze({
|
||||
easy: {
|
||||
label: 'Endoscopy', coatDrain: 0.7, density: 0.75, window: 1.3, surgeSpeed: 0.92, contact: 0.8,
|
||||
assists: { flowAutoCenter: true, aimMagnetism: true },
|
||||
note: 'For players here to see the body, not to beat it. Windows are the big lever: 1.3x turns the hiatus ring gate from a skill check into a rhythm.',
|
||||
},
|
||||
normal: {
|
||||
label: 'Clinical', coatDrain: 1.0, density: 1.0, window: 1.0, surgeSpeed: 1.0, contact: 1.0,
|
||||
assists: { flowAutoCenter: false, aimMagnetism: true },
|
||||
note: 'The authored experience. Every number in every level JSON is a normal-mode number.',
|
||||
},
|
||||
hard: {
|
||||
label: 'Terminal', coatDrain: 1.35, density: 1.25, window: 0.8, surgeSpeed: 1.1, contact: 1.25,
|
||||
assists: { flowAutoCenter: false, aimMagnetism: false },
|
||||
note: 'surgeSpeed 1.1 puts the L2 finale at 23.1 u/s against a 20 u/s current: the chase stops being a sprint and becomes a route-planning problem (antacid or die).',
|
||||
},
|
||||
});
|
||||
|
||||
export const DEFAULT_DIFFICULTY = 'normal';
|
||||
|
||||
export function getDifficulty(id = DEFAULT_DIFFICULTY) {
|
||||
return DIFFICULTIES[id] ?? DIFFICULTIES[DEFAULT_DIFFICULTY];
|
||||
}
|
||||
|
||||
/**
|
||||
* → a NEW level object with difficulty applied. Never mutates. Deterministic (same input =>
|
||||
* same output, no RNG), so it is safe to run before world generation.
|
||||
* Only scales what the table says it scales; everything else passes through untouched.
|
||||
*/
|
||||
export function applyDifficulty(level, id = DEFAULT_DIFFICULTY) {
|
||||
const d = getDifficulty(id);
|
||||
if (id === 'normal') return level;
|
||||
const events = level.events.map((e) => {
|
||||
if (e.type === 'spawn' && typeof e.count === 'number') {
|
||||
const count = Math.max(1, Math.round(e.count * d.density));
|
||||
// wall enemies carry one theta per instance — keep the arrays consistent.
|
||||
if (Array.isArray(e.theta)) {
|
||||
const theta = [];
|
||||
for (let i = 0; i < count; i++) theta.push(e.theta[i % e.theta.length]);
|
||||
return { ...e, count, theta };
|
||||
}
|
||||
return { ...e, count };
|
||||
}
|
||||
if (e.type === 'hazard' && e.kind === 'reflux_surge' && typeof e.speed === 'number') {
|
||||
return { ...e, speed: Math.round(e.speed * d.surgeSpeed * 10) / 10 };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
return { ...level, events, difficulty: id };
|
||||
}
|
||||
108
web/js/levels/enemies.js
Normal file
108
web/js/levels/enemies.js
Normal file
@ -0,0 +1,108 @@
|
||||
// levels/enemies.js (Lane C) — the enemy CATALOGUE: fiction id → archetype + skin.
|
||||
//
|
||||
// WHY THIS EXISTS: level JSONs name enemies in fiction ("bolus_chunk"), not in mechanics
|
||||
// ("floater"). This is the join table between the two, and nothing more.
|
||||
//
|
||||
// OWNERSHIP (settled with Lane B's round-1 work, see LANE_C_NOTES.md §→ Lane B):
|
||||
// Lane B owns MECHANICS — hp, radius, speed, damage, fire rate, score. They live in
|
||||
// combat/balance.js, tuned against B's cannon and B's feel numbers, and they are keyed by
|
||||
// ARCHETYPE. This file deliberately does NOT restate them: an hp of "2 shots" here and an
|
||||
// hp of 24 there (against damage 12) is the same number twice, which is one number too
|
||||
// many. If you want to know what a bolus chunk can take, ask balance.js.
|
||||
// Lane C owns COMPOSITION — which fiction appears, how many, where, in what order, and
|
||||
// what it is called. That is this file plus the level JSONs.
|
||||
//
|
||||
// LANE B: resolve a spawn with `getEnemy(ev.enemy)?.archetype` — one line, snippet in
|
||||
// LANE_C_NOTES.md §→ Lane B. The three bare archetype names also resolve (passthrough
|
||||
// entries at the bottom), so the round-0 stub level and anything already built against it
|
||||
// keep working unchanged.
|
||||
//
|
||||
// Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck).
|
||||
|
||||
/** The only archetypes Lane B implements in round 1. Selfcheck enforces membership. */
|
||||
export const ARCHETYPES = ['floater', 'seeker', 'turret'];
|
||||
|
||||
/**
|
||||
* Fields:
|
||||
* archetype 'floater'|'seeker'|'turret' — which of B's behaviors + balance rows drives it
|
||||
* biome where it belongs (documentation; '*' = anywhere)
|
||||
* tint emissive color. ART_BIBLE law: hostile = hot amber/red, projectiles =
|
||||
* white-hot, neutral flora = cyan (turns hostile if shot). Advisory for Lane D.
|
||||
* wall true => C must author an explicit `theta`. Lane B already defaults turrets to
|
||||
* the wall; this is an AUTHORING rule, not a runtime one — a wall-mounted enemy
|
||||
* owns an arc of the cross-section, and which arc is a design decision, never
|
||||
* a random one.
|
||||
* note the fiction, and why it earns its place in the encounter.
|
||||
*/
|
||||
export const ENEMIES = Object.freeze({
|
||||
// ---- L2 esophagus roster (all three are real esophageal pathology) ----------------
|
||||
bolus_chunk: {
|
||||
archetype: 'floater', biome: 'esophagus', tint: 0xd98a3a, wall: false,
|
||||
note: 'Swallowed food debris tumbling in the swallow. A floater taxes the lane it sits ' +
|
||||
'in rather than chasing you, which is exactly what debris should do: it turns the ' +
|
||||
'cross-section into a maze without ever raising the stakes. The L2 teaching enemy.',
|
||||
},
|
||||
eosinophil_swarm: {
|
||||
archetype: 'seeker', biome: 'esophagus', tint: 0xff4d3d, wall: false,
|
||||
note: 'Eosinophilic esophagitis, weaponised — immune cells that mistake ENDO-1 for the ' +
|
||||
'parasite they were built for. Individually trivial; the threat is arithmetic. ' +
|
||||
'B tuned the seeker at speed 19 against flow 14 specifically so it catches you ' +
|
||||
'from behind: the answer is to shoot or boost, never to outrun. That is the ' +
|
||||
'lesson L2 needs before the reflux finale asks you to outrun something.',
|
||||
},
|
||||
candida_bloom: {
|
||||
archetype: 'turret', biome: 'esophagus', tint: 0xffd166, wall: true,
|
||||
note: 'Candida esophagitis — white plaque colonies anchored to the mucosa, firing spore ' +
|
||||
'clusters. Wall-mounted, so it OWNS an arc of the tube and converts "which side do ' +
|
||||
'I fly" into a real decision. B\'s dart turn radius (13.3u) is wider than the tube, ' +
|
||||
'so it can never corner you, only punish a straight line — which is why L2 puts ' +
|
||||
'blooms on straights and in the squeeze, never on blind bends alone.',
|
||||
},
|
||||
|
||||
// ---- L1 oral cavity (roster stub — L1's encounter pass is round 2+) ---------------
|
||||
amylase_droplet: {
|
||||
archetype: 'floater', biome: 'oral', tint: 0xe8a13a, wall: false,
|
||||
note: 'Saliva enzyme blob with a corrosive contact aura. The tutorial floater — B tuned ' +
|
||||
'the floater archetype against exactly this fiction (see balance.js).',
|
||||
},
|
||||
|
||||
// ---- L3 stomach (roster stub — L3's encounter pass is round 2+) -------------------
|
||||
pepsin_swarm: {
|
||||
archetype: 'seeker', biome: 'stomach', tint: 0xff3b30, wall: false,
|
||||
note: 'The seeker in its home biome — B tuned the archetype against this fiction.',
|
||||
},
|
||||
peyers_turret: {
|
||||
archetype: 'turret', biome: 'small_intestine', tint: 0xffe066, wall: true,
|
||||
note: 'Antibody turret in a Peyer\'s patch — B tuned the turret archetype against this ' +
|
||||
'fiction. Belongs to L4; listed so the catalogue matches balance.js\'s vocabulary.',
|
||||
},
|
||||
|
||||
// ---- passthrough: bare archetype names --------------------------------------------
|
||||
// Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and
|
||||
// Lane B is never broken by this catalogue landing mid-round. Not for use in shipped
|
||||
// levels — selfcheck WARNS, because an enemy with no fiction has no place in the body.
|
||||
floater: { archetype: 'floater', biome: '*', tint: 0xd98a3a, wall: false, placeholder: true, note: 'Bare archetype passthrough.' },
|
||||
seeker: { archetype: 'seeker', biome: '*', tint: 0xff4d3d, wall: false, placeholder: true, note: 'Bare archetype passthrough.' },
|
||||
turret: { archetype: 'turret', biome: '*', tint: 0xffd166, wall: true, placeholder: true, note: 'Bare archetype passthrough.' },
|
||||
});
|
||||
|
||||
/** → enemy spec, or null on miss. Consumers must handle null (assets-are-optional law). */
|
||||
export function getEnemy(id) {
|
||||
return Object.prototype.hasOwnProperty.call(ENEMIES, id) ? ENEMIES[id] : null;
|
||||
}
|
||||
|
||||
/** Pickups Lane C may place. Lane B/E: `pickup {kind}` on the bus when collected. */
|
||||
export const PICKUPS = Object.freeze({
|
||||
nutrient_orb: { note: 'Score + boost recharge. The breadcrumb: an orb line TEACHES the racing line before it pays anything.' },
|
||||
mucin_glob: { note: 'Mucus coat refill. Placed immediately before pressure — the game pays you right before it charges you.' },
|
||||
b12_cell: { note: 'Hull repair. Rare: hull is the scarce resource, coat is the renewable one.' },
|
||||
antacid_ammo: { note: 'Antacid torpedoes. Dual-use (GDD): B\'s torpedo neutralises an acid zone for 10s in a 14u radius — fired BACKWARD it stalls a reflux surge.' },
|
||||
biopsy_sample: { note: 'THE collectible. Exactly 3 per level (selfcheck enforces). Visible-but-costly law: always in plain sight, never on the racing line.' },
|
||||
});
|
||||
|
||||
/** Hazard kinds Lane C places in data. Specs + owners in LANE_C_NOTES.md §Hazard requests. */
|
||||
export const HAZARDS = Object.freeze({
|
||||
reflux_surge: { needs: ['speed'], optional: ['from', 'span', 'lethal', 'neutralizable'], owner: 'B', note: 'Acid wall chasing from behind at `speed`. `from` = spawn offset relative to the trigger s (negative = behind the player). `lethal: false` => the non-lethal teaching version. `neutralizable` => B\'s antacid torpedo stalls it (balance.js: neutralizeDuration 10s).' },
|
||||
aortic_squeeze: { needs: ['period', 'amplitude', 'theta'], optional: ['span'], owner: 'A', note: 'Zone hazard: the aortic arch pulses against ONE SIDE of the tube. The wall at `theta` bulges inward by `amplitude` * radius on `period`; the opposite arc stays clear and is the answer. Directional on purpose — a zone that sealed symmetrically would be an untimeable toll in a flow-locked tube (you cannot stop). Wall contact = graze damage, not death.' },
|
||||
ring_gate: { needs: ['period', 'open'], optional: [], owner: 'A', note: 'Point hazard: a peristaltic ring seals the lumen, opening for `open` seconds every `period`. Timeable precisely BECAUSE it is a point — the player modulates throttle on approach to arrive on an open beat.' },
|
||||
});
|
||||
545
web/js/levels/index.js
Normal file
545
web/js/levels/index.js
Normal file
@ -0,0 +1,545 @@
|
||||
// levels/index.js (Lane C) — level registry, loader, schema selfcheck, pacing simulator.
|
||||
//
|
||||
// Runs in BOTH the browser (boot.js: `import('./levels/index.js').getLevel(id)`) and node
|
||||
// (`node web/js/levels/index.js --selfcheck`, called by tools/qa.sh). That dual life is why
|
||||
// this file imports no THREE and touches no DOM: it is data and arithmetic, nothing else.
|
||||
//
|
||||
// node web/js/levels/index.js --selfcheck validate every registered level (qa gate)
|
||||
// node web/js/levels/index.js --sim pacing report for every level
|
||||
// node web/js/levels/index.js --sim L2_esophagus pacing report for one level
|
||||
//
|
||||
// Schema of record: docs/TECH.md §Level data schema v1 (Lane C owns it; changes go through
|
||||
// LANE_C_NOTES.md + F referee, never silently).
|
||||
|
||||
import { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS } from './enemies.js';
|
||||
import { applyDifficulty, getDifficulty, DEFAULT_DIFFICULTY } from './difficulty.js';
|
||||
|
||||
export { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS };
|
||||
export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from './difficulty.js';
|
||||
|
||||
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
||||
|
||||
/** Schema version this module speaks. Levels declare `"schema": N`. */
|
||||
export const SCHEMA = 1;
|
||||
|
||||
/** Campaign order. L4/L5/LS land in later rounds; the registry is the source of truth. */
|
||||
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];
|
||||
|
||||
/** Booting with no ?lvl= gives you the vertical slice. Lane F: see LANE_C_NOTES §→ Lane F. */
|
||||
export const DEFAULT_LEVEL = 'L2_esophagus';
|
||||
|
||||
/**
|
||||
* Playability constants — Lane C's model of "is there room to fly here".
|
||||
*
|
||||
* These MIRROR the other lanes rather than assert anything: A's world/index.js computes
|
||||
* `wallRho = radiusAt(s) - waveMaxAt(s) - SKIN` with `waveMaxAt = wave.amp + wave.breathe`
|
||||
* (per-biome, read live from A's registry — see waveMargin()), and B's flight/tuning.js
|
||||
* sets the hull collision sphere to 0.9. Only MIN_CLEARANCE is Lane C's own call.
|
||||
*
|
||||
* If A or B move their numbers, this module notices on the next qa run and the levels get
|
||||
* re-tuned — which is not hypothetical: A raised the esophagus amp 0.9 -> 1.4 during round 1
|
||||
* and it cost the diaphragmatic hiatus 0.5 units of flyable radius. See LANE_C_NOTES.md.
|
||||
*/
|
||||
export const PLAY = Object.freeze({
|
||||
PERISTALSIS_AMP: 0.9, // fallback only, used when A's registry is unreachable (stub value)
|
||||
SKIN: 0.6, // collision safety margin — matches A's world/index.js SKIN
|
||||
SHIP_R: 0.9, // ENDO-1 collision sphere — from B's flight/tuning.js ship.radius
|
||||
MIN_CLEARANCE: 2.5, // C'S LAW: the tightest point must still leave a 2.5-unit free disc
|
||||
// (~2.8x the hull's collision radius). Below that a constriction
|
||||
// stops being a challenge and becomes a toll you simply pay.
|
||||
});
|
||||
|
||||
/**
|
||||
* Fallback biome list, used only when Lane A's registry cannot be loaded.
|
||||
* Mirrors world/biomes.js §Canonical ids. Lane A's registry is AUTHORITATIVE when present —
|
||||
* biome = the visual/tissue family, `segments[].name` carries the anatomy. That division is
|
||||
* why C needs no 'cardia'/'pylorus'/'pharynx' ids: those are names, not looks.
|
||||
*/
|
||||
export const BIOMES_EXPECTED = [
|
||||
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
|
||||
];
|
||||
|
||||
/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */
|
||||
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 });
|
||||
|
||||
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
|
||||
const CHASE_THROTTLE = 1.35;
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// loading
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
async function loadJSON(rel) {
|
||||
const url = new URL(rel, import.meta.url); // resolves against THIS file in node + browser
|
||||
if (isNode) {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
return JSON.parse(await readFile(url, 'utf8'));
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`level fetch failed: ${rel} (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* → the level object for `id` (defaults to the vertical slice), validated.
|
||||
* Throws on unknown id or on validation errors — a broken level must never reach the world.
|
||||
*/
|
||||
export async function getLevel(id = DEFAULT_LEVEL, { difficulty = DEFAULT_DIFFICULTY } = {}) {
|
||||
const key = id ?? DEFAULT_LEVEL;
|
||||
if (!CAMPAIGN.includes(key)) throw new Error(`unknown level "${key}" (have: ${CAMPAIGN.join(', ')})`);
|
||||
if (!cache.has(key)) cache.set(key, loadJSON(`./${key}.json`));
|
||||
const level = await cache.get(key);
|
||||
const { errors } = await validate(level, { expectId: key });
|
||||
if (errors.length) throw new Error(`level "${key}" failed validation:\n - ${errors.join('\n - ')}`);
|
||||
return applyDifficulty(level, difficulty);
|
||||
}
|
||||
|
||||
/** → [{ id, name, tagline, length, par }] for menus (Lane E). */
|
||||
export async function listLevels() {
|
||||
const out = [];
|
||||
for (const id of CAMPAIGN) {
|
||||
const l = await loadJSON(`./${id}.json`);
|
||||
out.push({ id, name: l.name, tagline: l.tagline, length: totalLength(l), par: l.par, skeleton: isSkeleton(l) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane A's biome registry, or C's fallback. Cached module-level so the sync clearance
|
||||
* helpers can read wave amplitudes without every call site going async.
|
||||
* We consume A's `wave.amp` rather than assuming the stub's 0.9: peristalsis amplitude is
|
||||
* per-biome (small_intestine is 1.1), and it is subtracted from the flyable radius, so a
|
||||
* level that is comfortable in the esophagus could be a wall in the ileum.
|
||||
*/
|
||||
let REG = null;
|
||||
|
||||
async function biomeRegistry() {
|
||||
if (REG) return REG;
|
||||
try {
|
||||
const m = await import('../world/biomes.js'); // Lane A's, when it lands
|
||||
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
||||
REG = {
|
||||
ids: new Set(Object.keys(m.BIOMES)),
|
||||
// Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
|
||||
marginFor: (id) => {
|
||||
const w = m.BIOMES[id]?.wave;
|
||||
return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
|
||||
},
|
||||
authoritative: true,
|
||||
};
|
||||
return REG;
|
||||
}
|
||||
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
|
||||
REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false };
|
||||
return REG;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// geometry / pacing helpers (shared by validate + pace)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
export function totalLength(level) {
|
||||
return level.segments.reduce((a, s) => a + s.length, 0);
|
||||
}
|
||||
|
||||
/** → the segment containing s, plus its start offset. */
|
||||
export function segmentAt(level, s) {
|
||||
let s0 = 0;
|
||||
for (const seg of level.segments) {
|
||||
if (s < s0 + seg.length || seg === level.segments[level.segments.length - 1]) return { seg, s0 };
|
||||
s0 += seg.length;
|
||||
}
|
||||
return { seg: level.segments[0], s0: 0 };
|
||||
}
|
||||
|
||||
const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? '');
|
||||
|
||||
/**
|
||||
* Lane B's combat balance, or false. Same guarded-import pattern as the biome registry:
|
||||
* Lane C's registry CONSUMES the other lanes' contracts and owns only composition, so
|
||||
* score lives in exactly one place (B's balance.js, keyed by archetype) and this module
|
||||
* reads it rather than restating it.
|
||||
*/
|
||||
let BAL = null;
|
||||
|
||||
async function combatBalance() {
|
||||
if (BAL !== null) return BAL;
|
||||
try {
|
||||
const m = await import('../combat/balance.js'); // Lane B's, when it lands
|
||||
if (m.BALANCE?.enemies) { BAL = m.BALANCE; return BAL; }
|
||||
} catch { /* Lane B hasn't landed yet */ }
|
||||
BAL = false;
|
||||
return BAL;
|
||||
}
|
||||
|
||||
/** → the score Lane B awards for killing `enemyId`, or null if B's balance isn't loaded. */
|
||||
export function scoreFor(enemyId) {
|
||||
const spec = getEnemy(enemyId);
|
||||
if (!spec || !BAL) return null;
|
||||
return BAL.enemies?.[spec.archetype]?.score ?? null;
|
||||
}
|
||||
|
||||
/** → total points available from kills, or null. Used to sanity-check par.score. */
|
||||
export function scoreBudget(level) {
|
||||
if (!BAL) return null;
|
||||
let total = 0;
|
||||
for (const e of level.events ?? []) {
|
||||
if (e.type !== 'spawn') continue;
|
||||
const s = scoreFor(e.enemy);
|
||||
if (s == null) return null;
|
||||
total += s * (e.count ?? 1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */
|
||||
function waveMargin(seg) {
|
||||
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
|
||||
}
|
||||
|
||||
/** Static free-flight radius of a segment at its narrowest wobble trough. */
|
||||
function staticClearance(seg) {
|
||||
const r = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
|
||||
return r - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearance including span hazards that eat into the tube.
|
||||
* A one-sided squeeze (aortic) does NOT halve the tube: it bites `amplitude*R` out of one
|
||||
* arc, so the largest circle you can still fly through is wallRho - intrusion/2.
|
||||
* A ring_gate is exempt — it seals on purpose and is a timing gate, not a corridor.
|
||||
*/
|
||||
function hazardClearance(level, seg, s0) {
|
||||
let clear = staticClearance(seg);
|
||||
const rStatic = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
|
||||
for (const e of level.events) {
|
||||
if (e.type !== 'hazard' || !e.span) continue;
|
||||
const overlaps = e.s < s0 + seg.length && e.s + e.span > s0;
|
||||
if (!overlaps) continue;
|
||||
if (e.kind === 'aortic_squeeze') {
|
||||
const intrusion = (e.amplitude ?? 0) * rStatic;
|
||||
clear = Math.min(clear, staticClearance(seg) - intrusion / 2);
|
||||
}
|
||||
}
|
||||
return clear;
|
||||
}
|
||||
|
||||
/** How fast the player is actually moving at s, under a throttle profile. */
|
||||
function speedAt(level, s, k) {
|
||||
const { seg } = segmentAt(level, s);
|
||||
let throttle = k;
|
||||
for (const e of level.events) {
|
||||
if (e.type === 'hazard' && e.kind === 'reflux_surge' && e.lethal && e.span &&
|
||||
s >= e.s && s <= e.s + e.span) throttle = Math.max(throttle, CHASE_THROTTLE);
|
||||
}
|
||||
return Math.max(0.1, seg.flow * throttle);
|
||||
}
|
||||
|
||||
/** Seconds to travel [a, b] under profile k. Numeric integration; 2-unit steps. */
|
||||
export function travelTime(level, a, b, k) {
|
||||
const STEP = 2;
|
||||
let t = 0;
|
||||
for (let s = a; s < b; s += STEP) {
|
||||
const ds = Math.min(STEP, b - s);
|
||||
t += ds / speedAt(level, s + ds / 2, k);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// the pacing simulator — Lane C's evidence (you cannot screenshot data)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Models the level as a kinematic ride and reports the encounter curve.
|
||||
* This is a MODEL, not a playtest: it assumes a player who holds a constant throttle and is
|
||||
* never slowed by combat (true-ish in tube mode — the current carries you and you cannot
|
||||
* stop). Treat times as lower bounds on a real run and pressure as a relative index only.
|
||||
*
|
||||
* pressure = density x tightness x hazard, where
|
||||
* density = enemies per 100 units in the segment
|
||||
* tightness = widest segment radius / this segment radius (how little room you have)
|
||||
* hazard = 1 + a per-kind term (see hazardWeight)
|
||||
*/
|
||||
export function pace(level, { profile = 'par' } = {}) {
|
||||
const k = PROFILES[profile] ?? PROFILES.par;
|
||||
const total = totalLength(level);
|
||||
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
||||
|
||||
const beats = [];
|
||||
let s0 = 0, tAcc = 0;
|
||||
for (const seg of level.segments) {
|
||||
const s1 = s0 + seg.length;
|
||||
const inSeg = (e) => e.s >= s0 && e.s < s1;
|
||||
const enemies = level.events.filter((e) => e.type === 'spawn' && inSeg(e))
|
||||
.reduce((a, e) => a + (e.count ?? 1), 0);
|
||||
const hazards = level.events.filter((e) => e.type === 'hazard' && inSeg(e));
|
||||
const density = (enemies / seg.length) * 100;
|
||||
const tightness = widest / seg.radius.base;
|
||||
const hazard = hazards.reduce((a, h) => a * hazardWeight(h), 1);
|
||||
const dt = travelTime(level, s0, s1, k);
|
||||
tAcc += dt;
|
||||
beats.push({
|
||||
name: seg.name ?? seg.biome, s0, s1, length: seg.length, flow: seg.flow,
|
||||
enemies, density, tightness, hazard,
|
||||
pressure: density * tightness * hazard,
|
||||
clearance: hazardClearance(level, seg, s0),
|
||||
seconds: dt, cumulative: tAcc,
|
||||
hazardKinds: hazards.map((h) => h.kind),
|
||||
});
|
||||
s0 = s1;
|
||||
}
|
||||
|
||||
const checkpoints = level.events.filter((e) => e.type === 'checkpoint');
|
||||
const gaps = checkpoints.map((cp, i) => {
|
||||
const next = checkpoints[i + 1]?.s ?? total;
|
||||
return { from: cp.s, to: next, name: cp.name ?? '', units: next - cp.s, seconds: travelTime(level, cp.s, next, PROFILES.par) };
|
||||
});
|
||||
|
||||
return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
|
||||
}
|
||||
|
||||
function hazardWeight(h) {
|
||||
switch (h.kind) {
|
||||
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
||||
case 'ring_gate': return 1 + (1 - (h.open ?? 1) / (h.period ?? 1));
|
||||
case 'reflux_surge': return h.lethal ? 1.8 : 1.15;
|
||||
default: return 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// validate — the schema selfcheck (qa.sh gate)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* → { errors, warnings }. Errors block qa; warnings are advisory.
|
||||
* Skeleton levels (design.status starts "SKELETON") downgrade CONTENT rules (pacing,
|
||||
* samples, checkpoint coverage) to warnings — a level that has not been designed yet must
|
||||
* not fail the gate for not being designed yet. STRUCTURAL rules always apply.
|
||||
*/
|
||||
export async function validate(level, { expectId = null } = {}) {
|
||||
const errors = [], warnings = [];
|
||||
const E = (m) => errors.push(m);
|
||||
const W = (m) => warnings.push(m);
|
||||
const skel = isSkeleton(level);
|
||||
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
||||
|
||||
// --- structural -----------------------------------------------------------------
|
||||
if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
|
||||
if (typeof level.id !== 'string' || !level.id) E('missing id');
|
||||
if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`);
|
||||
if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
|
||||
if (typeof level.name !== 'string' || !level.name) E('missing name');
|
||||
if (!Array.isArray(level.segments) || !level.segments.length) { E('segments must be a non-empty array'); return { errors, warnings }; }
|
||||
if (!Array.isArray(level.events)) E('events must be an array');
|
||||
if (!Array.isArray(level.arenas)) E('arenas must be an array');
|
||||
|
||||
const total = totalLength(level);
|
||||
const { ids: biomes, authoritative } = await biomeRegistry();
|
||||
if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`);
|
||||
const bal = await combatBalance();
|
||||
if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");
|
||||
|
||||
// --- segments -------------------------------------------------------------------
|
||||
let s0 = 0;
|
||||
for (const [i, seg] of level.segments.entries()) {
|
||||
const at = `segments[${i}] "${seg.name ?? seg.biome}"`;
|
||||
if (!biomes.has(seg.biome)) E(`${at}: unknown biome "${seg.biome}"`);
|
||||
if (!(seg.length > 0)) E(`${at}: length must be > 0`);
|
||||
if (!(seg.radius?.base > 0)) E(`${at}: radius.base must be > 0`);
|
||||
const wob = seg.radius?.wobble ?? 0;
|
||||
if (wob < 0 || wob >= 1) E(`${at}: radius.wobble must be in [0,1) (got ${wob})`);
|
||||
if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`);
|
||||
if (!(seg.flow >= 0)) E(`${at}: flow must be >= 0`);
|
||||
|
||||
// C's playability invariant — the check that catches "I made a cool tube you can't fly".
|
||||
const clear = hazardClearance(level, seg, s0);
|
||||
if (clear < PLAY.MIN_CLEARANCE) {
|
||||
E(`${at}: free radius ${clear.toFixed(2)} < MIN_CLEARANCE ${PLAY.MIN_CLEARANCE} ` +
|
||||
`(base ${seg.radius.base}, wobble ${wob}, minus wave ${waveMargin(seg).toFixed(2)} + skin ${PLAY.SKIN} + hull ${PLAY.SHIP_R}` +
|
||||
`${clear < staticClearance(seg) ? ' + span hazard' : ''}) — unflyable`);
|
||||
}
|
||||
s0 += seg.length;
|
||||
}
|
||||
|
||||
// --- events ---------------------------------------------------------------------
|
||||
let prev = -Infinity;
|
||||
for (const [i, e] of (level.events ?? []).entries()) {
|
||||
const at = `events[${i}] ${e.type}@${e.s}`;
|
||||
if (typeof e.s !== 'number') { E(`${at}: missing numeric s`); continue; }
|
||||
if (e.s < prev) E(`${at}: events must be sorted by s (previous was ${prev})`);
|
||||
prev = e.s;
|
||||
if (e.s < 0 || e.s > total) E(`${at}: s outside level (0..${total})`);
|
||||
if (e.span != null && e.s + e.span > total) E(`${at}: span runs past the end of the level`);
|
||||
|
||||
const thetas = Array.isArray(e.theta) ? e.theta : (e.theta != null ? [e.theta] : []);
|
||||
for (const t of thetas) if (!(t >= 0 && t < TAU)) E(`${at}: theta ${t} outside [0, 2pi)`);
|
||||
if (e.rho != null && !(e.rho >= 0 && e.rho <= 1)) E(`${at}: rho ${e.rho} outside [0,1] (rho is a FRACTION of the safe radius, not a distance)`);
|
||||
|
||||
switch (e.type) {
|
||||
case 'spawn': {
|
||||
const spec = getEnemy(e.enemy);
|
||||
if (!spec) { E(`${at}: unknown enemy "${e.enemy}" (not in enemies.js catalogue)`); break; }
|
||||
if (!ARCHETYPES.includes(spec.archetype)) E(`${at}: enemy "${e.enemy}" has archetype "${spec.archetype}" which Lane B does not implement (${ARCHETYPES.join('/')})`);
|
||||
else if (bal && !bal.enemies?.[spec.archetype]) E(`${at}: Lane B's balance.js has no row for archetype "${spec.archetype}" — the contract drifted`);
|
||||
if (spec.placeholder) W(`${at}: spawns bare archetype "${e.enemy}" — placeholder with no fiction; give it a real catalogue entry`);
|
||||
if (spec.wall && !thetas.length) E(`${at}: "${e.enemy}" is wall-mounted and needs an explicit theta`);
|
||||
if (Array.isArray(e.theta) && e.theta.length !== (e.count ?? 1)) E(`${at}: theta array has ${e.theta.length} entries but count is ${e.count ?? 1}`);
|
||||
break;
|
||||
}
|
||||
case 'pickup':
|
||||
if (!PICKUPS[e.kind]) E(`${at}: unknown pickup kind "${e.kind}"`);
|
||||
break;
|
||||
case 'hazard': {
|
||||
const h = HAZARDS[e.kind];
|
||||
if (!h) { E(`${at}: unknown hazard kind "${e.kind}"`); break; }
|
||||
for (const p of h.needs) if (e[p] == null) E(`${at}: hazard "${e.kind}" requires "${p}"`);
|
||||
if (e.kind === 'ring_gate') {
|
||||
const duty = (e.open ?? 0) / (e.period ?? 1);
|
||||
if (duty < 0.3) W(`${at}: ring_gate open only ${(duty * 100) | 0}% of the time — below C's 30% fairness floor`);
|
||||
}
|
||||
if (e.warn != null && e.warn < 2 && e.lethal !== false) W(`${at}: warn ${e.warn}s is under the 2s telegraph law for lethal hazards`);
|
||||
break;
|
||||
}
|
||||
case 'checkpoint': case 'boss': case 'gate': break;
|
||||
default: W(`${at}: unknown event type "${e.type}" — B/E will ignore it`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- arenas ---------------------------------------------------------------------
|
||||
for (const [i, a] of (level.arenas ?? []).entries()) {
|
||||
const at = `arenas[${i}]`;
|
||||
if (!(a.radius > 0)) E(`${at}: radius must be > 0`);
|
||||
if (a.at - a.radius < 0 || a.at + a.radius > total) E(`${at}: sphere (at ${a.at} r ${a.radius}) spans ${a.at - a.radius}..${a.at + a.radius}, outside 0..${total}`);
|
||||
if (a.biome && !biomes.has(a.biome)) E(`${at}: unknown biome "${a.biome}"`);
|
||||
}
|
||||
|
||||
// --- content laws (downgraded for skeletons) ------------------------------------
|
||||
const cps = (level.events ?? []).filter((e) => e.type === 'checkpoint');
|
||||
for (const e of level.events ?? []) {
|
||||
if (e.type === 'boss' && !cps.some((c) => c.s <= e.s && e.s - c.s < 200))
|
||||
C(`boss "${e.id}"@${e.s} has no checkpoint within 200 units before it`);
|
||||
if (e.type === 'hazard' && e.lethal && !cps.some((c) => c.s <= e.s && e.s - c.s < 120))
|
||||
C(`lethal hazard "${e.kind}"@${e.s} has no checkpoint within 120 units before it`);
|
||||
}
|
||||
|
||||
// death costs <= 30s of progress (charter). Measured at par throttle.
|
||||
for (const [i, cp] of cps.entries()) {
|
||||
const next = cps[i + 1]?.s ?? total;
|
||||
const secs = travelTime(level, cp.s, next, PROFILES.par);
|
||||
if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`);
|
||||
}
|
||||
|
||||
const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
|
||||
.reduce((a, e) => a + (e.count ?? 1), 0);
|
||||
if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);
|
||||
|
||||
// par sanity: an unbeatable par is a bug, a free par is a waste.
|
||||
if (level.par?.time > 0) {
|
||||
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
||||
const slow = pace(level, { profile: 'cautious' }).seconds;
|
||||
if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — unachievable`);
|
||||
else if (level.par.time > slow) C(`par.time ${level.par.time}s is slower than a timid run (${slow.toFixed(0)}s) — free medal`);
|
||||
} else C('no par.time');
|
||||
|
||||
if (level.par?.score > 0 && (level.events ?? []).some((e) => e.type === 'spawn')) {
|
||||
const budget = scoreBudget(level);
|
||||
if (budget != null && level.par.score > budget)
|
||||
W(`par.score ${level.par.score} exceeds the kill budget ${budget} (Lane B's scores x C's counts) — only reachable via pickups and combo multipliers`);
|
||||
}
|
||||
|
||||
// gate wiring
|
||||
for (const e of level.events ?? []) {
|
||||
if (e.type === 'gate' && e.to && !CAMPAIGN.includes(e.to)) W(`gate "${e.id}" points at "${e.to}" which is not in the campaign registry yet`);
|
||||
}
|
||||
if (level.next && !CAMPAIGN.includes(level.next)) W(`next "${level.next}" is not in the campaign registry yet`);
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// CLI (node only — guarded so importing this in the browser has zero side effects)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
function bar(v, max, width = 22) {
|
||||
const n = Math.max(0, Math.min(width, Math.round((v / max) * width)));
|
||||
return '#'.repeat(n) + '.'.repeat(width - n);
|
||||
}
|
||||
|
||||
async function cliSelfcheck() {
|
||||
let bad = 0, warned = 0;
|
||||
for (const id of CAMPAIGN) {
|
||||
let level;
|
||||
try { level = await loadJSON(`./${id}.json`); }
|
||||
catch (err) { console.error(` x ${id}: cannot load — ${err.message}`); bad++; continue; }
|
||||
const { errors, warnings } = await validate(level, { expectId: id });
|
||||
const tag = isSkeleton(level) ? ' (skeleton)' : '';
|
||||
if (errors.length) {
|
||||
bad++;
|
||||
console.error(` x ${id}${tag}: ${errors.length} error(s)`);
|
||||
for (const e of errors) console.error(` ERROR ${e}`);
|
||||
} else {
|
||||
console.log(` . ${id}${tag}: ok — ${level.segments.length} segments, ${totalLength(level)} units, ${level.events.length} events`);
|
||||
}
|
||||
for (const w of warnings) { warned++; console.log(` warn ${w}`); }
|
||||
}
|
||||
console.log(`levels selfcheck: ${CAMPAIGN.length - bad}/${CAMPAIGN.length} valid, ${warned} warning(s)`);
|
||||
return bad === 0;
|
||||
}
|
||||
|
||||
async function cliSim(only) {
|
||||
const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes
|
||||
const bal = await combatBalance(); // so the score budget uses Lane B's real scores
|
||||
console.log(`biome registry: ${reg.authoritative ? "Lane A's world/biomes.js" : 'Lane C fallback list (Lane A not present)'}`);
|
||||
console.log(`combat balance: ${bal ? "Lane B's combat/balance.js" : 'not present (score budget unavailable)'}`);
|
||||
for (const id of CAMPAIGN) {
|
||||
if (only && id !== only) continue;
|
||||
const level = await loadJSON(`./${id}.json`);
|
||||
const p = pace(level, { profile: 'par' });
|
||||
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
||||
const slow = pace(level, { profile: 'cautious' }).seconds;
|
||||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
||||
|
||||
console.log(`\n=== ${id} — ${level.name}${isSkeleton(level) ? ' [SKELETON]' : ''} ===`);
|
||||
const budget = scoreBudget(level);
|
||||
console.log(`${p.total} units · par.time ${level.par?.time ?? '?'}s (${mmss(level.par?.time ?? 0)}) · par.score ${level.par?.score ?? '?'}` +
|
||||
(budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : ''));
|
||||
console.log(`sim: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
|
||||
const maxP = Math.max(...p.beats.map((b) => b.pressure), 0.001);
|
||||
console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum');
|
||||
for (const b of p.beats) {
|
||||
console.log(
|
||||
` ${(b.name ?? '').padEnd(26).slice(0, 26)} ${String(b.s0).padStart(4)}-${String(b.s1).padStart(4)} ` +
|
||||
`${String(b.length).padStart(5)} ${String(b.flow).padStart(4)} ${String(b.enemies).padStart(3)} ` +
|
||||
`${b.density.toFixed(2).padStart(5)} ${b.tightness.toFixed(2).padStart(5)} ${b.hazard.toFixed(2).padStart(4)} ` +
|
||||
`${b.pressure.toFixed(2).padStart(6)} ${bar(b.pressure, maxP, 10)} ${b.clearance.toFixed(2).padStart(5)} ` +
|
||||
`${b.seconds.toFixed(0).padStart(4)}s ${mmss(b.cumulative).padStart(5)}`);
|
||||
}
|
||||
const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
|
||||
console.log(` ${'TOTAL'.padEnd(26)} ${String(0).padStart(4)}-${String(p.total).padStart(4)} ${String(p.total).padStart(5)} ${'-'.padStart(4)} ${String(totEn).padStart(3)}`);
|
||||
|
||||
if (p.checkpoints.length) {
|
||||
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
||||
for (const g of p.checkpoints) {
|
||||
const flag = g.seconds > 30 ? ' <-- OVER' : '';
|
||||
console.log(` ${String(g.from).padStart(4)} -> ${String(g.to).padStart(4)} ${String(g.units).padStart(4)}u ${g.seconds.toFixed(1).padStart(5)}s ${g.name}${flag}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
|
||||
if (process.argv.includes('--sim')) {
|
||||
const i = process.argv.indexOf('--sim');
|
||||
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
|
||||
}
|
||||
if (process.argv.includes('--selfcheck')) {
|
||||
const ok = await cliSelfcheck();
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,35 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Sample level — kept in sync with LEVEL SCHEMA v1 by Lane C (round 1, authorised by
|
||||
// ROUND1_INSTRUCTIONS §Lane C.1). Schema of record: docs/TECH.md §Level data schema v1.
|
||||
// This is the schema's shop window: if you are implementing against level data, this is the
|
||||
// smallest complete example of it.
|
||||
//
|
||||
// Two deliberate restraints, both so this stays a stub and not a level:
|
||||
// - It keeps the BARE archetype ids ('floater'/'seeker'/'turret') rather than the fiction
|
||||
// ids that real levels use ('bolus_chunk'...). Lane B's pools are keyed by archetype and
|
||||
// B's dev harness (flight/dev.js) reads STUB_LEVEL directly, so renaming here would break
|
||||
// a live lane for nothing. levels/enemies.js resolves both (passthrough entries).
|
||||
// - Turrets are authored one-per-event with a scalar `theta`. Real levels may use a
|
||||
// `theta: [a, b]` array for count > 1; that form needs one line in B's spawn loop first
|
||||
// (snippet in LANE_C_NOTES.md §→ Lane B), so it is not demonstrated here yet.
|
||||
export const STUB_LEVEL = {
|
||||
schema: 1,
|
||||
id: 'STUB', name: 'Stub Esophagus', seed: 20260716,
|
||||
segments: [{ biome: 'esophagus', length: 400, radius: { base: 10, wobble: 0.15 }, curviness: 0.3, flow: 14 }],
|
||||
par: { time: 30, score: 900, samples: 0 },
|
||||
segments: [{
|
||||
biome: 'esophagus', name: 'Stub Esophagus',
|
||||
length: 400, radius: { base: 10, wobble: 0.15 }, curviness: 0.3, flow: 14,
|
||||
}],
|
||||
arenas: [],
|
||||
events: [
|
||||
{ s: 60, type: 'spawn', enemy: 'floater', count: 4 },
|
||||
{ s: 140, type: 'spawn', enemy: 'seeker', count: 3 },
|
||||
{ s: 200, type: 'checkpoint' },
|
||||
{ s: 260, type: 'spawn', enemy: 'turret', count: 2 },
|
||||
{ s: 60, type: 'spawn', enemy: 'floater', count: 4, spread: 60 },
|
||||
{ s: 140, type: 'spawn', enemy: 'seeker', count: 3, spread: 40 },
|
||||
{ s: 200, type: 'checkpoint', name: 'Stub Checkpoint' },
|
||||
{ s: 260, type: 'spawn', enemy: 'turret', count: 1, theta: 0 },
|
||||
{ s: 280, type: 'spawn', enemy: 'turret', count: 1, theta: 3.1416 },
|
||||
{ s: 320, type: 'pickup', kind: 'nutrient_orb', count: 3, spread: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user