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.
116 lines
5.5 KiB
GDScript
116 lines
5.5 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
# §6.1 match lifecycle state machine (task 5.1). The table is pure data, so
|
|
# it can be checked exhaustively rather than by example — which is the point
|
|
# of keeping it out of NetworkedMatch.
|
|
|
|
const MatchStateScript = preload("res://scripts/match_state.gd")
|
|
|
|
|
|
func test_wire_values_are_stable() -> void:
|
|
# These integers ARE the snapshot's match_state byte. Renumbering an
|
|
# existing state silently reinterprets every packet from an older peer,
|
|
# so pin them: this test failing means a protocol break, not a typo.
|
|
assert_eq(MatchStateScript.State.LOBBY, 0, "LOBBY")
|
|
assert_eq(MatchStateScript.State.LOADING, 1, "LOADING")
|
|
assert_eq(MatchStateScript.State.WARMUP, 2, "WARMUP")
|
|
assert_eq(MatchStateScript.State.PLAYING, 3, "PLAYING")
|
|
assert_eq(MatchStateScript.State.GOAL_PAUSE, 4, "GOAL_PAUSE")
|
|
assert_eq(MatchStateScript.State.FULL_TIME, 5, "FULL_TIME")
|
|
assert_eq(MatchStateScript.State.OVERTIME_WARMUP, 6, "OVERTIME_WARMUP")
|
|
assert_eq(MatchStateScript.State.OVERTIME, 7, "OVERTIME")
|
|
assert_eq(MatchStateScript.State.RESULTS, 8, "RESULTS")
|
|
|
|
|
|
func test_every_state_fits_in_the_wire_byte() -> void:
|
|
for value in MatchStateScript.State.values():
|
|
assert_true(value >= 0 and value <= 255, "state %d must fit a u8" % value)
|
|
|
|
|
|
func test_the_documented_happy_path_is_walkable() -> void:
|
|
# §6.1's own diagram, start to finish, including the goal loop.
|
|
var path := [
|
|
MatchStateScript.State.LOBBY, MatchStateScript.State.LOADING,
|
|
MatchStateScript.State.WARMUP, MatchStateScript.State.PLAYING,
|
|
MatchStateScript.State.GOAL_PAUSE, MatchStateScript.State.WARMUP,
|
|
MatchStateScript.State.PLAYING, MatchStateScript.State.FULL_TIME,
|
|
MatchStateScript.State.OVERTIME_WARMUP, MatchStateScript.State.OVERTIME,
|
|
MatchStateScript.State.RESULTS, MatchStateScript.State.LOBBY,
|
|
]
|
|
for i in path.size() - 1:
|
|
assert_true(
|
|
MatchStateScript.can_transition(path[i], path[i + 1]),
|
|
"%s -> %s must be legal" % [MatchStateScript.to_name(path[i]), MatchStateScript.to_name(path[i + 1])]
|
|
)
|
|
|
|
|
|
func test_illegal_shortcuts_are_rejected() -> void:
|
|
var illegal := [
|
|
[MatchStateScript.State.LOBBY, MatchStateScript.State.PLAYING], # must load first
|
|
[MatchStateScript.State.LOADING, MatchStateScript.State.PLAYING], # must warm up first
|
|
[MatchStateScript.State.WARMUP, MatchStateScript.State.GOAL_PAUSE], # cannot score before play
|
|
[MatchStateScript.State.PLAYING, MatchStateScript.State.RESULTS], # must pass full time
|
|
[MatchStateScript.State.RESULTS, MatchStateScript.State.PLAYING], # match is over
|
|
[MatchStateScript.State.FULL_TIME, MatchStateScript.State.PLAYING], # regulation cannot resume
|
|
]
|
|
for pair in illegal:
|
|
assert_true(
|
|
not MatchStateScript.can_transition(pair[0], pair[1]),
|
|
"%s -> %s must be rejected" % [MatchStateScript.to_name(pair[0]), MatchStateScript.to_name(pair[1])]
|
|
)
|
|
|
|
|
|
func test_abort_to_lobby_is_reachable_from_anywhere_but_lobby() -> void:
|
|
# §6.4: "if the last human leaves, abort to LOBBY" can fire at any point.
|
|
for state in MatchStateScript.State.values():
|
|
if state == MatchStateScript.State.LOBBY:
|
|
assert_true(not MatchStateScript.can_transition(state, state), "LOBBY -> LOBBY is not a transition")
|
|
continue
|
|
assert_true(
|
|
MatchStateScript.can_transition(state, MatchStateScript.State.LOBBY),
|
|
"%s must be able to abort to LOBBY" % MatchStateScript.to_name(state)
|
|
)
|
|
|
|
|
|
func test_no_state_transitions_to_itself() -> void:
|
|
for state in MatchStateScript.State.values():
|
|
assert_true(not MatchStateScript.can_transition(state, state), "%s -> itself" % MatchStateScript.to_name(state))
|
|
|
|
|
|
func test_every_state_is_reachable_and_can_make_progress() -> void:
|
|
# Guards against a state being added to the enum and forgotten in the
|
|
# table — an orphan would be broadcastable but a dead end, or unreachable
|
|
# but present on the wire.
|
|
for state in MatchStateScript.State.values():
|
|
var has_exit := false
|
|
var has_entry := false
|
|
for other in MatchStateScript.State.values():
|
|
if other != state and MatchStateScript.can_transition(state, other):
|
|
has_exit = true
|
|
if other != state and MatchStateScript.can_transition(other, state):
|
|
has_entry = true
|
|
assert_true(has_exit, "%s has no legal exit" % MatchStateScript.to_name(state))
|
|
assert_true(has_entry, "%s is unreachable" % MatchStateScript.to_name(state))
|
|
|
|
|
|
func test_only_playing_and_overtime_are_live() -> void:
|
|
# is_live() gates simulation in tasks 5.3/5.4; a warmup or a goal pause
|
|
# must never read as live.
|
|
assert_true(MatchStateScript.is_live(MatchStateScript.State.PLAYING), "PLAYING is live")
|
|
assert_true(MatchStateScript.is_live(MatchStateScript.State.OVERTIME), "OVERTIME is live")
|
|
for state in [
|
|
MatchStateScript.State.LOBBY, MatchStateScript.State.LOADING, MatchStateScript.State.WARMUP,
|
|
MatchStateScript.State.GOAL_PAUSE, MatchStateScript.State.FULL_TIME,
|
|
MatchStateScript.State.OVERTIME_WARMUP, MatchStateScript.State.RESULTS,
|
|
]:
|
|
assert_true(not MatchStateScript.is_live(state), "%s must not be live" % MatchStateScript.to_name(state))
|
|
|
|
|
|
func test_unknown_values_are_rejected_rather_than_coerced() -> void:
|
|
# A newer server can legitimately send a state this build has never heard
|
|
# of; it must be refused, not clamped into a neighbouring valid state.
|
|
for bogus in [-1, 9, 42, 255]:
|
|
assert_true(not MatchStateScript.is_valid(bogus), "%d is not a valid state" % bogus)
|
|
assert_true(not MatchStateScript.can_transition(MatchStateScript.State.PLAYING, bogus), "cannot enter %d" % bogus)
|
|
assert_true(not MatchStateScript.can_transition(bogus, MatchStateScript.State.PLAYING), "cannot leave %d" % bogus)
|