Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s

A failed connection left the connect buttons disabled forever. _show_menu()
returned early when a menu already existed, so the set_busy(false) that
re-enables them never ran on the way back from _abort_connect -- the player was
locked out of both joining and hosting with no way out but a restart. Also
guards against a second attempt stacking on an in-flight one.

Contact damage is gone as a concept: nothing in this game hurts you by touching
it, because every threat should be a bullet you can see and dodge. The Stalker
walked at you and dealt contact damage and nothing else, so it now carries a
point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for
about 78px of reach, so it still has to close and leaves nothing lingering.
tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new
enemy cannot quietly reintroduce the mechanic.

The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not
assumed. It calls Net.shutdown(), the socket closes, and the server takes the
same linkdead path as a SIGKILL, because the channel is keyed on the socket
closing rather than on how it closed. Made permanent: a --leave-after bot flag
and two smoke assertions covering the polite exit alongside the hard kill, so a
future "clean leave" message that skipped the channel would fail a test. The
menu now says so out loud in a dungeon -- the mechanic only reads as fair if
the cost is known before clicking.

103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 19:37:26 +02:00
parent f70de1b825
commit e1f9fa8096
17 changed files with 235 additions and 40 deletions
+14
View File
@@ -65,6 +65,7 @@ var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {}
var _interp: float = 0.0
var _bot_tick: int = 0
var _dungeon_ticks: int = 0
func _ready() -> void:
@@ -104,6 +105,19 @@ func _physics_process(delta: float) -> void:
world.step()
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
_maybe_bot_leave()
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
## disconnect is caught by the same channel a SIGKILL is. Deferred because
## Net.shutdown() frees this node.
func _maybe_bot_leave() -> void:
if GameOpts.bot_leave_after <= 0 or instance_kind != Protocol.InstanceKind.DUNGEON:
return
_dungeon_ticks += 1
if _dungeon_ticks == GameOpts.bot_leave_after:
GameLog.info("client", "BOT_GRACEFUL_LEAVE")
Net.shutdown.call_deferred()
# --- Input ------------------------------------------------------------------
+2 -2
View File
@@ -108,8 +108,8 @@ func on_peer_disconnected(peer_id: int) -> void:
return
if inst.kind == Protocol.InstanceKind.DUNGEON:
inst.detach_peer(peer_id)
GameLog.info("server", "peer %d dropped in instance %d, channelling out"
% [peer_id, inst.id])
GameLog.info("server", "peer %d '%s' dropped in instance %d, channelling out"
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
_broadcast_roster()
return
inst.remove_peer(peer_id)