mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04: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:
@@ -23,6 +23,7 @@ signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, tea
|
||||
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
|
||||
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
|
||||
|
||||
# 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-
|
||||
@@ -164,6 +165,18 @@ func send_score_update(score: Dictionary) -> void:
|
||||
_score_update.rpc(score)
|
||||
|
||||
|
||||
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
|
||||
# transition happened on rather than a duration — §6.2's closing note: on a
|
||||
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
|
||||
# duration would then be applied from whenever it happened to arrive.
|
||||
# The same state also rides every snapshot's match_state byte, so a client
|
||||
# that misses this entirely still converges (see NetworkedMatch's own
|
||||
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
|
||||
# to carry `at_tick`, not to be the sole channel.
|
||||
func send_state_change(state: int, at_tick: int) -> void:
|
||||
_state_change.rpc(state, 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)
|
||||
@@ -250,6 +263,19 @@ func _snapshot(bytes: PackedByteArray) -> void:
|
||||
snapshot_received.emit(decoded)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _state_change(state: int, at_tick: int) -> void:
|
||||
# "authority" already means a forging client is rejected by Godot itself
|
||||
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
|
||||
# an authoritative server sending a state this build doesn't know about is
|
||||
# a real forward-compatibility case — drop it rather than driving the
|
||||
# client into an undefined state.
|
||||
if not MatchState.is_valid(state):
|
||||
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
|
||||
return
|
||||
state_change_received.emit(state, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _score_update(score: Dictionary) -> void:
|
||||
score_update_received.emit(score)
|
||||
|
||||
Reference in New Issue
Block a user