From 6567d408a590f43bea172605efcf3579b906cdee Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 25 Sep 2026 07:19:02 -0700 Subject: [PATCH 1/7] avoid deep Footman and Building copies in read-only loops Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/sim.nim | 156 ++++++++++++++++++----------- 1 file changed, 97 insertions(+), 59 deletions(-) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 7f4677c9..8aaeab6c 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -695,7 +695,8 @@ proc fillVisionKeys(world: World, dest: var seq[int32]) = dest.add int32(hero.state != Dying and hero.hp > 0) dest.add sightBounds(hero.position) dest.add int32(world.footmen.len) - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] if footman.camp > 0: continue dest.add footman.id @@ -703,7 +704,8 @@ proc fillVisionKeys(world: World, dest: var seq[int32]) = dest.add int32(footman.state != Dying and footman.hp > 0) dest.add sightBounds(footman.position) dest.add int32(world.buildings.len) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] dest.add tower.id dest.add int32(tower.team.ord) dest.add int32(tower.hp > 0) @@ -723,7 +725,8 @@ proc rebuildVision*(world: World) {.measure.} = visionBlockers.setLen(sightTerrain.blockerHeights.len) for i, value in sightTerrain.blockerHeights: visionBlockers[i] = value - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.hp > 0: addVisionBlocker(tower.position, 28) for fort in world.forts: @@ -735,11 +738,13 @@ proc rebuildVision*(world: World) {.measure.} = let hero = world.heroes[i] if hero.team == team and hero.state != Dying and hero.hp > 0: addVisionSource(hero.position, 10, 14) - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] if footman.camp == 0 and footman.team == team and footman.state != Dying and footman.hp > 0: addVisionSource(footman.position, FootmanSightRadius div WorldScale, 12) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.team == team and tower.hp > 0: let range = TowerAttackRanges[tower.tier] for tile in sightTiles(tower.position): @@ -785,7 +790,8 @@ proc footmanIndex(world: World, id: int32): int = ## Returns the footman slot for one id, or -1. if id == 0: return -1 - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] if footman.id == id: return i -1 @@ -803,7 +809,8 @@ proc buildingIndex*(world: World, id: int32): int = ## Returns the tower slot for one id, or -1. if id == 0: return -1 - for i, tower in world.buildings: + for i in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[i] if tower.id == id: return i -1 @@ -825,13 +832,13 @@ when defined(replayEvents): let creep = world.footmanIndex(id) let building = world.buildingIndex(id) if creep >= 0: - let value = world.footmen[creep] + let value {.cursor.} = world.footmen[creep] result.kind = if value.camp > 0: NeutralObjectKind else: FootmanObjectKind result.class = if value.camp > 0: value.campTier.int32 else: value.kind.ord.int32 result.team = value.faction position = value.position elif building >= 0: - let value = world.buildings[building] + let value {.cursor.} = world.buildings[building] result.kind = if value.kind == TowerBuilding: TowerObjectKind else: BarracksObjectKind @@ -956,7 +963,7 @@ proc hitKey(world: World, id: int32): HitKey = else: let creep = world.footmanIndex(id) if creep >= 0: - let actor = world.footmen[creep] + let actor {.cursor.} = world.footmen[creep] result.kind = if actor.camp > 0: NeutralObjectKind else: FootmanObjectKind result.class = if actor.camp > 0: actor.campTier.int32 else: actor.kind.ord.int32 position = actor.position @@ -965,7 +972,7 @@ proc hitKey(world: World, id: int32): HitKey = else: let building = world.buildingIndex(id) if building >= 0: - let actor = world.buildings[building] + let actor {.cursor.} = world.buildings[building] result.kind = TowerObjectKind result.class = actor.kind.ord.int32 * 3 + actor.tier.ord.int32 position = actor.position @@ -1088,7 +1095,8 @@ proc laneCleared(world: World, team: Team): bool = ## Returns whether attackers have destroyed every tower in any one lane. for lane in 0 .. 2: var standing = false - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.kind == TowerBuilding and not tower.guardsGod and tower.team == team and tower.lane == lane and tower.hp > 0: standing = true @@ -1103,7 +1111,8 @@ proc buildingExposed*(world: World, tower: Building): bool = return false if tower.guardsGod: return world.laneCleared(tower.team) - for other in world.buildings: + for otherSlot in 0 ..< world.buildings.len: + let other {.cursor.} = world.buildings[otherSlot] if other.kind == TowerBuilding and not other.guardsGod and other.team == tower.team and other.lane == tower.lane and other.hp > 0 and @@ -1117,14 +1126,16 @@ proc nextEnemyBuilding( ## Finds lane towers, then barracks, then the nearest exposed god guard. let enemy = if team == RedTeam: BlueTeam else: RedTeam for tier in TowerTier: - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.kind == TowerBuilding and not tower.guardsGod and tower.team == enemy and tower.lane == lane and tower.tier == tier and tower.hp > 0: return tower var nearest = int64.high - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] if building.kind == BarracksBuilding and building.team == enemy and building.lane == lane and building.hp > 0: let distance = distanceSquared(position, building.position) @@ -1135,7 +1146,8 @@ proc nextEnemyBuilding( result = building if result.id != 0: return - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] if building.guardsGod and building.team == enemy and world.buildingExposed(building): let distance = distanceSquared(position, building.position) @@ -1147,7 +1159,8 @@ proc nextEnemyBuilding( proc fortExposed*(world: World, team: Team): bool = ## Keeps a god invulnerable until both of its own guard towers are dead. - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.guardsGod and tower.team == team and tower.hp > 0: return false true @@ -1638,7 +1651,8 @@ proc knownWalkable*(world: World, team: Team, layer, x, z: int): bool = ## Reports static terrain and remembered occupancy without fog information leaks. if not isWalkable(layer, x, z): return false - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] if not building.knownAlive[team]: continue for tile in building.footprint: @@ -1671,7 +1685,7 @@ proc damageBuilding( ) = ## Applies building damage and releases occupied tiles on the killing hit. when defined(replayEvents): - let before = world.buildings[index].hp + let before {.cursor.} = world.buildings[index].hp world.buildings[index].hp -= damage when defined(replayEvents): world.damageEvent(source, world.buildings[index].id, damage, @@ -1969,23 +1983,27 @@ proc nearestNavTile( proc spawnWave(world: World) {.measure.} = ## Spawns three melee creeps and one caster per surviving barracks. var count = 0 - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] if building.kind == BarracksBuilding and building.hp > 0: count += CreepsPerBarracks - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.camp == 0: inc count if count > UnitCap: return var occupied: seq[PathTile] - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] if footman.hp <= 0: continue var tile: NavTile if navTileAt(footman.position, tile, footman.team): occupied.add PathTile( layer: tile.layer.int32, x: tile.x.int32, z: tile.z.int32) - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] if building.kind != BarracksBuilding or building.hp <= 0: continue for unit in 0 ..< CreepsPerBarracks: @@ -2103,7 +2121,7 @@ proc rawWorldObjectAt(world: World, index: int, value: var WorldObject): bool = return true let buildingIndex = index - world.forts.len if buildingIndex < world.buildings.len: - let tower = world.buildings[buildingIndex] + let tower {.cursor.} = world.buildings[buildingIndex] value = WorldObject( id: tower.id, kind: (if tower.kind == TowerBuilding: TowerObjectKind @@ -2147,7 +2165,7 @@ proc rawWorldObjectAt(world: World, index: int, value: var WorldObject): bool = return true let footmanIndex = heroIndex - world.heroes.len if footmanIndex < world.footmen.len: - let footman = world.footmen[footmanIndex] + let footman {.cursor.} = world.footmen[footmanIndex] value = WorldObject( id: footman.id, kind: (if footman.camp > 0: NeutralObjectKind else: FootmanObjectKind), @@ -2693,7 +2711,8 @@ proc portalLanding*( let orientation = if team == RedTeam: -1'i64 else: 1'i64 var best = (int64.high, int64.high, int64.high, int64.high, int64.high, int64.high) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.kind != TowerBuilding or tower.team != team or tower.hp <= 0 or (anchorId != 0 and tower.id != anchorId): continue @@ -2838,7 +2857,7 @@ proc isEnemyTarget(world: World, hero: Hero, targetId: int32): bool = return false let footman = footmanIndex(world, targetId) if footman >= 0: - let other = world.footmen[footman] + let other {.cursor.} = world.footmen[footman] return world.hostile(other, hero.team, engageResting = true) let otherHero = heroIndex(world, targetId) if otherHero >= 0: @@ -2846,7 +2865,7 @@ proc isEnemyTarget(world: World, hero: Hero, targetId: int32): bool = return other.team != hero.team and other.hp > 0 and other.state != Dying let tower = buildingIndex(world, targetId) if tower >= 0: - let other = world.buildings[tower] + let other {.cursor.} = world.buildings[tower] return other.team != hero.team and other.hp > 0 for fort in world.forts: if fort.id == targetId: @@ -3062,7 +3081,7 @@ proc updateTower*(world: World, tower: var Building) = targetFootman = footmanIndex(world, tower.targetId) targetHero = heroIndex(world, tower.targetId) if targetFootman >= 0: - let footman = world.footmen[targetFootman] + let footman {.cursor.} = world.footmen[targetFootman] if not world.hostile(footman, tower.team) or footman.state == Dying or footman.hp <= 0 or not within(tower.position, footman.position, attackRange) or @@ -3080,7 +3099,8 @@ proc updateTower*(world: World, tower: var Building) = bestSquared = int64(attackRange) * attackRange bestId = 0'i32 bestPosition: WorldPoint - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] if not world.hostile(footman, tower.team) or footman.state == Dying or footman.hp <= 0 or not visible(world, tower.team, footman.position): @@ -3207,7 +3227,8 @@ proc spawnCamp(world: World, index: int) = var occupied: seq[PathTile] group: seq[Footman] - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.hp > 0: var tile: NavTile if navTileAt(unit.position, tile, unit.team): @@ -3291,7 +3312,8 @@ proc provokeCamp(world: World, index: int) = template consider(candidateId: int32, point: WorldPoint) = ## Finds the closest visible intruder to any living camp member. if within(point, camp.center, NeutralLeash): - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.camp != index + 1 or unit.hp <= 0 or unit.state == Dying: continue let distance = distanceSquared(unit.position, point) @@ -3310,7 +3332,8 @@ proc provokeCamp(world: World, index: int) = for hero in world.heroes: if hero.hp > 0 and hero.state != Dying: consider(hero.id, hero.position) - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.camp == 0 and unit.hp > 0 and unit.state != Dying: consider(unit.id, unit.position) if targetId != 0: @@ -3327,7 +3350,8 @@ proc updateCamps(world: World) = ## Handles whole-group leashes, full resets, and delayed full-camp respawns. var activity = newSeq[tuple[alive: int, away, outside: bool]]( world.camps.len) - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.camp == 0 or unit.hp <= 0: continue let index = unit.camp - 1 @@ -3381,7 +3405,8 @@ proc updateCamps(world: World) = world.returnCamp(index) else: var seen = false - for unit in world.footmen: + for unitSlot in 0 ..< world.footmen.len: + let unit {.cursor.} = world.footmen[unitSlot] if unit.camp == index + 1 and unit.hp > 0 and unit.campCanSee(target.position, 12): seen = true @@ -3427,7 +3452,8 @@ proc updateNeutral(world: World, unit: var Footman) = for hero in world.heroes: if hero.hp > 0 and hero.state != Dying: consider(hero.id, hero.position) - for other in world.footmen: + for otherSlot in 0 ..< world.footmen.len: + let other {.cursor.} = world.footmen[otherSlot] if other.camp == 0 and other.hp > 0 and other.state != Dying: consider(other.id, other.position) if targetId == 0: @@ -3493,7 +3519,7 @@ proc updateFootman(world: World, footman: var Footman) = targetHero = heroIndex(world, footman.targetHeroId) targetBuilding = buildingIndex(world, footman.targetBuildingId) if targetFootman >= 0: - let other = world.footmen[targetFootman] + let other {.cursor.} = world.footmen[targetFootman] if not world.hostile(other, footman.team) or not visible(world, footman.team, other.position) or not within( @@ -3513,7 +3539,7 @@ proc updateFootman(world: World, footman: var Footman) = ): targetHero = -1 if targetBuilding >= 0: - let tower = world.buildings[targetBuilding] + let tower {.cursor.} = world.buildings[targetBuilding] if not buildingExposed(world, tower) or not visible(world, footman.team, tower.position) or not within( @@ -3527,7 +3553,8 @@ proc updateFootman(world: World, footman: var Footman) = bestSquared = int64(FootmanSightRadius) * FootmanSightRadius bestId = 0'i32 bestPosition: WorldPoint - for i, other in world.footmen: + for i in 0 ..< world.footmen.len: + let other {.cursor.} = world.footmen[i] if not world.hostile(other, footman.team): continue if not visible(world, footman.team, other.position): @@ -3953,7 +3980,7 @@ proc hitTarget( let creep = world.footmanIndex(target) if creep >= 0: if world.footmen[creep].hp > 0 and not world.returning(world.footmen[creep]): - let camp = world.footmen[creep].camp + let camp {.cursor.} = world.footmen[creep].camp if camp > 0: if world.camps[camp - 1].state == RestingCamp: when defined(replayEvents): @@ -4110,7 +4137,7 @@ proc canHitTarget( ## Requires a living, visible, exposed enemy within the strike's range. var position: WorldPoint if targetFootman >= 0: - let target = world.footmen[targetFootman] + let target {.cursor.} = world.footmen[targetFootman] if not world.hostile(target, hero.team, engageResting = true): return false position = target.position @@ -4120,7 +4147,7 @@ proc canHitTarget( return false position = target.position elif targetBuilding >= 0: - let target = world.buildings[targetBuilding] + let target {.cursor.} = world.buildings[targetBuilding] if target.team == hero.team or not world.buildingExposed(target): return false position = target.position @@ -4384,7 +4411,7 @@ proc spellTarget*(world: World, id: int32, value: var WorldObject): bool = return true let footman = footmanIndex(world, id) if footman >= 0: - let target = world.footmen[footman] + let target {.cursor.} = world.footmen[footman] value = WorldObject(id: id, kind: (if target.camp > 0: NeutralObjectKind else: FootmanObjectKind), team: target.team, position: target.position, hp: target.hp, @@ -4393,7 +4420,7 @@ proc spellTarget*(world: World, id: int32, value: var WorldObject): bool = return true let tower = buildingIndex(world, id) if tower >= 0: - let target = world.buildings[tower] + let target {.cursor.} = world.buildings[tower] value = WorldObject(id: id, team: target.team, position: target.position, hp: target.hp, alive: world.buildingExposed(target)) return true @@ -4485,9 +4512,11 @@ proc resolveSpell(world: World, spell: var SpellCast) = for hero in world.heroes: affect(hero.id, hero.position) if spec.kind == Strike: - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] affect(footman.id, footman.position) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] affect(tower.id, tower.position) for fort in world.forts: affect(fort.id, fort.center) @@ -4551,9 +4580,11 @@ proc advanceGroundShot(world: World, spell: var SpellCast) = nearestPosition = position for hero in world.heroes: consider(hero.id, hero.position) - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] consider(footman.id, footman.position) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] consider(tower.id, tower.position) for fort in world.forts: consider(fort.id, fort.center) @@ -4820,7 +4851,8 @@ proc nearestEnemy( var bestSquared = int64(radius) * int64(radius) bestPosition: WorldPoint - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] if not world.hostile(footman, hero.team, engageResting = hero.attackMoving) or footman.state == Dying or footman.hp <= 0 or @@ -4846,7 +4878,8 @@ proc nearestEnemy( bestSquared = squared bestPosition = other.position result = other.id - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] if tower.team == hero.team or tower.hp <= 0 or not buildingExposed(world, tower) or @@ -4946,7 +4979,7 @@ proc updateHero(world: World, hero: Hero) = if hero.attackObjectId != 0: targetFootman = footmanIndex(world, hero.attackObjectId) if targetFootman >= 0: - let footman = world.footmen[targetFootman] + let footman {.cursor.} = world.footmen[targetFootman] if not world.hostile(footman, hero.team, engageResting = true) or not visible(world, hero.team, footman.position): targetFootman = -1 @@ -4960,7 +4993,7 @@ proc updateHero(world: World, hero: Hero) = if targetFootman < 0 and targetHero < 0: targetBuilding = buildingIndex(world, hero.attackObjectId) if targetBuilding >= 0: - let tower = world.buildings[targetBuilding] + let tower {.cursor.} = world.buildings[targetBuilding] if tower.team == hero.team or not buildingExposed(world, tower) or not visible(world, hero.team, tower.position): targetBuilding = -1 @@ -4985,7 +5018,7 @@ proc updateHero(world: World, hero: Hero) = if hero.attackObjectId != 0: targetFootman = footmanIndex(world, hero.attackObjectId) if targetFootman >= 0: - let footman = world.footmen[targetFootman] + let footman {.cursor.} = world.footmen[targetFootman] if not world.hostile(footman, hero.team, engageResting = true): targetFootman = -1 if targetFootman < 0: @@ -4998,7 +5031,7 @@ proc updateHero(world: World, hero: Hero) = if targetFootman < 0 and targetHero < 0: targetBuilding = buildingIndex(world, hero.attackObjectId) if targetBuilding >= 0: - let tower = world.buildings[targetBuilding] + let tower {.cursor.} = world.buildings[targetBuilding] if tower.team == hero.team or not buildingExposed(world, tower): targetBuilding = -1 if targetFootman < 0 and targetHero < 0 and targetBuilding < 0: @@ -5114,7 +5147,8 @@ proc separateUnits(game: Game) = let world = game.world var maximumRadius = FixedZero game.collisionUnits.setLen(0) - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] if footman.state != Dying: game.collisionUnits.add CollisionUnit(body: footman.body, index: i, layer: footman.navLayer, team: footman.team, @@ -5237,7 +5271,8 @@ proc stateHash*(game: Game): uint64 = hash.addHashy(fort.hp) hash.addHashy(world.navigationRevision) hash.addHashy(world.buildings.len) - for tower in world.buildings: + for towerSlot in 0 ..< world.buildings.len: + let tower {.cursor.} = world.buildings[towerSlot] hash.addHashy(tower.kind.ord) hash.addHashy(tower.guardsGod) hash.addHashy(tower.knownAlive) @@ -5338,7 +5373,8 @@ proc stateHash*(game: Game): uint64 = hash.addHashy(camp.lastSeenTick) hash.addHashy(camp.started) hash.addHashy(world.footmen.len) - for footman in world.footmen: + for footmanSlot in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[footmanSlot] hash.addHashy(footman.id) hash.addHashy(footman.kind.ord) hash.addHashy(footman.camp) @@ -5506,7 +5542,8 @@ proc tickWorld*(game: Game, onHeroTurn: proc() {.closure.}) {.measure.} = # Plan every unit against the same actor state, then publish together. game.nextFootmen.setLen(world.footmen.len) - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] game.nextFootmen[i] = footman game.nextHeroes.setLen(world.heroes.len) for i, hero in world.heroes: @@ -5821,7 +5858,8 @@ proc newGame*( when defined(replayEvents): for fort in world.forts: world.lifecycleEvent(EntitySpawned, 0, fort.id, Initialization) - for building in world.buildings: + for buildingSlot in 0 ..< world.buildings.len: + let building {.cursor.} = world.buildings[buildingSlot] world.lifecycleEvent(EntitySpawned, 0, building.id, Initialization) for hero in world.heroes: world.lifecycleEvent(EntitySpawned, 0, hero.id, Initialization) From 50c577cde4922ee32384649eb310d43fbf6ff870 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:03:45 -0700 Subject: [PATCH 2/7] share the sorted script object list across a team's decision frame Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/sim.nim | 101 ++++++++++++++++------------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 8aaeab6c..17e4cdfa 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -305,10 +305,10 @@ type teamExplored*: array[2, seq[uint8]] visionCache: array[2, VisionCache] visionSkipKeys: seq[int32] - scriptObjects: seq[WorldObject] - scriptObjectCount: int - scriptObjectsHeroId: int32 - scriptObjectsTick: int32 + scriptObjects: array[Team, seq[WorldObject]] + scriptObjectCount: array[Team, int] + scriptObjectsHeroId: array[Team, int32] + scriptObjectsTick: array[Team, int32] observationsFrozen: bool observedObjects: seq[WorldObject] observedSpells: array[Team, seq[SpellCast]] @@ -2220,7 +2220,7 @@ proc freezeObservations*(world: World): bool = cmp(world.spellObservationKey(first, team), world.spellObservationKey(second, team)) ) - world.scriptObjectsTick = -1 + world.scriptObjectsTick = [-1'i32, -1] world.observationsFrozen = true true @@ -2230,7 +2230,7 @@ proc thawObservations*(world: World) = world.observedObjects.setLen(0) for spells in world.observedSpells.mitems: spells.setLen(0) - world.scriptObjectsTick = -1 + world.scriptObjectsTick = [-1'i32, -1] iterator observedCasts*(world: World, team: Team): SpellCast = ## Reads the common decision frame, or live casts outside that phase. @@ -2259,44 +2259,50 @@ proc scriptObjectKey(value: WorldObject, observer: Team): (group, int(value.faction != observer.ord.int32), direction * value.position.z, direction * value.position.x, value.id) -proc ensureScriptObjects(world: World, heroId: int32) = - ## Rebuilds the visible object list once per hero decision tick. - if world.scriptObjectsHeroId == heroId and - world.scriptObjectsTick == world.tick: - return - world.scriptObjectCount = 0 +proc ensureScriptObjects(world: World, heroId: int32): Team = + ## Rebuilds the visible object list once per team decision frame. + ## Every hero of a team sees the same list while observations are + ## frozen, so one sort serves the whole team. let observer = heroIndex(world, heroId) - if observer >= 0: - let team = world.heroes[observer].team - var value: WorldObject - let count = - if world.observationsFrozen: world.observedObjects.len - else: rawWorldObjectCount(world) - for i in 0 ..< count: - if world.observationsFrozen: - value = world.observedObjects[i] - elif not rawWorldObjectAt(world, i, value): - continue - if not objectVisibleTo(world, team, value) or - (value.kind in [TowerObjectKind, BarracksObjectKind] and value.hp <= 0): - continue - if world.scriptObjectCount == world.scriptObjects.len: - world.scriptObjects.add value - else: - world.scriptObjects[world.scriptObjectCount] = value - inc world.scriptObjectCount - world.scriptObjects.setLen(world.scriptObjectCount) - world.scriptObjects.sort(proc(first, second: WorldObject): int = - ## Orders observed identities in the querying team's coordinate frame. - cmp(first.scriptObjectKey(team), second.scriptObjectKey(team)) - ) - world.scriptObjectsHeroId = heroId - world.scriptObjectsTick = world.tick + if observer < 0: + return + let team = world.heroes[observer].team + result = team + if world.scriptObjectsTick[team] == world.tick and + (world.observationsFrozen or world.scriptObjectsHeroId[team] == heroId): + return + world.scriptObjectCount[team] = 0 + var value: WorldObject + let count = + if world.observationsFrozen: world.observedObjects.len + else: rawWorldObjectCount(world) + for i in 0 ..< count: + if world.observationsFrozen: + value = world.observedObjects[i] + elif not rawWorldObjectAt(world, i, value): + continue + if not objectVisibleTo(world, team, value) or + (value.kind in [TowerObjectKind, BarracksObjectKind] and value.hp <= 0): + continue + if world.scriptObjectCount[team] == world.scriptObjects[team].len: + world.scriptObjects[team].add value + else: + world.scriptObjects[team][world.scriptObjectCount[team]] = value + inc world.scriptObjectCount[team] + world.scriptObjects[team].setLen(world.scriptObjectCount[team]) + world.scriptObjects[team].sort(proc(first, second: WorldObject): int = + ## Orders observed identities in the querying team's coordinate frame. + cmp(first.scriptObjectKey(team), second.scriptObjectKey(team)) + ) + world.scriptObjectsHeroId[team] = heroId + world.scriptObjectsTick[team] = world.tick proc worldObjectCount*(world: World, heroId: int32): int = ## Returns the number of objects visible to one hero script. - world.ensureScriptObjects(heroId) - world.scriptObjectCount + if world.heroIndex(heroId) < 0: + return 0 + let team = world.ensureScriptObjects(heroId) + world.scriptObjectCount[team] proc worldObjectAt*( world: World, @@ -2305,10 +2311,12 @@ proc worldObjectAt*( value: var WorldObject ): bool = ## Reads one object from a hero's stable visibility-filtered enumeration. - world.ensureScriptObjects(heroId) - if index < 0 or index >= world.scriptObjectCount: + if world.heroIndex(heroId) < 0: + return false + let team = world.ensureScriptObjects(heroId) + if index < 0 or index >= world.scriptObjectCount[team]: return false - value = world.scriptObjects[index] + value = world.scriptObjects[team][index] true proc worldObjectById*( @@ -3851,7 +3859,7 @@ proc applyDraft*( hero.maxMana = heroMaxMana(hero.class, hero.level) hero.mana = hero.maxMana hero.initHeroCharges() - world.scriptObjectsTick = -1 + world.scriptObjectsTick = [-1'i32, -1] inc world.draftTurn world.draftTurnTicks = 0 if world.draftTurn == world.draftOrder.len: @@ -5763,8 +5771,9 @@ proc newGame*( forts: startingForts(map), nextFootmanId: FirstFootmanId, winner: RedTeam, - scriptObjects: newSeqOfCap[WorldObject](256), - scriptObjectsTick: -1 + scriptObjects: [newSeqOfCap[WorldObject](256), + newSeqOfCap[WorldObject](256)], + scriptObjectsTick: [-1'i32, -1] ), map: map, replayMode: replayMode, From f38415580a1c1e141309275bde5f188c9977734c Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:05:33 -0700 Subject: [PATCH 3/7] compare and copy vision height grids with memcmp and copyMem Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/sim.nim | 5 +++-- src/polyworld/visions.nim | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 17e4cdfa..19aa700d 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -723,8 +723,9 @@ proc rebuildVision*(world: World) {.measure.} = if sameVisionKeys(visionSkipNow, world.visionSkipKeys): return visionBlockers.setLen(sightTerrain.blockerHeights.len) - for i, value in sightTerrain.blockerHeights: - visionBlockers[i] = value + if visionBlockers.len > 0: + copyMem(visionBlockers[0].addr, sightTerrain.blockerHeights[0].addr, + visionBlockers.len * sizeof(int16)) for towerSlot in 0 ..< world.buildings.len: let tower {.cursor.} = world.buildings[towerSlot] if tower.hp > 0: diff --git a/src/polyworld/visions.nim b/src/polyworld/visions.nim index 1c41e123..5e7cb439 100644 --- a/src/polyworld/visions.nim +++ b/src/polyworld/visions.nim @@ -357,6 +357,11 @@ proc revealVision*( ): visible[index] = 255 +proc sameHeights(a, b: seq[int16]): bool = + ## Compares two height grids with one memory comparison. + a.len == b.len and (a.len == 0 or + equalMem(a[0].unsafeAddr, b[0].unsafeAddr, a.len * sizeof(int16))) + proc revealVisionCached*( cache: var VisionCache, visible: var seq[uint8], @@ -367,7 +372,8 @@ proc revealVisionCached*( ## Retains only the previous frame's source rays. Terrain or blocker changes ## invalidate every entry, including height changes without moving a source. if cache.width != width or cache.height != height or - cache.terrain != terrainHeights or cache.blockers != blockerHeights: + not sameHeights(cache.terrain, terrainHeights) or + not sameHeights(cache.blockers, blockerHeights): cache.sources.clear() cache.width = width cache.height = height From 1b1ef021685a99561f3eede1d779056215d6eb63 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:09:33 -0700 Subject: [PATCH 4/7] rebind navigationWorld after a lane waits, cache A* paths per revision Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/sim.nim | 38 ++++++++++++++++++++++--- examples/gods_of_the_arena/training.nim | 3 ++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 19aa700d..7032f447 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -9,7 +9,7 @@ ## VM type on `Game` but never runs a program. import - std/algorithm, + std/[algorithm, tables], bassy, fixxy, polyworld/[bodies, hashes, metrics, noises, pathing, profiles, rngs, tapes, visions, mailboxes], @@ -75,6 +75,10 @@ type decisions*: int lastWork*, lastInstructions*: int64 + PathCacheKey = object + startLayer, startX, startZ, finishLayer, finishX, finishZ: int + tieOrder: PathTieOrder + Footman* = object id*: int32 kind*: CreepKind @@ -277,6 +281,8 @@ type buildings*: seq[Building] occupancy*: seq[seq[int16]] navigationRevision*: int32 + pathCache: Table[PathCacheKey, seq[PathTile]] + pathCacheRevision: int32 spawnIntervalTicks*: int32 spawnTimerTicks*: int32 gameOver*: bool @@ -1605,6 +1611,10 @@ proc buildingFootprint(building: Building): seq[PathTile] = if touches: result.add PathTile(layer: layer.int32, x: localX.int32, z: localZ.int32) +proc bindNavigation*(world: World) = + ## Points pathing at one world, e.g. after another match ran mid-tick. + navigationWorld = world + proc syncBuildings*(world: World) = ## Releases destroyed footprints and invalidates paths and stale targets. navigationWorld = world @@ -2453,6 +2463,25 @@ proc movementPath(tiles: seq[PathTile], start: WorldPoint, position = destination anchor = reach +proc navigationPath(world: World, query: PathQuery, + tiles: var seq[PathTile]) = + ## Runs A* over navigationOpen, reusing results until occupancy changes. + const MaxCachedPaths = 4096 + if world.pathCacheRevision != world.navigationRevision or + world.pathCache.len >= MaxCachedPaths: + world.pathCache.clear() + world.pathCacheRevision = world.navigationRevision + let key = PathCacheKey( + startLayer: query.startLayer, startX: query.startX, startZ: query.startZ, + finishLayer: query.finishLayer, finishX: query.finishX, + finishZ: query.finishZ, tieOrder: query.tieOrder) + when not defined(gotaNoPathCache): + world.pathCache.withValue(key, cached): + tiles = cached[] + return + discard fillTilePath(query, tiles) + world.pathCache[key] = tiles + proc followCreepPath(world: World, footman: var Footman, goal: WorldPoint) = ## Follows a cached route and throttles changed chase goals and failed searches. if footman.controls[RootControl].ends > world.tick: @@ -2477,11 +2506,12 @@ proc followCreepPath(world: World, footman: var Footman, goal: WorldPoint) = int(mapCoordinate(goal.z, footman.team)), goal.y, last, reverseSearch = footman.team == RedTeam ): - let route = findTilePath(PathQuery( + var route: seq[PathTile] + world.navigationPath(PathQuery( startLayer: first.layer, startX: first.x, startZ: first.z, finishLayer: last.layer, finishX: last.x, finishZ: last.z, tieOrder: (if footman.team == RedTeam: ReverseTies else: ForwardTies), - walkable: navigationOpen)).tiles + walkable: navigationOpen), route) footman.movePath = movementPath(route, footman.position, footman.team) # The first tile is the search origin, not a movement destination. if footman.movePath.len > 1: @@ -2539,7 +2569,7 @@ proc setHeroDestination( not nearestNavTile(targetX, targetY, referenceY, finishTile, reverseSearch = hero.team == RedTeam): return false - discard fillTilePath(PathQuery( + navigationWorld.navigationPath(PathQuery( startLayer: startTile.layer, startX: startTile.x, startZ: startTile.z, diff --git a/examples/gods_of_the_arena/training.nim b/examples/gods_of_the_arena/training.nim index aac290ff..0a9a6f74 100644 --- a/examples/gods_of_the_arena/training.nim +++ b/examples/gods_of_the_arena/training.nim @@ -178,7 +178,10 @@ proc runLane(args: LaneWorkerArgs) {.thread.} = return 0 doAssert action in 0 ..< GotaActionCount, "action outside GotaActionCount" + # Other lanes ticked while this one waited; point the shared + # globals back at this match before its heroes continue. activeGame = game + bindNavigation(game.world) action, 1) inc limits.maxHostFunctions From 22f5875ffe1912ec97bd6d844353c5fe1b000293 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:19:07 -0700 Subject: [PATCH 5/7] unroll visible() over its sight cells Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/sim.nim | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 7032f447..53c1b0c9 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -783,11 +783,22 @@ proc rebuildVision*(world: World) {.measure.} = proc visible*(world: World, team: Team, position: WorldPoint): bool = ## Returns whether a position is currently visible to one team. - if world.teamVisible[team.ord].len != mapTiles() * mapTiles(): + ## Same cells as sightTiles, unrolled because this runs per unit pair. + let tiles = mapTiles() + if world.teamVisible[team.ord].len != tiles * tiles: return false - for tile in sightTiles(position): - if world.teamVisible[team.ord][tile.z * mapTiles() + tile.x] != 0: - return true + let + x = floorWorldTile(position.x) + tiles div 2 + z = floorWorldTile(position.z) + tiles div 2 + firstX = max(0, x - int(position.x mod WorldScale == 0)) + firstZ = max(0, z - int(position.z mod WorldScale == 0)) + lastX = min(tiles - 1, x) + lastZ = min(tiles - 1, z) + for tileZ in firstZ .. lastZ: + let row = tileZ * tiles + for tileX in firstX .. lastX: + if world.teamVisible[team.ord][row + tileX] != 0: + return true proc enemyFort(team: Team): int = ## Returns the opposing fort index for a team. From 031eff1c12f376cbb0fea34bb47f17cb33e9f394 Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 25 Sep 2026 07:19:11 -0700 Subject: [PATCH 6/7] lockstep training API: every hero an agent, no worker threads Co-Authored-By: Claude Opus 5.5 --- examples/gods_of_the_arena/lockstep.nim | 327 ++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 examples/gods_of_the_arena/lockstep.nim diff --git a/examples/gods_of_the_arena/lockstep.nim b/examples/gods_of_the_arena/lockstep.nim new file mode 100644 index 00000000..e819f8d3 --- /dev/null +++ b/examples/gods_of_the_arena/lockstep.nim @@ -0,0 +1,327 @@ +## Lockstep decision batches for external GotA trainers. +## +## Every policy-controlled hero is one agent. The policy script calls +## `chooseAction(f(0..N-1))` where the `' METTA_DECISION` marker sits; the +## call records the hero's latest features and returns the action the +## trainer last chose for it, so scripts never block and no threads are +## needed. `step` applies one action per agent, runs `actionTicks` ticks of +## every lane, and returns each agent's newest features and team reward. +## Three rewards, picked by RewardMode: +## - LeaderboardReward follows the hosted score: each hero is worth +## max(0, XP * 1440 - tick * 200), summed over the team; a step's reward +## is the change divided by 5 * 1440 * 1000, and a loss or timeout drops +## the team's potential to zero, as the leaderboard scores non-winners zero. +## - XpReward is the change in the team's total XP divided by 100, with no +## tick penalty and no reset on a loss, so a policy learns from scratch. +## - XpOutcomeReward is XpReward plus OutcomeBonus for a win and minus it +## for a loss or timeout, so surviving matters beyond the next XP. +## With `selfPlay` both teams run the policy (10 agents per lane); +## otherwise one team plays a bundled opponent (5 agents per lane). +## Finished lanes restart with the next seed inside the same step. +import std/strutils +include bots + +const + GotaFeatureCount* {.intdefine.} = 8 + GotaActionCount* {.intdefine.} = 8 + ScoreTicksPerMinute = 1440'i64 + ScoreXpPerMinute = 200'i64 + ScoreDenominator = 5 * ScoreTicksPerMinute * 1000 + GotaSourceCommit {.strdefine.} = "" + TeamSize = 5 + XpDenominator = 100 + OutcomeBonus = 50'f32 + +type + RewardMode* = enum + LeaderboardReward, XpReward, XpOutcomeReward + + LaneStats* {.bycopy.} = object + ## Summary of one finished match, from the first policy team's side. + finished*, outcome*, tick*, selfPlay*: int32 + potential*: int64 + ## Leaderboard score numerator at the end; points = potential / 7200. + + StepLane = object + game: Game + seed: int + team: Team + ## The first policy team; selfPlay lanes also control the other. + agents: seq[int] + ## Hero index of each of this lane's agents, in agent order. + features: seq[array[GotaFeatureCount, int32]] + actions: seq[int32] + potentials: array[Team, int64] + + StepBatch* = ref object + lanes: seq[StepLane] + configPath, bot, policy: string + opponents: seq[string] + maxTicks, actionTicks: int + selfPlay: bool + rewardMode: RewardMode + agentsPerLane*: int + +proc teamPotential(world: World, team: Team): int64 = + ## The hosted leaderboard score numerator for one team. + for hero in world.heroes: + if hero.team == team: + result += max(int64(hero.totalXp) * ScoreTicksPerMinute - + int64(world.tick) * ScoreXpPerMinute, 0) + +proc teamXp(world: World, team: Team): int64 = + ## Total XP earned by one team's heroes. + for hero in world.heroes: + if hero.team == team: + result += int64(hero.totalXp) + +proc outcome(world: World, team: Team, timedOut: bool): int32 = + ## 1 for a win, -1 for a loss or timeout, 0 while playing. + if world.gameOver and world.winner == team: 1 + elif world.gameOver or timedOut: -1 + else: 0 + +proc installPolicy(batch: StepBatch, lane: ptr StepLane, index: int) = + ## Compiles the policy for one hero with a non-blocking chooseAction. + let + game = lane.game + hero = game.world.heroes[index] + agent = lane.agents.len + lane.agents.add index + lane.features.add default(array[GotaFeatureCount, int32]) + lane.actions.add 0 + var + host = initHeroHost(hero.id) + limits = heroVmLimits() + limits.disableFixed = true + limits.maxParameters = GotaFeatureCount + discard host.addFunction("chooseAction", GotaFeatureCount, + proc(values: openArray[int32]): int32 = + for i, value in values: + doAssert value in -100 .. 100 + lane.features[agent][i] = value + lane.actions[agent], + 1) + inc limits.maxHostFunctions + var arguments: seq[string] + for i in 0 ..< GotaFeatureCount: + arguments.add "f(" & $i & ")" + let source = batch.policy.replace("' METTA_DECISION", + "decision = chooseAction(" & arguments.join(",") & ")") + let program = compile(source, host, limits) + bindHeroData(program) + game.heroVms[index] = HeroVm( + runtime: initRuntime(program, host, limits), limits: limits, ready: true + ) + +proc startLane(batch: StepBatch, lane: ptr StepLane, seed: int) = + ## Builds a fresh match for one lane and installs the policy scripts. + let + config = loadConfig(batch.configPath) + gameMap = generateMap(int32(seed), config.mapPreset) + game = newGame( + gameMap, config.spawnIntervalTicks, 10, false, ReplayData(), false + ) + team = Team((seed mod 10) div 5) + opponent = batch.opponents[(seed div 10) mod batch.opponents.len] + groups = + if team == RedTeam: + @[BotGroup(path: batch.bot, count: TeamSize), + BotGroup(path: opponent, count: TeamSize)] + else: + @[BotGroup(path: opponent, count: TeamSize), + BotGroup(path: batch.bot, count: TeamSize)] + loadBots(game, groups) + game.replayData = initReplayData( + currentSetup(game, uint32(batch.maxTicks)), gameMap.preset + ) + lane.game = game + lane.seed = seed + lane.team = team + lane.agents.setLen(0) + lane.features.setLen(0) + lane.actions.setLen(0) + for index, hero in game.world.heroes: + if hero.team == team: + batch.installPolicy(lane, index) + if batch.selfPlay: + for index, hero in game.world.heroes: + if hero.team != team: + batch.installPolicy(lane, index) + doAssert lane.agents.len == batch.agentsPerLane + for side in Team: + lane.potentials[side] = + case batch.rewardMode + of LeaderboardReward: teamPotential(game.world, side) + of XpReward, XpOutcomeReward: teamXp(game.world, side) + +proc newStepBatch*( + configPath, bot, opponent, policy: string, + count, maxTicks, actionTicks: int, + selfPlay: bool, + rewardMode = LeaderboardReward +): StepBatch = + ## Starts `count` lanes seeded 0 ..< count. + doAssert count > 0 and maxTicks in 1 .. 28_800 and actionTicks > 0 + let opponents = opponent.splitLines() + doAssert opponents.len > 0 + result = StepBatch( + configPath: configPath, + bot: bot, + opponents: opponents, + policy: policy, + maxTicks: maxTicks, + actionTicks: actionTicks, + selfPlay: selfPlay, + rewardMode: rewardMode, + agentsPerLane: if selfPlay: 2 * TeamSize else: TeamSize + ) + result.lanes.setLen(count) + for i in 0 ..< count: + result.startLane(result.lanes[i].addr, i) + +proc agentCount*(batch: StepBatch): int = + ## Agents across every lane. + batch.lanes.len * batch.agentsPerLane + +proc reset*(batch: StepBatch, seed: int) = + ## Restarts every lane; -1 continues each lane's seed sequence. + for i in 0 ..< batch.lanes.len: + let next = + if seed == -1: batch.lanes[i].seed + batch.lanes.len + else: seed * batch.lanes.len + i + batch.startLane(batch.lanes[i].addr, next) + +proc observe*( + batch: StepBatch, + features: var openArray[int32], + seats: var openArray[int32] +) = + ## Writes each agent's newest features and its seat within its team. + let size = GotaFeatureCount + var agent = 0 + for lane in batch.lanes: + for i, index in lane.agents: + for j in 0 ..< size: + features[agent * size + j] = lane.features[i][j] + seats[agent] = int32(index mod TeamSize) + inc agent + +proc step*( + batch: StepBatch, + actions: openArray[int32], + rewards: var openArray[float32], + terminals: var openArray[uint8], + stats: var openArray[LaneStats] +) = + ## Runs every lane for actionTicks ticks with the given actions. + doAssert actions.len == batch.agentCount + var agent = 0 + for laneIndex in 0 ..< batch.lanes.len: + let lane = batch.lanes[laneIndex].addr + for i in 0 ..< lane.agents.len: + doAssert actions[agent + i] in 0 ..< GotaActionCount + lane.actions[i] = actions[agent + i] + let game = lane.game + var ticks = 0 + while ticks < batch.actionTicks and not game.world.gameOver and + game.world.tick < batch.maxTicks: + activeGame = game + tickWorld(game, proc() = runBotDecisions(game)) + inc ticks + for vm in game.heroVms: + doAssert not vm.failed, vm.lastError + let + done = game.world.gameOver or game.world.tick >= batch.maxTicks + world = game.world + var reward: array[Team, float32] + for side in Team: + case batch.rewardMode + of LeaderboardReward: + let potential = + if done and outcome(world, side, true) != 1: 0'i64 + else: teamPotential(world, side) + reward[side] = float32(potential - lane.potentials[side]) / + float32(ScoreDenominator) + lane.potentials[side] = potential + of XpReward, XpOutcomeReward: + let xp = teamXp(world, side) + reward[side] = float32(xp - lane.potentials[side]) / + float32(XpDenominator) + if batch.rewardMode == XpOutcomeReward and done: + reward[side] += + float32(outcome(world, side, true)) * OutcomeBonus + lane.potentials[side] = xp + stats[laneIndex] = LaneStats() + if done: + stats[laneIndex] = LaneStats( + finished: 1, + outcome: outcome(world, lane.team, true), + tick: world.tick, + selfPlay: int32(batch.selfPlay), + potential: teamPotential(world, lane.team) + ) + for i, index in lane.agents: + rewards[agent + i] = reward[world.heroes[index].team] + terminals[agent + i] = uint8(done) + if done: + batch.startLane(lane, lane.seed + batch.lanes.len) + agent += lane.agents.len + +proc gota_source_commit(): cstring {.cdecl, exportc, dynlib.} = + GotaSourceCommit.cstring + +proc gota_stats_size(): cint {.cdecl, exportc, dynlib.} = + cint(sizeof(LaneStats)) + +proc gota_create( + config, bot, opponent, policy: cstring, + count, maxTicks, actionTicks, selfPlay, rewardMode: cint +): pointer {.cdecl, exportc, dynlib.} = + doAssert rewardMode in 0 .. RewardMode.high.ord, "unknown reward mode" + let batch = newStepBatch($config, $bot, $opponent, readFile($policy), + int(count), int(maxTicks), int(actionTicks), selfPlay != 0, + RewardMode(rewardMode)) + GC_ref(batch) + cast[pointer](batch) + +proc gota_agents(handle: pointer): cint {.cdecl, exportc, dynlib.} = + cint(cast[StepBatch](handle).agentCount) + +proc gota_reset( + handle: pointer, + seed: int64, + features, seats: ptr UncheckedArray[int32] +) {.cdecl, exportc, dynlib.} = + let batch = cast[StepBatch](handle) + batch.reset(int(seed)) + let count = batch.agentCount + batch.observe( + features.toOpenArray(0, count * GotaFeatureCount - 1), + seats.toOpenArray(0, count - 1) + ) + +proc gota_step( + handle: pointer, + actions: ptr UncheckedArray[int32], + features, seats: ptr UncheckedArray[int32], + rewards: ptr UncheckedArray[float32], + terminals: ptr UncheckedArray[uint8], + stats: ptr UncheckedArray[LaneStats] +) {.cdecl, exportc, dynlib.} = + let + batch = cast[StepBatch](handle) + count = batch.agentCount + batch.step( + actions.toOpenArray(0, count - 1), + rewards.toOpenArray(0, count - 1), + terminals.toOpenArray(0, count - 1), + stats.toOpenArray(0, batch.lanes.len - 1) + ) + batch.observe( + features.toOpenArray(0, count * GotaFeatureCount - 1), + seats.toOpenArray(0, count - 1) + ) + +proc gota_close(handle: pointer) {.cdecl, exportc, dynlib.} = + GC_unref(cast[StepBatch](handle)) From 452bec89fa4d22410bd172015cba693804a12a44 Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 25 Sep 2026 07:26:32 -0700 Subject: [PATCH 7/7] test the lockstep training API in CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 1 + tests/test_gota_lockstep.nim | 77 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/test_gota_lockstep.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a150cbdd..2cb03d2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,6 +47,7 @@ jobs: tests/test_profiles.nim - run: nim r tests/test_gota_sim.nim -- --bot:examples/gods_of_the_arena/players/base.bas:10 - run: nim r -d:headless tests/test_gota_training.nim + - run: nim r -d:headless tests/test_gota_lockstep.nim - run: nim r -d:headless tests/test_recordings.nim --bot examples/call_to_adventure/players/base.bas:4 - run: nim r -d:headless -d:recordGota tests/test_recordings.nim --bot examples/gods_of_the_arena/players/base.bas:10 - run: nim r -d:headless -d:recordHlf tests/test_recordings.nim --bot examples/heartleaf/players/base.bas:9 diff --git a/tests/test_gota_lockstep.nim b/tests/test_gota_lockstep.nim new file mode 100644 index 00000000..3b8c87b5 --- /dev/null +++ b/tests/test_gota_lockstep.nim @@ -0,0 +1,77 @@ +import ../examples/gods_of_the_arena/lockstep + +let policy = "dim f(" & $GotaFeatureCount & ")\n" & """ +f(0) = 100 +f(1) = selfTeam * 100 +f(2) = selfClass * 10 +f(3) = 77 +f(4) = -77 +f(5) = 55 +f(6) = -55 +f(7) = 44 +' METTA_DECISION +if decision = 0 then + walkTo(10, 10) +end if +if decision = 1 then + walkTo(mapWidth - 10, 10) +end if +if decision >= 3 and decision <= 6 then + castTarget(decision - 3, selfId) +end if +""" +let + config = "examples/gods_of_the_arena/presets/saved.json" + bot = "examples/gods_of_the_arena/players/base.bas" + +proc run( + selfPlay: bool, + rewardMode: RewardMode, + maxTicks, steps: int +): tuple[features: seq[int32], seats: seq[int32], rewards: seq[float32], + finished: int] = + ## Plays a fixed action pattern and returns the last frame. + let batch = newStepBatch(config, bot, bot, policy, 2, maxTicks, 24, + selfPlay, rewardMode) + let agents = batch.agentCount + var + actions = newSeq[int32](agents) + rewards = newSeq[float32](agents) + terminals = newSeq[uint8](agents) + stats = newSeq[LaneStats](2) + result.features = newSeq[int32](agents * GotaFeatureCount) + result.seats = newSeq[int32](agents) + for step in 0 ..< steps: + for i in 0 ..< agents: + actions[i] = int32((step + i) mod GotaActionCount) + batch.step(actions, rewards, terminals, stats) + for lane in stats: + result.finished += int(lane.finished) + batch.observe(result.features, result.seats) + result.rewards = rewards + +echo "Testing agent counts" +doAssert newStepBatch(config, bot, bot, policy, 3, 240, 24, false).agentCount == 15 +doAssert newStepBatch(config, bot, bot, policy, 3, 240, 24, true).agentCount == 30 + +echo "Testing observations and seats" +let played = run(true, XpReward, 2400, 20) +for agent in 0 ..< played.seats.len: + doAssert played.seats[agent] in 0 .. 4 + doAssert played.features[agent * GotaFeatureCount + 3] == 77 + doAssert played.features[agent * GotaFeatureCount + 7] == 44 + +echo "Testing determinism" +let replayed = run(true, XpReward, 2400, 20) +doAssert played.features == replayed.features +doAssert played.rewards == replayed.rewards + +echo "Testing restarts" +let restarted = run(false, LeaderboardReward, 240, 25) +doAssert restarted.finished >= 4, "every lane should finish twice" + +echo "Testing reward modes" +for mode in RewardMode: + let frame = run(false, mode, 480, 10) + for reward in frame.rewards: + doAssert reward == reward, "reward must not be NaN"