Files
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00

89 lines
3.3 KiB
GDScript

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