Files
claude e0c1e0d5c6
ci / verify (push) Successful in 49s
Stage 5: bosses that move, attacks that warn, and a second boss
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00

293 lines
10 KiB
GDScript

extends Node
## End-to-end check of inventory and loot: drop -> snapshot -> pick up ->
## persist -> use -> drop again, plus the two loot visibilities.
##
## godot --headless --path . res://tools/diag_loot.tscn
##
## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero
## on any failure, so it gates like a test.
##
## It exists for the same reason tools/diag_progression.tscn does: the bot smoke
## test cannot cover this. Bots are poor shots, so they almost never produce a
## drop, and the one thing worth checking end to end here is precisely what
## happens after something dies.
##
## Input is driven through Net.send_input with real encoded frames rather than
## by calling the world's handlers, because the whole claim being tested is that
## a client can do all of this with nothing but an InputFrame.
const STORE_PATH := "user://diag_loot.json"
## A second player in the world who is deliberately NOT in inst.peers: nothing
## is ever sent to it, so it costs no transport, but it is a real SimPlayer as
## far as instanced drops and per-peer snapshot encoding are concerned.
const GHOST := 999
var _fails: Array[String] = []
var _step: int = 0
var _srv: ServerRuntime
var _account: int = 515151
var _character: Character
var _hp_before_use: int = 0
## Tick of the most recent frame this file sent, so each step can confirm the
## server actually consumed it.
var _last_press_tick: int = 0
## True once this file is the only thing sending input.
var _driving: bool = false
func _ready() -> void:
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
GameOpts.bot_client = true
GameOpts.boss_rush = true # a quiet room: the boss, and whatever we place
GameOpts.account_override = _account
if Net.host(27402) != OK:
push_error("could not host")
get_tree().quit(1)
return
_srv = Net.server
_srv.store = CharacterStore.new(STORE_PATH)
_srv.store.load_from_disk()
Net.start_local_client()
func _check(ok: bool, what: String) -> void:
if ok:
print(" ok %s" % what)
else:
print(" FAIL %s" % what)
_fails.append(what)
func _world() -> SimWorld:
var inst := _srv.instance_of(Net.LOCAL_PEER)
return inst.world if inst != null else null
func _me() -> SimPlayer:
var w := _world()
return w.players.get(Net.LOCAL_PEER) if w != null else null
## One press and its release, encoded and sent exactly as the client would.
## The release matters: item actions are edge-triggered, and a starved server
## coasts on the last frame it was given, so a press with no release would leave
## the button held forever and the next press would not be an edge at all.
func _press(buttons: int, slot: int = 0) -> void:
var w := _world()
var p := _me()
if w == null or p == null:
return
# Numbered from the last input the server ACCEPTED, not from the world tick.
# The bot was numbering its frames INPUT_TARGET_LEAD ahead of the server, so
# a frame numbered from the world tick sits behind last_input_tick and is
# discarded as a duplicate -- correctly, and silently. That is worth
# labouring over: when it happened, the pickup never occurred, and every
# later check still passed, because "the slot is empty" and "the item is on
# the floor" are both true of a world where nothing happened at all.
var base := maxi(w.tick + 2, p.last_input_tick + 1)
var frames: Array[InputFrame] = [
InputFrame.make(base, Vector2.ZERO, 0.0, buttons, slot),
InputFrame.make(base + 1, Vector2.ZERO, 0.0, 0, slot),
]
_last_press_tick = base + 1
Net.send_input(NetCodec.encode_inputs(frames))
## Assert the last press reached the simulation. Without this the whole file
## degrades into checking that nothing happened, which it would do quietly.
func _press_landed(what: String) -> void:
var p := _me()
_check(p != null and p.last_input_tick >= _last_press_tick,
"the %s press reached the simulation" % what)
func _stored_inventory() -> Array[StringName]:
var c := _srv.store.get_character(_account, _character.id)
return c.inventory if c != null else ([] as Array[StringName])
func _physics_process(_delta: float) -> void:
_step += 1
# The client is no longer numbering its own frames, so keep its counter
# level with the server's. Without this it warns about its input lead every
# few ticks -- expected noise, and expected noise in a diagnostic is how a
# real warning gets missed.
if _driving and _world() != null:
Net.client.input_tick = _world().tick + SimConfig.INPUT_TARGET_LEAD
match _step:
20: _login()
40: _srv._send_to_dungeon(Net.LOCAL_PEER)
60: _enter_dungeon()
70: _kill_something()
78: _see_the_drop()
80: _press(InputFrame.BTN_INTERACT)
88: _took_it()
90: _press(InputFrame.BTN_DROP, 0)
98: _dropped_it()
100: _press(InputFrame.BTN_INTERACT)
108: _took_it_again()
110: _press(InputFrame.BTN_USE, 0)
118: _drank_it()
120: _kill_the_boss()
126: _boss_loot()
132: _finish()
func _login() -> void:
_character = _srv.store.last_played(_account)
_check(_character != null, "a character exists after login")
if _character == null:
_finish()
return
_check(_character.inventory.size() == SimConfig.INVENTORY_SLOTS,
"and starts with %d empty slots" % SimConfig.INVENTORY_SLOTS)
func _enter_dungeon() -> void:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
"moved into a dungeon")
if inst == null:
_finish()
return
# Stop the bot driving its own input; from here every frame is one we sent
# deliberately, so a pickup can only happen because this file asked for it.
Net.client.set_physics_process(false)
_driving = true
var p := _me()
# Drop the bot's backlog. The client keeps roughly INPUT_TARGET_LEAD frames
# in flight, so without this the first press queues up behind them.
p.input_queue.clear()
# And stop it COASTING. A starved server repeats the last frame it was
# given for INPUT_MAX_AGE ticks, so the bot's final movement vector kept
# walking the player for half a second after the takeover -- far enough off
# the item it had been placed on that the pickup found nothing. The press
# itself arrived correctly, which is why "the press reached the simulation"
# passed while everything it should have caused failed.
p.held_input = InputFrame.new()
# Long arrival protection instead of god mode: it is a state the game
# already has, so nothing here is testing a code path players never hit.
p.spawn_grace = 100000
func _kill_something() -> void:
var w := _world()
var p := _me()
var def := Content.drifter()
# Forced to a certain drop. The drop RATE is a unit-test question; what this
# file is for is everything that happens after the roll succeeds.
def.loot = [LootDrop.make(Items.HEALTH_POTION, 1.0)]
var e := w.spawn_enemy(def, p.pos + Vector2(60.0, 0.0))
w._damage_enemy(e, def.max_hp * 2)
_check(w.loot.size() == 1, "a kill leaves an item on the floor")
for l in w.loot.values():
_check(l.owner_peer == 0, "and trash loot is shared, not instanced")
# Stand on it, so the pickup below is about the button and not about
# walking there.
p.pos = l.pos
func _see_the_drop() -> void:
_check(Net.client.ground_loot().size() == 1,
"the item reaches the client through the snapshot")
var near := Net.client.loot_in_reach()
_check(not near.is_empty(), "and the client can tell it is in reach")
func _took_it() -> void:
_press_landed("interact")
var p := _me()
_check(p.inventory[0] == Items.HEALTH_POTION, "interact picks it up")
_check(_world().loot.size() == 0, "and it leaves the floor")
_check(_stored_inventory()[0] == Items.HEALTH_POTION,
"the pickup is written to the character store at once")
_check(Items.by_index(int(Net.client.my_inventory[0])) == Items.HEALTH_POTION,
"and the client's own bag agrees")
func _dropped_it() -> void:
_press_landed("drop")
var w := _world()
_check(_me().inventory[0] == Items.NONE, "dropping empties the slot")
_check(w.loot.size() == 1, "and puts it back on the floor")
_check(_stored_inventory()[0] == Items.NONE, "the store follows the drop too")
for l in w.loot.values():
_check(l.owner_peer == 0, "a dropped item is world-shared")
_me().pos = l.pos
func _took_it_again() -> void:
_press_landed("second interact")
var p := _me()
_check(p.inventory[0] == Items.HEALTH_POTION, "what you drop can be picked back up")
# Hurt, so the potion has something to do. Set directly rather than shot:
# what is being tested is the item, not hit resolution.
p.hp = maxi(p.max_hp / 4, 1)
_hp_before_use = p.hp
func _drank_it() -> void:
_press_landed("use")
var p := _me()
_check(p.hp > _hp_before_use + 5,
"using the potion heals (%d -> %d)" % [_hp_before_use, p.hp])
_check(p.inventory[0] == Items.NONE, "and consumes it")
_check(_stored_inventory()[0] == Items.NONE, "the store follows the use")
func _kill_the_boss() -> void:
var w := _world()
var p := _me()
# A second player, close enough to the boss to be sent the same snapshot.
var ghost := w.add_player(GHOST, "ghost")
ghost.pos = w.boss.pos + Vector2(40.0, 40.0)
ghost.spawn_grace = 100000
p.pos = w.boss.pos + Vector2(-40.0, 40.0)
w._damage_boss(w.boss.def.max_hp * 2)
func _boss_loot() -> void:
var w := _world()
var shared := 0
var mine := 0
var theirs := 0
for l in w.loot.values():
if l.owner_peer == 0:
shared += 1
elif l.owner_peer == Net.LOCAL_PEER:
mine += 1
elif l.owner_peer == GHOST:
theirs += 1
_check(shared == 1, "the boss leaves one shared potion for the party")
_check(mine == 1 and theirs == 1, "and one ration per living player")
# The visibility rule is enforced on the wire, so check it there rather than
# by inspecting the world: what matters is what each peer is actually told.
var for_me := NetCodec.decode_snapshot(NetCodec.encode_snapshot(
w, Protocol.COUNTDOWN_NONE, Net.LOCAL_PEER))
var for_them := NetCodec.decode_snapshot(NetCodec.encode_snapshot(
w, Protocol.COUNTDOWN_NONE, GHOST))
_check((for_me["loot"] as Array).size() == 2,
"my snapshot holds the shared potion and my ration, nothing else")
_check((for_them["loot"] as Array).size() == 2,
"and theirs holds the shared potion and their ration")
var my_ids := []
for l: Dictionary in for_me["loot"]:
my_ids.append(int(l["id"]))
var overlap := 0
for l: Dictionary in for_them["loot"]:
if my_ids.has(int(l["id"])):
overlap += 1
_check(overlap == 1,
"exactly one of the two items is the same entity -- the shared one")
func _finish() -> void:
print("---")
if _fails.is_empty():
print("LOOT_OK")
else:
print("LOOT_FAIL (%d)" % _fails.size())
Net.shutdown()
get_tree().quit(0 if _fails.is_empty() else 1)