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 —
|
dead zone where the server rejects everything and the client never notices —
|
||||||
the ship and the authoritative position separate permanently. Pinned by
|
the ship and the authoritative position separate permanently. Pinned by
|
||||||
`tests/unit/test_input_lead.gd`.
|
`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
|
- **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
|
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
|
||||||
dense patterns safer than sparse ones.
|
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
|
`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.
|
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
|
## No invulnerability frames
|
||||||
|
|
||||||
A hit grants no immunity — every bullet that touches you deals its damage. In a
|
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")
|
script = ExtResource("3_e6oj1")
|
||||||
id = &"drifter"
|
id = &"drifter"
|
||||||
display_name = "Drifter"
|
display_name = "Drifter"
|
||||||
contact_damage = 8
|
|
||||||
speed = 55.0
|
speed = 55.0
|
||||||
emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
|
emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
|
||||||
pattern_loop_ticks = 120
|
pattern_loop_ticks = 120
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ id = &"dummy"
|
|||||||
display_name = "Target Dummy"
|
display_name = "Target Dummy"
|
||||||
max_hp = 100000
|
max_hp = 100000
|
||||||
radius = 20.0
|
radius = 20.0
|
||||||
contact_damage = 0
|
|
||||||
move = 0
|
move = 0
|
||||||
visual = 3
|
visual = 3
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
[gd_resource type="Resource" script_class="EnemyDef" format=3]
|
[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/sim/patterns/bullet_emitter.gd" id="1_t1lyl"]
|
||||||
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_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]
|
[resource]
|
||||||
script = ExtResource("2_t1lyl")
|
script = ExtResource("3_ehenj")
|
||||||
id = &"stalker"
|
id = &"stalker"
|
||||||
display_name = "Stalker"
|
display_name = "Stalker"
|
||||||
max_hp = 30
|
max_hp = 30
|
||||||
radius = 12.0
|
radius = 12.0
|
||||||
contact_damage = 16
|
|
||||||
move = 3
|
move = 3
|
||||||
speed = 95.0
|
speed = 95.0
|
||||||
retarget_interval = 20
|
retarget_interval = 20
|
||||||
visual = 2
|
visual = 2
|
||||||
|
emitters = Array[ExtResource("1_t1lyl")]([SubResource("Resource_tpjo4")])
|
||||||
|
pattern_loop_ticks = 45
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ id = &"turret"
|
|||||||
display_name = "Turret"
|
display_name = "Turret"
|
||||||
max_hp = 70
|
max_hp = 70
|
||||||
radius = 16.0
|
radius = 16.0
|
||||||
contact_damage = 0
|
|
||||||
move = 0
|
move = 0
|
||||||
speed = 0.0
|
speed = 0.0
|
||||||
visual = 1
|
visual = 1
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ enum Move {
|
|||||||
@export var display_name: String = "Drone"
|
@export var display_name: String = "Drone"
|
||||||
@export var max_hp: int = 40
|
@export var max_hp: int = 40
|
||||||
@export var radius: float = 14.0
|
@export var radius: float = 14.0
|
||||||
@export var contact_damage: int = 10
|
|
||||||
@export var move: Move = Move.DRIFT
|
@export var move: Move = Move.DRIFT
|
||||||
@export var speed: float = 60.0
|
@export var speed: float = 60.0
|
||||||
## ORBIT radius, or STRAFE preferred distance.
|
## ORBIT radius, or STRAFE preferred distance.
|
||||||
|
|||||||
+24
-5
@@ -41,7 +41,6 @@ static func drifter() -> EnemyDef:
|
|||||||
d.display_name = "Drifter"
|
d.display_name = "Drifter"
|
||||||
d.max_hp = 40
|
d.max_hp = 40
|
||||||
d.radius = 14.0
|
d.radius = 14.0
|
||||||
d.contact_damage = 8
|
|
||||||
d.move = EnemyDef.Move.DRIFT
|
d.move = EnemyDef.Move.DRIFT
|
||||||
d.speed = 55.0
|
d.speed = 55.0
|
||||||
d.visual = 0
|
d.visual = 0
|
||||||
@@ -68,7 +67,6 @@ static func turret() -> EnemyDef:
|
|||||||
d.display_name = "Turret"
|
d.display_name = "Turret"
|
||||||
d.max_hp = 70
|
d.max_hp = 70
|
||||||
d.radius = 16.0
|
d.radius = 16.0
|
||||||
d.contact_damage = 0
|
|
||||||
d.move = EnemyDef.Move.STATIC
|
d.move = EnemyDef.Move.STATIC
|
||||||
d.speed = 0.0
|
d.speed = 0.0
|
||||||
d.visual = 1
|
d.visual = 1
|
||||||
@@ -88,18 +86,40 @@ static func turret() -> EnemyDef:
|
|||||||
return d
|
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:
|
static func stalker() -> EnemyDef:
|
||||||
var d := EnemyDef.new()
|
var d := EnemyDef.new()
|
||||||
d.id = ENEMY_STALKER
|
d.id = ENEMY_STALKER
|
||||||
d.display_name = "Stalker"
|
d.display_name = "Stalker"
|
||||||
d.max_hp = 30
|
d.max_hp = 30
|
||||||
d.radius = 12.0
|
d.radius = 12.0
|
||||||
d.contact_damage = 16
|
|
||||||
d.move = EnemyDef.Move.APPROACH
|
d.move = EnemyDef.Move.APPROACH
|
||||||
d.speed = 95.0
|
d.speed = 95.0
|
||||||
d.retarget_interval = 20
|
d.retarget_interval = 20
|
||||||
d.visual = 2
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +130,6 @@ static func dummy() -> EnemyDef:
|
|||||||
d.display_name = "Target Dummy"
|
d.display_name = "Target Dummy"
|
||||||
d.max_hp = 100000
|
d.max_hp = 100000
|
||||||
d.radius = 20.0
|
d.radius = 20.0
|
||||||
d.contact_damage = 0
|
|
||||||
d.move = EnemyDef.Move.STATIC
|
d.move = EnemyDef.Move.STATIC
|
||||||
d.visual = 3
|
d.visual = 3
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ static var bot_client: bool = false
|
|||||||
static var autoquit_ticks: int = 0
|
static var autoquit_ticks: int = 0
|
||||||
## Skip the menu and connect straight away. Implied by --bot.
|
## Skip the menu and connect straight away. Implied by --bot.
|
||||||
static var autojoin: bool = false
|
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).
|
## Skip the menu and start a listen server (host + local player).
|
||||||
static var listen: bool = false
|
static var listen: bool = false
|
||||||
## Server-side dev switch: new dungeons open straight onto the boss, skipping
|
## 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
|
autojoin = true
|
||||||
"--join":
|
"--join":
|
||||||
autojoin = true
|
autojoin = true
|
||||||
|
"--leave-after":
|
||||||
|
i += 1
|
||||||
|
if i < argv.size():
|
||||||
|
bot_leave_after = int(argv[i])
|
||||||
"--listen":
|
"--listen":
|
||||||
listen = true
|
listen = true
|
||||||
"--boss-rush":
|
"--boss-rush":
|
||||||
|
|||||||
+11
-3
@@ -58,13 +58,17 @@ func _run_dedicated_server() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _show_menu() -> void:
|
func _show_menu() -> void:
|
||||||
if _menu != null:
|
|
||||||
return
|
|
||||||
_clear_game()
|
_clear_game()
|
||||||
|
if _menu == null:
|
||||||
_menu = preload("res://src/ui/main_menu.gd").new()
|
_menu = preload("res://src/ui/main_menu.gd").new()
|
||||||
_menu.join_requested.connect(_join)
|
_menu.join_requested.connect(_join)
|
||||||
_menu.host_requested.connect(_host_and_play)
|
_menu.host_requested.connect(_host_and_play)
|
||||||
add_child(_menu)
|
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
|
## 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
|
## acknowledged them. The game scene now waits for the server to actually place
|
||||||
## us in an instance.
|
## us in an instance.
|
||||||
func _join(address: String, port: int) -> void:
|
func _join(address: String, port: int) -> void:
|
||||||
|
if _connecting:
|
||||||
|
return
|
||||||
GameOpts.host_address = address
|
GameOpts.host_address = address
|
||||||
GameOpts.port = port
|
GameOpts.port = port
|
||||||
if Net.join(address, port) != OK:
|
if Net.join(address, port) != OK:
|
||||||
@@ -106,7 +112,7 @@ func _abort_connect(reason: String) -> void:
|
|||||||
get_tree().quit(1)
|
get_tree().quit(1)
|
||||||
return
|
return
|
||||||
Net.shutdown()
|
Net.shutdown()
|
||||||
_show_menu()
|
_show_menu() # re-enables the connect buttons
|
||||||
_menu_status(reason)
|
_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
|
## connected over loopback. The client takes no shortcuts because of it -- it
|
||||||
## still only sends input and still only learns outcomes from snapshots.
|
## still only sends input and still only learns outcomes from snapshots.
|
||||||
func _host_and_play(port: int) -> void:
|
func _host_and_play(port: int) -> void:
|
||||||
|
if _connecting:
|
||||||
|
return
|
||||||
if Net.host(port) != OK:
|
if Net.host(port) != OK:
|
||||||
_menu_status(Net.last_error)
|
_menu_status(Net.last_error)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ var snap_prev: Dictionary = {}
|
|||||||
var snap_curr: Dictionary = {}
|
var snap_curr: Dictionary = {}
|
||||||
var _interp: float = 0.0
|
var _interp: float = 0.0
|
||||||
var _bot_tick: int = 0
|
var _bot_tick: int = 0
|
||||||
|
var _dungeon_ticks: int = 0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -104,6 +105,19 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
world.step()
|
world.step()
|
||||||
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
|
_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 ------------------------------------------------------------------
|
# --- Input ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ func on_peer_disconnected(peer_id: int) -> void:
|
|||||||
return
|
return
|
||||||
if inst.kind == Protocol.InstanceKind.DUNGEON:
|
if inst.kind == Protocol.InstanceKind.DUNGEON:
|
||||||
inst.detach_peer(peer_id)
|
inst.detach_peer(peer_id)
|
||||||
GameLog.info("server", "peer %d dropped in instance %d, channelling out"
|
GameLog.info("server", "peer %d '%s' dropped in instance %d, channelling out"
|
||||||
% [peer_id, inst.id])
|
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
|
||||||
_broadcast_roster()
|
_broadcast_roster()
|
||||||
return
|
return
|
||||||
inst.remove_peer(peer_id)
|
inst.remove_peer(peer_id)
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ func step() -> void:
|
|||||||
_step_boss()
|
_step_boss()
|
||||||
pool.step()
|
pool.step()
|
||||||
_resolve_bullet_hits()
|
_resolve_bullet_hits()
|
||||||
_resolve_contact_damage()
|
|
||||||
_emit_spawn_events()
|
_emit_spawn_events()
|
||||||
else:
|
else:
|
||||||
# Replica: bullets only. Actor state arrives in snapshots.
|
# Replica: bullets only. Actor state arrives in snapshots.
|
||||||
@@ -352,17 +351,6 @@ func _resolve_bullet_hits() -> void:
|
|||||||
_kill_bullet(i)
|
_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.
|
## Bullets removed early must be announced -- clients cannot derive a hit.
|
||||||
func _kill_bullet(slot: int) -> void:
|
func _kill_bullet(slot: int) -> void:
|
||||||
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
|
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
|
||||||
|
|||||||
+25
-6
@@ -12,6 +12,9 @@ signal disconnect_requested
|
|||||||
|
|
||||||
var _panel: VBoxContainer
|
var _panel: VBoxContainer
|
||||||
var _hub_button: Button
|
var _hub_button: Button
|
||||||
|
var _disconnect_button: Button
|
||||||
|
var _note: Label
|
||||||
|
var _in_dungeon: bool = false
|
||||||
var _open: bool = false
|
var _open: bool = false
|
||||||
|
|
||||||
|
|
||||||
@@ -42,17 +45,18 @@ func _ready() -> void:
|
|||||||
title.add_theme_font_size_override("font_size", 26)
|
title.add_theme_font_size_override("font_size", 26)
|
||||||
_panel.add_child(title)
|
_panel.add_child(title)
|
||||||
|
|
||||||
var note := Label.new()
|
_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_font_size_override("font_size", 12)
|
_note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
|
||||||
note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
|
_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
_panel.add_child(note)
|
_note.custom_minimum_size = Vector2(320.0, 0.0)
|
||||||
|
_panel.add_child(_note)
|
||||||
|
|
||||||
_hub_button = _button("Return to hub", func() -> void:
|
_hub_button = _button("Return to hub", func() -> void:
|
||||||
return_to_hub_requested.emit()
|
return_to_hub_requested.emit()
|
||||||
close())
|
close())
|
||||||
_button("Resume", func() -> void: close())
|
_button("Resume", func() -> void: close())
|
||||||
_button("Disconnect to menu", func() -> void:
|
_disconnect_button = _button("Disconnect to menu", func() -> void:
|
||||||
disconnect_requested.emit()
|
disconnect_requested.emit()
|
||||||
close())
|
close())
|
||||||
_button("Quit game", func() -> void: get_tree().quit())
|
_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.
|
## The hub button is meaningless when you are already in the hub.
|
||||||
func set_in_dungeon(in_dungeon: bool) -> void:
|
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
|
_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
|
sleep 0.4
|
||||||
done
|
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
|
# 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,
|
# 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
|
# 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" \
|
check "a hard drop is channelled, not instant" \
|
||||||
"$OUT/server.log" "dropped in instance [0-9]+, channelling out"
|
"$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 "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 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"
|
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user