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:
@@ -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:
|
||||
|
||||
+36
-20
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user