mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
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.
This commit is contained in:
@@ -159,7 +159,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
# ball every time (0 contacts in 3/3 runs). Closing the loop on the
|
||||
# actual bearing keeps this exercising the real input path while being
|
||||
# indifferent to how the kickoff happened to orient the ship.
|
||||
await _drive_at_ball(my_slot.ship, match_scene.ball, 3.0)
|
||||
await _drive_at_ball(my_slot.ship, match_scene.ball, 8.0)
|
||||
# Leave a >150ms observation window before the normal drive so a
|
||||
# subsequent goal reset cannot mask blend-back.
|
||||
Input.action_release("move_forward")
|
||||
@@ -514,9 +514,14 @@ func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> vo
|
||||
Input.action_release("turn_right")
|
||||
if absf(yaw_error) > ALIGNED_RADIANS:
|
||||
Input.action_press("turn_right" if yaw_error > 0.0 else "turn_left")
|
||||
Input.action_release("move_forward")
|
||||
else:
|
||||
# Thrust whenever the ball is anywhere ahead, not only once perfectly
|
||||
# aligned. Cutting thrust to turn made the ship hover and burn the
|
||||
# window without closing distance, which is why this reached the ball
|
||||
# only 2 runs in 3; turning under power converges much faster.
|
||||
if absf(yaw_error) < PI * 0.5:
|
||||
Input.action_press("move_forward")
|
||||
else:
|
||||
Input.action_release("move_forward")
|
||||
# Vertical alignment matters too — the ball sits above the floor and a
|
||||
# ship that is climbing sails straight over it.
|
||||
Input.action_release("move_up")
|
||||
@@ -560,6 +565,73 @@ func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_second
|
||||
# honest encoder — this IS what a hostile custom client sending raw ENet
|
||||
# packets would look like, so bypassing the normal send path is the point,
|
||||
# not a shortcut.
|
||||
# §6.4 (tasks 5.6/5.7), host side. Watches its own slots across a client's
|
||||
# disconnect and reconnect and asserts the documented contract: the ship is
|
||||
# never despawned, the controller is swapped rather than left dangling, the
|
||||
# slot is reserved by identity, and a returning player gets it back.
|
||||
func run_disconnect_host_check(lifetime_seconds: float) -> void:
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: host scene is not NetworkedMatch")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var slots_before: int = match_scene._slots.size()
|
||||
if slots_before == 0:
|
||||
print("SMOKE FAIL: host has no slots — the client never made it into the roster")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var ship_before = match_scene._slots[0].ship
|
||||
var name_before: String = match_scene._slots[0].player_name
|
||||
print("SMOKE INFO: host has %d slot(s), player_name=%s" % [slots_before, name_before])
|
||||
|
||||
# Wait for the client to drop. Guarded on the scene still existing: §6.4's
|
||||
# abort can tear the match down underneath this loop.
|
||||
var drop_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < drop_deadline and _is_networked_match(match_scene) and not match_scene._slots[0].disconnected:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match aborted during the disconnect window — the reservation should have held it open")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var saw_disconnect: bool = match_scene._slots[0].disconnected
|
||||
var ship_survived: bool = match_scene._slots.size() == slots_before and is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
|
||||
var controller_valid: bool = is_instance_valid(match_scene._slots[0].controller)
|
||||
var reserved: bool = match_scene._slots[0].reserved_until_tick > Engine.get_physics_frames()
|
||||
print("SMOKE INFO: after disconnect saw_disconnect=%s ship_survived=%s controller_valid=%s reserved=%s" % [
|
||||
str(saw_disconnect), str(ship_survived), str(controller_valid), str(reserved)
|
||||
])
|
||||
|
||||
# Then for it to come back and reclaim the slot.
|
||||
var back_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < back_deadline and _is_networked_match(match_scene) and match_scene._slots[0].disconnected:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match aborted before the player could reconnect")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var reclaimed: bool = not match_scene._slots[0].disconnected
|
||||
var same_ship: bool = is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
|
||||
# Ticking on past the swap proves task 5.7: _physics_process writes
|
||||
# slot.controller.action every tick, so a dangling reference from
|
||||
# set_controller()'s queue_free() would have crashed by now.
|
||||
for i in 60:
|
||||
if not _is_networked_match(match_scene):
|
||||
break
|
||||
await get_tree().physics_frame
|
||||
|
||||
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship and is_instance_valid(match_scene._slots[0].controller)
|
||||
print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s)" % [
|
||||
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship)
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
func run_malformed_abuse_check() -> void:
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
# A single-element Array, not a plain bool: GDScript lambdas capture
|
||||
|
||||
Reference in New Issue
Block a user