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
-1
View File
@@ -21,7 +21,6 @@ enum Move {
@export var display_name: String = "Drone"
@export var max_hp: int = 40
@export var radius: float = 14.0
@export var contact_damage: int = 10
@export var move: Move = Move.DRIFT
@export var speed: float = 60.0
## ORBIT radius, or STRAFE preferred distance.
+24 -5
View File
@@ -41,7 +41,6 @@ static func drifter() -> EnemyDef:
d.display_name = "Drifter"
d.max_hp = 40
d.radius = 14.0
d.contact_damage = 8
d.move = EnemyDef.Move.DRIFT
d.speed = 55.0
d.visual = 0
@@ -68,7 +67,6 @@ static func turret() -> EnemyDef:
d.display_name = "Turret"
d.max_hp = 70
d.radius = 16.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.speed = 0.0
d.visual = 1
@@ -88,18 +86,40 @@ static func turret() -> EnemyDef:
return d
## Walks at you and does nothing else. The pressure enemy.
## Walks at you and fires a point-blank burst. The pressure enemy.
##
## It used to deal contact damage and nothing else. Nothing in this game hurts
## you by touching you -- every threat is a bullet you can see coming and dodge,
## which is the whole contract of the genre. So the Stalker's melee is a
## shotgun with a deliberately tiny lifetime: ~78px of travel, which only
## threatens once it has closed the distance, and leaves nothing lingering in
## the arena afterwards.
static func stalker() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_STALKER
d.display_name = "Stalker"
d.max_hp = 30
d.radius = 12.0
d.contact_damage = 16
d.move = EnemyDef.Move.APPROACH
d.speed = 95.0
d.retarget_interval = 20
d.visual = 2
d.pattern_loop_ticks = 45
var lunge := AimedSpreadEmitter.new()
lunge.start_tick = 0
lunge.interval = 45
lunge.count = 5
lunge.spread_deg = 62.0
lunge.speed = 260.0
lunge.radius = 6.0
lunge.damage = 14
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
# player distance at any range you would call "not point blank".
lunge.lifetime = 18
lunge.muzzle_offset = 10.0
lunge.kind = SimConfig.KIND_HEAVY
d.emitters = [lunge]
return d
@@ -110,7 +130,6 @@ static func dummy() -> EnemyDef:
d.display_name = "Target Dummy"
d.max_hp = 100000
d.radius = 20.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.visual = 3
return d
+9
View File
@@ -17,6 +17,11 @@ static var bot_client: bool = false
static var autoquit_ticks: int = 0
## Skip the menu and connect straight away. Implied by --bot.
static var autojoin: bool = false
## Bot harness only: gracefully disconnect after this many ticks inside a
## dungeon, the way the in-game menu's "Disconnect" button does. Lets the smoke
## test cover the clean-exit path as well as a hard kill -- both must be caught
## by the same server-side escape channel.
static var bot_leave_after: int = 0
## Skip the menu and start a listen server (host + local player).
static var listen: bool = false
## Server-side dev switch: new dungeons open straight onto the boss, skipping
@@ -38,6 +43,10 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
autojoin = true
"--join":
autojoin = true
"--leave-after":
i += 1
if i < argv.size():
bot_leave_after = int(argv[i])
"--listen":
listen = true
"--boss-rush":
+15 -7
View File
@@ -58,13 +58,17 @@ func _run_dedicated_server() -> void:
func _show_menu() -> void:
if _menu != null:
return
_clear_game()
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
add_child(_menu)
if _menu == null:
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
# disabled from _menu_busy(true), and skipping this left the player locked
# out of both joining and hosting with no way back short of a restart.
_menu.set_busy(false)
## Note what this deliberately does NOT do: enter the game. Net.join() only
@@ -74,6 +78,8 @@ func _show_menu() -> void:
## acknowledged them. The game scene now waits for the server to actually place
## us in an instance.
func _join(address: String, port: int) -> void:
if _connecting:
return
GameOpts.host_address = address
GameOpts.port = port
if Net.join(address, port) != OK:
@@ -106,7 +112,7 @@ func _abort_connect(reason: String) -> void:
get_tree().quit(1)
return
Net.shutdown()
_show_menu()
_show_menu() # re-enables the connect buttons
_menu_status(reason)
@@ -114,6 +120,8 @@ func _abort_connect(reason: String) -> void:
## connected over loopback. The client takes no shortcuts because of it -- it
## still only sends input and still only learns outcomes from snapshots.
func _host_and_play(port: int) -> void:
if _connecting:
return
if Net.host(port) != OK:
_menu_status(Net.last_error)
return
+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)
-12
View File
@@ -140,7 +140,6 @@ func step() -> void:
_step_boss()
pool.step()
_resolve_bullet_hits()
_resolve_contact_damage()
_emit_spawn_events()
else:
# Replica: bullets only. Actor state arrives in snapshots.
@@ -352,17 +351,6 @@ func _resolve_bullet_hits() -> void:
_kill_bullet(i)
func _resolve_contact_damage() -> void:
for e in enemies.values():
if not e.alive or e.def.contact_damage <= 0:
continue
for p in players.values():
if not p.alive or p.invulnerable():
continue
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, e.def.contact_damage)
## Bullets removed early must be announced -- clients cannot derive a hit.
func _kill_bullet(slot: int) -> void:
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
+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"