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:
parent
f0d4e661d7
commit
8af1c03aca
9 changed files with 1239 additions and 19 deletions
|
|
@ -1 +1 @@
|
|||
1668c66917fd109402238179b75633a0b8ea549f
|
||||
f0d4e661d71735c512e375c77fe404268a3a157a
|
||||
|
|
|
|||
23
bots/coordinator/Dockerfile
Normal file
23
bots/coordinator/Dockerfile
Normal 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
235
bots/coordinator/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
bots/coordinator/package.json
Normal file
17
bots/coordinator/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
110
bots/coordinator/src/game.ts
Normal file
110
bots/coordinator/src/game.ts
Normal 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));
|
||||
}
|
||||
124
bots/coordinator/src/index.ts
Normal file
124
bots/coordinator/src/index.ts
Normal 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}`);
|
||||
});
|
||||
676
bots/coordinator/src/strategy.ts
Normal file
676
bots/coordinator/src/strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
bots/coordinator/tsconfig.json
Normal file
18
bots/coordinator/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue