mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
00b900d864a9ea43bbf083bdaa5cf261be8cea4d
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aac00f148e |
fix(tests): stop the ENet residual gate failing on host scheduling noise
The client-bot smoke asserted remote_residual_position_p99 < 0.3. Commit
|
||
|
|
46fe696a58 |
fix(tests): measure cumulative travel, not displacement, in the ENet host check
run_ci_host_check asserted input reached the server by comparing each bot ship's position against one recorded before the check forced a goal. But a goal's kickoff teleports every ship back to spawn (_begin_kickoff -> reset_ships), so that comparison measured only the distance covered since the last reset — a window whose length depends on when the sample lands relative to the kickoff rather than on whether input was flowing at all. It failed on master with peers at 0.51m and 0.23m against a 0.5m threshold: one passed by a centimetre, the other failed, with both connected, neither stalled, and every other assertion in the run green. The commit it failed on touches only training JSON, and the push two minutes earlier passed on identical game code. Accumulate per-tick path length in _await_recording_score instead, discarding any single-frame step over 2.0m as a teleport — Ship.max_speed (35 m/s) is hard-clamped each tick in _integrate_forces, so 60Hz caps legitimate travel at ~0.58m. Same 0.5m threshold now reads 28-75m across runs, and it is strictly stronger than before: it asserts input kept arriving for the whole wait rather than that the ship merely ended up somewhere else. |
||
|
|
24d6a547d6 | fix(ci): isolate imported client test image | ||
|
|
ff725e1ffa |
feat(multiplayer): §6.3 late joiners take a vacated slot at the next kickoff
"Spectate now, take the slot at the next kickoff" was a print statement. The server logged it and never acted; on the client, _is_spectator was assigned once in _on_match_config_received and never revisited - and that handler returns early whenever _slots is non-empty, so no rebroadcast could promote an in-match spectator. The reconnect path only worked because a returning player is a fresh process. Server: late joiners are queued in arrival order and the queue is drained from _begin_kickoff, before the reset transforms are read, so a promoted player's ship is placed by that same kickoff and the controller swap lands on an already-frozen body. A slot is available only once its player has gone AND their 30s reservation has lapsed - §6.4 outranks §6.3, since taking a reserved slot would quietly break the reconnect promise. _abort_if_abandoned now counts a waiting spectator as somebody present, or the one person queued for the slot that just opened is dumped to the lobby at the moment they were about to get it. Client: new broadcast slot_assigned (reliable, channel 0). Broadcast because every client holds its own slot list and one naming the wrong peer keeps flying somebody else's ship as a remote body; reliable because no per-snapshot field would re-converge a client that missed it. The promoted client undoes what made the body remote - fresh interpolator, physics interpolation back on, offsets cleared - and deliberately does not unfreeze, clearing _local_prediction_ready so the next snapshot teleports it to a real authoritative pose first. The controller-attach block moved to _take_local_ownership rather than being copied. New --role=host-latejoin/--role=client-latejoin and --slot-reservation-seconds=. Verified 4/4 both sides: queued, NOT promoted merely because the reservation lapsed, takes the slot at the kickoff, same ship instance, and both peers independently measure ~45.7m under its input. Control with a 90s reservation: kickoff fires, nothing is promoted, the slot still reads the departed player's name. |
||
|
|
5714829c13 |
test(multiplayer): grade §6.4's reconnect from the returning player's side
The disconnect scenario only ever asserted the server's bookkeeping, and the client's half was failing every run. run_disconnect_host_check ticked 60 physics frames past the reclaim and then shut the server down, so the reconnecting client - whose wiring check waits a 2.0s settle before it looks at anything - had its peer torn out from under it and reported "current_scene is not NetworkedMatch after 2.0s". The host printed PASS throughout, and the host was the side anyone read. The hold is now a real window (8s), and the host also asserts that the reconnected player's input reaches the server and moves the ship the server owns - every other assertion there is slot bookkeeping that would hold identically for a client whose input pipeline came back dead. Both position and connection state are sampled while the peer is still connected: the client leaves on its own schedule, and an end-of-hold sample reported still_connected=false for a good run. New --role=client-reconnect asserts the returning player is not a spectator, owns a slot with its own peer_id, has a real ship, rejoined a live match with the clock already known (§6.2 step 2's bootstrap), and can still drive. That set is chosen because a stale _last_match_config once made a reconnecting player a spectator, and that bug was visible in this scenario's own logs while it reported PASS. Verified 3/3 both sides. Control: rejoining while the slot is still occupied fails on is_player=false - and since the first control run reported it as the generic "lost its ship mid-drive", the spectator case is now diagnosed before the drive rather than after. |
||
|
|
866efa0d9b |
fix(multiplayer): server no longer rate-limits a backlog it caused itself
Closes task 5.10's three recording gaps, and the gap-closing found a real input-loss bug. Replay log: a failed write now ends the log permanently instead of desyncing every later record's framing; close() is called from _exit_tree with a summary, since the RefCounted destructor closes it implicitly but never says whether the log is complete; rejected packets are recorded with their reason in the kind byte (framing unchanged, FORMAT_VERSION 2 so "no rejects" differs from "this build never recorded them"). Recording is capped at 8 per peer per window - uncapped, the diagnostic is a remote disk-fill amplifier, since the attacker picks the packet rate. Uncapped totals live on MatchSim and survive the peer's disconnect. The bug: a 2s host stall has the client sending at 60Hz throughout, and ENet delivers that whole backlog in the first window after resume - 70 of an honest client's packets rejected as "rate limit exceeded". Redundancy does not cover it, because the dropped packets are contiguous: 0 of 70 rescued, and 82 of 923 sequences (8.88%, ~1.4s of input) never reached the server, against 0.00% with no stall. Every prediction gate passed. Fixed by granting each already-tracked peer a capped, two-window packet grace when the server detects its own wall-clock stall. Rate-limit rejects 70 -> 0, sequences missing 8.88% -> 0.00%, seq-guard rejects 9 -> 0. Controls on the unfixed build lost 4.34/7.52/7.86%. All three abuse roles still disconnect and no flood induced a stall, so the grace cannot be farmed. Also corrects an earlier wrong conclusion: the reviewer's free-flight p95 0.688 is real and reproduces on two processes with 0.0% snapshot loss. The plain --role=client drive fails the 0.5 free-flight bound in 3 of 8 runs because that drive is mostly a contact test - the harness comment already said so - leaving a cohort as small as 12 samples. Near-surface error is genuinely several times open-air error, so the calibrated bound now belongs to --exercise-free-flight alone and the plain role asserts the always-well-sampled all-cohort percentiles at 1.2/2.0, printing the free-flight numbers as reported-not-asserted. 6/6 plain runs pass where 3/7 failed; tightening to 0.3 still fails. tools/replay_dump.gd reads a log back: counts by kind, plus how much of the input sequence stream reached the server once redundancy is counted. |
||
|
|
e51dc765a2 |
test(multiplayer): report transport health on prediction-quality failures
A percentile alone cannot tell "the predictor regressed" from "the client never received the data". The client gate now prints snapshot_loss / snapshot_age / rtt on every run, and on a quality failure with >20% loss says explicitly that the run was transport-starved. It deliberately does not convert the failure into a pass: a client that cannot receive snapshots is still a failed run, just a differently-diagnosed one. Both directions of the new branch verified non-vacuously (forced true so it fires and formats; restored so it stays quiet on a healthy run while the INFO line still prints). Records the investigation behind it in multiplayer-todo.md: the reviewer's 3-process p95 0.688 did not reproduce. An idle third process costs nothing (p99 0.094), a spectator costs a small but real amount (p99 0.094-0.146), and snapshot loss held at 0.0% even under 2x CPU oversubscription - all an order of magnitude inside the 0.5/2.0 gates. Also notes that a previously working class_name can silently drop out of the .godot class cache, which surfaces as a bogus parse error with nothing in git status to explain it. |
||
|
|
7a1668c902 |
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. |
||
|
|
b5e9dff33c |
fix(multiplayer): Phase 5 adversarial review fixes - reconnect, spectators
An adversarial review found five real defects in the Phase 5 lifecycle
work. Two were critical and both were verified against controls.
CRITICAL - a reconnecting client silently became a spectator.
_try_reclaim_slot() swapped slot.peer_id, but MatchSim caches the last
match_config and replays THAT to whoever asks. A reconnecting client in
a fresh process requested config, received the pre-disconnect peer-id
array, could not find itself, left _my_slot null and fell through to the
spectator path - no ship, no input, for the rest of the match. The
evidence was already in my own disconnect-test logs ("no slot for this
peer - spectating", my_slot_ok=false) and I dismissed it: the host-side
check only asserted the SERVER reclaimed the slot, never that the
returning client owned it. Config is now rebroadcast on reclaim.
Verified: my_slot_ok=false -> true.
CRITICAL - spectators received no snapshots at all. §6.3 says a
spectator "receives identical snapshots (the snapshot is already a
broadcast - zero extra server work)". That was only ever true of the
body SEGMENT: _broadcast_snapshot unicasts one packet per SLOT, so a
peer without a slot got nothing - no poses, no reset_gen, no
match_state byte. Spectating was entirely non-functional. The segment is
still shared, so this is one extra send per spectator. Verified against
a control: 0 snapshots and state stuck at LOADING before, 361 snapshots
and PLAYING after.
HIGH - cycling the spectator camera to the ball was a type error.
ShipCameraRig.target is declared `var target: Ship` and the rig reaches
into ship-only API, so it would have fired the moment anyone cycled past
the last ship. Cycling is ships-only; the rig already has its own
ball-cam mode for watching the ball.
MEDIUM - clients never received match_ended or overtime_started. Both
emitted only inside server-side logic, so a client froze and returned to
the lobby without a result and its timer never switched to overtime.
Derived from replicated state instead of adding two more RPCs: the
client already has the authoritative score, and the transition is the
event.
MEDIUM - the goal cinematic ignored its authoritative window. goal_tick
and resume_tick arrived and were unused; the client started a fresh
fixed-length timer on RPC receipt, so a reliable retransmit could run
the celebration past the server's window and into the next kickoff.
_goal_pause_seconds() now returns the time actually remaining, clamped
so an elapsed window cannot produce a non-positive timer.
Also added: a match_bootstrap RPC carrying state, score, clock and
reset_gen to one peer. match_config alone carries arena and roster only,
so a late joiner or reconnecting player had no score or clock until the
next goal happened to fire. It is sent on join AND on every
request_match_config retry - the join-time send has exactly the same
race match_config already had (the server sends it before the peer has
loaded the match scene and connected its listeners), which the control
run exposed: state was reaching PLAYING via the snapshot byte, not the
bootstrap.
New test: --role=client-spectator asserts a slotless peer receives the
snapshot stream, follows the lifecycle, agrees with the wire byte, and
can cycle targets without ever handing the camera a non-Ship. Verified
non-vacuous. The ball-contact steering now closes all the way to 1.2m
instead of coasting from 3m, which was missing the ball outright in
roughly 1 run in 4.
Not fixed, and still open: the 30s slot reservation is keyed on the
player's display name, so any peer can claim a departed player's ship by
choosing their name. §6.2 step 1 reserves auth_ticket for Phase 7; this
needs a real identity token, not a name.
Regression: 87 unit tests; free-flight LAN; transition gate 0.00%; ball
contact 4/4; goal cycle; full match to RESULTS/LOBBY; disconnect and
reconnect; spectator; two-bot CI.
|
||
|
|
a5cbc977b5 |
feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log
Completes Phase 5's implementation. Every task is verified at 1v1; the 3v3 phase gate itself has not been run and remains outstanding. 5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and swaps the controller (--fill-bots gives it a bot, the default leaves it inert per §1.4), sets `stalled` immediately so the nameplate greys out rather than waiting ~500ms for the abandoned jitter buffer to starve, and reserves the slot for 30s keyed by player name so a reconnect gets the same ship back. 5.7 was a real bug, found by the test rather than by review: SlotInfo.controller was declared RLShipController, but the takeover swaps in an AIShipController or the base controller - the narrower type makes that assignment fail its type check, leaving the field pointing at the controller set_controller() just queue_free()d. It surfaced as controller_valid=false on the first run. The per-tick action write is now also gated on `is RLShipController`, since a disconnected slot's bot drives itself and overwriting it from a starving buffer would pin it to the departed player's last input. §6.4's two rules conflict: reserve for 30s, but abort when the last human leaves. Applied naively the abort wins instantly in a 1v1 and the reservation can never be redeemed, making reconnect unreachable exactly when it matters. Abort now waits for no connections AND no outstanding reservations. 5.8 spectators: a slotless peer spawns no ship and receives the same snapshot broadcast. HUDController.spectator_mode keeps the clock, score and goal celebration and hides only the ship instrument cluster - it previously push_error'd and bailed, leaving a spectator with a dead HUD. Camera cycles ships in slot order then the ball. --max-spectators caps it, counted from the live peer list so a dropped spectator cannot leak a unit of the cap. 5.9 escape respawn: new GameMode._on_bodies_respawned() virtual; NetworkedMatch bumps reset_gen through Phase 2's deferred path so the bump and the respawned pose land in the same broadcast. Single-player modes are unaffected - the base is a no-op. 5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the wire bytes verbatim in both directions rather than re-serialising - a re-encode would launder away precisely the malformed payload being chased. A live 6s match recorded 1115 records (557 inputs / 558 snapshots) and a stored snapshot decodes back to server_tick=100 match_state=WARMUP bodies=2. Note for future work: --check-only --script is the only thing that catches a parse error in networked_match.gd, because the unit runner never loads it. Two separate breakages passed the full unit suite while breaking every two-process run. A new class_name also needs --import before it resolves. Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario), --match-length=<s>, --replay-log, --fill-bots/--no-fill-bots, --max-spectators. The ball-contact scenario now steers at the ball with closed-loop real input instead of a hand-tuned fixed heading, which 5.3 broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from 2/3 to 5/5. Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps; transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI. |
||
|
|
3d6906b981 |
feat(multiplayer): Phase 5 tasks 5.2-5.5 - clock, kickoff, goals, full time
Implements the rest of the §6.2 lifecycle on top of 5.1's state machine.
5.3 kickoff: the server resets every body and broadcasts the RESULTING
transforms, never a seed - §1's locked decision, because shared-seed
determinism needs both sides to consume the RNG stream in identical
order forever and the first randf() added to the reset path desyncs
silently. Countdown is derived from server_tick on both peers, and a
kickoff that lands after its own resume tick applies immediately and
skips the countdown rather than scheduling into the past.
5.4 goals: goal_scored(scoring_team, score, goal_tick, resume_tick).
Score is authoritative at sensor time, before any presentation. The
reset moved OUT of the sensor path and into the kickoff at resume_tick,
which is what stops the server resetting while clients are still
mid-celebration. Engine.time_scale is never touched.
5.2 clock: tick-derived, no Timer and no _process polling. The goal
pause shifts the absolute end_tick by (resume_tick - goal_tick) rather
than pausing anything, so no float drift accumulates across goals.
5.5 full time: clock expiry -> FULL_TIME -> sudden death on a draw or
RESULTS, golden goal in overtime, then LOBBY on both peers - clients
return to the lobby, not the main menu. get_tree().paused is never used.
Four bugs found and fixed while building this, each by a failing run
rather than by inspection:
- Tick order was load-bearing: _update_kickoff_countdown() clears the
same _kickoff_resume_tick that _update_match_state() reads to leave
WARMUP, so running the countdown first wiped the transition condition
and the match sat frozen in WARMUP forever.
- _apply_match_state resets _state_deadline_tick on every transition, so
a GOAL_PAUSE deadline assigned before _set_match_state was wiped and
the match never resumed. Deadlines are now owned by _apply_match_state.
- Freezing "all bodies" is wrong on a client. Remote ships and the ball
are permanently FREEZE_MODE_KINEMATIC and transform-driven; freezing
them all unfroze the remote ones on the way back out, so they fell
under gravity while the interpolator fought them - 210 hard snaps and
an infinite p99. A client now freezes only the one body it simulates.
- A frozen body never runs _integrate_forces, so the queued kickoff
teleport was stranded by an immediate set_deferred("freeze", true).
Freeze now happens on a strictly later tick, the same pattern Phase 2
used for _pending_reset_gen_bump_tick.
Prediction and reconciliation are suspended while the match is not live:
during a countdown or goal pause the local ship is frozen on both peers,
and running delta transport over those frozen states produced a p95
position error of 2.4e10 m. Input keeps flowing so the server's jitter
buffer does not starve into `stalled`.
Also fixed: a kickoff can arrive before match_config, and body order is
slot order - applying it early placed the BALL at positions[0], on top
of the first ship, which the ball-cam reported as "target vector can't
be zero" 95 times. It is now held until the roster exists.
Test changes: the ball-contact scenario steered by a hand-tuned fixed
heading, which 5.3 broke because kickoff applies KICKOFF_YAW_JITTER - it
flew past the ball in 3/3 runs. It now closes the loop on the actual
bearing using real input actions. Assertions that read a frozen ship
(freeze, thrust) are gated on the match being live, and the hooks now
survive the scene teardown at RESULTS instead of hanging on freed
objects for the full timeout.
Regression: 81 unit tests; free-flight LAN p99 0.143m and 80±20ms, both
0 hard snaps; transition gate 0.00%; ball contact 3/3; two-bot CI.
|
||
|
|
9f28c02488 |
feat(multiplayer): Phase 5 task 5.1 - match lifecycle state machine
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. |
||
|
|
75f485667b |
feat(multiplayer): Phase 4 prediction correctness + two input-death fixes
Closes Phase 4's outstanding action-sequence-correctness invariant, then fixes two server-side bugs an adversarial review of that work uncovered. Server simulation, bot observations, collision resources and tick rate are unchanged: the server_physics_parity trace is byte-for-byte identical to HEAD across 360 ticks including both ships' full observation vectors. 4.11 - prediction history filed under the ISSUING sequence _send_local_input filed each post-step predicted state under the timeline's estimate of the sequence the server would consume this tick, trailing issuance by input_lead. The body had integrated the intent issued under _input_seq, so predicted[S] held "state after the intent from now" while the server's authority for S is "state after action(S)". They agree only while the stick is still. Filing under _input_seq costs nothing: which action the ship uses is decided in LocalNetShipController.get_action() and is untouched. Every prior Phase 4 gate held its input steady, and a steady input cannot falsify a sequence label - the 60s runs honestly reported marker=0/3784. New --exercise-input-transitions role toggles thrust every 6 ticks; it is the only gate that can catch a label regression. Verified non-vacuous: the old label fails it at 50%. 4.12 - issued-but-unsimulated sequences, and the release path An attack (delta > 1) issues and sends several sequences for one local physics step. Those gap sequences had no recorded prediction, so a server ack of one reported missing_not_recorded - indistinguishable from ring loss, costing a teleport and resync suppression several times a minute. They are now recorded stateless via record_unsimulated() and answered with a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0. A release (delta == 0) re-recorded at the unchanged _input_seq, filing the current intent under a sequence that went out carrying a different action; LocalInputTimeline deliberately refuses to mutate an issued sequence, so the ring contradicted the wire. Recording is now skipped on release ticks. 4.13 - two Phase 3 bugs silently killing player input (a) InputJitterBuffer.consume() advanced last_applied_seq on every tick including a starve. Since ingest() discards seq <= last_applied_seq, one starve on a sequence the client had not sent yet stranded the stream one ahead of arrivals permanently - both sides advancing in lockstep, every honest packet discarded on arrival. The client's own input_lead release is enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a clean LAN. Now only gives up on a sequence once strictly newer data proves it lost. Silent-client stall and ring-overflow resync are unchanged. (b) The seq-range guard bounded incoming seq against highest_ingested_seq, which only advances inside ingest(), which that guard gates. After a ~2s host hitch every packet was rejected forever with no diagnostic (600+ consecutive rejections reproduced via SIGSTOP). Third iteration of this guard; each previous version bounded against a value only the accepted path could advance. Adds an escape after 10 consecutive rejections, which grants an attacker nothing the rate limiter does not already bound. (c) The transitions gate reported PASS at 3.76% while input was completely dead, because suppression stops _record_metrics - a worse outage yields fewer samples and a LOWER rate. Now scales the required sample count with run length and asserts the wire's server_stalled bit. Reverting both fixes makes it fail at samples 292/600, server_stalled=true, input_lead=12. Fixing (a) also explained a residual the review had already traced: 151 of 151 action-marker mismatches were the server repeating a stale action on a starve, not a prediction defect. Marker is now 0.00% in all three conditions (was 1.7-2.5%), and free-flight p99 improved to 0.141/0.168/0.154m from 0.170/0.176/0.184m. Two pre-existing test defects fixed alongside: the ball gate asserted RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across a 3-5s window (now polls the scores the server actually held; note score_changed is emitted only on the client path). QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5; two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes. Phase 4 sign-off still pending a human playtest at ~100ms RTT - the milestone asks how it feels, which no gate here answers. |
||
|
|
cf73074e27 |
fix(multiplayer): resolve composition regression from second adversarial review
A second adversarial review of the previous fix commit found two of its nine fixes silently defeated each other: the seq-range guard (fix for a MEDIUM epoch-mismatch finding) capped the exact variable the ring-overflow resync (fix for the original CRITICAL finding) depends on, making the resync unreachable in production and recreating permanent input death at a lower failure threshold, reachable via ordinary server tick loss alone. - CRITICAL: rebind the seq-range guard to InputJitterBuffer's own highest_ingested_seq (now public) instead of the consumer-side last_applied_seq, so it tracks the client's send epoch rather than a value that can lag arbitrarily far behind during a stall. - HIGH: InputLeadController's release logic still ANDed the old `lead > LEAD_MIN` gate onto the new depth-driven condition, so a backlog the controller never caused still couldn't drain. Split into two independent decisions: the seq-duplicate action follows real depth alone; lead's own bookkeeping separately never drops below its floor. - MEDIUM: widen the CI driver's movement/stalled sampling margin (run_seconds - 2.0, was - 0.5) and assert the peer is still in multiplayer.get_peers() at sample time, since the old margin let the check pass on residual starvation grace after a bot had already disconnected. - LOW: measure horizontal-only displacement in the human smoke test's movement check — the old 3D-distance bar was beatable by pure gravity settling with fully dead input. - LOW: fix a real "clean stderr" violation (match_net.gd broadcasting a departure notice to a peer whose ENet channels are already torn down, including a second peer disconnecting in the same poll batch) by deferring the notification to the next idle frame. - Wire the server's per-slot stalled bit into the client debug overlay for real — a prior commit message claimed this already reached the overlay when only the CI gate actually read it. Re-verified end-to-end against the real production RPC path (not just unit tests in isolation, which is how the composition bug got past the first round): a 2-bot CI match with a 1.5s host SIGSTOP freeze injected mid-run, well past the 0.6s threshold the review reproduced the bug at, now recovers cleanly on repeated runs with zero stderr noise. |
||
|
|
2325313ad2 |
fix(multiplayer): adversarial review fixes for Phase 3
An Opus subagent's adversarial review of Phase 3 found a critical, silent, permanent bug plus eight smaller real issues, all empirically verified with real two- and three-process runs: CRITICAL: InputJitterBuffer's 32-entry ring permanently bricked a player's input once the un-consumed backlog exceeded the ring's capacity - a fresh arrival would land in the exact slot consume() was still waiting on, and since both counters only ever advance, the gap never closed. Reproduced with a real SIGSTOP/SIGCONT host freeze: client movement dropped from ~26m to 0.00m at ~0.7s, worse under real loss (a lossy link lowered the fatal threshold to ~400ms), and reachable via ordinary clock drift with no external trigger at all. Fixed by tracking the highest seq ever ingested and having consume() jump directly to what the ring can still provide once the gap exceeds capacity, instead of starving through an unrecoverable span. Re-verified with a 3s freeze (well past the original threshold): full recovery. HIGH: InputLeadController's release logic was gated on its own past attacks (lead > LEAD_MIN) rather than the real server-reported depth, so a backlog it didn't itself cause was never drained. Fixed to gate on actual depth vs target. MEDIUM-HIGH: the rate limiter's "N consecutive over-budget seconds" streak hard-reset to 0 on any clean window, letting a duty-cycled flood (burst, one clean window, repeat) sustain ~33x budget indefinitely with zero warnings. Replaced with a leaky-bucket accumulator immune to the same evasion by construction. MEDIUM: the seq > server_tick + 20 guard compared two unrelated clock epochs (server process uptime vs. client's own from-zero seq numbering), so it never actually protected anything on a long-running server and could silently drop an honest client's input forever. Bound against the buffer's own last_applied_seq instead. MEDIUM: InputJitterBuffer.stalled was computed but never reached the wire - the one signal that would have made the ring-overflow bug visible anywhere. Now wired through _ship_to_net_body_state. MEDIUM: task 3.6's CI driver's assertions didn't depend on client input reaching the server at all, so it kept passing with the ring-overflow bug actively triggered. Added real ship-movement and non-stalled checks, sampled while bots are still connected (an initial attempt sampled after their own legitimate disconnect, which starves identically to the bug). LOW-MEDIUM: a lead change silently mislabelled _input_history's older entries, since the wire format has no per-entry seq field. Fixed by handling each delta case (ordinary/release/attack) on its own terms. LOW: bandwidth and snapshot-loss overlay metrics froze at their last value during a total outage instead of decaying - exactly when they matter most. Both now report honest post-outage values. LOW: a guard comment on NetworkManager._ping misdescribed the actual disconnect_peer() arguments in use. Corrected. New permanent regression tests: test_ring_overflow_resyncs_to_fresh_data _instead_of_starving_forever, test_release_drains_a_backlog_it_never_ caused_itself, and client-abuse-flood-dutycycle (reproduces the exact duty-cycle evasion). Full regression suite, including the net-sim-latency milestone gate, all abuse roles, and the CI driver, re-run clean after every fix. |
||
|
|
caa9f44ab6 |
feat(multiplayer): Phase 3 task 3.6 - --test-bot client mode + CI driver
networked_match.gd's client can now swap its input sampler for a real AIShipController (--test-bot, optionally --test-bot-model=<path>, defaulting to bots/promoted/medium.json) instead of PlayerShipController. Unlike the human sampler, AIShipController needs real scene context (get_parent() as Ship, plus ball/teammate/opponent discovery via groups), so it's parented onto the client's own ship via Ship.set_controller() rather than left floating - and the field's static type widened from PlayerShipController to the shared ShipController base to allow either. Known, documented limitation: this client's ships are all FREEZE_MODE_KINEMATIC and driven purely by transform writes, so nothing ever writes linear_velocity/angular_velocity onto them - the bot's observations always see every ship as stationary. It still produces well-formed, bounded actions from that degraded input (the policy network's output layer is bounded regardless of input quality), which is sufficient for this task's actual job: generating realistic sustained network traffic for CI, not winning matches. New CI driver (tests/networked_match_ci.gd/.tscn): a headless server plus two headless --test-bot clients playing a real match. task 3.6's original acceptance text also named "p95/p99 prediction error" and "snap count" - both Phase 4 concepts that don't exist until client-side prediction and its hard-snap threshold are built, so asserting on them now would be fabricated. What's checked instead: snapshot throughput (500+ received over an 8s run, comfortably above a 60Hz-scaled floor), and genuine cross-peer score agreement - forced via a deterministic server-side goal (bot-vs-bot scoring isn't reliable enough within a short run to gate on), with each client independently writing its own final score to a peer-id- keyed file for the host to compare against the other bot's, not just trusting the server's own view. "Clean stderr" is left as the external invocation's job, same as every other smoke test in this project. Verified with real 3-process runs (host + two bots): both clients independently confirmed identical scores after a forced goal, both saw 500+ snapshots, and all three processes exited 0 with clean stderr on a representative run (one run separately hit the same known, already- documented single-benign-error disconnect-timing race task 3.4's own abuse tests hit - not a new issue). Full regression suite, including the net-sim-latency milestone gate and the abuse-detection tests, re-run clean. |
||
|
|
b290f49143 |
feat(multiplayer): Phase 3 task 3.4 - input validation, rate limiting, disconnect policy
MatchSim._recv_input now validates before decoding (§3.1 steps 2-3): per-peer rolling-1s rate limiting (packet count AND byte budget, dropping over-budget packets and disconnecting after 3 consecutive over-budget seconds), and framing validation (redundancy count and payload size checked against NetCodec's own layout before unpack_input ever runs, disconnecting after 20 malformed packets). Framing has to be validated explicitly rather than relying on decode failure: StreamPeerBuffer silently zero-fills past EOF instead of erroring, a finding from Phase 2's adversarial review. networked_match.gd's _on_input_received now rejects any seq claiming to be more than 20 ticks ahead of the current server tick (§3.1 step 4) and counts (rather than silently ignoring) input from a peer with no slot, for observability. Verified with two new permanent regression tests (networked_match_smoke.gd --role=client-abuse-malformed / client-abuse-flood) that call MatchSim._recv_input directly with garbage bytes and a legitimate-but- too-frequent flood, respectively, bypassing the honest client encoder entirely - the same thing a hostile custom client sending raw ENet packets would look like. Both confirm real disconnection, not just that the server tolerates the abuse. Two bugs surfaced by getting these tests to actually pass cleanly: a GDScript lambda-capture-by-value mistake in the tests themselves (a plain `var disconnected := false` mutated inside a signal-handler lambda never became visible to the enclosing function - fixed by capturing a single-element Array instead, which is captured by reference); and a narrow real race where NetworkManager's own ping/pong reply could target a peer that a concurrent abuse-triggered disconnect had just removed from the same poll() batch, now guarded. (Passing disconnect_peer's `force` parameter as an attempted fix for a related one-off benign error was tried and reverted - it made Godot's own peer-list bookkeeping inconsistent, producing hundreds of errors instead of one; verified empirically rather than assumed.) Full regression suite, including the net-sim-latency milestone gate, re-run clean. |
||
|
|
39a41c016c |
feat(multiplayer): Phase 2 server-authoritative simulation, dumb client
Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot layout from the lobby roster, the server drives each connected peer's ship via RLShipController fed by decoded client input and broadcasts 60Hz snapshots, and the client renders everything (including its own ship) from a per-body NetInterpolator with no local prediction yet. Dual-time remote entities split collider updates (present-time, for correct contacts) from $Visual updates (interp-delayed, for smoothness). Camera/HUD wiring and remote engine-flame VFX fell out of the existing Ship API for free once snapshots were flowing. Three real bugs found and fixed while getting a two-process test green: an RPC method named _input collided with Node's built-in _input virtual and broke the whole MatchSim autoload from loading; networked_match.gd never called NetworkManager.poll(), so nothing sent via RPC in this scene reached the wire despite Phase 1's manual polling being wired up everywhere else; and a match_config request/response fallback (added to close a startup race) could double-deliver once polling was fixed, requiring an idempotency guard. Verified with tests/networked_match_smoke: a real headless two-process host+client run shows the client rendering 31m of server-authoritative movement from a held forward-thrust input, with thrust_z=1.0 confirmed on the interpolated snapshot mid-drive and camera/HUD both wired. Full Phase 1 regression suite re-run clean alongside it. Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done; Phase 2's own gate needs it before it's fully met. |