feat(multiplayer): Phase 5 task 5.1 - match lifecycle state machine

Adds the §6.1 state machine, its broadcast, and the client side that
follows it. Physics, freezing and input are deliberately NOT gated on
state yet - 5.3 and 5.4 own freeze/unfreeze at kickoff and goal, and
doing it here would change the conditions every Phase 4 prediction gate
was measured under.

scripts/match_state.gd holds the enum and transition table as pure data
with no scene or RPC dependency, so the table is checked exhaustively
rather than by example: every state reachable, every state has an exit,
no self-transitions, abort-to-LOBBY from anywhere per §6.4, illegal
shortcuts rejected, unknown values refused rather than coerced. The enum
values are the wire format - match_state has been a u8 in the snapshot
header since §2.4 - so a test pins them; only append, never renumber.

The server validates every transition and push_errors an illegal one
rather than following it. Clients deliberately do NOT enforce the table:
authoritative state must be accepted, and a late joiner legitimately
jumps straight to PLAYING.

Two channels carry the state. state_change (reliable, channel 0) is
prompt and carries an absolute at_tick, never a duration. The snapshot's
match_state byte is the catch-up path for a client not yet sent a
transition - a late joiner, or the window between scene load and the
first RPC.

The byte needs a tick guard, and this was found the hard way. Snapshots
are unreliable_ordered on channel 2 and ordering holds only within a
channel, so a state_change for tick N routinely arrives before an
in-flight snapshot from tick N-2. Without the guard the client applies
the new state then gets dragged back by the older byte, oscillating on
every transition - observed directly as LOADING -> WARMUP -> LOBBY ->
PLAYING -> LOBBY while running a deliberately-broken-byte control. Only
a byte at least as new as match_state_since_tick is accepted.

WARMUP_TICKS/GOAL_PAUSE_TICKS are honest placeholders so 5.1 drives real
transitions to verify against; 5.3 and 5.4 replace them. The server also
leaves LOADING immediately rather than waiting for scene_ready, which
does not exist yet.

New smoke flag --exercise-match-state, passed to both roles: the host
forces a goal to drive a GOAL_PAUSE cycle, the client records the
sequence and asserts every consecutive pair is legal, that ticks are
monotonic, and that the wire byte agrees with its own state. Observed
LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP with tick deltas
matching the configured durations exactly.

Verified against a control: hardcoding the snapshot byte back to 0 fails
both the byte assertion and the transition-legality assertion. The byte
is asserted separately from the RPC precisely because everything else in
the check is RPC-driven and would pass with a dead byte - the same gap
that hid the Phase 4 label bug (gotcha 47).

Regression: 81 unit tests; 60s free-flight LAN (p99 0.148m, 0 hard
snaps, marker 0/3364); transition gate 0.00%; ball contact; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 09:31:22 +01:00
parent 75f485667b
commit 9f28c02488
9 changed files with 485 additions and 7 deletions
+115
View File
@@ -0,0 +1,115 @@
extends "res://tests/test_case.gd"
# §6.1 match lifecycle state machine (task 5.1). The table is pure data, so
# it can be checked exhaustively rather than by example — which is the point
# of keeping it out of NetworkedMatch.
const MatchStateScript = preload("res://scripts/match_state.gd")
func test_wire_values_are_stable() -> void:
# These integers ARE the snapshot's match_state byte. Renumbering an
# existing state silently reinterprets every packet from an older peer,
# so pin them: this test failing means a protocol break, not a typo.
assert_eq(MatchStateScript.State.LOBBY, 0, "LOBBY")
assert_eq(MatchStateScript.State.LOADING, 1, "LOADING")
assert_eq(MatchStateScript.State.WARMUP, 2, "WARMUP")
assert_eq(MatchStateScript.State.PLAYING, 3, "PLAYING")
assert_eq(MatchStateScript.State.GOAL_PAUSE, 4, "GOAL_PAUSE")
assert_eq(MatchStateScript.State.FULL_TIME, 5, "FULL_TIME")
assert_eq(MatchStateScript.State.OVERTIME_WARMUP, 6, "OVERTIME_WARMUP")
assert_eq(MatchStateScript.State.OVERTIME, 7, "OVERTIME")
assert_eq(MatchStateScript.State.RESULTS, 8, "RESULTS")
func test_every_state_fits_in_the_wire_byte() -> void:
for value in MatchStateScript.State.values():
assert_true(value >= 0 and value <= 255, "state %d must fit a u8" % value)
func test_the_documented_happy_path_is_walkable() -> void:
# §6.1's own diagram, start to finish, including the goal loop.
var path := [
MatchStateScript.State.LOBBY, MatchStateScript.State.LOADING,
MatchStateScript.State.WARMUP, MatchStateScript.State.PLAYING,
MatchStateScript.State.GOAL_PAUSE, MatchStateScript.State.WARMUP,
MatchStateScript.State.PLAYING, MatchStateScript.State.FULL_TIME,
MatchStateScript.State.OVERTIME_WARMUP, MatchStateScript.State.OVERTIME,
MatchStateScript.State.RESULTS, MatchStateScript.State.LOBBY,
]
for i in path.size() - 1:
assert_true(
MatchStateScript.can_transition(path[i], path[i + 1]),
"%s -> %s must be legal" % [MatchStateScript.to_name(path[i]), MatchStateScript.to_name(path[i + 1])]
)
func test_illegal_shortcuts_are_rejected() -> void:
var illegal := [
[MatchStateScript.State.LOBBY, MatchStateScript.State.PLAYING], # must load first
[MatchStateScript.State.LOADING, MatchStateScript.State.PLAYING], # must warm up first
[MatchStateScript.State.WARMUP, MatchStateScript.State.GOAL_PAUSE], # cannot score before play
[MatchStateScript.State.PLAYING, MatchStateScript.State.RESULTS], # must pass full time
[MatchStateScript.State.RESULTS, MatchStateScript.State.PLAYING], # match is over
[MatchStateScript.State.FULL_TIME, MatchStateScript.State.PLAYING], # regulation cannot resume
]
for pair in illegal:
assert_true(
not MatchStateScript.can_transition(pair[0], pair[1]),
"%s -> %s must be rejected" % [MatchStateScript.to_name(pair[0]), MatchStateScript.to_name(pair[1])]
)
func test_abort_to_lobby_is_reachable_from_anywhere_but_lobby() -> void:
# §6.4: "if the last human leaves, abort to LOBBY" can fire at any point.
for state in MatchStateScript.State.values():
if state == MatchStateScript.State.LOBBY:
assert_true(not MatchStateScript.can_transition(state, state), "LOBBY -> LOBBY is not a transition")
continue
assert_true(
MatchStateScript.can_transition(state, MatchStateScript.State.LOBBY),
"%s must be able to abort to LOBBY" % MatchStateScript.to_name(state)
)
func test_no_state_transitions_to_itself() -> void:
for state in MatchStateScript.State.values():
assert_true(not MatchStateScript.can_transition(state, state), "%s -> itself" % MatchStateScript.to_name(state))
func test_every_state_is_reachable_and_can_make_progress() -> void:
# Guards against a state being added to the enum and forgotten in the
# table — an orphan would be broadcastable but a dead end, or unreachable
# but present on the wire.
for state in MatchStateScript.State.values():
var has_exit := false
var has_entry := false
for other in MatchStateScript.State.values():
if other != state and MatchStateScript.can_transition(state, other):
has_exit = true
if other != state and MatchStateScript.can_transition(other, state):
has_entry = true
assert_true(has_exit, "%s has no legal exit" % MatchStateScript.to_name(state))
assert_true(has_entry, "%s is unreachable" % MatchStateScript.to_name(state))
func test_only_playing_and_overtime_are_live() -> void:
# is_live() gates simulation in tasks 5.3/5.4; a warmup or a goal pause
# must never read as live.
assert_true(MatchStateScript.is_live(MatchStateScript.State.PLAYING), "PLAYING is live")
assert_true(MatchStateScript.is_live(MatchStateScript.State.OVERTIME), "OVERTIME is live")
for state in [
MatchStateScript.State.LOBBY, MatchStateScript.State.LOADING, MatchStateScript.State.WARMUP,
MatchStateScript.State.GOAL_PAUSE, MatchStateScript.State.FULL_TIME,
MatchStateScript.State.OVERTIME_WARMUP, MatchStateScript.State.RESULTS,
]:
assert_true(not MatchStateScript.is_live(state), "%s must not be live" % MatchStateScript.to_name(state))
func test_unknown_values_are_rejected_rather_than_coerced() -> void:
# A newer server can legitimately send a state this build has never heard
# of; it must be refused, not clamped into a neighbouring valid state.
for bogus in [-1, 9, 42, 255]:
assert_true(not MatchStateScript.is_valid(bogus), "%d is not a valid state" % bogus)
assert_true(not MatchStateScript.can_transition(MatchStateScript.State.PLAYING, bogus), "cannot enter %d" % bogus)
assert_true(not MatchStateScript.can_transition(bogus, MatchStateScript.State.PLAYING), "cannot leave %d" % bogus)
+1
View File
@@ -0,0 +1 @@
uid://b062q6v6jmur2
+5 -2
View File
@@ -18,6 +18,7 @@ var _drive_seconds := DEFAULT_DRIVE_SECONDS
var _exercise_ball_contact := false
var _exercise_free_flight := false
var _exercise_input_transitions := false
var _exercise_match_state := false
var _warmup_seconds := 0.0
@@ -35,6 +36,8 @@ func _ready() -> void:
_exercise_free_flight = true
elif arg == "--exercise-input-transitions":
_exercise_input_transitions = true
elif arg == "--exercise-match-state":
_exercise_match_state = true
elif arg.begins_with("--warmup-seconds="):
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
@@ -94,7 +97,7 @@ func _on_host_player_joined(_peer_id: int, _name: String) -> void:
get_tree().root.add_child.call_deferred(hooks)
# The host must outlive client settle + drive, plus connection/shutdown
# slack. This keeps --drive-seconds useful for sustained prediction QA.
hooks.run_host_check.call_deferred(_settle_seconds + _warmup_seconds + _drive_seconds + 4.0)
hooks.run_host_check.call_deferred(_settle_seconds + _warmup_seconds + _drive_seconds + 4.0, _exercise_match_state)
func _on_client_welcomed() -> void:
@@ -103,7 +106,7 @@ func _on_client_welcomed() -> void:
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_client_check.call_deferred(_settle_seconds, _drive_seconds, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions)
hooks.run_client_check.call_deferred(_settle_seconds, _drive_seconds, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions, _exercise_match_state)
func _on_abuser_welcomed() -> void:
+95 -3
View File
@@ -22,7 +22,7 @@ func _is_networked_match(node: Node) -> bool:
return node != null and node.get_script() == NetworkedMatchScript
func run_host_check(lifetime_seconds: float) -> void:
func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> void:
await get_tree().create_timer(lifetime_seconds * 0.4).timeout
var match_scene := get_tree().current_scene
var ok := _is_networked_match(match_scene)
@@ -40,7 +40,21 @@ func run_host_check(lifetime_seconds: float) -> void:
var success := ok and ship_count == 1 and ball_ok
print("SMOKE %s: host spawn check (ship_count=%d, ball_ok=%s)" % ["PASS" if success else "FAIL", ship_count, str(ball_ok)])
if force_goal and ok:
# Drive a real PLAYING -> GOAL_PAUSE -> WARMUP -> PLAYING cycle so the
# client has a goal transition to follow. Teleporting the ball into a
# goal is the same deterministic trick the CI driver and Phase 2's
# goal-reset-ordering fix both use — two low-skill peers scoring
# naturally inside a short run is not reliable enough to gate on.
var goals: Array = match_scene.arena.get_goals() if match_scene.arena else []
if is_instance_valid(match_scene.ball) and not goals.is_empty():
match_scene.ball.linear_velocity = Vector3.ZERO
match_scene.ball.global_position = goals[0].global_position
print("SMOKE INFO: host forced a goal to exercise the GOAL_PAUSE transition")
await get_tree().create_timer(lifetime_seconds * 0.6).timeout
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():
var ship: Ship = match_scene.ships[0]
print("SMOKE INFO: host ship final position=%s action=%s (spawned, driven by client input if any arrived)" % [str(ship.global_position), str(ship.get_current_action_copy().thrust)])
@@ -48,7 +62,35 @@ func run_host_check(lifetime_seconds: float) -> void:
get_tree().quit(0 if success else 1)
func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball_contact: bool = false, exercise_free_flight: bool = false, warmup_seconds: float = 0.0, exercise_input_transitions: bool = false) -> void:
func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball_contact: bool = false, exercise_free_flight: bool = false, warmup_seconds: float = 0.0, exercise_input_transitions: bool = false, exercise_match_state: bool = false) -> void:
# Subscribed BEFORE the settle wait, not after: the server leaves LOADING
# and enters WARMUP as soon as _start_server() finishes, and PLAYING 90
# ticks later — both would already be history by the time a post-settle
# listener attached, and the test would silently observe nothing.
var observed_states: Array[int] = []
var observed_ticks: Array[int] = []
if exercise_match_state:
# change_scene_to_file is deferred, and so is this call — current_scene
# is still the smoke driver for the first few frames, so connecting
# immediately silently observes nothing at all (it did: empty list).
# Poll until the real scene exists, bounded so a genuine failure to
# load reports as an empty observation rather than hanging.
var deadline := Time.get_ticks_msec() + int(settle_seconds * 1000.0)
while Time.get_ticks_msec() < deadline and not _is_networked_match(get_tree().current_scene):
await get_tree().process_frame
var state_scene := get_tree().current_scene
if _is_networked_match(state_scene):
# Seed with whatever the client has already converged to. The
# server may legitimately have reached PLAYING before this client
# finished loading — that is the snapshot-byte catch-up path doing
# its job, not a missed transition.
observed_states.append(state_scene.match_state)
observed_ticks.append(state_scene.match_state_since_tick)
state_scene.match_state_changed.connect(func(s: int, at_tick: int) -> void:
observed_states.append(s)
observed_ticks.append(at_tick)
)
await get_tree().create_timer(settle_seconds).timeout
var match_scene := get_tree().current_scene
@@ -261,7 +303,57 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
and int(net_stats.get("ball_blend_complete_count", 0)) > 0 \
and int(net_stats.get("ball_blend_max_duration_ms", BALL_BLEND_ACCEPTANCE_MS)) <= BALL_BLEND_ACCEPTANCE_MS \
and proxy_motion_ok)
var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok
# §6.1 task 5.1: the client must FOLLOW the server's machine, not run its
# own. Assert three separable things — that transitions arrived at all,
# that every consecutive pair is legal per the shared table (so the client
# never lands somewhere the server could not have sent it), and that the
# specific documented sequence for this scenario was observed.
var match_state_ok := true
if exercise_match_state:
var names: Array[String] = []
for s in observed_states:
names.append(MatchState.to_name(s))
for i in observed_states.size() - 1:
if not MatchState.can_transition(observed_states[i], observed_states[i + 1]):
print("SMOKE FAIL: client observed an illegal transition %s -> %s" % [names[i], names[i + 1]])
match_state_ok = false
# Ticks are absolute and monotonic; a transition attributed to an
# earlier tick than its predecessor means the at_tick plumbing is wrong.
for i in observed_ticks.size() - 1:
if observed_ticks[i + 1] < observed_ticks[i]:
print("SMOKE FAIL: transition ticks went backwards: %s" % str(observed_ticks))
match_state_ok = false
var reached_playing := MatchState.State.PLAYING in observed_states
var saw_goal_pause := MatchState.State.GOAL_PAUSE 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):
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
# RPC and would pass identically with a dead byte — which is exactly
# how Phase 4's mislabelled prediction history survived every gate.
# The byte is the only channel a late joiner or a client that missed a
# transition has (§6.3), so assert it independently.
var wire_state := int(net_stats.get("snapshot_match_state", -1))
var live_state := int(net_stats.get("match_state", -1))
if wire_state != live_state or not MatchState.is_valid(wire_state):
print("SMOKE FAIL: snapshot match_state byte is %s but the client is in %s" % [
MatchState.to_name(wire_state), MatchState.to_name(live_state)
])
match_state_ok = false
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)" % [
"PASS" if match_state_ok else "FAIL", " -> ".join(names),
str(reached_playing), str(saw_goal_pause), str(resumed_after_goal), 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
print("SMOKE %s: client locally predicted %.2fm horizontal, local_prediction_ok=%s prediction_quality_ok=%s" % [
"PASS" if success else "FAIL", moved_horizontal, str(local_prediction_ok), str(prediction_quality_ok)
])