Files
CosmicClash/Game/tests/cases/test_project_settings.gd
Josh Creek 076d27a564 feat(input): full controller support, rebindable controls, and rotation fixes
Playing with a gamepad did not work: all six move_* actions had no joypad
event at all, so a pad could yaw/pitch/roll/turbo but could not translate.
Nothing caught it because every action existed and the game booted fine —
no assertion checked that an action is reachable on *both* devices.

Controller layout, on the 6DOF convention (left stick aims, right stick
translates), using all six of the pad's analog axes for the ship's six
degrees of freedom:

  left stick   yaw + pitch        right stick  strafe + vertical
  LB / RB      roll               RT / LT      forward / back
  L3           turbo              R3           ball camera

Input is now read with Input.get_axis instead of is_action_pressed, so
triggers and sticks are proportional. Keyboard values are unchanged.

Three rotation bugs found by measuring a real Ship rather than reading the
code:

- apply_torque() is world-space and the torque was never rotated into the
  hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became
  pitch after a 90 degree turn and inverted at 180, so the controls were
  correct flying up-field and backwards flying back.
- ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to
  every axis, giving yaw 7x the angular acceleration of pitch and roll
  (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so
  rotation_acceleration means rad/s^2 and all three axes match. Yaw is
  unchanged.
- pitch_down pitched the nose UP: get_axis's arguments were reversed, so
  the I/K keys and the stick each did the opposite of their label.

Menus were unusable on a pad for a separate reason: Godot 4.7 gives
ui_up/down/left/right joypad events by default but leaves ui_accept and
ui_cancel with none (verified against a pristine project), so a controller
could move the highlight and never press anything. A confirms and B goes
back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather
than ui_cancel, so carrying B for menus cannot abandon a live match.

Bindings for both devices are rebindable in Settings -> Controls, persisted
to user://input.cfg — a separate file from settings.cfg because
VideoSettings.save() rewrites that file wholesale and would drop any
section it does not know about. project.godot stays the source of truth for
defaults; overrides are only ever a delta on top of a boot-time snapshot.

Verified: 268 unit tests, the ENet integration gate, and a 16-sample
before/after comparison of networked prediction residuals showing the
physics change does not regress them (median 0.083m -> 0.065m).

Note for follow-up: every policy in Game/bots/ was trained against the old
sluggish, world-axis rotation and will over-rotate until retrained.
2026-09-06 20:41:20 +01:00

102 lines
4.5 KiB
GDScript

extends "res://tests/test_case.gd"
# Guards the project settings that are load-bearing but easy to destroy
# silently. Godot's ConfigFile writer does not round-trip comments in
# project.godot: it drops `;` blocks outright, and a `#` block sitting directly
# above a setting can be spliced onto that setting's own line on rewrite, which
# comments the setting out. A dedicated build would then boot the interactive
# main menu instead of the server, and nothing would fail until someone noticed
# a server process rendering a menu.
#
# The explanations that used to live as comments beside these settings are now
# in the code that owns them — server_boot.gd and video_settings.gd.
#
# The feature-override assertions read project.godot as TEXT rather than through
# ProjectSettings. Godot resolves `key.<feature>` overrides at load time against
# the running build's own feature tags and does not expose the suffixed key, so
# get_setting("run/main_scene.dedicated_server") returns "" in a normal editor/
# headless run even when the line is perfectly intact. Reading the file also
# matches the actual threat, which is textual corruption of the file.
func _project_godot_lines() -> PackedStringArray:
var file := FileAccess.open("res://project.godot", FileAccess.READ)
if file == null:
return PackedStringArray()
return file.get_as_text().split("\n")
# True only if `key="value"` appears as a real, uncommented assignment. A line
# that got spliced into a `#`/`;` comment is deliberately NOT a match — that is
# precisely the corruption being guarded against.
func _has_setting_line(key: String, value: String) -> bool:
var wanted := "%s=\"%s\"" % [key, value]
for raw_line in _project_godot_lines():
var line := raw_line.strip_edges()
if line.begins_with("#") or line.begins_with(";"):
continue
if line == wanted:
return true
return false
func test_project_godot_is_readable() -> void:
# Everything below is vacuously true if the file could not be opened.
assert_true(not _project_godot_lines().is_empty(), "project.godot readable and non-empty")
func test_dedicated_server_feature_override_is_set() -> void:
# Consumed by dedicated exports; see server_boot.gd.
assert_true(
_has_setting_line("run/main_scene.dedicated_server", "res://scenes/server_boot.tscn"),
"run/main_scene.dedicated_server present and uncommented"
)
func test_training_feature_override_is_set() -> void:
assert_true(
_has_setting_line("run/main_scene.training", "res://scenes/training.tscn"),
"run/main_scene.training present and uncommented"
)
func test_physics_engine_is_jolt() -> void:
# The whole flight model and every trained policy assume Jolt. Silently
# reverting to Godot Physics would change ship/ball behaviour under bots
# trained against Jolt, without any other test failing on its own.
var engine: String = ProjectSettings.get_setting("physics/3d/physics_engine", "")
assert_eq(engine, "Jolt Physics", "3D physics engine")
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name
)
func test_matchmaking_scene_is_the_control_plane_entry_point() -> void:
var scene := load("res://scenes/matchmaking.tscn")
assert_true(scene != null, "matchmaking scene exists")
assert_true(FileAccess.file_exists("res://scripts/matchmaking.gd"), "matchmaking controller exists")
func test_test_hook_autoloads_are_not_shipped() -> void:
# main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand
# when running those scene-level smoke tests, and must be removed again —
# see CLAUDE.md. Shipping one registered would run test code in the real
# game, so fail here rather than discovering it in a build.
# McpInteractionServer is registered automatically by the vendored godot-mcp
# tooling whenever it launches the project, and is left behind in
# project.godot afterwards. It is a debug channel into a running game, so
# shipping it registered is worse than a stray test hook, and it arrives
# without anyone having typed it.
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks", "McpInteractionServer"]:
assert_true(
not ProjectSettings.has_setting("autoload/" + hook_name),
"test hook autoload/%s must not be registered" % hook_name
)