diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index f1982cb..60d440c 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -1668c66917fd109402238179b75633a0b8ea549f +f0d4e661d71735c512e375c77fe404268a3a157a diff --git a/bots/coordinator/Dockerfile b/bots/coordinator/Dockerfile new file mode 100644 index 0000000..9a6b6a4 --- /dev/null +++ b/bots/coordinator/Dockerfile @@ -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"] diff --git a/bots/coordinator/package-lock.json b/bots/coordinator/package-lock.json new file mode 100644 index 0000000..da65ec1 --- /dev/null +++ b/bots/coordinator/package-lock.json @@ -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" + } + } + } +} diff --git a/bots/coordinator/package.json b/bots/coordinator/package.json new file mode 100644 index 0000000..3e27e82 --- /dev/null +++ b/bots/coordinator/package.json @@ -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" + } +} diff --git a/bots/coordinator/src/game.ts b/bots/coordinator/src/game.ts new file mode 100644 index 0000000..7a3107b --- /dev/null +++ b/bots/coordinator/src/game.ts @@ -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 { + return new Set(positions.map(posKey)); +} diff --git a/bots/coordinator/src/index.ts b/bots/coordinator/src/index.ts new file mode 100644 index 0000000..ee352f2 --- /dev/null +++ b/bots/coordinator/src/index.ts @@ -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 { + // 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}`); +}); diff --git a/bots/coordinator/src/strategy.ts b/bots/coordinator/src/strategy.ts new file mode 100644 index 0000000..3cad074 --- /dev/null +++ b/bots/coordinator/src/strategy.ts @@ -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(); + + /** + * 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(); + 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(); + + 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(); + const usedRoles = new Set(); + + // 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, + energySet: Set, + enemyPositions: Map, + myCores: VisibleCore[], + state: GameState, + assignedTargets: Set + ): 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, + walls: Set, + 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, + walls: Set, + 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, + walls: Set, + 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, + 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, + 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(); + + 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, 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, 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; + } +} diff --git a/bots/coordinator/tsconfig.json b/bots/coordinator/tsconfig.json new file mode 100644 index 0000000..5a3b015 --- /dev/null +++ b/bots/coordinator/tsconfig.json @@ -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"] +} diff --git a/docker-compose.bots.yml b/docker-compose.bots.yml index 43a6776..3e616dc 100644 --- a/docker-compose.bots.yml +++ b/docker-compose.bots.yml @@ -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