feat(training): support N-vs-M matches with persistent per-ship spawn IDs

Extends ShipObservations beyond the old self+1-opponent layout to padded
teammate/opponent arrays (MAX_TEAMMATES=4, MAX_OPPONENTS=5, SIZE=83),
zero-filling slots past the real roster size the same way the old single-
opponent slot was zero-filled when absent.

Slot stability across ticks requires a persistent identity: Ship gains
spawn_index (set once by GameMode.spawn_ship, never reassigned — there's no
despawn path anywhere in this codebase, so a roster is fixed for the whole
episode/match). ai_ship_controller.gd's opponent discovery is rewritten from
"first non-self ship" to classify every other ship by team and sort by
spawn_index; training_mode.gd/ship_ai_controller.gd carry the equivalent
sorted lists through the training path so both agree on slot assignment for
the same roster.

training_mode.gd and match_mode.gd both gain a team_size export (default 1,
so every existing curriculum script and match keeps today's 1v1 behaviour
unchanged). This is plumbing only: no 2v2+ curriculum or reward design, and
no match-mode UI to pick team size, has been done yet. The two checkpoints
in Game/bots/promoted/ are fitted to the old 35-float layout and are not
migrated — expected to go stale until the next training run.
This commit is contained in:
Josh Creek
2026-08-05 09:17:56 +01:00
parent 18fdb0f232
commit 3049c42867
7 changed files with 164 additions and 60 deletions
+29 -9
View File
@@ -35,7 +35,8 @@ var _action := ShipAction.new()
var _ticks_until_decision := 0
var _ship: Ship
var _opponent: Ship
var _teammates: Array[Ship] = []
var _opponents: Array[Ship] = []
var _ball: RigidBody3D
var _attack_goal_position: Vector3
var _scene_refs_ready := false
@@ -60,7 +61,7 @@ func get_action() -> ShipAction:
func _decide() -> void:
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position)
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
var out := _policy.forward(obs)
# See ShipActionCodec for the decode — the single source of truth shared
# with the training side, so this must never reimplement layout/ordering
@@ -76,21 +77,40 @@ func _decide() -> void:
_action = ShipActionCodec.from_logits(out, action_noise)
# Find ship/ball/opponent/goal once everything is spawned. ShipAction axes
# are body-frame so only observations need team context (ShipObservations).
# Find ship/ball/teammates/opponents/goal once everything is spawned.
# ShipAction axes are body-frame so only observations need team context
# (ShipObservations). Rosters never change mid-match (no despawn path exists
# anywhere in this codebase), so this only needs to run once — sorted by
# spawn_index so a given ship keeps the same observation slot for the whole
# match, matching TrainingMode's identically-sorted lists.
func _discover_scene_refs() -> bool:
_ship = get_parent() as Ship
if _ship == null or not is_inside_tree():
return false
_ball = get_tree().get_first_node_in_group("ball")
if _ball == null:
return false
# Cleared, not just appended to: if an earlier call reached this point but
# a later check still failed, a retry must not re-append onto whatever it
# already collected — that would duplicate every ship in the roster.
_teammates.clear()
_opponents.clear()
for node in get_tree().get_nodes_in_group("ship"):
if node != _ship:
_opponent = node
break
var other := node as Ship
if other == _ship:
continue
if other.team == _ship.team:
_teammates.append(other)
else:
_opponents.append(other)
_teammates.sort_custom(_by_spawn_index)
_opponents.sort_custom(_by_spawn_index)
for goal in get_tree().get_nodes_in_group("goal"):
if goal.team == 1 - _ship.team:
_attack_goal_position = goal.global_position
if _ball == null:
return false
_scene_refs_ready = true
return true
static func _by_spawn_index(a: Ship, b: Ship) -> bool:
return a.spawn_index < b.spawn_index