feat(training): add generation 5 curriculum

This commit is contained in:
Josh Creek
2026-08-08 14:56:17 +01:00
parent 33952b3cd0
commit 341a67f6da
10 changed files with 832 additions and 19 deletions
+77 -2
View File
@@ -64,6 +64,11 @@ extends GameMode
# stayed floor-pinned even though the initial one wasn't. See
# _place_air_drill.
@export_range(0.0, 1.0) var air_drill_chance := 0.0
# Moving-ball aerial interception branch used by generation 5. Unlike the
# stationary/random air drill, the ball follows a reachable trajectory toward
# a real goal and ships start low behind/lateral to it, so a useful touch is
# naturally reinforced by the existing goal-directed ball rewards.
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
@@ -129,6 +134,8 @@ var _episode_ticks := 0
# already uses, just for one side of a live training episode.
var _opponent_mode := "self_play"
var _opponent_model_path := ""
var _opponent_model_pool: Array[String] = []
var _frozen_opponent_bots: Array[AIShipController] = []
# ShipAIController @export overrides collected from --ai_<name>=<value> args,
# applied to every ShipAIController this run creates (see _attach_agent).
var _ai_overrides := {}
@@ -164,7 +171,7 @@ func _start() -> void:
team0_ships.append(spawn_ship(0, i, RLShipController.new()))
# The opponent_mode branch applies uniformly to every ship on team 1: an
# "inert"/"frozen" run means the whole opposing team gets that treatment,
# "inert"/"frozen"/"league" run means the whole opposing team gets that treatment,
# not just one ship.
var team1_ships: Array[Ship] = []
for i in team_size:
@@ -177,6 +184,12 @@ func _start() -> void:
var bot := AIShipController.new()
bot.model_path = _opponent_model_path
ship1 = spawn_ship(1, i, bot)
_frozen_opponent_bots.append(bot)
"league":
var bot := AIShipController.new()
bot.model_path = _opponent_model_pool[0] if not _opponent_model_pool.is_empty() else ""
ship1 = spawn_ship(1, i, bot)
_frozen_opponent_bots.append(bot)
_:
ship1 = spawn_ship(1, i, RLShipController.new())
team1_ships.append(ship1)
@@ -230,13 +243,15 @@ func _parse_eval_args() -> void:
const TRAINING_MODE_OVERRIDES := [
"goal_reward", "draw_penalty", "kickoff_state_chance",
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
"air_intercept_chance", "team_size",
]
# ShipAIController @export names a curriculum run may override, read as
# --ai_<name>=<value> to avoid colliding with the names above.
const SHIP_AI_OVERRIDES := [
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
"wall_contact_penalty", "tilt_penalty", "speed_reward_weight", "time_penalty",
"forward_velocity_to_ball_weight", "wall_contact_penalty", "tilt_penalty",
"ground_tilt_penalty", "speed_reward_weight", "time_penalty",
"airborne_penalty",
]
@@ -246,10 +261,20 @@ func _parse_curriculum_args() -> void:
if args.has("opponent_mode"):
_opponent_mode = args["opponent_mode"]
_opponent_model_path = args.get("opponent_model", _opponent_model_path)
if args.has("opponent_model_pool"):
for path in String(args["opponent_model_pool"]).split(",", false):
if not path.is_empty():
_opponent_model_pool.append(path)
if _opponent_mode == "league" and _opponent_model_pool.is_empty():
push_error("TrainingMode: opponent_mode=league requires --opponent_model_pool=path,path")
for name in TRAINING_MODE_OVERRIDES:
if args.has(name):
set(name, _typed_like(args[name], get(name)))
var start_probability := kickoff_state_chance + ball_near_goal_chance \
+ air_drill_chance + air_intercept_chance
if start_probability > 1.0:
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
for name in SHIP_AI_OVERRIDES:
var key := "ai_%s" % name
@@ -281,10 +306,12 @@ func _ai_default(name: String) -> Variant:
"ball_touch_cooldown_ticks": return 60
"ball_touch_direction_floor": return 0.3
"velocity_to_ball_weight": return 0.02
"forward_velocity_to_ball_weight": return 0.0
"ball_velocity_to_goal_weight": return 0.004
"ball_distance_penalty": return 0.002
"wall_contact_penalty": return 0.0025
"tilt_penalty": return 0.0005
"ground_tilt_penalty": return 0.0
"speed_reward_weight": return 0.004
"time_penalty": return 0.001
"airborne_penalty": return 0.0
@@ -386,6 +413,7 @@ func _end_eval_episode() -> void:
func _reset_episode() -> void:
for agent in _agents:
agent.reset()
_select_league_opponent()
var roll := randf()
if roll < kickoff_state_chance:
@@ -396,6 +424,8 @@ func _reset_episode() -> void:
_place_ball_near_goal()
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance:
_place_air_drill()
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance + air_intercept_chance:
_place_air_intercept()
else:
_place_ships_random()
_place_ball_random()
@@ -416,6 +446,15 @@ func _place_ball_random() -> void:
# out via reward shaping.
const AIR_DRILL_BALL_WALL_CLEARANCE := 5.0
func _select_league_opponent() -> void:
if _opponent_mode != "league" or _opponent_model_pool.is_empty():
return
var path := _opponent_model_pool[randi() % _opponent_model_pool.size()]
for bot in _frozen_opponent_bots:
bot.load_policy(path)
# Air drill state (see air_drill_chance): ball spawned high, both ships
# spawned low and lateral, so the state is unsolvable without climbing.
func _place_air_drill() -> void:
@@ -456,6 +495,42 @@ func _place_air_drill() -> void:
_place_body(ship, Transform3D(orientation, ship_position), Vector3.ZERO, Vector3.ZERO)
# Goal-relevant aerial intercept: a high ball is already travelling toward a
# randomly selected goal, while ships begin low and behind/lateral to its
# path. The generous wall clearance prevents rebound farming and an upright
# yaw-only spawn avoids wasting the short drill window on random recovery.
func _place_air_intercept() -> void:
var goal := _goal_for_team(randi() % 2)
var ball_position := Vector3(
randf_range(-8.0, 8.0),
randf_range(6.0, minf(12.0, FIELD_MAX_Y)),
randf_range(-10.0, 10.0)
)
var to_goal := (goal.global_position - ball_position).normalized()
var ball_velocity := (to_goal + Vector3(randf_range(-0.15, 0.15), randf_range(0.0, 0.15), 0.0)).normalized() \
* randf_range(6.0, 11.0)
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO)
var placed: Array[Vector3] = []
var behind := -Vector3(ball_velocity.x, 0.0, ball_velocity.z).normalized()
for ship in ships:
if ship in _inert_ships:
continue
var ship_position := Vector3.ZERO
for _attempt in 20:
var lateral := Vector3(-behind.z, 0.0, behind.x) * randf_range(-7.0, 7.0)
ship_position = ball_position + behind * randf_range(7.0, 13.0) + lateral
ship_position.x = clampf(ship_position.x, -FIELD_HALF_X, FIELD_HALF_X)
ship_position.y = randf_range(FIELD_MIN_Y, 3.0)
ship_position.z = clampf(ship_position.z, -FIELD_HALF_Z, FIELD_HALF_Z)
if _spawn_position_clear(ship_position) and _far_enough_from(ship_position, placed):
break
placed.append(ship_position)
var face_ball := ball_position - ship_position
var yaw := atan2(-face_ball.x, -face_ball.z)
_place_body(ship, Transform3D(Basis.from_euler(Vector3(0.0, yaw, 0.0)), ship_position), Vector3.ZERO, Vector3.ZERO)
# Attacking/defending drill states: ball close to a goal, moving toward it.
# Which goal is picked is biased by attack_goal_bias (0.5 = uniform between
# both, matching historical behaviour; 1.0 = always the goal team 0 attacks).