feat(server): complete phase 6 local verification

This commit is contained in:
Josh Creek
2026-08-21 18:38:30 +01:00
parent ec896b27ac
commit f2b72394de
21 changed files with 604 additions and 4 deletions
+32 -1
View File
@@ -306,12 +306,20 @@ var _spectator_target_index := 0
# §6.3, server only. Peers that joined mid-match with no slot to reclaim, in
# arrival order, waiting for the next kickoff to hand them a vacated slot.
var _late_joiners: Array[Dictionary] = []
# Task 6.5, server only. Set by ServerMatchLoop immediately before it switches
# to this scene; static because the loop cannot hold a reference to a node that
# does not exist yet, and consumed on read so it cannot leak into a later match.
static var server_arena_override := ""
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
# server, cannot be triggered by an RPC, and defaults to disabled.
var _smoke_force_goal_tick := -1
var _smoke_goal_forced := false
func _ready() -> void:
@@ -334,6 +342,9 @@ func _ready() -> void:
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
# a client cannot shorten anyone's match.
match_length_seconds = maxf(1.0, float(config.get_value("match-length")))
var smoke_after := float(config.get_value("smoke-force-goal-after"))
if smoke_after >= 0.0:
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
var replay_path := String(config.get_value("replay-log"))
if not replay_path.is_empty():
# Task 5.10. Diagnostic only: a log that cannot be opened must
@@ -418,7 +429,11 @@ func _exit_tree() -> void:
# ============================================================
func _start_server() -> void:
var arena_path := ArenaRegistry.random_path()
# Task 6.5: the server match loop hands the arena down so rotation is a
# rotation rather than a coincidence. Consumed once, so a match started any
# other way (a test harness, a future lobby button) still picks at random.
var arena_path := server_arena_override if not server_arena_override.is_empty() else ArenaRegistry.random_path()
server_arena_override = ""
_arena_path = arena_path
arena = (load(arena_path) as PackedScene).instantiate()
add_child(arena)
@@ -1084,6 +1099,21 @@ func _on_goal_registered(conceding_team: int) -> void:
_broadcast_clock_state()
func _maybe_force_smoke_goal() -> void:
if _smoke_goal_forced or _smoke_force_goal_tick == -1 or match_state != MatchState.State.PLAYING:
return
if _smoke_force_goal_tick == -2:
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
_smoke_force_goal_tick = Engine.get_physics_frames() + int(maxf(0.0, float(config.get_value("smoke-force-goal-after"))) * SimConstants.TICK_HZ)
ServerLog.info("smoke_goal_armed", {"tick": _smoke_force_goal_tick})
return
if Engine.get_physics_frames() < _smoke_force_goal_tick:
return
_smoke_goal_forced = true
ServerLog.info("smoke_goal_forced", {"tick": Engine.get_physics_frames()})
_on_goal_registered(0)
# Task 5.9. Server-only by construction: _respawn_escaped_bodies() is gated on
# _owns_world_simulation(). The bump uses Phase 2's deferred path because the
# respawn only QUEUES a teleport — bumping now would broadcast the new
@@ -2161,6 +2191,7 @@ func _physics_process(_delta: float) -> void:
# Also before the broadcast, so a transition taken this tick ships in
# this tick's own match_state byte rather than trailing it by one.
_update_match_state()
_maybe_force_smoke_goal()
_expire_slot_reservations()
# Countdown and clock are derived from absolute ticks on both peers, so
# these run on the client too.