Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
ci / verify (push) Successful in 49s
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.
CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.
The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.
TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.
The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.
Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.
Two things found while finishing:
- tools/export_content.gd had a hand-maintained boss list and had already
gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
ALL_BOSSES now feed the export tool, the renderer and five tests that each
kept their own copy.
- diag_loot failed intermittently after another diagnostic. Taking over from
the bot cleared its input queue but not its HELD input, so a starved server
coasted on the bot's last movement vector for half a second and walked the
player off the item it had been placed on. The press arrived correctly,
which is why "the press reached the simulation" passed while everything it
should have caused failed.
check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -362,3 +362,38 @@ old 100000 health was already past the u16 the snapshot sends enemy health in,
|
||||
and once a shot did 60 a patient player could have worn it down and left the
|
||||
hub without a practice target until the next restart. A flag says what was
|
||||
actually meant.
|
||||
|
||||
---
|
||||
|
||||
## Bosses that move, and attacks that warn
|
||||
|
||||
**Movement belongs to the phase, not to the boss.** A fight that stands still
|
||||
and then starts hunting you is one boss with two phases. `BossDef.stationary`
|
||||
was removed rather than kept alongside the phases: a flag claiming the boss
|
||||
stood still while one of its phases walked around would be a second source of
|
||||
truth, and the wrong one. `BossDef.moves()` is derived.
|
||||
|
||||
**CHASE holds a distance instead of closing.** A boss standing on top of a
|
||||
player is a boss whose bullets cannot be read, and this genre cannot afford
|
||||
that. It backs off when you come inside its preferred range.
|
||||
|
||||
**Waypoints are fractions of the arena.** The Warden's hall and the Choir Vault
|
||||
are different sizes, and a phase written against absolute coordinates would only
|
||||
work in one of them.
|
||||
|
||||
**The boss room clamp moved after movement.** It was a no-op while every boss
|
||||
was stationary. Boss rooms deliberately do not lock, so walking out is always an
|
||||
escape — which is only true if the boss cannot follow.
|
||||
|
||||
**Telegraphed strikes are announced by a stateless emitter.** Emitters are
|
||||
shared resources, so nothing may be remembered between the warning and the
|
||||
strike; the positions are derived from the volley number instead, and a test
|
||||
asserts the burst lands where the marker promised.
|
||||
|
||||
**Telegraph markers are drawn through fog and through walls**, unlike every
|
||||
other thing in the view. A warning you cannot see is an unavoidable hit with
|
||||
extra steps, which is the opposite of what a telegraph is for.
|
||||
|
||||
**Which boss a run has comes from its seed, not its depth.** Depth is a dev flag
|
||||
that nothing in play raises, so keying the arena to it meant the second boss
|
||||
existed and no player could ever reach it.
|
||||
|
||||
+73
-17
@@ -20,8 +20,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
|
||||
| Gate | Covers | Runtime |
|
||||
| --- | --- | --- |
|
||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||
| `tools/test.sh` | 364 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `tools/test.sh` | 409 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 19 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
|
||||
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
|
||||
@@ -314,24 +314,80 @@ All four settled with the user; the reasoning is in
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — Boss features and new bosses · *todo*
|
||||
## Stage 5 — Boss features and new bosses · *done*
|
||||
|
||||
| Feature | State |
|
||||
| --- | --- |
|
||||
| Stationary phases | done — every current phase |
|
||||
| Boss confined to its room | done — `SimWorld._step_boss` clamps to `SimBoss.room` |
|
||||
| Roaming / chasing within the boss room | todo |
|
||||
| Phases that move to preset locations | todo |
|
||||
| Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog |
|
||||
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` |
|
||||
| Feature | State | Where |
|
||||
| --- | --- | --- |
|
||||
| Stationary phases | done | every Warden phase |
|
||||
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
|
||||
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
|
||||
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
|
||||
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
|
||||
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
|
||||
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
|
||||
|
||||
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
|
||||
asserts the simulation needs no changes to run it. Movement is the first thing
|
||||
that format has not covered, so expect `BossPhase` to gain a movement field
|
||||
rather than `SimWorld` gaining a per-boss branch.
|
||||
### Boss movement
|
||||
|
||||
Remember boss rooms **do not lock** (a settled decision): a player can always
|
||||
walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.
|
||||
Movement is a property of the **phase**, not of the boss: a fight that stands
|
||||
still and then starts hunting you is one boss with two phases. Four modes —
|
||||
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
|
||||
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
|
||||
|
||||
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
|
||||
works in the Warden's hall and the Choir Vault alike.
|
||||
- **CHASE holds a distance rather than closing.** A boss standing on top of you
|
||||
is a boss whose bullets cannot be read.
|
||||
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
|
||||
destination and would otherwise snap onto its circle on the first tick.
|
||||
- **Movement slides against geometry**, so a boss cannot walk through the
|
||||
pillars its own arena was designed around.
|
||||
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
|
||||
still — which is exactly when an invariant is cheapest to establish. Boss
|
||||
rooms deliberately do not lock, and that is only an escape if the boss cannot
|
||||
follow you out.
|
||||
|
||||
### Telegraphed strikes
|
||||
|
||||
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
|
||||
later. The moment in between is the whole feature: a burst that appears at your
|
||||
feet is a coin flip, the same burst with a second of notice is a question.
|
||||
|
||||
- **The emitter is stateless**, like every other one — they are shared resources
|
||||
and two bosses of the same kind must not stomp each other. Strike positions
|
||||
are *derived* from the volley number, so the announcement and the strike
|
||||
compute the same points with nothing stored between them.
|
||||
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
|
||||
besides spawn bullets.
|
||||
- **Markers are drawn through fog and through walls.** Everything else in the
|
||||
view respects line of sight; a warning you cannot see is not a warning. See
|
||||
`WorldView._draw_telegraphs`.
|
||||
|
||||
### The Cantor of the Vault
|
||||
|
||||
| Phase | Movement | Idea |
|
||||
| --- | --- | --- |
|
||||
| Call to Prayer | static | Teaches the marker, and nothing else. |
|
||||
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
|
||||
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
|
||||
| Final Cadence | orbit | Movement and telegraphs at once. |
|
||||
|
||||
**Which boss you meet comes from the run's seed**, not its depth. Depth is a dev
|
||||
flag nothing in play raises, so keying the arena to it left the second boss
|
||||
existing and unreachable. Every run rolls a fresh seed, so it is a coin flip per
|
||||
dungeon, and `test_both_bosses_are_reachable_at_the_depth_people_play` pins it.
|
||||
|
||||
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
|
||||
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
|
||||
had already gone stale and silently stopped writing the second boss.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **No boss-specific music, intro or death sequence.** A boss dies like an
|
||||
enemy, only louder.
|
||||
- **Telegraph markers are one shape.** A circle is the only warning the client
|
||||
can draw; a lane or a cone would need another event field.
|
||||
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
|
||||
the generator's "boss arena on the right-hand side" assumption will be tested.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user