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
+3
View File
@@ -116,6 +116,9 @@ ticks in milliseconds with no SceneTree.
dead zone where the server rejects everything and the client never notices —
the ship and the authoritative position separate permanently. Pinned by
`tests/unit/test_input_lead.gd`.
- **No contact damage.** Every enemy threatens through bullets only; touching
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
an emitter.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
dense patterns safer than sparse ones.
+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
-1
View File
@@ -17,7 +17,6 @@ damage = 10
script = ExtResource("3_e6oj1")
id = &"drifter"
display_name = "Drifter"
contact_damage = 8
speed = 55.0
emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
pattern_loop_ticks = 120
-1
View File
@@ -9,6 +9,5 @@ id = &"dummy"
display_name = "Target Dummy"
max_hp = 100000
radius = 20.0
contact_damage = 0
move = 0
visual = 3
+17 -4
View File
@@ -1,16 +1,29 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_dkoo8"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_t1lyl"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_t1lyl"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e7xon"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_ehenj"]
[sub_resource type="Resource" id="Resource_tpjo4"]
script = ExtResource("2_e7xon")
spread_deg = 62.0
muzzle_offset = 10.0
interval = 45
speed = 260.0
radius = 6.0
lifetime = 18
damage = 14
kind = 3
[resource]
script = ExtResource("2_t1lyl")
script = ExtResource("3_ehenj")
id = &"stalker"
display_name = "Stalker"
max_hp = 30
radius = 12.0
contact_damage = 16
move = 3
speed = 95.0
retarget_interval = 20
visual = 2
emitters = Array[ExtResource("1_t1lyl")]([SubResource("Resource_tpjo4")])
pattern_loop_ticks = 45
-1
View File
@@ -17,7 +17,6 @@ id = &"turret"
display_name = "Turret"
max_hp = 70
radius = 16.0
contact_damage = 0
move = 0
speed = 0.0
visual = 1
-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"
+78
View File
@@ -0,0 +1,78 @@
extends GutTest
## Invariants every piece of content has to satisfy. These are cheap to write
## and they are what stops a new enemy from quietly reintroducing a mechanic the
## game has decided against.
const HOSTILES := [
Content.ENEMY_DRIFTER,
Content.ENEMY_TURRET,
Content.ENEMY_STALKER,
]
## The rule: nothing in this game hurts you by touching you. Every threat is a
## bullet you can see and dodge, so an enemy with no emitters is either inert
## scenery or a design mistake.
func test_every_hostile_enemy_actually_fires_something() -> void:
for id in HOSTILES:
var def := Content.enemy(id)
assert_gt(def.emitters.size(), 0,
"%s has no emitters -- it would be a harmless obstacle" % id)
func test_the_practice_dummy_is_inert() -> void:
var def := Content.enemy(Content.ENEMY_DUMMY)
assert_eq(def.emitters.size(), 0, "the hub target must never shoot back")
## Standing inside an enemy costs nothing by itself. Uses the dummy so the test
## measures contact alone, with no bullets in play to confuse the result.
func test_touching_an_enemy_deals_no_damage() -> void:
var world := SimWorld.new(1)
var p := world.add_player(1, "tester")
var e := world.spawn_enemy(Content.dummy(), Vector2.ZERO)
p.pos = e.pos
p.spawn_grace = 0
for _i in 120:
world.step()
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP,
"contact damage is not a damage type in this game")
## The Stalker's burst replaces its old contact damage, so it has to be a real
## threat once it closes -- and harmless from across the arena.
func test_the_stalker_threatens_at_point_blank_only() -> void:
var near := SimWorld.new(1)
var pn := near.add_player(1, "tester")
pn.pos = Vector2.ZERO
pn.spawn_grace = 0
near.spawn_enemy(Content.stalker(), Vector2(40.0, 0.0))
for _i in 90:
near.step()
assert_lt(pn.hp, SimConfig.PLAYER_MAX_HP, "point blank has to hurt")
var far := SimWorld.new(1)
var pf := far.add_player(1, "tester")
pf.pos = Vector2(0.0, 300.0)
pf.spawn_grace = 0
# Pinned in place so it cannot close the distance during the test.
var e := far.spawn_enemy(Content.stalker(), Vector2(0.0, -300.0))
e.def = Content.stalker()
e.def.speed = 0.0
for _i in 90:
far.step()
assert_eq(pf.hp, SimConfig.PLAYER_MAX_HP,
"its bullets must expire long before they cross the arena")
func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
var world := SimWorld.new(1)
var p := world.add_player(1, "tester")
p.pos = Vector2(0.0, 300.0)
var e := world.spawn_enemy(Content.stalker(), Vector2(0.0, -300.0))
e.def = Content.stalker()
e.def.speed = 0.0
for _i in 300:
world.step()
assert_lt(world.pool.live_count, 12,
"a short lifetime should keep spent point-blank shots from accumulating")
+1
View File
@@ -0,0 +1 @@
uid://cmmr6xgf4isls
+14
View File
@@ -51,6 +51,17 @@ for n in 1 2; do
sleep 0.4
done
# A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game
# menu's "Disconnect" button calls. The escape channel is keyed on the socket
# closing, not on how it closed, so a clean exit must be caught exactly like the
# SIGKILL below. If someone ever adds a "clean leave" message that bypasses the
# channel, this is what catches it.
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "leavebot" --leave-after 120 --autoquit "$CLIENT_TICKS" \
> "$OUT/leavebot.log" 2>&1 &
PIDS+=($!)
sleep 0.4
# A third bot that gets SIGKILLed the moment it is inside a dungeon. This is the
# anti-disconnect-cheese path: the server must keep its body in the world,
# channel it out over the same one second the escape button costs, and only then
@@ -104,6 +115,9 @@ check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY
check "a hard drop is channelled, not instant" \
"$OUT/server.log" "dropped in instance [0-9]+, channelling out"
check "the dropped body is released" "$OUT/server.log" "released from instance [0-9]+ after drop"
check "a polite disconnect leaves too" "$OUT/leavebot.log" "BOT_GRACEFUL_LEAVE"
check "a polite disconnect is also channelled" \
"$OUT/server.log" "'leavebot' dropped in instance [0-9]+, channelling out"
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"