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
+25 -6
View File
@@ -12,6 +12,9 @@ signal disconnect_requested
var _panel: VBoxContainer
var _hub_button: Button
var _disconnect_button: Button
var _note: Label
var _in_dungeon: bool = false
var _open: bool = false
@@ -42,17 +45,18 @@ func _ready() -> void:
title.add_theme_font_size_override("font_size", 26)
_panel.add_child(title)
var note := Label.new()
note.text = "The world keeps running. You are not safe here."
note.add_theme_font_size_override("font_size", 12)
note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
_panel.add_child(note)
_note = Label.new()
_note.add_theme_font_size_override("font_size", 12)
_note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_note.custom_minimum_size = Vector2(320.0, 0.0)
_panel.add_child(_note)
_hub_button = _button("Return to hub", func() -> void:
return_to_hub_requested.emit()
close())
_button("Resume", func() -> void: close())
_button("Disconnect to menu", func() -> void:
_disconnect_button = _button("Disconnect to menu", func() -> void:
disconnect_requested.emit()
close())
_button("Quit game", func() -> void: get_tree().quit())
@@ -100,4 +104,19 @@ func is_open() -> bool:
## The hub button is meaningless when you are already in the hub.
func set_in_dungeon(in_dungeon: bool) -> void:
if in_dungeon == _in_dungeon and not _note.text.is_empty():
return
_in_dungeon = in_dungeon
_hub_button.disabled = not in_dungeon
if in_dungeon:
# Disconnecting is not an escape: the server keeps the body in the
# world, channelling out, for the same second the escape button costs.
# Saying so is the difference between a considered choice and a nasty
# surprise -- the mechanic only reads as fair if players know about it.
_note.text = "The world keeps running while this is open. " \
+ "Disconnecting does not save you: your ship stays in the dungeon " \
+ "for one second and can still be killed."
_disconnect_button.text = "Disconnect to menu (ship stays 1s)"
else:
_note.text = "The world keeps running while this is open."
_disconnect_button.text = "Disconnect to menu"