mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 17:12:46 +00:00
fix(ai): map team-relative rotation actions
This commit is contained in:
@@ -75,11 +75,13 @@ func _decide() -> void:
|
|||||||
_action.thrust.y = 0.0
|
_action.thrust.y = 0.0
|
||||||
else:
|
else:
|
||||||
_action = ShipActionCodec.from_logits(out, action_noise)
|
_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.
|
# Find ship/ball/teammates/opponents/goal once everything is spawned.
|
||||||
# ShipAction axes are body-frame so only observations need team context
|
# ShipAction thrust axes are body-frame, while its rotation axes are mapped
|
||||||
# (ShipObservations). Rosters never change mid-match (no despawn path exists
|
# 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
|
# 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
|
# spawn_index so a given ship keeps the same observation slot for the whole
|
||||||
# match, matching TrainingMode's identically-sorted lists.
|
# match, matching TrainingMode's identically-sorted lists.
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ extends RefCounted
|
|||||||
# "do not fork this logic" role for observations; the train/inference seam
|
# "do not fork this logic" role for observations; the train/inference seam
|
||||||
# broke once before over exactly this kind of divergence (commit 8c15c46).
|
# 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
|
# Curriculum generation 4 replaces the old continuous Gaussian action space
|
||||||
# (Box(7), see the "continuous" path below) with a per-axis MultiDiscrete
|
# (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
|
# 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
|
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
|
# Legacy continuous decode — moved verbatim from ai_ship_controller.gd so
|
||||||
# every model exported before generation 4 (no "action_space" block in its
|
# every model exported before generation 4 (no "action_space" block in its
|
||||||
# JSON, e.g. Game/bots/promoted/easy.json) keeps behaving byte-identically.
|
# JSON, e.g. Game/bots/promoted/easy.json) keeps behaving byte-identically.
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ func get_action_space() -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
func set_action(action) -> void:
|
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():
|
func reset():
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import gymnasium as gym
|
import gymnasium as gym
|
||||||
import numpy as np
|
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
|
# out of order too, not just catch nothing because both sides changed
|
||||||
# together.
|
# together.
|
||||||
EXPECTED_ORDER = ["rot_x", "rot_y", "rot_z", "thrust_x", "thrust_y", "thrust_z", "turbo"]
|
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:
|
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)
|
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:
|
def main() -> int:
|
||||||
checks = [
|
checks = [
|
||||||
check_action_heads_match_expected_order,
|
check_action_heads_match_expected_order,
|
||||||
check_gymnasium_sorts_to_expected_order,
|
check_gymnasium_sorts_to_expected_order,
|
||||||
check_action_space_processor_produces_expected_multi_discrete,
|
check_action_space_processor_produces_expected_multi_discrete,
|
||||||
check_round_trip_preserves_per_head_values,
|
check_round_trip_preserves_per_head_values,
|
||||||
|
check_team_frame_mapping_is_shared,
|
||||||
]
|
]
|
||||||
for check in checks:
|
for check in checks:
|
||||||
check()
|
check()
|
||||||
|
|||||||
Reference in New Issue
Block a user