mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +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:
@@ -0,0 +1,120 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Task 5.10. The log's whole value is that a recorded match can be replayed
|
||||
# faithfully enough to reproduce a reported snap, so what matters is that the
|
||||
# bytes come back BYTE-IDENTICAL and correctly framed — not merely that
|
||||
# something was written.
|
||||
|
||||
const ReplayLogScript = preload("res://scripts/replay_log.gd")
|
||||
|
||||
|
||||
func _temp_path(suffix: String) -> String:
|
||||
return "user://test_replay_%s_%d.ccrp" % [suffix, Time.get_ticks_usec()]
|
||||
|
||||
|
||||
func test_records_round_trip_byte_for_byte() -> void:
|
||||
var path := _temp_path("roundtrip")
|
||||
var log_writer = ReplayLogScript.new()
|
||||
assert_eq(log_writer.open_for_write(path), OK, "opens for write")
|
||||
|
||||
var input_payload := PackedByteArray([0x01, 0xFF, 0x00, 0x7F, 0x80])
|
||||
var snapshot_payload := PackedByteArray([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
log_writer.record_input(120, 4242, input_payload)
|
||||
log_writer.record_snapshot(121, snapshot_payload)
|
||||
log_writer.close()
|
||||
|
||||
var read := ReplayLogScript.read_all(path)
|
||||
assert_eq(read.get("version", -1), ReplayLogScript.FORMAT_VERSION, "version round-trips")
|
||||
assert_eq(read.get("tick_hz", -1), SimConstants.TICK_HZ, "tick rate is recorded so a reader need not guess")
|
||||
var records: Array = read.get("records", [])
|
||||
assert_eq(records.size(), 2, "both records read back")
|
||||
|
||||
assert_eq(records[0]["kind"], ReplayLogScript.RecordKind.INPUT, "first is an input")
|
||||
assert_eq(records[0]["tick"], 120, "input tick")
|
||||
assert_eq(records[0]["peer_id"], 4242, "input peer")
|
||||
assert_eq(records[0]["payload"], input_payload, "input payload is byte-identical")
|
||||
|
||||
assert_eq(records[1]["kind"], ReplayLogScript.RecordKind.SNAPSHOT, "second is a snapshot")
|
||||
assert_eq(records[1]["tick"], 121, "snapshot tick")
|
||||
assert_eq(records[1]["payload"], snapshot_payload, "snapshot payload is byte-identical")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
func test_a_real_packet_survives_the_round_trip() -> void:
|
||||
# The payloads above are hand-made. Use a genuine NetCodec input packet so
|
||||
# a framing bug that only shows up at real packet sizes cannot hide.
|
||||
var path := _temp_path("realpacket")
|
||||
var action := ShipAction.new()
|
||||
action.thrust = Vector3(0.5, -0.25, 1.0)
|
||||
action.turbo = true
|
||||
var packet := NetCodec.pack_input(77, 55, 1234, [action, action, action])
|
||||
|
||||
var log_writer = ReplayLogScript.new()
|
||||
log_writer.open_for_write(path)
|
||||
log_writer.record_input(500, 7, packet)
|
||||
log_writer.close()
|
||||
|
||||
var records: Array = ReplayLogScript.read_all(path).get("records", [])
|
||||
assert_eq(records.size(), 1, "one record")
|
||||
assert_eq(records[0]["payload"], packet, "a real input packet round-trips unchanged")
|
||||
# And it must still decode as the packet it was.
|
||||
var decoded := NetCodec.unpack_input(records[0]["payload"])
|
||||
assert_eq(decoded["seq"], 77, "the replayed packet still decodes to its own sequence")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
func test_empty_log_reads_back_as_no_records() -> void:
|
||||
var path := _temp_path("empty")
|
||||
var log_writer = ReplayLogScript.new()
|
||||
log_writer.open_for_write(path)
|
||||
log_writer.close()
|
||||
var read := ReplayLogScript.read_all(path)
|
||||
assert_eq(read.get("records", [-1]).size(), 0, "a header-only log has no records")
|
||||
assert_eq(read.get("tick_hz", -1), SimConstants.TICK_HZ, "but still reports its header")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
func test_a_non_replay_file_is_rejected_rather_than_misread() -> void:
|
||||
# FileAccess zero-fills past EOF exactly as StreamPeerBuffer does, so
|
||||
# without a magic check an arbitrary file decodes as an endless run of
|
||||
# zero-length records instead of failing.
|
||||
var path := _temp_path("garbage")
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string("this is definitely not a replay log")
|
||||
f.close()
|
||||
assert_true(ReplayLogScript.read_all(path).is_empty(), "a foreign file is refused")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
func test_a_truncated_log_yields_its_intact_records() -> void:
|
||||
# A server killed mid-write is the NORMAL way one of these ends, so a
|
||||
# partial final record must not discard the whole session.
|
||||
var path := _temp_path("truncated")
|
||||
var log_writer = ReplayLogScript.new()
|
||||
log_writer.open_for_write(path)
|
||||
log_writer.record_input(1, 1, PackedByteArray([1, 2, 3, 4]))
|
||||
log_writer.record_input(2, 1, PackedByteArray([5, 6, 7, 8]))
|
||||
log_writer.close()
|
||||
|
||||
var whole := FileAccess.get_file_as_bytes(path)
|
||||
var cut := whole.slice(0, whole.size() - 3) # lop off part of the last payload
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_buffer(cut)
|
||||
f.close()
|
||||
|
||||
var records: Array = ReplayLogScript.read_all(path).get("records", [])
|
||||
assert_eq(records.size(), 1, "the intact record survives a truncated tail")
|
||||
assert_eq(records[0]["payload"], PackedByteArray([1, 2, 3, 4]), "and is still correct")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
func test_writing_to_an_unopened_log_is_a_no_op() -> void:
|
||||
# --replay-log is optional, so every record_* call happens behind a null
|
||||
# check in production — but the class must not corrupt or crash if that
|
||||
# check is ever missed.
|
||||
var log_writer = ReplayLogScript.new()
|
||||
assert_true(not log_writer.is_open(), "starts closed")
|
||||
log_writer.record_input(1, 1, PackedByteArray([1]))
|
||||
log_writer.record_snapshot(1, PackedByteArray([1]))
|
||||
assert_eq(log_writer.records_written, 0, "nothing was recorded")
|
||||
log_writer.close()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2e71m5byxbiy
|
||||
Reference in New Issue
Block a user