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
+33
View File
@@ -104,6 +104,39 @@ Two defences now:
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
returns to normal; it exits non-zero if it does not.
### Every exit runs the same channel
There is deliberately no "I am leaving cleanly" message. The escape channel is
keyed on the socket closing, so all four ways out converge on it:
| Route | Path |
| --- | --- |
| Hold F | `BTN_ESCAPE` -> channel |
| Menu -> "Return to hub" | synthesises a held `BTN_ESCAPE` -> channel |
| Menu -> "Disconnect to menu" | `Net.shutdown()` -> socket close -> `linkdead` -> channel |
| Killing the process / pulling the cable | socket close -> `linkdead` -> channel |
The last two are the same server-side code path, and `tools/smoke.sh` asserts
both: one bot is SIGKILLed mid-dungeon, another calls the same `Net.shutdown()`
the menu button calls. Adding a clean-leave message that skipped the channel
would break that test, which is the point of having it.
The menu says so out loud when you are in a dungeon -- the mechanic only reads
as fair if the player knows the cost before clicking.
## No contact damage
Nothing hurts you by touching it. Every threat is a bullet you can see and
dodge, which is the contract the genre runs on; an enemy that damages you for
occupying the same space is an unavoidable, unreadable hit.
The Stalker used to be exactly that -- it walked at you and dealt contact
damage. It now carries a point-blank shotgun instead: five pellets, 62 degrees,
and an 18-tick lifetime that gives it about 78px of reach, so it still has to
close the distance and still leaves nothing lingering in the arena.
`tests/unit/test_content.gd` asserts that every hostile enemy has at least one
emitter, so a new enemy cannot quietly reintroduce the mechanic.
## No invulnerability frames
A hit grants no immunity — every bullet that touches you deals its damage. In a