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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,5 @@ id = &"dummy"
|
||||
display_name = "Target Dummy"
|
||||
max_hp = 100000
|
||||
radius = 20.0
|
||||
contact_damage = 0
|
||||
move = 0
|
||||
visual = 3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmmr6xgf4isls
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user