Files
claude 4a98cf4b0e
ci / verify (push) Successful in 48s
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 <noreply@anthropic.com>
2026-09-07 10:36:06 +02:00

195 lines
7.5 KiB
GDScript

extends GutTest
## Every sprite rect must land inside its texture.
##
## A wrong atlas coordinate does not error — it silently draws whatever happens
## to be at those pixels, or nothing. Without a display to check against, this
## is what catches a mistyped rect.
func _fits(tex: Texture2D, r: Rect2) -> bool:
var size := Vector2(tex.get_size())
return r.position.x >= 0.0 and r.position.y >= 0.0 \
and r.end.x <= size.x and r.end.y <= size.y
func test_the_committed_texture_loads() -> void:
assert_not_null(Art.TILESET)
assert_gt(Art.TILESET.get_width(), 0)
## The licence-restricted sheets are absent from a clean clone by design, so
## their absence must be a supported state rather than a failure. When they are
## present locally, the rect tests below still validate them.
func test_the_game_runs_without_the_restricted_art() -> void:
var bullets := Art.bullets_texture()
if bullets == null:
assert_true(true, "no bullet sheet: renderer falls back to generated dots")
else:
assert_gt(bullets.get_width(), 0)
func test_terrain_rects_are_inside_the_tileset() -> void:
var rects: Array[Rect2] = [Art.WALL, Art.WALL_CAP, Art.PIT, Art.BARRICADE, Art.PILLAR]
rects.append_array(Art.FLOOR_VARIANTS)
for r in rects:
assert_true(_fits(Art.TILESET, r), "terrain rect %s is outside the atlas" % r)
## Actor strips are read frame by frame along the sheet, so the LAST frame is
## what has to fit, not the first.
func test_every_animation_frame_is_inside_the_tileset() -> void:
var strips: Array[Rect2] = [Art.PLAYER_IDLE, Art.PLAYER_RUN, Art.BOSS_IDLE]
strips.append_array(Art.ENEMY_IDLE)
for strip in strips:
for n in Art.ACTOR_FRAMES:
var f := Art.frame(strip, n)
assert_true(_fits(Art.TILESET, f),
"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:
pass_test("bullet sheet not present locally; nothing to validate")
return
for kind in SimConfig.KIND_HEAVY + 1:
for n in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, n)
assert_true(_fits(sheet, r),
"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:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally")
return
var needed := float(SimConfig.KIND_HEAVY + 1) * Art.BULLET_CELL
assert_gte(float(sheet.get_height()), needed,
"atlas is too short for %d bullet kinds" % (SimConfig.KIND_HEAVY + 1))
assert_gte(float(sheet.get_width()), Art.BULLET_FRAMES * Art.BULLET_CELL)
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
## the wrong creature.
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
for id in Content.ALL_ENEMIES:
var v := Content.enemy(id).visual
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
func test_impact_sheet_covers_its_declared_frames() -> void:
var impact := Art.impact_texture()
if impact == null:
pass_test("impact sheet not present locally; nothing to validate")
return
var last := Rect2(
Vector2(float((Art.IMPACT_FRAMES - 1) % Art.IMPACT_COLUMNS), 0.0) * Art.IMPACT_CELL,
Vector2(Art.IMPACT_CELL, Art.IMPACT_CELL))
assert_true(_fits(impact, last))
func test_every_sound_loaded_and_is_short_enough_to_be_a_sound_effect() -> void:
var sounds := {
"shoot": Art.SFX_SHOOT,
"player_hit": Art.SFX_PLAYER_HIT,
"enemy_death": Art.SFX_ENEMY_DEATH,
"boss_death": Art.SFX_BOSS_DEATH,
}
for name in sounds:
var s: AudioStream = sounds[name]
assert_not_null(s, "%s failed to load" % name)
assert_gt(s.get_length(), 0.0, "%s is empty" % name)
# The source pack ships 2.5s+ masters. Anything that long here means the
# conversion step was skipped and shots will smear into each other.
assert_lt(s.get_length(), 1.5, "%s is too long for a game SFX" % name)
func test_the_shoot_sound_is_shorter_than_the_fire_cooldown_allows_to_overlap() -> void:
var cooldown := float(SimConfig.PLAYER_FIRE_COOLDOWN) * SimConfig.TICK_DELTA
assert_lt(Art.SFX_SHOOT.get_length(), cooldown * 4.0,
"held fire would stack more than a few voices of this sound at once")
## The wall cap is mostly transparent by design -- it is an overlay for the top
## edge of a wall, not a tile. Drawing it on its own is what produced a black
## void along every wall, so this records why it must never be used alone.
func test_the_wall_cap_is_mostly_transparent() -> void:
var img := Art.TILESET.get_image()
var clear := 0
for y in int(Art.WALL_CAP.size.y):
for x in int(Art.WALL_CAP.size.x):
var a := img.get_pixel(int(Art.WALL_CAP.position.x) + x,
int(Art.WALL_CAP.position.y) + y).a
if a < 0.15:
clear += 1
assert_gt(clear, 100,
"if this ever becomes opaque the cap could stand alone, but while it " +
"is mostly empty it must always be drawn over floor and wall")
## The wall face, by contrast, has to be solid: it is the one tile drawn with
## nothing but floor beneath it.
func test_the_wall_face_is_opaque() -> void:
var img := Art.TILESET.get_image()
var clear := 0
for y in int(Art.WALL.size.y):
for x in int(Art.WALL.size.x):
if img.get_pixel(int(Art.WALL.position.x) + x,
int(Art.WALL.position.y) + y).a < 0.15:
clear += 1
assert_eq(clear, 0, "the wall face must not have holes in it")
## The player anchor exists because the knight is bottom-heavy in its cell.
## If the art is ever re-cut, this catches the anchor silently becoming wrong.
func test_the_player_anchor_matches_where_the_knight_actually_is() -> void:
var img := Art.TILESET.get_image()
var top := -1
var bottom := -1
for y in int(Art.PLAYER_IDLE.size.y):
for x in int(Art.PLAYER_IDLE.size.x):
if img.get_pixel(int(Art.PLAYER_IDLE.position.x) + x,
int(Art.PLAYER_IDLE.position.y) + y).a > 0.15:
if top < 0:
top = y
bottom = y
break
assert_gte(top, 0, "the idle frame should not be empty")
var body_centre := (float(top) + float(bottom)) * 0.5
var cell_centre := Art.PLAYER_IDLE.size.y * 0.5
assert_almost_eq(Art.PLAYER_ANCHOR.y, cell_centre - body_centre, 1.0,
"anchor should cancel the offset between the cell's centre and the " +
"character's, or the hitbox sits away from the body")