mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
Merge pull request #13 from jcreek/multiplayer-phase1-transport
Add multiplayer functionality
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
name: Dedicated Server Smoke Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
dedicated-server-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build and verify exported dedicated server
|
||||
run: make verify-phase6
|
||||
@@ -0,0 +1,16 @@
|
||||
name: ENet Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
enet-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the pinned Godot test image
|
||||
run: docker build --target enet-test -t cosmic-clash-enet-tests .
|
||||
- name: Run multi-process ENet smoke tests
|
||||
run: docker run --rm cosmic-clash-enet-tests bash scripts/verify_enet_integration.sh
|
||||
@@ -17,5 +17,14 @@ training/checkpoints/*/ppo_*_steps.zip
|
||||
# export_linux.sh / run_training.sh), not a training result.
|
||||
training/build/
|
||||
|
||||
# Exported dedicated server binary (task 6.1): same reasoning — an 85MB
|
||||
# regenerable artifact, rebuilt by `godot --headless --path Game
|
||||
# --export-release "Linux Dedicated Server"`.
|
||||
server/build/
|
||||
|
||||
# Steam exports and local App ID configuration are developer-machine inputs.
|
||||
steam/build/
|
||||
steam_appid.txt
|
||||
|
||||
# Texture generator scripts: throwaway env, not the scripts themselves.
|
||||
tools/textures/.venv/
|
||||
|
||||
@@ -53,11 +53,14 @@ Upstream ships telemetry, and there are **two independent switches** — turning
|
||||
|
||||
## Commands
|
||||
|
||||
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
|
||||
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
|
||||
|
||||
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
|
||||
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
|
||||
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
|
||||
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. Exits 0/1. Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed.
|
||||
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). See `network_manager.gd`'s header comment and `multiplayer-todo.md` §9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
|
||||
- **`main_menu.tscn`'s Host/Join flow** (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
|
||||
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
|
||||
|
||||
## Architecture
|
||||
@@ -66,7 +69,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
|
||||
|
||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
||||
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Local-only dedicated-server build and verification image. Pin the Godot
|
||||
# release family used by project.godot; no image is pushed by this repository.
|
||||
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported
|
||||
WORKDIR /workspace
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libfontconfig1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY Game /workspace/Game
|
||||
# `--import` starts the editor, waits for resource import to finish, then
|
||||
# exits. Do not combine it with `--quit`, which ends the editor after one
|
||||
# iteration and can interrupt generation of `.godot/imported` resources.
|
||||
RUN godot --headless --path Game --import \
|
||||
&& test -f Game/.godot/imported/nebula_station.glb-fa9a6dd87ae3789d04205b52215e2e76.scn \
|
||||
&& test -f Game/.godot/imported/nebula_debris.glb-39af77a17c0998b5b73172577d906cb9.scn \
|
||||
&& test -f Game/.godot/imported/nebula_planet.glb-2431590907ff85cf1057e7d6ad614ed7.scn
|
||||
|
||||
# Test the source client from an untouched, fully imported project. The
|
||||
# dedicated-server export below rewrites the main scene and must not be used
|
||||
# to run client integration tests.
|
||||
FROM project-imported AS enet-test
|
||||
COPY scripts/verify_enet_integration.sh /workspace/scripts/verify_enet_integration.sh
|
||||
|
||||
# Godot dedicated exports disallow command-line scene overrides. Bake the
|
||||
# server scene into this export (the interactive project's source stays
|
||||
# unchanged), then generate the global-script/autoload metadata it needs.
|
||||
FROM project-imported AS exporter
|
||||
RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"|' Game/project.godot \
|
||||
&& mkdir -p /opt/cosmic-clash \
|
||||
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
|
||||
|
||||
FROM --platform=linux/amd64 ubuntu:24.04 AS server
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
|
||||
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
|
||||
RUN chmod 0755 /opt/cosmic-clash/cosmic-clash-server
|
||||
WORKDIR /opt/cosmic-clash
|
||||
EXPOSE 7777/udp
|
||||
ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
|
||||
|
||||
# Test-only target: runs the source client harness against the exported server.
|
||||
FROM exporter AS smoke-client
|
||||
WORKDIR /workspace
|
||||
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Blender authoring sources live here, but the game consumes the exported
|
||||
# runtime assets under res://assets/models. Keep Godot's project scanner from
|
||||
# requiring Blender when importing or testing in headless environments.
|
||||
@@ -1,63 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://ckohaa5ebxym2"
|
||||
path="res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/blender_models/ball.blend"
|
||||
dest_files=["res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
blender/nodes/visible=0
|
||||
blender/nodes/active_collection_only=false
|
||||
blender/nodes/punctual_lights=true
|
||||
blender/nodes/cameras=true
|
||||
blender/nodes/custom_properties=true
|
||||
blender/nodes/modifiers=1
|
||||
blender/meshes/vertex_colors=1
|
||||
blender/meshes/uvs=true
|
||||
blender/meshes/normals=true
|
||||
blender/meshes/export_geometry_nodes_instances=false
|
||||
blender/meshes/gpu_instances=false
|
||||
blender/meshes/tangents=true
|
||||
blender/meshes/skins=2
|
||||
blender/meshes/export_bones_deforming_mesh_only=false
|
||||
blender/materials/unpack_enabled=true
|
||||
blender/materials/export_materials=1
|
||||
blender/animation/limit_playback=true
|
||||
blender/animation/always_sample=true
|
||||
blender/animation/group_tracks=true
|
||||
gltf/naming_version=2
|
||||
gltf/texture_map_mode=1
|
||||
@@ -1,63 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://d2mrvrt1h305x"
|
||||
path="res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/blender_models/nebula_decoration.blend"
|
||||
dest_files=["res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
blender/nodes/visible=0
|
||||
blender/nodes/active_collection_only=false
|
||||
blender/nodes/punctual_lights=true
|
||||
blender/nodes/cameras=true
|
||||
blender/nodes/custom_properties=true
|
||||
blender/nodes/modifiers=1
|
||||
blender/meshes/vertex_colors=1
|
||||
blender/meshes/uvs=true
|
||||
blender/meshes/normals=true
|
||||
blender/meshes/export_geometry_nodes_instances=false
|
||||
blender/meshes/gpu_instances=false
|
||||
blender/meshes/tangents=true
|
||||
blender/meshes/skins=2
|
||||
blender/meshes/export_bones_deforming_mesh_only=false
|
||||
blender/materials/unpack_enabled=true
|
||||
blender/materials/export_materials=1
|
||||
blender/animation/limit_playback=true
|
||||
blender/animation/always_sample=true
|
||||
blender/animation/group_tracks=true
|
||||
gltf/naming_version=2
|
||||
gltf/texture_map_mode=1
|
||||
@@ -1,63 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bp2eqsu8o3082"
|
||||
path="res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/blender_models/ship.blend"
|
||||
dest_files=["res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
blender/nodes/visible=0
|
||||
blender/nodes/active_collection_only=false
|
||||
blender/nodes/punctual_lights=true
|
||||
blender/nodes/cameras=true
|
||||
blender/nodes/custom_properties=true
|
||||
blender/nodes/modifiers=1
|
||||
blender/meshes/vertex_colors=1
|
||||
blender/meshes/uvs=true
|
||||
blender/meshes/normals=true
|
||||
blender/meshes/export_geometry_nodes_instances=false
|
||||
blender/meshes/gpu_instances=false
|
||||
blender/meshes/tangents=true
|
||||
blender/meshes/skins=2
|
||||
blender/meshes/export_bones_deforming_mesh_only=false
|
||||
blender/materials/unpack_enabled=true
|
||||
blender/materials/export_materials=1
|
||||
blender/animation/limit_playback=true
|
||||
blender/animation/always_sample=true
|
||||
blender/animation/group_tracks=true
|
||||
gltf/naming_version=2
|
||||
gltf/texture_map_mode=1
|
||||
@@ -26,3 +26,90 @@ texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Linux Dedicated Server"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=true
|
||||
custom_features="dedicated_server"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../server/build/CosmicClashServer.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=false
|
||||
texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.2]
|
||||
|
||||
name="Linux Steam Client"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features="steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteam.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.2.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.3]
|
||||
|
||||
name="Linux Steam Dedicated Server"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=true
|
||||
custom_features="dedicated_server,steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteamServer.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.3.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=false
|
||||
texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
@@ -15,6 +15,7 @@ collision_mask = 13
|
||||
mass = 3
|
||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||
continuous_cd = true
|
||||
can_sleep = false
|
||||
inertia = Vector3(3, 3, 3)
|
||||
gravity_scale = 0.8
|
||||
linear_damp = 0.1
|
||||
@@ -25,6 +26,8 @@ metadata/_edit_group_ = true
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("SphereShape3D_c5p07")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visual"]
|
||||
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0)
|
||||
mesh = ExtResource("1_ball")
|
||||
|
||||
@@ -17,13 +17,17 @@ collision_mask = 7
|
||||
mass = 5.0
|
||||
physics_material_override = SubResource("PhysicsMaterial_ship")
|
||||
inertia = Vector3(7, 1, 7)
|
||||
can_sleep = false
|
||||
continuous_cd = true
|
||||
script = ExtResource("1_efag7")
|
||||
|
||||
[node name="Nose" type="MeshInstance3D" parent="."]
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
|
||||
[node name="Nose" type="MeshInstance3D" parent="Visual"]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
||||
mesh = ExtResource("3_nose")
|
||||
|
||||
[node name="TailFin" type="MeshInstance3D" parent="."]
|
||||
[node name="TailFin" type="MeshInstance3D" parent="Visual"]
|
||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
||||
mesh = ExtResource("5_talfin")
|
||||
|
||||
|
||||
@@ -21,19 +21,61 @@ run/main_scene="uid://bcq14356s3e2i"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
run/main_scene.training="res://scenes/training.tscn"
|
||||
# Dedicated exports select the server boot scene before the interactive menu
|
||||
# is loaded. This is the same project-setting feature override used above by
|
||||
# the training export.
|
||||
run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
||||
|
||||
[autoload]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
GameSettings="*res://scripts/game_settings.gd"
|
||||
VideoSettings="*res://scripts/video_settings.gd"
|
||||
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||
PerfOverlay="*res://scripts/perf_overlay.gd"
|
||||
NetSim="*res://scripts/net_sim.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]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/size/mode=2
|
||||
; Task 0.17c: kept fixed at "viewport" + 1080p rather than moved to
|
||||
; "disabled", deliberately. A player on a 1440p/4K display cannot render
|
||||
; native this way, and a 1080p player cannot render lower than 1080p through
|
||||
; window scaling alone — but task 0.17b's Viewport.scaling_3d_scale already
|
||||
; covers "render lower than the window" independently of stretch mode (it
|
||||
; scales the 3D viewport's own internal resolution before this blit, not the
|
||||
; window itself), and task 0.15b found an unexplained ~6% non-uniform width
|
||||
; scaling on this project's one tested (Mac/Retina) machine — see
|
||||
; multiplayer-todo.md §5.5.1 — that needs understanding before stretch mode
|
||||
; is touched, not blindly carried into a resolution-dependent change.
|
||||
window/stretch/mode="viewport"
|
||||
window/stretch/aspect="expand"
|
||||
; Task 0.17: default matches VideoSettings.gd's VsyncMode.ADAPTIVE default —
|
||||
; VideoSettings.apply_vsync() overwrites this at runtime via DisplayServer as
|
||||
; soon as the autoload initializes, so this is only what's in effect for the
|
||||
; brief pre-autoload window and if VideoSettings ever fails to load.
|
||||
window/vsync/vsync_mode=2
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
@@ -119,6 +161,17 @@ roll_right={
|
||||
]
|
||||
}
|
||||
|
||||
toggle_perf_overlay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_net_overlay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[layer_names]
|
||||
|
||||
3d_physics/layer_1/name="Ships"
|
||||
@@ -130,9 +183,13 @@ roll_right={
|
||||
|
||||
3d/physics_engine="Jolt Physics"
|
||||
common/physics_interpolation=true
|
||||
common/physics_jitter_fix=0.0
|
||||
|
||||
[rendering]
|
||||
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
anti_aliasing/quality/screen_space_aa=1
|
||||
anti_aliasing/quality/use_debanding=true
|
||||
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||
lights_and_shadows/directional_shadow/size=2048
|
||||
lights_and_shadows/soft_shadow_filter_quality=2
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
|
||||
|
||||
[node name="Lobby" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_lobby")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Lobby"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "Connecting..."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 1"
|
||||
|
||||
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 2"
|
||||
|
||||
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Switch Team"
|
||||
|
||||
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Ready"
|
||||
|
||||
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Leave"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
@@ -100,6 +100,51 @@ custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Play Match"
|
||||
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Multiplayer"
|
||||
|
||||
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "LAN / direct IP — host a match or join one"
|
||||
|
||||
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Host"
|
||||
|
||||
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "127.0.0.1"
|
||||
placeholder_text = "IP address"
|
||||
|
||||
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
custom_minimum_size = Vector2(96, 40)
|
||||
layout_mode = 2
|
||||
text = "Join"
|
||||
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.5, 0.5, 1)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
@@ -180,7 +225,58 @@ custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Watch Match"
|
||||
|
||||
[node name="ConnectingOverlay" type="Control" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="ConnectingOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0, 0, 0, 0.7)
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="ConnectingOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="ConnectingOverlay/CenterContainer"]
|
||||
custom_minimum_size = Vector2(360, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="ConnectingStatusLabel" type="Label" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Connecting..."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="ConnectingCancelButton" type="Button" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Cancel"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
|
||||
|
||||
@@ -7,5 +7,4 @@
|
||||
script = ExtResource("1_m")
|
||||
bot_model_path = "res://bots/promoted/easy.json"
|
||||
|
||||
|
||||
[node name="HUD" parent="." instance=ExtResource("3_m")]
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/server_boot.gd" id="1_sb"]
|
||||
|
||||
[node name="ServerBoot" type="Node"]
|
||||
script = ExtResource("1_sb")
|
||||
@@ -34,6 +34,21 @@ horizontal_alignment = 1
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Graphics preset"
|
||||
|
||||
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
@@ -49,6 +64,33 @@ custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Resolution scale"
|
||||
|
||||
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 4
|
||||
min_value = 0.5
|
||||
max_value = 1.0
|
||||
step = 0.05
|
||||
value = 1.0
|
||||
|
||||
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
@@ -103,6 +145,51 @@ layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "VSync"
|
||||
|
||||
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "FPS cap"
|
||||
|
||||
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Current"
|
||||
|
||||
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "0 fps"
|
||||
|
||||
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
layout_mode = 2
|
||||
@@ -112,7 +199,11 @@ custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
|
||||
@@ -30,6 +30,11 @@ class_name HUDController
|
||||
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
|
||||
|
||||
var ship: Node
|
||||
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
|
||||
# the local peer has no ship of its own. Distinct from `ship == null` by
|
||||
# accident: a missing ship is still an error for a player, and silently
|
||||
# degrading to a spectator HUD would hide that.
|
||||
var spectator_mode := false
|
||||
var _last_score := {0: 0, 1: 0}
|
||||
var _goal_tween: Tween
|
||||
|
||||
@@ -43,6 +48,16 @@ func _initialize_hud():
|
||||
# this runs — not discovered via group, since the "ship" group can have
|
||||
# 2+ members and there's no reliable way to tell which one is "ours".
|
||||
if not ship:
|
||||
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
|
||||
# must still get the score, clock and goal celebration. Only the
|
||||
# per-ship instrument cluster is meaningless without one, so hide that
|
||||
# and carry on wiring everything else — this used to push_error and
|
||||
# bail, which left a spectator with a completely dead HUD.
|
||||
if spectator_mode:
|
||||
print("HUDController: spectator mode — hiding ship instruments")
|
||||
_hide_ship_instruments()
|
||||
_connect_mode_signals()
|
||||
return
|
||||
push_error("HUDController: No ship assigned")
|
||||
return
|
||||
|
||||
@@ -59,6 +74,24 @@ func _initialize_hud():
|
||||
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
|
||||
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
|
||||
|
||||
_connect_mode_signals()
|
||||
|
||||
|
||||
func _hide_ship_instruments() -> void:
|
||||
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
|
||||
# meaning without a ship. Everything else on the HUD still does.
|
||||
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
|
||||
if node and is_instance_valid(node):
|
||||
node.visible = false
|
||||
var cluster := get_node_or_null("Control/Instruments/Cluster")
|
||||
if cluster and is_instance_valid(cluster):
|
||||
cluster.visible = false
|
||||
|
||||
|
||||
# Everything that depends on the MODE rather than on owning a ship: clock,
|
||||
# score, team identity, match-ended, kickoff countdown. A spectator gets all
|
||||
# of it.
|
||||
func _connect_mode_signals() -> void:
|
||||
# Connect to game manager's timer signal; modes without a timer
|
||||
# (e.g. free play) just don't show one
|
||||
var game_manager = get_tree().get_first_node_in_group("game")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class_name AdaptiveInputDepthController
|
||||
extends RefCounted
|
||||
|
||||
# Client-only policy for choosing whether the server's input jitter buffer may
|
||||
# run at depth zero. It deliberately does not change server buffering, action
|
||||
# encoding, or bot behavior; NetworkedMatch pins --test-bot clients at depth 1.
|
||||
|
||||
const TARGET_DEPTH_SAFE := 1
|
||||
const TARGET_DEPTH_LOW_LATENCY := 0
|
||||
const CLEAN_JITTER_MS := 3.0
|
||||
const EXIT_JITTER_MS := 5.0
|
||||
const REQUIRED_STABLE_TICKS := 240
|
||||
const REENTRY_COOLDOWN_TICKS := 120
|
||||
|
||||
var target_depth := TARGET_DEPTH_SAFE
|
||||
var stable_low_jitter_ticks := 0
|
||||
var cooldown_ticks := 0
|
||||
|
||||
|
||||
func update(rtt_ms: float, jitter_ms: float, advertised_depth: int) -> int:
|
||||
if cooldown_ticks > 0:
|
||||
cooldown_ticks -= 1
|
||||
# -2 is a genuine server starvation sentinel. -1 means no header yet and
|
||||
# must not be mistaken for starvation.
|
||||
if advertised_depth < -1 or jitter_ms > EXIT_JITTER_MS:
|
||||
target_depth = TARGET_DEPTH_SAFE
|
||||
stable_low_jitter_ticks = 0
|
||||
cooldown_ticks = REENTRY_COOLDOWN_TICKS
|
||||
return target_depth
|
||||
if rtt_ms >= 0.0 and jitter_ms < CLEAN_JITTER_MS:
|
||||
stable_low_jitter_ticks += 1
|
||||
if stable_low_jitter_ticks >= REQUIRED_STABLE_TICKS and cooldown_ticks == 0:
|
||||
target_depth = TARGET_DEPTH_LOW_LATENCY
|
||||
else:
|
||||
stable_low_jitter_ticks = 0
|
||||
target_depth = TARGET_DEPTH_SAFE
|
||||
return target_depth
|
||||
@@ -0,0 +1 @@
|
||||
uid://dofgtukllr7yr
|
||||
@@ -1,6 +1,13 @@
|
||||
class_name AIShipController
|
||||
extends ShipController
|
||||
|
||||
# Emitted if a cached teammate/opponent reference is found freed and dropped
|
||||
# from the roster (see _decide). No despawn path exists anywhere in this
|
||||
# codebase today — rosters are fixed at match start — so this never fires in
|
||||
# practice; it's cheap insurance against ShipObservations.build() crashing on
|
||||
# a stale reference if that ever changes.
|
||||
signal roster_changed
|
||||
|
||||
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
|
||||
# same canonical observation as training (ShipObservations) and runs the
|
||||
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
|
||||
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
|
||||
func _ready():
|
||||
if not model_path.is_empty():
|
||||
load_policy(model_path)
|
||||
# Stagger the first decision across [1, reaction_ticks] so bots sharing a
|
||||
# reaction cadence don't all run policy inference on the same physics
|
||||
# tick — six bots landing together is a ~2.4 ms spike in a 16.7 ms budget
|
||||
# (policy_network.gd's forward pass). The phase offset this establishes
|
||||
# persists across subsequent decisions since each one re-arms the same
|
||||
# period from wherever _ticks_until_decision currently sits.
|
||||
_ticks_until_decision = randi_range(1, maxi(reaction_ticks, 1))
|
||||
|
||||
|
||||
# League training swaps a frozen opponent's policy between episodes without
|
||||
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
|
||||
|
||||
|
||||
func _decide() -> void:
|
||||
if _teammates.any(func(s): return not is_instance_valid(s)) \
|
||||
or _opponents.any(func(s): return not is_instance_valid(s)):
|
||||
_teammates = _teammates.filter(is_instance_valid)
|
||||
_opponents = _opponents.filter(is_instance_valid)
|
||||
roster_changed.emit()
|
||||
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
||||
var out := _policy.forward(obs)
|
||||
# See ShipActionCodec for the decode — the single source of truth shared
|
||||
|
||||
+48
-12
@@ -19,23 +19,59 @@ extends Node3D
|
||||
@export var glow_hdr_threshold := 1.0
|
||||
|
||||
|
||||
var _env: Environment
|
||||
# Lights authored with shadow_enabled = true (the DirectionalLight3D + 4
|
||||
# PitchLights omnis) — captured once, before gating ever touches them. Every
|
||||
# call after the first re-applies VideoSettings.shadows_enabled to exactly
|
||||
# these lights, so the set can't self-poison (if it were re-derived from
|
||||
# current state, a light this same code just turned off would look
|
||||
# indistinguishable from FillLight, which is authored off on purpose and must
|
||||
# never be turned on by the preset ladder).
|
||||
var _shadow_capable_lights: Array[Light3D] = []
|
||||
|
||||
|
||||
func _ready():
|
||||
add_to_group("arena")
|
||||
# A headless server never renders, so duplicating and configuring a full
|
||||
# Environment (glow/SSAO/SSIL/SDFGI) for it is pure waste — mirrors the
|
||||
# same guard at ship.gd and arena_boundary.gd.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||
if world_env and world_env.environment:
|
||||
var env := world_env.environment.duplicate(true) as Environment
|
||||
world_env.environment = env
|
||||
_env = world_env.environment.duplicate(true) as Environment
|
||||
world_env.environment = _env
|
||||
if sky_material:
|
||||
if not env.sky:
|
||||
env.sky = Sky.new()
|
||||
env.sky.sky_material = sky_material
|
||||
env.ambient_light_color = ambient_light_color
|
||||
env.ambient_light_energy = ambient_light_energy
|
||||
env.glow_intensity = glow_intensity
|
||||
env.glow_strength = glow_strength
|
||||
env.glow_bloom = glow_bloom
|
||||
env.glow_hdr_threshold = glow_hdr_threshold
|
||||
VideoSettings.apply_to_environment(env)
|
||||
if not _env.sky:
|
||||
_env.sky = Sky.new()
|
||||
_env.sky.sky_material = sky_material
|
||||
_env.ambient_light_color = ambient_light_color
|
||||
_env.ambient_light_energy = ambient_light_energy
|
||||
_env.glow_intensity = glow_intensity
|
||||
_env.glow_strength = glow_strength
|
||||
_env.glow_bloom = glow_bloom
|
||||
_env.glow_hdr_threshold = glow_hdr_threshold
|
||||
for light in find_children("*", "Light3D", true, false):
|
||||
if (light as Light3D).shadow_enabled:
|
||||
_shadow_capable_lights.append(light)
|
||||
_apply_video_settings()
|
||||
# Task 0.17: a preset change from the settings menu must take effect
|
||||
# on the arena that's already loaded, not just the next one — this is
|
||||
# the "settings persist and apply without a restart" acceptance bar.
|
||||
VideoSettings.settings_changed.connect(_apply_video_settings)
|
||||
|
||||
|
||||
# Re-run on every VideoSettings.settings_changed (preset or individual
|
||||
# toggle) as well as once at load. Shadow gating lives here rather than in
|
||||
# VideoSettings.apply_to_environment() because it targets Light3D nodes in
|
||||
# this arena's own tree, not the Environment resource.
|
||||
func _apply_video_settings() -> void:
|
||||
if not is_instance_valid(_env):
|
||||
return
|
||||
VideoSettings.apply_to_environment(_env)
|
||||
for light in _shadow_capable_lights:
|
||||
if is_instance_valid(light):
|
||||
light.shadow_enabled = VideoSettings.shadows_enabled
|
||||
|
||||
|
||||
func get_ball_spawn() -> Transform3D:
|
||||
|
||||
@@ -117,6 +117,10 @@ var _field_material: ShaderMaterial
|
||||
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
||||
# pattern so the viewport lookup isn't repeated every frame.
|
||||
var _camera: Camera3D
|
||||
var _last_camera_local_pos := Vector3.INF
|
||||
# Below this, the shader's per-pixel facing test can't produce a visibly
|
||||
# different result — skip the to_local()/set_shader_parameter() call.
|
||||
const CAMERA_UNIFORM_UPDATE_THRESHOLD := 0.05
|
||||
|
||||
# Group every generated collider is tagged with. A CollisionShape3D only
|
||||
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
||||
@@ -184,6 +188,14 @@ func get_surface_pull(
|
||||
global_pos: Vector3, wall_strength: float, wall_range: float,
|
||||
ceiling_strength: float, ceiling_range: float
|
||||
) -> Vector3:
|
||||
# Early-out: every dynamic body pays to_local() plus five _falloff calls
|
||||
# every tick even mid-arena, where every term is exactly zero. Compared
|
||||
# directly against global_pos, matching the same identity-transform
|
||||
# assumption GameMode._is_escaped already makes against these constants.
|
||||
if absf(global_pos.x) < INNER_HALF_X - wall_range \
|
||||
and absf(global_pos.z) < INNER_HALF_Z - wall_range \
|
||||
and global_pos.y < INNER_HEIGHT - ceiling_range:
|
||||
return Vector3.ZERO
|
||||
var p := to_local(global_pos)
|
||||
var pull := Vector3.ZERO
|
||||
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
|
||||
@@ -214,7 +226,11 @@ func _process(_delta: float) -> void:
|
||||
var camera := _get_camera()
|
||||
if camera == null:
|
||||
return # headless (RL/CI) has no camera
|
||||
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
|
||||
var local_pos := to_local(camera.global_position)
|
||||
if local_pos.distance_to(_last_camera_local_pos) < CAMERA_UNIFORM_UPDATE_THRESHOLD:
|
||||
return
|
||||
_last_camera_local_pos = local_pos
|
||||
_field_material.set_shader_parameter("camera_local_pos", local_pos)
|
||||
|
||||
|
||||
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
||||
|
||||
@@ -24,3 +24,26 @@ const ARENAS := [
|
||||
static func random_path() -> String:
|
||||
var candidates := ARENAS.filter(func(arena): return arena["random"])
|
||||
return candidates[randi() % candidates.size()]["path"]
|
||||
|
||||
|
||||
# The arenas a server may rotate through, in declaration order. Same filter as
|
||||
# random_path(): an elevated-goal variant is Free-Play-only until a checkpoint
|
||||
# trained on it is promoted, and a dedicated server rotating onto one would
|
||||
# hand every bot-filled slot an arena it cannot score in.
|
||||
static func rotation_paths() -> Array:
|
||||
return ARENAS.filter(func(arena): return arena["random"]).map(func(arena): return arena["path"])
|
||||
|
||||
|
||||
# Task 6.5's arena rotation, as pure arithmetic so it is unit-testable without
|
||||
# a server: given how many matches have already been played, which arena is
|
||||
# next. `random` deliberately still uses the global RNG (the caller wants
|
||||
# variety, not reproducibility); `sequential` is a pure function of the count,
|
||||
# which is what makes "the server cycles arenas" an assertable claim rather
|
||||
# than an observation about luck.
|
||||
static func path_for_match(match_index: int, mode: String) -> String:
|
||||
var paths := rotation_paths()
|
||||
if paths.is_empty():
|
||||
return ARENAS[0]["path"]
|
||||
if mode == "random":
|
||||
return paths[randi() % paths.size()]
|
||||
return paths[posmod(match_index, paths.size())]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
extends Node
|
||||
|
||||
# Autoload: drops Engine.max_fps while the window is unfocused, so an idle
|
||||
# background window doesn't keep rendering at whatever uncapped rate the
|
||||
# hardware can hit. Independent of — and complementary to — the per-menu
|
||||
# refresh-rate cap in main_menu.gd/settings_menu.gd, which only covers menu
|
||||
# screens; this covers every scene, including gameplay.
|
||||
|
||||
const BACKGROUND_FPS := 30
|
||||
|
||||
# 0 means "uncapped"; also what we restore to if focus is lost before any
|
||||
# menu/gameplay scene has had a chance to set its own cap.
|
||||
var _foreground_max_fps := 0
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
match what:
|
||||
NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||
_foreground_max_fps = Engine.max_fps
|
||||
Engine.max_fps = BACKGROUND_FPS
|
||||
NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||
Engine.max_fps = _foreground_max_fps
|
||||
@@ -0,0 +1 @@
|
||||
uid://kkge43vtwhyv
|
||||
+73
-1
@@ -18,6 +18,63 @@ const MAX_SPEED := 32.0
|
||||
|
||||
var _boundary: ArenaBoundary
|
||||
var _trail: GPUParticles3D
|
||||
@onready var visual: Node3D = $Visual
|
||||
|
||||
var _pending_teleport: Transform3D
|
||||
var _has_pending_teleport := false
|
||||
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||||
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||||
var _pending_teleport_has_velocity := false
|
||||
|
||||
|
||||
# Queues an authoritative teleport, applied at the top of the next
|
||||
# _integrate_forces — the only Jolt-safe place to write state.transform
|
||||
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
|
||||
# physics step via set_deferred("global_transform", ...).
|
||||
func queue_teleport(to: Transform3D) -> void:
|
||||
_pending_teleport = to
|
||||
_has_pending_teleport = true
|
||||
_pending_teleport_has_velocity = false
|
||||
|
||||
|
||||
# Kept parallel to Ship's network correction hook. A locally predicted ball
|
||||
# must resume from the authoritative velocity after a correction; gameplay
|
||||
# resets still deliberately use queue_teleport() and zero both velocities.
|
||||
# The queued-but-not-yet-applied teleport target, or null when none is
|
||||
# pending. queue_teleport() defers the actual write to the next
|
||||
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||||
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||||
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||||
# pre-reset position and corrects it a tick later.
|
||||
func get_pending_teleport():
|
||||
return _pending_teleport if _has_pending_teleport else null
|
||||
|
||||
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
_pending_teleport_angular_velocity = new_angular_velocity
|
||||
_pending_teleport_has_velocity = true
|
||||
_has_pending_teleport = true
|
||||
|
||||
|
||||
# -1 = use the real linear_velocity (default; see _physics_process below).
|
||||
# A frozen remote ball (Phase 4) holds zero velocity — Godot/Jolt zeroes and
|
||||
# ignores velocity writes on frozen bodies — so the trail needs a
|
||||
# presentation-only speed fed in from outside instead of reading physics
|
||||
# state that will never reflect the ball's true remote motion.
|
||||
var _visual_speed_override: float = -1.0
|
||||
|
||||
# Prediction correction hook: exactly like Ship's visual offset, but kept
|
||||
# here so a locally predicted ball can move its collider to authority while
|
||||
# the mesh catches up over a short presentation-only decay.
|
||||
var net_visual_offset := Vector3.ZERO
|
||||
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||
const MAX_NET_VISUAL_OFFSET := 0.4
|
||||
|
||||
|
||||
func set_visual_speed(speed: float) -> void:
|
||||
_visual_speed_override = speed
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -31,8 +88,15 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if net_visual_offset != Vector3.ZERO:
|
||||
net_visual_offset = net_visual_offset.limit_length(MAX_NET_VISUAL_OFFSET)
|
||||
net_visual_offset *= pow(NET_VISUAL_OFFSET_DECAY, _delta * 60.0)
|
||||
if net_visual_offset.length_squared() < 0.0001:
|
||||
net_visual_offset = Vector3.ZERO
|
||||
visual.position = net_visual_offset
|
||||
if _trail:
|
||||
var speed_ratio := clampf(linear_velocity.length() / MAX_SPEED, 0.0, 1.0)
|
||||
var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length()
|
||||
var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0)
|
||||
_trail.emitting = speed_ratio > 0.12
|
||||
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
|
||||
|
||||
@@ -68,6 +132,14 @@ func _build_trail() -> void:
|
||||
|
||||
|
||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
if _has_pending_teleport:
|
||||
_has_pending_teleport = false
|
||||
state.transform = _pending_teleport
|
||||
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
_pending_teleport_has_velocity = false
|
||||
reset_physics_interpolation()
|
||||
|
||||
if _boundary:
|
||||
var pull := _boundary.get_surface_pull(
|
||||
global_position, wall_pull_strength, wall_pull_range,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name EnetTransport
|
||||
extends NetTransport
|
||||
|
||||
func transport_id() -> String:
|
||||
return "enet"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func create_server(port: int, max_clients: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, port: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
+96
-72
@@ -17,16 +17,10 @@ var hud: HUDController
|
||||
var ball: RigidBody3D
|
||||
var ships: Array[Ship] = []
|
||||
var _ship_spawn_transforms := {}
|
||||
var _hit_stop_generation := 0
|
||||
var _hit_stop_active := false
|
||||
var _time_scale_before_hit_stop := 1.0
|
||||
var _camera_rig: ShipCameraRig
|
||||
var _goal_slowmo_active := false
|
||||
var _time_scale_before_goal := 1.0
|
||||
var _goal_in_progress := false
|
||||
|
||||
const GOAL_CELEBRATION_SECONDS := 1.6
|
||||
const GOAL_SLOWMO_SCALE := 0.22
|
||||
|
||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||
# references this or emits a score_changed signal, and HUDController relies
|
||||
@@ -38,6 +32,18 @@ var score := {0: 0, 1: 0}
|
||||
func _ready():
|
||||
# Group lets the HUD discover the game mode for timer/score signals
|
||||
add_to_group("game")
|
||||
# At the default 8, a client hitching to ~20 fps runs up to 8 physics
|
||||
# ticks in one rendered frame — and each of those ticks costs roughly as
|
||||
# much as the frame that caused the hitch, so the client can spiral
|
||||
# further behind instead of recovering. 4 trades a lower worst-case
|
||||
# catch-up rate for bounded per-frame cost.
|
||||
Engine.max_physics_steps_per_frame = 4
|
||||
# A fresh RandomNumberGenerator defaults to a fixed internal state (unlike
|
||||
# the global randf_range, which Godot auto-randomizes at startup), so an
|
||||
# explicit randomize() is required unless a seed was set for reproducible
|
||||
# kickoffs (see kickoff_rng_seed above).
|
||||
if kickoff_rng_seed == 0:
|
||||
_kickoff_rng.randomize()
|
||||
for child in get_children():
|
||||
if child is Arena:
|
||||
arena = child
|
||||
@@ -51,8 +57,9 @@ func _ready():
|
||||
if not arena:
|
||||
push_error("GameMode: scene has no Arena child")
|
||||
return
|
||||
for goal in arena.get_goals():
|
||||
goal.goal_scored.connect(_handle_goal_scored)
|
||||
if _owns_goal_logic():
|
||||
for goal in arena.get_goals():
|
||||
goal.goal_scored.connect(_handle_goal_scored)
|
||||
_start()
|
||||
|
||||
|
||||
@@ -68,6 +75,31 @@ func _start() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# Virtual: whether this mode decides goals from its own local Goal sensors.
|
||||
# True for every mode today. A future networked client mode overrides this
|
||||
# false — it must learn a goal happened from an authoritative server message,
|
||||
# not from an interpolated remote ball wandering through its local Goal
|
||||
# Area3D, which would score client-side against no one.
|
||||
func _owns_goal_logic() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
# Virtual: whether this mode simulates and enforces its own world (escaped-
|
||||
# body respawn runs locally in _physics_process). True for every mode today.
|
||||
# A future networked client mode overrides this false — the server is
|
||||
# authoritative for body positions, and a client respawning a body itself
|
||||
# would fight that authority.
|
||||
func _owns_world_simulation() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
# Virtual: how long the goal cinematic holds before resuming play. Subclasses
|
||||
# that want a different cadence override this instead of touching
|
||||
# GOAL_CELEBRATION_SECONDS directly.
|
||||
func _goal_pause_seconds() -> float:
|
||||
return GOAL_CELEBRATION_SECONDS
|
||||
|
||||
|
||||
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
||||
func _on_goal_scored(_conceding_team: int) -> void:
|
||||
pass
|
||||
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
|
||||
_goal_in_progress = true
|
||||
_on_goal_registered(conceding_team)
|
||||
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
||||
# A scene change (Esc, match end) queued during the celebration removes
|
||||
# this node from the tree before the await chain finishes; resuming past
|
||||
# that point would touch arena/hud state that is mid-teardown.
|
||||
if not is_inside_tree():
|
||||
return
|
||||
await _on_goal_scored(conceding_team)
|
||||
if not is_inside_tree():
|
||||
return
|
||||
_goal_in_progress = false
|
||||
|
||||
|
||||
@@ -97,34 +136,23 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
||||
# real-time presentation delay between episodes.
|
||||
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
||||
return
|
||||
_restore_hit_stop()
|
||||
_goal_slowmo_active = true
|
||||
_time_scale_before_goal = Engine.time_scale
|
||||
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
|
||||
var goal_position := Vector3.ZERO
|
||||
for goal in arena.get_goals():
|
||||
if goal.team == conceding_team:
|
||||
goal_position = goal.global_position
|
||||
break
|
||||
# The cinematic camera cut itself (hard FOV change, cut to a fixed angle)
|
||||
# carries the "moment" that Engine.time_scale slow-mo used to sell —
|
||||
# world simulation speed is never touched, so this behaves identically
|
||||
# for a future networked client watching a shared server sim.
|
||||
_camera_rig.begin_goal_cut(goal_position)
|
||||
if hud:
|
||||
hud.show_goal_celebration(scoring_team)
|
||||
await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout
|
||||
await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout
|
||||
if is_instance_valid(_camera_rig):
|
||||
_camera_rig.end_goal_cut()
|
||||
if hud and is_instance_valid(hud):
|
||||
hud.hide_goal_celebration()
|
||||
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
|
||||
# time scale, but restore any hit-stop that was already queued this frame.
|
||||
_restore_hit_stop()
|
||||
_restore_goal_slowmo()
|
||||
|
||||
|
||||
func _restore_goal_slowmo() -> void:
|
||||
if not _goal_slowmo_active:
|
||||
return
|
||||
Engine.time_scale = _time_scale_before_goal
|
||||
_goal_slowmo_active = false
|
||||
|
||||
|
||||
func spawn_ball() -> RigidBody3D:
|
||||
@@ -136,7 +164,7 @@ func spawn_ball() -> RigidBody3D:
|
||||
|
||||
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
||||
var ship: Ship = ship_scene.instantiate()
|
||||
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
|
||||
ship.name = "Ship_T%d_S%d" % [team, spawn_index]
|
||||
add_child(ship)
|
||||
var spawns := arena.get_ship_spawns(team)
|
||||
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
||||
@@ -155,7 +183,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
add_child(rig)
|
||||
_camera_rig = rig
|
||||
rig.target = target
|
||||
rig.impact_feedback.connect(_on_player_impact)
|
||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||
# than letting it guess via the "ship" group.
|
||||
if hud:
|
||||
@@ -163,42 +190,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
return rig
|
||||
|
||||
|
||||
func _on_player_impact(intensity: float) -> void:
|
||||
if not _goal_slowmo_active:
|
||||
_run_hit_stop(intensity)
|
||||
|
||||
|
||||
func _run_hit_stop(intensity: float) -> void:
|
||||
if _goal_slowmo_active:
|
||||
return
|
||||
_hit_stop_generation += 1
|
||||
var generation := _hit_stop_generation
|
||||
if not _hit_stop_active:
|
||||
_time_scale_before_hit_stop = Engine.time_scale
|
||||
_hit_stop_active = true
|
||||
Engine.time_scale = minf(
|
||||
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
|
||||
)
|
||||
await get_tree().create_timer(
|
||||
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
|
||||
).timeout
|
||||
if generation == _hit_stop_generation:
|
||||
_restore_hit_stop()
|
||||
|
||||
|
||||
func _restore_hit_stop() -> void:
|
||||
if not _hit_stop_active:
|
||||
return
|
||||
Engine.time_scale = _time_scale_before_hit_stop
|
||||
_hit_stop_active = false
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# A scene change during the unscaled timer must never strand global time.
|
||||
_restore_hit_stop()
|
||||
_restore_goal_slowmo()
|
||||
|
||||
|
||||
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
||||
# apply their own GameSettings-override logic first, which differs between
|
||||
# modes (Match lets GameSettings override all three fields, Spectate only
|
||||
@@ -233,6 +224,16 @@ func _record_goal(scoring_team: int) -> void:
|
||||
const KICKOFF_POSITION_JITTER := 0.3
|
||||
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
||||
|
||||
# Owned rather than global `randf_range`, so a fixed seed makes kickoffs
|
||||
# exactly reproducible (replay logs, deterministic tests) without disturbing
|
||||
# any other system's random stream.
|
||||
@export var kickoff_rng_seed: int = 0:
|
||||
set(value):
|
||||
kickoff_rng_seed = value
|
||||
if value != 0:
|
||||
_kickoff_rng.seed = value
|
||||
var _kickoff_rng := RandomNumberGenerator.new()
|
||||
|
||||
|
||||
func reset_ball() -> void:
|
||||
if is_instance_valid(ball):
|
||||
@@ -243,24 +244,33 @@ func reset_ships() -> void:
|
||||
for ship in ships:
|
||||
if is_instance_valid(ship):
|
||||
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
||||
if is_instance_valid(_camera_rig):
|
||||
# _reset_body's queue_teleport defers the actual transform write to
|
||||
# the ship's next _integrate_forces (task 0.15) — snapping the camera
|
||||
# now would read the pre-teleport position. Wait one physics tick so
|
||||
# the teleport has already landed; without this the camera would also
|
||||
# smoothly chase the teleported ship across the arena instead of
|
||||
# cutting with it.
|
||||
await get_tree().physics_frame
|
||||
if is_instance_valid(_camera_rig):
|
||||
_camera_rig.snap_to_target()
|
||||
|
||||
|
||||
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
||||
var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
|
||||
var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter))
|
||||
var basis := to.basis
|
||||
if yaw_jitter > 0.0:
|
||||
basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter))
|
||||
basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter))
|
||||
return Transform3D(basis, to.origin + offset)
|
||||
|
||||
|
||||
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||
# Deferred: a RigidBody3D transform can't be set mid-physics-step
|
||||
body.set_deferred("global_transform", to)
|
||||
body.set_deferred("linear_velocity", Vector3.ZERO)
|
||||
body.set_deferred("angular_velocity", Vector3.ZERO)
|
||||
# A kickoff reset is a teleport: without this, physics interpolation
|
||||
# smears the body across the arena for a frame
|
||||
body.call_deferred("reset_physics_interpolation")
|
||||
# Queued and applied inside the body's own _integrate_forces — the only
|
||||
# Jolt-safe place to write state.transform — instead of racing the
|
||||
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
|
||||
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
|
||||
# so a statically-typed call here won't resolve.
|
||||
body.call("queue_teleport", to)
|
||||
|
||||
|
||||
func _unhandled_input(event):
|
||||
@@ -282,17 +292,31 @@ const ESCAPE_MARGIN := 15.0
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_respawn_escaped_bodies()
|
||||
if _owns_world_simulation():
|
||||
_respawn_escaped_bodies()
|
||||
|
||||
|
||||
func _respawn_escaped_bodies() -> void:
|
||||
var respawned := false
|
||||
for ship in ships:
|
||||
if is_instance_valid(ship) and _is_escaped(ship.global_position):
|
||||
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
|
||||
_reset_body(ship, _ship_spawn_transforms[ship])
|
||||
respawned = true
|
||||
if is_instance_valid(ball) and _is_escaped(ball.global_position):
|
||||
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
|
||||
_reset_body(ball, arena.get_ball_spawn())
|
||||
respawned = true
|
||||
if respawned:
|
||||
_on_bodies_respawned()
|
||||
|
||||
|
||||
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
|
||||
# interpolating toward it would smoothly slide a body the width of the arena
|
||||
# and then fight the correction. NetworkedMatch overrides this to bump
|
||||
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
|
||||
func _on_bodies_respawned() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _is_escaped(position: Vector3) -> bool:
|
||||
|
||||
@@ -38,7 +38,7 @@ func _process(delta: float) -> void:
|
||||
_pitch = new_pitch
|
||||
_roll = new_roll
|
||||
if changed:
|
||||
queue_redraw()
|
||||
_throttled_redraw(delta)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -33,7 +33,7 @@ func _process(delta: float) -> void:
|
||||
var changed := absf(new_value - _value) > max_value * 0.001
|
||||
_value = new_value
|
||||
if changed:
|
||||
queue_redraw()
|
||||
_throttled_redraw(delta)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -35,7 +35,7 @@ func _process(delta: float) -> void:
|
||||
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
||||
_heading = new_heading
|
||||
if changed:
|
||||
queue_redraw()
|
||||
_throttled_redraw(delta)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
||||
@@ -7,6 +7,13 @@ extends Control
|
||||
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
||||
|
||||
const SMOOTHING := 12.0
|
||||
# _draw does real work (text shaping, building point arrays); nobody can
|
||||
# perceive an instrument repainting faster than this, so redraws are paced
|
||||
# to it independently of the render frame rate — value smoothing itself
|
||||
# still runs every _process call, only the (expensive) repaint is throttled.
|
||||
const REDRAW_INTERVAL := 1.0 / 60.0
|
||||
|
||||
var _time_since_redraw := 0.0
|
||||
|
||||
|
||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||
@@ -23,3 +30,12 @@ static func angle_delta_deg(from: float, to: float) -> float:
|
||||
|
||||
func _smoothing_weight(delta: float) -> float:
|
||||
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
|
||||
|
||||
# Call instead of queue_redraw() directly once a subclass's _process has
|
||||
# decided the smoothed value moved enough to warrant a repaint.
|
||||
func _throttled_redraw(delta: float) -> void:
|
||||
_time_since_redraw += delta
|
||||
if _time_since_redraw >= REDRAW_INTERVAL:
|
||||
_time_since_redraw = 0.0
|
||||
queue_redraw()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
class_name InputJitterBuffer
|
||||
extends RefCounted
|
||||
|
||||
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
|
||||
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
|
||||
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
|
||||
# 3.5's unit tests can drive it with scripted arrival traces with no live
|
||||
# match. NetworkedMatch owns one instance per connected slot and is the only
|
||||
# thing that talks to the network layer; this class only knows about
|
||||
# sequence numbers and ShipActions.
|
||||
#
|
||||
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
|
||||
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
|
||||
# large or malicious seq is, and consume() only ever trusts a slot whose
|
||||
# stored seq exactly matches the one it expects — a stale or wrapped-around
|
||||
# entry is indistinguishable from an empty one. Range/rate validation of seq
|
||||
# against the current server tick is the CALLER's job (task 3.4), not this
|
||||
# class's, since only the caller knows the current server tick.
|
||||
|
||||
const RING_SIZE := 32
|
||||
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
|
||||
# tick-rate-derived constant, so left as a literal rather than pulling in
|
||||
# SimConstants for one number.
|
||||
const STARVE_ZERO_TICKS := 30
|
||||
|
||||
var last_applied_seq := -1 # -1: consume() has never been called yet
|
||||
var last_action := ShipAction.new()
|
||||
var starved_ticks := 0
|
||||
var stalled := false
|
||||
|
||||
var _ring_action: Array = []
|
||||
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
||||
# True once ingest() has ever been called for real. Consumption is a no-op
|
||||
# (no starvation counted, no advancement) until then — see ingest()'s own
|
||||
# comment for why an un-seeded buffer would otherwise never converge with
|
||||
# what the client is actually sending.
|
||||
var _seeded := false
|
||||
# Highest seq ever seen by ingest(), regardless of whether it's still in the
|
||||
# ring — consume()'s only way to tell "the data is gone because the ring
|
||||
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
|
||||
# own comment for why this exists: an adversarial review found that without
|
||||
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
|
||||
# server clock drift) permanently zeroed a connected player's input for the
|
||||
# rest of the match.
|
||||
#
|
||||
# Deliberately public (no underscore), same as last_applied_seq: the
|
||||
# networked_match.gd caller's seq-range guard (§3.1 step 4) must bound
|
||||
# against THIS, not against last_applied_seq. A second adversarial review
|
||||
# found that bounding against last_applied_seq caps every accepted seq at
|
||||
# last_applied_seq + RING_SIZE, which in turn caps this field at the same
|
||||
# ceiling — making the resync condition below (which needs this field to
|
||||
# reach expected + RING_SIZE) arithmetically unreachable on the only call
|
||||
# path that exists in production. The two fixes looked independent but
|
||||
# shared a variable and silently cancelled each other out. highest_ingested
|
||||
# tracks the client's own send epoch instead, which the guard can safely
|
||||
# let run ahead of a lagging consumer.
|
||||
var highest_ingested_seq := -1
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_ring_action.resize(RING_SIZE)
|
||||
_ring_seq.resize(RING_SIZE)
|
||||
for i in RING_SIZE:
|
||||
_ring_seq[i] = -1
|
||||
|
||||
|
||||
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
|
||||
# fields directly: actions[i] is the action for sequence (newest_seq - i),
|
||||
# newest-first. Already-consumed or stale entries are silently discarded
|
||||
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
|
||||
# applied tick harmless.
|
||||
func ingest(newest_seq: int, actions: Array) -> void:
|
||||
if not _seeded:
|
||||
# The server starts calling consume() every tick the instant this
|
||||
# slot exists — well before this player's first packet has had time
|
||||
# to arrive (connection handshake, arena/ship spawn, first
|
||||
# _physics_process tick on the client all take real time first). An
|
||||
# un-seeded last_applied_seq of -1 would have consume() "expecting"
|
||||
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
|
||||
# arbitrarily far ahead of whatever the client's own from-1
|
||||
# numbering has actually reached by the time real packets show up —
|
||||
# and since both sides only ever advance monotonically with no
|
||||
# resync mechanism, that gap would never close. Seed to align
|
||||
# "expected" with reality the moment real data first exists.
|
||||
last_applied_seq = newest_seq - actions.size()
|
||||
_seeded = true
|
||||
if newest_seq > highest_ingested_seq:
|
||||
highest_ingested_seq = newest_seq
|
||||
for i in actions.size():
|
||||
var seq: int = newest_seq - i
|
||||
if seq <= last_applied_seq:
|
||||
continue
|
||||
var idx := seq % RING_SIZE
|
||||
_ring_seq[idx] = seq
|
||||
_ring_action[idx] = actions[i]
|
||||
|
||||
|
||||
# Contiguous run of not-yet-applied entries starting right after
|
||||
# last_applied_seq — reported as input_buffer_depth in every snapshot
|
||||
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
|
||||
func depth() -> int:
|
||||
if not _seeded or last_applied_seq < 0:
|
||||
return 0
|
||||
var d := 0
|
||||
var seq := last_applied_seq + 1
|
||||
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
|
||||
d += 1
|
||||
seq += 1
|
||||
return d
|
||||
|
||||
|
||||
# Called once per server physics tick, before the step (§3.2). A no-op
|
||||
# (returns the zero-initialized last_action, no starvation counted) until
|
||||
# this player's first real packet has ever arrived — see ingest()'s comment.
|
||||
func consume() -> ShipAction:
|
||||
if not _seeded:
|
||||
return last_action
|
||||
var expected := last_applied_seq + 1
|
||||
var idx := expected % RING_SIZE
|
||||
|
||||
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
|
||||
# ticks of not-yet-consumed data at once — if the caller has fallen
|
||||
# further behind the newest data actually arriving than that (a host
|
||||
# stall, or persistent client/server clock drift), every tick between
|
||||
# "expected" and "highest_ingested_seq - RING_SIZE" has already been
|
||||
# irrecoverably overwritten by more recent arrivals landing on the same
|
||||
# ring slots. Waiting for it tick-by-tick would starve — and, past
|
||||
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
|
||||
# though fresh, real input already exists in the ring right now. An
|
||||
# adversarial review found and reproduced this exact failure (a ~0.7s
|
||||
# host freeze permanently zeroed a connected player's input for the
|
||||
# rest of the match, with no self-recovery). Skip the unrecoverable
|
||||
# span and resync directly to what the ring can still actually provide.
|
||||
if highest_ingested_seq - expected >= RING_SIZE:
|
||||
last_applied_seq = highest_ingested_seq - RING_SIZE
|
||||
expected = last_applied_seq + 1
|
||||
idx = expected % RING_SIZE
|
||||
|
||||
if _ring_seq[idx] == expected:
|
||||
last_action = _ring_action[idx]
|
||||
starved_ticks = 0
|
||||
stalled = false
|
||||
last_applied_seq = expected
|
||||
return last_action
|
||||
|
||||
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
||||
# and the client already predicted with the real input either way,
|
||||
# so repeating minimises expected divergence (§3.2). Only zero after
|
||||
# a sustained stall, so a disconnecting player's ship doesn't fly
|
||||
# into a wall at full throttle forever.
|
||||
starved_ticks += 1
|
||||
if starved_ticks > STARVE_ZERO_TICKS:
|
||||
last_action = ShipAction.new()
|
||||
stalled = true
|
||||
|
||||
# Only GIVE UP on `expected` when strictly newer data has actually
|
||||
# arrived, which proves it was lost or reordered rather than merely late.
|
||||
#
|
||||
# Advancing unconditionally (what this did originally) is catastrophic
|
||||
# rather than merely lossy, because ingest() discards anything
|
||||
# `seq <= last_applied_seq`. One starve on a sequence the client has not
|
||||
# even sent yet leaves the server permanently one ahead of arrivals:
|
||||
# both sides then advance one per tick, the gap never closes, and every
|
||||
# honest packet is discarded on arrival for the rest of the match. An
|
||||
# adversarial review reproduced exactly that on a clean LAN — the client's
|
||||
# own input_lead RELEASE (delta == 0, which deliberately issues no new
|
||||
# sequence for one tick) is sufficient to trigger it, so it fired roughly
|
||||
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
|
||||
# lead controller's debounce allowed a +3 attack to jump the client clear.
|
||||
#
|
||||
# Holding cannot deadlock: if the client genuinely goes silent,
|
||||
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
|
||||
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
|
||||
# If it falls far behind instead, the ring-overflow resync above still
|
||||
# jumps the cursor forward. Both escape paths are unchanged.
|
||||
if highest_ingested_seq > expected:
|
||||
last_applied_seq = expected
|
||||
return last_action
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp818kexskb34
|
||||
@@ -0,0 +1,120 @@
|
||||
class_name InputLeadController
|
||||
extends RefCounted
|
||||
|
||||
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
|
||||
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
||||
# so it's directly unit-testable against scripted depth traces.
|
||||
#
|
||||
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
|
||||
# with any server-side adaptation: three control loops acting on one plant
|
||||
# (buffer occupancy) with different time constants is a textbook
|
||||
# oscillation, and on a jittery link it presents to the player as
|
||||
# intermittent sticky controls that are nearly impossible to attribute.
|
||||
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
|
||||
# — it does nothing adaptive with it.
|
||||
#
|
||||
# "Lead" is realized concretely as extra distance between this client's own
|
||||
# outgoing sequence numbers and what the server has actually consumed:
|
||||
# skipping a sequence number (jumping the client's own seq counter by more
|
||||
# than 1 for one tick) buys the server one more tick of buffered depth
|
||||
# before it would starve; duplicating one (not incrementing the seq counter
|
||||
# for one tick — the same seq gets sent again) narrows that margin by one
|
||||
# tick of latency. The server's own ring buffer doesn't need to know this
|
||||
# happened: a skipped seq just means "the redundant copies of it never
|
||||
# existed, it's an ordinary drop" (already handled), and a duplicated seq
|
||||
# is a same-seq resend, already discarded harmlessly once consumed
|
||||
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
|
||||
#
|
||||
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
|
||||
# two full seconds to absorb a single wifi spike, during which the player
|
||||
# steers and the ship does not turn, "the most rage-inducing failure mode
|
||||
# in any netcode" per §3.3's own words.
|
||||
|
||||
const LEAD_MIN := 1
|
||||
const LEAD_MAX := 12
|
||||
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
|
||||
# binds the fast-attack side; slow-release's own 60-tick cadence already
|
||||
# exceeds it, so this one constant covers both.
|
||||
const MIN_CHANGE_INTERVAL_TICKS := 30
|
||||
const RELEASE_INTERVAL_TICKS := 60
|
||||
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
|
||||
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
|
||||
# server-reported depth is genuinely ABOVE this — see update()'s own
|
||||
# comment for why gating on `lead` alone (an adversarial review's original
|
||||
# finding here) was wrong.
|
||||
const TARGET_DEPTH := 1
|
||||
|
||||
var lead := LEAD_MIN
|
||||
|
||||
var _ticks_since_change := 0
|
||||
var _clean_surplus_ticks := 0
|
||||
|
||||
|
||||
# Call once per client physics tick with the most recently known server-
|
||||
# reported input_buffer_depth for THIS client's own slot (echoed in every
|
||||
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
|
||||
# yet. Returns the seq delta the caller should add for this tick's
|
||||
# outgoing packet: ordinarily 1 (ship normally increments its send
|
||||
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
|
||||
# change actually fires (skip N extra / duplicate the current one).
|
||||
func update(input_buffer_depth: int, target_depth: int = TARGET_DEPTH) -> int:
|
||||
_ticks_since_change += 1
|
||||
if input_buffer_depth == -1:
|
||||
return 1 # no server depth has arrived yet
|
||||
if input_buffer_depth < -1:
|
||||
# -1 is an explicit server starvation sentinel, distinct from a
|
||||
# healthy zero-depth buffer on an adaptive clean link.
|
||||
_clean_surplus_ticks = 0
|
||||
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
||||
var starve_lead := mini(lead + 3, LEAD_MAX)
|
||||
var starve_delta := starve_lead - lead
|
||||
lead = starve_lead
|
||||
_ticks_since_change = 0
|
||||
return 1 + starve_delta
|
||||
return 1
|
||||
target_depth = maxi(0, target_depth)
|
||||
|
||||
if input_buffer_depth <= target_depth - 1:
|
||||
# A starve: the server's ring was empty for this player when it
|
||||
# built that snapshot. React immediately, not after 2 seconds of
|
||||
# evidence like release requires — but still debounced against
|
||||
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
|
||||
# reports doesn't compound into repeated, overlapping jumps.
|
||||
_clean_surplus_ticks = 0
|
||||
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
||||
var new_lead := mini(lead + 3, LEAD_MAX)
|
||||
var delta := new_lead - lead
|
||||
lead = new_lead
|
||||
_ticks_since_change = 0
|
||||
return 1 + delta
|
||||
return 1
|
||||
|
||||
# Release must react to the ACTUAL server-reported depth, not to this
|
||||
# controller's own memory of past attacks. A first pass at this fix
|
||||
# added the depth check above but left the OLD gate, `lead > LEAD_MIN`,
|
||||
# still ANDed onto the final condition below — so a backlog this
|
||||
# controller did NOT itself cause (a server hitch, persistent client/
|
||||
# server clock drift, a ring resync) still could never be drained:
|
||||
# with lead pinned at its starting floor, that clause always failed
|
||||
# even while input_buffer_depth sat well above target. A second
|
||||
# adversarial review caught it, confirmed by this file's own
|
||||
# test_release_drains_a_backlog_it_never_caused_itself, whose original
|
||||
# assertion text literally said "lead cannot release below its own
|
||||
# floor even under large surplus" as if that were correct.
|
||||
#
|
||||
# The fix splits the one gate into two separate decisions: whether to
|
||||
# duplicate this tick's seq (the only thing that actually narrows real
|
||||
# buffered depth) follows the real signal alone, below; whether to
|
||||
# keep decrementing `lead`'s own bookkeeping below its documented
|
||||
# floor is a separate, cosmetic-only choice made inside that branch.
|
||||
if input_buffer_depth > target_depth:
|
||||
_clean_surplus_ticks += 1
|
||||
else:
|
||||
_clean_surplus_ticks = 0
|
||||
|
||||
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS:
|
||||
if lead > LEAD_MIN:
|
||||
lead -= 1
|
||||
_ticks_since_change = 0
|
||||
return 0 # duplicate this tick's seq — one tick of latency recovered
|
||||
return 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvwwwkf82nkdk
|
||||
@@ -0,0 +1,116 @@
|
||||
extends Control
|
||||
|
||||
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
|
||||
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
|
||||
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
|
||||
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
|
||||
# after NetworkManager.host()/join() succeeds — this scene must always be
|
||||
# loaded that way (as the real current_scene), not instantiated as a child
|
||||
# of something else: change_scene_to_file() operates on
|
||||
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
|
||||
# below call it themselves, which hangs if this scene isn't actually the
|
||||
# tree's current_scene when that happens (see multiplayer-todo.md §9
|
||||
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
|
||||
|
||||
@onready var _status_label: Label = %StatusLabel
|
||||
@onready var _team0_list: VBoxContainer = %Team0List
|
||||
@onready var _team1_list: VBoxContainer = %Team1List
|
||||
@onready var _controls_row: HBoxContainer = %ControlsRow
|
||||
@onready var _switch_team_button: Button = %SwitchTeamButton
|
||||
@onready var _ready_button: CheckButton = %ReadyButton
|
||||
@onready var _leave_button: Button = %LeaveButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
MatchNet.welcomed.connect(_on_welcomed)
|
||||
MatchNet.player_joined.connect(_on_roster_changed)
|
||||
MatchNet.player_left.connect(_on_roster_changed)
|
||||
MatchNet.player_state_changed.connect(_on_roster_changed)
|
||||
MatchNet.rejected.connect(_on_rejected)
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
|
||||
# The server process is never a roster member (§1.1 decision 2) — it
|
||||
# gets a read-only view, no team/ready controls to operate on itself.
|
||||
_controls_row.visible = NetworkManager.is_client
|
||||
|
||||
_refresh()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_leave()
|
||||
|
||||
|
||||
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_welcomed() -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_rejected(reason: String) -> void:
|
||||
_status_label.text = "Connection rejected: %s" % reason
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _on_switch_team_pressed() -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||
if info == null:
|
||||
return
|
||||
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
|
||||
|
||||
|
||||
func _on_ready_toggled(pressed: bool) -> void:
|
||||
MatchNet.request_set_ready(pressed)
|
||||
|
||||
|
||||
func _on_leave_pressed() -> void:
|
||||
_leave()
|
||||
|
||||
|
||||
func _leave() -> void:
|
||||
NetworkManager.shutdown()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
if NetworkManager.is_server:
|
||||
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
|
||||
elif NetworkManager.is_client:
|
||||
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
|
||||
else:
|
||||
_status_label.text = "Not connected"
|
||||
|
||||
for child in _team0_list.get_children():
|
||||
child.queue_free()
|
||||
for child in _team1_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var infos: Array = MatchNet.roster.values()
|
||||
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
|
||||
for info: MatchNet.PlayerInfo in infos:
|
||||
var row := Label.new()
|
||||
var marker := " (you)" if info.peer_id == my_id else ""
|
||||
var ready_mark := "✓" if info.ready else "…"
|
||||
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
|
||||
var target_list := _team0_list if info.team == 0 else _team1_list
|
||||
target_list.add_child(row)
|
||||
|
||||
if NetworkManager.is_client:
|
||||
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||
if my_info != null:
|
||||
_ready_button.set_pressed_no_signal(my_info.ready)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qd513s2cqls3
|
||||
@@ -0,0 +1,78 @@
|
||||
class_name LocalInputTimeline
|
||||
extends RefCounted
|
||||
|
||||
# Client-only sequence/action timeline. It mirrors the stream the server's
|
||||
# InputJitterBuffer will consume: an attack fills its deliberate sequence gap
|
||||
# with repeat-last actions, while a release retransmits immutable data.
|
||||
|
||||
const ShipActionScript = preload("res://scripts/ship_action.gd")
|
||||
const RETAINED_REDUNDANCY := 4
|
||||
|
||||
var latest_issued_seq := 0
|
||||
var latest_applied_seq := -1
|
||||
var configured := false
|
||||
var _actions := {}
|
||||
var _last_issued_action = ShipActionScript.new()
|
||||
var _last_applied_action = ShipActionScript.new()
|
||||
|
||||
|
||||
func configure_initial_delay(delay_ticks: int) -> void:
|
||||
if configured:
|
||||
return
|
||||
latest_applied_seq = -maxi(delay_ticks, 1)
|
||||
configured = true
|
||||
|
||||
|
||||
func issue(delta: int, intent) -> int:
|
||||
if delta <= 0:
|
||||
# An already-issued sequence may be in flight or consumed. Never mutate
|
||||
# it; carry current raw intent to the next unique command instead.
|
||||
return latest_issued_seq
|
||||
var from_seq := latest_issued_seq + 1
|
||||
latest_issued_seq += delta
|
||||
for seq in range(from_seq, latest_issued_seq):
|
||||
_actions[seq] = _last_issued_action.copy()
|
||||
_actions[latest_issued_seq] = intent.copy()
|
||||
_last_issued_action = intent.copy()
|
||||
_prune_consumed_actions()
|
||||
return latest_issued_seq
|
||||
|
||||
|
||||
func consume() -> Dictionary:
|
||||
latest_applied_seq += 1
|
||||
if _actions.has(latest_applied_seq):
|
||||
_last_applied_action = _actions[latest_applied_seq].copy()
|
||||
_prune_consumed_actions()
|
||||
return {"seq": latest_applied_seq, "action": _last_applied_action.copy()}
|
||||
|
||||
|
||||
# The action actually issued for a sequence, or null if it is no longer
|
||||
# retained. Returns a copy: the timeline's stored actions are immutable once
|
||||
# issued (see issue()), and handing out the live object would let a caller
|
||||
# break that from the outside.
|
||||
func action_for(seq: int):
|
||||
if not _actions.has(seq):
|
||||
return null
|
||||
return _actions[seq].copy()
|
||||
|
||||
|
||||
func packet_actions(max_count: int) -> Array:
|
||||
var out: Array = []
|
||||
for seq in range(latest_issued_seq, maxi(0, latest_issued_seq - max_count), -1):
|
||||
if not _actions.has(seq):
|
||||
break
|
||||
out.append(_actions[seq].copy())
|
||||
return out
|
||||
|
||||
|
||||
func retained_action_count() -> int:
|
||||
return _actions.size()
|
||||
|
||||
|
||||
func _prune_consumed_actions() -> void:
|
||||
# Preserve the local command needed for the server's redundancy window,
|
||||
# then discard actions that are older than both consumption and backup use.
|
||||
var keep_from := latest_issued_seq - RETAINED_REDUNDANCY + 1
|
||||
for seq in _actions.keys():
|
||||
if int(seq) < keep_from:
|
||||
_actions.erase(seq)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8nwh3anyddm5
|
||||
@@ -0,0 +1,27 @@
|
||||
class_name LocalNetShipController
|
||||
extends ShipController
|
||||
|
||||
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
|
||||
|
||||
var source: ShipController
|
||||
var timeline: LocalInputTimeline
|
||||
var last_applied_seq := -1
|
||||
var last_sampled_intent: ShipAction
|
||||
|
||||
|
||||
func _init(new_source: ShipController, new_timeline: LocalInputTimeline) -> void:
|
||||
source = new_source
|
||||
timeline = new_timeline
|
||||
last_sampled_intent = ShipAction.new()
|
||||
|
||||
|
||||
func get_action() -> ShipAction:
|
||||
# Ship invokes this exactly once per local physics tick. Prediction must use
|
||||
# the player's current intent immediately; the timeline is transmission and
|
||||
# immutable-redundancy bookkeeping only. Advance its cursor solely to label
|
||||
# this post-step state at the estimated server-consumption sequence; never
|
||||
# use its queued action to delay local control.
|
||||
last_sampled_intent = source.get_action().copy()
|
||||
var label := timeline.consume()
|
||||
last_applied_seq = int(label["seq"])
|
||||
return last_sampled_intent.copy()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyaoxrjb006a8
|
||||
@@ -0,0 +1,294 @@
|
||||
class_name LocalPredictionHistory
|
||||
extends RefCounted
|
||||
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
|
||||
# This is deliberately independent of NetworkedMatch and the scene tree so
|
||||
# sequence/ring behaviour can be tested from scripted traces. Each entry is
|
||||
# tagged with its full sequence number: an old value in a wrapped slot is
|
||||
# never accepted as a prediction for a newer sequence.
|
||||
#
|
||||
# Acknowledge and record are separate producer/consumer clocks. The input
|
||||
# sender can continue producing while snapshots stop arriving, so record()
|
||||
# explicitly marks overflow once more than RING_SIZE unacknowledged sequence
|
||||
# positions exist. It still retains the newest representable window, but
|
||||
# callers can see that an authoritative resync is required instead of
|
||||
# mistaking a wrapped overwrite for a valid comparison.
|
||||
#
|
||||
# resync_required is a live condition, NOT a latch: compare_authoritative()
|
||||
# clears it again once acknowledgements have genuinely caught back up (see
|
||||
# that method). This mirrors input_jitter_buffer.gd's `stalled`, which
|
||||
# likewise drops back to false the moment a normal tick is consumed again.
|
||||
# A latched flag would mean one transient ~2s stall anywhere in a match
|
||||
# permanently pinned every later tick into "needs a hard resync", which is
|
||||
# exactly the behaviour soft correction exists to avoid — and it would also
|
||||
# cap overflow_count at 1 forever, since a second episode could never
|
||||
# observe the flag going false again.
|
||||
#
|
||||
# Two things a "matched" result does NOT guarantee, flagged for whoever
|
||||
# builds task 4.3's actual correction logic on top of this:
|
||||
#
|
||||
# 1. A "matched" result can still be reporting stale data. The slot-tag
|
||||
# equality check in get_prediction() guarantees a match's payload
|
||||
# genuinely belongs to the queried seq (never wrong-seq data mislabeled
|
||||
# as right), but nothing in the "matched" status itself says HOW OLD
|
||||
# that entry is. Under sparse recording (record() is not called with
|
||||
# strictly consecutive seqs — see the record() comment below), an entry
|
||||
# from well over RING_SIZE ticks ago can still report "matched" for a
|
||||
# query landing on its untouched residue. resync_required correctly
|
||||
# stays true in that case (the span guard below is exact), but the
|
||||
# comparison payload itself carries no matched_stale/age distinction. A
|
||||
# caller wanting to reject "matched but ancient" needs to separately
|
||||
# check newest_recorded_seq - seq itself.
|
||||
#
|
||||
# 2. HISTORICAL, now fixed — kept because the reasoning still constrains
|
||||
# callers. record() used to be called twice for the same seq with a
|
||||
# DIFFERENT action on the release path (delta == 0), the later call
|
||||
# silently overwriting the slot. That was wrong, not merely imprecise:
|
||||
# LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq]
|
||||
# for an already-issued sequence ("may be in flight or consumed"), so
|
||||
# the overwrite made this ring contradict the wire — it claimed an
|
||||
# action for S that was never sent for S. networked_match.gd now skips
|
||||
# recording entirely on a release tick, leaving the original (correct)
|
||||
# predicted[S] in place. Callers must keep it that way: an already-
|
||||
# recorded sequence's ACTION is immutable here, exactly as it is in the
|
||||
# timeline. Only overwrite_state()/rebase_state_range() may revise an
|
||||
# entry, and only its state.
|
||||
#
|
||||
# 3. A sequence can be ISSUED without ever being locally SIMULATED. The
|
||||
# input_lead controller's attack path (delta > 1) skips sequence numbers
|
||||
# to buy server-side buffer margin: those gap sequences are filled with
|
||||
# repeat-last actions and sent, but the client took exactly ONE physics
|
||||
# step that tick, so no post-step state exists for them. They are
|
||||
# recorded via record_unsimulated() and report "unsimulated_gap" rather
|
||||
# than "missing_not_recorded" — a routine consequence of this client's
|
||||
# own lead control, NOT evidence of history loss, and specifically not a
|
||||
# hard-snap condition. Distinguishing them matters: treating them as
|
||||
# missing history teleported the ship and armed resync suppression
|
||||
# several times a minute during ordinary play.
|
||||
|
||||
const RING_SIZE := 128
|
||||
|
||||
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
||||
var _ring_entry: Array = []
|
||||
var _has_recorded := false
|
||||
|
||||
var newest_recorded_seq := -1
|
||||
var last_acknowledged_seq := 0
|
||||
var overflow_count := 0
|
||||
var resync_required := false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_ring_seq.resize(RING_SIZE)
|
||||
_ring_entry.resize(RING_SIZE)
|
||||
for i in RING_SIZE:
|
||||
_ring_seq[i] = -1
|
||||
|
||||
|
||||
# A reset starts a new authoritative epoch. Retained inputs/states describe
|
||||
# the old world and must never be compared to the new kickoff state.
|
||||
func begin_epoch() -> void:
|
||||
for i in RING_SIZE:
|
||||
_ring_seq[i] = -1
|
||||
_ring_entry[i] = null
|
||||
_has_recorded = false
|
||||
newest_recorded_seq = -1
|
||||
last_acknowledged_seq = 0
|
||||
resync_required = false
|
||||
|
||||
|
||||
# Stores a private copy of both action and state. Returns true when this
|
||||
# record crossed the unacknowledged-capacity boundary; the caller does not
|
||||
# need that return today, but it makes the eviction event observable rather
|
||||
# than silent when reconciliation starts applying corrections in Phase 4.3.
|
||||
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
# Only the LEADING edge of an episode counts: resync_required is
|
||||
# still true for every subsequent tick of the same stall, and
|
||||
# counting those would report one outage as hundreds. Because
|
||||
# compare_authoritative() can now clear the flag, a genuinely
|
||||
# separate later episode does increment this again.
|
||||
overflowed_now = not resync_required
|
||||
resync_required = true
|
||||
if overflowed_now:
|
||||
overflow_count += 1
|
||||
newest_recorded_seq = seq
|
||||
_has_recorded = true
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
_ring_seq[idx] = seq
|
||||
_ring_entry[idx] = {
|
||||
"action": action.copy(),
|
||||
"state": state.copy(),
|
||||
"contact_window": contact_window,
|
||||
"unsimulated": false,
|
||||
}
|
||||
return overflowed_now
|
||||
|
||||
|
||||
# Records a sequence that was issued and sent but never locally simulated —
|
||||
# an attack's skipped sequence numbers (see note 3 in this file's header).
|
||||
# It advances the same newest/overflow bookkeeping record() does, because the
|
||||
# sequence genuinely is outstanding and the server will genuinely acknowledge
|
||||
# it; only the post-step state is absent, because the client never computed
|
||||
# one. Deliberately carries the action anyway: it is what went on the wire, so
|
||||
# a caller diagnosing an acknowledgement still has the honest command, and
|
||||
# nothing here has to invent a state to keep the ring dense.
|
||||
func record_unsimulated(seq: int, action: ShipAction) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
overflowed_now = not resync_required
|
||||
resync_required = true
|
||||
if overflowed_now:
|
||||
overflow_count += 1
|
||||
newest_recorded_seq = seq
|
||||
_has_recorded = true
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
_ring_seq[idx] = seq
|
||||
_ring_entry[idx] = {
|
||||
"action": action.copy(),
|
||||
"state": null,
|
||||
"contact_window": false,
|
||||
"unsimulated": true,
|
||||
}
|
||||
return overflowed_now
|
||||
|
||||
|
||||
# Returns independent copies so diagnostic/reconciliation consumers cannot
|
||||
# mutate a retained prediction by accident.
|
||||
func get_prediction(seq: int) -> Dictionary:
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
if _ring_seq[idx] != seq:
|
||||
return {}
|
||||
var entry: Dictionary = _ring_entry[idx]
|
||||
if bool(entry.get("unsimulated", false)):
|
||||
# No state to hand back — see note 3. Callers must check this flag
|
||||
# before touching "state"; it is null, not a zeroed NetBodyState,
|
||||
# specifically so a caller that forgets fails loudly instead of
|
||||
# silently comparing against the origin.
|
||||
return {
|
||||
"seq": seq,
|
||||
"action": (entry["action"] as ShipAction).copy(),
|
||||
"state": null,
|
||||
"contact_window": false,
|
||||
"unsimulated": true,
|
||||
}
|
||||
return {
|
||||
"seq": seq,
|
||||
"action": (entry["action"] as ShipAction).copy(),
|
||||
"state": (entry["state"] as NetBodyState).copy(),
|
||||
"contact_window": bool(entry.get("contact_window", false)),
|
||||
"unsimulated": false,
|
||||
}
|
||||
|
||||
|
||||
# Reconciliation changes the state paired with already-sent input, never the
|
||||
# input itself. This is deliberately a no-op for an absent/skipped sequence:
|
||||
# input-lead control permits sparse sequence numbers, so there is no honest
|
||||
# action to invent for such a slot.
|
||||
func overwrite_state(seq: int, state: NetBodyState) -> bool:
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
if _ring_seq[idx] != seq:
|
||||
return false
|
||||
var entry: Dictionary = _ring_entry[idx]
|
||||
if bool(entry.get("unsimulated", false)):
|
||||
# Writing a state here would manufacture a local prediction for a
|
||||
# sequence this client never simulated, which is exactly the fabricated
|
||||
# history §4.4 forbids. The slot stays stateless.
|
||||
return false
|
||||
entry["state"] = state.copy()
|
||||
return true
|
||||
|
||||
|
||||
func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void:
|
||||
for seq in range(from_seq, to_seq + 1):
|
||||
overwrite_state(seq, state)
|
||||
|
||||
|
||||
# Carries an authoritative same-sequence correction through the retained
|
||||
# future. This is intentionally a transport operation, not a synthetic
|
||||
# physics replay: the live Jolt body has already advanced through the real
|
||||
# contact world, and a soft correction must not leave its later comparisons
|
||||
# describing the old trajectory.
|
||||
func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void:
|
||||
for seq in range(from_seq, to_seq + 1):
|
||||
var prediction := get_prediction(seq)
|
||||
if prediction.is_empty() or bool(prediction.get("unsimulated", false)):
|
||||
continue
|
||||
var state: NetBodyState = prediction["state"]
|
||||
state.position += position_delta
|
||||
state.rotation = (rotation_delta * state.rotation).normalized()
|
||||
state.linear_velocity += linear_velocity_delta
|
||||
state.angular_velocity += angular_velocity_delta
|
||||
overwrite_state(seq, state)
|
||||
|
||||
|
||||
# Produces comparison data only. Applying a snap, teleport, velocity delta,
|
||||
# or visual offset belongs to later Phase 4 tasks.
|
||||
func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||
if seq > last_acknowledged_seq:
|
||||
last_acknowledged_seq = seq
|
||||
var prediction := get_prediction(seq)
|
||||
if prediction.is_empty():
|
||||
return {
|
||||
"status": _missing_status(seq),
|
||||
"seq": seq,
|
||||
"authoritative_state": authoritative.copy(),
|
||||
}
|
||||
|
||||
# A successful match is the only evidence that the acknowledgement clock
|
||||
# has genuinely caught back up, so it is the only thing allowed to clear
|
||||
# resync_required — a "missing_evicted"/"missing_not_recorded" result
|
||||
# proves the opposite, and must leave the flag alone.
|
||||
#
|
||||
# The extra span check is not redundant. record() is not guaranteed to be
|
||||
# called with consecutive sequences: input_lead_controller.update() can
|
||||
# return 0 or up to 1+3, so the client's seq can skip forward, leaving a
|
||||
# ring slot holding a tag OLDER than newest_recorded_seq - RING_SIZE
|
||||
# (its residue was simply never rewritten). get_prediction() would still
|
||||
# report that as "matched", so matching alone does not imply the
|
||||
# outstanding window is back within capacity. Gate on the exact inverse
|
||||
# of record()'s own trip inequality instead, which holds regardless of
|
||||
# how sparsely sequences were recorded.
|
||||
if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE:
|
||||
resync_required = false
|
||||
|
||||
if bool(prediction.get("unsimulated", false)):
|
||||
# Reaching this sequence at all proves the acknowledgement clock is
|
||||
# healthy — the entry is present and correctly tagged — so the
|
||||
# resync_required clear above still applies. There is simply nothing
|
||||
# to compare, because the client never simulated this sequence.
|
||||
return {
|
||||
"status": "unsimulated_gap",
|
||||
"seq": seq,
|
||||
"action": prediction["action"],
|
||||
"authoritative_state": authoritative.copy(),
|
||||
}
|
||||
|
||||
var predicted_state: NetBodyState = prediction["state"]
|
||||
var position_error := authoritative.position - predicted_state.position
|
||||
var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation)
|
||||
return {
|
||||
"status": "matched",
|
||||
"seq": seq,
|
||||
"action": prediction["action"],
|
||||
"predicted_state": predicted_state,
|
||||
"authoritative_state": authoritative.copy(),
|
||||
"position_error": position_error,
|
||||
"position_error_magnitude": position_error.length(),
|
||||
"rotation_error_radians": rotation_error_radians,
|
||||
"rotation_error_degrees": rad_to_deg(rotation_error_radians),
|
||||
"linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity,
|
||||
"angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity,
|
||||
"contact_window": bool(prediction.get("contact_window", false)),
|
||||
}
|
||||
|
||||
|
||||
func _missing_status(seq: int) -> String:
|
||||
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
|
||||
return "missing_evicted"
|
||||
return "missing_not_recorded"
|
||||
@@ -0,0 +1 @@
|
||||
uid://goarfpbthyf6
|
||||
+126
-3
@@ -31,9 +31,17 @@ const DIFFICULTIES := [
|
||||
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
||||
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
||||
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
||||
@onready var join_address_edit: LineEdit = %JoinAddressEdit
|
||||
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
|
||||
@onready var connecting_overlay: Control = %ConnectingOverlay
|
||||
@onready var connecting_status_label: Label = %ConnectingStatusLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# An idle menu has no reason to render past the display's own refresh
|
||||
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
|
||||
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
||||
_populate_difficulty_dropdown()
|
||||
_populate_arena_dropdown()
|
||||
dev_section.visible = OS.is_debug_build()
|
||||
@@ -42,9 +50,27 @@ func _ready() -> void:
|
||||
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
||||
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
# (NetworkManager.host() either succeeds immediately or fails immediately),
|
||||
# but Join is not — it can take anywhere from a clean local-network round
|
||||
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
|
||||
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
|
||||
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
|
||||
# path that returns the player to a sane, retryable menu state rather than
|
||||
# just hanging with no feedback.
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _populate_difficulty_dropdown() -> void:
|
||||
difficulty_dropdown.clear()
|
||||
for tier in DIFFICULTIES:
|
||||
@@ -115,10 +141,19 @@ func _selected_path(dropdown: OptionButton) -> String:
|
||||
return ""
|
||||
|
||||
|
||||
# The menu's own refresh-rate fps cap (see _ready) is a menu-only concern;
|
||||
# gameplay scenes respect the player's own VideoSettings fps cap instead
|
||||
# (task 0.17), which only actually caps anything when vsync is Disabled and a
|
||||
# divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0.
|
||||
func _leave_to_gameplay(scene_path: String) -> void:
|
||||
VideoSettings.apply_fps_cap()
|
||||
get_tree().change_scene_to_file(scene_path)
|
||||
|
||||
|
||||
func _on_free_play_pressed() -> void:
|
||||
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
||||
GameSettings.selected_arena_path = chosen["path"]
|
||||
get_tree().change_scene_to_file("res://scenes/free_play.tscn")
|
||||
_leave_to_gameplay("res://scenes/free_play.tscn")
|
||||
|
||||
|
||||
func _on_match_pressed() -> void:
|
||||
@@ -134,7 +169,7 @@ func _on_match_pressed() -> void:
|
||||
GameSettings.selected_bot_path = override_path
|
||||
GameSettings.selected_bot_reaction_ticks = -1
|
||||
GameSettings.selected_bot_action_noise = -1.0
|
||||
get_tree().change_scene_to_file("res://scenes/match.tscn")
|
||||
_leave_to_gameplay("res://scenes/match.tscn")
|
||||
|
||||
|
||||
func _on_settings_pressed() -> void:
|
||||
@@ -144,4 +179,92 @@ func _on_settings_pressed() -> void:
|
||||
func _on_spectate_pressed() -> void:
|
||||
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
||||
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
||||
get_tree().change_scene_to_file("res://scenes/spectate.tscn")
|
||||
_leave_to_gameplay("res://scenes/spectate.tscn")
|
||||
|
||||
|
||||
func _on_host_pressed() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var err := NetworkManager.host()
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not host: %s" % error_string(err))
|
||||
return
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
func _on_join_address_submitted(_new_text: String) -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
|
||||
# anything a menu should make a player wait for — verified empirically
|
||||
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
|
||||
# refused loopback connection: connection_failed never fired within 14s.
|
||||
# This timer is what actually guarantees "connection-refused reaches a sane
|
||||
# UI state" rather than leaving the overlay up indefinitely.
|
||||
const CONNECT_TIMEOUT_SECONDS := 6.0
|
||||
|
||||
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
|
||||
|
||||
|
||||
func _start_join() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var address := join_address_edit.text.strip_edges()
|
||||
if address.is_empty():
|
||||
_show_multiplayer_error("Enter an IP address to join")
|
||||
return
|
||||
var err := NetworkManager.join(address)
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not join: %s" % error_string(err))
|
||||
return
|
||||
connecting_status_label.text = "Connecting to %s..." % address
|
||||
connecting_overlay.visible = true
|
||||
_connect_timeout_token += 1
|
||||
var my_token := _connect_timeout_token
|
||||
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
|
||||
|
||||
|
||||
func _on_connect_timeout(token: int) -> void:
|
||||
if token != _connect_timeout_token or not connecting_overlay.visible:
|
||||
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _on_connecting_cancel_pressed() -> void:
|
||||
_connect_timeout_token += 1
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return # e.g. a stray/late signal after Cancel already shut the peer down
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _leave_to_lobby() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
||||
|
||||
|
||||
func _show_multiplayer_error(message: String) -> void:
|
||||
multiplayer_error_label.text = message
|
||||
multiplayer_error_label.visible = true
|
||||
|
||||
|
||||
func _clear_multiplayer_error() -> void:
|
||||
multiplayer_error_label.visible = false
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
|
||||
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
|
||||
# hello/welcome, strict protocol_version and physics_ticks_per_second
|
||||
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
|
||||
# somewhere durable to keep it across the lobby→match scene transition —
|
||||
# each player's team and ready state. Slot assignment (fixed spawn index
|
||||
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
|
||||
# from this roster's team field at spawn time, not stored redundantly here.
|
||||
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
|
||||
signal player_joined(peer_id: int, player_name: String)
|
||||
signal player_left(peer_id: int)
|
||||
signal player_state_changed(peer_id: int, team: int, ready: bool)
|
||||
signal rejected(reason: String) # client-side only: the server refused our hello
|
||||
signal welcomed() # client-side only: our hello was accepted
|
||||
|
||||
const TEAM_COUNT := 2
|
||||
|
||||
# player_name is the one client-supplied value in _hello that gets broadcast
|
||||
# verbatim to every other peer (protocol_version/tick_hz are checked, never
|
||||
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
|
||||
# the string at all — a legitimate client only ever sends local_player_name,
|
||||
# which the UI already keeps short, so anything past this is a bug or an
|
||||
# attacker, not a real name to truncate politely. Adversarial review found
|
||||
# an unbounded name relayed to every peer head-of-line-blocks the reliable
|
||||
# control channel hard enough that a concurrently-joining client's own
|
||||
# _welcome never arrived — this is what closes that.
|
||||
const MAX_INPUT_LENGTH := 256
|
||||
const MAX_PLAYER_NAME_LENGTH := 24
|
||||
|
||||
|
||||
class PlayerInfo:
|
||||
var peer_id: int
|
||||
var player_name: String
|
||||
var team: int = 0
|
||||
var ready: bool = false
|
||||
|
||||
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
|
||||
peer_id = p_peer_id
|
||||
player_name = p_player_name
|
||||
team = p_team
|
||||
ready = p_ready
|
||||
|
||||
|
||||
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
|
||||
var local_player_name := "Player"
|
||||
|
||||
# Test hook (tests/match_net_smoke.gd): set false before connecting to
|
||||
# suppress the automatic real hello, so a test can send a deliberately
|
||||
# mismatched one instead to exercise the rejection path.
|
||||
var _auto_hello := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
NetworkManager.shutting_down.connect(_on_shutting_down)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
roster.clear()
|
||||
if _auto_hello:
|
||||
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
roster.clear()
|
||||
|
||||
|
||||
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
|
||||
# NetworkManager.shutdown() itself (Leave, or hosting again after already
|
||||
# hosting) never fires disconnected_from_server — that signal only fires
|
||||
# from an incoming multiplayer.server_disconnected event, which a server
|
||||
# never receives about itself. Without this, roster (and every peer's team/
|
||||
# ready state in it) would persist forever across a host/re-host cycle in
|
||||
# the same process.
|
||||
func _on_shutting_down() -> void:
|
||||
roster.clear()
|
||||
|
||||
|
||||
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
|
||||
# proper hello just needs its (possibly absent) roster entry cleaned up.
|
||||
# The normal leave path also goes through here after the server erases it,
|
||||
# guarded by roster.erase()'s own has-check below.
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_remove_player(peer_id)
|
||||
|
||||
|
||||
func _remove_player(peer_id: int) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
roster.erase(peer_id)
|
||||
player_left.emit(peer_id)
|
||||
# rpc() broadcasts to every peer in multiplayer.get_peers() — including,
|
||||
# transiently, the very peer that just disconnected: this fires from
|
||||
# NetworkManager's client_disconnected signal, and empirically that
|
||||
# peer's own ENetConnection can still be momentarily present in the
|
||||
# broadcast's target set with its channels already torn down, which
|
||||
# logs "Unable to send packet on channel 0, max channels: 0" on every
|
||||
# single disconnect (found by a second adversarial review — harmless to
|
||||
# the game, since the departing peer obviously doesn't need to hear
|
||||
# about its own departure, but it meant "clean stderr" wasn't actually
|
||||
# clean for any test in this project).
|
||||
#
|
||||
# A first attempt filtered the broadcast down to rpc_id() calls that
|
||||
# explicitly skip `peer_id`. That's necessary but not sufficient: when
|
||||
# two peers disconnect within the same poll() batch (both bots quitting
|
||||
# at the end of a CI run land within the same tick), get_peers() here
|
||||
# can still list the SECOND peer as connected while its own disconnect
|
||||
# event just hasn't been dispatched yet in this same batch — sending to
|
||||
# it hits the identical error, one hop later. Defer the whole
|
||||
# notification to the next idle frame instead of sending synchronously
|
||||
# from inside signal-handling: by then poll() has fully returned, every
|
||||
# disconnect event in this batch has been dispatched, and get_peers()
|
||||
# reflects the settled, genuinely-still-connected set.
|
||||
call_deferred("_broadcast_player_left", peer_id)
|
||||
|
||||
|
||||
func _broadcast_player_left(peer_id: int) -> void:
|
||||
for other_peer_id in multiplayer.get_peers():
|
||||
if other_peer_id != peer_id:
|
||||
_player_left.rpc_id(other_peer_id, peer_id)
|
||||
|
||||
|
||||
# Balances a new joiner onto whichever team currently has fewer players
|
||||
# (ties go to team 0). Server only.
|
||||
func _pick_balanced_team() -> int:
|
||||
var counts := []
|
||||
counts.resize(TEAM_COUNT)
|
||||
counts.fill(0)
|
||||
for info: PlayerInfo in roster.values():
|
||||
counts[info.team] += 1
|
||||
var best_team := 0
|
||||
for team in range(TEAM_COUNT):
|
||||
if counts[team] < counts[best_team]:
|
||||
best_team = team
|
||||
return best_team
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if roster.has(peer_id):
|
||||
return # duplicate hello from an already-accepted peer; ignore
|
||||
|
||||
if protocol_version != NetCodec.PROTOCOL_VERSION:
|
||||
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
|
||||
return
|
||||
if tick_hz != SimConstants.TICK_HZ:
|
||||
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
|
||||
return
|
||||
if player_name.length() > MAX_INPUT_LENGTH:
|
||||
await _reject(peer_id, "player name too long")
|
||||
return
|
||||
var clean_name := _sanitize_player_name(player_name)
|
||||
|
||||
# Tell the new peer about everyone already here before anyone is told
|
||||
# about them, so no client ever observes an unknown peer_id in a
|
||||
# player_joined it didn't get a prior player_joined for.
|
||||
for existing_id: int in roster.keys():
|
||||
var existing: PlayerInfo = roster[existing_id]
|
||||
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
||||
|
||||
var team := _pick_balanced_team()
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
|
||||
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
|
||||
_welcome.rpc_id(peer_id)
|
||||
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
|
||||
|
||||
|
||||
# Strips control/formatting characters (so a name can't corrupt a log line
|
||||
# or blow out UI layout with e.g. embedded newlines) and clamps to display
|
||||
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
|
||||
# this runs, so this never iterates an attacker-sized string. static: pure
|
||||
# function of its argument, doesn't touch roster/multiplayer — also lets
|
||||
# tests/cases/test_match_net.gd call it with no Node instantiation.
|
||||
static func _sanitize_player_name(raw: String) -> String:
|
||||
var clean := ""
|
||||
for c in raw:
|
||||
var code := c.unicode_at(0)
|
||||
if code >= 0x20 and code != 0x7F:
|
||||
clean += c
|
||||
clean = clean.strip_edges()
|
||||
if clean.length() > MAX_PLAYER_NAME_LENGTH:
|
||||
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
|
||||
if clean.is_empty():
|
||||
clean = "Player"
|
||||
return clean
|
||||
|
||||
|
||||
func _reject(peer_id: int, reason: String) -> void:
|
||||
_rejected.rpc_id(peer_id, reason)
|
||||
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
|
||||
# to actually reach the wire before we pull the connection out from
|
||||
# under it.
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
|
||||
|
||||
# Client-callable requests. Both are fire-and-forget: the authoritative
|
||||
# change comes back through _state_changed once the server applies it, same
|
||||
# as everyone else's — a client never mutates its own roster entry directly.
|
||||
func request_set_team(team: int) -> void:
|
||||
_set_team.rpc_id(1, team)
|
||||
|
||||
|
||||
func request_set_ready(ready: bool) -> void:
|
||||
_set_ready.rpc_id(1, ready)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_team(team: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.team == team:
|
||||
return
|
||||
info.team = team
|
||||
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
||||
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_ready(ready: bool) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.ready == ready:
|
||||
return
|
||||
info.ready = ready
|
||||
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
info.team = team
|
||||
info.ready = ready
|
||||
player_state_changed.emit(peer_id, team, ready)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _welcome() -> void:
|
||||
welcomed.emit()
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _rejected(reason: String) -> void:
|
||||
rejected.emit(reason)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
|
||||
player_joined.emit(peer_id, player_name)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _player_left(peer_id: int) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
roster.erase(peer_id)
|
||||
player_left.emit(peer_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8300uu0s6jqt
|
||||
@@ -0,0 +1,536 @@
|
||||
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
|
||||
# Task 5.10. A packet this autoload dropped before it could ever reach a match,
|
||||
# with the verbatim bytes — the replay log's whole reason to exist is the field
|
||||
# report "my input did nothing", and an accepted-input-only log has thrown away
|
||||
# exactly the evidence that would explain it. `reason` is an InputRejectReason;
|
||||
# the transport layer deliberately does not know about the replay format's own
|
||||
# record kinds, so the mapping lives at the listener.
|
||||
signal input_rejected(peer_id: int, reason: int, bytes: PackedByteArray)
|
||||
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
|
||||
signal score_update_received(score: Dictionary)
|
||||
signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State
|
||||
# §6.2 step 6. positions/rotations are body-order: every slot in order, then
|
||||
# the ball — the same order the snapshot uses, so one convention covers both.
|
||||
# rotations is 4 floats per body (x, y, z, w).
|
||||
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
|
||||
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
|
||||
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
|
||||
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
|
||||
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
|
||||
signal slot_assigned_received(peer_id: int, slot_index: int)
|
||||
|
||||
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
|
||||
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
||||
# level concern independent of any particular match's roster/slot state, and
|
||||
# this autoload already owns the RPC that receives the raw bytes.
|
||||
#
|
||||
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
|
||||
const RATE_LIMIT_PACKETS_PER_SEC := 110
|
||||
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
|
||||
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
|
||||
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
|
||||
# budget scaled by that worst-case size — no separate constant to keep in
|
||||
# sync by hand.
|
||||
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
|
||||
const RATE_LIMIT_WINDOW_MS := 1000
|
||||
# Leaky-bucket excess tolerance, expressed in the same "N seconds' worth of
|
||||
# budget" terms the original consecutive-streak design used. An adversarial
|
||||
# review found that design — a streak counter that HARD-RESET to 0 on any
|
||||
# single clean window — was trivially evaded by a duty-cycled flood (burst,
|
||||
# then one clean window, repeat): reproduced sustaining ~33x the packet
|
||||
# budget indefinitely with zero disconnect warnings. A leaky bucket doesn't
|
||||
# care how the excess is distributed in time — see the window-roll logic
|
||||
# below for how it accumulates and drains.
|
||||
const RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT := RATE_LIMIT_PACKETS_PER_SEC * 3
|
||||
const RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT := RATE_LIMIT_BYTES_PER_SEC * 3
|
||||
const MALFORMED_LIMIT_TO_DISCONNECT := 20
|
||||
# Task 5.10: how many rejected packets per peer per rate-limit window are
|
||||
# forwarded to `input_rejected`. Sized so an honest client — whose rejects are
|
||||
# occasional by definition, since a client rejected every tick is a bug the log
|
||||
# is meant to catch — is never sampled away, while a flood cannot turn the log
|
||||
# into unbounded attacker-controlled disk writes.
|
||||
const REJECTS_RECORDED_PER_WINDOW := 8
|
||||
|
||||
# Server-stall grace (found by task 5.10's own reject recording, which is the
|
||||
# only reason it was visible at all).
|
||||
#
|
||||
# When the server stalls — a 2s SIGSTOP stands in for a GC/IO/scheduler hitch —
|
||||
# the client keeps sending at 60Hz throughout, and ENet delivers that entire
|
||||
# backlog in the first window after resume. Measured: 70 of an HONEST client's
|
||||
# input packets rejected as "rate limit exceeded", against a limit the client
|
||||
# never came close to violating on its own. Redundancy does not cover it: the
|
||||
# dropped packets are CONTIGUOUS, so each one's redundancy window falls inside
|
||||
# the same dropped run — 0 of 70 were rescued, and 82 of 923 sequences (8.88%,
|
||||
# ~1.4s of that player's input) never reached the server at all, versus 0.00%
|
||||
# missing on an otherwise identical run with no stall. Every prediction gate
|
||||
# still passed, which is exactly why this needed the log to find.
|
||||
#
|
||||
# So: don't rate-limit a backlog the server itself caused. The grace is capped,
|
||||
# expires after two windows, and is granted only to peers already being
|
||||
# tracked, so it cannot be farmed by a peer that connects during the stall. An
|
||||
# attacker who can induce server stalls to earn budget already has a strictly
|
||||
# worse capability than sending extra input packets.
|
||||
const STALL_DETECT_MS := 250
|
||||
const MAX_STALL_GRACE_PACKETS := SimConstants.TICK_HZ * 4 # 4s of a 60Hz client's backlog
|
||||
const STALL_GRACE_WINDOWS := 2
|
||||
|
||||
enum InputRejectReason {
|
||||
MALFORMED = 0,
|
||||
RATE_LIMIT = 1,
|
||||
}
|
||||
|
||||
|
||||
class _PeerInputState:
|
||||
var window_start_ms := 0
|
||||
var packets_this_window := 0
|
||||
var bytes_this_window := 0
|
||||
# Leaky bucket: grows by this window's actual total, drains by one
|
||||
# window's worth of budget, every window — regardless of whether that
|
||||
# window was itself over or under budget. A steady rate at or under
|
||||
# budget nets to zero forever (never accumulates); any sustained AVERAGE
|
||||
# above budget accumulates over time no matter how it's shaped into
|
||||
# bursts, unlike a streak counter a clean gap can reset to 0.
|
||||
var excess_packets := 0.0
|
||||
var excess_bytes := 0.0
|
||||
var malformed_count := 0
|
||||
# Reject-recording budget for the current window. Without it the diagnostic
|
||||
# is a remote disk-fill amplifier: the attacker chooses the flood rate, and
|
||||
# every dropped packet would otherwise become a disk write. Capped per
|
||||
# window, reset with the window itself, so an honest client's occasional
|
||||
# reject is always captured while a flood contributes a bounded sample.
|
||||
var rejects_recorded_this_window := 0
|
||||
# Extra packets this peer may send before the limiter treats it as abuse,
|
||||
# granted when the SERVER stalls and expiring shortly after.
|
||||
var grace_packets := 0
|
||||
var grace_windows_left := 0
|
||||
var logged_rate_limit_this_window := false
|
||||
|
||||
|
||||
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
|
||||
# Uncapped lifetime reject totals, so the sampled log can be read against the
|
||||
# true figure — "8 rate-limit rejects recorded" means nothing on its own when
|
||||
# the recorder itself stops at 8 per window. Deliberately NOT part of
|
||||
# _PeerInputState, which is erased the moment a peer disconnects: a departed
|
||||
# peer's reject history is exactly what the post-mortem wants, and the first
|
||||
# version of this lost it (every summary printed an empty dictionary, because
|
||||
# the client had always disconnected by the time the server tore the match
|
||||
# down). peer_id -> {"malformed": int, "rate_limit": int}.
|
||||
var _reject_totals: Dictionary = {}
|
||||
var _last_physics_ms := 0
|
||||
|
||||
# Bandwidth (task 3.7's debug overlay): only the two 60Hz hot-path channels
|
||||
# (input, snapshot) — match_config/score_update are low-frequency control
|
||||
# messages, not what §2's byte-budget analysis or a live overlay cares
|
||||
# about. Rolling per-second counters, recomputed opportunistically on each
|
||||
# send/receive rather than on a timer — nothing needs the rate outside of
|
||||
# an on-demand overlay read anyway. Use get_bytes_sent_per_sec() /
|
||||
# get_bytes_received_per_sec() to READ these, not the raw fields directly
|
||||
# — see those functions for why.
|
||||
const BANDWIDTH_WINDOW_MS := 1000
|
||||
var bytes_sent_per_sec := 0.0
|
||||
var bytes_received_per_sec := 0.0
|
||||
var _sent_window_start_ms := 0
|
||||
var _sent_window_bytes := 0
|
||||
var _received_window_start_ms := 0
|
||||
var _received_window_bytes := 0
|
||||
|
||||
|
||||
# An adversarial review found bytes_*_per_sec only ever gets recomputed
|
||||
# INSIDE _track_sent()/_track_received() — i.e. only when traffic actually
|
||||
# arrives — so if traffic stops entirely (right before a disconnect, or
|
||||
# during exactly the kind of outage this overlay exists to diagnose), the
|
||||
# last computed rate displays forever instead of decaying toward zero.
|
||||
# Report zero once meaningfully more than one window has passed with
|
||||
# nothing tracked, rather than trusting a stale field.
|
||||
func get_bytes_sent_per_sec() -> float:
|
||||
if Time.get_ticks_msec() - _sent_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
|
||||
return 0.0
|
||||
return bytes_sent_per_sec
|
||||
|
||||
|
||||
func get_bytes_received_per_sec() -> float:
|
||||
if Time.get_ticks_msec() - _received_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
|
||||
return 0.0
|
||||
return bytes_received_per_sec
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
|
||||
# Seeded here, not left at 0, so the first physics frame measures a frame
|
||||
# gap rather than the whole process uptime.
|
||||
_last_physics_ms = Time.get_ticks_msec()
|
||||
|
||||
|
||||
# Server-side stall watchdog. A SIGSTOPped or hitching process doesn't run this
|
||||
# either, so the first physics frame after the stall is the one that sees the
|
||||
# whole wall-clock gap — which is precisely the size of the client backlog
|
||||
# about to arrive. Grace is handed only to peers ALREADY sending input, so a
|
||||
# peer that connects during the stall gets none of it.
|
||||
func _physics_process(_delta: float) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
var gap := now - _last_physics_ms
|
||||
_last_physics_ms = now
|
||||
if not multiplayer.is_server() or _peer_input_state.is_empty():
|
||||
return
|
||||
if gap < STALL_DETECT_MS:
|
||||
return
|
||||
var credit: int = mini(int(float(gap) * SimConstants.TICK_HZ / 1000.0), MAX_STALL_GRACE_PACKETS)
|
||||
for peer_id in _peer_input_state:
|
||||
var state: _PeerInputState = _peer_input_state[peer_id]
|
||||
state.grace_packets = mini(state.grace_packets + credit, MAX_STALL_GRACE_PACKETS)
|
||||
state.grace_windows_left = STALL_GRACE_WINDOWS
|
||||
push_warning("MatchSim: server stalled %dms — granting %d packets of rate-limit grace to %d peer(s)" % [
|
||||
gap, credit, _peer_input_state.size()
|
||||
])
|
||||
ServerLog.warn("server_stalled", {"gap_ms": gap, "grace_packets": credit, "peers": _peer_input_state.size()})
|
||||
|
||||
|
||||
func _track_sent(n: int) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if now - _sent_window_start_ms >= BANDWIDTH_WINDOW_MS:
|
||||
bytes_sent_per_sec = _sent_window_bytes * 1000.0 / maxf(1.0, float(now - _sent_window_start_ms))
|
||||
_sent_window_start_ms = now
|
||||
_sent_window_bytes = 0
|
||||
_sent_window_bytes += n
|
||||
|
||||
|
||||
func _track_received(n: int) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if now - _received_window_start_ms >= BANDWIDTH_WINDOW_MS:
|
||||
bytes_received_per_sec = _received_window_bytes * 1000.0 / maxf(1.0, float(now - _received_window_start_ms))
|
||||
_received_window_start_ms = now
|
||||
_received_window_bytes = 0
|
||||
_received_window_bytes += n
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Also the client's cue to ask for live match state — see
|
||||
# NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the
|
||||
# SAME race match_config has: the server sends it when the peer joins the
|
||||
# roster, which is before that peer has loaded the match scene and connected
|
||||
# its listeners, so a one-shot send is simply missed. Delivery has to be
|
||||
# "ask until you get it" for both.
|
||||
signal match_config_requested(peer_id: int)
|
||||
|
||||
|
||||
func request_match_config() -> void:
|
||||
_request_match_config.rpc_id(1)
|
||||
|
||||
|
||||
func send_input(bytes: PackedByteArray) -> void:
|
||||
_track_sent(bytes.size())
|
||||
# bytes is already fully packed (any timestamps it carries are already
|
||||
# fixed), so wrapping the dispatch itself is enough — task 2.8.
|
||||
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
|
||||
|
||||
|
||||
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
|
||||
_track_sent(bytes.size())
|
||||
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
|
||||
|
||||
|
||||
func send_score_update(score: Dictionary) -> void:
|
||||
_score_update.rpc(score)
|
||||
|
||||
|
||||
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
|
||||
# transition happened on rather than a duration — §6.2's closing note: on a
|
||||
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
|
||||
# duration would then be applied from whenever it happened to arrive.
|
||||
# The same state also rides every snapshot's match_state byte, so a client
|
||||
# that misses this entirely still converges (see NetworkedMatch's own
|
||||
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
|
||||
# to carry `at_tick`, not to be the sole channel.
|
||||
func send_state_change(state: int, at_tick: int) -> void:
|
||||
_state_change.rpc(state, at_tick)
|
||||
|
||||
|
||||
# §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the
|
||||
# resulting TRANSFORMS, never a seed. Shared-seed determinism would require
|
||||
# both sides to consume the RNG stream in identical order forever, and the
|
||||
# first randf() anyone later adds to the reset path silently desyncs kickoff
|
||||
# positions with no error message. A few hundred bytes once per kickoff cannot
|
||||
# rot that way.
|
||||
func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
_kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen)
|
||||
|
||||
|
||||
func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
|
||||
|
||||
|
||||
# remaining_ticks is authoritative while `running` is false: a stopped clock
|
||||
# cannot be derived from end_tick minus the current tick, or it drains through
|
||||
# every goal pause and kickoff countdown.
|
||||
func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||
_clock_state.rpc(running, end_tick, remaining_ticks, at_tick)
|
||||
|
||||
|
||||
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
|
||||
# on arrival, sent to one peer rather than broadcast.
|
||||
#
|
||||
# match_config alone is not enough and never was: it carries arena and roster
|
||||
# only, so a late joiner or a reconnecting player had no score, no clock, and
|
||||
# no match state until the next goal or transition happened to fire. An
|
||||
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
|
||||
# exactly this set, so this is that message under a name that does not clash
|
||||
# with MatchNet's own lobby-level welcome.
|
||||
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||
|
||||
|
||||
# §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner
|
||||
# alone: every client holds its own copy of the slot list, and a peer_id that
|
||||
# only the promoted client learns about leaves everyone else's copy naming a
|
||||
# player who is no longer in that seat. Reliable channel 0 — a client that
|
||||
# misses this keeps flying somebody else's ship as a remote body forever, and
|
||||
# unlike match_state there is no per-snapshot field that would re-converge it.
|
||||
func send_slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||
_slot_assigned.rpc(peer_id, slot_index)
|
||||
|
||||
|
||||
@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("authority", "call_remote", "reliable", 0)
|
||||
func _slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||
slot_assigned_received.emit(peer_id, slot_index)
|
||||
|
||||
|
||||
@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"]
|
||||
)
|
||||
match_config_requested.emit(peer_id)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
|
||||
func _recv_input(bytes: PackedByteArray) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_track_received(bytes.size())
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
|
||||
var state: _PeerInputState = _peer_input_state.get(peer_id)
|
||||
if state == null:
|
||||
state = _PeerInputState.new()
|
||||
_peer_input_state[peer_id] = state
|
||||
|
||||
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
|
||||
# packet past the window boundary, not on a timer — this RPC only ever
|
||||
# runs when a packet actually arrives, so there's nothing to roll over
|
||||
# when nothing is arriving anyway.
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
|
||||
# The leaky bucket drains against the SAME budget the window itself was
|
||||
# policed with, grace included — otherwise a server stall would still
|
||||
# accumulate excess toward a disconnect for traffic the server just
|
||||
# explicitly allowed.
|
||||
state.excess_packets = maxf(0.0, state.excess_packets + float(state.packets_this_window) - float(_packet_budget(state)))
|
||||
state.excess_bytes = maxf(0.0, state.excess_bytes + float(state.bytes_this_window) - float(_byte_budget(state)))
|
||||
state.window_start_ms = now_ms
|
||||
state.packets_this_window = 0
|
||||
state.bytes_this_window = 0
|
||||
state.rejects_recorded_this_window = 0
|
||||
state.logged_rate_limit_this_window = false
|
||||
if state.grace_windows_left > 0:
|
||||
state.grace_windows_left -= 1
|
||||
if state.grace_windows_left == 0:
|
||||
state.grace_packets = 0
|
||||
if state.excess_packets > RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT or state.excess_bytes > RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT:
|
||||
# Record before disconnecting, same reasoning as _count_malformed:
|
||||
# the log should contain the packet that ended the connection, not
|
||||
# stop one short of it.
|
||||
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
|
||||
_disconnect_abusive_peer(peer_id, "input rate limit exceeded (excess_packets=%.0f excess_bytes=%.0f)" % [state.excess_packets, state.excess_bytes])
|
||||
return
|
||||
|
||||
state.packets_this_window += 1
|
||||
state.bytes_this_window += bytes.size()
|
||||
if state.packets_this_window > _packet_budget(state) or state.bytes_this_window > _byte_budget(state):
|
||||
# Over budget for the current window — drop, counted above at the next
|
||||
# window roll.
|
||||
if not state.logged_rate_limit_this_window:
|
||||
# ONCE per window, not per packet: a flood is thousands of packets a
|
||||
# second and the log line must not become the amplifier the replay
|
||||
# recorder was capped to avoid being.
|
||||
state.logged_rate_limit_this_window = true
|
||||
ServerLog.warn("rate_limited", {
|
||||
"peer_id": peer_id, "packets": state.packets_this_window,
|
||||
"budget": _packet_budget(state), "grace": state.grace_packets,
|
||||
})
|
||||
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
|
||||
return
|
||||
|
||||
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
|
||||
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
|
||||
# past EOF rather than erroring (found during Phase 2's adversarial
|
||||
# review's hostile-client stress test), so a too-short or size-mismatched
|
||||
# payload would otherwise decode "successfully" into garbage actions
|
||||
# instead of being rejected.
|
||||
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
|
||||
_count_malformed(peer_id, state, bytes)
|
||||
return
|
||||
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
|
||||
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
|
||||
_count_malformed(peer_id, state, bytes)
|
||||
return
|
||||
|
||||
var decoded := NetCodec.unpack_input(bytes)
|
||||
# Carry the verbatim wire bytes alongside the decode. Task 5.10's replay
|
||||
# log stores exactly what arrived rather than a re-serialisation, which is
|
||||
# the whole reason it can reproduce a reported snap: a re-encode would
|
||||
# launder away precisely the malformed or edge-case payload being chased.
|
||||
decoded["raw"] = bytes
|
||||
input_received.emit(peer_id, decoded)
|
||||
|
||||
|
||||
# The budget a peer is actually policed against right now: the standing limit
|
||||
# plus any outstanding server-stall grace. Bytes scale with packets by the same
|
||||
# worst-case-packet factor RATE_LIMIT_BYTES_PER_SEC itself is derived from, so
|
||||
# the two budgets can never drift apart by hand.
|
||||
func _packet_budget(state: _PeerInputState) -> int:
|
||||
return RATE_LIMIT_PACKETS_PER_SEC + state.grace_packets
|
||||
|
||||
|
||||
func _byte_budget(state: _PeerInputState) -> int:
|
||||
return _packet_budget(state) * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
|
||||
|
||||
|
||||
func _count_malformed(peer_id: int, state: _PeerInputState, bytes: PackedByteArray) -> void:
|
||||
state.malformed_count += 1
|
||||
# Emitted before the disconnect check so the packet that finally crossed
|
||||
# the limit is itself in the log, not just the 19 before it.
|
||||
_emit_reject(peer_id, state, InputRejectReason.MALFORMED, bytes)
|
||||
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
|
||||
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
|
||||
|
||||
|
||||
func _emit_reject(peer_id: int, state: _PeerInputState, reason: int, bytes: PackedByteArray) -> void:
|
||||
var totals: Dictionary = _reject_totals.get(peer_id, {"malformed": 0, "rate_limit": 0})
|
||||
var key := "rate_limit" if reason == InputRejectReason.RATE_LIMIT else "malformed"
|
||||
totals[key] = int(totals[key]) + 1
|
||||
_reject_totals[peer_id] = totals
|
||||
if state.rejects_recorded_this_window >= REJECTS_RECORDED_PER_WINDOW:
|
||||
return
|
||||
state.rejects_recorded_this_window += 1
|
||||
input_rejected.emit(peer_id, reason, bytes)
|
||||
|
||||
|
||||
# Server-side, diagnostic. peer_id -> {"malformed": int, "rate_limit": int},
|
||||
# uncapped and surviving the peer's disconnect. Peers with no rejects at all
|
||||
# never appear, so an empty dictionary means a clean session.
|
||||
func get_reject_totals() -> Dictionary:
|
||||
return _reject_totals.duplicate(true)
|
||||
|
||||
|
||||
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
|
||||
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
|
||||
# Task 6.4: the one server event an operator is most likely to be asked
|
||||
# about ("why was I kicked?"), and it was previously only a push_warning —
|
||||
# which does not carry the peer, the reason or a timestamp into the log
|
||||
# stream a container actually captures.
|
||||
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
|
||||
_peer_input_state.erase(peer_id)
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable_ordered", 2)
|
||||
func _snapshot(bytes: PackedByteArray) -> void:
|
||||
_track_received(bytes.size())
|
||||
var decoded := NetCodec.unpack_snapshot(bytes)
|
||||
snapshot_received.emit(decoded)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _state_change(state: int, at_tick: int) -> void:
|
||||
# "authority" already means a forging client is rejected by Godot itself
|
||||
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
|
||||
# an authoritative server sending a state this build doesn't know about is
|
||||
# a real forward-compatibility case — drop it rather than driving the
|
||||
# client into an undefined state.
|
||||
if not MatchState.is_valid(state):
|
||||
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
|
||||
return
|
||||
state_change_received.emit(state, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||
# 4 quaternion floats per body. A mismatch means a corrupt or hostile
|
||||
# payload; dropping it is safe because the snapshot stream still carries
|
||||
# authoritative poses and the next kickoff will re-sync.
|
||||
if rotations.size() != positions.size() * 4:
|
||||
push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()])
|
||||
return
|
||||
kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||
goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||
clock_state_received.emit(running, end_tick, remaining_ticks, at_tick)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||
if not MatchState.is_valid(state):
|
||||
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
|
||||
return
|
||||
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _score_update(score: Dictionary) -> void:
|
||||
score_update_received.emit(score)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bk81de78uwut
|
||||
@@ -0,0 +1,88 @@
|
||||
class_name MatchState
|
||||
|
||||
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
|
||||
#
|
||||
# Pure data + a transition table, deliberately with no scene, RPC or
|
||||
# NetworkedMatch dependency — same reason net_codec.gd and
|
||||
# input_jitter_buffer.gd are standalone: the table can then be exhaustively
|
||||
# unit-tested without a live match.
|
||||
#
|
||||
# The integer values ARE the wire format. `match_state` has been a u8 in the
|
||||
# snapshot header since §2.4 (net_codec.gd's pack_snapshot_body_segment), so
|
||||
# these numbers are protocol, not an implementation detail: never renumber an
|
||||
# existing state, only append. LOBBY is 0 so a zeroed/placeholder snapshot
|
||||
# body decodes to a state that is obviously "not in a match" rather than to
|
||||
# something mid-play.
|
||||
|
||||
enum State {
|
||||
LOBBY = 0,
|
||||
LOADING = 1,
|
||||
WARMUP = 2,
|
||||
PLAYING = 3,
|
||||
GOAL_PAUSE = 4,
|
||||
FULL_TIME = 5,
|
||||
OVERTIME_WARMUP = 6,
|
||||
OVERTIME = 7,
|
||||
RESULTS = 8,
|
||||
}
|
||||
|
||||
# Legal successors, straight from §6.1's diagram. Enforced rather than
|
||||
# documented: an illegal transition is a server logic bug, and the failure it
|
||||
# otherwise produces (clients following the server into a state its own code
|
||||
# never expected to broadcast) is exactly the kind that shows up as an
|
||||
# unreproducible field report three phases later.
|
||||
#
|
||||
# LOBBY is reachable from ANY state and is handled separately in
|
||||
# can_transition() rather than being listed nine times — §6.4's "if the last
|
||||
# human leaves, abort to LOBBY" can fire at any point, including mid-goal.
|
||||
const _SUCCESSORS := {
|
||||
State.LOBBY: [State.LOADING],
|
||||
State.LOADING: [State.WARMUP],
|
||||
State.WARMUP: [State.PLAYING],
|
||||
# A goal, or the clock running out. FULL_TIME is entered on the clock even
|
||||
# if a goal is in flight — §6.2 step 9's clock is authoritative.
|
||||
State.PLAYING: [State.GOAL_PAUSE, State.FULL_TIME],
|
||||
# Back to a kickoff, or straight to results when the goal that caused the
|
||||
# pause also ended the match (golden goal in overtime, or a goal on the
|
||||
# final tick).
|
||||
State.GOAL_PAUSE: [State.WARMUP, State.OVERTIME_WARMUP, State.RESULTS],
|
||||
State.FULL_TIME: [State.OVERTIME_WARMUP, State.RESULTS],
|
||||
State.OVERTIME_WARMUP: [State.OVERTIME],
|
||||
State.OVERTIME: [State.GOAL_PAUSE, State.RESULTS],
|
||||
State.RESULTS: [State.LOBBY],
|
||||
}
|
||||
|
||||
# States in which the simulation is live and inputs drive ships. Everything
|
||||
# else freezes bodies (§6.2 steps 6 and 8). Kept as a set here rather than as
|
||||
# an `if state == PLAYING or state == OVERTIME` scattered through
|
||||
# NetworkedMatch, so adding a future live state can't miss a site.
|
||||
const _LIVE := [State.PLAYING, State.OVERTIME]
|
||||
|
||||
|
||||
static func is_valid(state: int) -> bool:
|
||||
return state in State.values()
|
||||
|
||||
|
||||
static func is_live(state: int) -> bool:
|
||||
return state in _LIVE
|
||||
|
||||
|
||||
# True when the match is over and the clock should not advance. Distinct from
|
||||
# `not is_live()`: a WARMUP is not live but the match is very much ongoing.
|
||||
static func is_terminal(state: int) -> bool:
|
||||
return state == State.RESULTS or state == State.LOBBY
|
||||
|
||||
|
||||
static func can_transition(from_state: int, to_state: int) -> bool:
|
||||
if not is_valid(from_state) or not is_valid(to_state):
|
||||
return false
|
||||
if to_state == State.LOBBY:
|
||||
return from_state != State.LOBBY # §6.4 abort, from anywhere
|
||||
return to_state in _SUCCESSORS.get(from_state, [])
|
||||
|
||||
|
||||
static func to_name(state: int) -> String:
|
||||
for key in State.keys():
|
||||
if State[key] == state:
|
||||
return key
|
||||
return "UNKNOWN(%d)" % state
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1etnxbdelq1p
|
||||
@@ -0,0 +1,47 @@
|
||||
extends RefCounted
|
||||
|
||||
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
|
||||
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
|
||||
# node — NetCodec's pack/unpack must stay callable from pure-function tests
|
||||
# with no live scene. Phase 2's snapshot writer fills one of these per body
|
||||
# per tick from the real RigidBody3D state; Phase 2's interpolator does the
|
||||
# reverse.
|
||||
#
|
||||
# avel_range must match what the sender quantised with (SHIP_AVEL_RANGE vs
|
||||
# BALL_AVEL_RANGE in net_codec.gd) — it is not carried on the wire, because
|
||||
# slot order already tells both peers which body is which (§1.3: "entities
|
||||
# are addressed by integer slot, never by path").
|
||||
|
||||
var position := Vector3.ZERO
|
||||
var rotation := Quaternion.IDENTITY
|
||||
var linear_velocity := Vector3.ZERO
|
||||
var angular_velocity := Vector3.ZERO
|
||||
var frozen := false
|
||||
var turbo := false
|
||||
var thrust_z := 0.0 # -1..1; re-quantised to a 3-bit bin on the wire
|
||||
var stalled := false
|
||||
var avel_range := 4.0 # NetCodec.SHIP_AVEL_RANGE; set to BALL_AVEL_RANGE for the ball
|
||||
|
||||
# Self-referential preload, not get_script().new() — this file deliberately
|
||||
# has no class_name (same cache-timing reason as test_case.gd and other
|
||||
# path-`extends`d files in this project), and get_script().new() throws
|
||||
# "Nonexistent function 'new' in base 'GDScript'" from within the script's
|
||||
# own body in this Godot version.
|
||||
const _NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
|
||||
# Same contract as ShipAction.copy() (see its own comment): a distinct
|
||||
# instance with equal fields, for callers that hold onto a state past the
|
||||
# tick/comparison it was returned in.
|
||||
func copy() -> RefCounted:
|
||||
var c := _NetBodyState.new()
|
||||
c.position = position
|
||||
c.rotation = rotation
|
||||
c.linear_velocity = linear_velocity
|
||||
c.angular_velocity = angular_velocity
|
||||
c.frozen = frozen
|
||||
c.turbo = turbo
|
||||
c.thrust_z = thrust_z
|
||||
c.stalled = stalled
|
||||
c.avel_range = avel_range
|
||||
return c
|
||||
@@ -0,0 +1 @@
|
||||
uid://bc1r0cqvtbqec
|
||||
@@ -0,0 +1,295 @@
|
||||
class_name NetCodec
|
||||
|
||||
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
|
||||
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
|
||||
# no autoload state — so they're testable head-on by tests/test_runner.tscn
|
||||
# without a live connection.
|
||||
#
|
||||
# Referenced from elsewhere via preload(), not the bare class_name, per the
|
||||
# same global-script-class-cache caveat documented in tests/test_case.gd and
|
||||
# sim_constants.gd.
|
||||
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
const ShipAction = preload("res://scripts/ship_action.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
# --- Protocol ---
|
||||
const PROTOCOL_VERSION := 1
|
||||
const TICK_HZ: int = SimConstants.TICK_HZ
|
||||
|
||||
# --- Channels (logical intent; NetworkManager may need to offset these on
|
||||
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
|
||||
const CHANNEL_CONTROL := 0
|
||||
const CHANNEL_INPUT := 1
|
||||
const CHANNEL_SNAPSHOT := 2
|
||||
|
||||
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
|
||||
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
|
||||
|
||||
# --- Input packet (§2.3) ---
|
||||
const MAX_REDUNDANCY := 4
|
||||
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
|
||||
const INPUT_HEADER_SIZE := 12
|
||||
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
|
||||
const INPUT_FLAG_TURBO := 1 << 0
|
||||
|
||||
# --- Snapshot packet (§2.4) ---
|
||||
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
|
||||
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
|
||||
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
|
||||
const SNAPSHOT_BODY_HEADER_SIZE := 8
|
||||
const SNAPSHOT_BODY_SIZE := 22
|
||||
|
||||
const BODY_FLAG_FROZEN := 1 << 0
|
||||
const BODY_FLAG_TURBO := 1 << 1
|
||||
const BODY_FLAG_THRUST_Z_SHIFT := 2
|
||||
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
|
||||
const BODY_FLAG_STALLED := 1 << 5
|
||||
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
|
||||
|
||||
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
|
||||
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
|
||||
# constants these are sized against) ---
|
||||
const POS_RANGE := 64.0 # metres, ±
|
||||
const VEL_RANGE := 64.0 # m/s, ±
|
||||
const QUAT_COMPONENT_RANGE := 1.0
|
||||
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
|
||||
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
|
||||
|
||||
const I16_MAX := 32767
|
||||
const I8_MAX := 127
|
||||
const THRUST_Z_BIN_MAX := 7 # 3 bits
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quantisers — pure, reusable, independently testable.
|
||||
# ============================================================
|
||||
|
||||
static func quantize_i16(value: float, range_max: float) -> int:
|
||||
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
|
||||
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
|
||||
|
||||
static func dequantize_i16(raw: int, range_max: float) -> float:
|
||||
return (float(raw) / I16_MAX) * range_max
|
||||
|
||||
static func quantize_i8(value: float, range_max: float) -> int:
|
||||
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
|
||||
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
|
||||
|
||||
static func dequantize_i8(raw: int, range_max: float) -> float:
|
||||
return (float(raw) / I8_MAX) * range_max
|
||||
|
||||
static func quantize_thrust_z_bin(thrust_z: float) -> int:
|
||||
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
|
||||
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
|
||||
|
||||
static func dequantize_thrust_z_bin(bin_value: int) -> float:
|
||||
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
|
||||
|
||||
|
||||
static func type_version_byte(type: PacketType) -> int:
|
||||
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
|
||||
|
||||
static func packet_type_of(type_version: int) -> int:
|
||||
return (type_version >> 4) & 0x0F
|
||||
|
||||
static func protocol_version_of(type_version: int) -> int:
|
||||
return type_version & 0x0F
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Input packet — client -> server, channel 1 (§2.3)
|
||||
# ============================================================
|
||||
|
||||
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
|
||||
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
|
||||
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u8(type_version_byte(PacketType.INPUT))
|
||||
buf.put_u32(seq)
|
||||
buf.put_u8(count)
|
||||
buf.put_u32(ack_snapshot_tick)
|
||||
buf.put_u16(client_send_ms & 0xFFFF)
|
||||
for i in count:
|
||||
var action: ShipAction = actions[i]
|
||||
buf.put_8(quantize_i8(action.thrust.x, 1.0))
|
||||
buf.put_8(quantize_i8(action.thrust.y, 1.0))
|
||||
buf.put_8(quantize_i8(action.thrust.z, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.x, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.y, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.z, 1.0))
|
||||
var flags := 0
|
||||
if action.turbo:
|
||||
flags |= INPUT_FLAG_TURBO
|
||||
buf.put_u8(flags)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
|
||||
# client_send_ms, actions (Array[ShipAction], newest first).
|
||||
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.data_array = bytes
|
||||
var type_version := buf.get_u8()
|
||||
var seq := buf.get_u32()
|
||||
var count := buf.get_u8()
|
||||
var ack_snapshot_tick := buf.get_u32()
|
||||
var client_send_ms := buf.get_u16()
|
||||
var actions: Array[ShipAction] = []
|
||||
for i in count:
|
||||
var a := ShipAction.new()
|
||||
a.thrust = Vector3(
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0)
|
||||
)
|
||||
a.rotation = Vector3(
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0)
|
||||
)
|
||||
var flags := buf.get_u8()
|
||||
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
|
||||
actions.append(a)
|
||||
return {
|
||||
"type_version": type_version,
|
||||
"seq": seq,
|
||||
"count": count,
|
||||
"ack_snapshot_tick": ack_snapshot_tick,
|
||||
"client_send_ms": client_send_ms,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Snapshot packet — server -> client, channel 2 (§2.4)
|
||||
# ============================================================
|
||||
|
||||
# Shared across every peer this tick — build once, reuse (§2.4's stated
|
||||
# intent). Returns type_version + server_tick + match_state + reset_gen +
|
||||
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
|
||||
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
|
||||
buf.put_u32(server_tick)
|
||||
buf.put_u8(match_state & 0xFF)
|
||||
buf.put_u8(reset_gen & 0xFF)
|
||||
buf.put_u8(bodies.size())
|
||||
for body in bodies:
|
||||
var b: NetBodyState = body
|
||||
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
|
||||
var flags := 0
|
||||
if b.frozen:
|
||||
flags |= BODY_FLAG_FROZEN
|
||||
if b.turbo:
|
||||
flags |= BODY_FLAG_TURBO
|
||||
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
|
||||
if b.stalled:
|
||||
flags |= BODY_FLAG_STALLED
|
||||
if b.rotation.w < 0.0:
|
||||
flags |= BODY_FLAG_QUAT_W_SIGN
|
||||
buf.put_u8(flags)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u32(last_input_seq)
|
||||
buf.put_8(clampi(input_buffer_depth, -128, 127))
|
||||
buf.put_u16(echo_client_send_ms & 0xFFFF)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
# Convenience: one full per-client packet = per-client header + shared body segment.
|
||||
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
|
||||
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
|
||||
var out := PackedByteArray()
|
||||
out.append_array(header)
|
||||
out.append_array(body_segment)
|
||||
return out
|
||||
|
||||
|
||||
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
|
||||
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
|
||||
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.data_array = bytes
|
||||
var last_input_seq := buf.get_u32()
|
||||
var input_buffer_depth := buf.get_8()
|
||||
var echo_client_send_ms := buf.get_u16()
|
||||
var type_version := buf.get_u8()
|
||||
var server_tick := buf.get_u32()
|
||||
var match_state := buf.get_u8()
|
||||
var reset_gen := buf.get_u8()
|
||||
var body_count := buf.get_u8()
|
||||
var bodies: Array[NetBodyState] = []
|
||||
for i in body_count:
|
||||
var b := NetBodyState.new()
|
||||
b.position = Vector3(
|
||||
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||
dequantize_i16(buf.get_16(), POS_RANGE)
|
||||
)
|
||||
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
b.linear_velocity = Vector3(
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE)
|
||||
)
|
||||
# avel_range is unknown to the codec at this point (it isn't on the
|
||||
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
|
||||
# the caller, which knows this slot's body kind, rescale if it's the
|
||||
# ball's slot. Storing the raw i8 would avoid this, but every other
|
||||
# field in this struct is already physical units; consistency wins.
|
||||
b.angular_velocity = Vector3(
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
|
||||
)
|
||||
var flags := buf.get_u8()
|
||||
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
|
||||
b.turbo = (flags & BODY_FLAG_TURBO) != 0
|
||||
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
|
||||
b.thrust_z = dequantize_thrust_z_bin(bin_value)
|
||||
b.stalled = (flags & BODY_FLAG_STALLED) != 0
|
||||
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
|
||||
var w := sqrt(maxf(w_sq, 0.0))
|
||||
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
|
||||
w = -w
|
||||
b.rotation = Quaternion(qx, qy, qz, w)
|
||||
bodies.append(b)
|
||||
return {
|
||||
"last_input_seq": last_input_seq,
|
||||
"input_buffer_depth": input_buffer_depth,
|
||||
"echo_client_send_ms": echo_client_send_ms,
|
||||
"type_version": type_version,
|
||||
"server_tick": server_tick,
|
||||
"match_state": match_state,
|
||||
"reset_gen": reset_gen,
|
||||
"bodies": bodies,
|
||||
}
|
||||
|
||||
|
||||
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
|
||||
# assumption unpack_snapshot() decoded it with to the range it was actually
|
||||
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
|
||||
# immediately after unpack_snapshot(), using slot order to know which.
|
||||
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
|
||||
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
|
||||
body.avel_range = actual_range
|
||||
return
|
||||
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
|
||||
body.avel_range = actual_range
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbb72h1ue0hdp
|
||||
@@ -0,0 +1,87 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Autoload: toggleable network debug overlay (F4 by default — see
|
||||
# toggle_net_overlay in project.godot's [input]). Read-only against
|
||||
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
|
||||
# — headless-guarded, hidden by default, no gameplay-state writes.
|
||||
|
||||
var _label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
set_process(false)
|
||||
return
|
||||
layer = 100
|
||||
_label = Label.new()
|
||||
_label.add_theme_font_size_override("font_size", 14)
|
||||
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
|
||||
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
||||
_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||
_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_label.position = Vector2(12, 90)
|
||||
_label.visible = false
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("toggle_net_overlay") and _label:
|
||||
_label.visible = not _label.visible
|
||||
return
|
||||
if not _label or not _label.visible or not (event is InputEventKey) or not event.pressed or event.echo:
|
||||
return
|
||||
var game := get_tree().get_first_node_in_group("game")
|
||||
if game == null or not game.has_method("adjust_prediction_tuning"):
|
||||
return
|
||||
# Client-only live tuning: [/] threshold, -/= visual decay, ,/. visual
|
||||
# offset, P present-time A/B. Deliberately no project input actions: these
|
||||
# diagnostics never enter ShipAction or server/controller code.
|
||||
match event.keycode:
|
||||
KEY_BRACKETLEFT: game.adjust_prediction_tuning(-0.1)
|
||||
KEY_BRACKETRIGHT: game.adjust_prediction_tuning(0.1)
|
||||
KEY_MINUS: game.adjust_prediction_tuning(0.0, -0.01)
|
||||
KEY_EQUAL: game.adjust_prediction_tuning(0.0, 0.01)
|
||||
KEY_COMMA: game.adjust_prediction_tuning(0.0, 0.0, -0.05)
|
||||
KEY_PERIOD: game.adjust_prediction_tuning(0.0, 0.0, 0.05)
|
||||
KEY_P: game.adjust_prediction_tuning(0.0, 0.0, 0.0, true)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _label or not _label.visible:
|
||||
return
|
||||
if NetworkManager.is_server:
|
||||
_label.text = "NET: server, %d peer(s) out %s in %s" % [
|
||||
MatchNet.roster.size(), _format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
|
||||
]
|
||||
elif NetworkManager.is_client:
|
||||
if NetworkManager.rtt_ms < 0.0:
|
||||
_label.text = "NET: client, connecting (no clock sample yet)"
|
||||
else:
|
||||
# task 3.7: RTT, jitter, loss, buffer depth, snapshot age,
|
||||
# bandwidth all live here now. Prediction error is intentionally
|
||||
# absent — there is no client-side prediction until Phase 4, so
|
||||
# there is nothing honest to show for it yet. STALLED shows the
|
||||
# server's own InputJitterBuffer.stalled bit for this client's
|
||||
# slot, round-tripped through the wire.
|
||||
var stats := {}
|
||||
var game := get_tree().get_first_node_in_group("game")
|
||||
if game and game.has_method("get_net_debug_stats"):
|
||||
stats = game.get_net_debug_stats()
|
||||
var stalled_suffix := " STALLED" if stats.get("server_stalled", false) else ""
|
||||
var prediction: Dictionary = stats.get("prediction", {})
|
||||
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s (target %s) lead %s loss %.1f%% snap age %.1fms%s\npred pos p50/p95/p99 %.3f / %.3f / %.3fm\npred rot p50/p95/p99 %.2f / %.2f / %.2fdeg snaps %.2f/min\nremote residual p99 %.3fm / %.2fdeg A/B present=%s\ntune [/] pos %.2f -/= decay ,/. offset %.2f P toggle\nout %s in %s" % [
|
||||
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
|
||||
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_target_depth", "-")), str(stats.get("input_lead", "-")),
|
||||
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0), stalled_suffix,
|
||||
prediction.get("position_error_p50", 0.0), prediction.get("position_error_p95", 0.0), prediction.get("position_error_p99", 0.0),
|
||||
prediction.get("rotation_error_p50", 0.0), prediction.get("rotation_error_p95", 0.0), prediction.get("rotation_error_p99", 0.0), prediction.get("hard_snap_rate_per_min", 0.0),
|
||||
stats.get("remote_residual_position_p99", 0.0), stats.get("remote_residual_rotation_p99", 0.0), str(game.remote_visual_present_time_enabled if game else false),
|
||||
prediction.get("position_threshold", 0.0), prediction.get("max_visual_offset", 0.0),
|
||||
_format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
|
||||
]
|
||||
else:
|
||||
_label.text = "NET: offline"
|
||||
|
||||
|
||||
func _format_kbps(bytes_per_sec: float) -> String:
|
||||
return "%.2f KB/s" % (bytes_per_sec / 1000.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn2rdmwfo7phi
|
||||
@@ -0,0 +1,126 @@
|
||||
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:
|
||||
# Never let a stale unreliable snapshot rewrite the epoch. The previous
|
||||
# ordering cleared samples on its reset byte before checking tick order,
|
||||
# so a delayed pre-reset packet could alternately flip generations and
|
||||
# repeatedly cancel an active local ball handoff.
|
||||
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
||||
return false
|
||||
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
|
||||
if is_reset:
|
||||
_samples.clear()
|
||||
reset_gen = sample_reset_gen
|
||||
_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 accepts_tick(server_tick: int) -> bool:
|
||||
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
|
||||
|
||||
|
||||
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)
|
||||
var angular_speed := state.angular_velocity.length()
|
||||
if angular_speed > 0.00001:
|
||||
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
|
||||
else:
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgb1vcapxami7
|
||||
@@ -0,0 +1,273 @@
|
||||
extends RefCounted
|
||||
|
||||
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
|
||||
# NetworkedMatch so the decision table is pure-testable; the imperative half
|
||||
# only writes Ship's existing Jolt-safe queued correction hooks.
|
||||
|
||||
const DEFAULT_HARD_POSITION_ERROR := 2.0
|
||||
const DEFAULT_HARD_ROTATION_ERROR_DEGREES := 60.0
|
||||
const DEFAULT_MAX_VISUAL_OFFSET := 0.4
|
||||
const METRIC_SAMPLE_CAPACITY := 3600 # one minute at the 60Hz snapshot rate
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
||||
|
||||
var _last_reset_gen := -1 # first snapshot establishes baseline, never resets
|
||||
var _position_errors: Array[float] = []
|
||||
var _rotation_errors: Array[float] = []
|
||||
var _free_flight_position_errors: Array[float] = []
|
||||
var _free_flight_rotation_errors: Array[float] = []
|
||||
var _visual_correction_errors: Array[float] = []
|
||||
var _free_flight_visual_correction_errors: Array[float] = []
|
||||
var _hard_snap_count := 0
|
||||
var _decision_count := 0
|
||||
var _resync_until_seq := -1
|
||||
var _metrics_started_ms := -1
|
||||
var hard_position_error := DEFAULT_HARD_POSITION_ERROR
|
||||
var hard_rotation_error_degrees := DEFAULT_HARD_ROTATION_ERROR_DEGREES
|
||||
var max_visual_offset := DEFAULT_MAX_VISUAL_OFFSET
|
||||
var _hard_snap_reasons := {}
|
||||
var _hard_snap_cohorts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
var _cohort_counts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
|
||||
|
||||
static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bool, position_threshold: float = DEFAULT_HARD_POSITION_ERROR, rotation_threshold_degrees: float = DEFAULT_HARD_ROTATION_ERROR_DEGREES) -> Dictionary:
|
||||
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if reset_changed:
|
||||
return {"mode": "hard", "reason": "reset_gen"}
|
||||
# An attack's skipped sequence is issued, sent, and acknowledged, but never
|
||||
# locally simulated — there is no predicted state to compare and nothing is
|
||||
# wrong. It is not history loss and must not teleport the ship or arm resync
|
||||
# suppression: the lead controller produces these during ordinary play, and
|
||||
# treating them as missing history cost several unnecessary hard snaps a
|
||||
# minute. Skip the acknowledgement; the next simulated sequence (at most a
|
||||
# tick or two later, since the server consumes one per tick) reconciles
|
||||
# normally against real data.
|
||||
if comparison.get("status", "") == "unsimulated_gap":
|
||||
return {"mode": "skip", "reason": "unsimulated_gap"}
|
||||
if comparison.get("status", "missing_not_recorded") != "matched":
|
||||
return {"mode": "hard", "reason": comparison.get("status", "missing")}
|
||||
if authoritative == null or authoritative.frozen != local_frozen:
|
||||
return {"mode": "hard", "reason": "frozen_mismatch"}
|
||||
if float(comparison["position_error_magnitude"]) > position_threshold:
|
||||
return {"mode": "hard", "reason": "position_error"}
|
||||
if float(comparison["rotation_error_degrees"]) > rotation_threshold_degrees:
|
||||
return {"mode": "hard", "reason": "rotation_error"}
|
||||
return {"mode": "soft", "reason": "within_thresholds"}
|
||||
|
||||
|
||||
static func soft_corrected_transform(current_transform: Transform3D, comparison: Dictionary) -> Transform3D:
|
||||
var authoritative: NetBodyState = comparison["authoritative_state"]
|
||||
var predicted: NetBodyState = comparison["predicted_state"]
|
||||
var position_delta: Vector3 = authoritative.position - predicted.position
|
||||
var rotation_delta := Basis(authoritative.rotation.normalized()) * Basis(predicted.rotation.normalized()).inverse()
|
||||
return Transform3D(
|
||||
(rotation_delta * current_transform.basis).orthonormalized(),
|
||||
current_transform.origin + position_delta
|
||||
)
|
||||
|
||||
|
||||
func reconcile(comparison: Dictionary, ship: Ship, reset_gen: int, current_seq: int, history: LocalPredictionHistory) -> Dictionary:
|
||||
var reset_changed := _last_reset_gen != -1 and reset_gen != _last_reset_gen
|
||||
_last_reset_gen = reset_gen
|
||||
var comparison_seq := int(comparison.get("seq", -1))
|
||||
# A reset is an epoch boundary, never ordinary stale traffic. It must
|
||||
# preempt an outstanding missing-history suppression or the first reset
|
||||
# snapshot could be discarded and every later snapshot share its generation.
|
||||
if reset_changed:
|
||||
_resync_until_seq = -1
|
||||
var reset_decision := decide(comparison, ship.freeze, true, hard_position_error, hard_rotation_error_degrees)
|
||||
_record_metrics(comparison, reset_decision)
|
||||
var reset_authority: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if reset_authority != null:
|
||||
ship.queue_teleport_with_velocity(Transform3D(Basis(reset_authority.rotation), reset_authority.position), reset_authority.linear_velocity, reset_authority.angular_velocity)
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
_resync_until_seq = current_seq + 1
|
||||
return reset_decision
|
||||
if _resync_until_seq >= 0:
|
||||
if comparison.get("status", "") == "matched" and comparison_seq >= _resync_until_seq:
|
||||
_resync_until_seq = -1
|
||||
else:
|
||||
return {"mode": "suppressed", "reason": "awaiting_resync"}
|
||||
var decision := decide(comparison, ship.freeze, false, hard_position_error, hard_rotation_error_degrees)
|
||||
_record_metrics(comparison, decision)
|
||||
if decision["mode"] == "skip":
|
||||
# Deliberately before the authority write below: a skipped acknowledgement
|
||||
# leaves the body, the visual offset and _resync_until_seq exactly as they
|
||||
# were. Nothing about this sequence is unhealthy, so nothing is corrected
|
||||
# and nothing is suppressed.
|
||||
return decision
|
||||
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if authoritative == null:
|
||||
return decision
|
||||
if comparison.get("status", "") == "matched" and decision["reason"] != "reset_gen":
|
||||
# Transport the same-sequence authority error through current Jolt state
|
||||
# and retained predictions. This deliberately avoids fake single-body
|
||||
# replay, which cannot reproduce contact impulses/friction.
|
||||
var predicted: NetBodyState = comparison["predicted_state"]
|
||||
var position_delta: Vector3 = comparison["position_error"]
|
||||
var velocity_error: Vector3 = comparison["linear_velocity_error"]
|
||||
var angular_velocity_error: Vector3 = comparison["angular_velocity_error"]
|
||||
var rotation_delta := (authoritative.rotation.normalized() * predicted.rotation.normalized().inverse()).normalized()
|
||||
history.overwrite_state(int(comparison["seq"]), authoritative)
|
||||
history.rebase_state_range(int(comparison["seq"]) + 1, current_seq, position_delta, rotation_delta, velocity_error, angular_velocity_error)
|
||||
var old_basis := ship.global_transform.basis
|
||||
var corrected_transform := soft_corrected_transform(ship.global_transform, comparison)
|
||||
# Apply both velocity deltas to the live body atomically with pose. The
|
||||
# same deltas are transported through retained history above.
|
||||
ship.queue_teleport_with_velocity(corrected_transform, ship.linear_velocity + velocity_error, ship.angular_velocity + angular_velocity_error)
|
||||
var position_error: Vector3 = comparison["position_error"]
|
||||
if decision["mode"] == "soft":
|
||||
ship.net_visual_offset = (ship.global_transform.basis.inverse() * -position_error).limit_length(max_visual_offset)
|
||||
# The body rotates in world space. Convert the inverse correction to
|
||||
# the child visual's local basis so its global orientation is preserved
|
||||
# through the physical correction (B_old^-1 Δ^-1 B_old).
|
||||
var local_visual_delta: Basis = old_basis.inverse() * Basis(rotation_delta.inverse()) * old_basis
|
||||
ship.net_visual_rotation_offset = local_visual_delta.get_rotation_quaternion() * ship.net_visual_rotation_offset
|
||||
else:
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
else:
|
||||
# Reset/missing state has no trustworthy delta. Place authority once;
|
||||
# callers must wait for a new matched history entry before correction.
|
||||
ship.queue_teleport_with_velocity(Transform3D(Basis(authoritative.rotation), authoritative.position), authoritative.linear_velocity, authoritative.angular_velocity)
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
# Retain no fabricated future. Once local input history contains a
|
||||
# newly acknowledged sequence, normal delta reconciliation resumes.
|
||||
_resync_until_seq = current_seq + 1
|
||||
return decision
|
||||
|
||||
|
||||
func get_metrics() -> Dictionary:
|
||||
return {
|
||||
"sample_count": _position_errors.size(),
|
||||
"position_error_p50": _percentile(0.50),
|
||||
"position_error_p95": _percentile(0.95),
|
||||
"position_error_p99": _percentile(0.99),
|
||||
"rotation_error_p50": _rotation_percentile(0.50),
|
||||
"rotation_error_p95": _rotation_percentile(0.95),
|
||||
"rotation_error_p99": _rotation_percentile(0.99),
|
||||
"free_flight_sample_count": _free_flight_position_errors.size(),
|
||||
"free_flight_position_error_p95": _percentile_from(_free_flight_position_errors, 0.95),
|
||||
"free_flight_position_error_p99": _percentile_from(_free_flight_position_errors, 0.99),
|
||||
"free_flight_rotation_error_p95": _percentile_from(_free_flight_rotation_errors, 0.95),
|
||||
"free_flight_rotation_error_p99": _percentile_from(_free_flight_rotation_errors, 0.99),
|
||||
"visual_correction_p95": _percentile_from(_visual_correction_errors, 0.95),
|
||||
"visual_correction_p99": _percentile_from(_visual_correction_errors, 0.99),
|
||||
"free_flight_visual_correction_p95": _percentile_from(_free_flight_visual_correction_errors, 0.95),
|
||||
"free_flight_visual_correction_p99": _percentile_from(_free_flight_visual_correction_errors, 0.99),
|
||||
"hard_snap_count": _hard_snap_count,
|
||||
"hard_snap_rate_per_min": _hard_snap_rate_per_min(),
|
||||
"hard_snap_reasons": _hard_snap_reasons.duplicate(),
|
||||
"hard_snap_cohorts": _hard_snap_cohorts.duplicate(),
|
||||
"cohorts": _cohort_counts.duplicate(),
|
||||
"position_threshold": hard_position_error,
|
||||
"rotation_threshold_degrees": hard_rotation_error_degrees,
|
||||
"max_visual_offset": max_visual_offset,
|
||||
}
|
||||
|
||||
|
||||
func clear_metrics() -> void:
|
||||
_position_errors.clear()
|
||||
_rotation_errors.clear()
|
||||
_free_flight_position_errors.clear()
|
||||
_free_flight_rotation_errors.clear()
|
||||
_visual_correction_errors.clear()
|
||||
_free_flight_visual_correction_errors.clear()
|
||||
_hard_snap_count = 0
|
||||
_decision_count = 0
|
||||
_resync_until_seq = -1
|
||||
_metrics_started_ms = -1
|
||||
_hard_snap_reasons.clear()
|
||||
_hard_snap_cohorts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
_cohort_counts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
|
||||
|
||||
func _record_metrics(comparison: Dictionary, decision: Dictionary) -> void:
|
||||
if _metrics_started_ms < 0:
|
||||
_metrics_started_ms = Time.get_ticks_msec()
|
||||
_decision_count += 1
|
||||
var cohort := _cohort_for(comparison, decision)
|
||||
if decision["mode"] == "hard":
|
||||
_hard_snap_count += 1
|
||||
var reason := str(decision.get("reason", "unknown"))
|
||||
_hard_snap_reasons[reason] = int(_hard_snap_reasons.get(reason, 0)) + 1
|
||||
_hard_snap_cohorts[cohort] = int(_hard_snap_cohorts.get(cohort, 0)) + 1
|
||||
_cohort_counts[cohort] = int(_cohort_counts.get(cohort, 0)) + 1
|
||||
if comparison.get("status", "") == "matched":
|
||||
# Only same-sequence predictions are quality samples. Recovery events
|
||||
# still count in their own cohorts/reason ledger, but must not distort
|
||||
# p95/p99 with an error that cannot honestly be measured.
|
||||
_position_errors.append(float(comparison["position_error_magnitude"]))
|
||||
_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
|
||||
if cohort == "free_flight":
|
||||
_free_flight_position_errors.append(float(comparison["position_error_magnitude"]))
|
||||
_free_flight_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
|
||||
# The visual offset hides at most max_visual_offset of a soft correction.
|
||||
# Record the exposed remainder, never the capped hidden component; hard
|
||||
# corrections are independently gated by their cohort count above.
|
||||
var visual_error := maxf(0.0, float(comparison.get("position_error_magnitude", 0.0)) - max_visual_offset) if decision["mode"] == "soft" else 0.0
|
||||
_visual_correction_errors.append(visual_error)
|
||||
if cohort == "free_flight":
|
||||
_free_flight_visual_correction_errors.append(visual_error)
|
||||
if _position_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_position_errors.pop_front()
|
||||
if _rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_rotation_errors.pop_front()
|
||||
if _free_flight_position_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_position_errors.pop_front()
|
||||
if _free_flight_rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_rotation_errors.pop_front()
|
||||
if _visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_visual_correction_errors.pop_front()
|
||||
if _free_flight_visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_visual_correction_errors.pop_front()
|
||||
|
||||
|
||||
func _cohort_for(comparison: Dictionary, decision: Dictionary) -> String:
|
||||
if decision.get("reason", "") == "reset_gen":
|
||||
return "reset"
|
||||
if decision.get("reason", "") == "unsimulated_gap":
|
||||
# Its own cohort, not free_flight: these carry no error sample, and
|
||||
# folding them into a quality cohort would silently inflate its count
|
||||
# with rows that contributed no measurement.
|
||||
return "unsimulated"
|
||||
if decision.get("reason", "").begins_with("missing") or _resync_until_seq >= 0:
|
||||
return "resync"
|
||||
if comparison.get("contact_window", false):
|
||||
return "contact"
|
||||
return "free_flight"
|
||||
|
||||
|
||||
func _hard_snap_rate_per_min() -> float:
|
||||
if _metrics_started_ms < 0:
|
||||
return 0.0
|
||||
var elapsed_seconds := maxf(float(Time.get_ticks_msec() - _metrics_started_ms) / 1000.0, 0.001)
|
||||
return float(_hard_snap_count) * 60.0 / elapsed_seconds
|
||||
|
||||
|
||||
func _percentile(fraction: float) -> float:
|
||||
return _percentile_from(_position_errors, fraction)
|
||||
|
||||
|
||||
func _percentile_from(samples: Array[float], fraction: float) -> float:
|
||||
if samples.is_empty():
|
||||
return 0.0
|
||||
var sorted := samples.duplicate()
|
||||
sorted.sort()
|
||||
var index := clampi(roundi((sorted.size() - 1) * fraction), 0, sorted.size() - 1)
|
||||
return sorted[index]
|
||||
|
||||
|
||||
func _rotation_percentile(fraction: float) -> float:
|
||||
return _percentile_from(_rotation_errors, fraction)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0qjwh4af8pbn
|
||||
@@ -0,0 +1,134 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] NetSim). Debug-only, seeded
|
||||
# latency/jitter/loss/duplicate decorator around outgoing RPC dispatch —
|
||||
# task 2.8. A pure passthrough (send() calls dispatch.call() immediately)
|
||||
# unless CLI flags are given, so every existing test and the real game are
|
||||
# byte-for-byte unaffected by this autoload merely existing.
|
||||
#
|
||||
# CLI (read once, in this process's own OS.get_cmdline_user_args()):
|
||||
# --net-sim-latency=<ms> one-way delay added before each wrapped send
|
||||
# --net-sim-jitter=<ms> extra uniform-random 0..jitter added per send
|
||||
# --net-sim-loss=<0..1> fraction of sends dropped entirely (never sent)
|
||||
# --net-sim-dup=<0..1> probability a send is ALSO sent a second time
|
||||
# --net-sim-seed=<int> RNG seed (default fixed, so a bad run reproduces
|
||||
# unless a CI/local run deliberately wants a
|
||||
# different one — same "seeded so failures
|
||||
# reproduce" bar as §11's testing section sets)
|
||||
#
|
||||
# "Asymmetric-capable" per §7 task 2.8 is not a separate feature: each
|
||||
# process reads only its own CLI args and only delays its own outgoing
|
||||
# sends, so running the host and client with different flags (e.g. a
|
||||
# lossy-upload client against a clean host) already produces asymmetric
|
||||
# behaviour with no extra plumbing.
|
||||
#
|
||||
# Call sites build a zero-argument Callable that performs the actual
|
||||
# rpc_id()/rpc() dispatch, so NetSim never needs to know per-call argument
|
||||
# shapes. IMPORTANT for callers that embed a timestamp in the call (e.g.
|
||||
# NetworkManager's _ping/_pong): capture Time.get_ticks_msec() *before*
|
||||
# calling send(), not inside the wrapped Callable — the delay is meant to
|
||||
# simulate wire transit *after* the packet is "sent", so a timestamp taken
|
||||
# inside the delayed closure would silently absorb this process's own
|
||||
# outbound leg out of any round-trip measurement built on top of it.
|
||||
#
|
||||
# Wraps MatchSim.send_input / send_snapshot per the doc's task 2.8 scope,
|
||||
# plus NetworkManager's _ping/_pong dispatch — the latter is a deliberate
|
||||
# addition beyond the literal task text: it's the only RTT measurement that
|
||||
# already exists and is already tested (tests/clock_smoke.gd, task 1.8), so
|
||||
# routing it through NetSim is what makes "`--net-sim-latency 80` measurably
|
||||
# raises observed RTT" (this task's own stated acceptance criterion)
|
||||
# checkable today, without waiting on Phase 3's per-peer snapshot echo.
|
||||
|
||||
const DEFAULT_SEED := 20260820
|
||||
|
||||
var latency_ms := 0.0
|
||||
var jitter_ms := 0.0
|
||||
var loss_fraction := 0.0
|
||||
var dup_fraction := 0.0
|
||||
|
||||
var _rng := RandomNumberGenerator.new() # owned instance — never the global RNG, task 0.7's rule
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var seed_value := DEFAULT_SEED
|
||||
for arg: String in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--net-sim-latency="):
|
||||
latency_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
|
||||
elif arg.begins_with("--net-sim-jitter="):
|
||||
jitter_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
|
||||
elif arg.begins_with("--net-sim-loss="):
|
||||
loss_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
|
||||
elif arg.begins_with("--net-sim-dup="):
|
||||
dup_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
|
||||
elif arg.begins_with("--net-sim-seed="):
|
||||
seed_value = arg.get_slice("=", 1).to_int()
|
||||
_rng.seed = seed_value
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return latency_ms > 0.0 or jitter_ms > 0.0 or loss_fraction > 0.0 or dup_fraction > 0.0
|
||||
|
||||
|
||||
# target_peer_id: the specific remote peer this dispatch is addressed to
|
||||
# (rpc_id's target), or -1 for a broadcast / not a targeted send. Only used
|
||||
# to re-validate a delayed send right before it actually fires — see _fire.
|
||||
func send(dispatch: Callable, target_peer_id: int = -1) -> void:
|
||||
if not is_active():
|
||||
dispatch.call()
|
||||
return
|
||||
if _rng.randf() < loss_fraction:
|
||||
return
|
||||
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
|
||||
if _rng.randf() < dup_fraction:
|
||||
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
|
||||
|
||||
|
||||
func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> void:
|
||||
if delay_sec <= 0.0:
|
||||
dispatch.call()
|
||||
return
|
||||
# process_always = true: a simulated wire delay must keep counting down
|
||||
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
|
||||
# this today; multiplayer-todo.md §8 already flags get_tree().paused
|
||||
# stopping the client's own send/receive loop as a separate refactor
|
||||
# item). Pausing this timer too would let a paused client's in-flight
|
||||
# packets pile up and arrive in a burst on unpause instead of on their
|
||||
# simulated schedule.
|
||||
get_tree().create_timer(delay_sec, true).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
|
||||
|
||||
|
||||
# Re-validates the target right before a DELAYED send actually fires.
|
||||
# NetSim's whole point is to hold a packet in flight past the moment it was
|
||||
# queued, and in that window the target peer (or this process's own
|
||||
# connection) can legitimately be gone — a disconnect mid-match, or this
|
||||
# process's own shutdown() already having reset multiplayer_peer to a fresh
|
||||
# OfflineMultiplayerPeer. Firing anyway reproduced two real bugs while
|
||||
# building this task: "Attempt to call RPC with unknown peer ID" (stale
|
||||
# remote target — networked_match.gd's own get_peers() filter on
|
||||
# _broadcast_snapshot only checked validity at *schedule* time, and the
|
||||
# target had disconnected by the time the delayed send actually fired) and
|
||||
# "'_recv_input' on yourself is not allowed by selected mode" (this
|
||||
# process's own peer was already torn down, so peer id 1 now refers to
|
||||
# itself instead of the server). The synchronous (delay_sec <= 0 / NetSim
|
||||
# inactive) path is deliberately NOT re-validated here — nothing has had
|
||||
# time to change since the caller's own validation, and matching the
|
||||
# pre-NetSim behaviour exactly there is what keeps NetSim a true no-op when
|
||||
# no CLI flags are given.
|
||||
#
|
||||
# Known residual gap, judged not worth the complexity for debug-only
|
||||
# tooling: if this process shuts down AND reconnects (a fresh host()/join())
|
||||
# within one delayed send's hold time, multiplayer_peer is a real peer again
|
||||
# and get_peers() may coincidentally contain the same target_peer_id from
|
||||
# the new session, so a stale send from the old session could slip through.
|
||||
# Closing that fully would need a generation counter bumped on every
|
||||
# shutdown/host/join and stamped on each scheduled send — disproportionate
|
||||
# for a latency simulator that only ever runs in manual/CI testing.
|
||||
func _fire(dispatch: Callable, target_peer_id: int) -> void:
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer == null or peer is OfflineMultiplayerPeer:
|
||||
return
|
||||
if peer is ENetMultiplayerPeer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return
|
||||
if target_peer_id != -1 and target_peer_id not in multiplayer.get_peers():
|
||||
return
|
||||
dispatch.call()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cboh4k3bka8vu
|
||||
@@ -0,0 +1,26 @@
|
||||
class_name NetTransport
|
||||
extends RefCounted
|
||||
|
||||
# Narrow construction boundary for Godot's MultiplayerPeer implementations.
|
||||
# NetworkManager owns polling, RPC policy, and lifecycle; a transport only
|
||||
# creates a peer. Keeping that split means ENet remains a first-class path
|
||||
# while Steam can use SDR without duplicating the rest of the networking code.
|
||||
|
||||
func transport_id() -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return "transport is unavailable"
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
|
||||
|
||||
func create_client(_address: String, _port: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
@@ -0,0 +1,281 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
|
||||
# hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
||||
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
||||
#
|
||||
# server_relay = false is set the moment a peer exists: the default `true`
|
||||
# lets any client rpc() any other client *through the server*, which this
|
||||
# project's server-authoritative model must never allow — §2.1 calls this
|
||||
# out as the single highest-value one-line security change in the document.
|
||||
#
|
||||
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
|
||||
# shutdown()/close the peer the instant connected_to_server or peer_connected
|
||||
# fires. ENet's connect handshake isn't fully settled on the *other* side the
|
||||
# moment your own side's signal fires — the final ACK still needs a couple
|
||||
# more poll() cycles to actually reach the wire. Closing immediately drops
|
||||
# it and leaves the other side's handshake permanently incomplete (it will
|
||||
# never see peer_connected/connected_to_server at all). Callers that shut
|
||||
# down right after a fresh connection should let a frame or two pass first.
|
||||
#
|
||||
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
|
||||
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
|
||||
# full frame before it's actually pushed onto the wire — and the return leg
|
||||
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
|
||||
# that off; every caller that sends or expects to receive on a tight cadence
|
||||
# must now call NetworkManager.poll() itself. The intended placement per
|
||||
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
|
||||
# sending input, plus top of both _process and _physics_process for receive;
|
||||
# server: tick start to drain, tick end to flush) has no real per-tick caller
|
||||
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
|
||||
# Until then, anything driving a connection (tests/net_smoke.gd included)
|
||||
# must poll() every frame itself or nothing will ever be sent or received.
|
||||
|
||||
signal client_connected(peer_id: int)
|
||||
signal client_disconnected(peer_id: int)
|
||||
signal connected_to_server()
|
||||
signal connection_failed()
|
||||
signal disconnected_from_server()
|
||||
signal clock_updated(rtt_ms: float, offset_ms: float)
|
||||
# Fires at the top of every shutdown() call, whether this process was
|
||||
# hosting, joined, or already offline, and regardless of *why* (deliberate
|
||||
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
|
||||
# review found MatchNet.roster had no path that cleared it when a HOST
|
||||
# stopped hosting — connected_to_server/disconnected_from_server only cover
|
||||
# the client side — so a host -> lobby -> leave -> host-again cycle left a
|
||||
# permanent phantom player. Listeners that need per-role cleanup should
|
||||
# still use the more specific signals above; this one exists so "something
|
||||
# is about to reset the connection, drop anything you were keeping" has
|
||||
# exactly one place to hook regardless of role.
|
||||
signal shutting_down()
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_CLIENTS := 32
|
||||
const TRANSPORT_ENET := "enet"
|
||||
const TRANSPORT_STEAM := "steam"
|
||||
|
||||
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
|
||||
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
|
||||
|
||||
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
||||
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
||||
# rolling window, because the lowest-RTT sample has the least queueing
|
||||
# error. get_server_time_estimate_ms() is the thing every later phase
|
||||
# (interpolation delay, tick_offset seeding) actually wants — everything
|
||||
# else here exists to produce it.
|
||||
const PING_INTERVAL_SEC := 1.0
|
||||
const CLOCK_WINDOW_SEC := 5.0
|
||||
|
||||
var is_server := false
|
||||
var is_client := false
|
||||
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var active_transport := ""
|
||||
|
||||
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
||||
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
||||
var _clock_samples: Array[Dictionary] = []
|
||||
var _ping_accum_sec := 0.0
|
||||
|
||||
# Jitter (task 3.7's debug overlay): RFC3550-style EWMA of the deviation
|
||||
# between consecutive RAW (not min-filtered) RTT samples — rtt_ms itself is
|
||||
# a min-RTT, deliberately insensitive to jitter by design (§4.7), so a
|
||||
# separate, unfiltered running estimate is needed to actually see it.
|
||||
const JITTER_EWMA_ALPHA := 1.0 / 16.0 # matches RFC3550's own smoothing factor
|
||||
var jitter_ms := 0.0
|
||||
var _last_raw_rtt_ms := -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().set_multiplayer_poll_enabled(false)
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# is_client turns true the instant join() is called, before the ENet
|
||||
# handshake actually completes (or fails) — a slow or refused connect
|
||||
# attempt would otherwise leave this trying to rpc_id() on a peer
|
||||
# that's still CONNECTING (or already failed), which Godot logs as
|
||||
# "Trying to call an RPC via a multiplayer peer which is not
|
||||
# connected." every single frame. Require the real transport state.
|
||||
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return
|
||||
_ping_accum_sec += delta
|
||||
if _ping_accum_sec >= PING_INTERVAL_SEC:
|
||||
_ping_accum_sec = 0.0
|
||||
# Capture the timestamp now, before NetSim (task 2.8) can add any
|
||||
# simulated delay — see net_sim.gd's header comment for why.
|
||||
var send_ms := Time.get_ticks_msec()
|
||||
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
|
||||
|
||||
|
||||
# Estimate of what the server's Time.get_ticks_msec() reads right now.
|
||||
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
|
||||
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
|
||||
func get_server_time_estimate_ms() -> float:
|
||||
return float(Time.get_ticks_msec()) + clock_offset_ms
|
||||
|
||||
|
||||
# The single entry point every per-tick caller uses instead of relying on
|
||||
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
|
||||
# polling the default OfflineMultiplayerPeer is a no-op.
|
||||
func poll() -> void:
|
||||
multiplayer.poll()
|
||||
|
||||
|
||||
func available_transports() -> PackedStringArray:
|
||||
var transports := PackedStringArray([TRANSPORT_ENET])
|
||||
if SteamTransportScript.new().is_available():
|
||||
transports.append(TRANSPORT_STEAM)
|
||||
return transports
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_server(port, max_clients)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = true
|
||||
is_client = false
|
||||
return OK
|
||||
|
||||
|
||||
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_client(address, port)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = false
|
||||
is_client = true
|
||||
return OK
|
||||
|
||||
|
||||
func shutdown() -> void:
|
||||
shutting_down.emit()
|
||||
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
|
||||
# sentinel, never null — closing that sentinel is a no-op, but assigning
|
||||
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
|
||||
# leaves the API in a state distinct from its own default, which is a
|
||||
# known source of confusing follow-on bugs (godotengine/godot#81540).
|
||||
# Always reset to a real OfflineMultiplayerPeer, never raw null.
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer != null and not (peer is OfflineMultiplayerPeer):
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
_peer = null
|
||||
active_transport = ""
|
||||
is_server = false
|
||||
is_client = false
|
||||
rtt_ms = -1.0
|
||||
clock_offset_ms = 0.0
|
||||
_clock_samples.clear()
|
||||
_ping_accum_sec = 0.0
|
||||
jitter_ms = 0.0
|
||||
_last_raw_rtt_ms = -1.0
|
||||
|
||||
|
||||
func _make_transport(transport: String) -> NetTransport:
|
||||
match transport:
|
||||
TRANSPORT_ENET:
|
||||
return EnetTransportScript.new()
|
||||
TRANSPORT_STEAM:
|
||||
return SteamTransportScript.new()
|
||||
_:
|
||||
push_error("NetworkManager: unknown transport '%s'" % transport)
|
||||
return null
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _ping(client_send_ms: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# Same rule as the client's send above: read the server's clock now, at
|
||||
# true receipt time, before NetSim can delay the reply — otherwise the
|
||||
# server's own outbound leg would be silently absorbed out of both the
|
||||
# RTT sample and the offset estimate instead of adding to them.
|
||||
var server_now := Time.get_ticks_msec()
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
# A single poll() call can process several queued RPCs from the same
|
||||
# peer in one batch — an earlier one in that same batch (e.g. task 3.4's
|
||||
# abuse-triggered match_sim.gd disconnect_peer() call, or the peer
|
||||
# disconnecting for any other reason mid-batch) can leave this ping's
|
||||
# sender no longer a valid peer by the time its own turn in the batch
|
||||
# comes up. Empirically confirmed reachable with disconnect_peer()'s
|
||||
# default arguments (a graceful, non-forced disconnect — match_sim.gd's
|
||||
# own disconnect call tried force=true as an alternative and reverted
|
||||
# it, since that left Godot's own peer-list bookkeeping inconsistent
|
||||
# and produced far MORE of this exact class of error, not fewer:
|
||||
# hundreds vs. one, verified). NetSim's inactive/passthrough path (the
|
||||
# common case — no CLI flags) dispatches immediately with no
|
||||
# validation of its own, so check here rather than relying on it.
|
||||
if sender_id not in multiplayer.get_peers():
|
||||
return
|
||||
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _pong(client_send_ms: int, server_now_ms: int) -> void:
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
var sample_rtt := float(now_ms - client_send_ms)
|
||||
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
|
||||
if _last_raw_rtt_ms >= 0.0:
|
||||
var deviation := absf(sample_rtt - _last_raw_rtt_ms)
|
||||
jitter_ms += (deviation - jitter_ms) * JITTER_EWMA_ALPHA
|
||||
_last_raw_rtt_ms = sample_rtt
|
||||
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
|
||||
|
||||
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
|
||||
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
|
||||
|
||||
var best: Dictionary = _clock_samples[0]
|
||||
for sample: Dictionary in _clock_samples:
|
||||
if sample["rtt"] < best["rtt"]:
|
||||
best = sample
|
||||
rtt_ms = best["rtt"]
|
||||
clock_offset_ms = best["offset"]
|
||||
clock_updated.emit(rtt_ms, clock_offset_ms)
|
||||
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
client_connected.emit(peer_id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
client_disconnected.emit(peer_id)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
connected_to_server.emit()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
is_client = false
|
||||
connection_failed.emit()
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
is_server = false
|
||||
is_client = false
|
||||
disconnected_from_server.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd1g4evti23ab
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://da8db6ofcbjt2
|
||||
@@ -0,0 +1,62 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Autoload: toggleable frame-time/bottleneck overlay (F3 by default — see
|
||||
# toggle_perf_overlay in project.godot's [input]). Read-only against
|
||||
# Performance monitors; never touches rendering or gameplay state. Exists so
|
||||
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
|
||||
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
|
||||
# GPU-bound. See multiplayer-todo.md task 0.20.
|
||||
|
||||
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
|
||||
# history itself being a rate-dependent quantity.
|
||||
const HISTORY_SIZE := 120
|
||||
|
||||
var _label: Label
|
||||
var _frame_times_ms: PackedFloat32Array = PackedFloat32Array()
|
||||
var _history_index := 0
|
||||
var _history_filled := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# A headless server never renders and has no input to toggle this with.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
set_process(false)
|
||||
return
|
||||
layer = 100
|
||||
_label = Label.new()
|
||||
_label.add_theme_font_size_override("font_size", 14)
|
||||
_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
||||
_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||
_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_label.position = Vector2(12, 12)
|
||||
_label.visible = false
|
||||
add_child(_label)
|
||||
_frame_times_ms.resize(HISTORY_SIZE)
|
||||
_frame_times_ms.fill(0.0)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("toggle_perf_overlay") and _label:
|
||||
_label.visible = not _label.visible
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _label or not _label.visible:
|
||||
return
|
||||
var frame_ms := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||
var physics_ms := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||
_frame_times_ms[_history_index] = frame_ms
|
||||
_history_index = (_history_index + 1) % HISTORY_SIZE
|
||||
_history_filled = mini(_history_filled + 1, HISTORY_SIZE)
|
||||
|
||||
var sorted := _frame_times_ms.slice(0, _history_filled)
|
||||
sorted.sort()
|
||||
var p50 := sorted[sorted.size() / 2]
|
||||
var p99 := sorted[int(sorted.size() * 0.99)]
|
||||
|
||||
_label.text = "FPS %d (p50 %.2fms p99 %.2fms)\nprocess %.2fms physics %.2fms\ndraw calls %d" % [
|
||||
Performance.get_monitor(Performance.TIME_FPS),
|
||||
p50, p99, frame_ms, physics_ms,
|
||||
Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlis4s1io7tnd
|
||||
@@ -0,0 +1,171 @@
|
||||
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.
|
||||
#
|
||||
# Framing is kind-agnostic, so new RecordKind values are additive — a reader
|
||||
# that doesn't know a kind still walks past it correctly. The version bump to 2
|
||||
# exists anyway because absence is otherwise ambiguous: without it, a log with
|
||||
# no REJECTED_* records cannot be told apart from one written by a build that
|
||||
# never recorded rejections in the first place, which is exactly the question
|
||||
# "the server dropped my input" needs answered.
|
||||
|
||||
const MAGIC := 0x50524343
|
||||
const FORMAT_VERSION := 2
|
||||
const HEADER_SIZE := 8
|
||||
const RECORD_HEADER_SIZE := 11
|
||||
|
||||
enum RecordKind {
|
||||
INPUT = 0, # client -> server, accepted and handed to the jitter buffer
|
||||
SNAPSHOT = 1, # server -> client, as sent
|
||||
# Rejections. An accepted-input-only log answers "what did the server
|
||||
# simulate", but the field report that actually needs a replay is usually
|
||||
# "my input did nothing" — and the packets that would explain it are
|
||||
# precisely the ones the old log discarded. Each reason is its own kind
|
||||
# rather than a reason field, so the framing above is unchanged.
|
||||
REJECTED_MALFORMED = 2, # failed §3.1 step 3 framing validation
|
||||
REJECTED_RATE_LIMIT = 3, # over budget for the current 1s window
|
||||
REJECTED_SEQ_GUARD = 4, # seq beyond the slot's ingest bound (§3.1 step 4)
|
||||
}
|
||||
|
||||
var _file: FileAccess = null
|
||||
var records_written := 0
|
||||
var bytes_written := 0
|
||||
# Set once a write actually fails (disk full, removed volume). Everything after
|
||||
# it is dropped: a partial record would desync the framing of every record that
|
||||
# follows, turning a truncation into a corrupt file.
|
||||
var write_failed := false
|
||||
var records_dropped := 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)
|
||||
|
||||
|
||||
# `kind` must be one of the REJECTED_* values; the caller knows why it dropped
|
||||
# the packet and nothing here can re-derive it.
|
||||
func record_rejected_input(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
||||
_write(kind, tick, peer_id, payload)
|
||||
|
||||
|
||||
func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
||||
if _file == null or write_failed:
|
||||
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())
|
||||
records_dropped += 1
|
||||
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)
|
||||
# Checked via get_error() rather than the store_* return values because it
|
||||
# reports the same condition once for the whole record instead of six times,
|
||||
# and because a diagnostic log that has quietly stopped writing is worse
|
||||
# than no log at all — the reader would see a plausible short match rather
|
||||
# than a failure. One write error ends the log permanently.
|
||||
var err := _file.get_error()
|
||||
if err != OK:
|
||||
write_failed = true
|
||||
records_dropped += 1
|
||||
push_warning("ReplayLog: write failed (%s) after %d records — log closed early" % [error_string(err), records_written])
|
||||
_file.close()
|
||||
_file = null
|
||||
return
|
||||
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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfrexwrkq3cia
|
||||
@@ -1,3 +1,15 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
|
||||
# a community server whose players are all dumped back to their own menus
|
||||
# every 2.5 minutes has no way to keep a lobby together.
|
||||
const LOBBY := "res://scenes/lobby.tscn"
|
||||
# Task 6.5: the dedicated server's match loop needs this by name, and it was
|
||||
# previously only ever reached by test harnesses hardcoding the string.
|
||||
const NETWORKED_MATCH := "res://scenes/networked_match.tscn"
|
||||
const SERVER_BOOT := "res://scenes/server_boot.tscn"
|
||||
# Arena variants add presentation-only scenery. The dedicated server uses the
|
||||
# common physical layout instead, while MatchSim still tells clients which
|
||||
# variant to render.
|
||||
const SERVER_ARENA := "res://scenes/arena_base.tscn"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
extends Node
|
||||
|
||||
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
||||
# via NetworkManager, logs structured lines, and watches for physics-tick
|
||||
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
|
||||
# a tick overrunning 16.7ms backs up the accumulator and the next frame
|
||||
# runs multiple ticks, spiking CPU further — worth logging, not just
|
||||
# silently absorbing).
|
||||
#
|
||||
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
|
||||
#
|
||||
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
|
||||
# scene. This is just the process shell: listen, log, idle cheaply.
|
||||
|
||||
var _last_physics_frame := 0
|
||||
var config: ServerConfig = null
|
||||
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
|
||||
|
||||
# Task 6.3. This process owns the whole command line, so it parses STRICTLY:
|
||||
# an unknown flag or an out-of-range value stops the server with a message
|
||||
# rather than starting one that silently ignores half of what it was told.
|
||||
config = ServerConfig.parse(OS.get_cmdline_user_args())
|
||||
if config.help_requested:
|
||||
print(ServerConfig.help_text())
|
||||
get_tree().quit(0)
|
||||
return
|
||||
if not config.is_valid():
|
||||
# Straight to stderr-ish plain print rather than through _log: the log
|
||||
# level itself may be one of the things that failed to parse, and an
|
||||
# operator running this by hand needs to see every problem at once, not
|
||||
# the first one.
|
||||
printerr("cosmic-clash-server: refusing to start")
|
||||
for problem in config.errors:
|
||||
printerr(" %s" % problem)
|
||||
printerr("try --help")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
ServerLog.configure(String(config.get_value("log-level")))
|
||||
var port := int(config.get_value("port"))
|
||||
var max_clients := int(config.get_value("max-clients"))
|
||||
|
||||
NetworkManager.client_connected.connect(_on_client_connected)
|
||||
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
||||
MatchNet.player_joined.connect(_on_player_joined)
|
||||
MatchNet.player_left.connect(_on_player_left)
|
||||
|
||||
var err := NetworkManager.host(port, max_clients)
|
||||
if err != OK:
|
||||
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_install_match_loop()
|
||||
ServerLog.info("server_started", {
|
||||
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
|
||||
"min_players": int(config.get_value("min-players")),
|
||||
"max_matches": int(config.get_value("max-matches")),
|
||||
"arena_rotation": String(config.get_value("arena-rotation")),
|
||||
})
|
||||
_last_physics_frame = Engine.get_physics_frames()
|
||||
|
||||
|
||||
# Task 6.5. Parented to the ROOT rather than to this node: the loop calls
|
||||
# change_scene_to_file, which frees the current scene — and this boot scene IS
|
||||
# the current scene, so a loop parented here would be freed by the first match
|
||||
# it started. Same constraint the smoke-test hooks document.
|
||||
func _install_match_loop() -> void:
|
||||
var loop := ServerMatchLoop.new()
|
||||
loop.name = "ServerMatchLoop"
|
||||
loop.min_players = int(config.get_value("min-players"))
|
||||
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
||||
loop.max_matches = int(config.get_value("max-matches"))
|
||||
loop.rotation_mode = String(config.get_value("arena-rotation"))
|
||||
get_tree().root.add_child.call_deferred(loop)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
var current := Engine.get_physics_frames()
|
||||
var steps := current - _last_physics_frame
|
||||
_last_physics_frame = current
|
||||
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
|
||||
# alternate between 0 and 2 ticks even on an idle, healthy server —
|
||||
# that's expected quantisation, not backlog. A real overrun is the
|
||||
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
|
||||
if steps > 2 and _watchdog_armed:
|
||||
ServerLog.warn("physics_overrun", {"steps": steps})
|
||||
_watchdog_armed = true
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _on_client_connected(peer_id: int) -> void:
|
||||
ServerLog.debug("peer_connected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_client_disconnected(peer_id: int) -> void:
|
||||
ServerLog.debug("peer_disconnected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
|
||||
|
||||
|
||||
func _on_player_left(peer_id: int) -> void:
|
||||
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci6xqqmag4axj
|
||||
@@ -0,0 +1,295 @@
|
||||
class_name ServerConfig
|
||||
extends RefCounted
|
||||
|
||||
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
|
||||
# declaration of every server flag, one parser, one `--help`.
|
||||
#
|
||||
# Standalone RefCounted with no scene or RPC dependency — same reason as
|
||||
# net_codec.gd, match_state.gd and input_jitter_buffer.gd — so the precedence
|
||||
# rules and every validation path are unit-testable against a scripted argv
|
||||
# with no live server.
|
||||
#
|
||||
# Why this exists rather than more `arg.begins_with(...)` chains: the flags had
|
||||
# grown to roughly thirty across server_boot.gd and networked_match.gd, each
|
||||
# parsed inline, none documented anywhere, and — the part that actually bites —
|
||||
# **an unrecognised flag was silently ignored**. `--max-clientss=8` ran a server
|
||||
# on the default player cap and said nothing about it. A dedicated server whose
|
||||
# operator cannot tell a typo from a working setting is the wrong kind of quiet,
|
||||
# so unknown flags and unparseable values are hard errors here.
|
||||
#
|
||||
# Precedence, highest first:
|
||||
# 1. the command line
|
||||
# 2. the config file (--config=<path>, a Godot ConfigFile under [server])
|
||||
# 3. the declared default
|
||||
#
|
||||
# That order is the conventional one and it is the one an operator expects when
|
||||
# they override a mounted config file for a single run.
|
||||
|
||||
enum Kind { BOOL, INT, FLOAT, STRING }
|
||||
|
||||
|
||||
class Spec:
|
||||
var key: String # canonical name, without the leading dashes
|
||||
var kind: int
|
||||
var default_value: Variant
|
||||
var help: String
|
||||
# Flags the match scene reads rather than the boot scene. Recorded so
|
||||
# `--help` can group them honestly instead of implying one consumer.
|
||||
var section: String
|
||||
|
||||
func _init(p_key: String, p_kind: int, p_default: Variant, p_section: String, p_help: String) -> void:
|
||||
key = p_key
|
||||
kind = p_kind
|
||||
default_value = p_default
|
||||
section = p_section
|
||||
help = p_help
|
||||
|
||||
|
||||
# The single source of truth. A flag that is not here does not exist, and
|
||||
# adding one here is all that is needed for it to be parsed, validated,
|
||||
# type-checked, config-file-backed and documented.
|
||||
static func specs() -> Array[Spec]:
|
||||
var out: Array[Spec] = []
|
||||
out.append(Spec.new("port", Kind.INT, 7777, "network", "UDP port to listen on"))
|
||||
out.append(Spec.new("max-clients", Kind.INT, 12, "network", "Maximum simultaneous connected peers"))
|
||||
out.append(Spec.new("max-spectators", Kind.INT, -1, "network", "Spectator cap; 0 disables spectating, negative means unlimited"))
|
||||
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
|
||||
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
|
||||
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
|
||||
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
|
||||
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
|
||||
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
|
||||
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
|
||||
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
|
||||
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
|
||||
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
|
||||
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
|
||||
return out
|
||||
|
||||
|
||||
var values: Dictionary = {} # key -> parsed value
|
||||
var errors: PackedStringArray = [] # human-readable, in the order encountered
|
||||
var help_requested := false
|
||||
var config_path := ""
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return errors.is_empty()
|
||||
|
||||
|
||||
func get_value(key: String) -> Variant:
|
||||
return values.get(key)
|
||||
|
||||
|
||||
# `argv` is OS.get_cmdline_user_args() in production. Taking it as a parameter
|
||||
# is what makes every branch below testable without a process.
|
||||
#
|
||||
# `strict` controls what an unrecognised flag means, and the distinction is
|
||||
# load-bearing rather than a convenience. server_boot.gd owns the whole command
|
||||
# line, so an unknown flag there is an operator error and must stop the process.
|
||||
# networked_match.gd is ONE CONSUMER of a shared argv — the smoke harnesses put
|
||||
# --role=, --drive-seconds= and a dozen client-side flags on the same line — so
|
||||
# it reads leniently. Nothing is lost: every server flag is declared here, so
|
||||
# the strict pass in server_boot.gd already validated all of them before the
|
||||
# match scene ever re-reads its own.
|
||||
static func parse(argv: PackedStringArray, strict: bool = true) -> ServerConfig:
|
||||
var config := ServerConfig.new()
|
||||
var by_key := {}
|
||||
for spec in specs():
|
||||
by_key[spec.key] = spec
|
||||
config.values[spec.key] = spec.default_value
|
||||
|
||||
# Two passes. --config has to be resolved before the file can be read, and
|
||||
# the file must be applied UNDER the command line rather than over it, so
|
||||
# the file cannot be loaded lazily as flags stream past.
|
||||
var seen: Array[String] = []
|
||||
var pending: Array = []
|
||||
for arg in argv:
|
||||
if arg == "--help" or arg == "-h":
|
||||
config.help_requested = true
|
||||
continue
|
||||
if not arg.begins_with("--"):
|
||||
if strict:
|
||||
config.errors.append("unrecognised argument '%s' (flags start with --)" % arg)
|
||||
continue
|
||||
var body := arg.substr(2)
|
||||
var key := body
|
||||
var raw := ""
|
||||
var has_value := false
|
||||
var eq := body.find("=")
|
||||
if eq >= 0:
|
||||
key = body.substr(0, eq)
|
||||
raw = body.substr(eq + 1)
|
||||
has_value = true
|
||||
# --no-<bool> is the conventional off switch and is NOT declared as its
|
||||
# own Spec, or `--help` would list every boolean twice. Rewrite it into
|
||||
# the positive flag with an inverted value before anything else looks
|
||||
# at it.
|
||||
var negated := _is_negation(key, by_key)
|
||||
if not negated.is_empty():
|
||||
if has_value:
|
||||
config.errors.append("flag '--%s' does not take a value" % key)
|
||||
continue
|
||||
key = negated
|
||||
raw = "false"
|
||||
has_value = true
|
||||
if not by_key.has(key):
|
||||
if strict:
|
||||
config.errors.append("unknown flag '--%s' (see --help)" % key)
|
||||
continue
|
||||
var spec: Spec = by_key[key]
|
||||
# A bare --flag is only meaningful for a bool, and --no-flag is the
|
||||
# conventional way to turn one off. Every other kind needs a value, and
|
||||
# a missing one is an error rather than a silent default.
|
||||
if not has_value:
|
||||
if spec.kind == Kind.BOOL:
|
||||
raw = "true"
|
||||
elif strict:
|
||||
config.errors.append("flag '--%s' needs a value (--%s=<%s>)" % [key, key, _kind_name(spec.kind)])
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
if key in seen:
|
||||
if strict:
|
||||
config.errors.append("flag '--%s' given more than once" % key)
|
||||
continue
|
||||
seen.append(key)
|
||||
if key == "config":
|
||||
config.config_path = raw
|
||||
continue
|
||||
pending.append([spec, raw])
|
||||
|
||||
# Config file first, so the command line lands on top of it.
|
||||
if not config.config_path.is_empty():
|
||||
config._apply_config_file(by_key)
|
||||
for entry in pending:
|
||||
var spec: Spec = entry[0]
|
||||
var parsed = _coerce(spec, entry[1])
|
||||
if parsed == null:
|
||||
config.errors.append("flag '--%s' expects %s, got '%s'" % [spec.key, _kind_name(spec.kind), entry[1]])
|
||||
continue
|
||||
config.values[spec.key] = parsed
|
||||
config._validate()
|
||||
return config
|
||||
|
||||
|
||||
# --no-<bool-flag>, handled by declaring the negation as a synonym rather than
|
||||
# as its own Spec — otherwise `--help` lists every boolean twice.
|
||||
static func _is_negation(key: String, by_key: Dictionary) -> String:
|
||||
if not key.begins_with("no-"):
|
||||
return ""
|
||||
var positive := key.substr(3)
|
||||
if by_key.has(positive) and (by_key[positive] as Spec).kind == Kind.BOOL:
|
||||
return positive
|
||||
return ""
|
||||
|
||||
|
||||
func _apply_config_file(by_key: Dictionary) -> void:
|
||||
var file := ConfigFile.new()
|
||||
var err := file.load(config_path)
|
||||
if err != OK:
|
||||
errors.append("could not read config file '%s' (%s)" % [config_path, error_string(err)])
|
||||
return
|
||||
for key in file.get_section_keys("server") if file.has_section("server") else []:
|
||||
if not by_key.has(key):
|
||||
errors.append("unknown key '%s' in config file '%s'" % [key, config_path])
|
||||
continue
|
||||
var spec: Spec = by_key[key]
|
||||
var raw = file.get_value("server", key)
|
||||
var parsed = _coerce(spec, str(raw))
|
||||
if parsed == null:
|
||||
errors.append("config file key '%s' expects %s, got '%s'" % [key, _kind_name(spec.kind), str(raw)])
|
||||
continue
|
||||
values[key] = parsed
|
||||
|
||||
|
||||
# Returns null on failure — deliberately, so "unparseable" is distinguishable
|
||||
# from a legitimately falsy 0/false/"" result.
|
||||
static func _coerce(spec: Spec, raw: String) -> Variant:
|
||||
match spec.kind:
|
||||
Kind.BOOL:
|
||||
var lowered := raw.to_lower()
|
||||
if lowered in ["true", "1", "yes", "on"]:
|
||||
return true
|
||||
if lowered in ["false", "0", "no", "off"]:
|
||||
return false
|
||||
return null
|
||||
Kind.INT:
|
||||
return int(raw) if raw.is_valid_int() else null
|
||||
Kind.FLOAT:
|
||||
# is_valid_float() accepts integers too, which is what an operator
|
||||
# writing --match-length=150 expects.
|
||||
return float(raw) if raw.is_valid_float() else null
|
||||
Kind.STRING:
|
||||
return raw
|
||||
return null
|
||||
|
||||
|
||||
# Range and enum checks the type system cannot express. Kept separate from
|
||||
# coercion so an error says "out of range" rather than "expects int".
|
||||
func _validate() -> void:
|
||||
var port := int(values["port"])
|
||||
if port < 1 or port > 65535:
|
||||
errors.append("--port must be 1-65535, got %d" % port)
|
||||
if int(values["max-clients"]) < 1:
|
||||
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
|
||||
if float(values["match-length"]) <= 0.0:
|
||||
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
|
||||
if int(values["max-matches"]) < 0:
|
||||
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
|
||||
if int(values["min-players"]) < 1:
|
||||
errors.append("--min-players must be at least 1, got %d" % int(values["min-players"]))
|
||||
if float(values["slot-reservation-seconds"]) < 0.0:
|
||||
errors.append("--slot-reservation-seconds cannot be negative, got %s" % str(values["slot-reservation-seconds"]))
|
||||
if float(values["smoke-force-goal-after"]) < -1.0:
|
||||
errors.append("--smoke-force-goal-after must be -1 (disabled) or 0 or more, got %s" % str(values["smoke-force-goal-after"]))
|
||||
var level := String(values["log-level"])
|
||||
if not level in ["debug", "info", "warn", "error"]:
|
||||
errors.append("--log-level must be one of debug, info, warn, error; got '%s'" % level)
|
||||
var rotation := String(values["arena-rotation"])
|
||||
if not rotation in ["sequential", "random"]:
|
||||
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
|
||||
|
||||
|
||||
static func _kind_name(kind: int) -> String:
|
||||
match kind:
|
||||
Kind.BOOL: return "bool"
|
||||
Kind.INT: return "int"
|
||||
Kind.FLOAT: return "number"
|
||||
Kind.STRING: return "string"
|
||||
return "value"
|
||||
|
||||
|
||||
static func help_text() -> String:
|
||||
var lines := PackedStringArray()
|
||||
lines.append("Cosmic Clash dedicated server")
|
||||
lines.append("")
|
||||
lines.append(" CosmicClashServer.x86_64 -- --port=7777 --max-clients=6")
|
||||
lines.append("")
|
||||
lines.append("Flags may also be supplied by a config file:")
|
||||
lines.append("")
|
||||
lines.append(" --config=/etc/cosmicclash/server.cfg")
|
||||
lines.append("")
|
||||
lines.append(" [server]")
|
||||
lines.append(" port=7777")
|
||||
lines.append(" max-clients=6")
|
||||
lines.append("")
|
||||
lines.append("The command line overrides the config file, which overrides the defaults")
|
||||
lines.append("shown below. An unknown flag is an error, not a warning.")
|
||||
var sections := ["general", "network", "match", "logging"]
|
||||
var all := specs()
|
||||
for section in sections:
|
||||
lines.append("")
|
||||
lines.append("%s:" % section)
|
||||
for spec in all:
|
||||
if spec.section != section:
|
||||
continue
|
||||
var value_hint := "" if spec.kind == Kind.BOOL else "=<%s>" % _kind_name(spec.kind)
|
||||
var flag := "--%s%s" % [spec.key, value_hint]
|
||||
var default_hint := ""
|
||||
if spec.kind == Kind.BOOL:
|
||||
default_hint = " [default: %s, disable with --no-%s]" % [str(spec.default_value), spec.key]
|
||||
elif not str(spec.default_value).is_empty():
|
||||
default_hint = " [default: %s]" % str(spec.default_value)
|
||||
lines.append(" %-34s %s%s" % [flag, spec.help, default_hint])
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddo2ye666o0am
|
||||
@@ -0,0 +1,92 @@
|
||||
class_name ServerLog
|
||||
extends RefCounted
|
||||
|
||||
# Structured server logging (multiplayer-todo.md task 6.4).
|
||||
#
|
||||
# Extracted from server_boot.gd's private `_log`, which could only ever see
|
||||
# what the boot scene itself observed: connects, disconnects, roster changes
|
||||
# and tick overruns. The events an operator actually asks about — who scored,
|
||||
# who got kicked and why, which peer is flooding — happen inside
|
||||
# networked_match.gd and match_sim.gd, neither of which could reach a logger
|
||||
# living on a scene node that gets freed at the first change_scene_to_file.
|
||||
# Static state on a class_name is reachable from all three with no autoload
|
||||
# and no ordering dependency.
|
||||
#
|
||||
# Format: `[<seconds since boot>] LEVEL event key=value key=value`. One line
|
||||
# per event, no wrapping, no multi-line payloads, keys before values — so
|
||||
# `grep 'player_joined'` and `awk` both work on it without a parser.
|
||||
#
|
||||
# ROTATION IS DELIBERATELY NOT IMPLEMENTED HERE. The server logs to stdout and
|
||||
# stops there, because every way this is actually run already owns log
|
||||
# rotation and does it better: `docker logs` with its json-file driver's
|
||||
# max-size/max-file, journald under the systemd unit, or a redirect into
|
||||
# logrotate for a bare process. A server that also writes and rotates its own
|
||||
# file would duplicate all of that and fight it in a container, where stdout is
|
||||
# the interface. SERVER.md documents the three configurations; task 6.6 ships
|
||||
# them. Godot's own `debug/file_logging` remains available for anyone who wants
|
||||
# a file as well, and it rotates via `max_log_files`.
|
||||
|
||||
const LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
|
||||
|
||||
static var _level := 1 # info
|
||||
static var _boot_ms := -1
|
||||
static var _enabled := false # servers only; a client process logs nothing
|
||||
|
||||
|
||||
# Called once by the process that owns the command line. Until then nothing is
|
||||
# emitted at all — a client, an editor session or a unit-test run must not
|
||||
# start printing server telemetry just because it loaded these scripts.
|
||||
static func configure(level_name: String) -> void:
|
||||
_level = int(LEVELS.get(level_name, 1))
|
||||
_boot_ms = Time.get_ticks_msec()
|
||||
_enabled = true
|
||||
|
||||
|
||||
static func is_enabled() -> bool:
|
||||
return _enabled
|
||||
|
||||
|
||||
static func level_name() -> String:
|
||||
for key in LEVELS:
|
||||
if int(LEVELS[key]) == _level:
|
||||
return key
|
||||
return "info"
|
||||
|
||||
|
||||
static func debug(event: String, fields: Dictionary = {}) -> void:
|
||||
_write("debug", event, fields)
|
||||
|
||||
|
||||
static func info(event: String, fields: Dictionary = {}) -> void:
|
||||
_write("info", event, fields)
|
||||
|
||||
|
||||
static func warn(event: String, fields: Dictionary = {}) -> void:
|
||||
_write("warn", event, fields)
|
||||
|
||||
|
||||
static func error(event: String, fields: Dictionary = {}) -> void:
|
||||
_write("error", event, fields)
|
||||
|
||||
|
||||
static func _write(level: String, event: String, fields: Dictionary) -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
if int(LEVELS.get(level, 1)) < _level:
|
||||
return
|
||||
var parts := PackedStringArray()
|
||||
for key in fields:
|
||||
parts.append("%s=%s" % [key, _flatten(fields[key])])
|
||||
var elapsed_sec := float(Time.get_ticks_msec() - _boot_ms) / 1000.0
|
||||
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])
|
||||
|
||||
|
||||
# One line per event is the whole contract, so a value containing a space or a
|
||||
# newline would break every downstream `awk '{print $4}'`. Quote rather than
|
||||
# silently mangle: a player name is operator-supplied and can contain anything.
|
||||
static func _flatten(value: Variant) -> String:
|
||||
var text := str(value)
|
||||
text = text.replace("\n", "\\n").replace("\r", "\\r")
|
||||
if " " in text or text.is_empty():
|
||||
return "\"%s\"" % text.replace("\"", "'")
|
||||
return text
|
||||
@@ -0,0 +1 @@
|
||||
uid://6oqo5tyiayu3
|
||||
@@ -0,0 +1,122 @@
|
||||
class_name ServerMatchLoop
|
||||
extends Node
|
||||
|
||||
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
|
||||
#
|
||||
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
|
||||
# a full match headless", but nothing in the product ever started a match:
|
||||
# lobby.gd has no start path, and every match in this project's history was
|
||||
# begun by a test harness calling change_scene_to_file directly. The dedicated
|
||||
# server booted, listened, and could never play anything. 6.5 was written as
|
||||
# "arena rotation between matches", which presumes a first match that nothing
|
||||
# produced — so the whole loop lives here, not just the rotation.
|
||||
#
|
||||
# Lifecycle:
|
||||
#
|
||||
# wait for --min-players (roster, not raw peers: a peer that has
|
||||
# connected but not completed the hello
|
||||
# handshake is not a player yet)
|
||||
# -> --start-countdown seconds (so a second player joining 200ms later
|
||||
# is in THIS match, not the next one)
|
||||
# -> networked_match.tscn on the arena --arena-rotation picked
|
||||
# -> the match runs itself and returns to the lobby at RESULTS
|
||||
# -> repeat, or exit(0) once --max-matches have completed
|
||||
#
|
||||
# Parented to the scene tree ROOT, never to current_scene: change_scene_to_file
|
||||
# frees whatever scene is live, and an orchestrator that gets freed by the
|
||||
# transition it just requested cannot orchestrate the next one. This is the
|
||||
# same constraint tests/networked_match_test_hooks.gd documents, arrived at the
|
||||
# same way — it is a property of Godot's scene switching, not of testing.
|
||||
#
|
||||
# The countdown is deliberately NOT a Timer: §6.1's tick-derived-clock rule
|
||||
# applies to anything whose timing a client can observe, and the wait before a
|
||||
# match is exactly that.
|
||||
|
||||
signal match_starting(arena_path: String, match_index: int)
|
||||
|
||||
const POLL_INTERVAL_MS := 250
|
||||
|
||||
var min_players := 1
|
||||
var start_countdown_seconds := 5.0
|
||||
var max_matches := 0 # 0 = run forever
|
||||
var rotation_mode := "sequential"
|
||||
|
||||
var matches_completed := 0
|
||||
var _countdown_started_ms := -1
|
||||
var _match_active := false
|
||||
var _next_poll_ms := 0
|
||||
var _shutting_down := false
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _shutting_down or not multiplayer.is_server():
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
if now < _next_poll_ms:
|
||||
return
|
||||
_next_poll_ms = now + POLL_INTERVAL_MS
|
||||
if _match_active:
|
||||
_poll_match_end()
|
||||
else:
|
||||
_poll_match_start(now)
|
||||
|
||||
|
||||
# A match is over when the match scene is gone. NetworkedMatch returns both
|
||||
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
|
||||
# when everyone has left (§6.4), so "the scene we started is no longer the
|
||||
# current scene" covers the clean end and the abandoned one identically —
|
||||
# without this node having to duplicate either rule or reach into match state.
|
||||
func _poll_match_end() -> void:
|
||||
var scene := get_tree().current_scene
|
||||
if is_instance_valid(scene) and scene.is_in_group("game"):
|
||||
return
|
||||
_match_active = false
|
||||
matches_completed += 1
|
||||
ServerLog.info("match_completed", {
|
||||
"completed": matches_completed, "of": max_matches if max_matches > 0 else "unlimited",
|
||||
})
|
||||
if max_matches > 0 and matches_completed >= max_matches:
|
||||
# §6's drain-and-exit: the point of --max-matches is that a supervisor
|
||||
# can restart the process on a new build between matches instead of
|
||||
# killing players mid-game. Exiting anywhere else would defeat it.
|
||||
_shutting_down = true
|
||||
ServerLog.info("server_draining", {"reason": "max_matches_reached", "matches": matches_completed})
|
||||
get_tree().quit(0)
|
||||
return
|
||||
# Straight back to waiting. The countdown restarts from scratch rather than
|
||||
# carrying over, so players who left during the last match are not counted
|
||||
# toward starting the next one.
|
||||
_countdown_started_ms = -1
|
||||
|
||||
|
||||
func _poll_match_start(now: int) -> void:
|
||||
var players := MatchNet.roster.size()
|
||||
if players < min_players:
|
||||
if _countdown_started_ms >= 0:
|
||||
ServerLog.info("match_start_cancelled", {"players": players, "needed": min_players})
|
||||
_countdown_started_ms = -1
|
||||
return
|
||||
if _countdown_started_ms < 0:
|
||||
_countdown_started_ms = now
|
||||
ServerLog.info("match_start_countdown", {
|
||||
"players": players, "seconds": start_countdown_seconds,
|
||||
})
|
||||
return
|
||||
if now - _countdown_started_ms < int(start_countdown_seconds * 1000.0):
|
||||
return
|
||||
_start_match()
|
||||
|
||||
|
||||
func _start_match() -> void:
|
||||
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
|
||||
# The match scene picks its own arena at random by default. Handing it one
|
||||
# explicitly is what makes rotation a rotation rather than a coincidence.
|
||||
NetworkedMatch.server_arena_override = arena_path
|
||||
_match_active = true
|
||||
_countdown_started_ms = -1
|
||||
ServerLog.info("match_starting", {
|
||||
"index": matches_completed + 1, "arena": arena_path,
|
||||
"players": MatchNet.roster.size(), "rotation": rotation_mode,
|
||||
})
|
||||
match_starting.emit(arena_path, matches_completed)
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
|
||||
@@ -0,0 +1 @@
|
||||
uid://xnvwnqushvvt
|
||||
@@ -1,26 +1,90 @@
|
||||
extends Control
|
||||
|
||||
# Settings screen: a small set of player-facing video knobs on top of
|
||||
# VideoSettings (the autoload holding + persisting them). Anti-aliasing
|
||||
# applies immediately since it's a Viewport-wide setting; glow/brightness
|
||||
# apply the next time an arena loads (see arena.gd), since they scale each
|
||||
# arena's own tuned Environment values rather than something viewport-wide.
|
||||
# Settings screen: player-facing video knobs on top of VideoSettings (the
|
||||
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
|
||||
# scale apply immediately since they're Viewport- or DisplayServer-wide;
|
||||
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
|
||||
# an Environment, or instantly to an already-loaded one via
|
||||
# VideoSettings.settings_changed (see arena.gd) — task 0.17.
|
||||
|
||||
const AA_OPTIONS := [
|
||||
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
|
||||
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
|
||||
{"name": "MSAA 2x", "mode": VideoSettings.AAMode.MSAA_2X},
|
||||
{"name": "MSAA 4x", "mode": VideoSettings.AAMode.MSAA},
|
||||
{"name": "MSAA 4x + FXAA", "mode": VideoSettings.AAMode.MSAA_FXAA},
|
||||
]
|
||||
|
||||
const PRESET_OPTIONS := [
|
||||
{"name": "Low", "preset": VideoSettings.Preset.LOW},
|
||||
{"name": "Medium", "preset": VideoSettings.Preset.MEDIUM},
|
||||
{"name": "High", "preset": VideoSettings.Preset.HIGH},
|
||||
{"name": "Custom", "preset": VideoSettings.Preset.CUSTOM},
|
||||
]
|
||||
|
||||
const VSYNC_OPTIONS := [
|
||||
{"name": "Disabled", "mode": VideoSettings.VsyncMode.DISABLED},
|
||||
{"name": "Enabled", "mode": VideoSettings.VsyncMode.ENABLED},
|
||||
{"name": "Adaptive", "mode": VideoSettings.VsyncMode.ADAPTIVE},
|
||||
]
|
||||
|
||||
@onready var preset_dropdown: OptionButton = %PresetDropdown
|
||||
@onready var aa_dropdown: OptionButton = %AADropdown
|
||||
@onready var glow_slider: HSlider = %GlowSlider
|
||||
@onready var glow_value_label: Label = %GlowValueLabel
|
||||
@onready var brightness_slider: HSlider = %BrightnessSlider
|
||||
@onready var brightness_value_label: Label = %BrightnessValueLabel
|
||||
@onready var resolution_slider: HSlider = %ResolutionSlider
|
||||
@onready var resolution_value_label: Label = %ResolutionValueLabel
|
||||
@onready var vsync_dropdown: OptionButton = %VsyncDropdown
|
||||
@onready var fps_cap_dropdown: OptionButton = %FpsCapDropdown
|
||||
@onready var fps_readout_label: Label = %FpsReadoutLabel
|
||||
|
||||
# fps_cap_dropdown item index -> VideoSettings divisor (0 = uncapped). Built
|
||||
# in _ready() from the live refresh rate so the menu never hardcodes a
|
||||
# specific display's numbers.
|
||||
var _fps_cap_divisors: Array[int] = []
|
||||
var _populating := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# An idle settings screen has no reason to render past the display's own
|
||||
# refresh rate; _on_back_pressed only returns to another capped menu, so
|
||||
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
|
||||
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
||||
|
||||
_populating = true
|
||||
_populate_preset_dropdown()
|
||||
_populate_aa_dropdown()
|
||||
_populate_vsync_dropdown()
|
||||
_populate_fps_cap_dropdown(refresh_rate)
|
||||
_populating = false
|
||||
|
||||
glow_slider.value = VideoSettings.glow_scale
|
||||
brightness_slider.value = VideoSettings.brightness
|
||||
resolution_slider.value = VideoSettings.resolution_scale
|
||||
_update_glow_label()
|
||||
_update_brightness_label()
|
||||
_update_resolution_label()
|
||||
_update_fps_cap_enabled()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
fps_readout_label.text = "%d fps" % Performance.get_monitor(Performance.TIME_FPS)
|
||||
|
||||
|
||||
func _populate_preset_dropdown() -> void:
|
||||
preset_dropdown.clear()
|
||||
var selected := 0
|
||||
for i in PRESET_OPTIONS.size():
|
||||
preset_dropdown.add_item(PRESET_OPTIONS[i]["name"])
|
||||
if PRESET_OPTIONS[i]["preset"] == VideoSettings.preset:
|
||||
selected = i
|
||||
preset_dropdown.select(selected)
|
||||
|
||||
|
||||
func _populate_aa_dropdown() -> void:
|
||||
aa_dropdown.clear()
|
||||
var selected := 0
|
||||
for i in AA_OPTIONS.size():
|
||||
@@ -29,10 +93,39 @@ func _ready() -> void:
|
||||
selected = i
|
||||
aa_dropdown.select(selected)
|
||||
|
||||
glow_slider.value = VideoSettings.glow_scale
|
||||
brightness_slider.value = VideoSettings.brightness
|
||||
_update_glow_label()
|
||||
_update_brightness_label()
|
||||
|
||||
func _populate_vsync_dropdown() -> void:
|
||||
vsync_dropdown.clear()
|
||||
var selected := 0
|
||||
for i in VSYNC_OPTIONS.size():
|
||||
vsync_dropdown.add_item(VSYNC_OPTIONS[i]["name"])
|
||||
if VSYNC_OPTIONS[i]["mode"] == VideoSettings.vsync_mode:
|
||||
selected = i
|
||||
vsync_dropdown.select(selected)
|
||||
|
||||
|
||||
# Options derive from the live refresh rate rather than a fixed list, per
|
||||
# task 0.17's "fps cap derived from screen_get_refresh_rate() divisors".
|
||||
# A -1 (or otherwise non-positive) query falls back to "Uncapped" only,
|
||||
# rather than presenting cap choices the engine can't compute a value for.
|
||||
func _populate_fps_cap_dropdown(refresh_rate: float) -> void:
|
||||
fps_cap_dropdown.clear()
|
||||
_fps_cap_divisors.clear()
|
||||
fps_cap_dropdown.add_item("Uncapped")
|
||||
_fps_cap_divisors.append(0)
|
||||
if refresh_rate > 0.0:
|
||||
for divisor in [1, 2, 3, 4]:
|
||||
var hz := refresh_rate / float(divisor)
|
||||
fps_cap_dropdown.add_item("%d fps (refresh / %d)" % [roundi(hz), divisor])
|
||||
_fps_cap_divisors.append(divisor)
|
||||
var selected := _fps_cap_divisors.find(VideoSettings.fps_cap_divisor)
|
||||
fps_cap_dropdown.select(maxi(selected, 0))
|
||||
|
||||
|
||||
# The fps cap dropdown only means anything with vsync Disabled — vsync
|
||||
# itself already caps to (a multiple of) the refresh rate otherwise.
|
||||
func _update_fps_cap_enabled() -> void:
|
||||
fps_cap_dropdown.disabled = VideoSettings.vsync_mode != VideoSettings.VsyncMode.DISABLED
|
||||
|
||||
|
||||
func _update_glow_label() -> void:
|
||||
@@ -43,9 +136,26 @@ func _update_brightness_label() -> void:
|
||||
brightness_value_label.text = "%d%%" % roundi(brightness_slider.value * 100.0)
|
||||
|
||||
|
||||
func _update_resolution_label() -> void:
|
||||
resolution_value_label.text = "%d%%" % roundi(resolution_slider.value * 100.0)
|
||||
|
||||
|
||||
func _on_preset_dropdown_item_selected(index: int) -> void:
|
||||
VideoSettings.apply_preset(PRESET_OPTIONS[index]["preset"])
|
||||
# The bundle may have changed AA/resolution scale underneath the other
|
||||
# controls — resync them without re-triggering their own "user changed
|
||||
# this by hand" mark_custom() path.
|
||||
_populating = true
|
||||
_populate_aa_dropdown()
|
||||
resolution_slider.value = VideoSettings.resolution_scale
|
||||
_populating = false
|
||||
_update_resolution_label()
|
||||
|
||||
|
||||
func _on_aa_dropdown_item_selected(index: int) -> void:
|
||||
VideoSettings.aa_mode = AA_OPTIONS[index]["mode"]
|
||||
VideoSettings.apply_aa()
|
||||
_mark_custom_if_user_driven()
|
||||
|
||||
|
||||
func _on_glow_slider_value_changed(value: float) -> void:
|
||||
@@ -58,6 +168,40 @@ func _on_brightness_slider_value_changed(value: float) -> void:
|
||||
_update_brightness_label()
|
||||
|
||||
|
||||
func _on_resolution_slider_value_changed(value: float) -> void:
|
||||
VideoSettings.resolution_scale = value
|
||||
VideoSettings.apply_resolution_scale()
|
||||
_update_resolution_label()
|
||||
_mark_custom_if_user_driven()
|
||||
|
||||
|
||||
func _on_vsync_dropdown_item_selected(index: int) -> void:
|
||||
VideoSettings.vsync_mode = VSYNC_OPTIONS[index]["mode"]
|
||||
VideoSettings.apply_vsync()
|
||||
_update_fps_cap_enabled()
|
||||
|
||||
|
||||
func _on_fps_cap_dropdown_item_selected(index: int) -> void:
|
||||
VideoSettings.fps_cap_divisor = _fps_cap_divisors[index]
|
||||
VideoSettings.apply_fps_cap()
|
||||
|
||||
|
||||
# Preset-gated fields (AA, resolution scale — glow/shadows/SDFGI/SSIL/SSAO
|
||||
# have no direct control in this menu yet) flip the preset dropdown to
|
||||
# Custom when the player overrides them by hand, so the dropdown never shows
|
||||
# a preset name next to settings that preset doesn't actually produce. Not
|
||||
# called while _populating (initial load) or while apply_preset() itself is
|
||||
# writing these same fields (VideoSettings.mark_custom() no-ops there too,
|
||||
# but skipping the redundant dropdown repopulation here is cheap).
|
||||
func _mark_custom_if_user_driven() -> void:
|
||||
if _populating:
|
||||
return
|
||||
VideoSettings.mark_custom()
|
||||
_populating = true
|
||||
_populate_preset_dropdown()
|
||||
_populating = false
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
+131
-7
@@ -1,5 +1,6 @@
|
||||
class_name Ship
|
||||
extends RigidBody3D
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
|
||||
# Physics-driven spaceship. All movement is force/torque-based, applied in
|
||||
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
|
||||
@@ -44,7 +45,8 @@ extends RigidBody3D
|
||||
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
|
||||
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
|
||||
# _apply_team_color() retints them per-team and must keep addressing them by
|
||||
# name). Verified via get_surface_count()/surface_get_material() before
|
||||
# name, under $Visual — see that function). Verified via
|
||||
# get_surface_count()/surface_get_material() before
|
||||
# writing this: hull and canopy are each a single surface with their own
|
||||
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
|
||||
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
|
||||
@@ -105,6 +107,88 @@ var _current_action: ShipAction = ShipAction.new()
|
||||
var _inert_action: ShipAction = ShipAction.new()
|
||||
var _boundary: ArenaBoundary
|
||||
|
||||
var _pending_teleport: Transform3D
|
||||
var _has_pending_teleport := false
|
||||
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||||
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||||
var _pending_teleport_has_velocity := false
|
||||
|
||||
|
||||
# Queues an authoritative teleport, applied at the top of the next
|
||||
# _integrate_forces — the only Jolt-safe place to write state.transform
|
||||
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
|
||||
# physics step via set_deferred("global_transform", ...).
|
||||
func queue_teleport(to: Transform3D) -> void:
|
||||
_pending_teleport = to
|
||||
_has_pending_teleport = true
|
||||
_pending_teleport_has_velocity = false
|
||||
|
||||
|
||||
# Network hard snaps need the server velocity as their new starting point,
|
||||
# unlike gameplay resets which deliberately zero it. Keep the write queued:
|
||||
# Jolt only permits state mutation from _integrate_forces.
|
||||
# The queued-but-not-yet-applied teleport target, or null when none is
|
||||
# pending. queue_teleport() defers the actual write to the next
|
||||
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||||
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||||
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||||
# pre-reset position and corrects it a tick later.
|
||||
func get_pending_teleport():
|
||||
return _pending_teleport if _has_pending_teleport else null
|
||||
|
||||
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
_pending_teleport_angular_velocity = new_angular_velocity
|
||||
_pending_teleport_has_velocity = true
|
||||
_has_pending_teleport = true
|
||||
|
||||
|
||||
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
|
||||
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
|
||||
# hook in _integrate_forces below is a no-op today.
|
||||
# Velocity delta from a soft correction, consumed once then cleared —
|
||||
# applied in full immediately (invisible to the player, and it's the
|
||||
# *cause* of future position error, so blending it just prolongs
|
||||
# divergence).
|
||||
var net_vel_correction := Vector3.ZERO
|
||||
# Rendered offset between the body and $Visual while a soft correction
|
||||
# decays away, so a position correction moves the collider in full without
|
||||
# visibly teleporting the mesh. Same decay convention as drag/righting
|
||||
# torque (_tick_scaled) above.
|
||||
var net_visual_offset := Vector3.ZERO
|
||||
var net_visual_rotation_offset := Quaternion.IDENTITY
|
||||
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||
const MAX_VISUAL_OFFSET := 0.4
|
||||
var net_prediction_contact_window := false # client telemetry only
|
||||
var net_visual_offset_decay := NET_VISUAL_OFFSET_DECAY
|
||||
var net_visual_offset_max := MAX_VISUAL_OFFSET
|
||||
|
||||
|
||||
func set_network_visual_tuning(decay: float, max_offset: float) -> void:
|
||||
# Called only by the local client debug overlay. Server/training ships keep
|
||||
# the constants above and therefore retain their exact existing behavior.
|
||||
net_visual_offset_decay = clampf(decay, 0.5, 0.99)
|
||||
net_visual_offset_max = clampf(max_offset, 0.05, 2.0)
|
||||
|
||||
|
||||
# Feeds thrust_z/turbo into the movement VFX for a ship with no local
|
||||
# controller driving _integrate_forces (a frozen remote ship never calls
|
||||
# get_action(), so _update_movement_vfx's engine glow/flame would otherwise
|
||||
# read a stale or zeroed action and show dead engines).
|
||||
func set_visual_action(thrust_z: float, turbo: bool) -> void:
|
||||
_current_action.thrust.z = thrust_z
|
||||
_current_action.turbo = turbo
|
||||
|
||||
|
||||
# The local network sender reads this after this tick's _integrate_forces,
|
||||
# rather than pulling PlayerShipController a second time. That preserves the
|
||||
# one get_action() call per physics tick contract.
|
||||
func get_current_action_copy() -> ShipAction:
|
||||
return _current_action.copy()
|
||||
|
||||
|
||||
# Instrument signals for efficient data distribution
|
||||
signal speed_changed(speed: float)
|
||||
signal attitude_changed(pitch: float, roll: float, yaw: float)
|
||||
@@ -135,6 +219,13 @@ var _engine_cores: Array[MeshInstance3D] = []
|
||||
var _engine_flames: Array[MeshInstance3D] = []
|
||||
var _engine_lights: Array[OmniLight3D] = []
|
||||
|
||||
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
|
||||
# TailFin) parents under this instead of the RigidBody3D directly, so a
|
||||
# future prediction correction (task 0.14) can offset the visual without
|
||||
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
|
||||
# and the controller child correctly stay on the body itself.
|
||||
@onready var visual: Node3D = $Visual
|
||||
|
||||
|
||||
func _ready():
|
||||
# Add ship to group for instrument discovery
|
||||
@@ -177,7 +268,7 @@ func _apply_team_color() -> void:
|
||||
return
|
||||
var accent := _get_team_material(team)
|
||||
for mesh_name in ["Nose", "TailFin"]:
|
||||
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
|
||||
var mesh := get_node_or_null("Visual/" + mesh_name) as MeshInstance3D
|
||||
if mesh:
|
||||
mesh.material_override = accent
|
||||
|
||||
@@ -205,7 +296,7 @@ func _build_merged_hull() -> void:
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.name = "MergedHull"
|
||||
instance.mesh = mesh
|
||||
add_child(instance)
|
||||
visual.add_child(instance)
|
||||
|
||||
|
||||
# Attach the node that drives this ship (player, AI, or network). Replaces
|
||||
@@ -238,7 +329,7 @@ func _build_movement_vfx() -> void:
|
||||
core.position = engine_pos
|
||||
core.mesh = core_mesh
|
||||
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(core)
|
||||
visual.add_child(core)
|
||||
_engine_cores.append(core)
|
||||
|
||||
# A single conventional orange flame replaces the layered particle plume
|
||||
@@ -265,7 +356,7 @@ func _build_movement_vfx() -> void:
|
||||
flame.mesh = flame_mesh
|
||||
flame.visible = false
|
||||
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(flame)
|
||||
visual.add_child(flame)
|
||||
_engine_flames.append(flame)
|
||||
|
||||
var light := OmniLight3D.new()
|
||||
@@ -275,7 +366,7 @@ func _build_movement_vfx() -> void:
|
||||
light.omni_range = 3.5
|
||||
light.omni_attenuation = 2.0
|
||||
light.shadow_enabled = false
|
||||
add_child(light)
|
||||
visual.add_child(light)
|
||||
_engine_lights.append(light)
|
||||
|
||||
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
|
||||
@@ -343,6 +434,39 @@ func _has_telemetry_listeners() -> bool:
|
||||
|
||||
|
||||
func _integrate_forces(state):
|
||||
# Reconciliation telemetry needs to distinguish genuine free flight from
|
||||
# Jolt contact windows. This is read only by the locally predicted client;
|
||||
# it never changes forces, actions, collision state, or server behavior.
|
||||
if not multiplayer.is_server():
|
||||
net_prediction_contact_window = state.get_contact_count() > 0
|
||||
if _has_pending_teleport:
|
||||
_has_pending_teleport = false
|
||||
state.transform = _pending_teleport
|
||||
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
_pending_teleport_has_velocity = false
|
||||
reset_physics_interpolation()
|
||||
if is_instance_valid(visual):
|
||||
visual.reset_physics_interpolation()
|
||||
|
||||
# --- Netcode correction hook (Phase 4) --- guarded: both fields default
|
||||
# to Vector3.ZERO and nothing writes them yet, so neither branch runs
|
||||
# today.
|
||||
if net_vel_correction != Vector3.ZERO:
|
||||
state.linear_velocity += net_vel_correction
|
||||
net_vel_correction = Vector3.ZERO
|
||||
if net_visual_offset != Vector3.ZERO:
|
||||
net_visual_offset = net_visual_offset.limit_length(net_visual_offset_max)
|
||||
net_visual_offset *= _tick_scaled(net_visual_offset_decay, state.step)
|
||||
if net_visual_offset.length_squared() < 0.0001:
|
||||
net_visual_offset = Vector3.ZERO
|
||||
visual.position = net_visual_offset
|
||||
if net_visual_rotation_offset != Quaternion.IDENTITY:
|
||||
net_visual_rotation_offset = net_visual_rotation_offset.slerp(Quaternion.IDENTITY, 1.0 - _tick_scaled(net_visual_offset_decay, state.step))
|
||||
if absf(net_visual_rotation_offset.angle_to(Quaternion.IDENTITY)) < 0.001:
|
||||
net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
visual.basis = Basis(net_visual_rotation_offset)
|
||||
|
||||
# One action per physics tick, pulled from the controller (deterministic)
|
||||
_current_action = controller.get_action() if controller else _inert_action
|
||||
|
||||
@@ -448,7 +572,7 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
||||
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
|
||||
# decays at the same rate per second regardless of physics_ticks_per_second.
|
||||
func _tick_scaled(k: float, step: float) -> float:
|
||||
return pow(k, step * 60.0)
|
||||
return pow(k, step * SimConstants.TICK_HZ)
|
||||
|
||||
|
||||
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||||
|
||||
@@ -9,3 +9,15 @@ extends RefCounted
|
||||
var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back
|
||||
var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll
|
||||
var turbo := false
|
||||
|
||||
|
||||
# Returns a distinct ShipAction with equal fields. Callers that hold onto an
|
||||
# action past the tick it was returned in (input history, prediction ring)
|
||||
# must copy() it — get_action() implementations are free to return a reused
|
||||
# instance, and player_ship_controller.gd's does.
|
||||
func copy() -> ShipAction:
|
||||
var c := ShipAction.new()
|
||||
c.thrust = thrust
|
||||
c.rotation = rotation
|
||||
c.turbo = turbo
|
||||
return c
|
||||
|
||||
+111
-8
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
|
||||
@export_group("Impact Shake")
|
||||
@export var max_shake_offset := 0.32
|
||||
@export var shake_decay := 8.0
|
||||
@export_group("Impact Punch")
|
||||
# Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd):
|
||||
# a camera-only FOV kick + PostFX flash that decays over real time, so it
|
||||
# works identically for hit-stop and goal moments without touching global
|
||||
# simulation speed — which a networked client could never do to a shared sim.
|
||||
@export var punch_fov_kick := 9.0
|
||||
@export var punch_vignette_kick := 0.28
|
||||
@export var punch_chroma_kick := 0.012
|
||||
@export var punch_decay := 5.0
|
||||
|
||||
var target: Ship:
|
||||
set(value):
|
||||
@@ -33,6 +42,16 @@ var target: Ship:
|
||||
target.ball_contact.disconnect(_on_target_ball_contact)
|
||||
target = value
|
||||
_connect_target()
|
||||
# Priming: a freshly assigned target's Visual has no interpolation
|
||||
# history yet (or is about to be reparented mid-spawn), and the rig
|
||||
# itself would otherwise lerp in from wherever it was previously
|
||||
# (world origin on first spawn, the old target on a Spectate switch)
|
||||
# over camera_smoothing seconds. Both read as a visible swoop/smear;
|
||||
# neither is a real camera move.
|
||||
if is_instance_valid(target) and is_instance_valid(target.visual):
|
||||
target.visual.reset_physics_interpolation()
|
||||
if is_inside_tree():
|
||||
snap_to_target()
|
||||
var ball_cam_enabled := true
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
|
||||
var _shake_noise := FastNoiseLite.new()
|
||||
var _shake_time := 0.0
|
||||
var _last_shake_offset := Vector3.ZERO
|
||||
var _punch_strength := 0.0
|
||||
var _goal_cut_active := false
|
||||
var _goal_cut_position := Vector3.ZERO
|
||||
var _goal_cut_look_at := Vector3.ZERO
|
||||
|
||||
const SHAKE_UPDATE_HZ := 60.0
|
||||
|
||||
|
||||
func _ready():
|
||||
# Group lets the HUD discover the rig for the camera-mode instrument
|
||||
add_to_group("ship_camera")
|
||||
# The rig moves itself every rendered frame in _process now (task 0.16),
|
||||
# not on the physics tick — Godot's built-in physics interpolation would
|
||||
# otherwise smooth between _physics_process-era transforms this rig no
|
||||
# longer writes, fighting the manual smoothing below.
|
||||
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||||
camera.fov = base_fov
|
||||
_effective_distance = camera_distance
|
||||
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
@@ -83,7 +110,7 @@ func _input(event):
|
||||
camera_mode_changed.emit(ball_cam_enabled)
|
||||
|
||||
|
||||
func _physics_process(delta):
|
||||
func _process(delta):
|
||||
# Camera position smoothing operates on the unshaken chase position. Remove
|
||||
# last frame's presentation-only offset first so shake never accumulates or
|
||||
# exceeds its configured amplitude during a low-time-scale hit-stop.
|
||||
@@ -93,6 +120,10 @@ func _physics_process(delta):
|
||||
return
|
||||
if _goal_cut_active:
|
||||
_smooth_look_at(_goal_cut_look_at, delta)
|
||||
# The cinematic cut freezes camera position, but leftover shake from
|
||||
# an impact right before the goal must still bleed off in real time —
|
||||
# otherwise it resumes at full pre-cut magnitude when play returns.
|
||||
_decay_shake(delta)
|
||||
return
|
||||
_update_speed_feel(delta)
|
||||
var ball := _get_ball()
|
||||
@@ -100,6 +131,7 @@ func _physics_process(delta):
|
||||
_update_ball_cam(delta, ball)
|
||||
else:
|
||||
_update_ship_cam(delta)
|
||||
_apply_punch(delta)
|
||||
|
||||
|
||||
func _get_ball() -> Node3D:
|
||||
@@ -112,7 +144,14 @@ func _update_ball_cam(delta, ball: Node3D):
|
||||
# Ball cam keeps the ship between the camera and the ball: the camera sits
|
||||
# on the ball→ship line (horizontal component only), looking at the ball,
|
||||
# so the ship stays low-centre in frame and the ball stays centred.
|
||||
var ship_pos: Vector3 = target.global_transform.origin
|
||||
# Reads target.visual, not target itself: once prediction correction
|
||||
# (task 0.14) lands, the body can be offset from what's on screen — the
|
||||
# camera must always frame what the player sees, not the collider.
|
||||
# get_global_transform_interpolated() (not global_transform) because this
|
||||
# now runs in _process: the physics body only moves once per 60Hz tick,
|
||||
# so sampling its raw transform every rendered frame at 240fps would
|
||||
# repeat the same value 4 times in a row and read as stutter.
|
||||
var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().origin
|
||||
var ball_pos: Vector3 = ball.global_transform.origin
|
||||
|
||||
# Smooth the orbit direction in angle space rather than lerping the camera
|
||||
@@ -145,9 +184,12 @@ func _update_ball_cam(delta, ball: Node3D):
|
||||
|
||||
|
||||
func _update_ship_cam(delta):
|
||||
# In ship cam, camera follows and looks in the same direction as the ship
|
||||
var ship_pos: Vector3 = target.global_transform.origin
|
||||
var ship_forward: Vector3 = -target.global_transform.basis.z
|
||||
# In ship cam, camera follows and looks in the same direction as the ship.
|
||||
# Reads target.visual, not target itself, via the interpolated transform —
|
||||
# see _update_ball_cam.
|
||||
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||
var ship_pos: Vector3 = visual_xform.origin
|
||||
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||
|
||||
# Position camera behind and above the ship
|
||||
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||
@@ -189,6 +231,7 @@ func _update_speed_feel(delta: float) -> void:
|
||||
|
||||
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
|
||||
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
|
||||
_punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0))
|
||||
for device in Input.get_connected_joypads():
|
||||
Input.start_joy_vibration(
|
||||
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
|
||||
@@ -201,17 +244,77 @@ func _apply_shake(delta: float) -> void:
|
||||
if _shake_strength <= 0.001:
|
||||
_shake_strength = 0.0
|
||||
return
|
||||
_shake_time += delta * 60.0
|
||||
_shake_time += delta
|
||||
# Quantized to a fixed 60Hz cadence rather than sampled once per rendered
|
||||
# frame. The noise domain's total distance travelled per second is the
|
||||
# same either way, but that's not what "reads the same" means here: at
|
||||
# 60fps, consecutive samples are frequency (2.5) domain-units apart —
|
||||
# far enough that FastNoiseLite's simplex correlation has decayed, so it
|
||||
# reads as sharp, uncorrelated jitter. At 240fps the same per-second
|
||||
# distance is split across 4x the samples, so consecutive samples are
|
||||
# ~4x closer together and highly correlated — a completely different,
|
||||
# much gentler wobble. Freezing the domain input to whole 60Hz ticks
|
||||
# makes every render frame within one tick reuse the exact same sample,
|
||||
# so the perceived shake texture is identical at any frame rate.
|
||||
var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ)
|
||||
var amplitude := max_shake_offset * _shake_strength * _shake_strength
|
||||
var noise := Vector2(
|
||||
_shake_noise.get_noise_1d(_shake_time),
|
||||
_shake_noise.get_noise_1d(_shake_time + 100.0)
|
||||
_shake_noise.get_noise_1d(tick),
|
||||
_shake_noise.get_noise_1d(tick + 100.0)
|
||||
).limit_length(1.0) * amplitude
|
||||
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
|
||||
camera.global_position += _last_shake_offset
|
||||
_decay_shake(delta)
|
||||
|
||||
|
||||
func _decay_shake(delta: float) -> void:
|
||||
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
||||
|
||||
|
||||
# Additive FOV kick + PostFX flash on top of _update_speed_feel's base
|
||||
# values, decaying over real time. Replaces the weight that Engine.time_scale
|
||||
# hit-stop used to sell on ball impact — see the Impact Punch export group.
|
||||
func _apply_punch(delta: float) -> void:
|
||||
if _punch_strength <= 0.001:
|
||||
_punch_strength = 0.0
|
||||
return
|
||||
camera.fov += punch_fov_kick * _punch_strength
|
||||
var chroma: float = post_material.get_shader_parameter("chromatic_aberration")
|
||||
var vignette: float = post_material.get_shader_parameter("vignette_strength")
|
||||
post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength)
|
||||
post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength)
|
||||
_punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta)
|
||||
|
||||
|
||||
# Places the camera at its resting chase position instantly, bypassing
|
||||
# camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the
|
||||
# thing being framed just teleported (kickoff, target reassignment) — without
|
||||
# this the rig would lerp smoothly across the whole arena over
|
||||
# camera_smoothing seconds, which reads as an unintended camera move rather
|
||||
# than a reset.
|
||||
func snap_to_target() -> void:
|
||||
if not is_instance_valid(target) or not is_instance_valid(camera):
|
||||
return
|
||||
_shake_strength = 0.0
|
||||
camera.global_position -= _last_shake_offset
|
||||
_last_shake_offset = Vector3.ZERO
|
||||
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||
var ship_pos: Vector3 = visual_xform.origin
|
||||
var ball := _get_ball()
|
||||
if ball_cam_enabled and ball:
|
||||
var ball_pos: Vector3 = ball.global_transform.origin
|
||||
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
|
||||
_orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK
|
||||
camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
|
||||
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||
camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP)
|
||||
else:
|
||||
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||
camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||
camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP)
|
||||
|
||||
|
||||
func begin_goal_cut(goal_position: Vector3) -> void:
|
||||
_goal_cut_active = true
|
||||
# The hard cut replaces the chase camera's presentation offset entirely;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class_name SimConstants
|
||||
|
||||
# Single source of truth for the physics tick rate. Every script-side timing
|
||||
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
|
||||
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
|
||||
# instead of restating the literal, so changing it changes every derived
|
||||
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
|
||||
# simulation needs to be a config change plus a retrain, not a protocol
|
||||
# rewrite hunting down bare 60s.
|
||||
#
|
||||
# NOT wired to project.godot's physics/common/physics_ticks_per_second — an
|
||||
# engine setting, not a script constant, so it must still be changed by hand
|
||||
# to match (currently unset, engine default 60 — see task 0.18).
|
||||
const TICK_HZ := 60
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkn4t3jpiekba
|
||||
@@ -0,0 +1,42 @@
|
||||
class_name SteamBootstrap
|
||||
extends RefCounted
|
||||
|
||||
# Spacewar is Valve's development App ID. It is intentionally a development
|
||||
# default, never a public-server identity or discovery configuration.
|
||||
const SPACEWAR_APP_ID := 480
|
||||
const APP_ID_ENV := "COSMIC_CLASH_STEAM_APP_ID"
|
||||
|
||||
|
||||
static func app_id() -> int:
|
||||
var configured := OS.get_environment(APP_ID_ENV).strip_edges()
|
||||
if configured.is_valid_int() and int(configured) > 0:
|
||||
return int(configured)
|
||||
return SPACEWAR_APP_ID
|
||||
|
||||
|
||||
static func is_runtime_available() -> bool:
|
||||
return OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer") and Engine.has_singleton("Steam")
|
||||
|
||||
|
||||
static func unavailable_reason() -> String:
|
||||
if not OS.has_feature("steam"):
|
||||
return "this export was not built with the steam feature"
|
||||
if not ClassDB.class_exists("SteamMultiplayerPeer"):
|
||||
return "SteamMultiplayerPeer is missing from this custom Godot build"
|
||||
if not Engine.has_singleton("Steam"):
|
||||
return "the GodotSteam Steam singleton is missing from this custom Godot build"
|
||||
return "Steam is unavailable"
|
||||
|
||||
|
||||
static func initialize() -> Dictionary:
|
||||
if not is_runtime_available():
|
||||
return {"error": ERR_UNAVAILABLE, "reason": unavailable_reason()}
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
# `steamInit` is deliberately called dynamically: stock Godot must be able
|
||||
# to parse and run this project without GodotSteam symbols installed.
|
||||
var result = steam.call("steamInit")
|
||||
if result is bool and result:
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
if result is Dictionary and bool(result.get("status", false)):
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name SteamTransport
|
||||
extends NetTransport
|
||||
|
||||
const SteamBootstrapScript = preload("res://scripts/steam_bootstrap.gd")
|
||||
|
||||
# The SteamMultiplayerPeer extension is looked up dynamically so a stock ENet
|
||||
# build never references an unavailable native class while parsing scripts.
|
||||
const VIRTUAL_PORT := 0
|
||||
|
||||
func transport_id() -> String:
|
||||
return "steam"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return SteamBootstrapScript.is_runtime_available()
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return SteamBootstrapScript.unavailable_reason()
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_host", VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, _port: int) -> Dictionary:
|
||||
var steam_id_text := address.strip_edges()
|
||||
if not steam_id_text.is_valid_int() or int(steam_id_text) <= 0:
|
||||
return {"error": ERR_INVALID_PARAMETER, "reason": "Steam transport requires the server's numeric Steam ID"}
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_client", int(steam_id_text), VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
@@ -1,5 +1,6 @@
|
||||
class_name TrainingMode
|
||||
extends GameMode
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
|
||||
# Headless self-play training mode: two RL-driven ships, no HUD, no camera.
|
||||
# The scene also contains the godot_rl_agents Sync node, which speaks TCP to
|
||||
@@ -120,8 +121,9 @@ const MAX_RANDOM_SHIP_SPEED := 8.0
|
||||
# marker spacing, which uses the same margin for the same reason.
|
||||
const MIN_SHIP_SEPARATION := 4.5
|
||||
|
||||
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
|
||||
const TICKS_PER_SIM_SECOND := 60.0
|
||||
# Sim runs at SimConstants.TICK_HZ physics ticks per sim-second regardless
|
||||
# of speedup.
|
||||
const TICKS_PER_SIM_SECOND := float(SimConstants.TICK_HZ)
|
||||
|
||||
var _agents: Array[ShipAIController] = []
|
||||
|
||||
|
||||
@@ -1,44 +1,164 @@
|
||||
extends Node
|
||||
|
||||
# Autoload: persisted player-facing video preferences (AA, glow, brightness).
|
||||
# AA is a Viewport-wide setting applied immediately via apply_aa(). Glow and
|
||||
# Autoload: persisted player-facing video preferences (preset, AA, glow,
|
||||
# brightness, vsync, fps cap, resolution scale). AA/vsync/fps-cap/resolution
|
||||
# scale are Viewport- or DisplayServer-wide and applied immediately. Glow and
|
||||
# brightness instead scale each arena's own tuned Environment values (see
|
||||
# arena.gd's _ready(), which calls apply_to_environment() once per arena
|
||||
# load) rather than overwriting them outright, so the per-arena bloom tuning
|
||||
# in arena_01/02/03.tscn survives underneath the user's preference.
|
||||
|
||||
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA }
|
||||
signal settings_changed # Arenas re-apply preset-gated Environment/light state live.
|
||||
|
||||
# MSAA_2X appended at the end, not inserted, so existing user://settings.cfg
|
||||
# files (which store this as a bare integer ordinal) keep meaning the same
|
||||
# thing after this rung was added — see task 0.19.
|
||||
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA, MSAA_2X }
|
||||
|
||||
# Ordinals are persisted the same way as AAMode above — append only.
|
||||
enum Preset { LOW, MEDIUM, HIGH, CUSTOM }
|
||||
enum VsyncMode { DISABLED, ENABLED, ADAPTIVE }
|
||||
|
||||
const SETTINGS_PATH := "user://settings.cfg"
|
||||
|
||||
var aa_mode: AAMode = AAMode.MSAA_FXAA
|
||||
# preset -> bundle applied to the individual fields below. CUSTOM has no
|
||||
# bundle: selecting it just stops future preset changes from overwriting
|
||||
# whatever the individual fields currently hold. Task 0.15b's measured
|
||||
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
|
||||
# against each other, so each rung is "meaningfully fewer full-screen passes
|
||||
# than the one above it" rather than a precisely tuned ladder.
|
||||
const PRESET_BUNDLES := {
|
||||
Preset.LOW: {
|
||||
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": false,
|
||||
"shadows_enabled": false, "glow_enabled": false, "aa_mode": AAMode.OFF,
|
||||
"resolution_scale": 0.8,
|
||||
},
|
||||
Preset.MEDIUM: {
|
||||
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": true,
|
||||
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
|
||||
"resolution_scale": 1.0,
|
||||
},
|
||||
Preset.HIGH: {
|
||||
"sdfgi_enabled": true, "ssil_enabled": true, "ssao_enabled": true,
|
||||
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
|
||||
"resolution_scale": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
var preset: Preset = Preset.HIGH
|
||||
var sdfgi_enabled: bool = true
|
||||
var ssil_enabled: bool = true
|
||||
var ssao_enabled: bool = true
|
||||
var shadows_enabled: bool = true
|
||||
var glow_enabled: bool = true
|
||||
|
||||
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
|
||||
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
|
||||
var aa_mode: AAMode = AAMode.FXAA
|
||||
var glow_scale: float = 1.0
|
||||
var brightness: float = 1.0
|
||||
|
||||
# 0.17b: Viewport.scaling_3d_scale, 0.5-1.0. Distinct from window stretch
|
||||
# (0.17c) — this scales the 3D viewport's own internal render resolution
|
||||
# before the fixed-1080p blit, so it works regardless of the stretch
|
||||
# decision. FSR2 rather than bilinear: a fixed-1080p target already discards
|
||||
# native resolution (see 0.17c), so FSR2's per-pixel sharpening recovers more
|
||||
# of that loss than a plain bilinear upscale at the same internal scale.
|
||||
var resolution_scale: float = 1.0
|
||||
var fsr_sharpness: float = 0.2
|
||||
|
||||
var vsync_mode: VsyncMode = VsyncMode.ADAPTIVE
|
||||
# 0 = uncapped; otherwise divides DisplayServer.screen_get_refresh_rate() at
|
||||
# apply time (not stored as a raw fps number) so the same preference re-derives
|
||||
# correctly if the game later runs on a different-refresh-rate display. Only
|
||||
# takes effect when vsync_mode == DISABLED — vsync itself already caps to the
|
||||
# refresh rate (or an unpredictable multiple of it, for ADAPTIVE) otherwise.
|
||||
var fps_cap_divisor: int = 0
|
||||
|
||||
var _applying_preset := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load()
|
||||
apply_aa()
|
||||
# A headless server never renders; applying any of this to its root
|
||||
# viewport or window is pure waste (mirrors the same guard at ship.gd and
|
||||
# arena_boundary.gd).
|
||||
if DisplayServer.get_name() != "headless":
|
||||
apply_aa()
|
||||
apply_resolution_scale()
|
||||
apply_vsync()
|
||||
|
||||
|
||||
func _load() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SETTINGS_PATH) != OK:
|
||||
return
|
||||
preset = cfg.get_value("video", "preset", preset) as Preset
|
||||
sdfgi_enabled = cfg.get_value("video", "sdfgi_enabled", sdfgi_enabled)
|
||||
ssil_enabled = cfg.get_value("video", "ssil_enabled", ssil_enabled)
|
||||
ssao_enabled = cfg.get_value("video", "ssao_enabled", ssao_enabled)
|
||||
shadows_enabled = cfg.get_value("video", "shadows_enabled", shadows_enabled)
|
||||
glow_enabled = cfg.get_value("video", "glow_enabled", glow_enabled)
|
||||
aa_mode = cfg.get_value("video", "aa_mode", aa_mode) as AAMode
|
||||
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
|
||||
brightness = cfg.get_value("video", "brightness", brightness)
|
||||
resolution_scale = cfg.get_value("video", "resolution_scale", resolution_scale)
|
||||
fsr_sharpness = cfg.get_value("video", "fsr_sharpness", fsr_sharpness)
|
||||
vsync_mode = cfg.get_value("video", "vsync_mode", vsync_mode) as VsyncMode
|
||||
fps_cap_divisor = cfg.get_value("video", "fps_cap_divisor", fps_cap_divisor)
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("video", "preset", preset)
|
||||
cfg.set_value("video", "sdfgi_enabled", sdfgi_enabled)
|
||||
cfg.set_value("video", "ssil_enabled", ssil_enabled)
|
||||
cfg.set_value("video", "ssao_enabled", ssao_enabled)
|
||||
cfg.set_value("video", "shadows_enabled", shadows_enabled)
|
||||
cfg.set_value("video", "glow_enabled", glow_enabled)
|
||||
cfg.set_value("video", "aa_mode", aa_mode)
|
||||
cfg.set_value("video", "glow_scale", glow_scale)
|
||||
cfg.set_value("video", "brightness", brightness)
|
||||
cfg.set_value("video", "resolution_scale", resolution_scale)
|
||||
cfg.set_value("video", "fsr_sharpness", fsr_sharpness)
|
||||
cfg.set_value("video", "vsync_mode", vsync_mode)
|
||||
cfg.set_value("video", "fps_cap_divisor", fps_cap_divisor)
|
||||
cfg.save(SETTINGS_PATH)
|
||||
|
||||
|
||||
# Pushes a preset's bundle into the individual fields and applies everything
|
||||
# live. CUSTOM is a no-op bundle-wise — it only matters as a marker so
|
||||
# set_custom_field() below knows not to silently revert to Custom itself.
|
||||
func apply_preset(new_preset: Preset) -> void:
|
||||
preset = new_preset
|
||||
if PRESET_BUNDLES.has(new_preset):
|
||||
var bundle: Dictionary = PRESET_BUNDLES[new_preset]
|
||||
_applying_preset = true
|
||||
sdfgi_enabled = bundle["sdfgi_enabled"]
|
||||
ssil_enabled = bundle["ssil_enabled"]
|
||||
ssao_enabled = bundle["ssao_enabled"]
|
||||
shadows_enabled = bundle["shadows_enabled"]
|
||||
glow_enabled = bundle["glow_enabled"]
|
||||
aa_mode = bundle["aa_mode"]
|
||||
resolution_scale = bundle["resolution_scale"]
|
||||
_applying_preset = false
|
||||
apply_aa()
|
||||
apply_resolution_scale()
|
||||
settings_changed.emit()
|
||||
|
||||
|
||||
# Called by the settings menu whenever the player edits an individual
|
||||
# preset-gated field directly (not via the preset dropdown) — flips to
|
||||
# Custom so the dropdown reflects reality instead of silently lying about
|
||||
# which preset is "selected". No-ops during apply_preset's own writes above.
|
||||
func mark_custom() -> void:
|
||||
if not _applying_preset:
|
||||
preset = Preset.CUSTOM
|
||||
|
||||
|
||||
func apply_aa() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
var viewport := get_tree().root
|
||||
match aa_mode:
|
||||
AAMode.OFF:
|
||||
@@ -53,13 +173,67 @@ func apply_aa() -> void:
|
||||
AAMode.MSAA_FXAA:
|
||||
viewport.msaa_3d = Viewport.MSAA_4X
|
||||
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
|
||||
AAMode.MSAA_2X:
|
||||
viewport.msaa_3d = Viewport.MSAA_2X
|
||||
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
||||
|
||||
|
||||
# Called once by each arena's _ready() to fold the user's glow/brightness
|
||||
# preference into that arena's own baked Environment tuning.
|
||||
func apply_resolution_scale() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
var viewport := get_tree().root
|
||||
if resolution_scale >= 0.999:
|
||||
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
|
||||
viewport.scaling_3d_scale = 1.0
|
||||
else:
|
||||
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_FSR2
|
||||
viewport.scaling_3d_scale = clampf(resolution_scale, 0.5, 1.0)
|
||||
viewport.fsr_sharpness = fsr_sharpness
|
||||
|
||||
|
||||
func apply_vsync() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
match vsync_mode:
|
||||
VsyncMode.DISABLED:
|
||||
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
|
||||
VsyncMode.ENABLED:
|
||||
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
|
||||
VsyncMode.ADAPTIVE:
|
||||
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE)
|
||||
apply_fps_cap()
|
||||
|
||||
|
||||
# Public (not just called from apply_vsync) because gameplay-scene transitions
|
||||
# (main_menu.gd's _leave_to_gameplay) need to apply the player's chosen cap
|
||||
# rather than hardcoding an uncapped 0 — the menu's own refresh-rate cap is a
|
||||
# separate, menu-only concern (settings_menu.gd/main_menu.gd _ready()).
|
||||
func apply_fps_cap() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
if vsync_mode != VsyncMode.DISABLED or fps_cap_divisor <= 0:
|
||||
Engine.max_fps = 0
|
||||
return
|
||||
var refresh := DisplayServer.screen_get_refresh_rate()
|
||||
# refresh_rate query returning -1 (or 0, unlikely but not contractually
|
||||
# excluded) falls back to uncapped rather than dividing by a negative
|
||||
# number into a nonsense cap.
|
||||
if refresh <= 0.0:
|
||||
Engine.max_fps = 0
|
||||
return
|
||||
Engine.max_fps = maxi(1, roundi(refresh / float(fps_cap_divisor)))
|
||||
|
||||
|
||||
# Called once by each arena's _ready() (and again on settings_changed, so an
|
||||
# already-loaded arena updates live) to fold the user's glow/brightness
|
||||
# preference into that arena's own baked Environment tuning, and to gate the
|
||||
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
|
||||
func apply_to_environment(env: Environment) -> void:
|
||||
if env == null:
|
||||
return
|
||||
env.glow_enabled = glow_scale > 0.0
|
||||
env.glow_enabled = glow_enabled and glow_scale > 0.0
|
||||
env.glow_intensity *= glow_scale
|
||||
env.adjustment_brightness *= brightness
|
||||
env.sdfgi_enabled = sdfgi_enabled
|
||||
env.ssil_enabled = ssil_enabled
|
||||
env.ssao_enabled = ssao_enabled
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const AdaptiveInputDepthController = preload("res://scripts/adaptive_input_depth_controller.gd")
|
||||
|
||||
|
||||
func test_starts_safe_and_enters_zero_only_after_sustained_clean_samples() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
assert_eq(policy.target_depth, 1, "starts at one buffered tick")
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS - 1:
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 1, "does not enter zero before enough stable observations")
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 0, "enters zero after the stable observation threshold")
|
||||
|
||||
|
||||
func test_starvation_exits_zero_immediately_and_enforces_cooldown() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.target_depth, 0, "precondition: clean link entered zero")
|
||||
assert_eq(policy.update(8.0, 2.0, -2), 1, "starvation sentinel immediately restores one tick")
|
||||
for _i in AdaptiveInputDepthController.REENTRY_COOLDOWN_TICKS:
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 1, "cooldown prevents immediate zero-depth re-entry")
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.target_depth, 0, "zero-depth can re-enter only after cooldown plus a fresh stable window")
|
||||
|
||||
|
||||
func test_high_jitter_exits_zero_immediately() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.update(8.0, 5.1, 0), 1, "jitter above exit threshold restores safe depth immediately")
|
||||
@@ -0,0 +1 @@
|
||||
uid://crkx670s4ma3j
|
||||
@@ -0,0 +1,60 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Task 6.5. "The server cycles arenas" has to be an assertable claim rather
|
||||
# than an observation about luck, which is why sequential rotation is a pure
|
||||
# function of the completed-match count.
|
||||
|
||||
|
||||
func test_sequential_rotation_visits_every_arena_before_repeating() -> void:
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
assert_true(paths.size() >= 2, "rotation needs at least two arenas to mean anything")
|
||||
var seen := {}
|
||||
for i in paths.size():
|
||||
seen[ArenaRegistry.path_for_match(i, "sequential")] = true
|
||||
assert_eq(seen.size(), paths.size(), "every rotation arena appears in the first cycle")
|
||||
|
||||
|
||||
func test_sequential_rotation_wraps_rather_than_running_out() -> void:
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
var first: String = ArenaRegistry.path_for_match(0, "sequential")
|
||||
var wrapped: String = ArenaRegistry.path_for_match(paths.size(), "sequential")
|
||||
assert_eq(wrapped, first, "match N wraps back to the first arena")
|
||||
# And a long-running server must not drift or fault at large counts.
|
||||
assert_eq(ArenaRegistry.path_for_match(paths.size() * 1000, "sequential"), first, "still correct after a thousand cycles")
|
||||
|
||||
|
||||
func test_consecutive_matches_are_never_the_same_arena_in_sequential_mode() -> void:
|
||||
# The point of rotation is that players do not play the same arena twice in
|
||||
# a row; wrapping must not produce a repeat at the seam either.
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
for i in paths.size() * 2:
|
||||
var current: String = ArenaRegistry.path_for_match(i, "sequential")
|
||||
var next: String = ArenaRegistry.path_for_match(i + 1, "sequential")
|
||||
assert_true(current != next, "match %d and %d differ" % [i, i + 1])
|
||||
|
||||
|
||||
func test_rotation_never_offers_an_arena_bots_cannot_score_in() -> void:
|
||||
# Elevated-goal variants are Free-Play-only until a checkpoint trained on
|
||||
# them is promoted. A server rotating onto one would hand every bot-filled
|
||||
# slot an arena it cannot score in.
|
||||
var rotation := ArenaRegistry.rotation_paths()
|
||||
for arena in ArenaRegistry.ARENAS:
|
||||
if not arena["random"]:
|
||||
assert_true(not (arena["path"] in rotation), "%s is excluded from rotation" % arena["name"])
|
||||
assert_true(rotation.size() > 0, "and something is left to rotate through")
|
||||
|
||||
|
||||
func test_random_mode_stays_inside_the_rotation_set() -> void:
|
||||
for i in 50:
|
||||
var path: String = ArenaRegistry.path_for_match(i, "random")
|
||||
assert_true(path in ArenaRegistry.rotation_paths(), "random picks are still rotation-eligible")
|
||||
|
||||
|
||||
func test_an_unknown_mode_falls_back_to_sequential_rather_than_faulting() -> void:
|
||||
# The CLI already rejects an undeclared mode, so this is the belt to that's
|
||||
# braces — but a server must not crash between matches over a string.
|
||||
assert_eq(
|
||||
ArenaRegistry.path_for_match(1, "spiral"),
|
||||
ArenaRegistry.path_for_match(1, "sequential"),
|
||||
"an unrecognised mode behaves as sequential"
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
||||
const ShipAction = preload("res://scripts/ship_action.gd")
|
||||
|
||||
|
||||
func _action(thrust_z: float) -> ShipAction:
|
||||
var a := ShipAction.new()
|
||||
a.thrust = Vector3(0.0, 0.0, thrust_z)
|
||||
return a
|
||||
|
||||
|
||||
func test_sequential_ingest_and_consume() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.1)])
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "tick 0")
|
||||
buf.ingest(1, [_action(0.2)])
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "tick 1")
|
||||
assert_eq(buf.last_applied_seq, 1, "last_applied_seq after 2 ticks")
|
||||
assert_eq(buf.starved_ticks, 0, "no starvation on a clean sequential stream")
|
||||
|
||||
|
||||
# §3.1's own acceptance criterion: "a 3-packet burst loss produces no
|
||||
# starvation." Redundancy-4 means a single surviving packet after 3 losses
|
||||
# still carries all 4 of the most recent ticks' actions.
|
||||
func test_redundancy_survives_3_packet_burst_loss() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.0)])
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.0, 0.0001, "seq 0")
|
||||
|
||||
# Packets for seq 1, 2, 3 are "lost" (never ingested individually) — only
|
||||
# the seq=4 packet, carrying seq 4,3,2,1 (newest-first, redundancy 4),
|
||||
# actually arrives.
|
||||
buf.ingest(4, [_action(0.4), _action(0.3), _action(0.2), _action(0.1)])
|
||||
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "seq 1 recovered from redundancy")
|
||||
assert_eq(buf.starved_ticks, 0, "seq 1 was not a starve")
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "seq 2 recovered from redundancy")
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.3, 0.0001, "seq 3 recovered from redundancy")
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.4, 0.0001, "seq 4 recovered from redundancy")
|
||||
assert_eq(buf.starved_ticks, 0, "no starvation anywhere across the whole burst-loss window")
|
||||
|
||||
|
||||
func test_starvation_repeats_last_action_then_zeroes_after_500ms() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.7)])
|
||||
buf.consume()
|
||||
|
||||
# Nothing else ever arrives — every consume() from here on starves.
|
||||
for i in InputJitterBuffer.STARVE_ZERO_TICKS:
|
||||
var a := buf.consume()
|
||||
assert_almost_eq(a.thrust.z, 0.7, 0.0001, "repeat-last during starve, tick %d" % i)
|
||||
assert_true(not buf.stalled, "not yet stalled at tick %d" % i)
|
||||
|
||||
# One more tick past STARVE_ZERO_TICKS (30 = 500ms at 60Hz) crosses the
|
||||
# "> 30" threshold and zeroes rather than keeps repeating forever.
|
||||
var stalled_action := buf.consume()
|
||||
assert_almost_eq(stalled_action.thrust.z, 0.0, 0.0001, "zeroed after sustained stall")
|
||||
assert_true(buf.stalled, "stalled flag set after 500ms of starvation")
|
||||
|
||||
|
||||
func test_late_stale_packet_is_discarded_harmlessly() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(5, [_action(0.5)])
|
||||
buf.consume() # seeded to 4 by ingest() (newest_seq - 1 action), one consume reaches 5
|
||||
assert_eq(buf.last_applied_seq, 5, "consumed up through seq 5")
|
||||
|
||||
# A reordered/duplicated packet for an already-consumed seq arrives late.
|
||||
buf.ingest(3, [_action(0.3)])
|
||||
assert_eq(buf.depth(), 0, "a stale packet below last_applied_seq must not appear as buffered depth")
|
||||
|
||||
buf.ingest(6, [_action(0.6)])
|
||||
assert_almost_eq(buf.consume().thrust.z, 0.6, 0.0001, "the genuinely-next seq still consumes correctly")
|
||||
|
||||
|
||||
func test_depth_reports_contiguous_buffered_run() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.0)])
|
||||
buf.consume() # last_applied_seq = 0
|
||||
|
||||
assert_eq(buf.depth(), 0, "nothing buffered ahead yet")
|
||||
buf.ingest(3, [_action(0.3), _action(0.2), _action(0.1)])
|
||||
assert_eq(buf.depth(), 3, "seq 1,2,3 all buffered and contiguous with last_applied_seq")
|
||||
|
||||
# A gap (seq 5 arrives but seq 4 never does) caps depth at the gap, not
|
||||
# the highest seq seen.
|
||||
buf.ingest(5, [_action(0.5)])
|
||||
assert_eq(buf.depth(), 3, "seq 5 sits past a gap at seq 4, so it doesn't extend the contiguous run")
|
||||
|
||||
|
||||
func test_ring_wraparound_does_not_confuse_a_stale_slot_with_a_fresh_one() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.0)])
|
||||
buf.consume()
|
||||
|
||||
# Advance last_applied_seq well past one full lap of the ring (32 entries)
|
||||
# so every stored slot tag is far behind "expected" and none may be
|
||||
# misread as valid.
|
||||
#
|
||||
# This used to drive that purely by starvation with nothing re-ingested.
|
||||
# It can't any more, and shouldn't: starvation only gives up on a sequence
|
||||
# once strictly newer data proves it lost, because advancing past a
|
||||
# sequence the client has not sent yet permanently strands the stream (see
|
||||
# test_starving_ahead_of_the_client_does_not_permanently_discard_its_input).
|
||||
# Drive it the way the real failure does instead — the client's epoch runs
|
||||
# ahead while the intervening packets are lost.
|
||||
var far := InputJitterBuffer.RING_SIZE * 3
|
||||
buf.ingest(far, [_action(0.1)])
|
||||
for i in InputJitterBuffer.RING_SIZE * 2:
|
||||
buf.consume()
|
||||
assert_true(buf.last_applied_seq > InputJitterBuffer.RING_SIZE, "advanced past a full lap of the ring")
|
||||
|
||||
# Now a fresh packet lands at the seq the ring slot for "expected" was
|
||||
# LAST used for, one full lap ago — if slot-tagging didn't work, this
|
||||
# would be misread as already-fresh data from the stale write.
|
||||
var expected := buf.last_applied_seq + 1
|
||||
buf.ingest(expected, [_action(0.9)])
|
||||
var a := buf.consume()
|
||||
assert_almost_eq(a.thrust.z, 0.9, 0.0001, "correctly reads the fresh same-slot-index seq, not a stale wraparound ghost")
|
||||
assert_eq(buf.starved_ticks, 0, "starvation clears once fresh data resumes")
|
||||
|
||||
|
||||
# The under-full direction (above) was covered before an adversarial review
|
||||
# found the OVER-full direction was not: a backlog bigger than RING_SIZE
|
||||
# (a host stall, or persistent client/server clock drift) made consume()
|
||||
# starve — and, past STARVE_ZERO_TICKS, zero the player's ship — forever,
|
||||
# because both last_applied_seq and the client's own seq only ever advance
|
||||
# with no resync, so the gap never closed even though fresh, real input
|
||||
# kept arriving the whole time.
|
||||
func test_ring_overflow_resyncs_to_fresh_data_instead_of_starving_forever() -> void:
|
||||
var buf := InputJitterBuffer.new()
|
||||
buf.ingest(0, [_action(0.0)])
|
||||
buf.consume() # last_applied_seq = 0
|
||||
|
||||
# A burst of packets arriving all at once, exactly what poll() delivers
|
||||
# in one batch once a stalled server resumes — the client kept sending
|
||||
# normally the whole time (a real packet every tick, last-4 redundancy,
|
||||
# newest-first), nothing consumed in between. 50 ticks' worth, well
|
||||
# past one full lap of the 32-entry ring.
|
||||
for seq in range(1, 51):
|
||||
var window: Array = []
|
||||
for k in 4:
|
||||
window.append(_action(float(seq - k) * 0.01))
|
||||
buf.ingest(seq, window)
|
||||
assert_eq(buf.last_applied_seq, 0, "nothing consumed yet, only ingested")
|
||||
|
||||
# The gap (50 - 1 = 49) exceeds RING_SIZE (32): everything older than
|
||||
# "50 - RING_SIZE" has already been irrecoverably overwritten by more
|
||||
# recent arrivals landing on the same ring slots. A single consume()
|
||||
# must resync directly to the oldest data the ring can still actually
|
||||
# provide, not starve through the entire abandoned span.
|
||||
var a := buf.consume()
|
||||
var expected_resync_seq := 50 - InputJitterBuffer.RING_SIZE + 1
|
||||
assert_eq(buf.last_applied_seq, expected_resync_seq, "resynced to exactly RING_SIZE behind the newest data")
|
||||
assert_almost_eq(a.thrust.z, float(expected_resync_seq) * 0.01, 0.0001, "recovered the resynced tick's real action from the ring, not a stale ghost or a zeroed one")
|
||||
assert_eq(buf.starved_ticks, 0, "resyncing to real data is not starvation")
|
||||
assert_true(not buf.stalled, "a recovered player must not be reported as stalled")
|
||||
|
||||
# Normal sequential consumption resumes correctly from the resync point.
|
||||
var next := buf.consume()
|
||||
assert_almost_eq(next.thrust.z, float(expected_resync_seq + 1) * 0.01, 0.0001, "next tick continues in order from the resync point")
|
||||
|
||||
|
||||
# --- Starvation must not strand the stream (adversarial review, B2) ---------
|
||||
# consume() used to advance last_applied_seq on EVERY tick including a starve.
|
||||
# Because ingest() discards anything `seq <= last_applied_seq`, one starve on a
|
||||
# sequence the client had not sent yet left the server permanently one ahead of
|
||||
# arrivals: both sides then advance one per tick, the gap never closes, and
|
||||
# every honest packet is discarded on arrival. Reproduced on a clean LAN — the
|
||||
# client's own input_lead release (delta == 0, which issues no new sequence for
|
||||
# one tick) was enough to trigger it, roughly every 6.5s of ordinary play.
|
||||
|
||||
func _thrust(value: float) -> ShipAction:
|
||||
var a := ShipAction.new()
|
||||
a.thrust = Vector3(0.0, 0.0, value)
|
||||
return a
|
||||
|
||||
|
||||
func test_starving_ahead_of_the_client_does_not_permanently_discard_its_input() -> void:
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "seq 1 applies normally")
|
||||
|
||||
# The client issues NO new sequence this tick (an input_lead release), so
|
||||
# nothing newer than seq 1 exists. The server must keep expecting seq 2
|
||||
# rather than consuming — and discarding — it.
|
||||
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "a starve repeats the last action")
|
||||
assert_eq(buffer.last_applied_seq, 1, "and does NOT advance past a sequence the client has not sent")
|
||||
|
||||
# The client's next real packet must still be accepted and applied.
|
||||
buffer.ingest(2, [_thrust(-1.0)])
|
||||
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "the next honest input is still applied, not discarded")
|
||||
|
||||
|
||||
func test_sustained_release_pattern_does_not_black_out_input() -> void:
|
||||
# The full B2 shape: client and server both advance one per tick, but the
|
||||
# client duplicates one sequence (a release). Pre-fix, every packet from
|
||||
# this point on was discarded and the ship froze for 30 ticks.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
buffer.consume() # release tick: server starves
|
||||
|
||||
var applied_real_input := 0
|
||||
for seq in range(2, 40):
|
||||
buffer.ingest(seq, [_thrust(1.0)])
|
||||
if absf(buffer.consume().thrust.z - 1.0) < 0.001:
|
||||
applied_real_input += 1
|
||||
assert_true(applied_real_input >= 35, "input keeps flowing after a release (applied %d/38)" % applied_real_input)
|
||||
assert_true(not buffer.stalled, "and the buffer never reports a stall")
|
||||
|
||||
|
||||
func test_a_genuinely_lost_packet_is_still_skipped_rather_than_waited_on() -> void:
|
||||
# The control for the two tests above: holding must not become "wait
|
||||
# forever". When strictly newer data has arrived, the missing sequence is
|
||||
# provably lost or reordered and must be given up on immediately.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
buffer.ingest(3, [_thrust(-1.0)]) # seq 2 never arrives; 3 does
|
||||
buffer.consume() # starves on 2, but 3 is newer -> skip it
|
||||
assert_eq(buffer.last_applied_seq, 2, "a lost sequence is skipped once newer data exists")
|
||||
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "and the newer sequence applies on the next tick")
|
||||
|
||||
|
||||
func test_a_silent_client_still_zeroes_and_stalls_on_schedule() -> void:
|
||||
# The other control: holding must not defeat the disconnect behaviour.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
for i in InputJitterBuffer.STARVE_ZERO_TICKS + 2:
|
||||
buffer.consume()
|
||||
assert_true(buffer.stalled, "a silent client still stalls")
|
||||
assert_almost_eq(buffer.last_action.thrust.z, 0.0, 0.001, "and its ship still stops")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cl18xku0mr8d0
|
||||
@@ -0,0 +1,154 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
|
||||
|
||||
|
||||
func test_starts_at_minimum() -> void:
|
||||
var c := InputLeadController.new()
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "initial lead")
|
||||
|
||||
|
||||
func test_unknown_depth_is_a_normal_tick() -> void:
|
||||
var c := InputLeadController.new()
|
||||
assert_eq(c.update(-1), 1, "no snapshot info yet -> ordinary +1 seq increment")
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead unchanged with no info")
|
||||
|
||||
|
||||
func test_healthy_depth_is_a_normal_tick_and_no_immediate_release() -> void:
|
||||
var c := InputLeadController.new()
|
||||
for i in 10:
|
||||
assert_eq(c.update(1), 1, "healthy depth -> ordinary +1 tick %d" % i)
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "release needs 2s clean, not 10 ticks")
|
||||
|
||||
|
||||
func test_zero_target_treats_an_empty_clean_link_buffer_as_healthy() -> void:
|
||||
var c := InputLeadController.new()
|
||||
for i in 10:
|
||||
assert_eq(c.update(0, 0), 1, "adaptive zero-depth target does not attack on a clean empty buffer")
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "clean-link target preserves the minimum lead")
|
||||
|
||||
|
||||
# §3.3: "on any starve, increase by up to 3 immediately" — but debounced by
|
||||
# MIN_CHANGE_INTERVAL_TICKS so it isn't literally same-tick.
|
||||
func test_starve_triggers_fast_attack_after_debounce_floor() -> void:
|
||||
var c := InputLeadController.new()
|
||||
var deltas: Array[int] = []
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
|
||||
deltas.append(c.update(0))
|
||||
# Every tick before the debounce floor is an ordinary +1 (no jump yet).
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1:
|
||||
assert_eq(deltas[i], 1, "no lead change before the debounce floor, tick %d" % i)
|
||||
assert_eq(deltas[InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1], 4, "attack fires on the debounce-floor tick: +1 ordinary + 3 skip")
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN + 3, "lead jumped by 3")
|
||||
|
||||
|
||||
func test_repeated_starvation_climbs_toward_max_and_clamps() -> void:
|
||||
var c := InputLeadController.new()
|
||||
# Enough sustained starvation to trigger several attack steps.
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS * 6:
|
||||
c.update(0)
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MAX, "clamps at LEAD_MAX under sustained starvation, never exceeds it")
|
||||
|
||||
|
||||
func test_release_requires_both_clean_surplus_and_its_own_interval() -> void:
|
||||
var c := InputLeadController.new()
|
||||
# Force lead above minimum first via one attack step.
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
|
||||
c.update(0)
|
||||
var lead_after_attack := c.lead
|
||||
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "lead raised above minimum before testing release")
|
||||
|
||||
# Fewer than CLEAN_SURPLUS_TICKS of surplus depth (above TARGET_DEPTH):
|
||||
# must not release yet.
|
||||
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
|
||||
c.update(InputLeadController.TARGET_DEPTH + 1)
|
||||
assert_eq(c.lead, lead_after_attack, "no release before 2s of clean surplus has elapsed")
|
||||
|
||||
# One more surplus tick crosses the clean-surplus threshold AND the
|
||||
# release interval (both are already satisfied by now since the
|
||||
# debounce timer has been running the whole time) -> releases by 1.
|
||||
var delta := c.update(InputLeadController.TARGET_DEPTH + 1)
|
||||
assert_eq(delta, 0, "release tick duplicates rather than incrementing seq")
|
||||
assert_eq(c.lead, lead_after_attack - 1, "lead released by exactly 1")
|
||||
|
||||
|
||||
func test_release_stops_at_minimum() -> void:
|
||||
var c := InputLeadController.new()
|
||||
# Sustained surplus depth with lead already at LEAD_MIN: `lead` itself
|
||||
# must never drop below the floor, but release must still fire
|
||||
# (duplicate a seq) once its own timing conditions are met, since a
|
||||
# real reported surplus at floor lead is exactly the "backlog this
|
||||
# controller never caused" case — capping `lead` is cosmetic, it must
|
||||
# not also block the seq-duplicate action that drains real depth.
|
||||
var released := false
|
||||
for i in InputLeadController.CLEAN_SURPLUS_TICKS * 3:
|
||||
if c.update(InputLeadController.TARGET_DEPTH + 1) == 0:
|
||||
released = true
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead never drops below the floor, tick %d" % i)
|
||||
assert_true(released, "release still fires (duplicates a seq) even though lead itself is pinned at minimum")
|
||||
|
||||
|
||||
func test_starve_resets_clean_surplus_counter() -> void:
|
||||
var c := InputLeadController.new()
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
|
||||
c.update(0) # raise lead above minimum via one attack step
|
||||
var lead_after_attack := c.lead
|
||||
|
||||
# Some, but not all, of a clean surplus window — and well under the
|
||||
# 30-tick attack debounce floor too, so the interrupting starve below
|
||||
# can't accidentally retrigger a second attack step of its own.
|
||||
var partial_clean_ticks := 10
|
||||
for i in partial_clean_ticks:
|
||||
c.update(InputLeadController.TARGET_DEPTH + 1)
|
||||
c.update(0) # a lone starve tick, resetting _clean_surplus_ticks
|
||||
assert_eq(c.lead, lead_after_attack, "the lone starve tick was too soon after the last change to trigger another attack")
|
||||
|
||||
# A full clean window from this fresh starting point is required before
|
||||
# release fires — one tick short must not be enough.
|
||||
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
|
||||
c.update(InputLeadController.TARGET_DEPTH + 1)
|
||||
assert_eq(c.lead, lead_after_attack, "the starve interruption forced a fresh 2s clean window, so no release yet")
|
||||
c.update(InputLeadController.TARGET_DEPTH + 1)
|
||||
assert_eq(c.lead, lead_after_attack - 1, "release finally fires once a full fresh clean window has elapsed since the interruption")
|
||||
|
||||
|
||||
# A first attempt at fixing this gated the whole release branch on `lead >
|
||||
# LEAD_MIN` — this controller's own memory of past attacks — so a backlog
|
||||
# it did NOT itself create (a server hitch, persistent client/server clock
|
||||
# drift, a burst re-delivery) was never drained: lead stayed at 1 forever
|
||||
# even while the server kept reporting a deep, real backlog, and — because
|
||||
# that gate blocked the seq-duplicate action too, not just lead's own
|
||||
# bookkeeping — the actual buffered depth was never drained either. A
|
||||
# second adversarial review caught that the depth check added alongside
|
||||
# it didn't remove the old gate, just sat next to it. This reproduces the
|
||||
# scenario directly: lead never attacks (depth is never reported as a
|
||||
# starve, <= 0), yet release must still fire from sustained real surplus
|
||||
# alone, even while lead itself stays pinned at its floor throughout.
|
||||
func test_release_drains_a_backlog_it_never_caused_itself() -> void:
|
||||
var c := InputLeadController.new()
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "starts at minimum, never attacked")
|
||||
|
||||
# A large, externally-caused surplus (e.g. right after the server's own
|
||||
# ring-overflow resync) reported for well over 2s — lead never moves
|
||||
# via attack since depth is never <= 0.
|
||||
var released_at_floor := false
|
||||
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
|
||||
if c.update(10) == 0:
|
||||
released_at_floor = true
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead's own bookkeeping never drops below its floor")
|
||||
assert_true(released_at_floor, "release still fires (duplicates a seq, actually draining real depth) even while lead is pinned at the floor")
|
||||
|
||||
# Raise it above the floor via one real attack, then confirm sustained
|
||||
# external surplus (not self-caused) still drains it back down.
|
||||
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
|
||||
c.update(0)
|
||||
var lead_after_attack := c.lead
|
||||
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "attack raised lead")
|
||||
|
||||
var released := false
|
||||
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
|
||||
if c.update(10) == 0:
|
||||
released = true
|
||||
break
|
||||
assert_true(released, "sustained externally-caused surplus (depth=10) must eventually trigger a release")
|
||||
assert_true(c.lead < lead_after_attack, "lead actually decreased in response to real depth, not just internal bookkeeping")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user