mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(*): retune scoring incentives and add finishing reps
This commit is contained in:
@@ -15,13 +15,28 @@ extends AIController3D
|
||||
# Reward shaping weights. Dense terms accrue per physics tick (60 sim-ticks
|
||||
# per sim-second); event terms fire once. Exported so tuning needs no code
|
||||
# edits. Goal rewards are added by TrainingMode, which owns goal events.
|
||||
@export var ball_touch_reward := 1.0
|
||||
@export var ball_touch_reward := 0.4
|
||||
# Ball touches pay out at most once per this many physics ticks (1 sim-
|
||||
# second at 60). Run07 lesson: body_entered re-fires on every micro-
|
||||
# separation, so pinning the ball against a surface farmed ~2 touches/s —
|
||||
# outearning every other term while the goal rate fell. The cooldown keeps
|
||||
# touches a stepping-stone signal instead of the objective.
|
||||
# touches a stepping-stone signal instead of the objective. Halved again
|
||||
# after run01-vs-run02 eval (training/eval_history.json) came back 87.5%
|
||||
# draws: even at 1 touch/s, a full episode's worth of touches could still
|
||||
# outweigh TrainingMode's goal_reward, so scoring and ending the episode
|
||||
# early was never worth it. See goal_reward's comment for the other half of
|
||||
# this fix.
|
||||
@export var ball_touch_cooldown_ticks := 60
|
||||
# A touch pays out scaled by how goal-directed it was — full ball_touch_reward
|
||||
# when the post-touch ball velocity points straight at the attack goal, down
|
||||
# to this floor when it doesn't (0 = only goal-directed touches pay at all).
|
||||
# Without this, any contact paid the same regardless of direction, so batting
|
||||
# the ball anywhere counted the same as an actual shot on goal — reinforcing
|
||||
# possession, not scoring. The floor keeps a purely defensive touch (e.g.
|
||||
# clearing a shot away from your own goal) worth something as a stepping
|
||||
# stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not
|
||||
# the objective" framing.
|
||||
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
|
||||
@export var velocity_to_ball_weight := 0.02
|
||||
@export var ball_velocity_to_goal_weight := 0.004
|
||||
# Per-tick penalty scaled by distance to the ball (full value at the arena's
|
||||
@@ -48,6 +63,13 @@ extends AIController3D
|
||||
# still was a rest state. Sized well below velocity_to_ball_weight so flying
|
||||
# fast toward the ball still beats flying fast anywhere else.
|
||||
@export var speed_reward_weight := 0.004
|
||||
# Flat per-tick cost (-0.06/s, -1.8 over a full 30s episode) applied
|
||||
# regardless of position or behaviour. Every other dense term can be farmed
|
||||
# indefinitely by an episode that never ends in a goal; this one can't — it
|
||||
# only stops accruing once the episode does, via a goal or the timeout. That
|
||||
# makes running the clock out strictly worse than scoring as soon as a
|
||||
# chance appears, instead of a free way to keep collecting dense reward.
|
||||
@export var time_penalty := 0.001
|
||||
|
||||
# Contact normals with y above this are floor contact (exempt from the wall
|
||||
# penalty); below it they read as wall (sideways) or ceiling (downward).
|
||||
@@ -122,6 +144,9 @@ func _physics_process(delta):
|
||||
return
|
||||
_ticks_since_ball_touch += 1
|
||||
|
||||
# Flat time cost — see time_penalty.
|
||||
reward -= time_penalty
|
||||
|
||||
# Dense shaping: own velocity toward the ball
|
||||
var to_ball := ball.global_position - ship.global_position
|
||||
if to_ball.length_squared() > 0.0001:
|
||||
@@ -174,6 +199,13 @@ func _wall_or_ceiling_contact() -> bool:
|
||||
|
||||
|
||||
func _on_ship_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("ball") and _ticks_since_ball_touch >= ball_touch_cooldown_ticks:
|
||||
reward += ball_touch_reward
|
||||
_ticks_since_ball_touch = 0
|
||||
if not body.is_in_group("ball") or _ticks_since_ball_touch < ball_touch_cooldown_ticks:
|
||||
return
|
||||
# Contact-signal ordering means ball.linear_velocity here already reflects
|
||||
# the collision impulse from this touch, not the pre-touch velocity.
|
||||
var alignment := 0.0
|
||||
var to_goal := attack_goal_position - ball.global_position
|
||||
if to_goal.length_squared() > 0.0001 and ball.linear_velocity.length_squared() > 0.0001:
|
||||
alignment = clampf(ball.linear_velocity.normalized().dot(to_goal.normalized()), 0.0, 1.0)
|
||||
reward += ball_touch_reward * lerpf(ball_touch_direction_floor, 1.0, alignment)
|
||||
_ticks_since_ball_touch = 0
|
||||
|
||||
@@ -18,11 +18,22 @@ extends GameMode
|
||||
# "EVAL_RESULT {...}" line is printed before quitting.
|
||||
|
||||
@export var episode_length_seconds := 30.0
|
||||
@export var goal_reward := 10.0
|
||||
# Run01-vs-run02 eval (see training/eval_history.json) came back 87.5% draws:
|
||||
# with a 30s episode, the dense per-tick terms on ShipAIController can sum to
|
||||
# several times this value before it was raised, so scoring and forfeiting
|
||||
# the rest of the episode's farmable reward was worse than never finishing.
|
||||
# Raised well above that ceiling so a real scoring chance always beats
|
||||
# continuing to farm dense reward for however long is left in the episode.
|
||||
@export var goal_reward := 40.0
|
||||
|
||||
# Episode-start state mix; remaining probability = fully random state.
|
||||
@export_range(0.0, 1.0) var kickoff_state_chance := 0.2
|
||||
@export_range(0.0, 1.0) var ball_near_goal_chance := 0.2
|
||||
# Raised from 0.2: fixing the reward incentive to score (see goal_reward,
|
||||
# ball_touch_reward, time_penalty on ShipAIController) only helps if the
|
||||
# policy also gets enough reps at actually finishing. At 0.2 that scenario
|
||||
# was 1 in 5 episode starts; most training time was spent in generic
|
||||
# midfield play where a finish never comes up.
|
||||
@export_range(0.0, 1.0) var ball_near_goal_chance := 0.35
|
||||
|
||||
# Placement bounds for randomized episode starts, derived from the standard
|
||||
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
|
||||
|
||||
@@ -18,5 +18,15 @@
|
||||
"wins_b": 1,
|
||||
"draws": 35,
|
||||
"win_rate_a": 0.1
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-20T18:47:54+00:00",
|
||||
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/run09.json",
|
||||
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/run07.json",
|
||||
"episodes": 40,
|
||||
"wins_a": 6,
|
||||
"wins_b": 9,
|
||||
"draws": 25,
|
||||
"win_rate_a": 0.15
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user