diff --git a/CLAUDE.md b/CLAUDE.md index 9383bed..ec10a23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index 51b081b..acd59ac 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -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 diff --git a/resources/enemies/drifter.tres b/resources/enemies/drifter.tres index ed4a123..4377a1c 100644 --- a/resources/enemies/drifter.tres +++ b/resources/enemies/drifter.tres @@ -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 diff --git a/resources/enemies/dummy.tres b/resources/enemies/dummy.tres index 08779bf..8730394 100644 --- a/resources/enemies/dummy.tres +++ b/resources/enemies/dummy.tres @@ -9,6 +9,5 @@ id = &"dummy" display_name = "Target Dummy" max_hp = 100000 radius = 20.0 -contact_damage = 0 move = 0 visual = 3 diff --git a/resources/enemies/stalker.tres b/resources/enemies/stalker.tres index e58e0a1..e5cc5b3 100644 --- a/resources/enemies/stalker.tres +++ b/resources/enemies/stalker.tres @@ -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 diff --git a/resources/enemies/turret.tres b/resources/enemies/turret.tres index 2d6c5bd..27ed137 100644 --- a/resources/enemies/turret.tres +++ b/resources/enemies/turret.tres @@ -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 diff --git a/src/actors/enemies/enemy_def.gd b/src/actors/enemies/enemy_def.gd index a5bd20f..8a401b5 100644 --- a/src/actors/enemies/enemy_def.gd +++ b/src/actors/enemies/enemy_def.gd @@ -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. diff --git a/src/content/content.gd b/src/content/content.gd index a95cfe1..bb96908 100644 --- a/src/content/content.gd +++ b/src/content/content.gd @@ -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 diff --git a/src/core/game_opts.gd b/src/core/game_opts.gd index ba54271..c726c84 100644 --- a/src/core/game_opts.gd +++ b/src/core/game_opts.gd @@ -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": diff --git a/src/main.gd b/src/main.gd index 1fce58c..e1d4c23 100644 --- a/src/main.gd +++ b/src/main.gd @@ -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 diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index ecd99eb..c9f37e8 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -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 ------------------------------------------------------------------ diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 57dd9cf..eddcd55 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -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) diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index c62a8b2..146498a 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -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]}) diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd index 1f08b7c..fb56f46 100644 --- a/src/ui/game_menu.gd +++ b/src/ui/game_menu.gd @@ -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" diff --git a/tests/unit/test_content.gd b/tests/unit/test_content.gd new file mode 100644 index 0000000..b498bf5 --- /dev/null +++ b/tests/unit/test_content.gd @@ -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") diff --git a/tests/unit/test_content.gd.uid b/tests/unit/test_content.gd.uid new file mode 100644 index 0000000..c12e94f --- /dev/null +++ b/tests/unit/test_content.gd.uid @@ -0,0 +1 @@ +uid://cmmr6xgf4isls diff --git a/tools/smoke.sh b/tools/smoke.sh index f357baf..3d49d51 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -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"