mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
9f28c02488
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.
89 lines
3.3 KiB
GDScript
89 lines
3.3 KiB
GDScript
class_name MatchState
|
|
|
|
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
|
|
#
|
|
# Pure data + a transition table, deliberately with no scene, RPC or
|
|
# NetworkedMatch dependency — same reason net_codec.gd and
|
|
# input_jitter_buffer.gd are standalone: the table can then be exhaustively
|
|
# unit-tested without a live match.
|
|
#
|
|
# The integer values ARE the wire format. `match_state` has been a u8 in the
|
|
# snapshot header since §2.4 (net_codec.gd's pack_snapshot_body_segment), so
|
|
# these numbers are protocol, not an implementation detail: never renumber an
|
|
# existing state, only append. LOBBY is 0 so a zeroed/placeholder snapshot
|
|
# body decodes to a state that is obviously "not in a match" rather than to
|
|
# something mid-play.
|
|
|
|
enum State {
|
|
LOBBY = 0,
|
|
LOADING = 1,
|
|
WARMUP = 2,
|
|
PLAYING = 3,
|
|
GOAL_PAUSE = 4,
|
|
FULL_TIME = 5,
|
|
OVERTIME_WARMUP = 6,
|
|
OVERTIME = 7,
|
|
RESULTS = 8,
|
|
}
|
|
|
|
# Legal successors, straight from §6.1's diagram. Enforced rather than
|
|
# documented: an illegal transition is a server logic bug, and the failure it
|
|
# otherwise produces (clients following the server into a state its own code
|
|
# never expected to broadcast) is exactly the kind that shows up as an
|
|
# unreproducible field report three phases later.
|
|
#
|
|
# LOBBY is reachable from ANY state and is handled separately in
|
|
# can_transition() rather than being listed nine times — §6.4's "if the last
|
|
# human leaves, abort to LOBBY" can fire at any point, including mid-goal.
|
|
const _SUCCESSORS := {
|
|
State.LOBBY: [State.LOADING],
|
|
State.LOADING: [State.WARMUP],
|
|
State.WARMUP: [State.PLAYING],
|
|
# A goal, or the clock running out. FULL_TIME is entered on the clock even
|
|
# if a goal is in flight — §6.2 step 9's clock is authoritative.
|
|
State.PLAYING: [State.GOAL_PAUSE, State.FULL_TIME],
|
|
# Back to a kickoff, or straight to results when the goal that caused the
|
|
# pause also ended the match (golden goal in overtime, or a goal on the
|
|
# final tick).
|
|
State.GOAL_PAUSE: [State.WARMUP, State.OVERTIME_WARMUP, State.RESULTS],
|
|
State.FULL_TIME: [State.OVERTIME_WARMUP, State.RESULTS],
|
|
State.OVERTIME_WARMUP: [State.OVERTIME],
|
|
State.OVERTIME: [State.GOAL_PAUSE, State.RESULTS],
|
|
State.RESULTS: [State.LOBBY],
|
|
}
|
|
|
|
# States in which the simulation is live and inputs drive ships. Everything
|
|
# else freezes bodies (§6.2 steps 6 and 8). Kept as a set here rather than as
|
|
# an `if state == PLAYING or state == OVERTIME` scattered through
|
|
# NetworkedMatch, so adding a future live state can't miss a site.
|
|
const _LIVE := [State.PLAYING, State.OVERTIME]
|
|
|
|
|
|
static func is_valid(state: int) -> bool:
|
|
return state in State.values()
|
|
|
|
|
|
static func is_live(state: int) -> bool:
|
|
return state in _LIVE
|
|
|
|
|
|
# True when the match is over and the clock should not advance. Distinct from
|
|
# `not is_live()`: a WARMUP is not live but the match is very much ongoing.
|
|
static func is_terminal(state: int) -> bool:
|
|
return state == State.RESULTS or state == State.LOBBY
|
|
|
|
|
|
static func can_transition(from_state: int, to_state: int) -> bool:
|
|
if not is_valid(from_state) or not is_valid(to_state):
|
|
return false
|
|
if to_state == State.LOBBY:
|
|
return from_state != State.LOBBY # §6.4 abort, from anywhere
|
|
return to_state in _SUCCESSORS.get(from_state, [])
|
|
|
|
|
|
static func to_name(state: int) -> String:
|
|
for key in State.keys():
|
|
if State[key] == state:
|
|
return key
|
|
return "UNKNOWN(%d)" % state
|