mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 20:23:43 +00:00
a5cbc977b5
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.
132 lines
4.3 KiB
GDScript
132 lines
4.3 KiB
GDScript
class_name ReplayLog
|
|
extends RefCounted
|
|
|
|
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
|
|
#
|
|
# The highest-value debuggability investment in Phase 5, and cheap precisely
|
|
# because the packets are ALREADY flat bytes: this stores them verbatim rather
|
|
# than re-serialising game state. Without it, "my ship snapped" is permanently
|
|
# unreproducible from a field report — the CI gate catches regressions, but it
|
|
# cannot debug a player's bad night.
|
|
#
|
|
# Deliberately a standalone RefCounted with no scene/RPC dependency, like
|
|
# net_codec.gd and input_jitter_buffer.gd, so it can be unit-tested against a
|
|
# scripted record/read cycle with no live match.
|
|
#
|
|
# Format. Little-endian throughout, matching StreamPeerBuffer's own defaults
|
|
# and NetCodec's wire encoding:
|
|
#
|
|
# magic u32 'CCRP' (0x50524343)
|
|
# version u16 FORMAT_VERSION
|
|
# tick_hz u16 so a reader can convert ticks to seconds without guessing
|
|
# then, repeated:
|
|
# kind u8 RecordKind
|
|
# tick u32 server tick (Engine.get_physics_frames())
|
|
# peer_id u32 sender for INPUT, 0 for SNAPSHOT
|
|
# length u16 payload byte count
|
|
# payload length bytes, exactly as it went on the wire
|
|
#
|
|
# `length` is a u16 because both hot-path packets are far under 64KB (a 1v1
|
|
# snapshot is ~59 bytes) and MatchSim.MAX_INPUT_LENGTH already rejects
|
|
# anything larger on the way in.
|
|
|
|
const MAGIC := 0x50524343
|
|
const FORMAT_VERSION := 1
|
|
const HEADER_SIZE := 8
|
|
const RECORD_HEADER_SIZE := 11
|
|
|
|
enum RecordKind {
|
|
INPUT = 0, # client -> server, as received
|
|
SNAPSHOT = 1, # server -> client, as sent
|
|
}
|
|
|
|
var _file: FileAccess = null
|
|
var records_written := 0
|
|
var bytes_written := 0
|
|
|
|
|
|
# Returns OK, or an error code. A replay log is diagnostic: a caller that
|
|
# cannot open one should carry on serving the match, not refuse to start.
|
|
func open_for_write(path: String) -> Error:
|
|
_file = FileAccess.open(path, FileAccess.WRITE)
|
|
if _file == null:
|
|
return FileAccess.get_open_error()
|
|
_file.store_32(MAGIC)
|
|
_file.store_16(FORMAT_VERSION)
|
|
_file.store_16(SimConstants.TICK_HZ)
|
|
bytes_written = HEADER_SIZE
|
|
return OK
|
|
|
|
|
|
func is_open() -> bool:
|
|
return _file != null
|
|
|
|
|
|
func record_input(tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
|
_write(RecordKind.INPUT, tick, peer_id, payload)
|
|
|
|
|
|
func record_snapshot(tick: int, payload: PackedByteArray) -> void:
|
|
_write(RecordKind.SNAPSHOT, tick, 0, payload)
|
|
|
|
|
|
func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
|
if _file == null:
|
|
return
|
|
if payload.size() > 0xFFFF:
|
|
# Cannot happen through the real ingress paths (see the header note),
|
|
# but truncating silently would corrupt every later record's framing.
|
|
push_warning("ReplayLog: dropping an oversized %d-byte payload" % payload.size())
|
|
return
|
|
_file.store_8(kind)
|
|
_file.store_32(tick)
|
|
_file.store_32(peer_id)
|
|
_file.store_16(payload.size())
|
|
if payload.size() > 0:
|
|
_file.store_buffer(payload)
|
|
records_written += 1
|
|
bytes_written += RECORD_HEADER_SIZE + payload.size()
|
|
|
|
|
|
func close() -> void:
|
|
if _file == null:
|
|
return
|
|
_file.close()
|
|
_file = null
|
|
|
|
|
|
# Reads a whole log back. Returns {"tick_hz": int, "records": Array} or an
|
|
# empty Dictionary if the file is missing/not a replay log. Static and
|
|
# self-contained so an offline tool — or a test — can consume a log without
|
|
# instantiating anything.
|
|
static func read_all(path: String) -> Dictionary:
|
|
var f := FileAccess.open(path, FileAccess.READ)
|
|
if f == null:
|
|
return {}
|
|
if f.get_length() < HEADER_SIZE or f.get_32() != MAGIC:
|
|
f.close()
|
|
return {}
|
|
var version := f.get_16()
|
|
var tick_hz := f.get_16()
|
|
var records: Array = []
|
|
# Bound every read on the declared length rather than trusting EOF:
|
|
# FileAccess silently zero-fills past the end, exactly as StreamPeerBuffer
|
|
# does, so a truncated file would otherwise decode as an endless run of
|
|
# zero-length records at tick 0.
|
|
while f.get_position() + RECORD_HEADER_SIZE <= f.get_length():
|
|
var kind := f.get_8()
|
|
var tick := f.get_32()
|
|
var peer_id := f.get_32()
|
|
var length := f.get_16()
|
|
if f.get_position() + length > f.get_length():
|
|
push_warning("ReplayLog: truncated final record in %s" % path)
|
|
break
|
|
records.append({
|
|
"kind": kind,
|
|
"tick": tick,
|
|
"peer_id": peer_id,
|
|
"payload": f.get_buffer(length) if length > 0 else PackedByteArray(),
|
|
})
|
|
f.close()
|
|
return {"version": version, "tick_hz": tick_hz, "records": records}
|