4a98cf4b0e
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>
199 lines
8.3 KiB
GDScript
199 lines
8.3 KiB
GDScript
extends GutTest
|
|
## The dungeon flavours. The Proving Grounds exists so a manual pass over loot,
|
|
## inventory and all four boss phases takes minutes rather than a quarter of an
|
|
## hour -- which is only true if it stays genuinely identical to the real run
|
|
## apart from the numbers it scales.
|
|
|
|
|
|
func test_every_registered_id_has_a_definition() -> void:
|
|
for id in Dungeons.ORDER:
|
|
assert_not_null(Dungeons.get_def(id), "%s is in ORDER with no definition" % id)
|
|
|
|
|
|
func test_the_wire_index_round_trips() -> void:
|
|
for id in Dungeons.ORDER:
|
|
assert_eq(Dungeons.by_index(Dungeons.index_of(id)), id)
|
|
|
|
|
|
## An id off the wire has to resolve to something playable. Falling back to the
|
|
## standard run is the safe direction; returning null would crash a peer for
|
|
## sending a byte this build does not recognise.
|
|
func test_an_unknown_id_falls_back_to_the_real_run() -> void:
|
|
assert_null(Dungeons.get_def(&"atlantis"))
|
|
assert_eq(Dungeons.get_or_default(&"atlantis").id, Dungeons.STANDARD)
|
|
assert_eq(Dungeons.by_index(99), Dungeons.default_id())
|
|
assert_eq(Dungeons.by_index(-1), Dungeons.default_id())
|
|
|
|
|
|
## The baseline has to be exactly the content as authored, or "identical but
|
|
## easier" is measured against something that is itself already scaled.
|
|
func test_the_standard_run_scales_nothing() -> void:
|
|
var d := Dungeons.standard()
|
|
assert_eq(d.enemy_hp_mult, 1.0)
|
|
assert_eq(d.boss_hp_mult, 1.0)
|
|
assert_eq(d.loot_chance_mult, 1.0)
|
|
assert_eq(d.apply_to_enemy(Content.drifter()).max_hp, Content.drifter().max_hp)
|
|
assert_eq(d.apply_to_boss(Content.warden()).max_hp, Content.warden().max_hp)
|
|
assert_eq(d.apply_to_enemy(Content.drifter()).loot[0].chance,
|
|
Content.drifter().loot[0].chance)
|
|
|
|
|
|
func test_the_proving_grounds_is_easier_in_every_direction() -> void:
|
|
var easy := Dungeons.proving_grounds()
|
|
assert_lt(easy.apply_to_enemy(Content.drifter()).max_hp, Content.drifter().max_hp)
|
|
assert_lt(easy.apply_to_boss(Content.warden()).max_hp, Content.warden().max_hp)
|
|
assert_gt(easy.apply_to_enemy(Content.drifter()).loot[0].chance,
|
|
Content.drifter().loot[0].chance)
|
|
|
|
|
|
## Nothing may be scaled out of existence: a one-shot enemy is still a fight,
|
|
## an enemy with zero health is a crash waiting for a divide.
|
|
func test_scaling_never_produces_a_creature_with_no_health() -> void:
|
|
var brutal := DungeonDef.new()
|
|
brutal.enemy_hp_mult = 0.0
|
|
brutal.boss_hp_mult = 0.0
|
|
assert_gte(brutal.apply_to_enemy(Content.drifter()).max_hp, 1)
|
|
assert_gte(brutal.apply_to_boss(Content.warden()).max_hp, 1)
|
|
|
|
|
|
## A generous multiplier must not push a chance past certain, or a "chance"
|
|
## stops being one and the roll becomes dead code.
|
|
func test_boosted_drop_chances_are_clamped_at_certain() -> void:
|
|
var generous := DungeonDef.new()
|
|
generous.loot_chance_mult = 500.0
|
|
for entry in generous.apply_to_enemy(Content.drifter()).loot:
|
|
assert_lte(entry.chance, 1.0)
|
|
# And a guaranteed drop stays exactly guaranteed rather than overflowing.
|
|
for entry in generous.apply_to_boss(Content.warden()).loot:
|
|
assert_eq(entry.chance, 1.0)
|
|
|
|
|
|
## Scaling mutates the def it is given. That is only safe because every
|
|
## Content.* call constructs a fresh one -- if that ever stops being true, one
|
|
## easy dungeon would permanently nerf every hard one in the process.
|
|
func test_scaling_one_dungeon_does_not_leak_into_the_next() -> void:
|
|
Dungeons.proving_grounds().apply_to_boss(Content.warden())
|
|
assert_eq(Dungeons.standard().apply_to_boss(Content.warden()).max_hp,
|
|
Content.warden().max_hp,
|
|
"a standard run built afterwards must still have a full-health boss")
|
|
|
|
|
|
# --- 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 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")
|
|
assert_eq(easy.world.enemies.size(), hard.world.enemies.size(),
|
|
"and the same number of enemies in the same rooms")
|
|
var easy_hp := 0
|
|
var hard_hp := 0
|
|
for e in easy.world.enemies.values():
|
|
easy_hp += e.hp
|
|
for e in hard.world.enemies.values():
|
|
hard_hp += e.hp
|
|
assert_lt(easy_hp, hard_hp)
|
|
|
|
|
|
func test_a_dungeon_defaults_to_the_real_run() -> void:
|
|
assert_eq(Instance.make_dungeon(2, 1, 1).dungeon_id, Dungeons.default_id())
|
|
|
|
|
|
## Walking into the Proving Grounds must never drop you into a standard run that
|
|
## happens to still be forming, however conveniently timed.
|
|
func test_a_forming_run_only_accepts_its_own_kind() -> void:
|
|
var inst := Instance.make_dungeon(2, 1, 1, Dungeons.PROVING)
|
|
assert_true(inst.accepts_new_party_member(Dungeons.PROVING))
|
|
assert_false(inst.accepts_new_party_member(Dungeons.STANDARD))
|
|
|
|
|
|
func test_the_hub_has_one_entrance_per_dungeon() -> void:
|
|
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
|
assert_eq(lobby.world.portals.size(), Dungeons.ORDER.size())
|
|
var seen := {}
|
|
for portal in lobby.world.portals:
|
|
seen[portal.dungeon] = true
|
|
for id in Dungeons.ORDER:
|
|
assert_true(seen.has(id), "%s has no way in" % id)
|
|
|
|
|
|
## Two entrances whose catchment areas overlap would make which dungeon you
|
|
## enter depend on list order rather than on where you are standing.
|
|
func test_the_entrances_are_far_enough_apart_to_be_unambiguous() -> void:
|
|
var portals := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world.portals
|
|
for i in portals.size():
|
|
for j in range(i + 1, portals.size()):
|
|
assert_gt(portals[i].pos.distance_to(portals[j].pos),
|
|
SimConfig.PORTAL_RADIUS * 2.0,
|
|
"portals %d and %d overlap" % [i, j])
|
|
|
|
|
|
func test_standing_on_an_entrance_resolves_to_that_dungeon() -> void:
|
|
var world := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world
|
|
for portal in world.portals:
|
|
var found := world.portal_at(portal.pos)
|
|
assert_not_null(found)
|
|
assert_eq(found.dungeon, portal.dungeon)
|
|
|
|
|
|
func test_standing_between_them_is_still_out_of_reach_of_both() -> void:
|
|
var world := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world
|
|
if world.portals.size() < 2:
|
|
pass_test("only one entrance; nothing to stand between")
|
|
return
|
|
var midpoint := (world.portals[0].pos + world.portals[1].pos) * 0.5
|
|
assert_null(world.portal_at(midpoint),
|
|
"the gap between entrances has to be a place where nothing happens")
|
|
|
|
|
|
## The event carries which dungeon, because the server must never take that from
|
|
## anything the client says.
|
|
func test_using_an_entrance_reports_which_dungeon_it_opens() -> void:
|
|
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
|
lobby.add_peer(1, "tester")
|
|
var target: SimPortal = lobby.world.portals[Dungeons.ORDER.size() - 1]
|
|
lobby.world.players[1].pos = target.pos
|
|
var frames: Array[InputFrame] = [InputFrame.make(
|
|
lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
|
lobby.world.queue_input(1, frames)
|
|
lobby.step()
|
|
var used := lobby.world.events.filter(
|
|
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
|
|
assert_eq(used.size(), 1)
|
|
assert_eq(StringName(used[0]["dungeon"]), target.dungeon)
|
|
|
|
|
|
func test_a_dungeon_world_has_no_entrances_of_its_own() -> void:
|
|
var inst := Instance.make_dungeon(2, 1, 1, Dungeons.PROVING)
|
|
assert_eq(inst.world.portals.size(), 0,
|
|
"there is no way further down yet, and no way to re-enter from inside")
|