mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
119 lines
5.3 KiB
Python
119 lines
5.3 KiB
Python
"""Rung 0 of TRAINING.md's validation ladder: offline, no Godot, seconds to
|
|
run. Catches the single most likely silent killer in the generation-4
|
|
action-space redesign — a head-order mismatch between the Python trainer and
|
|
Game/scripts/ship_action_codec.gd's HEADS. If they disagree, training still
|
|
runs happily for 24h+ (pitch commands driving strafe thrusters, say) and
|
|
only surfaces as inexplicably-bad behaviour, not an error. This can't
|
|
directly parse the GDScript file, but it locks the two real, checkable
|
|
invariants an order mismatch would actually depend on: that gymnasium's Dict
|
|
key-sorting produces the exact order ship_action_codec.gd's HEADS is written
|
|
in, and that godot_rl's ActionSpaceProcessor converts that into the
|
|
MultiDiscrete nvec the trainer expects. export_policy.py's own index-level
|
|
parity check plus a real in-game round trip (rung 3) are what catch anything
|
|
this can't.
|
|
|
|
Usage:
|
|
.venv/bin/python test_action_space.py
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import gymnasium as gym
|
|
import numpy as np
|
|
from godot_rl.core.utils import ActionSpaceProcessor
|
|
|
|
from export_policy import ACTION_HEADS
|
|
|
|
# The order Game/scripts/ship_action_codec.gd's HEADS is written in — kept
|
|
# here as a literal, independent restatement (not derived from ACTION_HEADS)
|
|
# so this test can actually catch export_policy.py's own list being edited
|
|
# 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:
|
|
names = [head["name"] for head in ACTION_HEADS]
|
|
assert names == EXPECTED_ORDER, (
|
|
f"export_policy.ACTION_HEADS order {names} != expected {EXPECTED_ORDER} — "
|
|
"this must match Game/scripts/ship_action_codec.gd's HEADS exactly"
|
|
)
|
|
|
|
|
|
def check_gymnasium_sorts_to_expected_order() -> None:
|
|
# Build the Dict deliberately out of order (reversed) to prove it's
|
|
# gymnasium's sort doing the work here, not insertion order — this is
|
|
# exactly what godot_env.py does with the dict Godot sends over the wire
|
|
# (see godot_env.py's from_dict, which builds a spaces.Dict from
|
|
# ShipAIController.get_action_space()'s Dictionary).
|
|
sizes = {head["name"]: len(head["bins"]) for head in ACTION_HEADS}
|
|
reversed_dict = gym.spaces.Dict({name: gym.spaces.Discrete(sizes[name]) for name in reversed(EXPECTED_ORDER)})
|
|
sorted_names = list(reversed_dict.keys())
|
|
assert sorted_names == EXPECTED_ORDER, (
|
|
f"gymnasium.spaces.Dict sorted {sorted_names}, expected {EXPECTED_ORDER} — "
|
|
"if this changed, every export from this generation onward would be silently "
|
|
"mis-ordered relative to ship_action_codec.gd"
|
|
)
|
|
|
|
|
|
def check_action_space_processor_produces_expected_multi_discrete() -> None:
|
|
sizes = [len(head["bins"]) for head in ACTION_HEADS]
|
|
tuple_space = gym.spaces.Tuple([gym.spaces.Discrete(n) for n in sizes])
|
|
processor = ActionSpaceProcessor(tuple_space, convert=True)
|
|
assert isinstance(processor.action_space, gym.spaces.MultiDiscrete), (
|
|
f"expected MultiDiscrete, got {type(processor.action_space)} — the all-discrete branch "
|
|
"in godot_rl's ActionSpaceProcessor may have changed (see requirements.txt's pin note)"
|
|
)
|
|
assert list(processor.action_space.nvec) == sizes, (
|
|
f"MultiDiscrete nvec {list(processor.action_space.nvec)} != expected {sizes}"
|
|
)
|
|
|
|
|
|
def check_round_trip_preserves_per_head_values() -> None:
|
|
# An integer action per env, one column per head in EXPECTED_ORDER —
|
|
# confirms to_original_dist splits a MultiDiscrete action back into the
|
|
# same per-head order it was built from (this is what set_action() on
|
|
# the Godot side receives, keyed by head name).
|
|
sizes = [len(head["bins"]) for head in ACTION_HEADS]
|
|
tuple_space = gym.spaces.Tuple([gym.spaces.Discrete(n) for n in sizes])
|
|
processor = ActionSpaceProcessor(tuple_space, convert=True)
|
|
|
|
n_envs = 3
|
|
rng = np.random.default_rng(0)
|
|
action = np.stack([rng.integers(0, n, size=n_envs) for n in sizes], axis=1).astype(np.int64)
|
|
original = processor.to_original_dist(action)
|
|
assert len(original) == len(sizes)
|
|
for head_index, expected_column in enumerate(action.T):
|
|
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()
|
|
print(f"PASS: {check.__name__}")
|
|
print(f"\nAll {len(checks)} action-space checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|