mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user