From 164fcd225b8d5d0c6179bc1ea3c0ead92004efb3 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 8 May 2026 10:29:49 -0400 Subject: [PATCH] feat(starter-typescript): add TypeScript/Node.js starter kit with Fastify - Fastify HTTP server with HMAC-SHA256 authentication - Full TypeScript type definitions for game protocol - Grid utilities: toroidal distance, BFS, neighbors - HMAC signing/verification via Node.js crypto - Multi-stage Dockerfile for production builds - GitHub Actions workflow for CI/CD - Placeholder strategy that moves toward energy - ES modules with Node.js 20+ support --- starters/typescript/.dockerignore | 8 + .../typescript/.github/workflows/build.yml | 29 + starters/typescript/.gitignore | 4 + starters/typescript/Dockerfile | 45 ++ starters/typescript/README.md | 147 +++++ starters/typescript/package-lock.json | 614 ++++++++++++++++++ starters/typescript/package.json | 25 + starters/typescript/src/auth.ts | 115 ++++ starters/typescript/src/grid.ts | 196 ++++++ starters/typescript/src/index.ts | 155 +++++ starters/typescript/src/strategy.ts | 132 ++++ starters/typescript/src/types.ts | 144 ++++ starters/typescript/tsconfig.json | 20 + 13 files changed, 1634 insertions(+) create mode 100644 starters/typescript/.dockerignore create mode 100644 starters/typescript/.github/workflows/build.yml create mode 100644 starters/typescript/.gitignore create mode 100644 starters/typescript/Dockerfile create mode 100644 starters/typescript/README.md create mode 100644 starters/typescript/package-lock.json create mode 100644 starters/typescript/package.json create mode 100644 starters/typescript/src/auth.ts create mode 100644 starters/typescript/src/grid.ts create mode 100644 starters/typescript/src/index.ts create mode 100644 starters/typescript/src/strategy.ts create mode 100644 starters/typescript/src/types.ts create mode 100644 starters/typescript/tsconfig.json diff --git a/starters/typescript/.dockerignore b/starters/typescript/.dockerignore new file mode 100644 index 0000000..ced8618 --- /dev/null +++ b/starters/typescript/.dockerignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.DS_Store +.git/ +.github/ +.gitignore +README.md diff --git a/starters/typescript/.github/workflows/build.yml b/starters/typescript/.github/workflows/build.yml new file mode 100644 index 0000000..217496e --- /dev/null +++ b/starters/typescript/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build and Push + +on: + push: + branches: [main] + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v5 + with: + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/starters/typescript/.gitignore b/starters/typescript/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/starters/typescript/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/starters/typescript/Dockerfile b/starters/typescript/Dockerfile new file mode 100644 index 0000000..eaf1485 --- /dev/null +++ b/starters/typescript/Dockerfile @@ -0,0 +1,45 @@ +# AI Code Battle - TypeScript Starter Bot +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install all dependencies (including devDependencies for TypeScript build) +RUN npm ci + +# Copy source code +COPY src/ ./src/ +COPY tsconfig.json ./ + +# Build TypeScript +RUN npx tsc + +# Production stage +FROM node:22-alpine + +WORKDIR /app + +# Copy only production dependencies and built code +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist + +# Set non-root user for security +RUN addgroup -g 1001 -S acb && \ + adduser -S -D -H -u 1001 -s /sbin/nologin -G acb -g acb acb +USER acb + +# Expose bot port +EXPOSE 8080 + +# Set required environment variable (default for dev) +ENV BOT_SECRET=dev-secret + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8080/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })" + +# Start the bot +CMD ["node", "dist/index.js"] diff --git a/starters/typescript/README.md b/starters/typescript/README.md new file mode 100644 index 0000000..6a445e1 --- /dev/null +++ b/starters/typescript/README.md @@ -0,0 +1,147 @@ +# acb-starter-typescript + +TypeScript/Node.js starter kit for [AI Code Battle](https://aicodebattle.com) — a competitive bot programming platform. + +Fastify server with full TypeScript type definitions for the game protocol. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Build TypeScript +npm run build + +# Run locally (requires BOT_SECRET) +BOT_SECRET=your-secret npm start + +# Or run in development mode (builds and starts) +npm run dev +``` + +## Run with Docker + +```bash +# Build image +docker build -t my-ts-bot . + +# Run container +docker run -e BOT_SECRET=your-secret -p 8080:8080 my-ts-bot +``` + +## Register Your Bot + +Once your bot is deployed and accessible via HTTPS: + +```bash +curl -X POST https://api.aicodebattle.com/api/register \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-ts-bot", + "endpoint_url": "https://my-bot.example.com", + "owner": "your-name", + "description": "My awesome TypeScript bot" + }' +``` + +Save the `bot_id` and `shared_secret` from the response — the secret is shown only once. + +## Project Structure + +``` +src/ +├── index.ts # Fastify HTTP server, HMAC auth, request handling +├── types.ts # Complete TypeScript type definitions for the protocol +├── auth.ts # HMAC signing/verification utilities +├── strategy.ts # Bot strategy - implement your logic here +└── grid.ts # Toroidal grid utilities (distance, BFS, neighbors) +package.json +tsconfig.json +Dockerfile +``` + +## Type Definitions + +The `src/types.ts` file contains complete TypeScript definitions for: + +- `GameState` - Fog-filtered game state sent each turn +- `GameConfig` - Match configuration parameters +- `PlayerInfo` - Your player information (energy, score) +- `Move` - Movement order for a single bot +- `MoveResponse` - Response format with optional debug telemetry +- `Direction` - Cardinal direction type ("N" | "E" | "S" | "W") + +## Grid Helpers + +`src/grid.ts` provides utility functions for the toroidal grid: + +- `toroidalManhattan()` - Manhattan distance with wrap-around +- `toroidalChebyshev()` - Chebyshev distance with wrap-around +- `toroidalDistanceSquared()` - Squared Euclidean distance (faster for comparisons) +- `neighbors()` - 8-directional neighbors with wrap +- `cardinalNeighbors()` - 4 cardinal neighbors with direction labels +- `bfs()` - BFS pathfinding, returns path or null +- `findNearest()` - Find closest target from a list + +## Customization + +Edit `computeMoves()` in `src/strategy.ts` to implement your strategy. + +The `state` object provides: + +- `state.bots` - All visible bots (yours and enemies) +- `state.energy` - Visible energy pickup locations +- `state.cores` - Visible core positions +- `state.walls` - Visible wall positions +- `state.you.energy` - Your current energy count +- `state.you.score` - Your current score +- `state.config` - Match parameters (grid size, etc.) + +Return an array of `Move` objects, each with: +- `row`, `col` - Your bot's current position +- `direction` - One of "N", "E", "S", or "W" + +Bots not included in the response stay in place. + +## Debug Telemetry + +Optional debug info can be included in your response for replay visualization: + +```typescript +const response: MoveResponse = { + moves: computedMoves, + debug: { + reasoning: "Moving toward energy at (15, 20)", + targets: [ + { row: 15, col: 20, label: "energy", priority: 0.9 } + ], + values: { + energy_reserves: state.you.energy, + mode: "gathering" + } + } +}; +``` + +Debug data is stored in replays but never parsed by the engine. + +## Protocol + +- **Endpoint:** `POST /turn` — receives game state JSON, returns moves JSON +- **Health:** `GET /health` — must return 200 (used during registration) +- **Timeout:** 3 seconds per turn +- **Auth:** HMAC-SHA256 via `X-ACB-Signature` header + +## Development + +```bash +# Type checking without emitting +npm run typecheck + +# Build for production +npm run build + +# Start production server +BOT_SECRET=dev-secret npm start +``` diff --git a/starters/typescript/package-lock.json b/starters/typescript/package-lock.json new file mode 100644 index 0000000..0087478 --- /dev/null +++ b/starters/typescript/package-lock.json @@ -0,0 +1,614 @@ +{ + "name": "acb-starter-typescript", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acb-starter-typescript", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fastify": "^5.2.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "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 + } + } +} diff --git a/starters/typescript/package.json b/starters/typescript/package.json new file mode 100644 index 0000000..f62d50e --- /dev/null +++ b/starters/typescript/package.json @@ -0,0 +1,25 @@ +{ + "name": "acb-starter-typescript", + "version": "1.0.0", + "description": "TypeScript/Node.js starter kit for AI Code Battle", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "keywords": ["ai-code-battle", "bot", "game", "typescript"], + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "fastify": "^5.2.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/starters/typescript/src/auth.ts b/starters/typescript/src/auth.ts new file mode 100644 index 0000000..e92efbf --- /dev/null +++ b/starters/typescript/src/auth.ts @@ -0,0 +1,115 @@ +/** + * AI Code Battle - HMAC Authentication + * + * Implements HMAC-SHA256 signing and verification for the game protocol. + */ + +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Verify the HMAC signature on an incoming request. + * + * @param body - Raw request body as Buffer + * @param matchId - Match ID from header + * @param turn - Turn number from header + * @param timestamp - Timestamp from header + * @param signature - X-ACB-Signature header value + * @param secret - Your bot's shared secret + * @returns true if signature is valid + */ +export function verifySignature( + body: Buffer, + matchId: string, + turn: string, + timestamp: string, + signature: string, + secret: string +): boolean { + const bodyHash = createHash("sha256").update(body).digest("hex"); + const signingString = `${matchId}.${turn}.${timestamp}.${bodyHash}`; + const expected = createHmac("sha256", secret) + .update(signingString) + .digest("hex"); + + // Constant-time comparison to prevent timing attacks + try { + return timingSafeEqual( + Buffer.from(signature, "hex"), + Buffer.from(expected, "hex") + ); + } catch { + return false; + } +} + +/** + * Generate HMAC signature for a response. + * + * @param body - Response body as string or Buffer + * @param matchId - Match ID + * @param turn - Turn number + * @param secret - Your bot's shared secret + * @returns Hex-encoded signature + */ +export function signResponse( + body: string | Buffer, + matchId: string, + turn: number, + secret: string +): string { + const bodyStr = typeof body === "string" ? body : body.toString(); + const bodyHash = createHash("sha256").update(bodyStr).digest("hex"); + const signingString = `${matchId}.${turn}.${bodyHash}`; + return createHmac("sha256", secret).update(signingString).digest("hex"); +} + +/** + * Verify that a timestamp is within the allowed window. + * Prevents replay attacks. + * + * @param timestamp - Unix timestamp or ISO 8601 string + * @param windowSeconds - Allowed window (default: 30) + * @returns true if timestamp is valid + */ +export function verifyTimestamp( + timestamp: string, + windowSeconds: number = 30 +): boolean { + let ts: Date; + + // Try ISO 8601 first + const parsed = new Date(timestamp); + if (!isNaN(parsed.getTime())) { + ts = parsed; + } else { + // Try Unix timestamp (seconds since epoch) + const seconds = parseInt(timestamp, 10); + if (isNaN(seconds)) { + return false; + } + ts = new Date(seconds * 1000); + } + + const now = new Date(); + const diff = (now.getTime() - ts.getTime()) / 1000; + return diff >= -windowSeconds && diff <= windowSeconds; +} + +/** + * Extract auth headers from a Fastify request. + */ +export function getAuthHeaders(headers: Record): { + matchId: string; + turn: string; + timestamp: string; + botId: string; + signature: string; +} { + return { + matchId: headers["x-acb-match-id"] || "", + turn: headers["x-acb-turn"] || "0", + timestamp: headers["x-acb-timestamp"] || "", + botId: headers["x-acb-bot-id"] || "", + signature: headers["x-acb-signature"] || "", + }; +} diff --git a/starters/typescript/src/grid.ts b/starters/typescript/src/grid.ts new file mode 100644 index 0000000..ca017ed --- /dev/null +++ b/starters/typescript/src/grid.ts @@ -0,0 +1,196 @@ +/** + * AI Code Battle - Grid Utilities + * + * Toroidal grid distance calculations, neighbor enumeration, + * and BFS pathfinding. + */ + +import type { Position } from "./types.js"; + +/** + * Toroidal Manhattan distance (shortest path with wrap-around). + * + * @returns Distance in grid cells + */ +export function toroidalManhattan( + r1: number, + c1: number, + r2: number, + c2: number, + cols: number, + rows: number +): number { + const dr = Math.min(Math.abs(r1 - r2), rows - Math.abs(r1 - r2)); + const dc = Math.min(Math.abs(c1 - c2), cols - Math.abs(c1 - c2)); + return dr + dc; +} + +/** + * Toroidal Chebyshev distance (shortest path with wrap-around, 8-directional). + * + * @returns Distance in grid cells + */ +export function toroidalChebyshev( + r1: number, + c1: number, + r2: number, + c2: number, + cols: number, + rows: number +): number { + const dr = Math.min(Math.abs(r1 - r2), rows - Math.abs(r1 - r2)); + const dc = Math.min(Math.abs(c1 - c2), cols - Math.abs(c1 - c2)); + return Math.max(dr, dc); +} + +/** + * Toroidal squared Euclidean distance. + * Faster than sqrt for comparisons. + */ +export function toroidalDistanceSquared( + r1: number, + c1: number, + r2: number, + c2: number, + cols: number, + rows: number +): number { + const dr = Math.min(Math.abs(r1 - r2), rows - Math.abs(r1 - r2)); + const dc = Math.min(Math.abs(c1 - c2), cols - Math.abs(c1 - c2)); + return dr * dr + dc * dc; +} + +/** + * Get all 8 neighboring positions with toroidal wrap. + * + * @returns Array of [row, col] pairs + */ +export function neighbors( + row: number, + col: number, + rows: number, + cols: number +): Position[] { + const offsets = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1], + ]; + return offsets.map(([dr, dc]) => ({ + row: (row + dr + rows) % rows, + col: (col + dc + cols) % cols, + })); +} + +/** + * Get 4 cardinal neighboring positions (N, E, S, W). + */ +export function cardinalNeighbors( + row: number, + col: number, + rows: number, + cols: number +): Array<{ pos: Position; dir: "N" | "E" | "S" | "W" }> { + return [ + { pos: { row: (row - 1 + rows) % rows, col }, dir: "N" }, + { pos: { row, col: (col + 1) % cols }, dir: "E" }, + { pos: { row: (row + 1) % rows, col }, dir: "S" }, + { pos: { row, col: (col - 1 + cols) % cols }, dir: "W" }, + ]; +} + +/** + * BFS pathfinding on a toroidal grid. + * + * @param start - Starting position [row, col] + * @param goal - Goal position [row, col] + * @param passable - Function returning true if a tile is walkable + * @returns Path from start to goal (excluding start), or null if unreachable + */ +export function bfs( + start: Position, + goal: Position, + passable: (row: number, col: number) => boolean, + rows: number, + cols: number +): Position[] | null { + if (start.row === goal.row && start.col === goal.col) { + return []; + } + + const key = (r: number, c: number) => `${r},${c}`; + const visited = new Set([key(start.row, start.col)]); + + interface QueueItem { + pos: Position; + path: Position[]; + } + + const queue: QueueItem[] = [{ pos: start, path: [] }]; + + while (queue.length > 0) { + const { pos, path } = queue.shift()!; + + for (const next of neighbors(pos.row, pos.col, rows, cols)) { + const newPath = [...path, next]; + + if (next.row === goal.row && next.col === goal.col) { + return newPath; + } + + const k = key(next.row, next.col); + if (!visited.has(k) && passable(next.row, next.col)) { + visited.add(k); + queue.push({ pos: next, path: newPath }); + } + } + } + + return null; +} + +/** + * Find the nearest position from a list of targets. + * + * @returns The nearest target or null if targets is empty + */ +export function findNearest( + from: Position, + targets: Position[], + rows: number, + cols: number +): Position | null { + if (targets.length === 0) return null; + + let nearest = targets[0]; + let bestDist = toroidalDistanceSquared( + from.row, + from.col, + nearest.row, + nearest.col, + cols, + rows + ); + + for (let i = 1; i < targets.length; i++) { + const dist = toroidalDistanceSquared( + from.row, + from.col, + targets[i].row, + targets[i].col, + cols, + rows + ); + if (dist < bestDist) { + bestDist = dist; + nearest = targets[i]; + } + } + + return nearest; +} diff --git a/starters/typescript/src/index.ts b/starters/typescript/src/index.ts new file mode 100644 index 0000000..5befb44 --- /dev/null +++ b/starters/typescript/src/index.ts @@ -0,0 +1,155 @@ +/** + * AI Code Battle - TypeScript Starter Bot + * + * Fastify HTTP server with HMAC authentication for the AI Code Battle platform. + * + * Environment variables: + * BOT_SECRET - Your bot's shared secret (required) + * BOT_PORT - Port to listen on (default: 8080) + */ + +import Fastify, { FastifyRequest, FastifyReply } from "fastify"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { GameState, MoveResponse } from "./types.js"; +import { + verifySignature, + signResponse, + verifyTimestamp, + getAuthHeaders, +} from "./auth.js"; +import { computeMoves } from "./strategy.js"; + +const PORT = parseInt(process.env.BOT_PORT || "8080", 10); +const SECRET = process.env.BOT_SECRET || ""; + +if (!SECRET) { + console.error("ERROR: BOT_SECRET environment variable is required"); + process.exit(1); +} + +// Create Fastify instance with a custom content parser to capture raw body +const app = Fastify({ + logger: false, // Set to true for HTTP request logging +}); + +// Add a custom parser to store raw body string for signature verification +app.addContentTypeParser( + "application/json", + { parseAs: "string" }, + async ( + request: FastifyRequest, + body: string + ) => { + // Store raw body for signature verification + (request as any).rawBody = body; + // Also return parsed JSON for normal use + return JSON.parse(body); + } +); + +/** + * Health check endpoint - used during bot registration. + */ +app.get("/health", async (_request: FastifyRequest, reply: FastifyReply) => { + reply.type("text/plain").code(200); + return "OK"; +}); + +/** + * Main game turn endpoint. + * Receives game state JSON, computes moves, returns moves JSON. + */ +app.post("/turn", async (request: FastifyRequest, reply: FastifyReply) => { + // Get raw body as string for signature verification + const rawBody = (request as any).rawBody; + if (typeof rawBody !== "string") { + reply.type("text/plain").code(400); + return "Invalid request body"; + } + const bodyBuffer = Buffer.from(rawBody, "utf-8"); + + // Extract auth headers + const headers = request.headers as Record; + const { matchId, turn, timestamp, signature } = getAuthHeaders(headers); + + // Verify HMAC signature + if ( + !signature || + !verifySignature(bodyBuffer, matchId, turn, timestamp, signature, SECRET) + ) { + reply.type("text/plain").code(401); + return "Invalid signature"; + } + + // Verify timestamp (prevent replay attacks) + if (!verifyTimestamp(timestamp)) { + reply.type("text/plain").code(401); + return "Invalid timestamp"; + } + + // Parse game state JSON (already parsed by our custom parser) + const state: GameState = request.body as GameState; + + // Log match start (turn 0) + if (state.turn === 0) { + console.log( + `match=${state.match_id} ` + + `season_id=${state.config.season_id || "none"} ` + + `rules_version=${state.config.rules_version || "none"} ` + + `rows=${state.config.rows} cols=${state.config.cols}` + ); + } + + // Compute moves + const moves = computeMoves(state); + + // Build response + const responseBody: MoveResponse = { moves }; + const responseJson = JSON.stringify(responseBody); + + // Sign response + const responseSig = signResponse( + responseJson, + matchId, + parseInt(turn, 10), + SECRET + ); + + // Send response with signature header + reply + .code(200) + .header("Content-Type", "application/json") + .header("X-ACB-Signature", responseSig); + return responseJson; +}); + +/** + * Read package.json for version info + */ +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +let version = "unknown"; +try { + const pkg = JSON.parse( + await readFile(join(__dirname, "..", "package.json"), "utf-8") + ); + version = pkg.version; +} catch { + // Ignore +} + +/** + * Start the server + */ +const start = async () => { + try { + await app.listen({ port: PORT, host: "0.0.0.0" }); + console.log(`acb-starter-typescript v${version} listening on port ${PORT}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +}; + +start(); diff --git a/starters/typescript/src/strategy.ts b/starters/typescript/src/strategy.ts new file mode 100644 index 0000000..9d43a38 --- /dev/null +++ b/starters/typescript/src/strategy.ts @@ -0,0 +1,132 @@ +/** + * AI Code Battle - Strategy Implementation + * + * This is where you implement your bot's strategy. + * The computeMoves function is called each turn with the current game state. + */ + +import type { GameState, Move, Direction } from "./types.js"; +import { toroidalManhattan, cardinalNeighbors } from "./grid.js"; + +/** + * Compute moves for all your bots. + * + * This is a placeholder strategy that moves bots toward nearby energy. + * Replace this with your own strategy! + * + * @param state - Current game state (fog-filtered for your player) + * @returns Array of moves for your bots + */ +export function computeMoves(state: GameState): Move[] { + const moves: Move[] = []; + const { rows, cols } = state.config; + const myBotId = state.you.id; + + // Find all my bots + const myBots = state.bots.filter((b) => b.owner === myBotId); + + // For each bot, decide where to move + for (const bot of myBots) { + const move = decideBotMove(bot, state); + if (move) { + moves.push(move); + } + } + + return moves; +} + +/** + * Decide a single bot's move. + * + * @param bot - The bot to move + * @param state - Current game state + * @returns Move command, or undefined to hold position + */ +function decideBotMove( + bot: { row: number; col: number; owner: number }, + state: GameState +): Move | undefined { + const { rows, cols } = state.config; + + // If there's energy visible, move toward the nearest one + if (state.energy.length > 0) { + let bestDir: Direction | null = null; + let bestDist = Infinity; + + for (const { pos, dir } of cardinalNeighbors(bot.row, bot.col, rows, cols)) { + // Skip if there's a wall + if (state.walls.some((w) => w.row === pos.row && w.col === pos.col)) { + continue; + } + + // Find distance to nearest energy from this neighbor position + for (const e of state.energy) { + const dist = toroidalManhattan(pos.row, pos.col, e.row, e.col, cols, rows); + if (dist < bestDist) { + bestDist = dist; + bestDir = dir; + } + } + } + + if (bestDir) { + return { row: bot.row, col: bot.col, direction: bestDir }; + } + } + + // No energy nearby - move randomly or hold + // You can replace this with more sophisticated logic + return undefined; // Hold position +} + +/** + * Example: Check if a position is safe (no visible enemies nearby). + */ +export function isSafe( + row: number, + col: number, + state: GameState, + radius2: number = 5 +): boolean { + const { rows, cols } = state.config; + const myBotId = state.you.id; + + for (const enemy of state.bots) { + if (enemy.owner === myBotId) continue; + + const dr = Math.min(Math.abs(row - enemy.row), rows - Math.abs(row - enemy.row)); + const dc = Math.min(Math.abs(col - enemy.col), cols - Math.abs(col - enemy.col)); + const dist2 = dr * dr + dc * dc; + + if (dist2 <= radius2) { + return false; // Enemy nearby + } + } + + return true; +} + +/** + * Example: Find a position to gather energy safely. + */ +export function findSafeGatherTarget( + bot: { row: number; col: number }, + state: GameState +): { row: number; col: number } | null { + const { rows, cols } = state.config; + + for (const energy of state.energy) { + if (isSafe(energy.row, energy.col, state)) { + return energy; + } + } + + // No safe energy found - go to nearest anyway + if (state.energy.length > 0) { + // Simple approach: first energy in list + return state.energy[0]; + } + + return null; +} diff --git a/starters/typescript/src/types.ts b/starters/typescript/src/types.ts new file mode 100644 index 0000000..6adefc5 --- /dev/null +++ b/starters/typescript/src/types.ts @@ -0,0 +1,144 @@ +/** + * AI Code Battle - TypeScript Type Definitions + * + * Complete type definitions for the game protocol. + * All types match the JSON schema documented in the protocol spec. + */ + +/** + * Cardinal movement directions. + */ +export type Direction = "N" | "E" | "S" | "W"; + +/** All four cardinal directions. */ +export const ALL_DIRECTIONS: Direction[] = ["N", "E", "S", "W"]; + +/** + * Grid position (row, column). + */ +export interface Position { + row: number; + col: number; +} + +/** + * Match configuration parameters. + * These are identical for all players and do not change between turns. + */ +export interface GameConfig { + rows: number; + cols: number; + max_turns: number; + vision_radius2: number; + attack_radius2: number; + spawn_cost: number; + energy_interval: number; + season_id?: string; + rules_version?: number; + special_tiles?: string[]; +} + +/** + * Information about the current player (you). + */ +export interface PlayerInfo { + id: number; + energy: number; + score: number; +} + +/** + * A bot visible within fog of war. + */ +export interface VisibleBot { + row: number; + col: number; + owner: number; +} + +/** + * A core (spawn point) visible within fog of war. + */ +export interface VisibleCore { + row: number; + col: number; + owner: number; + active: boolean; +} + +/** + * Energy tile position. + */ +export interface EnergyTile { + row: number; + col: number; +} + +/** + * Game state sent by the engine each turn. + * Contains only tiles visible within the player's fog of war. + */ +export interface GameState { + match_id: string; + turn: number; + config: GameConfig; + you: PlayerInfo; + bots: VisibleBot[]; + energy: EnergyTile[]; + cores: VisibleCore[]; + walls: Position[]; + dead: VisibleBot[]; +} + +/** + * A movement order for a single bot. + * References the bot's current position and the direction to move. + */ +export interface Move { + row: number; + col: number; + direction: Direction; +} + +/** + * Optional debug telemetry for replay visualization. + * Stored in the replay but never parsed by the engine. + * Max 10KB per turn. + */ +export interface DebugTelemetry { + reasoning?: string; + targets?: DebugTarget[]; + values?: Record; + heatmap?: DebugHeatmap; +} + +export interface DebugTarget { + row: number; + col: number; + label: string; + priority: number; +} + +export interface DebugHeatmap { + name: string; + data: number[][]; +} + +/** + * Response sent back to the engine. + */ +export interface MoveResponse { + moves: Move[]; + debug?: DebugTelemetry; +} + +/** + * Authentication headers from incoming requests. + */ +export interface AuthHeaders { + "x-acb-match-id": string; + "x-acb-turn": string; + "x-acb-timestamp": string; + "x-acb-bot-id": string; + "x-acb-signature": string; +} diff --git a/starters/typescript/tsconfig.json b/starters/typescript/tsconfig.json new file mode 100644 index 0000000..31becd5 --- /dev/null +++ b/starters/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "ts-node": { + "esm": true + } +}