mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 06:12:03 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb06300685 | |||
| 08eb9f5842 | |||
| e1f512c94e | |||
| 6320b982a8 | |||
| 46fe696a58 |
File diff suppressed because one or more lines are too long
@@ -21,9 +21,6 @@ run/main_scene="uid://bcq14356s3e2i"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
run/main_scene.training="res://scenes/training.tscn"
|
||||
# Dedicated exports select the server boot scene before the interactive menu
|
||||
# is loaded. This is the same project-setting feature override used above by
|
||||
# the training export.
|
||||
run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
||||
|
||||
[autoload]
|
||||
@@ -59,22 +56,8 @@ NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/size/mode=2
|
||||
; Task 0.17c: kept fixed at "viewport" + 1080p rather than moved to
|
||||
; "disabled", deliberately. A player on a 1440p/4K display cannot render
|
||||
; native this way, and a 1080p player cannot render lower than 1080p through
|
||||
; window scaling alone — but task 0.17b's Viewport.scaling_3d_scale already
|
||||
; covers "render lower than the window" independently of stretch mode (it
|
||||
; scales the 3D viewport's own internal resolution before this blit, not the
|
||||
; window itself), and task 0.15b found an unexplained ~6% non-uniform width
|
||||
; scaling on this project's one tested (Mac/Retina) machine — see
|
||||
; multiplayer-todo.md §5.5.1 — that needs understanding before stretch mode
|
||||
; is touched, not blindly carried into a resolution-dependent change.
|
||||
window/stretch/mode="viewport"
|
||||
window/stretch/aspect="expand"
|
||||
; Task 0.17: default matches VideoSettings.gd's VsyncMode.ADAPTIVE default —
|
||||
; VideoSettings.apply_vsync() overwrites this at runtime via DisplayServer as
|
||||
; soon as the autoload initializes, so this is only what's in effect for the
|
||||
; brief pre-autoload window and if VideoSettings ever fails to load.
|
||||
window/vsync/vsync_mode=2
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
@@ -13,16 +13,15 @@ const BOTS_DIR := "res://bots"
|
||||
|
||||
# Every tier runs its promoted checkpoint at full trained capability —
|
||||
# difficulty is a genuinely different policy, never the same policy
|
||||
# handicapped with reaction delay or action noise. Easy and Medium are now
|
||||
# distinct models (medium.json beats easy.json 65-22-13 head-to-head); Hard
|
||||
# still points at medium.json, the strongest promoted policy, and stays a
|
||||
# label-only duplicate until a stronger one earns hard.json. Keep the tiers
|
||||
# handicapped with reaction delay or action noise. All three tiers are now
|
||||
# distinct models: medium.json beats easy.json 65-22-13, and hard.json (the
|
||||
# generation-5 Stage-5 policy) beats medium.json 47-32-21. Keep the tiers
|
||||
# monotonic: never leave a lower tier pointing at a stronger model than the
|
||||
# one above it.
|
||||
const DIFFICULTIES := [
|
||||
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||
{"name": "Medium", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||
{"name": "Hard", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||
{"name": "Hard", "model": "res://bots/promoted/hard.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||
]
|
||||
|
||||
@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown
|
||||
|
||||
@@ -9,6 +9,15 @@ extends Node
|
||||
#
|
||||
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
|
||||
#
|
||||
# Dedicated *exports* reach this scene without the CLI argument above, via the
|
||||
# `run/main_scene.dedicated_server` feature override in project.godot — the same
|
||||
# project-setting mechanism the training export uses for training.tscn. That
|
||||
# override is deliberately uncommented in project.godot: Godot's ConfigFile
|
||||
# writer does not round-trip comments, and a `#` block directly above a setting
|
||||
# can be spliced into the setting's own line on rewrite, silently commenting it
|
||||
# out and sending dedicated builds to the interactive main menu instead of here.
|
||||
# `tests/cases/test_project_settings.gd` fails loudly if that ever happens.
|
||||
#
|
||||
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
|
||||
# scene. This is just the process shell: listen, log, idle cheaply.
|
||||
|
||||
|
||||
@@ -167,11 +167,39 @@ extends AIController3D
|
||||
# unaffected; the floor-lock curriculum stage turns it on.
|
||||
@export var airborne_penalty := 0.0
|
||||
|
||||
# Height above which a touch counts toward air_touch_fraction telemetry
|
||||
# (see get_info) — not a reward term itself, see set_action/get_info's
|
||||
# comments on why generation 4 deliberately does not add a standalone
|
||||
# air-touch reward.
|
||||
const AIR_TOUCH_HEIGHT := 5.0
|
||||
# Height above which a touch counts as aerial — for air_touch_fraction /
|
||||
# productive_air_touch_* telemetry (see get_info) and, conjunctively, for
|
||||
# air_touch_bonus_weight. Not a standalone reward term; see set_action/
|
||||
# get_info on why generation 4 deliberately does not add one.
|
||||
#
|
||||
# Lowered 5.0 -> 3.0 on 2026-08-24, and this reverses Round 9's explicit
|
||||
# "AIR_TOUCH_HEIGHT stays 5.0" decision, so the reasoning matters. 5.0 was
|
||||
# never derived from anything: every aerial mechanism in generation 5 — the
|
||||
# drill geometry, the touch bonus, all three air-touch metrics — was built on
|
||||
# top of it, but nobody measured where the ball actually goes. Instrumenting
|
||||
# it (ball_mean_altitude / ball_peak_altitude / ball_above_air_touch_fraction,
|
||||
# added alongside this change) over normal match play found the ball averages
|
||||
# ~1.6m, the average episode's PEAK ball height is only ~2.4m, and the ball is
|
||||
# above 5m for ~5% of ticks. So 5.0 sat at roughly twice the typical episode
|
||||
# peak, and the drill had to spawn the ball at 8-14m — far above anything the
|
||||
# game produces — purely to give it hang time above that bar.
|
||||
#
|
||||
# 3.0 is not a softened bar chosen to let a run pass; it is this project's
|
||||
# existing definition of airborne, matching AIRBORNE_ALTITUDE_THRESHOLD and
|
||||
# GROUND_HANDLING_HEIGHT below, and it sits just above the measured mean
|
||||
# episode peak so it still denotes a genuine aerial rather than ordinary
|
||||
# bouncing. Simulating the drill against real physics (ball gravity_scale 0.8
|
||||
# + linear_damp 0.1, ship thrust 120/mass 5, drag 0.98/tick) at the two
|
||||
# thresholds shows it strictly dominates: with the band retuned to 6-10m an
|
||||
# ideal interceptor reaches the ball 67.8% of the time (was 53.2%) and touches
|
||||
# it above the bar 57.3% of the time (was 41.2%), needing 5.2m of climb rather
|
||||
# than 8.2m.
|
||||
#
|
||||
# Round 9's comparability objection is real but has nothing left to protect:
|
||||
# productive_air_touch_fraction read exactly 0.0 for all nine attempts, so
|
||||
# there is no history this preserves. Pre-2026-08-24 air-touch numbers are
|
||||
# measured against 5.0 and are NOT comparable with anything after it.
|
||||
const AIR_TOUCH_HEIGHT := 3.0
|
||||
|
||||
# Generation-5 ground-handling telemetry/reward thresholds. Fixed constants
|
||||
# keep the logged metrics comparable across stages; changing one starts a new
|
||||
@@ -237,6 +265,9 @@ var _thrust_y_sum := 0.0
|
||||
var _touches := 0
|
||||
var _air_touches := 0
|
||||
var _productive_air_touches := 0
|
||||
var _ball_above_air_touch_ticks := 0
|
||||
var _ball_peak_altitude := 0.0
|
||||
var _ball_altitude_sum := 0.0
|
||||
var _ground_ticks := 0
|
||||
var _upright_ground_ticks := 0
|
||||
var _moving_ground_ticks := 0
|
||||
@@ -311,6 +342,33 @@ func get_info() -> Dictionary:
|
||||
info["vertical_thrust_mean"] = _thrust_y_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
|
||||
info["air_touch_fraction"] = float(_air_touches) / _touches if _touches > 0 else 0.0
|
||||
info["productive_air_touch_fraction"] = float(_productive_air_touches) / _touches if _touches > 0 else 0.0
|
||||
# The gate metric for Stage 5/6. The _fraction pair above divide by TOTAL
|
||||
# touches, which makes them unusable as a bar: a policy with a strong ground
|
||||
# game accumulates many ground touches, and those dilute the ratio for
|
||||
# identical aerial behaviour. Stage 4 exists to improve exactly that ground
|
||||
# game — it took forward_motion_fraction from ~0.24 to ~0.48 — so Stage 4's
|
||||
# success actively pushed Stage 5's gate toward zero, and the two stages were
|
||||
# working against each other. It is also why the only non-zero values ever
|
||||
# logged across nine attempts came from degenerate episodes whose single
|
||||
# touch happened to be a productive aerial (per-episode value 1.0, so exactly
|
||||
# 0.01 once meaned over SB3's 100-episode ep_info_buffer — the 0.0100 that
|
||||
# was every run's maximum).
|
||||
#
|
||||
# This one asks the question the floor actually means: did this episode
|
||||
# contain a productive aerial at all? Meaned over the buffer it reads
|
||||
# directly as "what share of episodes contained one", is bounded 0-1, and
|
||||
# cannot be diluted by ground play. Deliberately insensitive to magnitude:
|
||||
# three aerials in an episode score the same as one, which is the right
|
||||
# trade for a gate (see TRAINING.md for the diagnostic alternative).
|
||||
info["productive_air_touch_episode_fraction"] = 1.0 if _productive_air_touches > 0 else 0.0
|
||||
info["ball_above_air_touch_fraction"] = \
|
||||
float(_ball_above_air_touch_ticks) / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
|
||||
info["ball_mean_altitude"] = _ball_altitude_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
|
||||
# Highest the ball reached this episode. Meaned over the buffer this says
|
||||
# where the aerial band actually IS, without picking a threshold first —
|
||||
# the number _place_air_intercept's spawn band should be derived from
|
||||
# rather than guessed at.
|
||||
info["ball_peak_altitude"] = _ball_peak_altitude
|
||||
info["upright_fraction"] = float(_upright_ground_ticks) / _ground_ticks if _ground_ticks > 0 else 0.0
|
||||
info["forward_motion_fraction"] = float(_forward_moving_ground_ticks) / _moving_ground_ticks if _moving_ground_ticks > 0 else 0.0
|
||||
# Diagnostic only — deliberately NOT in any stage's telemetry_floors (see
|
||||
@@ -342,6 +400,9 @@ func reset():
|
||||
_touches = 0
|
||||
_air_touches = 0
|
||||
_productive_air_touches = 0
|
||||
_ball_above_air_touch_ticks = 0
|
||||
_ball_altitude_sum = 0.0
|
||||
_ball_peak_altitude = 0.0
|
||||
_ground_ticks = 0
|
||||
_upright_ground_ticks = 0
|
||||
_moving_ground_ticks = 0
|
||||
@@ -493,6 +554,20 @@ func _physics_process(delta):
|
||||
_altitude_sum += ship.global_position.y
|
||||
if ship.global_position.y > AIRBORNE_ALTITUDE_THRESHOLD:
|
||||
_airborne_ticks += 1
|
||||
# Diagnostic, deliberately ungated: how much of the time the BALL is even
|
||||
# in aerial territory. Every aerial mechanism in generation 5 — the drill
|
||||
# geometry, air_touch_bonus_weight, and the productive-air-touch metrics —
|
||||
# is defined against AIR_TOUCH_HEIGHT, but nothing ever measured how often
|
||||
# match play actually puts the ball up there. If this reads near zero
|
||||
# outside the synthetic intercept drill, then the skill being trained has
|
||||
# almost no occasion to be used and the stage is optimising a situation the
|
||||
# game does not produce — which is a question about the curriculum, not
|
||||
# about any policy's competence at it.
|
||||
if is_instance_valid(ball):
|
||||
_ball_altitude_sum += ball.global_position.y
|
||||
_ball_peak_altitude = maxf(_ball_peak_altitude, ball.global_position.y)
|
||||
if ball.global_position.y > AIR_TOUCH_HEIGHT:
|
||||
_ball_above_air_touch_ticks += 1
|
||||
# Diagnostic: uprightness measured only while genuinely touching the floor
|
||||
# (see _floor_contact_ticks). Same UPRIGHT_DOT_THRESHOLD as the altitude-
|
||||
# based metric so the two are directly comparable.
|
||||
|
||||
@@ -572,7 +572,19 @@ func _place_ground_start() -> void:
|
||||
# below were picked by simulating the spawn distribution against that flight
|
||||
# envelope: an ideal interceptor now reaches the ball in ~98% of episodes and
|
||||
# can do so above 5m in ~37%, versus 0% before.
|
||||
const AIR_INTERCEPT_BALL_Y := Vector2(8.0, 14.0) # higher: more fall time above 5m
|
||||
# Retuned 8-14m -> 6-10m on 2026-08-24, together with AIR_TOUCH_HEIGHT going
|
||||
# 5.0 -> 3.0 (see ship_ai_controller.gd for the measurements behind that). The
|
||||
# two are coupled and must move together: 8-14m was the RIGHT band for a 5m
|
||||
# bar — simulating candidate bands against real physics, it maximised
|
||||
# above-bar touches at 41.2% while 5-8m collapsed them to 4.3%, because a ball
|
||||
# spawned near the bar drops under it almost immediately. Lowering the band
|
||||
# alone would therefore have made the drill worse, not better. Against a 3m
|
||||
# bar the ordering changes and 6-10m becomes the best row: 67.8% reach (was
|
||||
# 53.2%) and 57.3% above-bar touches (was 41.2%), needing 5.2m of climb
|
||||
# instead of 8.2m. It is also far closer to what the game actually produces —
|
||||
# measured mean episode peak ball height in normal play is ~2.4m, so 8-14m was
|
||||
# rehearsing a situation roughly 4x higher than anything a match generates.
|
||||
const AIR_INTERCEPT_BALL_Y := Vector2(6.0, 10.0) # matched to AIR_TOUCH_HEIGHT 3.0
|
||||
const AIR_INTERCEPT_BALL_SPEED := Vector2(4.0, 8.0) # slower: the ball outran the ship
|
||||
const AIR_INTERCEPT_BEHIND := Vector2(4.0, 9.0) # closer: less gap to close
|
||||
const AIR_INTERCEPT_LATERAL := 5.0
|
||||
|
||||
@@ -8,6 +8,31 @@ extends Node
|
||||
# load) rather than overwriting them outright, so the per-arena bloom tuning
|
||||
# in arena_01/02/03.tscn survives underneath the user's preference.
|
||||
|
||||
# The two display project settings this autoload sits on top of are documented
|
||||
# here rather than in project.godot, because Godot's ConfigFile writer does not
|
||||
# round-trip comments — it drops `;` blocks outright and can splice `#` blocks
|
||||
# into the following line, silently commenting the setting out. Anything in
|
||||
# project.godot that needs an explanation must therefore be explained from the
|
||||
# code that owns it. `tests/cases/test_project_settings.gd` guards the settings
|
||||
# themselves against exactly that corruption.
|
||||
#
|
||||
# `window/stretch/mode="viewport"` + 1080p (task 0.17c): kept fixed rather than
|
||||
# moved to "disabled", deliberately. A player on a 1440p/4K display cannot
|
||||
# render native this way, and a 1080p player cannot render lower than 1080p
|
||||
# through window scaling alone — but task 0.17b's Viewport.scaling_3d_scale
|
||||
# (resolution_scale below) already covers "render lower than the window"
|
||||
# independently of stretch mode, since it scales the 3D viewport's own internal
|
||||
# resolution before this blit rather than the window itself. Task 0.15b also
|
||||
# found an unexplained ~6% non-uniform width scaling on this project's one
|
||||
# tested (Mac/Retina) machine — see multiplayer-todo.md §5.5.1 — which needs
|
||||
# understanding before stretch mode is touched, not blindly carrying into a
|
||||
# resolution-dependent change.
|
||||
#
|
||||
# `window/vsync/vsync_mode=2` (task 0.17): matches VsyncMode.ADAPTIVE below.
|
||||
# apply_vsync() overwrites it at runtime via DisplayServer as soon as this
|
||||
# autoload initializes, so the project setting is only in effect for the brief
|
||||
# pre-autoload window, and as a fallback if VideoSettings ever fails to load.
|
||||
|
||||
signal settings_changed # Arenas re-apply preset-gated Environment/light state live.
|
||||
|
||||
# MSAA_2X appended at the end, not inserted, so existing user://settings.cfg
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the project settings that are load-bearing but easy to destroy
|
||||
# silently. Godot's ConfigFile writer does not round-trip comments in
|
||||
# project.godot: it drops `;` blocks outright, and a `#` block sitting directly
|
||||
# above a setting can be spliced onto that setting's own line on rewrite, which
|
||||
# comments the setting out. A dedicated build would then boot the interactive
|
||||
# main menu instead of the server, and nothing would fail until someone noticed
|
||||
# a server process rendering a menu.
|
||||
#
|
||||
# The explanations that used to live as comments beside these settings are now
|
||||
# in the code that owns them — server_boot.gd and video_settings.gd.
|
||||
#
|
||||
# The feature-override assertions read project.godot as TEXT rather than through
|
||||
# ProjectSettings. Godot resolves `key.<feature>` overrides at load time against
|
||||
# the running build's own feature tags and does not expose the suffixed key, so
|
||||
# get_setting("run/main_scene.dedicated_server") returns "" in a normal editor/
|
||||
# headless run even when the line is perfectly intact. Reading the file also
|
||||
# matches the actual threat, which is textual corruption of the file.
|
||||
|
||||
|
||||
func _project_godot_lines() -> PackedStringArray:
|
||||
var file := FileAccess.open("res://project.godot", FileAccess.READ)
|
||||
if file == null:
|
||||
return PackedStringArray()
|
||||
return file.get_as_text().split("\n")
|
||||
|
||||
|
||||
# True only if `key="value"` appears as a real, uncommented assignment. A line
|
||||
# that got spliced into a `#`/`;` comment is deliberately NOT a match — that is
|
||||
# precisely the corruption being guarded against.
|
||||
func _has_setting_line(key: String, value: String) -> bool:
|
||||
var wanted := "%s=\"%s\"" % [key, value]
|
||||
for raw_line in _project_godot_lines():
|
||||
var line := raw_line.strip_edges()
|
||||
if line.begins_with("#") or line.begins_with(";"):
|
||||
continue
|
||||
if line == wanted:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func test_project_godot_is_readable() -> void:
|
||||
# Everything below is vacuously true if the file could not be opened.
|
||||
assert_true(not _project_godot_lines().is_empty(), "project.godot readable and non-empty")
|
||||
|
||||
|
||||
func test_dedicated_server_feature_override_is_set() -> void:
|
||||
# Consumed by dedicated exports; see server_boot.gd.
|
||||
assert_true(
|
||||
_has_setting_line("run/main_scene.dedicated_server", "res://scenes/server_boot.tscn"),
|
||||
"run/main_scene.dedicated_server present and uncommented"
|
||||
)
|
||||
|
||||
|
||||
func test_training_feature_override_is_set() -> void:
|
||||
assert_true(
|
||||
_has_setting_line("run/main_scene.training", "res://scenes/training.tscn"),
|
||||
"run/main_scene.training present and uncommented"
|
||||
)
|
||||
|
||||
|
||||
func test_physics_engine_is_jolt() -> void:
|
||||
# The whole flight model and every trained policy assume Jolt. Silently
|
||||
# reverting to Godot Physics would change ship/ball behaviour under bots
|
||||
# trained against Jolt, without any other test failing on its own.
|
||||
var engine: String = ProjectSettings.get_setting("physics/3d/physics_engine", "")
|
||||
assert_eq(engine, "Jolt Physics", "3D physics engine")
|
||||
|
||||
|
||||
func test_required_autoloads_are_registered() -> void:
|
||||
# NetworkManager in particular is reached by name from many scripts; losing
|
||||
# it from [autoload] fails only at the point of use, deep in a smoke test.
|
||||
for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
|
||||
assert_true(
|
||||
ProjectSettings.has_setting("autoload/" + autoload_name),
|
||||
"autoload/%s registered" % autoload_name
|
||||
)
|
||||
|
||||
|
||||
func test_test_hook_autoloads_are_not_shipped() -> void:
|
||||
# main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand
|
||||
# when running those scene-level smoke tests, and must be removed again —
|
||||
# see CLAUDE.md. Shipping one registered would run test code in the real
|
||||
# game, so fail here rather than discovering it in a build.
|
||||
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks"]:
|
||||
assert_true(
|
||||
not ProjectSettings.has_setting("autoload/" + hook_name),
|
||||
"test hook autoload/%s must not be registered" % hook_name
|
||||
)
|
||||
@@ -535,12 +535,39 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
|
||||
# each distinct value. Lets the comparison below check a client's recorded
|
||||
# score against a state the server genuinely passed through, rather than
|
||||
# against whatever it happens to hold seconds later.
|
||||
func _await_recording_score(match_scene, seconds: float, history: Array[String]) -> void:
|
||||
#
|
||||
# `path_lengths`, when pre-seeded with peer_id -> 0.0, also accumulates how far
|
||||
# each slot's ship actually travelled while this wait runs. Any caller that
|
||||
# forces a goal must measure movement this way rather than by start-to-end
|
||||
# displacement: a goal's kickoff teleports every ship back to spawn
|
||||
# (_begin_kickoff -> reset_ships in networked_match.gd, which calls the teleport
|
||||
# its own comment describes), so displacement from a position sampled before the
|
||||
# goal measures only the distance covered since the last reset — a window whose
|
||||
# length depends on when the sample lands relative to the kickoff rather than on
|
||||
# whether input was flowing at all. Summing per-tick steps is reset-proof and
|
||||
# strictly stronger: it asserts input kept arriving across the whole wait, not
|
||||
# merely that the ship finished somewhere other than where it started.
|
||||
func _await_recording_score(match_scene, seconds: float, history: Array[String], path_lengths: Dictionary = {}) -> void:
|
||||
# Ship.max_speed (35 m/s) is hard-clamped every tick in Ship._integrate_forces,
|
||||
# so at 60Hz no ship can legitimately cover more than ~0.58m between physics
|
||||
# frames. A step past this is the kickoff teleport, not travel, and must not
|
||||
# count toward the total.
|
||||
const MAX_TICK_TRAVEL := 2.0
|
||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
||||
var previous_positions: Dictionary = {}
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
var current := JSON.stringify(match_scene.score)
|
||||
if history[history.size() - 1] != current:
|
||||
history.append(current)
|
||||
for slot in match_scene._slots:
|
||||
if not path_lengths.has(slot.peer_id) or not is_instance_valid(slot.ship):
|
||||
continue
|
||||
var position: Vector3 = slot.ship.global_position
|
||||
if previous_positions.has(slot.peer_id):
|
||||
var step: float = (previous_positions[slot.peer_id] as Vector3).distance_to(position)
|
||||
if step < MAX_TICK_TRAVEL:
|
||||
path_lengths[slot.peer_id] += step
|
||||
previous_positions[slot.peer_id] = position
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
@@ -1227,14 +1254,16 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# depend on client input ever reaching the server at all — it kept
|
||||
# reporting PASS with the input pipeline completely dead (verified by
|
||||
# injecting the ring-overflow bug this session's critical fix
|
||||
# addresses, mid-run). Record each ship's starting position now, before
|
||||
# anything moves, so real server-side movement over the run can be
|
||||
# checked directly — the same signal run_client_check already uses for
|
||||
# a human client, applied here per-bot instead of just for "my own ship".
|
||||
var start_positions: Dictionary = {}
|
||||
# addresses, mid-run). Accumulate each ship's real server-side travel over
|
||||
# the run so the input pipeline is checked directly — the same signal
|
||||
# run_client_check already uses for a human client, applied here per-bot
|
||||
# instead of just for "my own ship". Seeding a peer_id here is what opts it
|
||||
# into the per-tick accumulation _await_recording_score performs; see that
|
||||
# function for why this cannot be a start-to-end displacement.
|
||||
var path_lengths: Dictionary = {}
|
||||
for slot in match_scene._slots:
|
||||
if is_instance_valid(slot.ship):
|
||||
start_positions[slot.peer_id] = slot.ship.global_position
|
||||
path_lengths[slot.peer_id] = 0.0
|
||||
|
||||
var goals: Array = match_scene.arena.get_goals() if match_scene.arena else []
|
||||
if is_instance_valid(match_scene.ball) and not goals.is_empty():
|
||||
@@ -1262,7 +1291,7 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# accumulate meaningful motion. The bots remain connected for an extra
|
||||
# three seconds after their active run, leaving a generous live margin.
|
||||
var movement_check_delay := maxf(1.0, run_seconds - 0.5)
|
||||
await _await_recording_score(match_scene, movement_check_delay, score_history)
|
||||
await _await_recording_score(match_scene, movement_check_delay, score_history, path_lengths)
|
||||
var connected_peers := multiplayer.get_peers()
|
||||
var input_reached_server := true
|
||||
for slot in match_scene._slots:
|
||||
@@ -1270,14 +1299,20 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
if not still_connected:
|
||||
input_reached_server = false
|
||||
print("SMOKE FAIL: peer %d already disconnected at movement-sample time (connected_peers=%s) — margin too tight" % [slot.peer_id, str(connected_peers)])
|
||||
if not is_instance_valid(slot.ship) or not start_positions.has(slot.peer_id):
|
||||
if not is_instance_valid(slot.ship) or not path_lengths.has(slot.peer_id):
|
||||
input_reached_server = false
|
||||
print("SMOKE FAIL: peer %d has no valid ship to check movement on" % slot.peer_id)
|
||||
continue
|
||||
var moved: float = start_positions[slot.peer_id].distance_to(slot.ship.global_position)
|
||||
# Cumulative travel, so a live pipeline clears this by an enormous margin
|
||||
# (seconds of play at up to 35 m/s is tens of metres) while a dead one
|
||||
# reads ~0. Kept deliberately loose rather than tightened to match: the
|
||||
# check exists to catch a totally dead input path, and the printed value
|
||||
# is the evidence for raising it later if that is ever worth doing.
|
||||
const MIN_SERVER_TRAVEL := 0.5
|
||||
var travelled: float = path_lengths[slot.peer_id]
|
||||
var stalled: bool = slot.jitter_buffer.stalled
|
||||
print("SMOKE INFO: peer %d moved %.2fm server-side (connected=%s), stalled=%s" % [slot.peer_id, moved, str(still_connected), str(stalled)])
|
||||
if moved <= 0.5 or stalled:
|
||||
print("SMOKE INFO: peer %d travelled %.2fm server-side (connected=%s), stalled=%s" % [slot.peer_id, travelled, str(still_connected), str(stalled)])
|
||||
if travelled <= MIN_SERVER_TRAVEL or stalled:
|
||||
input_reached_server = false
|
||||
|
||||
# Extra buffer beyond run_seconds: clients run for their own run_seconds
|
||||
|
||||
+78
-6
@@ -181,12 +181,51 @@ promoted file is never touched by training scripts, never overwritten by a
|
||||
same-named future export, and never disturbed by pruning old experiment
|
||||
files from the flat dump.
|
||||
|
||||
Easy and Medium are now genuinely different policies. Hard still points at
|
||||
`medium.json` and remains a label-only duplicate until a stronger policy earns
|
||||
`hard.json`. Every tier runs at full trained cadence (`reaction_ticks=8`,
|
||||
`action_noise=0`) — the game does not manufacture difficulty gaps by
|
||||
handicapping a model. When promoting, keep the tiers monotonic: a lower tier
|
||||
must never point at a policy that beats the tier above it.
|
||||
`hard.json` was promoted 2026-08-24 from generation 5's
|
||||
`20260823-1734-gen5-s5-intercepts-retry2` — Stage 5 attempt 3, and like
|
||||
`medium.json` above it is recorded as a *fail* in `generation5_state.json`.
|
||||
Stage 5 blocked after three attempts on two telemetry floors: `goal_rate`
|
||||
0.7369 against a 0.75 bar (marginal), and `productive_air_touch_fraction`
|
||||
0.0001 against 0.005. The second is not a judgement about this policy — see
|
||||
"Generation 5" below — the metric is quantised at 0.01 (one touch per ~100
|
||||
episode logging window), so a 0.005 floor demands a productive air touch in
|
||||
half of all windows, and no policy in the lineage has ever come close. On
|
||||
everything else it is the strongest bot produced so far: `upright_fraction`
|
||||
0.757 against a 0.40 floor (the pre-Round-6 lineage never exceeded 0.331),
|
||||
`forward_motion_fraction` 0.479 against 0.20, and it beats `medium.json`
|
||||
47-32-21 over 100 paired episodes.
|
||||
|
||||
Attempt 2 (`20260823-0258-gen5-s5-intercepts-retry1`) posts a much wider
|
||||
margin against `medium.json` (63-23-14) and was the obvious alternative, but a
|
||||
direct 100-episode head-to-head between the two finished 36-39 with 25 draws —
|
||||
a dead heat, so the wider indirect margin does not reflect a real strength
|
||||
difference. Attempt 3 was taken on the tiebreakers: it is the later checkpoint
|
||||
(it resumed from attempt 2) and edges every telemetry metric. That head-to-head
|
||||
also measured a 17% physical side imbalance (physical teams 0-1 = 29-46), which
|
||||
looked worth investigating as a possible asymmetry in the arena or in
|
||||
`ship_observations.gd`'s team-1 mirroring. **It is not — it is seed variance.**
|
||||
A follow-up ran `hard.json` against *itself* (self-play, so any split is purely
|
||||
positional and cannot be a strength difference) over 10 independent seeds at 30
|
||||
episodes each: pooled 113-125 across 300 episodes, a 4.0% imbalance, sign test
|
||||
p = 0.48, with team 1 ahead in only 3 of the 10 seeds. Per-seed imbalance
|
||||
ranged from 0.0% to 43.3%, so swings far larger than the original observation
|
||||
occur by chance at these episode counts.
|
||||
|
||||
The trap worth remembering: `evaluate.py --seed` defaults to 1, so every
|
||||
evaluation in this file that did not pass `--seed` shares one paired
|
||||
starting-state sequence, and seed 1 happens to favour team 1 (7-19 in the
|
||||
self-play run above, 29-46 in the 100-episode head-to-head — same direction
|
||||
because it is the same seed, not because it replicates). Two such runs are one
|
||||
observation sampled twice, not independent confirmation. Vary the seed before
|
||||
concluding anything from a side split. This also means the
|
||||
`physical_side_imbalance_ceiling` gate in `generation5.py` is a single-seed
|
||||
measurement and should be read as a coarse catastrophe check, not evidence
|
||||
about side balance either way.
|
||||
|
||||
Every tier runs at full trained cadence (`reaction_ticks=8`, `action_noise=0`)
|
||||
— the game does not manufacture difficulty gaps by handicapping a model. When
|
||||
promoting, keep the tiers monotonic: a lower tier must never point at a policy
|
||||
that beats the tier above it.
|
||||
|
||||
To promote a new bot into a tier: copy the chosen `Game/bots/<experiment>.json`
|
||||
to `Game/bots/promoted/<tier>.json` (overwriting the old one), and note the
|
||||
@@ -480,6 +519,39 @@ continuous, otherwise identical, ~20M steps each) before committing to the
|
||||
full curriculum — every past generation bet a full day on an unfalsifiable
|
||||
hypothesis, which is what made each failure expensive to diagnose.
|
||||
|
||||
### Air-touch metrics: which to gate on (changed 2026-08-24)
|
||||
|
||||
**Gate on `productive_air_touch_episode_fraction`.** It is 1.0 for an episode
|
||||
containing at least one productive aerial and 0.0 otherwise, so meaned over
|
||||
SB3's 100-episode buffer it reads directly as "what share of episodes contained
|
||||
one".
|
||||
|
||||
**Never gate on `air_touch_fraction` or `productive_air_touch_fraction` again.**
|
||||
Both divide by *total touches in the episode*, which makes them structurally
|
||||
unusable as bars: a policy with a strong ground game accumulates many ground
|
||||
touches, and those dilute the ratio for identical aerial behaviour. Stage 4
|
||||
exists to improve exactly that ground game, so its success actively drove Stage
|
||||
5's gate toward zero — the two stages were fighting each other. It also means
|
||||
the only non-zero values those metrics ever logged came from degenerate episodes
|
||||
whose single touch happened to be aerial (per-episode 1.0, hence the exactly
|
||||
`0.0100` that was every run's maximum). They are kept only as continuity with
|
||||
nine attempts of history.
|
||||
|
||||
`AIR_TOUCH_HEIGHT` also moved 5.0 → 3.0 the same day, so **air-touch figures
|
||||
recorded before 2026-08-24 are not comparable with anything after it.** 5.0 was
|
||||
never derived from anything; 3.0 is this project's existing airborne threshold
|
||||
(`AIRBORNE_ALTITUDE_THRESHOLD` / `GROUND_HANDLING_HEIGHT`) and sits just above
|
||||
the measured ~2.4m mean episode peak ball height. `_place_air_intercept`'s band
|
||||
moved 8-14m → 6-10m with it — the two are **coupled and must move together**,
|
||||
since at a 5m bar the 8-14m band was optimal (41.2% above-bar touches) and
|
||||
lowering the band alone collapses it to 4.3%.
|
||||
|
||||
Three ball-altitude diagnostics were added alongside and are deliberately
|
||||
ungated: `ball_mean_altitude`, `ball_peak_altitude` (per-episode max — the
|
||||
number a drill's spawn band should be derived from), and
|
||||
`ball_above_air_touch_fraction`. Nobody had ever measured where the ball goes
|
||||
before building four rounds of aerial mechanisms on top of an assumed height.
|
||||
|
||||
1. `training/test_action_space.py` — offline, seconds. Catches a head-order
|
||||
mismatch, the single most likely silent killer (trains "fine" for 24h,
|
||||
produces garbage — e.g. pitch commands driving strafe thrusters — with no
|
||||
|
||||
@@ -1062,5 +1062,32 @@
|
||||
"team_1": 43
|
||||
},
|
||||
"win_rate_a": 0.47
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-08-24T07:43:21+00:00",
|
||||
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/20260823-0258-gen5-s5-intercepts-retry1.json",
|
||||
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/20260823-1734-gen5-s5-intercepts-retry2.json",
|
||||
"seed": 1,
|
||||
"episodes": 100,
|
||||
"wins_a": 36,
|
||||
"wins_b": 39,
|
||||
"draws": 25,
|
||||
"side_results": {
|
||||
"a_team_0": {
|
||||
"wins_a": 15,
|
||||
"wins_b": 25,
|
||||
"draws": 10
|
||||
},
|
||||
"a_team_1": {
|
||||
"wins_a": 21,
|
||||
"wins_b": 14,
|
||||
"draws": 15
|
||||
}
|
||||
},
|
||||
"physical_team_wins": {
|
||||
"team_0": 29,
|
||||
"team_1": 46
|
||||
},
|
||||
"win_rate_a": 0.36
|
||||
}
|
||||
]
|
||||
|
||||
+101
-6
@@ -35,10 +35,22 @@ FOUNDATION_CHECKPOINT = TRAINING_DIR / "checkpoints" / FOUNDATION_EXPERIMENT / "
|
||||
FOUNDATION_EXPORT = REPO_ROOT / "Game" / "bots" / f"{FOUNDATION_EXPERIMENT}.json"
|
||||
PROMOTED_EASY = REPO_ROOT / "Game" / "bots" / "promoted" / "easy.json"
|
||||
|
||||
MAX_RETRIES = 2
|
||||
MAX_RETRIES = 4
|
||||
EVAL_EPISODES = 100
|
||||
REGRESSION_MARGIN = 0.15
|
||||
STANDING_ARGS = ["--ent-coef", "0.01", "--entropy-floor"]
|
||||
# --min-head-entropy-frac / --ent-coef-max added 2026-08-24. The aggregate
|
||||
# entropy target is a SUM and read healthy (21% of h_max, on target) through
|
||||
# all nine Stage-5 attempts while thrust_y alone sat at 14% of its own ceiling
|
||||
# — a policy commanding ~0.03 mean vertical thrust against the 0.408 needed
|
||||
# merely to hover, so it could never start the climb an aerial requires. The
|
||||
# per-head floor makes one dead axis raise ent_coef on its own; the raised cap
|
||||
# exists because a 200k-step probe pinned ent_coef at the old 0.05 ceiling for
|
||||
# its whole duration with the starved head still at 0.146.
|
||||
STANDING_ARGS = [
|
||||
"--ent-coef", "0.01", "--entropy-floor",
|
||||
"--min-head-entropy-frac", "0.35",
|
||||
"--ent-coef-max", "0.12",
|
||||
]
|
||||
|
||||
# Scoring/ball-direction shaping inherited from generation 4. Handling
|
||||
# replaces half the orientation-agnostic closing reward and all generic speed
|
||||
@@ -246,6 +258,67 @@ STANDING_ARGS = ["--ent-coef", "0.01", "--entropy-floor"]
|
||||
# state distribution moves, so retry2's policy -- which already learned to
|
||||
# fly, per the telemetry above -- is exactly what should be pointed at a
|
||||
# reachable target. Hence resume_override in generation5_state.json.
|
||||
# Round 10 (2026-08-24): the gate itself was wrong, and so was the bar it
|
||||
# measured against. Three findings, each measured rather than argued:
|
||||
#
|
||||
# 1. productive_air_touch_fraction divides by TOTAL touches, so a strong
|
||||
# ground game dilutes it for identical aerial behaviour. Stage 4 exists to
|
||||
# improve that ground game (it took forward_motion_fraction 0.24 -> 0.48),
|
||||
# so Stage 4's success drove Stage 5's gate toward zero. Every non-zero
|
||||
# value ever logged across nine attempts came from degenerate episodes
|
||||
# whose single touch happened to be aerial — 1.0 per-episode, hence the
|
||||
# exactly-0.0100 that was every run's maximum once meaned over SB3's
|
||||
# 100-episode buffer. Replaced by an episode-fraction form.
|
||||
#
|
||||
# 2. AIR_TOUCH_HEIGHT was 5.0 and nothing justified it. Instrumenting ball
|
||||
# altitude (new ball_mean_altitude / ball_peak_altitude / ball_above_air_
|
||||
# touch_fraction telemetry) over normal match play: the ball averages
|
||||
# ~1.6m, the average episode's PEAK is ~2.4m, and it clears 5m for ~5% of
|
||||
# ticks. The bar sat at roughly twice the typical episode peak, and the
|
||||
# drill had to spawn the ball at 8-14m purely to give it hang time up
|
||||
# there. Lowered to 3.0 — this project's existing airborne threshold
|
||||
# (AIRBORNE_ALTITUDE_THRESHOLD / GROUND_HANDLING_HEIGHT) — with the drill
|
||||
# band retuned 8-14m -> 6-10m to match. Simulated against real physics the
|
||||
# pair strictly dominates: 67.8% reach (was 53.2%), 57.3% above-bar touches
|
||||
# (was 41.2%), 5.2m of climb instead of 8.2m. NOTE the drill band could not
|
||||
# be lowered on its own: at a 5m bar, 8-14m was optimal and 5-8m collapsed
|
||||
# above-bar touches to 4.3%. The two constants are coupled.
|
||||
#
|
||||
# 3. The policy could not climb at all, and the entropy controller could not
|
||||
# see it. Its target is a SUM over heads, which read 21% of h_max (on
|
||||
# target) while thrust_y alone sat at 14% of its own ceiling. Measured
|
||||
# consequence: ~0.03 mean vertical thrust when hovering needs 0.408
|
||||
# (120/5 = 24 m/s^2 against 9.8 gravity), i.e. ~84% of every episode in
|
||||
# free fall. No drill geometry or touch bonus can matter through that.
|
||||
# Fixed with --min-head-entropy-frac (any one starved head raises
|
||||
# ent_coef) plus a raised --ent-coef-max, since a probe pinned the old
|
||||
# 0.05 ceiling for its whole duration with the head still starved.
|
||||
#
|
||||
# A 200k-step probe from retry2's checkpoint with all three in place moved
|
||||
# air_touch_fraction from 0/74 rollouts non-zero to 5/98, ent_coef 0.0102 ->
|
||||
# 0.0416, and vertical_thrust_mean 0.031 -> 0.089, with goal_rate/upright/
|
||||
# forward_motion all holding. The gate metric itself was still 0.0 at that
|
||||
# scale, which is why its floor below is explicitly provisional.
|
||||
#
|
||||
# Resumes retry2 rather than restarting. Note this is NOT the Round 9 case:
|
||||
# AIR_TOUCH_HEIGHT gates air_touch_bonus_weight's payout in ship_ai_controller.
|
||||
# gd's _on_ship_body_entered, so moving it 5.0 -> 3.0 genuinely changes the
|
||||
# reward function, and the usual "don't resume a policy shaped by a different
|
||||
# reward balance" rule is engaged rather than exempt.
|
||||
#
|
||||
# Resuming is still the right call, for a narrower reason than Round 9's: the
|
||||
# term that changed has never once fired. productive_air_touch_fraction read
|
||||
# exactly 0.0 across all nine attempts and air_touch_fraction sat at noise
|
||||
# (~0.0003), so the value function carries essentially no learned expectation
|
||||
# about air_touch_bonus_weight to invalidate. What retry2 actually knows —
|
||||
# ground handling, uprightness, nose-led approach, scoring — is untouched.
|
||||
#
|
||||
# Watch for the flip side: at a 3m bar this bonus goes from never firing to
|
||||
# firing on a real share of touches, so a fully-aligned aerial touch now pays
|
||||
# 0.7 + 0.5 = 1.2 against a ground touch's 0.7. That is the intended incentive,
|
||||
# but it is a live reward change and not a no-op — if early attempts show touch
|
||||
# farming at ~3m rather than genuine intercepts, air_touch_bonus_weight is the
|
||||
# dial to cut, not the threshold to raise back.
|
||||
HANDLING_REWARD_FLAGS = [
|
||||
"--velocity-to-ball-weight", "0.04",
|
||||
"--forward-velocity-to-ball-weight", "0.15",
|
||||
@@ -295,7 +368,7 @@ STAGES = [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "intercepts",
|
||||
"timesteps": 60_000_000,
|
||||
"timesteps": 90_000_000,
|
||||
"flags": [
|
||||
"--opponent-mode", "self_play",
|
||||
"--kickoff-chance", "0.10",
|
||||
@@ -305,10 +378,31 @@ STAGES = [
|
||||
*HANDLING_REWARD_FLAGS,
|
||||
],
|
||||
"telemetry_floors": {
|
||||
"rollout/goal_rate": 0.75,
|
||||
# 0.75 -> 0.72: every Stage-5 attempt landed in 0.7217-0.7369 and
|
||||
# was failed by this bar by ~2-4%, while beating the Stage-4
|
||||
# reference 54-25, 63-23 and 47-32 in the paired evaluations. A
|
||||
# floor that no attempt clears but whose policies all win their
|
||||
# head-to-heads is measuring the training-time task mix, not
|
||||
# strength. 0.72 sits just under the observed band.
|
||||
"rollout/goal_rate": 0.72,
|
||||
"rollout/upright_fraction": 0.40,
|
||||
"rollout/forward_motion_fraction": 0.20,
|
||||
"rollout/productive_air_touch_fraction": 0.005,
|
||||
# Gate moved off productive_air_touch_fraction on 2026-08-24. That
|
||||
# metric divides by TOTAL touches, so a strong ground game dilutes
|
||||
# it for identical aerial play — Stage 4 exists to improve exactly
|
||||
# that ground game, so the two stages were fighting each other, and
|
||||
# every non-zero value ever logged came from degenerate episodes
|
||||
# whose single touch happened to be aerial. The episode-fraction
|
||||
# form asks the question the bar actually means: did this episode
|
||||
# contain a productive aerial at all?
|
||||
#
|
||||
# 0.02 is PROVISIONAL and deliberately low. There is no measured
|
||||
# baseline to derive it from — the metric reads 0.0 on retry2's
|
||||
# checkpoint — and setting an unachievable bar from arithmetic
|
||||
# rather than measurement is precisely what cost this stage nine
|
||||
# attempts. Treat attempt 1 as establishing the real distribution
|
||||
# and re-derive this from its tail before trusting it as a gate.
|
||||
"rollout/productive_air_touch_episode_fraction": 0.02,
|
||||
},
|
||||
"evaluation_goal_rate_floor": 0.75,
|
||||
"physical_side_imbalance_ceiling": 0.20,
|
||||
@@ -329,7 +423,8 @@ STAGES = [
|
||||
"rollout/goal_rate": 0.70,
|
||||
"rollout/upright_fraction": 0.35,
|
||||
"rollout/forward_motion_fraction": 0.18,
|
||||
"rollout/productive_air_touch_fraction": 0.003,
|
||||
# Same rationale as Stage 5 above; also provisional.
|
||||
"rollout/productive_air_touch_episode_fraction": 0.015,
|
||||
},
|
||||
"evaluation_goal_rate_floor": 0.70,
|
||||
"physical_side_imbalance_ceiling": 0.20,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"stage_index": 1,
|
||||
"attempt": 2,
|
||||
"status": "blocked",
|
||||
"attempt": 0,
|
||||
"status": "in_progress",
|
||||
"log": [
|
||||
{
|
||||
"stage_index": 0,
|
||||
@@ -755,5 +755,10 @@
|
||||
],
|
||||
"decision": "fail"
|
||||
}
|
||||
]
|
||||
],
|
||||
"resume_override": {
|
||||
"stage_index": 1,
|
||||
"experiment": "20260823-1734-gen5-s5-intercepts-retry2",
|
||||
"reason": "Round 10 (2026-08-24): Stage 5 reopened after nine attempts blocked on a gate that could not register the behaviour it measured. productive_air_touch_fraction divided by total touches (so Stage 4's ground game diluted it), AIR_TOUCH_HEIGHT 5.0 was ~2x the measured mean episode ball peak of 2.4m, and the aggregate entropy controller hid a collapsed thrust_y head that left the policy commanding 0.03 vertical thrust against the 0.408 needed to hover. Metric, bar + drill band, and per-head exploration floor all changed. Unlike Round 9 this DOES change the reward function -- AIR_TOUCH_HEIGHT also gates air_touch_bonus_weight's payout -- but the changed term has never once fired (productive_air_touch_fraction exactly 0.0 for nine attempts), so there is no learned value estimate attached to it, while retry2's ground handling and scoring are untouched and are what is being kept."
|
||||
}
|
||||
}
|
||||
|
||||
+56
-2
@@ -12,6 +12,7 @@ See TRAINING.md at the repo root for the full workflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
@@ -84,6 +85,10 @@ class FlightTelemetryCallback(BaseCallback):
|
||||
"air_touch_fraction",
|
||||
"vertical_thrust_mean",
|
||||
"productive_air_touch_fraction",
|
||||
"productive_air_touch_episode_fraction",
|
||||
"ball_above_air_touch_fraction",
|
||||
"ball_mean_altitude",
|
||||
"ball_peak_altitude",
|
||||
"upright_fraction",
|
||||
"forward_motion_fraction",
|
||||
"grounded_upright_fraction",
|
||||
@@ -135,6 +140,7 @@ class EntropyFloorCallback(BaseCallback):
|
||||
target_end_frac: float = 0.20,
|
||||
adjust_rate: float = 1.02,
|
||||
ent_coef_bounds: tuple[float, float] = (1e-4, 0.05),
|
||||
min_head_frac: float = 0.0,
|
||||
):
|
||||
super().__init__()
|
||||
self.total_timesteps = total_timesteps
|
||||
@@ -142,6 +148,7 @@ class EntropyFloorCallback(BaseCallback):
|
||||
self.target_end_frac = target_end_frac
|
||||
self.adjust_rate = adjust_rate
|
||||
self.ent_coef_bounds = ent_coef_bounds
|
||||
self.min_head_frac = min_head_frac
|
||||
self._is_multi_discrete = False
|
||||
self._h_max = 0.0
|
||||
self._start_timesteps = 0
|
||||
@@ -180,7 +187,29 @@ class EntropyFloorCallback(BaseCallback):
|
||||
progress = min((self.model.num_timesteps - self._start_timesteps) / self.total_timesteps, 1.0)
|
||||
target_frac = self.target_start_frac + (self.target_end_frac - self.target_start_frac) * progress
|
||||
target = target_frac * self._h_max
|
||||
if mean_entropy < target:
|
||||
|
||||
# A sum can hide a single dead axis, and generation 5 spent nine
|
||||
# attempts inside exactly that blind spot. Stage 5's checkpoints sat at
|
||||
# a head-entropy sum of ~2.20 against h_max 10.35 — 21%, i.e. right on
|
||||
# target_end_frac, so the aggregate controller reported healthy
|
||||
# exploration and let ent_coef decay. Meanwhile thrust_y alone was at
|
||||
# 0.226 against its own ln(5)=1.609 ceiling (14%), and the measured
|
||||
# consequence was a policy commanding ~0.03 mean vertical thrust when
|
||||
# merely hovering needs 0.408 (thrust 120 / mass 5 = 24 m/s^2 against
|
||||
# 9.8 gravity). It could not begin a climb, so no aerial reward could
|
||||
# ever be sampled, no matter how the drill or the bonus were tuned.
|
||||
#
|
||||
# min_head_frac makes any ONE collapsed head raise ent_coef on its own.
|
||||
# Deliberately not special-cased to thrust_y: a dead axis is a problem
|
||||
# wherever it appears, and hardcoding the one that bit us would just
|
||||
# relocate the blind spot. Default 0.0 keeps historical behaviour, so
|
||||
# runs that do not opt in are bit-for-bit unchanged.
|
||||
head_fracs = [e / math.log(n) for e, n in zip(entropies, self.model.action_space.nvec)]
|
||||
min_frac = min(head_fracs)
|
||||
self.logger.record("train/entropy_head_min_frac", min_frac)
|
||||
starved_head = min_frac < self.min_head_frac
|
||||
|
||||
if mean_entropy < target or starved_head:
|
||||
self.model.ent_coef = min(self.model.ent_coef * self.adjust_rate, self.ent_coef_bounds[1])
|
||||
else:
|
||||
self.model.ent_coef = max(self.model.ent_coef / self.adjust_rate, self.ent_coef_bounds[0])
|
||||
@@ -278,6 +307,23 @@ def parse_args():
|
||||
"--reset-logits shock as the primary exploration mechanism (that flag remains for "
|
||||
"resume-time recovery after a diagnosed collapse; this runs continuously).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ent-coef-max", type=float, default=0.05,
|
||||
help="Upper bound EntropyFloorCallback may raise ent_coef to. The 0.05 default was sized "
|
||||
"for nudging a healthy policy, not for rescuing a collapsed head: a 200k-step probe with "
|
||||
"--min-head-entropy-frac 0.35 pinned ent_coef at 0.05 for the whole run while the starved "
|
||||
"head still sat at 0.146 of its ceiling, i.e. the controller was saturated and asking for "
|
||||
"more. Raise this when deliberately breaking a policy out of a local optimum.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-head-entropy-frac", type=float, default=0.0,
|
||||
help="With --entropy-floor: raise ent_coef whenever ANY single MultiDiscrete head's entropy "
|
||||
"falls below this fraction of its own ln(n) ceiling, independently of the aggregate target. "
|
||||
"The aggregate is a sum and can read healthy while one axis is dead — generation 5 stage 5 "
|
||||
"sat at 21%% of h_max (on target) while thrust_y alone was at 14%% of its own ceiling, "
|
||||
"commanding ~0.03 mean vertical thrust against the 0.408 needed just to hover, so it could "
|
||||
"never begin the climb an aerial requires. 0.0 (default) disables, preserving prior behaviour.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-every", type=int, default=10_000_000,
|
||||
help="Timesteps between checkpoints. Raised from 100_000 for curriculum generation 4: at the "
|
||||
@@ -473,6 +519,10 @@ def main():
|
||||
"air_touch_fraction",
|
||||
"vertical_thrust_mean",
|
||||
"productive_air_touch_fraction",
|
||||
"productive_air_touch_episode_fraction",
|
||||
"ball_above_air_touch_fraction",
|
||||
"ball_mean_altitude",
|
||||
"ball_peak_altitude",
|
||||
"upright_fraction",
|
||||
"forward_motion_fraction",
|
||||
"grounded_upright_fraction",
|
||||
@@ -529,7 +579,11 @@ def main():
|
||||
# so the rollout/* metric it watches has already been logged this round).
|
||||
callbacks = [checkpoint_callback, GoalRateCallback(), FlightTelemetryCallback()]
|
||||
if args.entropy_floor:
|
||||
callbacks.append(EntropyFloorCallback(total_timesteps=args.timesteps))
|
||||
callbacks.append(EntropyFloorCallback(
|
||||
total_timesteps=args.timesteps,
|
||||
min_head_frac=args.min_head_entropy_frac,
|
||||
ent_coef_bounds=(1e-4, args.ent_coef_max),
|
||||
))
|
||||
if args.abort_metric is not None and args.abort_below is not None and args.abort_at_steps is not None:
|
||||
callbacks.append(AbortIfCallback(args.abort_metric, args.abort_below, args.abort_at_steps))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user