class_name MatchState # Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md 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