mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): Phase 5 tasks 5.2-5.5 - clock, kickoff, goals, full time
Implements the rest of the §6.2 lifecycle on top of 5.1's state machine.
5.3 kickoff: the server resets every body and broadcasts the RESULTING
transforms, never a seed - §1's locked decision, because shared-seed
determinism needs both sides to consume the RNG stream in identical
order forever and the first randf() added to the reset path desyncs
silently. Countdown is derived from server_tick on both peers, and a
kickoff that lands after its own resume tick applies immediately and
skips the countdown rather than scheduling into the past.
5.4 goals: goal_scored(scoring_team, score, goal_tick, resume_tick).
Score is authoritative at sensor time, before any presentation. The
reset moved OUT of the sensor path and into the kickoff at resume_tick,
which is what stops the server resetting while clients are still
mid-celebration. Engine.time_scale is never touched.
5.2 clock: tick-derived, no Timer and no _process polling. The goal
pause shifts the absolute end_tick by (resume_tick - goal_tick) rather
than pausing anything, so no float drift accumulates across goals.
5.5 full time: clock expiry -> FULL_TIME -> sudden death on a draw or
RESULTS, golden goal in overtime, then LOBBY on both peers - clients
return to the lobby, not the main menu. get_tree().paused is never used.
Four bugs found and fixed while building this, each by a failing run
rather than by inspection:
- Tick order was load-bearing: _update_kickoff_countdown() clears the
same _kickoff_resume_tick that _update_match_state() reads to leave
WARMUP, so running the countdown first wiped the transition condition
and the match sat frozen in WARMUP forever.
- _apply_match_state resets _state_deadline_tick on every transition, so
a GOAL_PAUSE deadline assigned before _set_match_state was wiped and
the match never resumed. Deadlines are now owned by _apply_match_state.
- Freezing "all bodies" is wrong on a client. Remote ships and the ball
are permanently FREEZE_MODE_KINEMATIC and transform-driven; freezing
them all unfroze the remote ones on the way back out, so they fell
under gravity while the interpolator fought them - 210 hard snaps and
an infinite p99. A client now freezes only the one body it simulates.
- A frozen body never runs _integrate_forces, so the queued kickoff
teleport was stranded by an immediate set_deferred("freeze", true).
Freeze now happens on a strictly later tick, the same pattern Phase 2
used for _pending_reset_gen_bump_tick.
Prediction and reconciliation are suspended while the match is not live:
during a countdown or goal pause the local ship is frozen on both peers,
and running delta transport over those frozen states produced a p95
position error of 2.4e10 m. Input keeps flowing so the server's jitter
buffer does not starve into `stalled`.
Also fixed: a kickoff can arrive before match_config, and body order is
slot order - applying it early placed the BALL at positions[0], on top
of the first ship, which the ball-cam reported as "target vector can't
be zero" 95 times. It is now held until the roster exists.
Test changes: the ball-contact scenario steered by a hand-tuned fixed
heading, which 5.3 broke because kickoff applies KICKOFF_YAW_JITTER - it
flew past the ball in 3/3 runs. It now closes the loop on the actual
bearing using real input actions. Assertions that read a frozen ship
(freeze, thrust) are gated on the match being live, and the hooks now
survive the scene teardown at RESULTS instead of hanging on freed
objects for the full timeout.
Regression: 81 unit tests; free-flight LAN p99 0.143m and 80±20ms, both
0 hard snaps; transition gate 0.00%; ball contact 3/3; two-bot CI.
This commit is contained in:
@@ -40,6 +40,16 @@ func queue_teleport(to: Transform3D) -> void:
|
||||
# Kept parallel to Ship's network correction hook. A locally predicted ball
|
||||
# must resume from the authoritative velocity after a correction; gameplay
|
||||
# resets still deliberately use queue_teleport() and zero both velocities.
|
||||
# The queued-but-not-yet-applied teleport target, or null when none is
|
||||
# pending. queue_teleport() defers the actual write to the next
|
||||
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||||
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||||
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||||
# pre-reset position and corrects it a tick later.
|
||||
func get_pending_teleport():
|
||||
return _pending_teleport if _has_pending_teleport else null
|
||||
|
||||
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
|
||||
@@ -24,6 +24,12 @@ signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCo
|
||||
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
|
||||
signal score_update_received(score: Dictionary)
|
||||
signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State
|
||||
# §6.2 step 6. positions/rotations are body-order: every slot in order, then
|
||||
# the ball — the same order the snapshot uses, so one convention covers both.
|
||||
# rotations is 4 floats per body (x, y, z, w).
|
||||
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
|
||||
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
|
||||
signal clock_state_received(running: bool, end_tick: int, at_tick: int)
|
||||
|
||||
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
|
||||
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
||||
@@ -177,6 +183,24 @@ func send_state_change(state: int, at_tick: int) -> void:
|
||||
_state_change.rpc(state, at_tick)
|
||||
|
||||
|
||||
# §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the
|
||||
# resulting TRANSFORMS, never a seed. Shared-seed determinism would require
|
||||
# both sides to consume the RNG stream in identical order forever, and the
|
||||
# first randf() anyone later adds to the reset path silently desyncs kickoff
|
||||
# positions with no error message. A few hundred bytes once per kickoff cannot
|
||||
# rot that way.
|
||||
func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
_kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen)
|
||||
|
||||
|
||||
func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
|
||||
|
||||
|
||||
func send_clock_state(running: bool, end_tick: int, at_tick: int) -> void:
|
||||
_clock_state.rpc(running, end_tick, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
||||
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
|
||||
@@ -276,6 +300,27 @@ func _state_change(state: int, at_tick: int) -> void:
|
||||
state_change_received.emit(state, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
# 4 quaternion floats per body. A mismatch means a corrupt or hostile
|
||||
# payload; dropping it is safe because the snapshot stream still carries
|
||||
# authoritative poses and the next kickoff will re-sync.
|
||||
if rotations.size() != positions.size() * 4:
|
||||
push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()])
|
||||
return
|
||||
kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||
goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _clock_state(running: bool, end_tick: int, at_tick: int) -> void:
|
||||
clock_state_received.emit(running, end_tick, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _score_update(score: Dictionary) -> void:
|
||||
score_update_received.emit(score)
|
||||
|
||||
+444
-29
@@ -24,6 +24,16 @@ signal score_changed(score: Dictionary)
|
||||
# transition, client-side when it follows one — so HUD/camera work can bind to
|
||||
# one signal regardless of which process it runs in.
|
||||
signal match_state_changed(state: int, at_tick: int)
|
||||
# §6.2's closing note: HUDController duck-types on all five of these
|
||||
# (HUDController.gd:65, 88, 100, 103, 106) and silently omits a row when one
|
||||
# is missing. They are declared here AND genuinely emitted from the lifecycle
|
||||
# handlers below — Phase 2 learned that declaring a signal that never fires is
|
||||
# worse than not declaring it (has_signal("timer_updated") was true, so the
|
||||
# HUD showed a permanently frozen timer instead of correctly hiding it).
|
||||
signal timer_updated(minutes: int, seconds: int)
|
||||
signal match_ended(winning_team: int, score: Dictionary)
|
||||
signal kickoff_countdown(count: int)
|
||||
signal overtime_started
|
||||
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
@@ -235,8 +245,29 @@ var _state_deadline_tick := -1
|
||||
# and 5.4 replaces the GOAL_PAUSE one with _goal_pause_seconds() and the
|
||||
# client-cinematic split. They exist here only so 5.1 drives REAL transitions
|
||||
# to verify against, rather than a state machine nothing ever moves.
|
||||
const WARMUP_TICKS := 90 # 1.5s
|
||||
const GOAL_PAUSE_TICKS := 120 # 2s
|
||||
const WARMUP_TICKS := 3 * SimConstants.TICK_HZ # 3s kickoff countdown (§6.2 step 6)
|
||||
const RESULTS_TICKS := 8 * SimConstants.TICK_HZ # how long RESULTS holds before returning to the lobby
|
||||
|
||||
# §6.2 step 9. Tick-derived, never a Timer: `remaining = end_tick - now`.
|
||||
# -1 until the first kickoff arms it.
|
||||
var _end_tick := -1
|
||||
var _clock_running := false
|
||||
var _last_emitted_second := -1
|
||||
@export var match_length_seconds := 150.0
|
||||
|
||||
# §6.2 step 6. The tick play resumes on — the countdown's own end. Both peers
|
||||
# derive the displayed count from this and their own server-tick estimate, so
|
||||
# nothing depends on a local Timer staying in step.
|
||||
var _kickoff_resume_tick := -1
|
||||
# Client only: a kickoff that arrived before _slots existed (see
|
||||
# _on_kickoff_received), replayed once match_config lands.
|
||||
var _pending_kickoff := {}
|
||||
# Freeze is applied on a strictly later tick than the kickoff teleport that
|
||||
# precedes it — see _apply_kickoff. -1 when nothing is pending.
|
||||
var _pending_freeze_tick := -1
|
||||
var _last_emitted_countdown := -1
|
||||
var _in_overtime := false
|
||||
var _match_over := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -245,6 +276,12 @@ func _ready() -> void:
|
||||
if kickoff_rng_seed == 0:
|
||||
_kickoff_rng.randomize()
|
||||
if multiplayer.is_server():
|
||||
for arg: String in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--match-length="):
|
||||
# Regulation is 150s; a smoke test cannot wait that long to see
|
||||
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side
|
||||
# only — a client cannot shorten anyone's match.
|
||||
match_length_seconds = maxf(1.0, arg.get_slice("=", 1).to_float())
|
||||
_start_server()
|
||||
else:
|
||||
for arg: String in OS.get_cmdline_user_args():
|
||||
@@ -264,6 +301,9 @@ func _ready() -> void:
|
||||
MatchSim.snapshot_received.connect(_on_snapshot_received)
|
||||
MatchSim.score_update_received.connect(_on_score_update_received)
|
||||
MatchSim.state_change_received.connect(_on_state_change_received)
|
||||
MatchSim.kickoff_received.connect(_on_kickoff_received)
|
||||
MatchSim.goal_scored_received.connect(_on_goal_scored_received)
|
||||
MatchSim.clock_state_received.connect(_on_clock_state_received)
|
||||
_request_match_config_until_received()
|
||||
|
||||
|
||||
@@ -334,6 +374,10 @@ func _start_server() -> void:
|
||||
# would be worse than the honest placeholder.
|
||||
_apply_match_state(MatchState.State.LOADING, Engine.get_physics_frames())
|
||||
_set_match_state(MatchState.State.WARMUP)
|
||||
# The clock covers regulation only and is armed once; the goal-pause
|
||||
# extension below (§6.2 step 9) adjusts end_tick rather than restarting it.
|
||||
_arm_clock(int(match_length_seconds * SimConstants.TICK_HZ) + WARMUP_TICKS)
|
||||
_begin_kickoff()
|
||||
|
||||
|
||||
func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
@@ -457,27 +501,351 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
|
||||
_state_deadline_tick = -1
|
||||
if multiplayer.is_server():
|
||||
match new_state:
|
||||
MatchState.State.WARMUP, MatchState.State.OVERTIME_WARMUP:
|
||||
_state_deadline_tick = at_tick + WARMUP_TICKS
|
||||
MatchState.State.RESULTS:
|
||||
_state_deadline_tick = at_tick + RESULTS_TICKS
|
||||
MatchState.State.GOAL_PAUSE:
|
||||
_state_deadline_tick = at_tick + GOAL_PAUSE_TICKS
|
||||
# Owned here rather than assigned by the caller: _apply_match_state
|
||||
# resets _state_deadline_tick on every transition, so a deadline
|
||||
# set BEFORE _set_match_state was silently wiped and the match sat
|
||||
# in GOAL_PAUSE forever. at_tick is the goal tick, so this matches
|
||||
# the resume_tick already broadcast to clients.
|
||||
_state_deadline_tick = at_tick + int(_goal_pause_seconds() * SimConstants.TICK_HZ)
|
||||
MatchState.State.PLAYING, MatchState.State.OVERTIME:
|
||||
# Kickoff is over: bodies move again, and the clock resumes.
|
||||
_pending_freeze_tick = -1
|
||||
_set_bodies_frozen(false)
|
||||
# The clock only advances during live play (§6.2 step 9). Derived here
|
||||
# rather than tracked separately so it cannot disagree with the state.
|
||||
_clock_running = MatchState.is_live(new_state) and not _match_over
|
||||
if new_state == MatchState.State.LOBBY and not multiplayer.is_server():
|
||||
# §6.2 step 10: both sides return to the LOBBY, not the main menu.
|
||||
# Deferred because this runs from an RPC handler mid-tree-traversal
|
||||
# (gotcha 27: change_scene_to_file must not be called synchronously
|
||||
# from inside a node's own callback chain).
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
|
||||
match_state_changed.emit(new_state, at_tick)
|
||||
|
||||
|
||||
# --- §6.2 step 6: kickoff (task 5.3) ---------------------------------------
|
||||
|
||||
# Server: reset every body, then broadcast the RESULTING transforms. §1's
|
||||
# locked decision — never a shared RNG seed, because shared-seed determinism
|
||||
# needs both sides to consume the stream in identical order forever and the
|
||||
# first randf() anyone adds to the reset path desyncs kickoff silently.
|
||||
func _begin_kickoff() -> void:
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
# Bump before the broadcast so the kickoff and the reset_gen it announces
|
||||
# describe the same world. This deliberately does NOT use Phase 2's
|
||||
# deferred _pending_reset_gen_bump path: that exists because the GOAL
|
||||
# sensor fires mid-tick, before the queued teleport lands. Here we are
|
||||
# the ones issuing the teleport, and we send the transforms explicitly
|
||||
# rather than relying on a snapshot taken after they apply.
|
||||
_reset_gen = (_reset_gen + 1) % 256
|
||||
_pending_reset_gen_bump = false
|
||||
var countdown_start_tick := Engine.get_physics_frames()
|
||||
var positions := PackedVector3Array()
|
||||
var rotations := PackedFloat32Array()
|
||||
for slot in _slots:
|
||||
var t: Transform3D = slot.ship.global_transform if is_instance_valid(slot.ship) else Transform3D.IDENTITY
|
||||
_append_kickoff_body(positions, rotations, _pending_teleport_or_current(slot.ship, t))
|
||||
if is_instance_valid(ball):
|
||||
_append_kickoff_body(positions, rotations, _pending_teleport_or_current(ball, ball.global_transform))
|
||||
MatchSim.send_kickoff(positions, rotations, countdown_start_tick, _reset_gen)
|
||||
_apply_kickoff(positions, rotations, countdown_start_tick, _reset_gen)
|
||||
|
||||
|
||||
# reset_ball()/reset_ships() QUEUE a teleport applied in the body's own next
|
||||
# _integrate_forces (task 0.15), so global_transform still reads the PRE-reset
|
||||
# pose right now. Broadcasting that would send every client the old position
|
||||
# and then correct it a tick later — the same class of bug as Phase 2's 27m
|
||||
# goal slide. Read the queued target instead when there is one.
|
||||
func _pending_teleport_or_current(body: Node, fallback: Transform3D) -> Transform3D:
|
||||
if is_instance_valid(body) and body.has_method("get_pending_teleport"):
|
||||
var pending = body.call("get_pending_teleport")
|
||||
if pending != null:
|
||||
return pending
|
||||
return fallback
|
||||
|
||||
|
||||
func _append_kickoff_body(positions: PackedVector3Array, rotations: PackedFloat32Array, t: Transform3D) -> void:
|
||||
positions.append(t.origin)
|
||||
var q := t.basis.get_rotation_quaternion().normalized()
|
||||
rotations.append_array(PackedFloat32Array([q.x, q.y, q.z, q.w]))
|
||||
|
||||
|
||||
# Both peers. Places bodies exactly, freezes them, and arms the countdown.
|
||||
func _apply_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
_reset_gen = reset_gen
|
||||
_kickoff_resume_tick = countdown_start_tick + WARMUP_TICKS
|
||||
_last_emitted_countdown = -1
|
||||
var index := 0
|
||||
for slot in _slots:
|
||||
if index < positions.size() and is_instance_valid(slot.ship):
|
||||
_place_body(slot.ship, positions[index], _quat_at(rotations, index))
|
||||
index += 1
|
||||
if index < positions.size() and is_instance_valid(ball):
|
||||
_place_body(ball, positions[index], _quat_at(rotations, index))
|
||||
# Freeze on a LATER tick, not now. _place_body queues the teleport into the
|
||||
# body's next _integrate_forces, but a frozen body never runs one — and
|
||||
# set_deferred("freeze", true) lands at the end of this idle frame, before
|
||||
# that next physics step. Freezing immediately therefore strands the
|
||||
# teleport and leaves every body exactly where the goal left it. This is
|
||||
# the same "queued teleport lands a tick later" hazard Phase 2 hit with
|
||||
# _pending_reset_gen_bump_tick, and the same fix: gate on a strictly later
|
||||
# tick so the teleport has provably applied.
|
||||
_pending_freeze_tick = Engine.get_physics_frames() + 1
|
||||
# A client's prediction history describes the pre-kickoff world. Starting a
|
||||
# fresh epoch is the same contract §4.4 already specifies for a reset_gen
|
||||
# change; doing it here too means a kickoff that arrives before the first
|
||||
# post-kickoff snapshot cannot be reconciled against stale history.
|
||||
if not multiplayer.is_server() and _local_prediction_history != null:
|
||||
_local_prediction_history.begin_epoch()
|
||||
_last_local_reset_gen = reset_gen
|
||||
# §6.2's explicit late-arrival case: a kickoff delayed past its own resume
|
||||
# tick (ENet RTO can stretch a lifecycle burst to ~600ms on a lossy link)
|
||||
# must apply the reset immediately and SKIP the countdown, never schedule
|
||||
# it into the past and render a negative number.
|
||||
if _current_server_tick() >= _kickoff_resume_tick:
|
||||
_kickoff_resume_tick = -1
|
||||
_pending_freeze_tick = -1 # never freeze for a countdown already over
|
||||
kickoff_countdown.emit(0)
|
||||
_set_bodies_frozen(false)
|
||||
|
||||
|
||||
func _quat_at(rotations: PackedFloat32Array, index: int) -> Quaternion:
|
||||
var base := index * 4
|
||||
if base + 3 >= rotations.size():
|
||||
return Quaternion.IDENTITY
|
||||
return Quaternion(rotations[base], rotations[base + 1], rotations[base + 2], rotations[base + 3]).normalized()
|
||||
|
||||
|
||||
func _place_body(body: Node, position: Vector3, rotation: Quaternion) -> void:
|
||||
var target := Transform3D(Basis(rotation), position)
|
||||
# A body that is ALREADY frozen never runs _integrate_forces, so a queued
|
||||
# teleport would sit unapplied until something unfroze it — which on a
|
||||
# client is never, for the permanently-kinematic remote bodies. Those are
|
||||
# transform-driven by design (_apply_collider_state does exactly this), so
|
||||
# write directly. Anything still simulating goes through the Jolt-safe
|
||||
# queue instead (task 0.15): writing state.transform outside the body's own
|
||||
# _integrate_forces races the physics step.
|
||||
if body is RigidBody3D and (body as RigidBody3D).freeze:
|
||||
(body as RigidBody3D).global_transform = target
|
||||
(body as RigidBody3D).linear_velocity = Vector3.ZERO
|
||||
(body as RigidBody3D).angular_velocity = Vector3.ZERO
|
||||
else:
|
||||
body.call("queue_teleport_with_velocity", target, Vector3.ZERO, Vector3.ZERO)
|
||||
if body is Ship:
|
||||
var ship := body as Ship
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
|
||||
|
||||
func _set_bodies_frozen(frozen: bool) -> void:
|
||||
# set_deferred, matching match_mode.gd's own _set_frozen: `freeze` is a
|
||||
# physics-server-backed property and writing it mid-step is unsafe.
|
||||
#
|
||||
# ASYMMETRIC BY NECESSITY. On the server every body is a real dynamic
|
||||
# simulation and all of them freeze. On a CLIENT, `freeze` is already
|
||||
# load-bearing for something else: remote ships and the ball are
|
||||
# permanently FREEZE_MODE_KINEMATIC and driven purely by transform writes
|
||||
# from the interpolator, and only the local ship is unfrozen so Phase 4 can
|
||||
# predict it. Freezing "all bodies" on a client therefore UNFREEZES the
|
||||
# remote ones on the way back out — they immediately start falling under
|
||||
# gravity while the interpolator fights them for the transform. That is
|
||||
# what it did: 210 hard snaps and an infinite p99 in the first run.
|
||||
# A client only ever freezes the one body it actually simulates; the
|
||||
# remote ones already stop moving because the server's snapshots stop
|
||||
# changing.
|
||||
if multiplayer.is_server():
|
||||
if is_instance_valid(ball):
|
||||
ball.set_deferred("freeze", frozen)
|
||||
for slot in _slots:
|
||||
if is_instance_valid(slot.ship):
|
||||
slot.ship.set_deferred("freeze", frozen)
|
||||
return
|
||||
if _my_slot != null and is_instance_valid(_my_slot.ship):
|
||||
_my_slot.ship.set_deferred("freeze", frozen)
|
||||
|
||||
|
||||
func _apply_pending_freeze() -> void:
|
||||
if _pending_freeze_tick < 0 or Engine.get_physics_frames() <= _pending_freeze_tick:
|
||||
return
|
||||
_pending_freeze_tick = -1
|
||||
_set_bodies_frozen(true)
|
||||
|
||||
|
||||
func _on_kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
# match_config and kickoff are both reliable channel-0 messages, but a
|
||||
# client that is still loading its scene can receive the kickoff before it
|
||||
# has built _slots — and body order is slot order, so applying it early
|
||||
# placed the BALL at positions[0], i.e. exactly on top of the first ship.
|
||||
# The visible symptom was the ball-cam spamming "target vector can't be
|
||||
# zero" because its look-from and look-at had become the same point.
|
||||
# Hold it until the roster exists, then apply.
|
||||
if _slots.size() + 1 != positions.size():
|
||||
_pending_kickoff = {
|
||||
"positions": positions, "rotations": rotations,
|
||||
"countdown_start_tick": countdown_start_tick, "reset_gen": reset_gen,
|
||||
}
|
||||
return
|
||||
_apply_kickoff(positions, rotations, countdown_start_tick, reset_gen)
|
||||
|
||||
|
||||
func _apply_pending_kickoff() -> void:
|
||||
if _pending_kickoff.is_empty():
|
||||
return
|
||||
var k := _pending_kickoff
|
||||
_pending_kickoff = {}
|
||||
if _slots.size() + 1 != (k["positions"] as PackedVector3Array).size():
|
||||
# Still inconsistent (a roster change between the two messages). The
|
||||
# snapshot stream carries authoritative poses every tick regardless, so
|
||||
# dropping a stale kickoff is safe — it only costs the countdown.
|
||||
push_warning("NetworkedMatch: dropping a kickoff whose body count never matched the roster")
|
||||
return
|
||||
_apply_kickoff(k["positions"], k["rotations"], int(k["countdown_start_tick"]), int(k["reset_gen"]))
|
||||
|
||||
|
||||
# Both peers, once per physics tick. Emits the countdown from absolute ticks
|
||||
# so the two sides agree without either running a local Timer.
|
||||
func _update_kickoff_countdown() -> void:
|
||||
if _kickoff_resume_tick < 0:
|
||||
return
|
||||
var remaining_ticks := _kickoff_resume_tick - _current_server_tick()
|
||||
if remaining_ticks <= 0:
|
||||
_kickoff_resume_tick = -1
|
||||
_last_emitted_countdown = 0
|
||||
kickoff_countdown.emit(0)
|
||||
if not multiplayer.is_server():
|
||||
# The server unfreezes via its own PLAYING/OVERTIME transition;
|
||||
# a client does it here so it never waits a round trip to move.
|
||||
_set_bodies_frozen(false)
|
||||
return
|
||||
var count := int(ceil(float(remaining_ticks) / float(SimConstants.TICK_HZ)))
|
||||
if count != _last_emitted_countdown:
|
||||
_last_emitted_countdown = count
|
||||
kickoff_countdown.emit(count)
|
||||
|
||||
|
||||
# --- §6.2 step 8: goals (task 5.4) -----------------------------------------
|
||||
|
||||
func _on_goal_scored_received(scoring_team: int, new_score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||
# Client. Authoritative score first, then presentation — a client must
|
||||
# never derive the score from its own sensor.
|
||||
score = new_score.duplicate()
|
||||
score_changed.emit(score.duplicate())
|
||||
_set_bodies_frozen(true)
|
||||
# The cinematic is bounded by [goal_tick, resume_tick] (§6.2 step 8), and
|
||||
# is presentation only: it never gates when play resumes, which is what
|
||||
# kept the server resetting while clients were mid-celebration.
|
||||
_play_goal_celebration(scoring_team, 1 - scoring_team)
|
||||
|
||||
|
||||
# --- §6.2 step 9: clock (task 5.2) -----------------------------------------
|
||||
|
||||
func _current_server_tick() -> int:
|
||||
if multiplayer.is_server():
|
||||
return Engine.get_physics_frames()
|
||||
# Before the clock has synced this estimate is meaningless (Phase 2 fix
|
||||
# (5)); match_state_since_tick is the best bound available until then.
|
||||
if NetworkManager.rtt_ms < 0.0:
|
||||
return match_state_since_tick
|
||||
return _estimated_tick(NetworkManager.get_server_time_estimate_ms())
|
||||
|
||||
|
||||
func _arm_clock(length_ticks: int) -> void:
|
||||
_end_tick = Engine.get_physics_frames() + length_ticks
|
||||
_broadcast_clock_state()
|
||||
|
||||
|
||||
func _broadcast_clock_state() -> void:
|
||||
MatchSim.send_clock_state(_clock_running, _end_tick, Engine.get_physics_frames())
|
||||
|
||||
|
||||
func _on_clock_state_received(running: bool, end_tick: int, _at_tick: int) -> void:
|
||||
_clock_running = running
|
||||
_end_tick = end_tick
|
||||
|
||||
|
||||
# Both peers. Emits timer_updated only when the displayed second changes, the
|
||||
# same threshold pattern Ship uses for its telemetry signals.
|
||||
func _update_clock() -> void:
|
||||
if _end_tick < 0:
|
||||
return
|
||||
var remaining_ticks := maxi(0, _end_tick - _current_server_tick())
|
||||
var remaining_seconds := int(ceil(float(remaining_ticks) / float(SimConstants.TICK_HZ)))
|
||||
if remaining_seconds != _last_emitted_second:
|
||||
_last_emitted_second = remaining_seconds
|
||||
timer_updated.emit(remaining_seconds / 60, remaining_seconds % 60)
|
||||
|
||||
|
||||
# --- §6.2 step 10: full time, overtime, results (task 5.5) -----------------
|
||||
|
||||
func _enter_results(winning_team: int) -> void:
|
||||
_match_over = true
|
||||
_clock_running = false
|
||||
_set_bodies_frozen(true)
|
||||
match_ended.emit(winning_team, score.duplicate())
|
||||
_set_match_state(MatchState.State.RESULTS)
|
||||
|
||||
|
||||
func _winning_team() -> int:
|
||||
if score[0] == score[1]:
|
||||
return -1
|
||||
return 0 if score[0] > score[1] else 1
|
||||
|
||||
|
||||
# Server only, once per physics tick. Advances the states that end on their
|
||||
# own timer; goal- and clock-driven exits are pushed in from their own events.
|
||||
func _update_match_state() -> void:
|
||||
if _state_deadline_tick < 0 or Engine.get_physics_frames() < _state_deadline_tick:
|
||||
var now := Engine.get_physics_frames()
|
||||
# Full time is checked before the deadline switch below so a clock expiry
|
||||
# during PLAYING is acted on the tick it happens, not one state later.
|
||||
if _clock_running and _end_tick >= 0 and now >= _end_tick:
|
||||
if match_state == MatchState.State.PLAYING:
|
||||
_set_match_state(MatchState.State.FULL_TIME)
|
||||
return
|
||||
# A kickoff countdown ending is what starts play; the resume tick is
|
||||
# authoritative, not a separate deadline, so the two cannot drift apart.
|
||||
if _kickoff_resume_tick >= 0 and now >= _kickoff_resume_tick:
|
||||
if match_state == MatchState.State.WARMUP:
|
||||
_set_match_state(MatchState.State.PLAYING)
|
||||
_broadcast_clock_state()
|
||||
return
|
||||
if match_state == MatchState.State.OVERTIME_WARMUP:
|
||||
_set_match_state(MatchState.State.OVERTIME)
|
||||
_broadcast_clock_state()
|
||||
return
|
||||
if match_state == MatchState.State.FULL_TIME:
|
||||
# §6.2 step 10. A draw goes to sudden death; anything else is decided.
|
||||
if _winning_team() < 0:
|
||||
_in_overtime = true
|
||||
overtime_started.emit()
|
||||
_set_match_state(MatchState.State.OVERTIME_WARMUP)
|
||||
_begin_kickoff()
|
||||
else:
|
||||
_enter_results(_winning_team())
|
||||
return
|
||||
if _state_deadline_tick < 0 or now < _state_deadline_tick:
|
||||
return
|
||||
match match_state:
|
||||
MatchState.State.WARMUP:
|
||||
_set_match_state(MatchState.State.PLAYING)
|
||||
MatchState.State.OVERTIME_WARMUP:
|
||||
_set_match_state(MatchState.State.OVERTIME)
|
||||
MatchState.State.GOAL_PAUSE:
|
||||
# Task 5.5 decides RESULTS-vs-another-kickoff here once full time
|
||||
# and overtime exist; until then a goal always leads to a kickoff.
|
||||
if _in_overtime:
|
||||
# Golden goal: the first score after a draw ends it outright.
|
||||
_enter_results(_winning_team())
|
||||
return
|
||||
_set_match_state(MatchState.State.WARMUP)
|
||||
_begin_kickoff()
|
||||
MatchState.State.RESULTS:
|
||||
# §6.2 step 10: clients return to the LOBBY, never the main menu —
|
||||
# a community server that empties every 2.5 minutes is dead on
|
||||
# arrival. The state change is what moves both sides; the server
|
||||
# then leaves the match scene itself.
|
||||
_set_match_state(MatchState.State.LOBBY)
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
|
||||
|
||||
|
||||
func _on_state_change_received(state: int, at_tick: int) -> void:
|
||||
@@ -487,20 +855,35 @@ func _on_state_change_received(state: int, at_tick: int) -> void:
|
||||
|
||||
|
||||
func _on_goal_registered(conceding_team: int) -> void:
|
||||
_record_goal(1 - conceding_team)
|
||||
# §6.2 step 8. Immediate and authoritative at sensor time, before any
|
||||
# presentation — a last-second goal must count even though the cinematic
|
||||
# and the reset happen later.
|
||||
var scoring_team := 1 - conceding_team
|
||||
_record_goal(scoring_team)
|
||||
MatchSim.send_score_update(score.duplicate())
|
||||
if not multiplayer.is_server() or not MatchState.is_live(match_state):
|
||||
return
|
||||
var goal_tick := Engine.get_physics_frames()
|
||||
var resume_tick := goal_tick + int(_goal_pause_seconds() * SimConstants.TICK_HZ)
|
||||
# The clock stops for the celebration and resumes after it — expressed as
|
||||
# a shift of the absolute end tick (§5.2's own formula), never as pausing
|
||||
# a Timer, so no float drift accumulates across ten goals.
|
||||
if _end_tick >= 0 and not _in_overtime:
|
||||
_end_tick += resume_tick - goal_tick
|
||||
MatchSim.send_goal_scored(scoring_team, score.duplicate(), goal_tick, resume_tick)
|
||||
_set_bodies_frozen(true)
|
||||
_set_match_state(MatchState.State.GOAL_PAUSE)
|
||||
_broadcast_clock_state()
|
||||
|
||||
|
||||
func _on_goal_scored(_conceding_team: int) -> void:
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
_pending_reset_gen_bump = true
|
||||
_pending_reset_gen_bump_tick = Engine.get_physics_frames()
|
||||
# Only from a live state: GameMode debounces the sensor, but a second goal
|
||||
# landing while already in GOAL_PAUSE would otherwise be an illegal
|
||||
# transition and get push_error'd for something that is not a bug.
|
||||
if multiplayer.is_server() and MatchState.is_live(match_state):
|
||||
_set_match_state(MatchState.State.GOAL_PAUSE)
|
||||
# Deliberately empty. Before task 5.4 this reset the world the instant the
|
||||
# sensor fired, which is precisely the "server reset fires while clients
|
||||
# are mid-celebration" failure §5.4 exists to remove. The reset is now the
|
||||
# KICKOFF's job at resume_tick (_update_match_state -> _begin_kickoff), so
|
||||
# bodies stay frozen exactly where the goal happened for the whole
|
||||
# celebration window and every peer sees the same thing.
|
||||
pass
|
||||
|
||||
|
||||
func _broadcast_snapshot() -> void:
|
||||
@@ -550,7 +933,11 @@ func _ship_to_net_body_state(ship: Ship, stalled: bool) -> NetBodyState:
|
||||
s.rotation = ship.global_transform.basis.get_rotation_quaternion()
|
||||
s.linear_velocity = ship.linear_velocity
|
||||
s.angular_velocity = ship.angular_velocity
|
||||
s.frozen = false
|
||||
# Was hardcoded false. NetShipPredictor.decide() hard-corrects on
|
||||
# `authoritative.frozen != local_frozen`, which is exactly the mechanism
|
||||
# that keeps a client's predicted ship from drifting during a kickoff
|
||||
# freeze — it only works if the wire tells the truth.
|
||||
s.frozen = ship.freeze
|
||||
s.turbo = ship.is_turbo_active()
|
||||
# Matches Ship._update_movement_vfx's own read of thrust.z: only positive
|
||||
# forward thrust drives the visible flame (see task 2.6).
|
||||
@@ -668,6 +1055,9 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
_local_net_controller = LocalNetShipController.new(player, _local_input_timeline)
|
||||
_local_net_controller.add_child(player)
|
||||
_my_slot.ship.set_controller(_local_net_controller)
|
||||
# The roster now exists, so a kickoff that raced ahead of match_config can
|
||||
# finally be placed against the right bodies.
|
||||
_apply_pending_kickoff()
|
||||
|
||||
|
||||
func _spawn_hud() -> void:
|
||||
@@ -675,7 +1065,7 @@ func _spawn_hud() -> void:
|
||||
add_child(hud)
|
||||
|
||||
|
||||
func _send_local_input() -> void:
|
||||
func _send_local_input(record_prediction: bool = true) -> void:
|
||||
if _slots.is_empty():
|
||||
return # match_config hasn't arrived yet
|
||||
if not _local_prediction_ready or _my_slot == null or not is_instance_valid(_my_slot.ship):
|
||||
@@ -709,7 +1099,7 @@ func _send_local_input() -> void:
|
||||
# could never falsify it and a transition-heavy one reports ~9% action-marker
|
||||
# mismatch.
|
||||
var history_seq := _input_seq
|
||||
if delta > 0:
|
||||
if delta > 0 and record_prediction:
|
||||
# An attack (delta > 1) issues and SENDS several sequences for this one
|
||||
# local physics step; only the newest carries the action the body just
|
||||
# integrated. The skipped ones are real outstanding sequences the server
|
||||
@@ -1128,10 +1518,21 @@ func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
if _owns_world_simulation():
|
||||
_respawn_escaped_bodies()
|
||||
# ORDER IS LOAD-BEARING. _update_match_state() consumes _kickoff_resume_tick
|
||||
# to drive WARMUP -> PLAYING, and _update_kickoff_countdown() clears that
|
||||
# same field once it reaches zero. Running the countdown first meant the
|
||||
# server's transition condition was wiped before it was ever evaluated and
|
||||
# the match sat in WARMUP forever with every body frozen.
|
||||
if multiplayer.is_server():
|
||||
# Before the broadcast, so a transition taken this tick ships in this
|
||||
# tick's own match_state byte rather than trailing it by one.
|
||||
# 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()
|
||||
# Countdown and clock are derived from absolute ticks on both peers, so
|
||||
# these run on the client too.
|
||||
_apply_pending_freeze()
|
||||
_update_kickoff_countdown()
|
||||
_update_clock()
|
||||
if multiplayer.is_server():
|
||||
# _physics_process runs after this frame's _integrate_forces. Snapshot
|
||||
# FIRST: the body state therefore still describes the sequence consumed
|
||||
# on the prior callback. Sending after consume mislabeled that old state
|
||||
@@ -1148,8 +1549,22 @@ func _physics_process(_delta: float) -> void:
|
||||
_pending_reset_gen_bump = false
|
||||
return
|
||||
|
||||
_send_local_input()
|
||||
_consume_local_reconciliation()
|
||||
# Prediction and reconciliation are suspended while the match is not live.
|
||||
# During a kickoff countdown or a goal pause the local ship is frozen on
|
||||
# BOTH peers, so there is nothing to predict — but the reconciler still ran
|
||||
# its delta transport and visual-offset maths over those frozen states and
|
||||
# produced garbage: 200 hard snaps and a p95 position error of 2.4e10 m in
|
||||
# a single 12s run, while the instantaneous error stayed small. Input keeps
|
||||
# flowing so the server's jitter buffer does not starve into `stalled` and
|
||||
# the input_lead loop keeps its cadence; only the local prediction ring and
|
||||
# the correction step pause.
|
||||
var live := MatchState.is_live(match_state)
|
||||
_send_local_input(live)
|
||||
if live:
|
||||
_consume_local_reconciliation()
|
||||
else:
|
||||
# Anything queued from before the whistle describes the old world.
|
||||
_pending_local_reconciliation = {}
|
||||
_finish_ball_prediction()
|
||||
# get_server_time_estimate_ms() is meaningless before the first pong
|
||||
# lands (network_manager.gd's own doc comment says so explicitly) — an
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
|
||||
# a community server whose players are all dumped back to their own menus
|
||||
# every 2.5 minutes has no way to keep a lobby together.
|
||||
const LOBBY := "res://scenes/lobby.tscn"
|
||||
|
||||
@@ -127,6 +127,16 @@ func queue_teleport(to: Transform3D) -> void:
|
||||
# Network hard snaps need the server velocity as their new starting point,
|
||||
# unlike gameplay resets which deliberately zero it. Keep the write queued:
|
||||
# Jolt only permits state mutation from _integrate_forces.
|
||||
# The queued-but-not-yet-applied teleport target, or null when none is
|
||||
# pending. queue_teleport() defers the actual write to the next
|
||||
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||||
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||||
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||||
# pre-reset position and corrects it a tick later.
|
||||
func get_pending_teleport():
|
||||
return _pending_teleport if _has_pending_teleport else null
|
||||
|
||||
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
|
||||
@@ -18,8 +18,14 @@ const NetworkedMatchScript = preload("res://scripts/networked_match.gd")
|
||||
const BALL_BLEND_ACCEPTANCE_MS := 170 # 150ms contract + one rendered-frame allowance
|
||||
|
||||
|
||||
func _is_networked_match(node: Node) -> bool:
|
||||
return node != null and node.get_script() == NetworkedMatchScript
|
||||
func _is_networked_match(node) -> bool:
|
||||
# is_instance_valid FIRST, and the parameter is untyped for the same
|
||||
# reason: at RESULTS both peers change scene to the lobby (§6.2 step 10),
|
||||
# which frees the match scene while these hooks — deliberately parented
|
||||
# outside it so they survive scene swaps — are still holding a reference.
|
||||
# A typed Node parameter throws on a freed object before the body even
|
||||
# runs, which hung both processes for the full 5-minute timeout.
|
||||
return is_instance_valid(node) and node.get_script() == NetworkedMatchScript
|
||||
|
||||
|
||||
func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> void:
|
||||
@@ -53,6 +59,12 @@ func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> void:
|
||||
print("SMOKE INFO: host forced a goal to exercise the GOAL_PAUSE transition")
|
||||
|
||||
await get_tree().create_timer(lifetime_seconds * 0.6).timeout
|
||||
if not _is_networked_match(match_scene):
|
||||
# The match ended and the server returned itself to the lobby.
|
||||
print("SMOKE PASS: host ran the match to completion and left the match scene")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0)
|
||||
return
|
||||
if force_goal and _is_networked_match(match_scene):
|
||||
print("SMOKE INFO: host final match_state=%s" % MatchState.to_name(match_scene.match_state))
|
||||
if _is_networked_match(match_scene) and not match_scene.ships.is_empty():
|
||||
@@ -117,6 +129,19 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
print("SMOKE FAIL: spawn/wiring check failed")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# Bodies are frozen during the kickoff countdown and the goal pause (tasks
|
||||
# 5.3/5.4), so every assertion below — "not frozen", "a controller drives
|
||||
# it", "it moved" — is only meaningful once play is actually live. Before
|
||||
# 5.3 the match was live the instant it loaded and this wait did not exist;
|
||||
# sampling during WARMUP now reports a legitimately frozen ship as a
|
||||
# prediction failure.
|
||||
var live_deadline := Time.get_ticks_msec() + 15000
|
||||
while Time.get_ticks_msec() < live_deadline and not MatchState.is_live(match_scene.match_state):
|
||||
await get_tree().physics_frame
|
||||
if not MatchState.is_live(match_scene.match_state):
|
||||
print("SMOKE FAIL: match never reached a live state (stuck in %s)" % MatchState.to_name(match_scene.match_state))
|
||||
get_tree().quit(1)
|
||||
return
|
||||
match_scene._local_ship_predictor.clear_metrics()
|
||||
|
||||
# Drive forward thrust (a real, held key state — exercises the actual
|
||||
@@ -126,13 +151,17 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
# applied real thruster force, broadcast it back, and the client's
|
||||
# interpolator produced smooth motion from it.
|
||||
if exercise_ball_contact:
|
||||
# Slot T0/S0 needs a short diagonal burst to reach the centre ball.
|
||||
# Release it immediately and leave a >150ms observation window before
|
||||
# the normal drive, so a subsequent goal reset cannot mask blend-back.
|
||||
Input.action_press("move_forward")
|
||||
Input.action_press("move_right")
|
||||
await get_tree().create_timer(1.1).timeout
|
||||
Input.action_release("move_right")
|
||||
# Steer at the ball with real input rather than a fixed-heading burst.
|
||||
# This used to be "forward + right for 1.1s", tuned by hand against the
|
||||
# spawn orientation — which task 5.3's kickoff broke, because
|
||||
# reset_ships() applies KICKOFF_YAW_JITTER (task 0.7) and the ship no
|
||||
# longer starts on a known heading. The old burst then flew past the
|
||||
# ball every time (0 contacts in 3/3 runs). Closing the loop on the
|
||||
# actual bearing keeps this exercising the real input path while being
|
||||
# indifferent to how the kickoff happened to orient the ship.
|
||||
await _drive_at_ball(my_slot.ship, match_scene.ball, 3.0)
|
||||
# Leave a >150ms observation window before the normal drive so a
|
||||
# subsequent goal reset cannot mask blend-back.
|
||||
Input.action_release("move_forward")
|
||||
await get_tree().create_timer(0.35).timeout
|
||||
if not exercise_free_flight and not exercise_input_transitions:
|
||||
@@ -164,13 +193,21 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
# Phase 4.3: own ship is a genuine unfrozen local simulation. Its slot
|
||||
# intentionally receives no NetInterpolator samples; a controller attached
|
||||
# to the body supplies the one action used by this tick's physics step.
|
||||
var local_prediction_ok: bool = not my_slot.ship.freeze \
|
||||
and my_slot.ship.controller != null \
|
||||
# Split into structural and live halves. The structural half holds at every
|
||||
# instant. The freeze/thrust half only means anything while play is live:
|
||||
# tasks 5.3/5.4 freeze the local ship for the kickoff countdown and the
|
||||
# goal pause, and a goal can land anywhere in a drive, so asserting
|
||||
# unconditionally reports a correctly-frozen ship as a prediction failure.
|
||||
var live_now: bool = MatchState.is_live(match_scene.match_state)
|
||||
var structure_ok: bool = my_slot.ship.controller != null \
|
||||
and my_slot.ship.controller.get_parent() == my_slot.ship \
|
||||
and not my_slot.interpolator.has_samples() \
|
||||
and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5)
|
||||
print("SMOKE INFO: local_prediction=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [
|
||||
str(local_prediction_ok), str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples())
|
||||
and not my_slot.interpolator.has_samples()
|
||||
var driving_ok: bool = not live_now or (not my_slot.ship.freeze \
|
||||
and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5))
|
||||
var local_prediction_ok: bool = structure_ok and driving_ok
|
||||
print("SMOKE INFO: local_prediction=%s state=%s structure_ok=%s driving_ok=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [
|
||||
str(local_prediction_ok), MatchState.to_name(match_scene.match_state), str(structure_ok), str(driving_ok),
|
||||
str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples())
|
||||
])
|
||||
|
||||
if not exercise_free_flight and not exercise_input_transitions:
|
||||
@@ -185,6 +222,31 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
# handoff blend to finish before inspecting lifecycle telemetry.
|
||||
await get_tree().create_timer(0.35).timeout
|
||||
|
||||
# The match can legitimately END during a drive (§6.2 step 10: FULL_TIME ->
|
||||
# RESULTS -> LOBBY tears this scene down). Every assertion below reads the
|
||||
# match scene, so finish on the lifecycle evidence instead of dereferencing
|
||||
# freed objects.
|
||||
if not _is_networked_match(match_scene) or not is_instance_valid(my_slot.ship):
|
||||
var completed_ok := true
|
||||
if exercise_match_state:
|
||||
var seq: Array[String] = []
|
||||
for s in observed_states:
|
||||
seq.append(MatchState.to_name(s))
|
||||
completed_ok = MatchState.State.RESULTS in observed_states and MatchState.State.LOBBY in observed_states
|
||||
for i in observed_states.size() - 1:
|
||||
if not MatchState.can_transition(observed_states[i], observed_states[i + 1]):
|
||||
print("SMOKE FAIL: illegal transition %s -> %s" % [seq[i], seq[i + 1]])
|
||||
completed_ok = false
|
||||
print("SMOKE %s: match ran to completion and returned to the lobby (%s)" % [
|
||||
"PASS" if completed_ok else "FAIL", " -> ".join(seq)
|
||||
])
|
||||
else:
|
||||
print("SMOKE INFO: match scene torn down before the drive finished")
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if completed_ok else 1)
|
||||
return
|
||||
|
||||
var end_position: Vector3 = my_slot.ship.global_position
|
||||
var prediction_stats: Dictionary = match_scene.get_net_debug_stats().get("prediction", {})
|
||||
var net_stats: Dictionary = match_scene.get_net_debug_stats()
|
||||
@@ -323,14 +385,28 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
if observed_ticks[i + 1] < observed_ticks[i]:
|
||||
print("SMOKE FAIL: transition ticks went backwards: %s" % str(observed_ticks))
|
||||
match_state_ok = false
|
||||
# Which lifecycle path a run takes depends on its own timing: a short
|
||||
# --match-length reaches FULL_TIME before the forced goal lands, a
|
||||
# longer one exercises the goal cycle instead. Assert what the run
|
||||
# actually did rather than hardcoding one shape — but require it did
|
||||
# at least ONE of them, so a match that merely sat in PLAYING the
|
||||
# whole time cannot quietly pass.
|
||||
var reached_playing := MatchState.State.PLAYING in observed_states
|
||||
var saw_goal_pause := MatchState.State.GOAL_PAUSE in observed_states
|
||||
var saw_full_time := MatchState.State.FULL_TIME in observed_states
|
||||
# A goal must lead back to a kickoff, not leave the match parked.
|
||||
var resumed_after_goal := false
|
||||
for i in observed_states.size() - 1:
|
||||
if observed_states[i] == MatchState.State.GOAL_PAUSE and observed_states[i + 1] == MatchState.State.WARMUP:
|
||||
resumed_after_goal = true
|
||||
if not (reached_playing and saw_goal_pause and resumed_after_goal):
|
||||
# Full time must resolve: sudden death on a draw, results otherwise.
|
||||
var full_time_resolved := false
|
||||
for i in observed_states.size() - 1:
|
||||
if observed_states[i] == MatchState.State.FULL_TIME and observed_states[i + 1] in [MatchState.State.OVERTIME_WARMUP, MatchState.State.RESULTS]:
|
||||
full_time_resolved = true
|
||||
var goal_cycle_ok: bool = not saw_goal_pause or resumed_after_goal
|
||||
var full_time_ok: bool = not saw_full_time or full_time_resolved
|
||||
if not (reached_playing and goal_cycle_ok and full_time_ok and (saw_goal_pause or saw_full_time)):
|
||||
match_state_ok = false
|
||||
# The snapshot's match_state byte must carry the real state too, not a
|
||||
# hardcoded 0. Everything above is driven by the reliable state_change
|
||||
@@ -348,9 +424,10 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
if wire_state == MatchState.State.LOBBY:
|
||||
print("SMOKE FAIL: snapshot match_state byte reads LOBBY (0) mid-match — likely never populated")
|
||||
match_state_ok = false
|
||||
print("SMOKE %s: client followed the server's match state (%s; reached_playing=%s goal_pause=%s resumed=%s ticks=%s)" % [
|
||||
print("SMOKE %s: client followed the server's match state (%s; playing=%s goal_pause=%s resumed=%s full_time=%s resolved=%s ticks=%s)" % [
|
||||
"PASS" if match_state_ok else "FAIL", " -> ".join(names),
|
||||
str(reached_playing), str(saw_goal_pause), str(resumed_after_goal), str(observed_ticks),
|
||||
str(reached_playing), str(saw_goal_pause), str(resumed_after_goal),
|
||||
str(saw_full_time), str(full_time_resolved), str(observed_ticks),
|
||||
])
|
||||
|
||||
var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok and match_state_ok
|
||||
@@ -417,6 +494,45 @@ func _run_input_transition_trace(duration_seconds: float) -> void:
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
# Closed-loop steering: yaw toward the ball, thrust once roughly aligned, and
|
||||
# stop as soon as we are close enough that contact is imminent. Uses only real
|
||||
# Input actions, so the client input -> server -> snapshot path under test is
|
||||
# exercised exactly as a player would.
|
||||
func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> void:
|
||||
const ALIGNED_RADIANS := 0.25
|
||||
var deadline := Time.get_ticks_msec() + int(timeout_seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
if not is_instance_valid(ship) or not is_instance_valid(ball_body):
|
||||
break
|
||||
var to_ball := ball_body.global_position - ship.global_position
|
||||
if to_ball.length() < 3.0:
|
||||
break # close enough that the existing thrust carries it in
|
||||
# Bearing in the ship's own frame: -Z is forward, +X is right.
|
||||
var local := ship.global_transform.basis.inverse() * to_ball
|
||||
var yaw_error := atan2(local.x, -local.z)
|
||||
Input.action_release("turn_left")
|
||||
Input.action_release("turn_right")
|
||||
if absf(yaw_error) > ALIGNED_RADIANS:
|
||||
Input.action_press("turn_right" if yaw_error > 0.0 else "turn_left")
|
||||
Input.action_release("move_forward")
|
||||
else:
|
||||
Input.action_press("move_forward")
|
||||
# Vertical alignment matters too — the ball sits above the floor and a
|
||||
# ship that is climbing sails straight over it.
|
||||
Input.action_release("move_up")
|
||||
Input.action_release("move_down")
|
||||
if local.y > 1.0:
|
||||
Input.action_press("move_up")
|
||||
elif local.y < -1.0:
|
||||
Input.action_press("move_down")
|
||||
await get_tree().physics_frame
|
||||
Input.action_release("turn_left")
|
||||
Input.action_release("turn_right")
|
||||
Input.action_release("move_up")
|
||||
Input.action_release("move_down")
|
||||
Input.action_press("move_forward")
|
||||
|
||||
|
||||
func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_seconds: float) -> float:
|
||||
var elapsed := 0.0
|
||||
var peak_distance := 0.0
|
||||
|
||||
+4
-4
@@ -965,10 +965,10 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154)
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 5.1 `[D:2.1]` | **DONE.** `scripts/match_state.gd` (enum + validated transition table, pure/unit-testable), server-driven machine in `NetworkedMatch`, `state_change` RPC on reliable channel 0 carrying an absolute `at_tick`, and the snapshot `match_state` byte populated for real | Client observed `LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP` with monotonic ticks in a real two-process run; every consecutive pair legal; wire byte asserted independently of the RPC |
|
||||
| 5.2 `[D:5.1]` | Tick-derived clock replacing the `Timer` + `_process` polling; `clock_state` RPC; goal-time freeze as `end_tick += (resume_tick - goal_tick)` | Clocks agree across peers to within a tick; no float drift across 10 goals |
|
||||
| 5.3 `[D:5.1]` | `kickoff` RPC with broadcast transforms, freeze/unfreeze, `reset_gen`, countdown derived from `server_tick`, **and the specified late-arrival behaviour** | A `kickoff` delayed past `resume_tick` applies immediately without a negative countdown |
|
||||
| 5.4 `[D:5.1]` | `goal_scored` RPC; server-side pause window via `_goal_pause_seconds()` and `_set_frozen()` (**never `Engine.time_scale`**); client cinematic split from timing | Server reset no longer fires while clients are mid-celebration |
|
||||
| 5.5 `[D:5.1]` `[P]` | Full time, overtime, results, return-to-lobby; **remove `get_tree().paused`** | Clients keep sending inputs and processing snapshots throughout the results screen |
|
||||
| 5.2 `[D:5.1]` | **DONE.** `_end_tick`/`_clock_running`, `clock_state` RPC, `timer_updated` emitted from absolute ticks on both peers; goal pause shifts `end_tick` rather than pausing anything | No `Timer` and no `_process` polling remain in the networked path; both peers derive `remaining = end_tick - now` from the same server-tick estimate |
|
||||
| 5.3 `[D:5.1]` | **DONE.** `kickoff` RPC carrying resulting transforms (never a seed, per §1), deferred freeze, `reset_gen` bump, countdown from `server_tick`, late-arrival skip | Real two-process run: `LOADING -> WARMUP -> PLAYING`, countdown ticks match `WARMUP_TICKS` exactly; a kickoff past its own resume tick unfreezes immediately and emits `0` |
|
||||
| 5.4 `[D:5.1]` | **DONE.** `goal_scored(scoring_team, score, goal_tick, resume_tick)`, freeze on the goal tick, reset moved out of the sensor path into the kickoff at `resume_tick`; cinematic is presentation-only | `PLAYING -> GOAL_PAUSE -> WARMUP -> PLAYING` observed on the client; bodies stay where the goal left them for the whole window; `Engine.time_scale` untouched |
|
||||
| 5.5 `[D:5.1]` `[P]` | **DONE.** Clock expiry -> `FULL_TIME` -> sudden death on a draw or `RESULTS`, golden goal in overtime, then `LOBBY` on both peers. `get_tree().paused` is never used in the networked path | Full run observed end to end: `LOADING -> WARMUP -> PLAYING -> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> GOAL_PAUSE -> RESULTS -> LOBBY`, both peers returning to the lobby scene |
|
||||
| 5.6 `[D:5.1]` `[P]` | Disconnect → controller swap; 30 s identity-keyed slot reservation and reconnect; `--fill-bots` / `--no-fill-bots`; `stalled` flag and nameplate | A disconnect never despawns a ship; reconnect within 30 s restores the slot |
|
||||
| 5.7 `[D:5.6]` | Null `MatchNet`'s controller reference in the same transaction as the swap, and `is_instance_valid`-guard every use | No freed-object access on repeated disconnect/reconnect |
|
||||
| 5.8 `[D:5.1]` `[P]` | Spectators and late join; spectator-safe `HUDController` path; camera target cycling | A spectator can watch a live match and cycle targets |
|
||||
|
||||
Reference in New Issue
Block a user