mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
1811e9333e
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.
129 lines
5.8 KiB
GDScript
129 lines
5.8 KiB
GDScript
class_name ShipActionCodec
|
|
extends RefCounted
|
|
|
|
# Single source of truth for the RL action layout — shared by training
|
|
# (ShipAIController.get_action_space/set_action) and in-game inference
|
|
# (AIShipController._decide via PolicyNetwork) so a trained policy's action
|
|
# output is decoded identically in both contexts. Mirrors ShipObservations'
|
|
# "do not fork this logic" role for observations; the train/inference seam
|
|
# broke once before over exactly this kind of divergence (commit 8c15c46).
|
|
#
|
|
# 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
|
|
# ~10% of every training run across 3 generations and never recovered, which
|
|
# made a *sustained* set-point (e.g. hovering, thrust.y ~= 0.408 given this
|
|
# ship's mass/thrust — see TRAINING.md) essentially unreachable: the
|
|
# collapsed distribution can brush the hover value but never hold it long
|
|
# enough to accumulate the reward signal that would move the mean. A
|
|
# discrete bin is a single, atomic, repeatable choice with non-zero
|
|
# probability under any softmax, which does not have that failure mode.
|
|
#
|
|
# HEADS order is deliberately gymnasium's *sorted* key order (verified:
|
|
# "rot_x" < "rot_y" < "rot_z" < "thrust_x" < "thrust_y" < "thrust_z" <
|
|
# "turbo") — godot_rl's ActionSpaceProcessor builds the Tuple action space
|
|
# from a gymnasium Dict, which sorts keys regardless of insertion order, so
|
|
# this order is what SB3/PPO actually samples/trains against and what
|
|
# set_action() receives keyed by. Do not reorder without re-verifying that
|
|
# sort order.
|
|
const HEADS := [
|
|
{"name": "rot_x", "bins": [-1.0, -0.5, 0.0, 0.5, 1.0]},
|
|
{"name": "rot_y", "bins": [-1.0, -0.5, 0.0, 0.5, 1.0]},
|
|
{"name": "rot_z", "bins": [-1.0, -0.5, 0.0, 0.5, 1.0]},
|
|
{"name": "thrust_x", "bins": [-1.0, -0.5, 0.0, 0.5, 1.0]},
|
|
# Deliberately asymmetric: hovering this ship (mass 5.0, vertical_thrust
|
|
# 120, default gravity 9.8 m/s^2 — see ship.gd/ship.tscn) requires a
|
|
# sustained thrust.y ~= 0.408. Uniform-random selection over these 5 bins
|
|
# averages 0.34 — just below neutral buoyancy, so a fresh policy drifts
|
|
# gently through the volume instead of pinning to the floor (symmetric
|
|
# bins) or sticking to the ceiling (ceiling_pull_strength 11.5 > gravity
|
|
# 9.8, so the ceiling is easy to over-shoot into). This is the direct
|
|
# analogue of the RLGym/RLBot community fix for the same failure mode
|
|
# ("add more jump actions to the discrete action parser").
|
|
{"name": "thrust_y", "bins": [-0.5, 0.0, 0.45, 0.75, 1.0]},
|
|
{"name": "thrust_z", "bins": [-1.0, -0.5, 0.0, 0.5, 1.0]},
|
|
{"name": "turbo", "bins": [0.0, 1.0]},
|
|
]
|
|
|
|
|
|
static func action_space_dict() -> Dictionary:
|
|
var space := {}
|
|
for head in HEADS:
|
|
space[head["name"]] = {"size": head["bins"].size(), "action_type": "discrete"}
|
|
return space
|
|
|
|
|
|
# Training side: `action` is the Dictionary godot_rl's Sync node hands
|
|
# set_action() — one entry per HEADS key, each an int (or int-valued float)
|
|
# bin index in [0, bins.size()).
|
|
static func from_indices(action: Dictionary) -> ShipAction:
|
|
var result := ShipAction.new()
|
|
var values := {}
|
|
for head in HEADS:
|
|
var index: int = clampi(int(round(float(action[head["name"]]))), 0, head["bins"].size() - 1)
|
|
values[head["name"]] = head["bins"][index]
|
|
result.rotation = Vector3(values["rot_x"], values["rot_y"], values["rot_z"])
|
|
result.thrust = Vector3(values["thrust_x"], values["thrust_y"], values["thrust_z"])
|
|
result.turbo = values["turbo"] > 0.0
|
|
return result
|
|
|
|
|
|
# In-game inference for a MultiDiscrete-trained export: `logits` is the raw
|
|
# policy_network.gd output — 32 floats (5+5+5+5+5+5+2), one contiguous slice
|
|
# per head in HEADS order (matches export_policy.py's action_net layer,
|
|
# which concatenates SB3's per-head categorical logits in that same order).
|
|
# argmax within each slice picks that head's bin, same as SB3's
|
|
# MultiCategoricalDistribution.mode() under deterministic inference.
|
|
static func from_logits(logits: Array, noise: float) -> ShipAction:
|
|
var result := ShipAction.new()
|
|
var values := {}
|
|
var offset := 0
|
|
for head in HEADS:
|
|
var bins: Array = head["bins"]
|
|
var index := 0
|
|
if noise > 0.0 and randf() < noise:
|
|
# eps-random-bin: the discrete analogue of continuous action_noise
|
|
# (see ai_ship_controller.gd) — degrades gracefully and keeps the
|
|
# same 0..1 monotonic difficulty semantics as the continuous path.
|
|
index = randi() % bins.size()
|
|
else:
|
|
var best_value: float = logits[offset]
|
|
for i in range(1, bins.size()):
|
|
if logits[offset + i] > best_value:
|
|
best_value = logits[offset + i]
|
|
index = i
|
|
values[head["name"]] = bins[index]
|
|
offset += bins.size()
|
|
result.rotation = Vector3(values["rot_x"], values["rot_y"], values["rot_z"])
|
|
result.thrust = Vector3(values["thrust_x"], values["thrust_y"], values["thrust_z"])
|
|
result.turbo = values["turbo"] > 0.0
|
|
return result
|
|
|
|
|
|
# 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.
|
|
# `out` is the trainer's flattened Box(7) output, gymnasium-sorted: rotation
|
|
# xyz, thrust xyz, turbo (> 0 means on) — NOT ShipAction's thrust-first
|
|
# declaration order.
|
|
static func from_continuous(out: Array, noise: float) -> ShipAction:
|
|
var result := ShipAction.new()
|
|
result.rotation = Vector3(
|
|
_continuous_axis(out[0], noise),
|
|
_continuous_axis(out[1], noise),
|
|
_continuous_axis(out[2], noise)
|
|
)
|
|
result.thrust = Vector3(
|
|
_continuous_axis(out[3], noise),
|
|
_continuous_axis(out[4], noise),
|
|
_continuous_axis(out[5], noise)
|
|
)
|
|
result.turbo = out[6] > 0.0
|
|
return result
|
|
|
|
|
|
static func _continuous_axis(value: float, noise: float) -> float:
|
|
if noise > 0.0:
|
|
value += randf_range(-noise, noise)
|
|
return clampf(value, -1.0, 1.0)
|