mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 04:22:04 +00:00
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user