The Cantor gets its own portal; bullets stop strobing
ci / verify (push) Successful in 48s

A dungeon now names the boss arena it ends in, and the arena decides the boss.
Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds.

That replaces the seed coin-flip from the last commit, which was the wrong
call. Which boss you are about to fight is the single thing a player decides
before walking into a dungeon, and rolling it for them makes that decision
unavailable. Only the Proving Grounds still leaves its arena open, because a
harness you re-enter every couple of minutes wants whichever fight comes up
first -- and it is the one place where "either will do" is true.

test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here:
adding a boss and forgetting to give it a way in is now a failing test rather
than a boss nobody meets. The smoke test asserts all three portals open over a
real socket -- bots pick theirs by account id, so a run exercises each.

Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather
than a shape change, so running it had every bullet on screen strobing through
a palette in unison, which with a few hundred in the air is exactly as hard to
look at as it sounds. Each bullet holds one frame and turns slowly instead,
offset by its own id so a ring of twenty does not rotate as one rigid wheel.
The rate is slow enough that nothing completes a full turn inside its own
lifetime, so it reads as drift rather than spin, and a test pins that against
the longest-lived bullet in the game. The renderer slices four textures at
startup now instead of thirty-two.

check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 10:36:06 +02:00
parent 42568a40ad
commit 4a98cf4b0e
15 changed files with 251 additions and 100 deletions
+2 -1
View File
@@ -185,7 +185,8 @@ ticks in milliseconds with no SceneTree.
makes every existing client decode a potion as a ration, so it needs a
`Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally
decides which hub portal opens which dungeon (Nth `P` marker in the lobby
stamp → Nth entry).
stamp → Nth entry). A dungeon names the boss arena it ends in, so adding a
boss means giving it a dungeon or nobody will ever meet it.
- **Which dungeon you enter comes from where you are standing, never from the
client.** `SimWorld.portal_at()` resolves the player's server-side position
to a portal, and the `PORTAL_USED` event carries the answer. There is no
+26 -3
View File
@@ -403,6 +403,29 @@ asserts the burst lands where the marker promised.
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.
**Each boss has its own entrance in the hub.** A dungeon names the arena it ends
in, and the arena decides the boss. Which fight you are walking into is the one
thing a player chooses before a run, so it is not rolled for them — not from the
depth (a dev flag nothing in play raises, which left the second boss
unreachable) and not from the seed either. Only the Proving Grounds leaves it
open, because a harness you re-enter every couple of minutes wants whichever
fight comes up first.
---
## Bullets do not animate
**One frame, and a slow turn.** The bullet sheet's eight frames are a colour
cycle rather than a shape change, and running it meant every bullet on screen
strobed through a palette in unison — genuinely hard to look at with a few
hundred in the air, which is the normal case for this game.
Each bullet now holds one frame and rotates at `Art.BULLET_SPIN_RATE`, offset by
its own id so a ring of twenty does not turn as one rigid wheel. The rate is
slow enough that nothing completes a full turn inside its own lifetime, so it
reads as drift rather than as spin — a test pins that against the
longest-lived bullet in the game.
The other seven frames stay in the atlas because that is what the source art is.
Nothing draws them, and the renderer now slices four textures at startup instead
of thirty-two.
+32 -23
View File
@@ -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` | 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 |
| `tools/test.sh` | 414 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 22 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 |
@@ -74,24 +74,33 @@ draws whatever it holds. The defence is what the server declines to send.
## Dungeon kinds · *done*
Two entrances stand side by side in the hub, labelled, and open different runs.
Three entrances stand side by side in the hub, labelled with the fight each one
ends in.
| Dungeon | `Dungeons` id | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | ×1 | ×1 (3600) | ×1 (trash 8%) |
| Proving Grounds | `proving_grounds` | ×0.2 | ×0.08 (288) | ×10 (trash 80%, clamped) |
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
**A dungeon names its arena, and the arena decides its boss.** Which boss you
are about to fight is the one thing a player chooses before walking into a
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
of minutes, so the one you want is one re-entry away.
The Proving Grounds is a **test harness you can walk into**: same generator,
same rooms, same enemies, the same four-phase Warden — everything simply dies
faster and drops more. A manual pass over loot, the inventory, dropping and all
four boss phases takes a couple of minutes instead of a quarter of an hour, and
because it is a portal rather than a launch flag you can compare the two back to
back without restarting the server.
same rooms, same enemies, but everything dies far faster and drops far more. A
manual pass over loot, the inventory, dropping and all four boss phases takes a
couple of minutes instead of a quarter of an hour, and because it is a portal
rather than a launch flag you can compare it to a real run without restarting
the server.
Adding a third dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker
in the lobby stamp — the Nth marker, in reading order, opens the Nth entry.
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
two have to stay in the same order.
Three things worth knowing:
Two more things worth knowing:
- **It is multipliers over the shared content, not a copy of it.** A duplicated
`Content` would drift the moment anything was tuned, and "identical but
@@ -99,10 +108,9 @@ Three things worth knowing:
- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()`
resolves the server-side position; the `PORTAL_USED` event carries the answer.
No client message names a dungeon, which is what stops anyone picking the
generous loot table from the real run.
- **`accepts_new_party_member` takes the dungeon id.** Without that, walking
into the Proving Grounds would drop you into whatever standard run happened
to still be forming.
generous loot table from a real run. `accepts_new_party_member` compares
dungeon ids too, so walking into one entrance can never drop you into
another's forming run on timing alone.
---
@@ -371,10 +379,11 @@ feet is a coin flip, the same burst with a second of notice is a question.
| 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.
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
from the arena its dungeon names, not from the seed and certainly not from
`--depth`, which nothing in play raises.
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
giving it a way in is a failing test rather than a boss nobody meets.
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
+4
View File
@@ -13,6 +13,10 @@ extends Resource
## One line for the portal label, so a player standing in the hub can tell the
## entrances apart without reading a wiki.
@export var subtitle: String = ""
## Which hand-authored boss arena this run ends in, and therefore which boss it
## has -- see [method Rooms.boss_for_arena]. Empty means "whichever the seed
## rolls", which only the test harness wants.
@export var arena: StringName = &""
@export var enemy_hp_mult: float = 1.0
@export var boss_hp_mult: float = 1.0
## Multiplies every loot chance, clamped at certain. Guaranteed drops stay
+25 -1
View File
@@ -9,10 +9,12 @@ extends RefCounted
## one entry plus one marker.
const STANDARD := &"warden_descent"
const VAULT := &"choir_vault"
const PROVING := &"proving_grounds"
const ORDER: Array[StringName] = [
STANDARD,
VAULT,
PROVING,
]
@@ -24,6 +26,7 @@ static func default_id() -> StringName:
static func get_def(id: StringName) -> DungeonDef:
match id:
STANDARD: return standard()
VAULT: return vault()
PROVING: return proving_grounds()
return null
@@ -53,11 +56,28 @@ static func standard() -> DungeonDef:
var d := DungeonDef.new()
d.id = STANDARD
d.display_name = "Warden's Descent"
d.subtitle = "the real run"
d.subtitle = "the Warden of the Fold"
d.arena = &"warden_hall"
d.tint = Color(0.5, 0.9, 1.0)
return d
## The second boss's own run. Same generator and the same enemies as the
## Descent; what differs is the arena it ends in, and therefore who is waiting.
##
## A dedicated entrance rather than a coin flip on the run's seed: which boss
## you are about to fight is the single thing a player decides before walking
## into a dungeon, and rolling it for them makes that decision unavailable.
static func vault() -> DungeonDef:
var d := DungeonDef.new()
d.id = VAULT
d.display_name = "The Choir Vault"
d.subtitle = "the Cantor of the Vault"
d.arena = &"choir_vault"
d.tint = Color(0.75, 0.6, 1.0)
return d
## A test harness you can walk into.
##
## Same generator, same rooms, same enemies and the same Warden -- everything
@@ -71,6 +91,10 @@ static func proving_grounds() -> DungeonDef:
d.id = PROVING
d.display_name = "Proving Grounds"
d.subtitle = "for testing -- fragile, generous"
# Deliberately left to the seed. The harness exists to reach a fight fast,
# and either fight will do -- runs here last a couple of minutes, so the one
# you want is one re-entry away.
d.arena = &""
d.enemy_hp_mult = 0.2
d.boss_hp_mult = 0.08
# 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag,
+5 -5
View File
@@ -112,17 +112,17 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
## P a dungeon portal S player spawn T practice target
## U the upgrade NPC
##
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two
## of them now: the real run and the Proving Grounds, side by side so they can
## be compared without restarting anything. Adding a third dungeon means adding
## a third marker here.
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER: the
## Warden's Descent, the Choir Vault, and the Proving Grounds. Adding a dungeon
## means adding an entry there and a marker here, and they have to stay in the
## same order.
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#..............P.........P..............#",
"#.........P.........P.........P.........#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
+3 -1
View File
@@ -66,7 +66,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
inst.depth = maxi(dungeon_depth, 1)
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
inst.world = SimWorld.new(dungeon_seed)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
var flavour := Dungeons.get_or_default(inst.dungeon_id)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
inst.depth, flavour.arena)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.boss_spawn = built["boss_pos"]
+16 -10
View File
@@ -19,10 +19,11 @@ extends RefCounted
## Generation is still deterministic from (kind, seed, depth), which is what
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
## pins that.
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int,
arena: StringName = &"") -> Dictionary:
if kind == Protocol.InstanceKind.LOBBY:
return _build_lobby()
return generate(seed_value, depth)
return generate(seed_value, depth, arena)
static func _build_lobby() -> Dictionary:
@@ -71,7 +72,11 @@ static func _build_lobby() -> Dictionary:
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
## (Vector2), boss_room (Rect2i).
static func generate(seed_value: int, depth: int) -> Dictionary:
## [param arena] names the boss arena to stamp, and therefore which boss the run
## ends with. Empty leaves it to the seed, which is what the Proving Grounds
## wants and nothing else does -- for a real dungeon the boss is the thing a
## player chose at the portal.
static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value
var d := maxi(depth, 1)
@@ -86,16 +91,17 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
# The boss arena is placed first and everything else works around it, so a
# generated corridor can never carve through the authored fight.
# Which arena -- and therefore which boss -- comes from the SEED, not the
# 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. Every run rolls a fresh seed, so this is a coin flip per dungeon.
var arena := &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
# Normally the dungeon says which arena it is; only the test harness leaves
# it open, and then the seed decides.
var chosen := arena
if chosen.is_empty():
chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
var stamp := Rooms.warden_hall() if chosen == &"warden_hall" else Rooms.choir_vault()
var bs := Rooms.size_of(stamp)
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
var markers := Rooms.stamp(grid, stamp, boss_origin)
var boss_room := Rect2i(boss_origin, bs)
var arena_id := chosen
var boss_pos := grid.tile_centre(
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
@@ -165,7 +171,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
# Which boss lives here follows from WHICH arena was stamped. Choosing
# it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there.
"boss_id": Rooms.boss_for_arena(arena),
"boss_id": Rooms.boss_for_arena(arena_id),
}
+13 -2
View File
@@ -140,8 +140,19 @@ static func item_icon(item: StringName) -> Rect2:
# still, which is exactly what it looked like on screen.
const BULLET_CELL := 16.0
const BULLET_FRAMES := 8
## Seconds per bullet animation frame.
const BULLET_FRAME_SECONDS := 0.08
## Which of the sheet's frames is actually drawn.
##
## One, and always the same one. The sheet's eight frames are a COLOUR cycle
## rather than a shape change, and cycling them put a few hundred bullets on
## screen all strobing through a palette together -- which is exactly as hard to
## look at as it sounds. The bullets turn slowly instead, which reads as motion
## without flickering. The other seven frames stay in the atlas because that is
## what the source art is; nothing draws them.
const BULLET_STILL_FRAME := 0
## Radians per second a bullet turns as it travels. Deliberately slow: at this
## rate nothing completes a full turn inside its own lifetime, so the effect is
## a drift rather than a spin.
const BULLET_SPIN_RATE := 0.55
## Region for a bullet kind at an animation frame.
+25 -28
View File
@@ -9,6 +9,11 @@ extends Node2D
## carries a single texture, and each kind needs a different region of the sprite
## sheet. Four kinds means four draw calls, which is nothing, and it avoids a
## custom shader passing UV offsets through per-instance custom data.
##
## Bullets do not animate. The sheet's frames are a colour cycle, and running it
## meant every bullet on screen strobed through a palette in unison -- unreadable
## with a few hundred of them in the air. Each bullet turns slowly as it flies
## instead, which is motion you can look at. See Art.BULLET_STILL_FRAME.
## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind.
@@ -20,9 +25,8 @@ const _FALLBACK_COLOURS: Array[Color] = [
]
var _layers: Array[MultiMeshInstance2D] = []
## [kind][frame] -> standalone texture. See _slice_frames().
var _frames: Array[Array] = []
## Advances the bullets' own animation, independent of the simulation.
## Elapsed seconds, driving the slow turn. Wall time rather than a frame count,
## so bullets drift at the same rate whatever the frame rate is doing.
var _anim_time: float = 0.0
## True when the licence-restricted bullet sheet is absent and we are drawing
## generated dots instead. See Art.bullets_texture().
@@ -34,16 +38,17 @@ func _ready() -> void:
_fallback = sheet == null
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
var kinds := SimConfig.KIND_HEAVY + 1
var per_kind: Array[Texture2D] = []
if not _fallback:
_slice_frames(sheet, kinds)
art = _frames[0][0]
per_kind = _slice_still_frames(sheet, kinds)
if _fallback:
GameLog.info("view", "bullet sheet absent, drawing generated dots")
var quad := QuadMesh.new()
quad.size = Vector2.ONE
for kind in kinds:
var layer := MultiMeshInstance2D.new()
layer.texture = art
# Assigned once and never touched again -- that swap was the animation.
layer.texture = per_kind[kind] if not _fallback else art
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
@@ -57,8 +62,6 @@ func _ready() -> void:
func _process(delta: float) -> void:
_anim_time += delta
if not _fallback:
_apply_frame()
## A soft-edged disc with a bright core, generated at runtime. This is what the
@@ -77,33 +80,23 @@ func _make_dot_texture() -> ImageTexture:
return ImageTexture.create_from_image(img)
## Slice the atlas into one standalone texture per (kind, frame).
## Cut one standalone texture per kind out of the atlas.
##
## A MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs run
## 0..1 across whatever texture it is given, and handing it an AtlasTexture does
## not help -- the draw resolves to the underlying atlas and the region is
## ignored, so every bullet rendered as the entire sheet. Pre-slicing costs 32
## tiny textures once at startup and makes each frame a single assignment.
func _slice_frames(sheet: Texture2D, kinds: int) -> void:
## ignored, so every bullet rendered as the entire sheet. Slicing is the way
## round that, and now that only one frame is ever drawn it costs four small
## textures at startup rather than thirty-two.
func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
var img := sheet.get_image()
var out: Array[Texture2D] = []
for kind in kinds:
var per_kind: Array[Texture2D] = []
for f in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, f)
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
per_kind.append(ImageTexture.create_from_image(cell))
_frames.append(per_kind)
## Swap each layer to this frame's texture. One assignment per kind per frame;
## every bullet in a layer shares it, so there is no per-instance work.
func _apply_frame() -> void:
if _frames.is_empty():
return
var f := int(_anim_time / Art.BULLET_FRAME_SECONDS) % Art.BULLET_FRAMES
for kind in _layers.size():
_layers[kind].texture = _frames[kind][f]
out.append(ImageTexture.create_from_image(cell))
return out
func render_pool(pool: BulletPool) -> void:
@@ -121,7 +114,11 @@ func render_pool(pool: BulletPool) -> void:
# little larger so a bullet looks like the threat it is -- the same
# "prefer a visible near-miss" trade the ship sprite makes.
var d: float = pool.radius[i] * 3.0
var angle: float = (pool.vel[i] as Vector2).angle()
# Heading, plus a slow turn. The offset is taken from the bullet's own
# id so a ring of twenty does not rotate as one rigid wheel.
var angle: float = (pool.vel[i] as Vector2).angle() \
+ _anim_time * Art.BULLET_SPIN_RATE \
+ float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,
+29
View File
@@ -47,6 +47,9 @@ func test_every_animation_frame_is_inside_the_tileset() -> void:
"frame %d of strip %s runs off the atlas at %s" % [n, strip, f])
## Only BULLET_STILL_FRAME is ever drawn, but the whole sheet still has to be
## the shape the atlas builder produced -- a short one would mean the still
## frame of a later kind is cut from the wrong row.
func test_every_bullet_frame_is_inside_the_sheet() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
@@ -59,6 +62,32 @@ func test_every_bullet_frame_is_inside_the_sheet() -> void:
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
func test_the_still_frame_exists_for_every_kind() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally")
return
assert_lt(Art.BULLET_STILL_FRAME, Art.BULLET_FRAMES)
for kind in SimConfig.KIND_HEAVY + 1:
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
assert_true(_fits(sheet, r), "kind %d has no still frame" % kind)
## Slow enough that nothing completes a turn inside its own lifetime. A bullet
## that visibly spins is the flicker this replaced, wearing a different hat.
func test_bullets_turn_slowly_enough_to_read_as_drift() -> void:
var longest := float(SimConfig.PLAYER_BULLET_LIFETIME)
for id in Content.ALL_ENEMIES:
for e in Content.enemy(id).emitters:
longest = maxf(longest, float(e.lifetime))
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
for e in phase.emitters:
longest = maxf(longest, float(e.lifetime))
var turn := Art.BULLET_SPIN_RATE * longest * SimConfig.TICK_DELTA
assert_lt(turn, TAU, "the longest-lived bullet turns %.2f rad in its life" % turn)
## The atlas has one row per kind. A short sheet would silently draw the wrong
## bullet rather than error.
func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
+34 -18
View File
@@ -180,32 +180,48 @@ func test_each_arena_summons_its_own_boss() -> void:
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
## Seed parity picks the arena, and the boss follows it. Deliberately NOT the
## depth: depth is a dev flag nothing in play raises, so keying the arena to it
## left the second boss unreachable in an actual game.
func test_a_generated_dungeon_gets_the_boss_its_arena_belongs_to() -> void:
var even := MapGen.generate(1234, 1)
var odd := MapGen.generate(1235, 1)
assert_eq(StringName(even["boss_id"]), Content.BOSS_WARDEN)
assert_eq(StringName(odd["boss_id"]), Content.BOSS_CANTOR)
assert_not_null(Content.boss(StringName(even["boss_id"])))
assert_not_null(Content.boss(StringName(odd["boss_id"])))
## The arena a dungeon names decides its boss, whatever the seed. Which boss you
## are about to fight is the one thing a player chooses before walking into a
## dungeon, so it must not be rolled for them.
func test_the_named_arena_decides_the_boss_whatever_the_seed() -> void:
for run in 12:
var seed_value := run * 7919 + 3
assert_eq(StringName(MapGen.generate(seed_value, 1, &"warden_hall")["boss_id"]),
Content.BOSS_WARDEN)
assert_eq(StringName(MapGen.generate(seed_value, 1, &"choir_vault")["boss_id"]),
Content.BOSS_CANTOR)
## Both bosses have to actually turn up. A run picks its seed at random, so
## this is the check that neither is effectively unreachable.
func test_both_bosses_are_reachable_at_the_depth_people_play() -> void:
## Only the test harness leaves its arena open, and then the seed decides. Both
## fights still have to turn up there, or half the harness is unreachable.
func test_an_unnamed_arena_still_produces_both_fights() -> void:
var seen := {}
for run in 40:
seen[StringName(MapGen.generate(run * 7919 + 3, 1)["boss_id"])] = true
for id in Content.ALL_BOSSES:
assert_true(seen.has(id), "%s never appears at depth 1" % id)
assert_true(seen.has(id), "%s never appears with the arena left open" % id)
func test_an_instance_spawns_the_boss_its_map_asked_for() -> void:
var inst := Instance.make_dungeon(2, 4321, 1)
assert_eq(inst.boss_id, Content.BOSS_CANTOR)
assert_eq(inst.world.boss.def.id, Content.BOSS_CANTOR)
## The real check that a boss is reachable in play: some dungeon in the hub ends
## with it. Adding a boss and forgetting to give it a way in is exactly the
## mistake this catches.
func test_every_boss_has_a_dungeon_that_reaches_it() -> void:
var reachable := {}
for dungeon_id in Dungeons.ORDER:
var arena := Dungeons.get_def(dungeon_id).arena
if arena.is_empty():
continue # the harness rolls; covered above
reachable[Rooms.boss_for_arena(arena)] = true
for id in Content.ALL_BOSSES:
assert_true(reachable.has(id), "no hub portal leads to %s" % id)
func test_an_instance_spawns_the_boss_its_dungeon_asked_for() -> void:
for pair in [[Dungeons.STANDARD, Content.BOSS_WARDEN],
[Dungeons.VAULT, Content.BOSS_CANTOR]]:
var inst := Instance.make_dungeon(2, 4321, 1, pair[0])
assert_eq(inst.boss_id, pair[1], "%s should end with %s" % pair)
assert_eq(inst.world.boss.def.id, pair[1])
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
"and it starts inside its own arena")
+2 -2
View File
@@ -254,8 +254,8 @@ func test_a_replica_never_moves_a_boss() -> void:
## circuit, chases, orbits and telegraphs runs for thousands of ticks without
## leaving its room, standing in a wall, or firing nothing.
func test_the_cantor_survives_its_own_fight() -> void:
var inst := Instance.make_dungeon(2, 9183, 1)
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: an odd seed is the vault")
var inst := Instance.make_dungeon(2, 9183, 1, Dungeons.VAULT)
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: the vault is the Cantor's")
var b := inst.world.boss
var bait := inst.world.add_player(1, "bait")
bait.pos = b.pos + Vector2(180.0, 0.0)
+29 -2
View File
@@ -80,9 +80,36 @@ func test_scaling_one_dungeon_does_not_leak_into_the_next() -> void:
# --- Instances --------------------------------------------------------------
## Every dungeon in the hub names its arena, so a player knows what they are
## walking into. Only the harness leaves it to the seed.
func test_only_the_test_harness_leaves_its_boss_to_chance() -> void:
for id in Dungeons.ORDER:
var d := Dungeons.get_def(id)
if id == Dungeons.PROVING:
assert_true(d.arena.is_empty(), "the harness takes either fight")
else:
assert_false(d.arena.is_empty(), "%s must name its arena" % id)
assert_not_null(Content.boss(Rooms.boss_for_arena(d.arena)))
func test_each_real_dungeon_leads_somewhere_different() -> void:
var arenas := {}
for id in Dungeons.ORDER:
var arena := Dungeons.get_def(id).arena
if arena.is_empty():
continue
assert_false(arenas.has(arena), "two portals lead to the same fight")
arenas[arena] = true
## The seed is even on purpose. The harness rolls its arena, and only a run
## that landed in the SAME arena is comparable -- a different one is a different
## size, which moves the rooms and therefore the enemy count.
func test_a_proving_run_is_built_weaker_than_a_standard_one() -> void:
var hard := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
var easy := Instance.make_dungeon(3, 4242, 1, Dungeons.PROVING)
var same_arena := 4242
assert_eq(posmod(same_arena, 2), 0, "setup: this seed has to roll warden_hall")
var hard := Instance.make_dungeon(2, same_arena, 1, Dungeons.STANDARD)
var easy := Instance.make_dungeon(3, same_arena, 1, Dungeons.PROVING)
assert_lt(easy.world.boss.hp, hard.world.boss.hp)
assert_eq(easy.world.map.width, hard.world.map.width,
"same seed and depth must still give the same size of map")
+2
View File
@@ -128,7 +128,9 @@ check "a boss spawned" "$OUT/server.log" "BOSS_SPAWNED (warden|can
# opens one of each kind. This is what catches a portal wired to the wrong
# dungeon, or an instance matcher that ignores which dungeon was asked for.
check "the real run opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(warden_descent\)"
check "the choir vault opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(choir_vault\)"
check "the proving grounds opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(proving_grounds\)"
check "and each ended with its own boss" "$OUT/server.log" "BOSS_SPAWNED cantor in instance"
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"