fix(training): correct non-forward penalty math and add a grounding incentive

Adversarial review of the previous stage-4 retune found two problems:
non_forward_speed used planar_speed - forward_component, which under-charges
diagonal motion relative to true lateral speed (e.g. ~29% penalty at 45
degrees off the nose instead of the correct ~71%); fixed to the Pythagorean
magnitude for forward-facing angles, full speed for backward-facing ones.

Also, ground_tilt_penalty and non_forward_penalty only ever cost reward near
the floor with nothing offsetting them above it, which could teach a policy
that's still bad at ground handling to just avoid the floor rather than get
better at it. Added grounded_upright_reward (ship_ai_controller.gd) plus a
new ShipObservations.is_floor_contact helper for genuine belly-on-floor
contact detection, so grounding well while upright is the locally profitable
choice, not just the least-punished one.
This commit is contained in:
Josh Creek
2026-08-09 13:23:00 +01:00
parent c56f5ed1a3
commit 6f7536f03c
5 changed files with 71 additions and 5 deletions
+36 -2
View File
@@ -79,6 +79,20 @@ extends AIController3D
# forward_velocity_to_ball_weight's ball-conditioned bonus. Same
# GROUND_HANDLING_HEIGHT altitude fade as ground_tilt_penalty.
@export var non_forward_penalty := 0.0
# Per-tick bonus for genuinely resting on the floor (ShipObservations.
# is_floor_contact, real contact — not just being below
# GROUND_HANDLING_HEIGHT) while upright. The positive counterpart to
# ground_tilt_penalty/non_forward_penalty: without it, staying above
# GROUND_HANDLING_HEIGHT is reward-neutral relative to grounding, so a
# policy that's still bad at ground handling could "solve" those penalties
# by just avoiding the floor rather than by getting better at handling on
# it — worsening Stage 3's already-airborne-heavy baseline instead of
# fixing it. Kept an order of magnitude below ball_touch_reward/goal_reward
# and comparable to time_penalty/ball_distance_penalty so grounding well is
# attractive without making idling upright on the spot, away from the ball,
# competitive with actually playing (see ball_distance_penalty's run04
# lesson on why a flat positional bonus needs a countervailing cost).
@export var grounded_upright_reward := 0.0
# Per-tick bonus for own speed: 0 stationary, full value (+0.24/s) at
# max_speed. Run07 lesson: after the kickoff flurry both ships parked next to
# a cornered ball — with every other dense term near zero there, standing
@@ -333,16 +347,36 @@ func _physics_process(delta):
# (sideways or reverse), independent of the ball — the mirror image of
# forward_velocity_to_ball_weight's ball-conditioned bonus. Fades out with
# altitude via the same GROUND_HANDLING_HEIGHT ramp as ground_tilt_penalty.
# non_forward_speed is the true lateral magnitude (Pythagorean, not the
# cruder planar_speed - forward_component, which under-charges diagonal
# motion — e.g. at 45 degrees off the nose that gave ~29% of full-speed
# penalty instead of the correct ~71%) for any forward-facing component;
# a backward-facing component (dot product below zero) is fully
# penalized regardless of angle, same as pure sideways motion.
if non_forward_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
var non_forward_planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
var non_forward_planar_speed := non_forward_planar_velocity.length()
var non_forward_planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
if non_forward_planar_speed > 0.0001 and non_forward_planar_forward.length_squared() > 0.0001:
var forward_component: float = maxf(non_forward_planar_velocity.dot(non_forward_planar_forward.normalized()), 0.0)
var non_forward_speed: float = non_forward_planar_speed - forward_component
var forward_component: float = non_forward_planar_velocity.dot(non_forward_planar_forward.normalized())
var non_forward_speed: float
if forward_component >= 0.0:
non_forward_speed = sqrt(maxf(
non_forward_planar_speed * non_forward_planar_speed - forward_component * forward_component, 0.0
))
else:
non_forward_speed = non_forward_planar_speed
var non_forward_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
reward -= non_forward_penalty * (non_forward_speed / ship.max_speed) * non_forward_ground_factor
# Dense bonus: genuinely resting on the floor while upright (see
# grounded_upright_reward) — the positive counterpart to
# ground_tilt_penalty/non_forward_penalty, so grounding is worth
# pursuing, not just less punished than staying airborne.
if grounded_upright_reward > 0.0 and ShipObservations.is_floor_contact(ship):
var grounded_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
reward += grounded_upright_reward * maxf(grounded_uprightness, 0.0)
# Dense penalty: height above the floor (see airborne_penalty). The
# floor sits at world y = 0 (see training_mode.gd's FIELD_MIN_Y/
# _escaped bounds); normalized so the worst case is pinned at the