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.
114 lines
5.0 KiB
GDScript
114 lines
5.0 KiB
GDScript
class_name ShipObservations
|
|
extends RefCounted
|
|
|
|
# Canonical, team-relative observation builder. Shared by training
|
|
# (ShipAIController) and in-game inference (AIShipController) so a trained
|
|
# policy sees byte-identical inputs in both contexts — do not fork this logic.
|
|
#
|
|
# Self-play trick: observations for team 1 are rotated 180° about Y
|
|
# (x → -x, z → -z), so every ship perceives itself attacking toward -Z
|
|
# regardless of which side it spawned on. One policy can then play both teams.
|
|
# The same rotation must be inverted when interpreting actions (see canon —
|
|
# it is its own inverse).
|
|
|
|
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12,
|
|
# z ±18, height 12, goals at z ±18 (flush with the end walls); positions are
|
|
# soft-normalized to roughly [-1, 1]. Do not retune without retraining every
|
|
# model in Game/bots/.
|
|
const POSITION_SCALE := Vector3(20.0, 10.0, 20.0)
|
|
const BALL_SPEED_SCALE := 30.0
|
|
const GOAL_DISTANCE_SCALE := 40.0
|
|
|
|
# Contact normals with y above this are floor contact; below it they read as
|
|
# wall (sideways) or ceiling (downward) — mirrors
|
|
# ShipAIController.FLOOR_NORMAL_MIN_Y (kept here too since ShipAIController's
|
|
# wall_contact_penalty and this observation feature must agree on what
|
|
# counts as "in contact" for the reward/observation to stay consistent).
|
|
const FLOOR_NORMAL_MIN_Y := 0.7
|
|
|
|
# Number of floats build() returns; the policy input size. APPEND-ONLY: new
|
|
# features go on the end and existing indices never move, so an old exported
|
|
# model (whose network was trained against a shorter SIZE) still decodes its
|
|
# first N inputs identically when SIZE grows — see PolicyNetwork.forward's
|
|
# input_size slice/guard. Do not retune an *existing* index without
|
|
# retraining every model in Game/bots/.
|
|
const SIZE := 35
|
|
|
|
|
|
# 180° rotation about Y for team 1; identity for team 0. A proper rotation
|
|
# (preserves handedness), and its own inverse — used for both observations
|
|
# and mapping canonical-frame actions back to world intent.
|
|
static func canon(v: Vector3, team: int) -> Vector3:
|
|
return v if team == 0 else Vector3(-v.x, v.y, -v.z)
|
|
|
|
|
|
# attack_goal_position: centre of the goal this ship is trying to score in
|
|
# (the goal whose `team` == the opponent's team).
|
|
static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_position: Vector3) -> Array:
|
|
var team := ship.team
|
|
var obs := []
|
|
|
|
# Own kinematics
|
|
_append(obs, canon(ship.global_position, team) / POSITION_SCALE)
|
|
_append(obs, canon(-ship.global_transform.basis.z, team)) # forward
|
|
_append(obs, canon(ship.global_transform.basis.y, team)) # up
|
|
_append(obs, canon(ship.linear_velocity, team) / ship.max_speed)
|
|
_append(obs, canon(ship.angular_velocity, team) / ship.max_angular_speed)
|
|
|
|
# Ball, relative to self
|
|
var ball_rel := ball.global_position - ship.global_position
|
|
_append(obs, canon(ball_rel, team) / POSITION_SCALE)
|
|
_append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE)
|
|
|
|
# Opponent, relative to self (zeros if absent, e.g. a 1-ship drill)
|
|
if is_instance_valid(opponent):
|
|
var opp_rel := opponent.global_position - ship.global_position
|
|
_append(obs, canon(opp_rel, team) / POSITION_SCALE)
|
|
_append(obs, canon(opponent.linear_velocity, team) / ship.max_speed)
|
|
else:
|
|
_append(obs, Vector3.ZERO)
|
|
_append(obs, Vector3.ZERO)
|
|
|
|
# Goal we are attacking, relative to self
|
|
var goal_rel := attack_goal_position - ship.global_position
|
|
_append(obs, canon(goal_rel, team) / POSITION_SCALE)
|
|
obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE)
|
|
|
|
# Own contact state (appended — see SIZE's append-only invariant).
|
|
# Added for generation 4: ShipAIController's wall_contact_penalty used to
|
|
# fire on a condition the observation vector couldn't see coming,
|
|
# leaving the value function to predict a reward with no supporting
|
|
# signal. Also gives the policy a direct "am I resting on a surface"
|
|
# signal it can use to push off (a real aerial mechanic), distinct from
|
|
# inferring it indirectly from position/up-vector.
|
|
var normal := contact_normal(ship)
|
|
_append(obs, canon(normal, team))
|
|
obs.append(1.0 if normal != Vector3.ZERO else 0.0)
|
|
|
|
return obs
|
|
|
|
|
|
static func _append(obs: Array, v: Vector3) -> void:
|
|
obs.append(v.x)
|
|
obs.append(v.y)
|
|
obs.append(v.z)
|
|
|
|
|
|
# Aggregate wall/ceiling contact normal (zero if none, or if only floor
|
|
# contact — floor contact is excluded so "in_contact" means "touching
|
|
# something other than the ground it's expected to rest on", matching
|
|
# ShipAIController.wall_contact_penalty's own floor exemption). Requires
|
|
# ship.contact_monitor (see ship.gd's _ready — on unconditionally for every
|
|
# ship so training and in-game inference see identical observations).
|
|
static func contact_normal(ship: Ship) -> Vector3:
|
|
var state := PhysicsServer3D.body_get_direct_state(ship.get_rid())
|
|
if state == null:
|
|
return Vector3.ZERO
|
|
for i in state.get_contact_count():
|
|
if not state.get_contact_collider_object(i) is ArenaBoundary:
|
|
continue
|
|
var normal := state.get_contact_local_normal(i)
|
|
if normal.y < FLOOR_NORMAL_MIN_Y:
|
|
return normal
|
|
return Vector3.ZERO
|