feat(wasm): implement SwarmBot AssemblyScript WASM with full strategy per plan §11.2

Implements complete SwarmBot formation-based combat strategy in AssemblyScript:
- JSON parsing for game config and state
- Tight cohesion (radius=3) movement with circular mean center-of-mass
- Enemy-seeking behavior with engagement bonuses
- Toroidal distance calculations

Builds to 27KB swarm.wasm (AssemblyScript produces compact binaries vs
Go's ~12MB). Build script now copies to dist/.

Closes: bf-2a7w

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-25 17:45:46 -04:00
parent 8d15333f2b
commit 306b0d2c5f
6 changed files with 657 additions and 33 deletions

View file

@ -192,9 +192,9 @@ func DefaultConfig() Config {
CoresPerPlayer: 2,
ZoneEnabled: true,
ZoneStartTurn: 10, // Start early to force combat before passive bots spread
ZoneShrinkInterval: 1, // Per plan §3.7.1 (both 2-player and 3+)
ZoneShrinkStep: 2, // Per plan §3.7.1 (both 2-player and 3+)
ZoneMinRadius: 1, // Per plan §3.7.1: 3+ player default (ConfigForPlayers overrides for 2-player)
ZoneShrinkInterval: 1, // Per plan §3.7.1 (both 2-player and 3+)
ZoneShrinkStep: 2, // Per plan §3.7.1 (both 2-player and 3+)
ZoneMinRadius: 1, // Per plan §3.7.1: 3+ player default (ConfigForPlayers overrides for 2-player)
}
}
@ -240,13 +240,13 @@ func ConfigForPlayers(numPlayers, coresPerPlayer int) Config {
// Zone diameter must be <= 2 * attack radius so bots at opposite zone edges can reach each other
// Target: 65-80% combat density per plan §3.7.1
if numPlayers == 2 {
cfg.ZoneStartTurn = 10 // Start early to force combat before passive bots spread (testing showed turn 10 too late)
cfg.ZoneStartTurn = 10 // Start early to force combat before passive bots spread (testing showed turn 10 too late)
cfg.ZoneShrinkInterval = 1 // Per plan §3.7.1
cfg.ZoneShrinkStep = 2 // Per plan §3.7.1: 2 tiles per step forces engagement
cfg.ZoneMinRadius = 2 // Per plan §3.7.1: 2-player min radius
cfg.AttackRadius2 = 25 // 5 tiles (reduced from 6 to achieve 65-80% combat density target)
} else {
cfg.ZoneStartTurn = 10 // Start early to force combat before passive bots spread
cfg.ZoneStartTurn = 10 // Start early to force combat before passive bots spread
cfg.ZoneShrinkInterval = 1 // Per plan §3.7.1
cfg.ZoneShrinkStep = 2 // Per plan §3.7.1: 2 tiles per step forces engagement
cfg.ZoneMinRadius = 1 // Zone diameter (2) < attack radius (3.5), forces contact

View file

@ -39,10 +39,10 @@ func LoadStateJSON(stateJSON string) (*Match, error) {
func (m *Match) StepTurn(moves map[int]Move) (map[string]interface{}, error) {
// Execute the turn using existing turn execution logic
result := map[string]interface{}{
"turn": m.state.Turn,
"events": m.state.Events,
"bots": m.state.Bots,
"energy": m.state.Energy,
"turn": m.state.Turn,
"events": m.state.Events,
"bots": m.state.Bots,
"energy": m.state.Energy,
}
m.state.Turn++
return result, nil

View file

@ -2,7 +2,8 @@
# Build swarm.wasm from AssemblyScript source
set -e
cd "$(dirname "$0")"
mkdir -p ../../dist
npm install
npx asc index.ts -o build/swarm.wasm
npx asc index.ts -o build/swarm.wasm --runtime incremental
cp build/swarm.wasm ../../dist/swarm.wasm
echo "Built wasm/bots/swarm -> dist/swarm.wasm"

View file

@ -1,33 +1,600 @@
// AssemblyScript implementation of SwarmBot for WASM compilation.
// SwarmBot keeps units in tight formations and advances as a group.
// SwarmBot WASM implementation - formation-based combat with tight cohesion.
// Uses low-level WASM interface compatible with the sandbox loader.
// Configuration stored globally
let rows: i32 = 60;
let cols: i32 = 60;
let attackRadius2: i32 = 12;
let visionRadius2: i32 = 16;
// Visible state
// Bot ID
let myId: i32 = 0;
// Simple position encoding for bots (row * 10000 + col for unique encoding)
let botPositions: Int32Array = new Int32Array(0);
let botOwners: Int32Array = new Int32Array(0);
// Swarm configuration
const COHESION_RADIUS: i32 = 3;
const COHESION_RADIUS2: i32 = 9;
// Dynamic arrays for game state
let myBots: Position[] = [];
let enemyBots: Position[] = [];
let walls: Position[] = [];
let energy: Position[] = [];
// Position structure
class Position {
row: i32 = 0;
col: i32 = 0;
constructor(row: i32 = 0, col: i32 = 0) {
this.row = row;
this.col = col;
}
equals(other: Position): bool {
return this.row == other.row && this.col == other.col;
}
toString(): string {
return `${this.row},${this.col}`;
}
}
// Direction enum
const DIR_N: i32 = 0;
const DIR_E: i32 = 1;
const DIR_S: i32 = 2;
const DIR_W: i32 = 3;
const DROW: i32[] = [-1, 0, 1, 0];
const DCOL: i32[] = [0, 1, 0, -1];
const DIR_NAMES: string[] = ["N", "E", "S", "W"];
// Move output structure
class Move {
position: Position;
direction: i32;
constructor(pos: Position, dir: i32) {
this.position = pos;
this.direction = dir;
}
}
// Output buffer for moves
let outputMoves: Move[] = [];
// Initialize the bot with game config
export function init(configJson: string): string {
// Simple config parsing - expecting JSON like {"rows":60,"cols":60,"attack_radius2":12}
// For now, use defaults - can be enhanced with proper JSON parsing
// Parse config JSON: {"rows":60,"cols":60,"attack_radius2":12,"vision_radius2":16}
// Simple manual parsing for performance
let ptr = 0;
let len = configJson.length;
while (ptr < len) {
// Skip whitespace
while (ptr < len && configJson.charCodeAt(ptr) <= 32) ptr++;
if (ptr >= len) break;
// Find key
if (configJson.charCodeAt(ptr) == 34) { // "
ptr++;
let keyStart = ptr;
while (ptr < len && configJson.charCodeAt(ptr) != 34) ptr++;
let key = configJson.substring(keyStart, ptr);
ptr++; // skip closing "
// Skip to colon
while (ptr < len && configJson.charCodeAt(ptr) != 58) ptr++;
ptr++; // skip colon
// Parse value
while (ptr < len && configJson.charCodeAt(ptr) <= 32) ptr++;
let valueStart = ptr;
let neg = false;
if (configJson.charCodeAt(ptr) == 45) { // -
neg = true;
ptr++;
}
while (ptr < len && configJson.charCodeAt(ptr) >= 48 && configJson.charCodeAt(ptr) <= 57) ptr++;
let valStr = configJson.substring(valueStart, ptr);
let val = parseInt(valStr);
let value: i64 = neg ? -i64(val) : i64(val);
// Set config
if (key == "rows") rows = i32(value);
else if (key == "cols") cols = i32(value);
else if (key == "attack_radius2") attackRadius2 = i32(value);
else if (key == "vision_radius2") visionRadius2 = i32(value);
} else {
ptr++;
}
}
return "{\"ok\":true}";
}
// Compute moves for the current turn
// Parse JSON game state and compute moves
export function compute_moves(stateJson: string): string {
// Simplified: return basic moves without complex JSON parsing
// This is a minimal working implementation
return "[{\"position\":{\"row\":0,\"col\":0},\"direction\":\"N\"}]";
// Clear previous state
myBots = [];
enemyBots = [];
walls = [];
energy = [];
outputMoves = [];
// Parse state JSON
parseGameState(stateJson);
// Compute moves using swarm strategy
computeSwarmMoves();
// Build output JSON
return buildOutputJSON();
}
// Free result is a no-op for AssemblyScript
export function free_result(ptr: usize): void {
// GC handles memory
// Parse game state from JSON
function parseGameState(json: string): void {
let ptr = 0;
let len = json.length;
let inString = false;
let inArray = false;
let inObject = false;
let currentKey = "";
let currentValue = "";
let parsingKey = true;
let depth = 0;
let arrayDepth = 0;
let targetArray: string = "";
let inBotsArray = false;
let inWallsArray = false;
let inEnergyArray = false;
while (ptr < len) {
let ch = json.charCodeAt(ptr);
if (ch == 34) { // "
inString = !inString;
} else if (!inString) {
if (ch == 123) { // {
inObject = true;
depth++;
if (depth == 2) parsingKey = true;
} else if (ch == 125) { // }
inObject = false;
depth--;
if (depth == 1) parsingKey = true;
} else if (ch == 91) { // [
inArray = true;
arrayDepth++;
if (currentKey == "bots" && arrayDepth == 2) {
inBotsArray = true;
} else if (currentKey == "walls" && arrayDepth == 2) {
inWallsArray = true;
} else if (currentKey == "energy" && arrayDepth == 2) {
inEnergyArray = true;
}
} else if (ch == 93) { // ]
inArray = false;
arrayDepth--;
inBotsArray = false;
inWallsArray = false;
inEnergyArray = false;
} else if (ch == 58) { // :
parsingKey = false;
} else if (ch == 44) { // ,
parsingKey = true;
if (depth == 1) {
currentKey = "";
currentValue = "";
}
} else if (parsingKey && ch > 32) {
let start = ptr;
while (ptr < len && json.charCodeAt(ptr) > 32 && json.charCodeAt(ptr) != 58) ptr++;
currentKey = json.substring(start, ptr);
continue;
}
}
ptr++;
}
// Extract key values using string search (simplified approach)
extractMyId(json);
extractBotPositions(json);
extractWallPositions(json);
extractEnergyPositions(json);
}
// Extract my ID from JSON
function extractMyId(json: string): void {
let key = "\"you\":";
let idx = json.indexOf(key);
if (idx >= 0) {
idx += key.length;
let idKey = "\"id\":";
let idIdx = json.indexOf(idKey, idx);
if (idIdx >= 0) {
idIdx += idKey.length;
let end = idIdx;
while (end < json.length && json.charCodeAt(end) >= 48 && json.charCodeAt(end) <= 57) end++;
myId = i32(parseInt(json.substring(idIdx, end)));
}
}
}
// Extract bot positions from JSON
function extractBotPositions(json: string): void {
let botsStart = json.indexOf("\"bots\":[");
if (botsStart < 0) return;
botsStart += 8; // Skip "bots":[
let depth = 1;
let pos = botsStart;
while (pos < json.length && depth > 0) {
let ch = json.charCodeAt(pos);
if (ch == 123) { // { - start of bot object
let botEnd = json.indexOf("}", pos);
if (botEnd < 0) break;
let botJson = json.substring(pos, botEnd + 1);
// Extract position
let posStart = botJson.indexOf("\"position\":");
if (posStart >= 0) {
posStart = botJson.indexOf("{", posStart);
let posEnd = botJson.indexOf("}", posStart);
if (posEnd > posStart) {
let posJson = botJson.substring(posStart, posEnd + 1);
let rowStart = posJson.indexOf("\"row\":");
let colStart = posJson.indexOf("\"col\":");
if (rowStart >= 0 && colStart >= 0) {
rowStart += 6;
colStart += 6;
let rowEnd = rowStart;
while (rowEnd < posJson.length && (posJson.charCodeAt(rowEnd) >= 48 && posJson.charCodeAt(rowEnd) <= 57 || posJson.charCodeAt(rowEnd) == 45)) rowEnd++;
let colEnd = colStart;
while (colEnd < posJson.length && (posJson.charCodeAt(colEnd) >= 48 && posJson.charCodeAt(colEnd) <= 57 || posJson.charCodeAt(colEnd) == 45)) colEnd++;
let row = i32(parseInt(posJson.substring(rowStart, rowEnd)));
let col = i32(parseInt(posJson.substring(colStart, colEnd)));
// Extract owner
let ownerStart = botJson.indexOf("\"owner\":");
if (ownerStart >= 0) {
ownerStart += 9;
let ownerEnd = ownerStart;
while (ownerEnd < botJson.length && (botJson.charCodeAt(ownerEnd) >= 48 && botJson.charCodeAt(ownerEnd) <= 57)) ownerEnd++;
let owner = i32(parseInt(botJson.substring(ownerStart, ownerEnd)));
let botPos = new Position(row, col);
if (owner == myId) {
myBots.push(botPos);
} else {
enemyBots.push(botPos);
}
}
}
}
}
pos = botEnd + 1;
} else if (ch == 91) {
depth++;
pos++;
} else if (ch == 93) {
depth--;
pos++;
} else {
pos++;
}
}
}
// Extract wall positions from JSON
function extractWallPositions(json: string): void {
let wallsStart = json.indexOf("\"walls\":[");
if (wallsStart < 0) return;
wallsStart += 10; // Skip "walls":[
let depth = 1;
let pos = wallsStart;
while (pos < json.length && depth > 0) {
let ch = json.charCodeAt(pos);
if (ch == 123) { // { - start of wall object
let wallEnd = json.indexOf("}", pos);
if (wallEnd < 0) break;
let wallJson = json.substring(pos, wallEnd + 1);
let rowStart = wallJson.indexOf("\"row\":");
let colStart = wallJson.indexOf("\"col\":");
if (rowStart >= 0 && colStart >= 0) {
rowStart += 6;
colStart += 6;
let rowEnd = rowStart;
while (rowEnd < wallJson.length && (wallJson.charCodeAt(rowEnd) >= 48 && wallJson.charCodeAt(rowEnd) <= 57 || wallJson.charCodeAt(rowEnd) == 45)) rowEnd++;
let colEnd = colStart;
while (colEnd < wallJson.length && (wallJson.charCodeAt(colEnd) >= 48 && wallJson.charCodeAt(colEnd) <= 57 || wallJson.charCodeAt(colEnd) == 45)) colEnd++;
let row = i32(parseInt(wallJson.substring(rowStart, rowEnd)));
let col = i32(parseInt(wallJson.substring(colStart, colEnd)));
walls.push(new Position(row, col));
}
pos = wallEnd + 1;
} else if (ch == 91) {
depth++;
pos++;
} else if (ch == 93) {
depth--;
pos++;
} else {
pos++;
}
}
}
// Extract energy positions from JSON
function extractEnergyPositions(json: string): void {
let energyStart = json.indexOf("\"energy\":[");
if (energyStart < 0) return;
energyStart += 10; // Skip "energy":[
let depth = 1;
let pos = energyStart;
while (pos < json.length && depth > 0) {
let ch = json.charCodeAt(pos);
if (ch == 123) { // { - start of energy object
let energyEnd = json.indexOf("}", pos);
if (energyEnd < 0) break;
let energyJson = json.substring(pos, energyEnd + 1);
let rowStart = energyJson.indexOf("\"row\":");
let colStart = energyJson.indexOf("\"col\":");
if (rowStart >= 0 && colStart >= 0) {
rowStart += 6;
colStart += 6;
let rowEnd = rowStart;
while (rowEnd < energyJson.length && (energyJson.charCodeAt(rowEnd) >= 48 && energyJson.charCodeAt(rowEnd) <= 57 || energyJson.charCodeAt(rowEnd) == 45)) rowEnd++;
let colEnd = colStart;
while (colEnd < energyJson.length && (energyJson.charCodeAt(colEnd) >= 48 && energyJson.charCodeAt(colEnd) <= 57 || energyJson.charCodeAt(colEnd) == 45)) colEnd++;
let row = i32(parseInt(energyJson.substring(rowStart, rowEnd)));
let col = i32(parseInt(energyJson.substring(colStart, colEnd)));
energy.push(new Position(row, col));
}
pos = energyEnd + 1;
} else if (ch == 91) {
depth++;
pos++;
} else if (ch == 93) {
depth--;
pos++;
} else {
pos++;
}
}
}
// Compute swarm moves
function computeSwarmMoves(): void {
if (myBots.length == 0) return;
// Calculate swarm center
let swarmCenter = calculateCenter(myBots);
// Calculate enemy center
let enemyCenter: Position | null = null;
if (enemyBots.length > 0) {
enemyCenter = calculateCenter(enemyBots);
}
// Target is enemy center or map center
let target = enemyCenter != null ? enemyCenter : new Position(rows / 2, cols / 2);
// Compute move for each bot
for (let i = 0; i < myBots.length; i++) {
let bot = myBots[i];
let move = computeBotMove(bot, swarmCenter, target);
if (move != null) {
outputMoves.push(move);
}
}
}
// Calculate center of mass using circular mean for toroidal coordinates
function calculateCenter(positions: Position[]): Position {
if (positions.length == 0) {
return new Position(rows / 2, cols / 2);
}
let sumSinRow = 0.0;
let sumCosRow = 0.0;
let sumSinCol = 0.0;
let sumCosCol = 0.0;
let rowScale = (2.0 * 3.14159265359) / f64(rows);
let colScale = (2.0 * 3.14159265359) / f64(cols);
for (let i = 0; i < positions.length; i++) {
let pos = positions[i];
sumSinRow += Math.sin(f64(pos.row) * rowScale);
sumCosRow += Math.cos(f64(pos.row) * rowScale);
sumSinCol += Math.sin(f64(pos.col) * colScale);
sumCosCol += Math.cos(f64(pos.col) * colScale);
}
let avgRow = Math.atan2(sumSinRow / f64(positions.length), sumCosRow / f64(positions.length)) / rowScale;
let avgCol = Math.atan2(sumSinCol / f64(positions.length), sumCosCol / f64(positions.length)) / colScale;
let resultRow = i32(((avgRow % f64(rows)) + f64(rows)) % f64(rows));
let resultCol = i32(((avgCol % f64(cols)) + f64(cols)) % f64(cols));
return new Position(resultRow, resultCol);
}
// Compute move for a single bot
function computeBotMove(bot: Position, swarmCenter: Position, target: Position): Move | null {
let bestDir: i32 = -1;
let bestScore: f64 = -Infinity;
for (let dir = 0; dir < 4; dir++) {
let newPos = moveInDirection(bot, dir);
// Check if position is valid
if (!isValidPosition(newPos)) continue;
// Check cohesion
if (!maintainsCohesion(newPos, bot)) continue;
// Score this move
let score: f64 = 0;
// Reward moving toward target
let distToTarget = distance2(newPos, target);
let currentDistToTarget = distance2(bot, target);
score += (f64(currentDistToTarget) - f64(distToTarget)) * 10.0;
// Penalize being far from swarm center
let distToSwarm = distance2(newPos, swarmCenter);
score -= f64(distToSwarm) * 0.5;
// Bonus for moving toward enemies
let nearestEnemyDist: i32 = 999999;
for (let i = 0; i < enemyBots.length; i++) {
let dist = distance2(newPos, enemyBots[i]);
if (dist < nearestEnemyDist) {
nearestEnemyDist = dist;
}
}
if (nearestEnemyDist < 999999) {
if (nearestEnemyDist <= attackRadius2) {
score += 50.0;
}
}
if (score > bestScore) {
bestScore = score;
bestDir = dir;
}
}
if (bestDir >= 0) {
return new Move(bot, bestDir);
}
return null;
}
// Move in a direction (with toroidal wrap)
function moveInDirection(pos: Position, dir: i32): Position {
let newRow = pos.row + DROW[dir];
let newCol = pos.col + DCOL[dir];
// Toroidal wrap
if (newRow < 0) newRow = rows - 1;
if (newRow >= rows) newRow = 0;
if (newCol < 0) newCol = cols - 1;
if (newCol >= cols) newCol = 0;
return new Position(newRow, newCol);
}
// Check if position is valid (not wall, not enemy)
function isValidPosition(pos: Position): bool {
// Check walls
for (let i = 0; i < walls.length; i++) {
if (walls[i].equals(pos)) return false;
}
// Check enemy bots
for (let i = 0; i < enemyBots.length; i++) {
if (enemyBots[i].equals(pos)) return false;
}
return true;
}
// Check if move maintains cohesion
function maintainsCohesion(newPos: Position, oldPos: Position): bool {
for (let i = 0; i < myBots.length; i++) {
let other = myBots[i];
if (other.equals(oldPos)) continue;
let dist2 = distance2(newPos, other);
if (dist2 <= COHESION_RADIUS2) {
return true;
}
}
// Single bot case: always allow movement
if (myBots.length == 1) {
return true;
}
return false;
}
// Squared distance (toroidal)
function distance2(a: Position, b: Position): i32 {
let dr = Math.abs(a.row - b.row);
let dc = Math.abs(a.col - b.col);
if (dr > rows / 2) dr = rows - dr;
if (dc > cols / 2) dc = cols - dc;
return i32(dr * dr + dc * dc);
}
// Build output JSON
function buildOutputJSON(): string {
if (outputMoves.length == 0) {
return "{\"moves\":[]}";
}
let result = "{\"moves\":[";
for (let i = 0; i < outputMoves.length; i++) {
let move = outputMoves[i];
result += "{\"position\":{\"row\":";
result += move.position.row.toString();
result += ",\"col\":";
result += move.position.col.toString();
result += "},\"direction\":\"";
result += DIR_NAMES[move.direction];
result += "\"}";
if (i < outputMoves.length - 1) {
result += ",";
}
}
result += "]}";
return result;
}
// Free result is a no-op for AssemblyScript (GC handles memory)
export function free_result(ptr: usize): void {
// GC handles memory automatically
}

56
wasm/bots/swarm/package-lock.json generated Normal file
View file

@ -0,0 +1,56 @@
{
"name": "swarm-wasm",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "swarm-wasm",
"version": "1.0.0",
"devDependencies": {
"assemblyscript": "^0.27.0"
}
},
"node_modules/assemblyscript": {
"version": "0.27.37",
"resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.27.37.tgz",
"integrity": "sha512-YtY5k3PiV3SyUQ6gRlR2OCn8dcVRwkpiG/k2T5buoL2ymH/Z/YbaYWbk/f9mO2HTgEtGWjPiAQrIuvA7G/63Gg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"binaryen": "116.0.0-nightly.20240114",
"long": "^5.2.4"
},
"bin": {
"asc": "bin/asc.js",
"asinit": "bin/asinit.js"
},
"engines": {
"node": ">=18",
"npm": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/assemblyscript"
}
},
"node_modules/binaryen": {
"version": "116.0.0-nightly.20240114",
"resolved": "https://registry.npmjs.org/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
"integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"wasm-opt": "bin/wasm-opt",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
}
}
}

View file

@ -19,14 +19,14 @@ var (
func init() {
c := make(chan struct{})
js.Global().Set("acbEngine", js.ValueOf(map[string]interface{}{
"loadState": jsWrapper(loadState),
"step": jsWrapper(step),
"runMatch": jsWrapper(runMatch),
"getReplay": jsWrapper(getReplay),
"getBots": jsWrapper(getBots),
"getEnergy": jsWrapper(getEnergy),
"getConfig": jsWrapper(getConfig),
"getState": jsWrapper(getState),
"loadState": jsWrapper(loadState),
"step": jsWrapper(step),
"runMatch": jsWrapper(runMatch),
"getReplay": jsWrapper(getReplay),
"getBots": jsWrapper(getBots),
"getEnergy": jsWrapper(getEnergy),
"getConfig": jsWrapper(getConfig),
"getState": jsWrapper(getState),
}))
fmt.Println("ACB WASM Engine loaded")
close(c)