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
+47 -17
View File
@@ -65,6 +65,12 @@ extends GameMode
# _place_air_drill.
@export_range(0.0, 1.0) var air_drill_chance := 0.0
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
# design has been done, so a run above 1 is untested territory.
@export_range(1, 5) var team_size: int = 1
# Placement bounds for randomized episode starts, derived from the standard
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
# box, worst-case half-extent ~2.05) from spawning intersecting the walls,
@@ -153,22 +159,46 @@ func _start() -> void:
spawn_ship(team, 0, bot)
return
var ship_team0 := spawn_ship(0, 0, RLShipController.new())
var ship_team1: Ship
match _opponent_mode:
"inert":
ship_team1 = spawn_ship(1, 0, ShipController.new())
_inert_ships.append(ship_team1)
"frozen":
var bot := AIShipController.new()
bot.model_path = _opponent_model_path
ship_team1 = spawn_ship(1, 0, bot)
_:
ship_team1 = spawn_ship(1, 0, RLShipController.new())
var team0_ships: Array[Ship] = []
for i in team_size:
team0_ships.append(spawn_ship(0, i, RLShipController.new()))
_attach_agent(ship_team0, ship_team1)
# The opponent_mode branch applies uniformly to every ship on team 1: an
# "inert"/"frozen" run means the whole opposing team gets that treatment,
# not just one ship.
var team1_ships: Array[Ship] = []
for i in team_size:
var ship1: Ship
match _opponent_mode:
"inert":
ship1 = spawn_ship(1, i, ShipController.new())
_inert_ships.append(ship1)
"frozen":
var bot := AIShipController.new()
bot.model_path = _opponent_model_path
ship1 = spawn_ship(1, i, bot)
_:
ship1 = spawn_ship(1, i, RLShipController.new())
team1_ships.append(ship1)
# All ships spawn before any agent attaches, so every agent's
# teammates/opponents lists see the other side's full roster.
for ship in team0_ships:
_attach_agent(ship, _other_ships(team0_ships, ship), team1_ships)
if _opponent_mode == "self_play":
_attach_agent(ship_team1, ship_team0)
for ship in team1_ships:
_attach_agent(ship, _other_ships(team1_ships, ship), team0_ships)
# `roster` minus `ship`, preserving order — rosters are built by spawn_index
# already, so this stays spawn_index-sorted (see ShipObservations' slot-
# stability requirement).
func _other_ships(roster: Array[Ship], ship: Ship) -> Array[Ship]:
var others: Array[Ship] = []
for s in roster:
if s != ship:
others.append(s)
return others
# Shared "--key=value" cmdline scan used by both eval and curriculum parsing.
@@ -261,14 +291,14 @@ func _ai_default(name: String) -> Variant:
_: return null
func _attach_agent(ship: Ship, opponent: Ship) -> void:
func _attach_agent(ship: Ship, teammates: Array[Ship], opponents: Array[Ship]) -> void:
var agent := ShipAIController.new()
agent.name = "ShipAIController"
agent.reset_after = int(episode_length_seconds * TICKS_PER_SIM_SECOND)
for key in _ai_overrides:
agent.set(key, _ai_overrides[key])
ship.add_child(agent)
agent.setup(ship, ship.controller as RLShipController, ball, opponent, _attack_goal_position(ship.team))
agent.setup(ship, ship.controller as RLShipController, ball, teammates, opponents, _attack_goal_position(ship.team))
_agents.append(agent)
@@ -316,7 +346,7 @@ func _physics_process(_delta):
# A goal (_on_goal_scored) does NOT do this — a goal is a
# genuine terminal, V(s)=0 is correct there.
agent.truncated_this_episode = true
agent.terminal_obs = ShipObservations.build(agent.ship, agent.opponent, agent.ball, agent.attack_goal_position)
agent.terminal_obs = ShipObservations.build(agent.ship, agent.teammates, agent.opponents, agent.ball, agent.attack_goal_position)
_reset_episode()
return