From 57a298dc069f90d6745d06e31fc8e9cd2ab739cc Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:54:27 +0100 Subject: [PATCH] fix(ai): map team-relative rotation actions --- Game/scripts/ai_ship_controller.gd | 6 ++++-- Game/scripts/ship_action_codec.gd | 18 ++++++++++++++++++ Game/scripts/ship_ai_controller.gd | 4 +++- training/test_action_space.py | 13 +++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Game/scripts/ai_ship_controller.gd b/Game/scripts/ai_ship_controller.gd index a3156b7b..886ece7e 100644 --- a/Game/scripts/ai_ship_controller.gd +++ b/Game/scripts/ai_ship_controller.gd @@ -75,11 +75,13 @@ func _decide() -> void: _action.thrust.y = 0.0 else: _action = ShipActionCodec.from_logits(out, action_noise) + _action = ShipActionCodec.apply_team_frame(_action, _ship.team) # Find ship/ball/teammates/opponents/goal once everything is spawned. -# ShipAction axes are body-frame so only observations need team context -# (ShipObservations). Rosters never change mid-match (no despawn path exists +# ShipAction thrust axes are body-frame, while its rotation axes are mapped +# from the canonical team frame by ShipActionCodec. Rosters never change +# mid-match (no despawn path exists # anywhere in this codebase), so this only needs to run once — sorted by # spawn_index so a given ship keeps the same observation slot for the whole # match, matching TrainingMode's identically-sorted lists. diff --git a/Game/scripts/ship_action_codec.gd b/Game/scripts/ship_action_codec.gd index aa3cd9cf..5fe42a8a 100644 --- a/Game/scripts/ship_action_codec.gd +++ b/Game/scripts/ship_action_codec.gd @@ -8,6 +8,13 @@ extends RefCounted # "do not fork this logic" role for observations; the train/inference seam # broke once before over exactly this kind of divergence (commit 8c15c46). # +# Ship thrust is body-local, so its axes must not be mirrored for team 1. +# Ship rotation, however, is applied directly as world-space torque in +# Ship.apply_rotation_forces(). Team 1 observes a canonical frame rotated +# 180 degrees about world Y, so its canonical pitch/roll outputs must be +# rotated back to world space before they reach the ship. apply_team_frame() +# is the shared training/inference seam for that conversion. +# # Curriculum generation 4 replaces the old continuous Gaussian action space # (Box(7), see the "continuous" path below) with a per-axis MultiDiscrete # space: PPO's Gaussian std reliably collapsed to ~0.13-0.15 within the first @@ -100,6 +107,17 @@ static func from_logits(logits: Array, noise: float) -> ShipAction: return result +# Map a policy's canonical-frame rotation intent back into the physical +# team's world frame. A 180-degree Y rotation negates X and Z and leaves Y +# unchanged. Translation remains untouched because Ship applies it through +# the ship's local basis rather than as a world-space vector. +static func apply_team_frame(action: ShipAction, team: int) -> ShipAction: + if team == 1: + action.rotation.x = -action.rotation.x + action.rotation.z = -action.rotation.z + return action + + # Legacy continuous decode — moved verbatim from ai_ship_controller.gd so # every model exported before generation 4 (no "action_space" block in its # JSON, e.g. Game/bots/promoted/easy.json) keeps behaving byte-identically. diff --git a/Game/scripts/ship_ai_controller.gd b/Game/scripts/ship_ai_controller.gd index 62e85d1a..12b04c37 100644 --- a/Game/scripts/ship_ai_controller.gd +++ b/Game/scripts/ship_ai_controller.gd @@ -209,7 +209,9 @@ func get_action_space() -> Dictionary: func set_action(action) -> void: - rl_controller.action = ShipActionCodec.from_indices(action) + rl_controller.action = ShipActionCodec.apply_team_frame( + ShipActionCodec.from_indices(action), ship.team + ) func reset(): diff --git a/training/test_action_space.py b/training/test_action_space.py index 88c5d3d4..66b73719 100644 --- a/training/test_action_space.py +++ b/training/test_action_space.py @@ -17,6 +17,7 @@ Usage: """ import sys +from pathlib import Path import gymnasium as gym import numpy as np @@ -30,6 +31,7 @@ from export_policy import ACTION_HEADS # out of order too, not just catch nothing because both sides changed # together. EXPECTED_ORDER = ["rot_x", "rot_y", "rot_z", "thrust_x", "thrust_y", "thrust_z", "turbo"] +GAME_SCRIPTS = Path(__file__).resolve().parents[1] / "Game" / "scripts" def check_action_heads_match_expected_order() -> None: @@ -87,12 +89,23 @@ def check_round_trip_preserves_per_head_values() -> None: np.testing.assert_array_equal(np.asarray(original[head_index]), expected_column) +def check_team_frame_mapping_is_shared() -> None: + codec = (GAME_SCRIPTS / "ship_action_codec.gd").read_text() + training = (GAME_SCRIPTS / "ship_ai_controller.gd").read_text() + inference = (GAME_SCRIPTS / "ai_ship_controller.gd").read_text() + assert "action.rotation.x = -action.rotation.x" in codec + assert "action.rotation.z = -action.rotation.z" in codec + assert "ShipActionCodec.apply_team_frame(" in training + assert "ShipActionCodec.apply_team_frame(" in inference + + def main() -> int: checks = [ check_action_heads_match_expected_order, check_gymnasium_sorts_to_expected_order, check_action_space_processor_produces_expected_multi_discrete, check_round_trip_preserves_per_head_values, + check_team_frame_mapping_is_shared, ] for check in checks: check()