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>
600 lines
16 KiB
TypeScript
600 lines
16 KiB
TypeScript
// 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;
|
|
|
|
// Bot ID
|
|
let myId: i32 = 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 {
|
|
// 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}";
|
|
}
|
|
|
|
// Parse JSON game state and compute moves
|
|
export function compute_moves(stateJson: string): string {
|
|
// Clear previous state
|
|
myBots = [];
|
|
enemyBots = [];
|
|
walls = [];
|
|
energy = [];
|
|
outputMoves = [];
|
|
|
|
// Parse state JSON
|
|
parseGameState(stateJson);
|
|
|
|
// Compute moves using swarm strategy
|
|
computeSwarmMoves();
|
|
|
|
// Build output JSON
|
|
return buildOutputJSON();
|
|
}
|
|
|
|
// 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
|
|
}
|