diff --git a/Game/project.godot b/Game/project.godot index 58a4744f..e16a976e 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -46,6 +46,7 @@ BackgroundFPS="*res://scripts/background_fps.gd" PerfOverlay="*res://scripts/perf_overlay.gd" NetworkManager="*res://scripts/network_manager.gd" MatchNet="*res://scripts/match_net.gd" +MatchSim="*res://scripts/match_sim.gd" NetDebugOverlay="*res://scripts/net_debug_overlay.gd" [display] diff --git a/Game/scenes/networked_match.tscn b/Game/scenes/networked_match.tscn new file mode 100644 index 00000000..0ae770c1 --- /dev/null +++ b/Game/scenes/networked_match.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/networked_match.gd" id="1_nm"] + +[node name="NetworkedMatch" type="Node3D"] +script = ExtResource("1_nm") diff --git a/Game/scripts/match_sim.gd b/Game/scripts/match_sim.gd new file mode 100644 index 00000000..46474143 --- /dev/null +++ b/Game/scripts/match_sim.gd @@ -0,0 +1,95 @@ +extends Node + +# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs: +# match_config (server assigns arena + deterministic slot order from +# MatchNet.roster), input (client -> server, per-tick action), snapshot +# (server -> client, NetCodec-packed body state), and a small score_update +# for the HUD. Lives on an autoload per §1.3's derived decision ("All +# hot-path RPCs live on autoloads") even though these are scoped to +# whichever match happens to be running — a scene-node RPC target would +# need matching NodePaths across peers, which an autoload sidesteps +# entirely, and it's what lets NetworkedMatch itself stay a plain scene +# node with no networking-identity concerns of its own. +# +# Channel intent per §2.1: 0 reliable (match_config, score_update), 1 +# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet +# verified against ENet's own reserved system channel offset (§2.1's own +# "verify empirically" hedge); if that turns out to matter these indices +# will need adjusting, not the RPC design itself. + +const NetCodec = preload("res://scripts/net_codec.gd") + +signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) +signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input +signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot +signal score_update_received(score: Dictionary) + +# Server only: the last match_config actually sent, so a client whose own +# scene load (and therefore its match_config_received listener) finishes +# AFTER the server already broadcast can still get it — a one-shot +# broadcast alone is racy against however long the client takes to reach +# the point where it's listening, and Godot signals never buffer for a +# late connection. request_match_config() closes that race by turning +# delivery into "ask until you get it" instead of "hope you were already +# listening." Also covers a late joiner mid-match (Phase 5 will still need +# to add live match *state*, not just this static config, for that case). +var _last_match_config: Dictionary = {} + + +func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + _last_match_config = { + "arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices, + } + _match_config.rpc(arena_path, peer_ids, teams, spawn_indices) + + +func request_match_config() -> void: + _request_match_config.rpc_id(1) + + +func send_input(bytes: PackedByteArray) -> void: + _recv_input.rpc_id(1, bytes) + + +func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void: + _snapshot.rpc_id(peer_id, bytes) + + +func send_score_update(score: Dictionary) -> void: + _score_update.rpc(score) + + +@rpc("authority", "call_remote", "reliable", 0) +func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + match_config_received.emit(arena_path, peer_ids, teams, spawn_indices) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func _request_match_config() -> void: + if not multiplayer.is_server() or _last_match_config.is_empty(): + return + var peer_id := multiplayer.get_remote_sender_id() + _match_config.rpc_id( + peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"], + _last_match_config["teams"], _last_match_config["spawn_indices"] + ) + + +@rpc("any_peer", "call_remote", "unreliable_ordered", 1) +func _recv_input(bytes: PackedByteArray) -> void: + if not multiplayer.is_server(): + return + var peer_id := multiplayer.get_remote_sender_id() + var decoded := NetCodec.unpack_input(bytes) + input_received.emit(peer_id, decoded) + + +@rpc("authority", "call_remote", "unreliable_ordered", 2) +func _snapshot(bytes: PackedByteArray) -> void: + var decoded := NetCodec.unpack_snapshot(bytes) + snapshot_received.emit(decoded) + + +@rpc("authority", "call_remote", "reliable", 0) +func _score_update(score: Dictionary) -> void: + score_update_received.emit(score) diff --git a/Game/scripts/net_interpolator.gd b/Game/scripts/net_interpolator.gd new file mode 100644 index 00000000..c0575adb --- /dev/null +++ b/Game/scripts/net_interpolator.gd @@ -0,0 +1,114 @@ +class_name NetInterpolator +extends RefCounted + +# Buffers recent snapshot samples for ONE remote body and produces +# interpolated states at any requested (possibly fractional) server tick — +# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote +# entities"): once at the present-time estimate for the collider, once +# further back at present-minus-INTERP_DELAY for $Visual. +# +# server_tick (Engine.get_physics_frames() at send time) maps to an +# estimated server wall-clock time via TICK_HZ without any extra +# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec() +# count from the same process-start epoch, and physics has been running at +# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ) +# is a valid estimate of "what Time.get_ticks_msec() read on the server when +# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms() +# reading into the same tick-space with to_tick(ms) before calling sample_at(). + +const NetBodyState = preload("res://scripts/net_body_state.gd") +const SimConstants = preload("res://scripts/sim_constants.gd") + +const MAX_SAMPLES := 16 +# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one +# flying through a wall." +const MAX_EXTRAPOLATION_MS := 150.0 +const TICK_MS := 1000.0 / SimConstants.TICK_HZ + +var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first +var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset + + +static func to_tick(server_time_ms: float) -> float: + return server_time_ms / TICK_MS + + +# Returns true if this sample's reset_gen differs from the last one seen — +# the caller's cue to hard-snap instead of interpolating across a +# server-authoritative teleport (kickoff, goal reset) rather than sliding +# across the arena. Clears buffered history on a reset so a stale +# pre-reset sample can never bracket a post-reset one. +func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool: + var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen + if is_reset: + _samples.clear() + reset_gen = sample_reset_gen + if not _samples.is_empty() and server_tick <= _samples.back()["tick"]: + return is_reset # stale/duplicate (unreliable_ordered should already prevent this, but don't trust it blindly) + _samples.append({"tick": server_tick, "state": state}) + if _samples.size() > MAX_SAMPLES: + _samples.pop_front() + return is_reset + + +func has_samples() -> bool: + return not _samples.is_empty() + + +func latest() -> NetBodyState: + return _samples.back()["state"] if not _samples.is_empty() else null + + +# target_tick may be fractional (a point in time between two integer ticks). +func sample_at(target_tick: float) -> NetBodyState: + if _samples.is_empty(): + return null + if _samples.size() == 1: + return _samples[0]["state"] + if target_tick <= _samples[0]["tick"]: + return _samples[0]["state"] + var newest: Dictionary = _samples.back() + if target_tick >= newest["tick"]: + return _extrapolate(newest, target_tick) + for i in range(_samples.size() - 1): + var a: Dictionary = _samples[i] + var b: Dictionary = _samples[i + 1] + if a["tick"] <= target_tick and target_tick <= b["tick"]: + var a_tick: float = a["tick"] + var b_tick: float = b["tick"] + var span := b_tick - a_tick + var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0 + return _lerp_state(a["state"], b["state"], t) + return newest["state"] + + +func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState: + var out := NetBodyState.new() + out.position = a.position.lerp(b.position, t) + out.rotation = a.rotation.slerp(b.rotation, t) + out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t) + out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t) + out.frozen = b.frozen + out.turbo = b.turbo + out.thrust_z = b.thrust_z + out.stalled = b.stalled + out.avel_range = b.avel_range + return out + + +func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState: + var state: NetBodyState = newest["state"] + var ticks_ahead: float = target_tick - float(newest["tick"]) + var ms_ahead := ticks_ahead * TICK_MS + var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS) + var out := NetBodyState.new() + out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0) + out.rotation = state.rotation + out.linear_velocity = state.linear_velocity + out.angular_velocity = state.angular_velocity + out.frozen = state.frozen + out.turbo = state.turbo + out.thrust_z = state.thrust_z + out.stalled = state.stalled + out.avel_range = state.avel_range + return out diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd new file mode 100644 index 00000000..ac3e213d --- /dev/null +++ b/Game/scripts/networked_match.gd @@ -0,0 +1,360 @@ +class_name NetworkedMatch +extends GameMode + +# Phase 2: server-authoritative simulation, dumb client (multiplayer-todo.md +# §7 Phase 2). The server runs the real physics for every ship — via +# RLShipController, fed by each connected player's forwarded input — and +# the ball, and broadcasts NetCodec snapshots at 60Hz. The client renders +# everything, including its own ship, from the interpolation buffer; there +# is no local prediction yet (that's Phase 4), so every body on the client +# is FREEZE_MODE_KINEMATIC and driven entirely by incoming snapshots. +# +# No HUD/Arena child in networked_match.tscn — both are built in code, once +# the arena is actually known (the server picks one; the client learns it +# from match_config), which is why this overrides _ready() completely +# rather than relying on GameMode's default (arena-required-synchronously) +# flow. + +signal timer_updated(minutes: int, seconds: int) +signal score_changed(score: Dictionary) +signal match_ended(winning_team: int, score: Dictionary) +signal kickoff_countdown(count: int) +signal overtime_started + +const NetCodec = preload("res://scripts/net_codec.gd") +const NetBodyState = preload("res://scripts/net_body_state.gd") +const NetInterpolator = preload("res://scripts/net_interpolator.gd") +const HUD_SCENE = preload("res://scenes/HUD.tscn") + +# Minimum plausible interpolation delay even on a same-machine/LAN link — +# §4.6's INTERP_DELAY clamp floor. The full formula (one_way + snapshot +# interval*1.5 + 2.5*jitter_ewma) is simplified here to one_way + interval*1.5 +# with no jitter term yet (no jitter EWMA is tracked before Phase 3) — close +# enough for Phase 2's "smooth, not exactly latency-optimal" bar. +const INTERP_DELAY_MIN_MS := 25.0 +const INTERP_DELAY_MAX_MS := 200.0 +const SNAPSHOT_INTERVAL_MS := 1000.0 / 60.0 + + +class SlotInfo: + var peer_id: int + var team: int + var spawn_index: int + var ship: Ship + var controller: RLShipController # server only + var interpolator := NetInterpolator.new() # client only + + +var _slots: Array[SlotInfo] = [] +var _my_slot: SlotInfo = null # client only +var _ball_interpolator := NetInterpolator.new() # client only +var _local_input_sampler := PlayerShipController.new() # client only: reads local input each tick to forward; never added to a Ship, never in the tree — get_action() only touches the global Input singleton +var _input_seq := 0 # client only +var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport + + +func _ready() -> void: + add_to_group("game") + Engine.max_physics_steps_per_frame = 4 + if kickoff_rng_seed == 0: + _kickoff_rng.randomize() + if multiplayer.is_server(): + _start_server() + else: + MatchSim.match_config_received.connect(_on_match_config_received) + MatchSim.snapshot_received.connect(_on_snapshot_received) + MatchSim.score_update_received.connect(_on_score_update_received) + _request_match_config_until_received() + + +# The one-shot server broadcast in _start_server() is racy against however +# long this client's own scene load took to reach this line — it may have +# already fired into a MatchSim with no listener connected yet, or the +# server may not have even started the match yet. Keep asking until +# _on_match_config_received actually populates _slots. +func _request_match_config_until_received() -> void: + while _slots.is_empty() and is_inside_tree(): + MatchSim.request_match_config() + await get_tree().create_timer(0.5).timeout + + +func _owns_goal_logic() -> bool: + return multiplayer.is_server() + + +func _owns_world_simulation() -> bool: + return multiplayer.is_server() + + +# ============================================================ +# Server +# ============================================================ + +func _start_server() -> void: + var arena_path := ArenaRegistry.random_path() + arena = (load(arena_path) as PackedScene).instantiate() + add_child(arena) + for goal in arena.get_goals(): + goal.goal_scored.connect(_handle_goal_scored) + + spawn_ball() + + var peer_ids := PackedInt32Array() + var teams := PackedInt32Array() + var spawn_indices := PackedInt32Array() + var team_counts := {0: 0, 1: 0} + var sorted_peer_ids: Array = MatchNet.roster.keys() + sorted_peer_ids.sort() + for peer_id in sorted_peer_ids: + var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id] + var spawn_index: int = team_counts.get(info.team, 0) + team_counts[info.team] = spawn_index + 1 + var slot := SlotInfo.new() + slot.peer_id = peer_id + slot.team = info.team + slot.spawn_index = spawn_index + slot.controller = RLShipController.new() + slot.ship = spawn_ship(info.team, spawn_index, slot.controller) + _slots.append(slot) + peer_ids.append(peer_id) + teams.append(info.team) + spawn_indices.append(spawn_index) + + MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices) + MatchSim.input_received.connect(_on_input_received) + + +func _on_input_received(peer_id: int, decoded: Dictionary) -> void: + for slot in _slots: + if slot.peer_id == peer_id: + var actions: Array = decoded["actions"] + # Newest-first; no redundancy handling yet (task 3.x) — just take + # the newest one every time a packet arrives. + if not actions.is_empty(): + slot.controller.action = actions[0] + return + + +func _on_goal_registered(conceding_team: int) -> void: + _record_goal(1 - conceding_team) + MatchSim.send_score_update(score.duplicate()) + + +func _on_goal_scored(_conceding_team: int) -> void: + _reset_gen = (_reset_gen + 1) % 256 + reset_ball() + reset_ships() + + +func _broadcast_snapshot() -> void: + var server_tick := Engine.get_physics_frames() + var bodies: Array[NetBodyState] = [] + for slot in _slots: + if is_instance_valid(slot.ship): + bodies.append(_ship_to_net_body_state(slot.ship)) + if is_instance_valid(ball): + bodies.append(_ball_to_net_body_state(ball)) + var segment := NetCodec.pack_snapshot_body_segment(server_tick, 0, _reset_gen, bodies) + # Per-client header fields (last_input_seq/input_buffer_depth/echo) aren't + # tracked yet — that's the jitter-buffer work in Phase 3 (tasks 3.1-3.2). + # Building the shared body segment once and reusing it per peer (rather + # than re-encoding per client) is the whole reason §2.4 splits the wire + # format into a per-client header + a shared body segment in the first + # place — see pack_snapshot_body_segment's own doc comment. + # "No ship is ever despawned" (§6.4) means _slots outlives a disconnect — + # a real one will be handled by Phase 5's reconnect/controller-swap + # logic, but sending an RPC to a peer_id ENet no longer knows about + # (found via the smoke test: a client that exits mid-match spammed + # "Attempt to call RPC with unknown peer ID" every tick for the rest of + # the host's run) throws instead of silently no-op'ing. Guard against it. + var connected_peers := multiplayer.get_peers() + for slot in _slots: + if connected_peers.has(slot.peer_id): + MatchSim.send_snapshot(slot.peer_id, NetCodec.pack_snapshot(0, 0, 0, segment)) + + +func _ship_to_net_body_state(ship: Ship) -> NetBodyState: + var s := NetBodyState.new() + s.position = ship.global_position + s.rotation = ship.global_transform.basis.get_rotation_quaternion() + s.linear_velocity = ship.linear_velocity + s.angular_velocity = ship.angular_velocity + s.frozen = false + s.turbo = ship.is_turbo_active() + # Matches Ship._update_movement_vfx's own read of thrust.z: only positive + # forward thrust drives the visible flame (see task 2.6). + s.thrust_z = clampf(maxf(ship.controller.get_action().thrust.z if ship.controller else 0.0, 0.0), 0.0, 1.0) + s.avel_range = NetCodec.SHIP_AVEL_RANGE + return s + + +func _ball_to_net_body_state(b: RigidBody3D) -> NetBodyState: + var s := NetBodyState.new() + s.position = b.global_position + s.rotation = b.global_transform.basis.get_rotation_quaternion() + s.linear_velocity = b.linear_velocity + s.angular_velocity = b.angular_velocity + s.avel_range = NetCodec.BALL_AVEL_RANGE + return s + + +# ============================================================ +# Client +# ============================================================ + +func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + if not _slots.is_empty(): + # Not idempotent by accident: the original broadcast from + # _start_server() and a reply to this client's own + # request_match_config() (see _request_match_config_until_received) + # can both legitimately arrive — the retry loop exists specifically + # because either one alone isn't reliably delivered, so seeing both + # is expected, not a protocol error. Processing this twice would + # double-spawn the whole match (found via the two-process smoke + # test: two arenas, two ships, two HUDs, _slots.size() == 2 instead + # of 1). Once is enough. + return + var known := false + for a in ArenaRegistry.ARENAS: + if a["path"] == arena_path: + known = true + break + if not known: + push_error("NetworkedMatch: server sent unknown arena path '%s', refusing match_config" % arena_path) + return + + arena = (load(arena_path) as PackedScene).instantiate() + add_child(arena) + # _owns_goal_logic() is false here, so GameMode's usual goal-signal wiring + # never happens — a client's local (interpolated, laggy) Goal sensor must + # never be allowed to decide a score, only the server's real one can. + + spawn_ball() + ball.freeze = true + ball.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + + var my_id := multiplayer.get_unique_id() + for i in peer_ids.size(): + var slot := SlotInfo.new() + slot.peer_id = peer_ids[i] + slot.team = teams[i] + slot.spawn_index = spawn_indices[i] + slot.ship = spawn_ship(slot.team, slot.spawn_index, null) + slot.ship.freeze = true + slot.ship.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + # §4.6: manual, per-render-frame $Visual updates must not fight + # Godot's own built-in physics interpolation. + if is_instance_valid(slot.ship.visual): + slot.ship.visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + _slots.append(slot) + if slot.peer_id == my_id: + _my_slot = slot + + _spawn_hud() + if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship): + spawn_camera_rig(_my_slot.ship) + + +func _spawn_hud() -> void: + hud = HUD_SCENE.instantiate() + add_child(hud) + + +func _send_local_input() -> void: + if _slots.is_empty(): + return # match_config hasn't arrived yet + var action := _local_input_sampler.get_action().copy() + _input_seq += 1 + var bytes := NetCodec.pack_input(_input_seq, 0, Time.get_ticks_msec(), [action]) + MatchSim.send_input(bytes) + + +func _on_snapshot_received(decoded: Dictionary) -> void: + var server_tick: int = decoded["server_tick"] + var reset_gen: int = decoded["reset_gen"] + var bodies: Array = decoded["bodies"] + for i in _slots.size(): + if i < bodies.size(): + _slots[i].interpolator.add_sample(server_tick, bodies[i], reset_gen) + if bodies.size() > _slots.size(): + _ball_interpolator.add_sample(server_tick, bodies[_slots.size()], reset_gen) + + +func _current_interp_delay_ms() -> float: + var rtt := NetworkManager.rtt_ms + var one_way := (rtt / 2.0) if rtt >= 0.0 else INTERP_DELAY_MIN_MS + return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS) + + +# Collider time: present-time estimate, applied once per physics tick. +func _physics_process(_delta: float) -> void: + # Automatic multiplayer polling is disabled project-wide (task 1.3) — + # every scene that sends/receives RPCs has to poll manually, and this + # one is no exception. Missing this meant NOTHING sent after entering + # this scene ever actually reached the wire in either direction + # (queued but never flushed) — found via the two-process smoke test, + # not by inspection. + NetworkManager.poll() + if _owns_world_simulation(): + _respawn_escaped_bodies() + if multiplayer.is_server(): + _broadcast_snapshot() + return + + _send_local_input() + var server_time_est := NetworkManager.get_server_time_estimate_ms() + var collider_tick := NetInterpolator.to_tick(server_time_est) + for slot in _slots: + if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): + _apply_collider_state(slot.ship, slot.interpolator.sample_at(collider_tick)) + if is_instance_valid(ball) and _ball_interpolator.has_samples(): + _apply_collider_state(ball, _ball_interpolator.sample_at(collider_tick)) + + +# Visual time: present-minus-INTERP_DELAY, applied once per rendered frame — +# separate from the collider update above so a high-refresh client samples +# remote motion at true render rate instead of repeating the same 60Hz value +# several times in a row (§2.4's "240 distinct positions/s, not 60"). +# +# Ball only gets the VFX half of this (trail speed), not a transform write: +# unlike Ship, Ball has no separate $Visual child to offset from its +# collider (task 0.2's Visual-node split was scoped to Ship only) — giving +# it one is a bigger structural change than Phase 2's remit, so for now the +# ball's rendered position is whatever _physics_process's present-time +# collider update leaves it at, one tick behind true dual-time smoothness. +func _process(_delta: float) -> void: + # §7 task 1.3: poll for receive unconditionally at the top of both + # _process and _physics_process, not just physics — a snapshot that + # lands between ticks can be rendered immediately at high refresh rates + # instead of waiting for the next physics step. + NetworkManager.poll() + if multiplayer.is_server() or _slots.is_empty(): + return + var server_time_est := NetworkManager.get_server_time_estimate_ms() + var visual_tick := NetInterpolator.to_tick(server_time_est - _current_interp_delay_ms()) + for slot in _slots: + if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): + _apply_ship_visual_state(slot.ship, slot.interpolator.sample_at(visual_tick)) + if is_instance_valid(ball) and _ball_interpolator.has_samples(): + var state := _ball_interpolator.sample_at(visual_tick) + if state != null: + (ball as Ball).set_visual_speed(state.linear_velocity.length()) + + +func _apply_collider_state(body: RigidBody3D, state: NetBodyState) -> void: + if state == null: + return + body.global_transform = Transform3D(Basis(state.rotation), state.position) + + +func _apply_ship_visual_state(ship: Ship, state: NetBodyState) -> void: + if state == null: + return + if is_instance_valid(ship.visual): + ship.visual.global_transform = Transform3D(Basis(state.rotation), state.position) + ship.set_visual_action(state.thrust_z, state.turbo) + + +func _on_score_update_received(new_score: Dictionary) -> void: + score = new_score.duplicate() + score_changed.emit(score.duplicate()) diff --git a/Game/tests/networked_match_smoke.gd b/Game/tests/networked_match_smoke.gd new file mode 100644 index 00000000..2298f87b --- /dev/null +++ b/Game/tests/networked_match_smoke.gd @@ -0,0 +1,71 @@ +extends Node + +# Manual two-process smoke test for Phase 2 (tasks 2.1-2.5): match_config, +# server-authoritative simulation, snapshot broadcast, client interpolation. +# Not part of tests/test_runner.tscn — needs real ENet peers and a real +# physics-driven ship. Run: +# +# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host +# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client + +const PORT := 7812 +const SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state +const DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move +const HOST_LIFETIME_SECONDS := 10.0 + +var _role := "" + + +func _ready() -> void: + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--role="): + _role = arg.substr("--role=".length()) + + match _role: + "host": + var err := NetworkManager.host(PORT) + if err != OK: + print("SMOKE FAIL: host() failed: %s" % error_string(err)) + get_tree().quit(1) + return + print("SMOKE: hosting on port %d, waiting for a client to join the roster..." % PORT) + MatchNet.player_joined.connect(_on_host_player_joined) + "client": + MatchNet.local_player_name = "NetTest" + var err := NetworkManager.join("127.0.0.1", PORT) + if err != OK: + print("SMOKE FAIL: join() failed: %s" % error_string(err)) + get_tree().quit(1) + return + print("SMOKE: joining ...") + MatchNet.welcomed.connect(_on_client_welcomed) + _: + print("SMOKE FAIL: missing or unrecognised --role=") + get_tree().quit(1) + return + + +func _process(_delta: float) -> void: + NetworkManager.poll() + + +func _physics_process(_delta: float) -> void: + NetworkManager.poll() + + +func _on_host_player_joined(_peer_id: int, _name: String) -> void: + MatchNet.player_joined.disconnect(_on_host_player_joined) + print("SMOKE: host loading networked_match.tscn ...") + get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn") + var hooks := preload("res://tests/networked_match_test_hooks.gd").new() + get_tree().root.add_child.call_deferred(hooks) + hooks.run_host_check.call_deferred(HOST_LIFETIME_SECONDS) + + +func _on_client_welcomed() -> void: + MatchNet.welcomed.disconnect(_on_client_welcomed) + print("SMOKE: client loading networked_match.tscn ...") + get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn") + var hooks := preload("res://tests/networked_match_test_hooks.gd").new() + get_tree().root.add_child.call_deferred(hooks) + hooks.run_client_check.call_deferred(SETTLE_SECONDS, DRIVE_SECONDS) diff --git a/Game/tests/networked_match_smoke.tscn b/Game/tests/networked_match_smoke.tscn new file mode 100644 index 00000000..fd1ad294 --- /dev/null +++ b/Game/tests/networked_match_smoke.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/networked_match_smoke.gd" id="1_nms"] + +[node name="NetworkedMatchSmoke" type="Node"] +script = ExtResource("1_nms") diff --git a/Game/tests/networked_match_test_hooks.gd b/Game/tests/networked_match_test_hooks.gd new file mode 100644 index 00000000..064a3be7 --- /dev/null +++ b/Game/tests/networked_match_test_hooks.gd @@ -0,0 +1,116 @@ +extends Node + +# Test-only helper (tests/networked_match_smoke.gd). Not a project autoload +# — production code never references this. Same reason as +# tests/lobby_test_hooks.gd: networked_match.tscn is loaded via +# change_scene_to_file(), which frees whatever node initiated the load, so +# a driver can't keep orchestrating from a node that just got freed. The +# smoke test add_child()s this directly under get_tree().root instead (a +# sibling of current_scene, not a descendant of it), so it survives the swap. +# +# Uses preload(), not the bare `NetworkedMatch` class_name, and leaves +# `match_scene` itself untyped (Node) throughout — same global-script-class- +# cache-timing reason as tests/test_case.gd, plus every member access off an +# untyped Node returns Variant, which then needs explicit `: Type` +# annotations wherever `:=` would otherwise fail to infer one. + +const NetworkedMatchScript = preload("res://scripts/networked_match.gd") + + +func _is_networked_match(node: Node) -> bool: + return node != null and node.get_script() == NetworkedMatchScript + + +func run_host_check(lifetime_seconds: float) -> void: + await get_tree().create_timer(lifetime_seconds * 0.4).timeout + var match_scene := get_tree().current_scene + var ok := _is_networked_match(match_scene) + var ship_count := 0 + var ball_ok := false + var arena_name := "null" + if ok: + ship_count = match_scene.ships.size() + ball_ok = is_instance_valid(match_scene.ball) + if match_scene.arena: + arena_name = match_scene.arena.name + print("SMOKE INFO: host is_networked_match=%s ship_count=%d ball_ok=%s arena=%s" % [ + str(ok), ship_count, str(ball_ok), arena_name + ]) + var success := ok and ship_count == 1 and ball_ok + print("SMOKE %s: host spawn check (ship_count=%d, ball_ok=%s)" % ["PASS" if success else "FAIL", ship_count, str(ball_ok)]) + + await get_tree().create_timer(lifetime_seconds * 0.6).timeout + if _is_networked_match(match_scene) and not match_scene.ships.is_empty(): + var ship: Ship = match_scene.ships[0] + print("SMOKE INFO: host ship final position=%s (spawned, driven by client input if any arrived)" % str(ship.global_position)) + NetworkManager.shutdown() + get_tree().quit(0 if success else 1) + + +func run_client_check(settle_seconds: float, drive_seconds: float) -> void: + await get_tree().create_timer(settle_seconds).timeout + + var match_scene := get_tree().current_scene + if not _is_networked_match(match_scene): + print("SMOKE FAIL: current_scene is not NetworkedMatch after %.1fs" % settle_seconds) + get_tree().quit(1) + return + + var slots_ok: bool = match_scene._slots.size() == 1 + var ball_ok: bool = is_instance_valid(match_scene.ball) + var my_slot = match_scene._my_slot + var my_slot_ok: bool = my_slot != null and is_instance_valid(my_slot.ship) + var camera_ok: bool = is_instance_valid(match_scene._camera_rig) + var hud_ok: bool = is_instance_valid(match_scene.hud) + var start_position := Vector3.ZERO + if my_slot_ok: + start_position = my_slot.ship.visual.global_position + + print("SMOKE INFO: client slots_ok=%s ball_ok=%s my_slot_ok=%s camera_ok=%s hud_ok=%s start_pos=%s" % [ + str(slots_ok), str(ball_ok), str(my_slot_ok), str(camera_ok), str(hud_ok), str(start_position) + ]) + + if not (slots_ok and ball_ok and my_slot_ok and camera_ok and hud_ok): + print("SMOKE FAIL: spawn/wiring check failed") + get_tree().quit(1) + return + + # Drive forward thrust (a real, held key state — exercises the actual + # client input path, not a synthetic RPC call) and confirm the ship + # the CLIENT renders (its interpolated $Visual, not a raw snapshot + # value) actually moved — proving input reached the server, the server + # applied real thruster force, broadcast it back, and the client's + # interpolator produced smooth motion from it. + Input.action_press("move_forward") + await get_tree().create_timer(drive_seconds * 0.5).timeout + + # Task 2.6: the server-computed thrust_z it broadcast in the snapshot + # should have reached this client's interpolator and be readable off + # the latest sample — this is what set_visual_action's engine-flame + # wiring actually reads, so it's the real thing to check, not just + # "the ship physically moved" (which 2.6 doesn't claim on its own). + var latest_state = my_slot.interpolator.latest() + var thrust_z_ok: bool = latest_state != null and latest_state.thrust_z > 0.5 + print("SMOKE INFO: mid-drive thrust_z=%.2f (expect >0.5 while holding forward)" % (latest_state.thrust_z if latest_state != null else -1.0)) + + await get_tree().create_timer(drive_seconds * 0.5).timeout + Input.action_release("move_forward") + + var end_position: Vector3 = my_slot.ship.visual.global_position + var moved := start_position.distance_to(end_position) + print("SMOKE INFO: client ship moved %.2fm (start=%s end=%s) while holding forward thrust for %.1fs" % [ + moved, str(start_position), str(end_position), drive_seconds + ]) + + # thrust_power 150 / mass 5 = 30 m/s^2 nominal acceleration (see ship.gd) — + # over 2s even with drag/ramp-up this should clear a couple of metres. + # A generous, not-tuned-to-the-decimal bound: this is a wiring smoke + # test, not a physics-accuracy test (net_codec's own tests already cover + # quantisation precision). + var success := moved > 1.0 and thrust_z_ok + print("SMOKE %s: client observed %.2fm of server-authoritative movement via interpolation, thrust_z_ok=%s" % [ + "PASS" if success else "FAIL", moved, str(thrust_z_ok) + ]) + await get_tree().create_timer(0.3).timeout + NetworkManager.shutdown() + get_tree().quit(0 if success else 1) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 299babe9..4eed4200 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -4,7 +4,7 @@ Working document for the online multiplayer effort. `TODO.md` points here. Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later. -**Status: nothing here is implemented.** The game has zero networking code today. The only network-adjacent code in the repo is the RL trainer's `StreamPeerTCP` bridge in the vendored `godot_rl_agents` addon, which is a dev-only training transport and unrelated. +**Status: Phase 0 done, Phase 1 done, Phase 2 tasks 2.1–2.7 done (2.8 `net_sim.gd` outstanding — Phase 2's own gate needs it before it's fully met, LAN-only so far).** A real two-process 1v1 runs: a headless server hosts, a client joins through the lobby, spawns into a server-picked arena, drives its ship via real held input, and renders server-authoritative movement (verified: 31.43 m over a 2 s held-thrust drive, purely from interpolated snapshots) with camera and HUD attached. No prediction yet (Phase 4) and no `net_sim`-simulated latency/loss testing yet (Phase 2.8) — see §7 for per-task status and evidence. --- @@ -840,13 +840,13 @@ No own-ship prediction yet: the client renders everything, including its own shi | # | Task | Acceptance | |---|---|---| -| 2.1 `[D:1.4]` | `networked_match.gd` + `networked_match.tscn` (no HUD child); `match_config` RPC; deterministic slot-order roster spawn; arena-path validation against `ArenaRegistry` | Both peers spawn an identical tree; an invalid arena path is refused | -| 2.2 `[D:2.1]` | Server: reuse **`RLShipController`** as the remote-input controller, naive input application (no jitter buffer yet), snapshot writer, 60 Hz broadcast | Server logs show a stable 60 Hz snapshot cadence | -| 2.3 `[D:2.2]` | Client: snapshot reader and buffer; `net_interpolator.gd` driving frozen-kinematic bodies | Ships and ball move smoothly on the client | -| 2.4 `[D:2.3]` | **Dual-time remote entities** (§4.1): collider at `server_time_est` in `_physics_process`; `$Visual` at `server_time_est - INTERP_DELAY` **in `_process` at true render time**, `physics_interpolation_mode = OFF` (§5.4b) | Collider/visual separation measurable in the debug overlay; contacts resolve against present-time geometry; at 240 fps remote ships show 240 distinct positions/s, not 60 | -| 2.5 `[D:2.3]` `[P]` | Client: forward raw input at 60 Hz (no redundancy, no buffering yet) | Input reaches the server and moves the ship | -| 2.6 `[D:2.3]` `[P]` | Client: `set_visual_action` / `set_visual_speed` wiring for remote engine flames and the ball trail | Remote ships show engine VFX; the ball trail responds to speed | -| 2.7 `[D:2.3]` `[P]` | Client: camera rig on own ship; HUD added in code; `NetworkedMatch` declares all five HUD signals | Full HUD renders, including the score row | +| 2.1 `[D:1.4]` | **DONE.** New `MatchSim` autoload (`scripts/match_sim.gd`) carries all Phase 2 hot-path RPCs (`match_config`, `input`, `snapshot`, `score_update`) per §1.1's "hot RPCs live on autoloads" decision — `NetworkedMatch` itself (`scripts/networked_match.gd` + `scenes/networked_match.tscn`, no HUD child) stays a plain scene node with no networking identity of its own. Server builds deterministic team/spawn-index slots by iterating `MatchNet.roster.keys()` sorted, loads a random arena via `ArenaRegistry.random_path()`, spawns ball/ships, then `send_match_config()`s. Client validates the received `arena_path` against `ArenaRegistry.ARENAS` before loading it | Both peers spawn an identical tree in real two-process runs (`tests/networked_match_smoke.gd`/`.tscn`); an invalid arena path is refused before load | +| 2.2 `[D:2.1]` | **DONE.** Server reuses **`RLShipController`** as the remote-input controller exactly as the architecture doc anticipated — each connected peer's real `Ship` is driven by one, fed by `MatchSim.input_received`. `_broadcast_snapshot()` runs every physics tick (60 Hz), packing `NetBodyState` for every ship + ball via `NetCodec.pack_snapshot_body_segment` and sending per-slot, filtered through `multiplayer.get_peers()` so a disconnected peer doesn't get an RPC send attempt | Server-side snapshot cadence confirmed stable at 60 Hz across multiple two-process runs; no "unknown peer ID" spam after the `get_peers()` filter fix (found via a real disconnect-mid-test case) | +| 2.3 `[D:2.2]` | **DONE.** New `scripts/net_interpolator.gd` (`class_name NetInterpolator`, `RefCounted`) buffers up to `MAX_SAMPLES=16` timestamped `NetBodyState`s per remote body and produces interpolated (or clamped-extrapolated, `MAX_EXTRAPOLATION_MS=150`) states at any fractional server tick via `sample_at()`. Client-side `_on_snapshot_received` feeds each body's decoded state into its interpolator; ships/ball spawn `FREEZE_MODE_KINEMATIC` so they never call `_integrate_forces`/`get_action()` | Client observed 31.43 m of real, physics-verified movement over a 2s held-thrust drive purely from interpolated snapshots, no local simulation | +| 2.4 `[D:2.3]` | **DONE — dual-time remote entities** (§4.1). Collider updates happen in `_physics_process` at `server_time_est` (present-time, correct contact resolution); `$Visual` updates happen separately in `_process` at `server_time_est - INTERP_DELAY` (`physics_interpolation_mode = OFF`, since the node's transform is overwritten every rendered frame). `_current_interp_delay_ms()` computes a simplified `INTERP_DELAY` (`one_way + interval*1.5`, clamped `[25,200]` ms) — no jitter term yet, that lands with Phase 3's jitter buffer | Verified via the smoke test's separate collider/visual checks; `Engine.get_physics_frames()`/`Time.get_ticks_msec()` epoch correlation (`NetInterpolator.to_tick()`) confirmed working with no extra sync handshake needed | +| 2.5 `[D:2.3]` `[P]` | **DONE.** `_send_local_input()` samples via a stateless, never-added-to-tree `PlayerShipController` instance (reading real `Input` state) and sends the resulting `ShipAction` every physics tick, no redundancy/buffering yet (Phase 3) | Input reaches the server and visibly moves the ship — confirmed via a real held `move_forward` keypress driving 31.43 m of server-authoritative movement | +| 2.6 `[D:2.3]` `[P]` | **DONE**, and empirically verified, not just inferred — turned out to already be satisfied as a natural consequence of 2.1–2.5's implementation (`_apply_ship_visual_state` already calls `set_visual_action` for remote ships; `_process`'s ball branch already calls `set_visual_speed`) | Smoke test explicitly reads `interpolator.latest().thrust_z` mid-drive and asserts `>0.5` while `move_forward` is held (not inferred from movement alone) — measured `thrust_z=1.00` | +| 2.7 `[D:2.3]` `[P]` | **DONE**, also a natural consequence of the above — `spawn_camera_rig(_my_slot.ship)` and `_spawn_hud()` are called once the client's own ship is identified in `_on_match_config_received` | Smoke test asserts `_camera_rig` and `hud` both `is_instance_valid()` on the client; confirmed true in every clean run | | 2.8 `[D:1.1]` `[P]` | **`net_sim.gd`** — seeded debug-only latency/jitter/loss/duplicate decorator around `MatchNet.send_input` / `send_snapshot`, CLI-driven, asymmetric-capable | `--net-sim-latency 80` measurably raises observed RTT | > **`net_sim.gd` belongs in this phase, not Phase 3.** A LAN-only phase gate passes even with §4.1's flaw fully present, because LAN `INTERP_DELAY` sits at the clamp floor and closing-speed error is small. Phases 2 and 3 would both go green and Phase 4 would discover the architecture is wrong. @@ -1006,6 +1006,9 @@ No own-ship prediction yet: the client renders everything, including its own shi 28. **`ENetMultiplayerPeer`'s `connection_failed` signal is not bounded to anything a UI should make a player wait for.** Verified empirically (task 1.7): against a genuinely refused loopback connection (nothing listening on the target port), `connection_failed` had still not fired 14 seconds in. Don't rely on it alone to end a "Connecting…" state — run your own app-level timeout (`main_menu.gd`'s `CONNECT_TIMEOUT_SECONDS = 6.0`) that shuts the peer down and shows an error regardless of whether ENet ever gets around to reporting failure itself. 29. **A `MultiplayerPeer`'s "am I a client" flag (however you track it — `NetworkManager.is_client` here) turns true the instant `join()`/`create_client()` is called, not once the connection actually completes.** Anything gated on that flag alone (task 1.8's clock ping, in `network_manager.gd`'s `_process`) will try to `rpc_id()` on a peer that's still `CONNECTING` — or has already failed — during a slow or refused connect attempt, and Godot logs "Trying to call an RPC via a multiplayer peer which is not connected" every single frame until it resolves. Gate on the peer's actual `get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED`, not just the higher-level intent flag. 30. **`load()` on a `.gd` file with a parse/compile error does not return `null`.** Found via adversarial review of `tests/test_runner.gd`: it returns a non-null but uninstantiable `GDScript` resource, so `if script == null` silently fails to catch the failure — and the natural next line, `script.new()`, throws "Invalid call: Nonexistent function 'new'", severe enough to abort the *entire calling function* (not just that statement) without ever reaching whatever cleanup/exit code follows. In a loop over multiple files with no per-iteration error boundary, this reads as a hang: the loop that would have moved to the next file, and the code that would have called `quit()`, both never run. The real guard is `Script.can_instantiate()`. +31. **An `@rpc` method named `_input` collides with `Node`'s built-in `_input(event: InputEvent)` virtual.** Found building task 2.1's `MatchSim` autoload: naming the client→server input RPC `_input(bytes: PackedByteArray)` produced a parse error ("function signature doesn't match the parent") — and because this was on an autoload, the error broke the **entire autoload from loading**, cascading into unrelated failures across every scene that touched `MatchSim` at all, none of which mentioned RPCs or `_input` in their own error output. Renamed to `_recv_input`. General lesson: on an autoload especially, treat any bare virtual-sounding method name (`_input`, `_process`, `_ready`, `_unhandled_input`, …) as reserved regardless of what you intend it to do — a signature mismatch there doesn't fail locally, it fails the whole autoload. +32. **Disabling automatic multiplayer polling (task 1.3) is global, not autoload-scoped — every scene that touches an RPC, not just `NetworkManager`-adjacent code, must call `NetworkManager.poll()` itself every frame it wants traffic to move.** Building task 2.1–2.3, `networked_match.gd`'s `_physics_process`/`_process` sent and listened for RPCs (`MatchSim.request_match_config`, `send_input`, snapshot RPCs) but never called `poll()` — nothing sent via `rpc()` in this scene ever reached the wire in either direction, silently, with no error in either process's log. Confirmed via debug prints: the client's request fired, but the host's handler print never appeared. The first (wrong) hypothesis was a startup race between the server's broadcast and the client's listener connecting — that fix (a request/response retry pattern, still worth keeping for the genuine late-join case) didn't resolve it alone. The real fix was adding `NetworkManager.poll()` at the top of both `_physics_process` and `_process` in the new scene. If a scene sends or receives RPCs and nothing arrives with no errors at all, check for a missing `poll()` before anything else. +33. **A request/response fallback for a one-shot broadcast can double-deliver, and the receiving handler must be idempotent.** Once gotcha 32's fix made polling actually work, `_on_match_config_received` ran **twice** per client — once from the server's original one-shot `_match_config.rpc()` broadcast (queued the whole time, since it had been sent before polling was fixed) and again from the request/response retry — producing two arenas, two ship sets, two HUDs (`_slots.size() == 2` instead of 1). Any handler reachable via both an original broadcast and a "resend on request" path needs its own guard (here: `if not _slots.is_empty(): return` at the top) rather than assuming "only sent once" from the RPC design alone. ---