mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 20:42:03 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user