mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
fix(multiplayer): second adversarial review - Esc, stranded clients, clock
A second adversarial review (this one able to RUN things, unlike the first) reproduced five defects. Fixing the critical and high ones. CRITICAL - Esc no longer left a networked match, and a client whose server vanished was stranded forever. Two independent bugs composing: _unhandled_input (added for spectator target cycling) overrode GameMode._unhandled_input and returned early for every non-spectator without ever calling super(), silently killing ui_cancel -> main menu; and NetworkedMatch never connected NetworkManager.disconnected_from_ server the way lobby.gd does. Measured: a client whose host exited emitted 7,235 engine errors in ~18s and only left because a test timer fired. Now 1 benign teardown error, and it returns to the main menu. HIGH - the match clock lost up to 3 seconds of regulation per goal. _on_goal_registered extended end_tick by the celebration only (resume_tick - goal_tick) and never by the 180-tick kickoff countdown that follows it, while _update_clock derived remaining time from the current tick regardless of _clock_running - so regulation drained during every stoppage. Measured 660 PLAYING ticks for a 14s match against 840 expected: exactly one WARMUP lost. The HUD also opened at 0:17 for a 14s match because the initial arm folded WARMUP into end_tick. Replaced the per-goal arithmetic with bank-and-rebase: entering any non-live state banks the remaining ticks, leaving it rebases end_tick off the banked value. That covers celebration and countdown together and cannot drift, since nothing has to predict how long a stoppage will be. clock_state and match_bootstrap now carry remaining_ticks, which is authoritative whenever the clock is stopped. Verified with the reviewer's own metric: 840 PLAYING ticks for a 14s match, exactly. MEDIUM - clients never froze at FULL_TIME/RESULTS. The freeze handling sat inside `if multiplayer.is_server()`, so a local player flew around for the whole 8s results screen while every other peer saw their ship parked. Not fixed, and now demonstrated rather than merely suspected: - The 30s slot reservation is keyed on display NAME, so a stranger can take a departed player's ship and the real player is then locked out (reproduced). Worse than first thought: MatchNet.local_player_name defaults to "Player" and uniqueness is never enforced, so collisions are the common case, not an attack setup. Needs a real identity token; §6.2 step 1 reserves auth_ticket for Phase 7. - §6.3's "late joiner takes the slot at the next kickoff" is unimplemented - _is_spectator is assigned once and never revisited - while the server logs that it happened. - Replay log still ignores store_* return values, never records malformed/rejected inputs, and close() has no caller. - --role=host-disconnect grades the reconnecting client on ~1s of life before the host quits, and never asserts the client owns _my_slot. Regression: 87 unit tests; free-flight LAN; transition gate 0.00%; goal cycle; spectator; disconnect and reconnect; full match to RESULTS.
This commit is contained in:
+13
-10
@@ -29,8 +29,8 @@ signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.Stat
|
||||
# rotations is 4 floats per body (x, y, z, w).
|
||||
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
|
||||
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
|
||||
signal clock_state_received(running: bool, end_tick: int, at_tick: int)
|
||||
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int)
|
||||
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
|
||||
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
|
||||
|
||||
# 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-
|
||||
@@ -207,8 +207,11 @@ func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resu
|
||||
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
|
||||
|
||||
|
||||
func send_clock_state(running: bool, end_tick: int, at_tick: int) -> void:
|
||||
_clock_state.rpc(running, end_tick, at_tick)
|
||||
# remaining_ticks is authoritative while `running` is false: a stopped clock
|
||||
# cannot be derived from end_tick minus the current tick, or it drains through
|
||||
# every goal pause and kickoff countdown.
|
||||
func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||
_clock_state.rpc(running, end_tick, remaining_ticks, at_tick)
|
||||
|
||||
|
||||
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
|
||||
@@ -220,8 +223,8 @@ func send_clock_state(running: bool, end_tick: int, at_tick: int) -> void:
|
||||
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
|
||||
# exactly this set, so this is that message under a name that does not clash
|
||||
# with MatchNet's own lobby-level welcome.
|
||||
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int) -> void:
|
||||
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen)
|
||||
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
@@ -346,16 +349,16 @@ func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_t
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _clock_state(running: bool, end_tick: int, at_tick: int) -> void:
|
||||
clock_state_received.emit(running, end_tick, at_tick)
|
||||
func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||
clock_state_received.emit(running, end_tick, remaining_ticks, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int) -> void:
|
||||
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||
if not MatchState.is_valid(state):
|
||||
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
|
||||
return
|
||||
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen)
|
||||
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
|
||||
Reference in New Issue
Block a user