Files
Josh Creek 1811e9333e feat(training): curriculum generation 4 — MultiDiscrete action space redesign
Three curriculum generations (2026-07-21 through 2026-08-04) all tried
gating *when* the policy could use vertical thrust/pitch-roll on top of a
continuous Gaussian action space, and all three failed the same way: PPO's
action-distribution std collapsed within ~10% of steps and never recovered,
landing at a 15-32% win rate vs the grounded reference regardless of
mechanism (hard mask, then a gradual ramp). Generation 3's final attempt
just landed at 24% — the worst of the three.

Root cause, verified against this project's own physics: hovering this ship
requires *holding* thrust.y ~= 0.408 continuously (mass 5.0, vertical_thrust
120, gravity 9.8). A collapsed near-zero-mean Gaussian can brush that value
but never sustain it long enough to earn the reward gradient that would
move the mean — no amount of gating *when* the axis acts fixes a problem in
*how* the policy represents a decision on it. This also independently found
and fixes a real bug: godot_rl never marks an episode timeout as a
truncation, so PPO was bootstrapping V(s)=0 on every 30s draw in every
generation to date.

- Game/scripts/ship_action_codec.gd (new): single source of truth for a
  per-axis MultiDiscrete action space (7 heads, nvec [5,5,5,5,5,5,2]) shared
  by training and in-game inference, replacing the continuous Gaussian.
  thrust_y's bins are deliberately asymmetric so a random policy drifts
  through the volume instead of floor-pinning. Legacy continuous decode
  (ai_ship_controller.gd's old logic) preserved verbatim so every
  pre-generation-4 export (e.g. Game/bots/promoted/easy.json) keeps working
  unchanged via an optional "action_space" JSON field.
- ship_observations.gd: append own contact state (SIZE 31 -> 35, append-only)
  so the value function can see what wall_contact_penalty fires on.
- ship_ai_controller.gd: action space/decode via the codec; drop the
  vertical_ramp/pitch_roll_ramp mechanism entirely; tilt_penalty default
  lowered 4x (aerial approaches require pitching); flight telemetry
  (airborne_fraction, mean_altitude, air_touch_fraction, vertical_thrust_mean)
  and truncation-snapshot fields on get_info().
- training_mode.gd: new air_drill_chance state-setter branch (ball spawned
  high, ships low, kept clear of walls) so aerial practice is forced by the
  environment instead of relying on reward-driven exploration alone; snapshot
  terminal observations before a timeout reset for the truncation fix.
- cosmic_env.py: remap ShipAIController's truncated/terminal_obs info into
  SB3's TimeLimit.truncated/terminal_observation keys.
- train.py: --reset-logits (+ --reset-logits-heads) replaces the
  now-meaningless --reset-std; new EntropyFloorCallback (a persistent
  per-rollout ent_coef controller replacing the one-shot std-reset shock)
  and per-head entropy logging; FlightTelemetryCallback; --air-drill-chance/
  --tilt-penalty flags; optional AbortIfCallback kill-criterion.
- export_policy.py: writes the action_space block for MultiDiscrete models;
  index-level parity check (argmax per head) instead of comparing floats.
- curriculum.py: full rewrite — 3 stages (bootstrap/selfplay/gauntlet), no
  grounded stage, full action space live from step 1; deletes generation
  1-3's checkpoint-lineage machinery (nothing to resume from); final report
  evaluates against both promoted/easy.json and the new
  promoted/reference-grounded.json (a copy of curric-s5-aggression, the
  strongest grounded-era artifact, kept as a fixed yardstick).
- run_training.sh/.gitignore: commit only final.zip, not the ~2400
  intermediate checkpoint files a single stage was writing (~500MB ->
  ~0.2MB per run); requirements.txt pinned (behaviour here now depends on
  specific library internals, not just public APIs).
- test_action_space.py (new): offline rung-0 check catching a head-order
  mismatch before it silently corrupts 24h of training.

Validated: GDScript compiles clean (Godot --headless --import + script
validation), free_play.tscn and training.tscn both boot headless without
errors, offline action-space assertions pass. Not yet run: the actual
smoke-training/A-B validation ladder steps in TRAINING.md's "Generation 4"
section, before committing to the full ~32h curriculum.

See TRAINING.md's "Generation 4" section for the full design writeup.
2026-08-04 23:27:57 +01:00

123 lines
5.4 KiB
Python

"""Godot RL Agents environment wrappers that run Cosmic Clash from source.
Stock GodotEnv expects an *exported* game executable and rewrites its path
per-platform. These subclasses launch the project straight from the repo with
a Godot binary instead (no export step), pointing it at the training scene.
Each Godot instance contributes two agents (one ship per team) that share the
learning policy: self-play by construction.
"""
import pathlib
import subprocess
import numpy as np
from godot_rl.core.godot_env import GodotEnv
from godot_rl.wrappers.stable_baselines_wrapper import StableBaselinesGodotEnv
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
GAME_DIR = REPO_ROOT / "Game"
TRAINING_SCENE = "res://scenes/training.tscn"
class CosmicClashEnv(GodotEnv):
"""GodotEnv that launches either the project from source or an exported binary.
Source mode (default): `godot --path Game res://scenes/training.tscn` — the
positional scene argument overrides the project's normal main scene.
Exported mode (`exported=True`): `env_path` is a pre-built game executable
(see training/export_linux.sh, "Linux Training" preset) — no `--path` and
no scene override needed or possible: official Godot export templates have
path/scene overrides compiled out (`--scene`/a positional scene argument
hard-aborts with "compiled without support for path overrides"), so the
binary instead boots straight into training.tscn on its own via
project.godot's `run/main_scene.training` feature-tag override, activated
by that preset's `custom_features="training"`.
"""
def __init__(self, *args, exported: bool = False, **kwargs):
self.exported = exported
super().__init__(*args, **kwargs)
# env_path is a Godot binary (or, in exported mode, a game executable we
# built ourselves), not a stock godot_rl exported-project path: skip the
# suffix and platform checks stock GodotEnv applies to those.
def _set_platform_suffix(self, env_path: str) -> str:
return env_path
def check_platform(self, filename: str):
pass
def _launch_env(self, env_path, port, show_window, framerate, seed, action_repeat, speedup, **kwargs):
# sync.gd reads --key=value pairs from the raw command line; they must
# NOT go after a `--` separator or OS.get_cmdline_args() drops them.
cmd = [env_path]
if not self.exported:
cmd += ["--path", str(GAME_DIR), TRAINING_SCENE]
cmd += [
f"--port={port}",
f"--env_seed={seed}",
]
if not show_window:
cmd += ["--headless", "--disable-render-loop"]
if framerate is not None:
cmd += ["--fixed-fps", str(framerate)]
if action_repeat is not None:
cmd.append(f"--action_repeat={action_repeat}")
if speedup is not None:
cmd.append(f"--speedup={speedup}")
for key, value in kwargs.items():
cmd.append(f"--{key}={value}")
self.proc = subprocess.Popen(cmd, start_new_session=True)
class CosmicClashVecEnv(StableBaselinesGodotEnv):
"""SB3 VecEnv over N parallel CosmicClashEnv instances.
convert_action_space=True: godot_rl's ActionSpaceProcessor reports a
gym.spaces.MultiDiscrete when every per-axis action entry is Discrete
(see ShipActionCodec/ShipAIController.get_action_space) — nvec
[5,5,5,5,5,5,2] for rotation xyz, thrust xyz, turbo, in that
gymnasium-sorted key order. No conversion logic here needs to change for
that; this class's only functional addition is the truncation-info
remap below.
"""
def __init__(self, godot_bin: str, n_parallel: int = 1, seed: int = 0, port: int = GodotEnv.DEFAULT_PORT, **kwargs):
self.envs = [
CosmicClashEnv(
env_path=godot_bin,
convert_action_space=True,
port=port + p,
seed=seed + p,
**kwargs,
)
for p in range(n_parallel)
]
self.n_parallel = n_parallel
self._check_valid_action_space()
self.results = None
def step(self, action):
"""Remap ShipAIController.get_info()'s "truncated"/"terminal_obs"
into the keys SB3's on_policy_algorithm looks for
("TimeLimit.truncated"/"terminal_observation") so PPO bootstraps
V(s) through an episode timeout instead of treating every 30s draw
as a true terminal state.
Godot_rl's own godot_env.py never sets either key (it returns the
same `done` array for both term and trunc, "# TODO update API to
term, trunc") and StableBaselinesGodotEnv.step() only ever returns
that single collapsed `dones` array to SB3 — so without this, PPO
has no way to distinguish "episode ended because a goal was scored"
(a genuine terminal, V(s)=0 is correct) from "episode ended because
the 30s clock ran out" (an artificial boundary that should be
bootstrapped through), and was silently treating every draw as the
former in every curriculum generation to date.
"""
obs, rewards, dones, infos = super().step(action)
for info in infos:
if info.pop("truncated", False):
info["TimeLimit.truncated"] = True
info["terminal_observation"] = {"obs": np.array(info.pop("terminal_obs"), dtype=np.float32)}
return obs, rewards, dones, infos