feat(bots): add coordinator bot with dynamic role allocation

Implement CoordinatorBot - a multi-role strategy bot that dynamically
allocates roles (Attacker/Harvester/Defender/Scout) each turn based on
game state.

Features:
- Per-turn role assignment using greedy Hungarian-style algorithm
- Dynamic role rebalancing based on threat level, economic pressure, score
- Zone-aware survival logic
- HTTP server with HMAC authentication
- TypeScript implementation with full type safety

Role allocation algorithm:
- Base split: 50% attackers, 25% harvesters, 15% defenders, 10% scouts
- Adjusts: +10% defenders if threat > 0.3
- Adjusts: +10% harvesters if energy < spawn threshold
- Adjusts: +20% attackers if score leads by 5+
- Assigns bots by proximity to role targets

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-06-17 04:24:26 -04:00
parent f0d4e661d7
commit 8af1c03aca
9 changed files with 1239 additions and 19 deletions

View file

@ -1 +1 @@
1668c66917fd109402238179b75633a0b8ea549f
f0d4e661d71735c512e375c77fe404268a3a157a

View file

@ -0,0 +1,23 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json tsconfig.json ./
COPY src ./src
RUN npm install && npm run build
# Runtime stage
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
ENV BOT_PORT=8084
ENV BOT_SECRET=""
EXPOSE 8084
CMD ["npm", "start"]

235
bots/coordinator/package-lock.json generated Normal file
View file

@ -0,0 +1,235 @@
{
"name": "coordinator-bot",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coordinator-bot",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.7.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

View file

@ -0,0 +1,17 @@
{
"name": "coordinator-bot",
"version": "1.0.0",
"description": "CoordinatorBot - Dynamic role allocation strategy for AI Code Battle",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"ts-node": "^10.9.0"
}
}

View file

@ -0,0 +1,110 @@
/**
* Game state types for AI Code Battle protocol.
*/
export interface Position {
row: number;
col: number;
}
export interface GameConfig {
rows: number;
cols: number;
max_turns: number;
vision_radius2: number;
attack_radius2: number;
spawn_cost: number;
energy_interval: number;
}
export interface PlayerInfo {
id: number;
energy: number;
score: number;
}
export interface VisibleBot {
position: Position;
owner: number;
}
export interface VisibleCore {
position: Position;
owner: number;
active: boolean;
}
export interface ZoneBounds {
center: Position;
radius: number;
active: boolean;
}
export interface GameState {
match_id: string;
turn: number;
config: GameConfig;
you: PlayerInfo;
bots: VisibleBot[];
energy: Position[];
cores: VisibleCore[];
walls: Position[];
dead: VisibleBot[];
zone?: ZoneBounds;
}
export type Direction = 'N' | 'E' | 'S' | 'W';
export interface Move {
position: Position;
direction: Direction;
}
export interface MoveResponse {
moves: Move[];
}
// Utility functions
export function posKey(pos: Position): string {
return `${pos.row},${pos.col}`;
}
export function posEquals(a: Position, b: Position): boolean {
return a.row === b.row && a.col === b.col;
}
export function moveToward(pos: Position, dir: Direction, rows: number, cols: number): Position {
switch (dir) {
case 'N':
return { row: (pos.row - 1 + rows) % rows, col: pos.col };
case 'E':
return { row: pos.row, col: (pos.col + 1) % cols };
case 'S':
return { row: (pos.row + 1) % rows, col: pos.col };
case 'W':
return { row: pos.row, col: (pos.col - 1 + cols) % cols };
}
}
export function distance2(a: Position, b: Position, rows: number, cols: number): number {
let dr = Math.abs(a.row - b.row);
let dc = Math.abs(a.col - b.col);
dr = Math.min(dr, rows - dr);
dc = Math.min(dc, cols - dc);
return dr * dr + dc * dc;
}
export function manhattanDistance(a: Position, b: Position, rows: number, cols: number): number {
let dr = Math.abs(a.row - b.row);
let dc = Math.abs(a.col - b.col);
dr = Math.min(dr, rows - dr);
dc = Math.min(dc, cols - dc);
return dr + dc;
}
export const ALL_DIRECTIONS: Direction[] = ['N', 'E', 'S', 'W'];
export function buildPositionSet(positions: Position[]): Set<string> {
return new Set(positions.map(posKey));
}

View file

@ -0,0 +1,124 @@
/**
* CoordinatorBot - Dynamic role allocation strategy for AI Code Battle.
*
* HTTP server that handles game engine requests with HMAC authentication.
* Each turn assigns each bot a role (Attacker/Harvester/Defender/Scout) and
* executes role-appropriate logic, rebalancing dynamically based on game state.
*/
import * as crypto from 'crypto';
import * as http from 'http';
import { GameState, MoveResponse } from './game.js';
import { CoordinatorStrategy } from './strategy.js';
const PORT = parseInt(process.env.BOT_PORT || '8084', 10);
const SECRET = process.env.BOT_SECRET || '';
if (!SECRET) {
console.error('ERROR: BOT_SECRET environment variable is required');
process.exit(1);
}
const strategy = new CoordinatorStrategy();
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
return;
}
if (req.method === 'POST' && req.url === '/turn') {
handleTurn(req, res);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
});
async function handleTurn(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// Extract auth headers
const matchId = req.headers['x-acb-match-id'] as string;
const turnStr = req.headers['x-acb-turn'] as string;
const timestamp = req.headers['x-acb-timestamp'] as string;
const signature = req.headers['x-acb-signature'] as string;
if (!matchId || !turnStr || !timestamp || !signature) {
res.writeHead(401, { 'Content-Type': 'text/plain' });
res.end('Missing auth headers');
return;
}
// Read body
let body = '';
for await (const chunk of req) {
body += chunk;
}
// Verify signature
if (!verifySignature(SECRET, matchId, turnStr, timestamp, body, signature)) {
res.writeHead(401, { 'Content-Type': 'text/plain' });
res.end('Invalid signature');
return;
}
// Parse game state
let state: GameState;
try {
state = JSON.parse(body);
} catch (e) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid JSON');
return;
}
// Compute moves
const moves = strategy.computeMoves(state);
const turn = parseInt(turnStr, 10);
console.log(`Turn ${turn}: ${moves.length} moves computed`);
// Build response
const response: MoveResponse = { moves };
const responseBody = JSON.stringify(response);
// Sign response
const responseSig = signResponse(SECRET, matchId, turn, responseBody);
res.writeHead(200, {
'Content-Type': 'application/json',
'X-ACB-Signature': responseSig,
});
res.end(responseBody);
}
/**
* Verify HMAC signature of incoming request
*/
function verifySignature(
secret: string,
matchId: string,
turn: string,
timestamp: string,
body: string,
signature: string
): boolean {
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signingString = `${matchId}.${turn}.${bodyHash}`;
const expected = crypto.createHmac('sha256', secret).update(signingString).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
/**
* Sign response body
*/
function signResponse(secret: string, matchId: string, turn: number, body: string): string {
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signingString = `${matchId}.${turn}.${bodyHash}`;
return crypto.createHmac('sha256', secret).update(signingString).digest('hex');
}
server.listen(PORT, '0.0.0.0', () => {
console.log(`CoordinatorBot starting on port ${PORT}`);
});

View file

@ -0,0 +1,676 @@
/**
* Coordinator strategy - dynamic role allocation for multi-role bot coordination.
*
* Strategy: Each turn, assign each bot exactly one role (Attacker/Harvester/Defender/Scout)
* and execute role-appropriate logic. Role allocation adapts to game state:
* - High threat more defenders
* - Low energy more harvesters
* - Winning more attackers
*/
import {
GameState,
VisibleBot,
VisibleCore,
Position,
Move,
Direction,
GameConfig,
PlayerInfo,
posKey,
posEquals,
moveToward,
distance2,
manhattanDistance,
ALL_DIRECTIONS,
buildPositionSet,
} from './game.js';
export type BotRole = 'ATTACKER' | 'HARVESTER' | 'DEFENDER' | 'SCOUT';
interface RoleAssignment {
bot: VisibleBot;
role: BotRole;
target: Position | null;
}
/**
* Coordinator strategy implementation
*/
export class CoordinatorStrategy {
// Track last seen positions for scouting
private seenPositions = new Set<string>();
/**
* Compute moves for all owned bots
*/
computeMoves(state: GameState): Move[] {
const myId = state.you.id;
const config = state.config;
// Separate my bots from enemies
const myBots: VisibleBot[] = [];
const enemyBots: VisibleBot[] = [];
for (const bot of state.bots) {
if (bot.owner === myId) {
myBots.push(bot);
} else {
enemyBots.push(bot);
}
}
if (myBots.length === 0) {
return [];
}
// Build lookups
const walls = buildPositionSet(state.walls);
const energySet = buildPositionSet(state.energy);
const enemyPositions = new Map<string, VisibleBot>();
for (const bot of enemyBots) {
enemyPositions.set(posKey(bot.position), bot);
}
// Get my cores for defenders
const myCores = state.cores.filter(c => c.owner === myId && c.active);
// Assign roles to each bot
const assignments = this.assignRoles(
myBots,
enemyBots,
myCores,
state.energy,
state.you,
config,
state
);
// Compute moves based on role assignments
const moves: Move[] = [];
const assignedTargets = new Set<string>();
for (const assignment of assignments) {
const move = this.computeMoveForRole(
assignment,
walls,
energySet,
enemyPositions,
myCores,
state,
assignedTargets
);
if (move) {
moves.push(move);
}
// Track seen positions for scouting
this.seenPositions.add(posKey(assignment.bot.position));
}
return moves;
}
/**
* Assign roles to each bot based on game state
*/
private assignRoles(
myBots: VisibleBot[],
enemyBots: VisibleBot[],
myCores: VisibleCore[],
energyNodes: Position[],
playerInfo: PlayerInfo,
config: GameConfig,
state: GameState
): RoleAssignment[] {
const n = myBots.length;
if (n === 0) return [];
// Compute threat level: enemies near own cores / total bots
let threatLevel = 0;
if (myCores.length > 0) {
const enemiesNearCores = enemyBots.filter(bot =>
myCores.some(core => distance2(bot.position, core.position, config.rows, config.cols) <= 16) // 4 tiles radius
).length;
threatLevel = enemiesNearCores / n;
}
// Compute economic pressure: spawn_cost - energy
const spawnThreshold = config.spawn_cost;
const economicPressure = Math.max(0, spawnThreshold - playerInfo.energy) / spawnThreshold;
// Base role split
let attackerPct = 0.50;
let harvesterPct = 0.25;
let defenderPct = 0.15;
let scoutPct = 0.10;
// Adjust based on threat level
if (threatLevel > 0.3) {
defenderPct += 0.10;
attackerPct -= 0.10;
}
// Adjust based on economic pressure
if (economicPressure > 0.5) {
harvesterPct += 0.10;
attackerPct -= 0.10;
}
// Adjust based on score (winning → more attackers)
const maxScore = Math.max(playerInfo.score, 1);
const avgEnemyScore = 1; // Simplified - could track actual enemy scores
if (playerInfo.score > avgEnemyScore + 5) {
attackerPct += 0.20;
harvesterPct -= 0.10;
defenderPct -= 0.10;
}
// Calculate counts
const numAttackers = Math.max(1, Math.round(n * attackerPct));
const numHarvesters = Math.max(1, Math.round(n * harvesterPct));
const numDefenders = Math.max(0, Math.round(n * defenderPct));
const numScouts = Math.max(0, n - numAttackers - numHarvesters - numDefenders);
// Assign bots to roles by proximity to role targets (greedy Hungarian-style)
const roles: BotRole[] = [];
for (let i = 0; i < numAttackers; i++) roles.push('ATTACKER');
for (let i = 0; i < numHarvesters; i++) roles.push('HARVESTER');
for (let i = 0; i < numDefenders; i++) roles.push('DEFENDER');
for (let i = 0; i < numScouts; i++) roles.push('SCOUT');
// Calculate targets for each role type
const attackerTarget = this.getAttackerTarget(enemyBots, state.cores, config, playerInfo.id);
const harvesterTargets = this.getHarvesterTargets(energyNodes, myBots, enemyBots, config);
const defenderTargets = myCores.map(c => c.position);
const scoutTarget = this.getScoutTarget(state, config);
// Greedy assignment: match each bot to nearest available role target
const assignments: RoleAssignment[] = [];
const usedBots = new Set<number>();
const usedRoles = new Set<number>();
// Sort bots by distance to their preferred role targets
const botsWithScores = myBots.map((bot, idx) => {
let bestRole: BotRole = 'ATTACKER';
let bestDist = Infinity;
// Score each role type for this bot
if (roles.includes('ATTACKER') && attackerTarget) {
const dist = distance2(bot.position, attackerTarget, config.rows, config.cols);
if (dist < bestDist) { bestDist = dist; bestRole = 'ATTACKER'; }
}
if (roles.includes('HARVESTER') && harvesterTargets.length > 0) {
const nearestHarvester = harvesterTargets.reduce((min, t) =>
distance2(bot.position, t, config.rows, config.cols) < distance2(bot.position, min, config.rows, config.cols) ? t : min
);
const dist = distance2(bot.position, nearestHarvester, config.rows, config.cols);
if (dist < bestDist) { bestDist = dist; bestRole = 'HARVESTER'; }
}
if (roles.includes('DEFENDER') && defenderTargets.length > 0) {
const nearestDefender = defenderTargets.reduce((min, t) =>
distance2(bot.position, t, config.rows, config.cols) < distance2(bot.position, min, config.rows, config.cols) ? t : min
);
const dist = distance2(bot.position, nearestDefender, config.rows, config.cols);
if (dist < bestDist) { bestDist = dist; bestRole = 'DEFENDER'; }
}
if (roles.includes('SCOUT') && scoutTarget) {
const dist = distance2(bot.position, scoutTarget, config.rows, config.cols);
if (dist < bestDist) { bestDist = dist; bestRole = 'SCOUT'; }
}
return { bot, role: bestRole, dist: bestDist, idx };
});
// Assign bots greedily by closeness to their preferred role
botsWithScores.sort((a, b) => a.dist - b.dist);
for (const { bot, role, idx } of botsWithScores) {
if (usedBots.has(idx)) continue;
// Check if we still need this role
const roleIndex = roles.indexOf(role);
if (roleIndex === -1) continue;
usedBots.add(idx);
roles.splice(roleIndex, 1);
const target = this.getTargetForRole(role, bot, attackerTarget, harvesterTargets, defenderTargets, scoutTarget, config);
assignments.push({ bot, role, target });
}
return assignments;
}
/**
* Get target for attacker role (weakest enemy cluster)
*/
private getAttackerTarget(enemyBots: VisibleBot[], allCores: VisibleCore[], config: GameConfig, myId: number): Position | null {
if (enemyBots.length === 0) {
// No enemies visible - target nearest enemy core
const enemyCores = allCores.filter(c => c.owner !== myId);
if (enemyCores.length > 0) {
return enemyCores[0].position;
}
return { row: Math.floor(config.rows / 2), col: Math.floor(config.cols / 2) };
}
// Find weakest cluster (fewest bots, nearest)
const clusters = this.clusterBots(enemyBots, config);
if (clusters.length === 0) {
return enemyBots[0].position;
}
// Sort by size (ascending) then distance
const myBotPos = { row: 0, col: 0 }; // Placeholder - will use actual bot position in assignment
clusters.sort((a, b) => {
if (a.length !== b.length) return a.length - b.length;
const distA = distance2(myBotPos, a[0].position, config.rows, config.cols);
const distB = distance2(myBotPos, b[0].position, config.rows, config.cols);
return distA - distB;
});
return this.calculateCenter(clusters[0].map(b => b.position), config);
}
/**
* Get targets for harvesters (nearest uncontested energy nodes)
*/
private getHarvesterTargets(energyNodes: Position[], myBots: VisibleBot[], enemyBots: VisibleBot[], config: GameConfig): Position[] {
if (energyNodes.length === 0) return [];
// Filter out contested energy (near enemies)
const uncontested = energyNodes.filter(energy => {
return !enemyBots.some(bot =>
distance2(energy, bot.position, config.rows, config.cols) <= config.vision_radius2
);
});
return uncontested.length > 0 ? uncontested : energyNodes;
}
/**
* Get target for scout role (unexplored region)
*/
private getScoutTarget(state: GameState, config: GameConfig): Position {
// Find a position that hasn't been seen recently
const candidates: Position[] = [];
for (let r = 0; r < config.rows; r += 3) {
for (let c = 0; c < config.cols; c += 3) {
const key = posKey({ row: r, col: c });
if (!this.seenPositions.has(key)) {
candidates.push({ row: r, col: c });
}
}
}
if (candidates.length > 0) {
// Return random unseen position
return candidates[Math.floor(Math.random() * candidates.length)];
}
// All seen - return center
return { row: Math.floor(config.rows / 2), col: Math.floor(config.cols / 2) };
}
/**
* Get target position for a specific role
*/
private getTargetForRole(
role: BotRole,
bot: VisibleBot,
attackerTarget: Position | null,
harvesterTargets: Position[],
defenderTargets: Position[],
scoutTarget: Position | null,
config: GameConfig
): Position | null {
switch (role) {
case 'ATTACKER':
return attackerTarget;
case 'HARVESTER':
if (harvesterTargets.length === 0) return null;
return harvesterTargets.reduce((min, t) =>
distance2(bot.position, t, config.rows, config.cols) < distance2(bot.position, min, config.rows, config.cols) ? t : min
);
case 'DEFENDER':
if (defenderTargets.length === 0) return null;
return defenderTargets.reduce((min, t) =>
distance2(bot.position, t, config.rows, config.cols) < distance2(bot.position, min, config.rows, config.cols) ? t : min
);
case 'SCOUT':
return scoutTarget;
}
}
/**
* Compute move for a bot based on its assigned role
*/
private computeMoveForRole(
assignment: RoleAssignment,
walls: Set<string>,
energySet: Set<string>,
enemyPositions: Map<string, VisibleBot>,
myCores: VisibleCore[],
state: GameState,
assignedTargets: Set<string>
): Move | null {
const { bot, role, target } = assignment;
const config = state.config;
const rows = config.rows;
const cols = config.cols;
// Zone awareness - survival priority
if (state.zone && state.zone.active) {
const distToZoneCenter2 = distance2(bot.position, state.zone.center, rows, cols);
const safetyMargin2 = 4;
if (distToZoneCenter2 >= state.zone.radius * state.zone.radius - safetyMargin2) {
return this.moveTowardPosition(bot, state.zone.center, walls, rows, cols);
}
}
switch (role) {
case 'ATTACKER':
return this.computeAttackerMove(bot, target, enemyPositions, walls, state);
case 'HARVESTER':
return this.computeHarvesterMove(bot, target, energySet, walls, state);
case 'DEFENDER':
return this.computeDefenderMove(bot, target, enemyPositions, walls, state);
case 'SCOUT':
return this.computeScoutMove(bot, target, walls, state);
}
}
/**
* Attacker: rush toward weakest enemy cluster
*/
private computeAttackerMove(
bot: VisibleBot,
target: Position | null,
enemyPositions: Map<string, VisibleBot>,
walls: Set<string>,
state: GameState
): Move | null {
if (!target) return null;
const config = state.config;
const rows = config.rows;
const cols = config.cols;
let bestDir: Direction | null = null;
let bestScore = -Infinity;
for (const dir of ALL_DIRECTIONS) {
const newPos = moveToward(bot.position, dir, rows, cols);
const newPosKey = posKey(newPos);
if (walls.has(newPosKey) || enemyPositions.has(newPosKey)) {
continue;
}
let score = 0;
// Primary: move toward target
const distToTarget = distance2(newPos, target, rows, cols);
const currentDistToTarget = distance2(bot.position, target, rows, cols);
score += (currentDistToTarget - distToTarget) * 10;
// Secondary: avoid getting surrounded
const nearbyEnemies = this.countNearbyEnemies(newPos, enemyPositions, config);
score -= nearbyEnemies * 5;
// Tertiary: prefer attack range to target
if (distToTarget <= config.attack_radius2 && distToTarget > 0) {
score += 20;
}
if (score > bestScore) {
bestScore = score;
bestDir = dir;
}
}
if (bestDir) {
return { position: bot.position, direction: bestDir };
}
return null;
}
/**
* Harvester: BFS toward nearest uncontested energy node
*/
private computeHarvesterMove(
bot: VisibleBot,
target: Position | null,
energySet: Set<string>,
walls: Set<string>,
state: GameState
): Move | null {
if (!target) return null;
const config = state.config;
const rows = config.rows;
const cols = config.cols;
// Check if already on energy
if (energySet.has(posKey(bot.position))) {
// Stay put to collect
return null;
}
return this.moveTowardPosition(bot, target, walls, rows, cols);
}
/**
* Defender: stay near own core, intercept enemies
*/
private computeDefenderMove(
bot: VisibleBot,
target: Position | null,
enemyPositions: Map<string, VisibleBot>,
walls: Set<string>,
state: GameState
): Move | null {
if (!target) return null;
const config = state.config;
const rows = config.rows;
const cols = config.cols;
const DEFEND_RADIUS2 = 16; // 4 tiles
let bestDir: Direction | null = null;
let bestScore = -Infinity;
for (const dir of ALL_DIRECTIONS) {
const newPos = moveToward(bot.position, dir, rows, cols);
const newPosKey = posKey(newPos);
if (walls.has(newPosKey) || enemyPositions.has(newPosKey)) {
continue;
}
let score = 0;
// Primary: stay within defend radius of core
const distToCore2 = distance2(newPos, target, rows, cols);
if (distToCore2 <= DEFEND_RADIUS2) {
score += 50;
} else {
score -= (distToCore2 - DEFEND_RADIUS2) * 10;
}
// Secondary: move toward nearby enemies to intercept
let nearestEnemyDist = Infinity;
for (const enemy of enemyPositions.values()) {
const dist = distance2(newPos, enemy.position, rows, cols);
if (dist < nearestEnemyDist) {
nearestEnemyDist = dist;
}
}
if (nearestEnemyDist < Infinity) {
// Bonus for positioning between core and enemy
const currentNearestDist = this.minDistanceToEnemies(bot.position, enemyPositions, rows, cols);
if (nearestEnemyDist <= config.attack_radius2 && nearestEnemyDist > 0) {
score += 30; // In attack range
}
score += (currentNearestDist - nearestEnemyDist) * 2; // Move toward enemies
}
if (score > bestScore) {
bestScore = score;
bestDir = dir;
}
}
if (bestDir) {
return { position: bot.position, direction: bestDir };
}
return null;
}
/**
* Scout: move toward unexplored region
*/
private computeScoutMove(
bot: VisibleBot,
target: Position | null,
walls: Set<string>,
state: GameState
): Move | null {
if (!target) return null;
const config = state.config;
const rows = config.rows;
const cols = config.cols;
return this.moveTowardPosition(bot, target, walls, rows, cols);
}
/**
* Generic move-toward-position logic
*/
private moveTowardPosition(
bot: VisibleBot,
target: Position,
walls: Set<string>,
rows: number,
cols: number
): Move | null {
let bestDir: Direction | null = null;
let bestDist2 = Infinity;
for (const dir of ALL_DIRECTIONS) {
const newPos = moveToward(bot.position, dir, rows, cols);
const newPosKey = posKey(newPos);
if (walls.has(newPosKey)) {
continue;
}
const dist2 = distance2(newPos, target, rows, cols);
if (dist2 < bestDist2) {
bestDist2 = dist2;
bestDir = dir;
}
}
if (bestDir) {
return { position: bot.position, direction: bestDir };
}
return null;
}
/**
* Cluster nearby bots into groups
*/
private clusterBots(bots: VisibleBot[], config: GameConfig): VisibleBot[][] {
const clusters: VisibleBot[][] = [];
const used = new Set<string>();
for (const bot of bots) {
const key = posKey(bot.position);
if (used.has(key)) continue;
const cluster: VisibleBot[] = [bot];
used.add(key);
// Find nearby bots
for (const other of bots) {
if (posKey(other.position) === key) continue;
if (used.has(posKey(other.position))) continue;
const dist2 = distance2(bot.position, other.position, config.rows, config.cols);
if (dist2 <= 9) { // 3 tiles radius
cluster.push(other);
used.add(posKey(other.position));
}
}
clusters.push(cluster);
}
return clusters;
}
/**
* Calculate center of mass of positions
*/
private calculateCenter(positions: Position[], config: GameConfig): Position {
if (positions.length === 0) {
return { row: Math.floor(config.rows / 2), col: Math.floor(config.cols / 2) };
}
let sumSinRow = 0, sumCosRow = 0;
let sumSinCol = 0, sumCosCol = 0;
const rowScale = (2 * Math.PI) / config.rows;
const colScale = (2 * Math.PI) / config.cols;
for (const pos of positions) {
sumSinRow += Math.sin(pos.row * rowScale);
sumCosRow += Math.cos(pos.row * rowScale);
sumSinCol += Math.sin(pos.col * colScale);
sumCosCol += Math.cos(pos.col * colScale);
}
const avgRow = Math.atan2(sumSinRow / positions.length, sumCosRow / positions.length) / rowScale;
const avgCol = Math.atan2(sumSinCol / positions.length, sumCosCol / positions.length) / colScale;
return {
row: ((Math.floor(avgRow) % config.rows) + config.rows) % config.rows,
col: ((Math.floor(avgCol) % config.cols) + config.cols) % config.cols,
};
}
/**
* Count nearby enemies
*/
private countNearbyEnemies(pos: Position, enemyPositions: Map<string, VisibleBot>, config: GameConfig): number {
let count = 0;
for (const enemy of enemyPositions.values()) {
const dist2 = distance2(pos, enemy.position, config.rows, config.cols);
if (dist2 <= config.vision_radius2) {
count++;
}
}
return count;
}
/**
* Get minimum distance to any enemy
*/
private minDistanceToEnemies(pos: Position, enemyPositions: Map<string, VisibleBot>, rows: number, cols: number): number {
let minDist = Infinity;
for (const enemy of enemyPositions.values()) {
const dist = distance2(pos, enemy.position, rows, cols);
if (dist < minDist) {
minDist = dist;
}
}
return minDist;
}
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,27 +1,28 @@
# AI Code Battle - Bot Host Deployment
# Runs all 16 strategy bots as a single deployable unit
# Runs all 17 strategy bots as a single deployable unit
#
# Usage:
# docker-compose -f docker-compose.bots.yml up -d
#
# Environment variables (set in .env or pass directly):
# BOT_SECRET_RANDOM - Secret for RandomBot
# BOT_SECRET_GATHERER - Secret for GathererBot
# BOT_SECRET_RUSHER - Secret for RusherBot
# BOT_SECRET_GUARDIAN - Secret for GuardianBot
# BOT_SECRET_SWARM - Secret for SwarmBot
# BOT_SECRET_HUNTER - Secret for HunterBot
# BOT_SECRET_DEFENDER - Secret for DefenderBot
# BOT_SECRET_SCOUT - Secret for ScoutBot
# BOT_SECRET_FARMER - Secret for FarmerBot
# BOT_SECRET_PACIFIST - Secret for PacifistBot
# BOT_SECRET_PHALANX - Secret for PhalanxBot
# BOT_SECRET_RAIDER - Secret for RaiderBot
# BOT_SECRET_NOMAD - Secret for NomadBot
# BOT_SECRET_OPPORTUNIST - Secret for OpportunistBot
# BOT_SECRET_ASSASSIN - Secret for AssassinBot
# BOT_SECRET_KAMIKAZE - Secret for KamikazeBot
# BOT_SECRET_ECONOMIST - Secret for EconomistBot
# BOT_SECRET_RANDOM - Secret for RandomBot
# BOT_SECRET_GATHERER - Secret for GathererBot
# BOT_SECRET_RUSHER - Secret for RusherBot
# BOT_SECRET_GUARDIAN - Secret for GuardianBot
# BOT_SECRET_SWARM - Secret for SwarmBot
# BOT_SECRET_HUNTER - Secret for HunterBot
# BOT_SECRET_DEFENDER - Secret for DefenderBot
# BOT_SECRET_SCOUT - Secret for ScoutBot
# BOT_SECRET_FARMER - Secret for FarmerBot
# BOT_SECRET_PACIFIST - Secret for PacifistBot
# BOT_SECRET_PHALANX - Secret for PhalanxBot
# BOT_SECRET_RAIDER - Secret for RaiderBot
# BOT_SECRET_NOMAD - Secret for NomadBot
# BOT_SECRET_OPPORTUNIST - Secret for OpportunistBot
# BOT_SECRET_ASSASSIN - Secret for AssassinBot
# BOT_SECRET_KAMIKAZE - Secret for KamikazeBot
# BOT_SECRET_ECONOMIST - Secret for EconomistBot
# BOT_SECRET_COORDINATOR - Secret for CoordinatorBot
services:
random:
@ -297,3 +298,19 @@ services:
timeout: 5s
retries: 3
restart: unless-stopped
coordinator:
build:
context: ./bots/coordinator
dockerfile: Dockerfile
ports:
- "8098:8084"
environment:
- BOT_PORT=8084
- BOT_SECRET=${BOT_SECRET_COORDINATOR:-dev-secret-coordinator}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8084/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped