From 4a98cf4b0e6ab2a184c00d08fb2d425ecbb9db88 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 10:36:06 +0200 Subject: [PATCH] The Cantor gets its own portal; bullets stop strobing 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 --- CLAUDE.md | 3 +- docs/DECISIONS.md | 29 +++++++++++++-- docs/ROADMAP.md | 55 ++++++++++++++++------------ src/actors/dungeons/dungeon_def.gd | 4 +++ src/content/dungeons.gd | 26 +++++++++++++- src/content/rooms.gd | 10 +++--- src/instances/instance.gd | 4 ++- src/sim/map_gen.gd | 26 ++++++++------ src/view/art.gd | 15 ++++++-- src/view/bullet_renderer.gd | 57 ++++++++++++++---------------- tests/unit/test_art.gd | 29 +++++++++++++++ tests/unit/test_boss.gd | 56 ++++++++++++++++++----------- tests/unit/test_boss_movement.gd | 4 +-- tests/unit/test_dungeons.gd | 31 ++++++++++++++-- tools/smoke.sh | 2 ++ 15 files changed, 251 insertions(+), 100 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f60dc06..04e8eed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3a6dd23..9c67f51 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 275dc2d..87d6d56 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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` diff --git a/src/actors/dungeons/dungeon_def.gd b/src/actors/dungeons/dungeon_def.gd index f4728d2..1f94da7 100644 --- a/src/actors/dungeons/dungeon_def.gd +++ b/src/actors/dungeons/dungeon_def.gd @@ -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 diff --git a/src/content/dungeons.gd b/src/content/dungeons.gd index c068e7a..e0fb828 100644 --- a/src/content/dungeons.gd +++ b/src/content/dungeons.gd @@ -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, diff --git a/src/content/rooms.gd b/src/content/rooms.gd index 67cbadd..fd86d38 100644 --- a/src/content/rooms.gd +++ b/src/content/rooms.gd @@ -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.....#", "#.......................................#", diff --git a/src/instances/instance.gd b/src/instances/instance.gd index 4774cbf..93559ff 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -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"] diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 62923a7..526c3ea 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -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), } diff --git a/src/view/art.gd b/src/view/art.gd index d700adf..21c7ada 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -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. diff --git a/src/view/bullet_renderer.gd b/src/view/bullet_renderer.gd index a3d2a49..0a84f2e 100644 --- a/src/view/bullet_renderer.gd +++ b/src/view/bullet_renderer.gd @@ -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 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] + 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) + 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, diff --git a/tests/unit/test_art.gd b/tests/unit/test_art.gd index cd3ded8..bd2c9cf 100644 --- a/tests/unit/test_art.gd +++ b/tests/unit/test_art.gd @@ -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: diff --git a/tests/unit/test_boss.gd b/tests/unit/test_boss.gd index b6fc9e3..58f6658 100644 --- a/tests/unit/test_boss.gd +++ b/tests/unit/test_boss.gd @@ -180,34 +180,50 @@ 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) - assert_true(inst.world.boss.room.has_point(inst.world.boss.pos), - "and it starts inside its own arena") +## 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") ## The Cantor exists to prove the boss format stretched to movement and diff --git a/tests/unit/test_boss_movement.gd b/tests/unit/test_boss_movement.gd index 0505a3c..b1e775d 100644 --- a/tests/unit/test_boss_movement.gd +++ b/tests/unit/test_boss_movement.gd @@ -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) diff --git a/tests/unit/test_dungeons.gd b/tests/unit/test_dungeons.gd index fb1ade2..b508e23 100644 --- a/tests/unit/test_dungeons.gd +++ b/tests/unit/test_dungeons.gd @@ -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") diff --git a/tools/smoke.sh b/tools/smoke.sh index 2c3973d..1d58497 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -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"