Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user