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
This commit is contained in:
parent
fb6eeaed6a
commit
164fcd225b
13 changed files with 1634 additions and 0 deletions
8
starters/typescript/.dockerignore
Normal file
8
starters/typescript/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
README.md
|
||||
29
starters/typescript/.github/workflows/build.yml
vendored
Normal file
29
starters/typescript/.github/workflows/build.yml
vendored
Normal file
|
|
@ -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 }}
|
||||
4
starters/typescript/.gitignore
vendored
Normal file
4
starters/typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
45
starters/typescript/Dockerfile
Normal file
45
starters/typescript/Dockerfile
Normal file
|
|
@ -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"]
|
||||
147
starters/typescript/README.md
Normal file
147
starters/typescript/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
614
starters/typescript/package-lock.json
generated
Normal file
614
starters/typescript/package-lock.json
generated
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
25
starters/typescript/package.json
Normal file
25
starters/typescript/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
115
starters/typescript/src/auth.ts
Normal file
115
starters/typescript/src/auth.ts
Normal file
|
|
@ -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<string, string>): {
|
||||
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"] || "",
|
||||
};
|
||||
}
|
||||
196
starters/typescript/src/grid.ts
Normal file
196
starters/typescript/src/grid.ts
Normal file
|
|
@ -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<string>([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;
|
||||
}
|
||||
155
starters/typescript/src/index.ts
Normal file
155
starters/typescript/src/index.ts
Normal file
|
|
@ -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<string, string>;
|
||||
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();
|
||||
132
starters/typescript/src/strategy.ts
Normal file
132
starters/typescript/src/strategy.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
144
starters/typescript/src/types.ts
Normal file
144
starters/typescript/src/types.ts
Normal file
|
|
@ -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<string, string | number>;
|
||||
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;
|
||||
}
|
||||
20
starters/typescript/tsconfig.json
Normal file
20
starters/typescript/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue