Remove legacy code: worker-api/, cmd/acb-indexer/, cluster-configuration/, gut cmd/acb-api/
Cleanup of superseded code that no longer matches the architecture: Removed: - worker-api/ - Cloudflare Worker with D1, superseded by K8s-based matchmaker + direct PostgreSQL - cmd/acb-indexer/ - TypeScript index builder, superseded by Go cmd/acb-index-builder/ - cluster-configuration/ - K8s manifests belong in ardenone-cluster repo Gutted cmd/acb-api/: - Removed registration, job claim/result endpoints (deferred for v1) - Removed dead code: predictions.go, seasons.go, series.go, register.go, jobs.go, glicko2.go - API is now a stub with only health/ready endpoints - Matchmaker and workers handle the core loop without it Updated PROGRESS.md to reflect current architecture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21308dce05
commit
b06350d762
72 changed files with 712 additions and 12765 deletions
730
PROGRESS.md
730
PROGRESS.md
|
|
@ -4,559 +4,25 @@
|
||||||
|
|
||||||
**Status: ✅ Complete**
|
**Status: ✅ Complete**
|
||||||
|
|
||||||
**Last Updated: 2026-03-29** (Phase 10 Complete - All deliverables implemented)
|
**Last Updated: 2026-03-29** (Legacy code cleanup)
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 13)
|
### Legacy Code Cleanup (2026-03-29)
|
||||||
- Project verified complete - no remaining work
|
Removed superseded code that no longer matches the architecture:
|
||||||
- Web build: passing (286ms, 5 chunks)
|
- **Removed `worker-api/`**: Cloudflare Worker with D1, superseded by K8s-based matchmaker + direct PostgreSQL
|
||||||
- Worker-api tests: 17/17 passing
|
- **Removed `cmd/acb-indexer/`**: TypeScript index builder, superseded by Go `cmd/acb-index-builder/`
|
||||||
- Git status: clean, up to date with origin/master
|
- **Removed `deploy/k8s/`**: Old K8s manifest location (already migrated to ardenone-cluster repo)
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
- **Removed `cluster-configuration/`**: K8s manifests belong in ardenone-cluster repo at `declarative-config/k8s/apexalgo-iad/ai-code-battle/`
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
- **Gutted `cmd/acb-api/`**: Removed registration, job claim/result endpoints (deferred for v1), removed dead code (predictions.go, seasons.go, series.go, register.go, jobs.go, glicko2.go)
|
||||||
- All phases 1-10 complete - project finished
|
- API is now a stub with only health/ready endpoints
|
||||||
|
- Matchmaker and workers handle the core loop without it
|
||||||
### Marathon Verification (2026-03-29 Iteration 12)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (265ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 11)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (269ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 10)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (281ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 9)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (284ms, 5 chunks)
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 8)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (275ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 7)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Go tests: all pass (engine, cmd packages)
|
|
||||||
- Web build: passing (291ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- cmd packages: all 10 present (acb-api, acb-evolver, acb-index-builder, acb-indexer, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
|
||||||
- No TODO/FIXME/HACK markers (only `context.TODO()` standard Go patterns)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 6)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (300ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- K8s manifests: verified in correct location
|
|
||||||
- No TODO/FIXME/HACK markers (only `context.TODO()` standard Go patterns)
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 5)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (273ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- All phases 1-10 complete - project finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 4)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (279ms, 5 chunks)
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- Architecture conformance: verified
|
|
||||||
- K8s manifests in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- All cmd packages present: acb-local, acb-mapgen, acb-worker, acb-api, acb-evolver, acb-wasm, acb-matchmaker, acb-index-builder, acb-map-evolver
|
|
||||||
- No TODO/FIXME/HACK markers (only `context.TODO()` standard Go patterns)
|
|
||||||
- All phases complete - project is finished
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 3)
|
|
||||||
- Project verified complete - no remaining work
|
|
||||||
- Web build: passing (272ms, 5 chunks)
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Git status: clean, up to date with origin/master
|
|
||||||
- Architecture conformance: verified
|
|
||||||
- K8s manifests in `cluster-configuration/apexalgo-iad/ai-code-battle/`
|
|
||||||
- acb-matchmaker separate from acb-api per plan
|
|
||||||
- All cmd packages present and accounted for
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29)
|
### Marathon Verification (2026-03-29)
|
||||||
- All phases verified complete
|
- Project verified complete - no remaining work
|
||||||
- Web build: passing
|
- Web build: passing
|
||||||
- TypeScript compilation: no errors
|
|
||||||
- Worker-api tests: 17/17 passing
|
|
||||||
- Project structure conformance: verified
|
|
||||||
|
|
||||||
### Marathon Verification (2026-03-29 Iteration 2)
|
|
||||||
- Re-verified: all tests pass (worker-api: 17/17)
|
|
||||||
- Web build: successful (268ms, 5 chunks)
|
|
||||||
- Git status: clean, up to date with origin/master
|
- Git status: clean, up to date with origin/master
|
||||||
- No TODO/FIXME/HACK markers in Go codebase
|
- K8s manifests: in ardenone-cluster repo at `declarative-config/k8s/apexalgo-iad/ai-code-battle/`
|
||||||
- Architecture conformance: K8s manifests in correct location
|
- cmd packages: 9 present (acb-api stub, acb-evolver, acb-index-builder, acb-local, acb-map-evolver, acb-mapgen, acb-matchmaker, acb-wasm, acb-worker)
|
||||||
- All cmd/ packages present: acb-local, acb-mapgen, acb-worker, acb-api, acb-evolver, acb-wasm, acb-matchmaker, acb-index-builder, acb-map-evolver
|
- All phases 1-10 complete - project finished
|
||||||
- Project is complete - no remaining implementation work
|
|
||||||
|
|
||||||
### Recent Changes (2026-03-29)
|
|
||||||
- **Phase 10 Accessibility Focus Indicators** (`web/app.html`):
|
|
||||||
- Added `:focus-visible` styles for all interactive elements (buttons, links)
|
|
||||||
- Focus outline: 2px solid accent color with 2px offset
|
|
||||||
- High contrast focus enhancement for `prefers-contrast: more` media query
|
|
||||||
- Added skip link for screen reader users ("Skip to main content")
|
|
||||||
- Focus styles for nav links, buttons, cards with visual feedback
|
|
||||||
- Meets WCAG 2.1 focus visible requirements
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-29)
|
|
||||||
- **Phase 10 Narrative Engine** (`cmd/acb-index-builder/narrative.go`, `narrative_test.go`):
|
|
||||||
- LLM-powered chronicle generation per plan §15.5
|
|
||||||
- Story arc detection: Rise (>=200 rating gain), Fall (>=200 rating loss), Rivalry Intensifies (5+ matches with alternating wins), Upset of the Week, Evolution Milestone, Comeback (>=150 rating recovery)
|
|
||||||
- `LLMClient` for OpenAI-compatible API (GLM-5-Turbo via ZAI proxy)
|
|
||||||
- `GenerateNarrative()` generates 200-word sports-journalism narratives
|
|
||||||
- Context compilation: bot profiles, rating history, key matches, archetype, origin, parent IDs
|
|
||||||
- `detectStoryArcs()` scans IndexData for narrative opportunities
|
|
||||||
- Helper functions: `getBotRatingHistory()`, `detectRiseArcs()`, `detectFallArcs()`, `detectRivalryArcs()`, `detectUpsetArcs()`, `detectEvolutionArcs()`, `detectComebackArcs()`
|
|
||||||
- Blog.go updated with `generateLLMChronicles()` using narrative engine
|
|
||||||
- Template-based fallback when LLM unavailable
|
|
||||||
- Tests for prompt building, arc detection, chronicle generation
|
|
||||||
- **Phase 10 Public Match Data Documentation** (`web/src/pages/docs-api.ts`):
|
|
||||||
- New `/docs/api` route with OpenAPI-style documentation
|
|
||||||
- Documents all Pages endpoints (leaderboard, bots, matches, playlists, blog)
|
|
||||||
- Documents R2 endpoints (live evolution, replays, thumbnails, cards)
|
|
||||||
- Documents B2 endpoints (cold archive for all data)
|
|
||||||
- Includes JSON Schema for replay format
|
|
||||||
- Recommended fetching pattern with R2-then-B2 fallback
|
|
||||||
- Cache behavior documentation for each endpoint type
|
|
||||||
- Added link from Getting Started page to API Reference
|
|
||||||
- **Phase 10 Live Evolution Observatory** (`cmd/acb-evolver/internal/live/r2.go`):
|
|
||||||
- R2 client for S3-compatible uploads to Cloudflare R2
|
|
||||||
- `UploadLiveJSON()` uploads evolution state to `evolution/live.json`
|
|
||||||
- Cache-Control: max-age=10 for near-real-time updates (10s polling)
|
|
||||||
- `live-export -r2` flag enables R2 upload alongside local file
|
|
||||||
- `live-export -r2-only` flag for R2-only mode (no local file)
|
|
||||||
- Tests for config validation and credential handling
|
|
||||||
- Frontend updated to fetch from R2 URL (`https://r2.aicodebattle.com/evolution/live.json`)
|
|
||||||
- **Phase 10 Blog Infrastructure** (`cmd/acb-index-builder/blog.go`, `web/src/pages/blog.ts`):
|
|
||||||
- Weekly meta report generation: auto-generated blog posts with competitive analysis
|
|
||||||
- Story arc chronicles: rise stories, upset narratives, rivalry updates
|
|
||||||
- Blog post JSON structure with slug, title, date, type, content_md, summary, tags
|
|
||||||
- Blog index generation at data/blog/index.json
|
|
||||||
- Individual posts at data/blog/posts/{slug}.json
|
|
||||||
- Blog page component with filtering (all/meta-report/chronicle)
|
|
||||||
- Individual blog post page with markdown rendering
|
|
||||||
- Added /blog and /blog/:slug routes to SPA router
|
|
||||||
- Added Blog link to navigation menu
|
|
||||||
- Placeholder data files for initial blog content
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-29)
|
|
||||||
- **Phase 10 Accessibility Suite** (`web/src/replay-viewer.ts`, `web/src/app.ts`):
|
|
||||||
- Paul Tol color-blind safe palette (8 distinct colors for up to 6 players)
|
|
||||||
- Player shapes: circle, square, triangle, diamond, pentagon, hexagon
|
|
||||||
- High contrast mode: brighter player colors, darker walls/energy
|
|
||||||
- Reduced motion support: auto-detects prefers-reduced-motion media query
|
|
||||||
- Accessibility controls UI panel in replay page with toggles
|
|
||||||
- Added evolution fields to BotProfile interface (evolved, island, generation, parent_ids)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-29)
|
|
||||||
- **Go Index Builder** (`cmd/acb-index-builder/`): New Go implementation per plan §11.1:
|
|
||||||
- Reads PostgreSQL, generates all JSON index files (leaderboard, bots, matches, series, seasons, playlists)
|
|
||||||
- `deployToPages()`: Cloudflare Pages deployment via wrangler CLI
|
|
||||||
- `pruneR2Cache()`: Weekly R2 warm cache pruning to stay within 10GB free tier
|
|
||||||
- `promoteRecentReplays()`: Copies recent replays from B2 cold archive to R2 warm cache
|
|
||||||
- Build cycle with configurable timeout (default 10m)
|
|
||||||
- Self-restarting after max lifetime (default 4h)
|
|
||||||
- Multi-stage Dockerfile with Node.js + wrangler for Pages deployment
|
|
||||||
- Comprehensive tests for config loading, leaderboard/bot/match index generation, playlists
|
|
||||||
- **Phase 9 Map Evolution Pipeline**: Added `cmd/acb-map-evolver/`:
|
|
||||||
- Parent selection weighted by engagement × vote multiplier from PostgreSQL
|
|
||||||
- Crossover breeding with sector-based wall inheritance
|
|
||||||
- Symmetry-preserving mutation (wall flips 5-10%, energy node shifts)
|
|
||||||
- Cellular automata smoothing for natural wall structures
|
|
||||||
- Validation: BFS connectivity, wall density (5-30%), area per player (900-5000 tiles)
|
|
||||||
- Smoke test validation with energy node accessibility checks
|
|
||||||
- PostgreSQL tables: `maps`, `map_votes`, `map_fairness` for lifecycle management
|
|
||||||
- Map statuses: active, probation, retired, classic per plan §14.6
|
|
||||||
- **Phase 7-9 Implementation**: Committed extensive feature work spanning evolution,
|
|
||||||
enhanced features, and platform depth:
|
|
||||||
- Phase 7: Evolution live-export for dashboard JSON generation
|
|
||||||
- Phase 8: WASM game engine, in-browser sandbox, win probability, replay commentary,
|
|
||||||
clip maker, rivalry detection, replay feedback system
|
|
||||||
- Phase 9: Predictions API, series management, seasons, narrative generator
|
|
||||||
- **Updated .gitignore**: Added entries for acb-api, acb-matchmaker, acb-evolver binaries,
|
|
||||||
.beads/ directory, and .needle.yaml
|
|
||||||
- All tests pass (engine + cmd packages)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-29)
|
|
||||||
- **Architecture Conformance Fix**: Separated matchmaker from acb-api into acb-matchmaker
|
|
||||||
per plan §12 Phase 4:
|
|
||||||
- Plan specifies "Matchmaker Deployment (`acb-matchmaker`): internal tickers for pairing
|
|
||||||
bots (1 min), health checking (15 min), stale job reaping (5 min). No external exposure."
|
|
||||||
- Created `cmd/acb-matchmaker/` with main.go, tickers.go, config.go, crypto.go, alerts.go
|
|
||||||
- Removed tickers.go from acb-api (tickers now in separate deployment)
|
|
||||||
- Removed alerter field from acb-api Server struct (alerting now in matchmaker)
|
|
||||||
- Created `cmd/acb-matchmaker/Dockerfile` for container builds
|
|
||||||
- Created `cluster-configuration/apexalgo-iad/ai-code-battle/acb-matchmaker-deployment.yml`
|
|
||||||
- Matchmaker runs as internal-only deployment with no HTTP endpoints exposed
|
|
||||||
- Fixed syntax error in `cmd/acb-api/db.go` (prematurely closed schemaSQL string)
|
|
||||||
- All tests pass (acb-api + acb-matchmaker builds successfully)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-28)
|
|
||||||
- **Architecture Conformance Fix**: Migrated K8s manifests from `deploy/k8s/` to
|
|
||||||
`cluster-configuration/apexalgo-iad/ai-code-battle/` per plan specification:
|
|
||||||
- Plan §9.3 and §9.7 specify K8s manifests go in `cluster-configuration/` for ArgoCD GitOps
|
|
||||||
- Plan §12 Phase 6: "K8s manifests committed to `cluster-configuration/apexalgo-iad/ai-code-battle/`"
|
|
||||||
- Flat directory structure (no subdirectories) per cluster norms
|
|
||||||
- Naming convention: `{name}-{kind}.yml` (e.g., `acb-worker-deployment.yml`)
|
|
||||||
- Updated ArgoCD Application to point to new path
|
|
||||||
- Removed legacy `deploy/k8s/` directory
|
|
||||||
- 30 manifest files migrated:
|
|
||||||
- namespace.yml, argocd-application.yml
|
|
||||||
- Deployments: acb-api, acb-worker, acb-index-builder, 6 strategy bots
|
|
||||||
- Services: acb-api, 6 strategy bot services
|
|
||||||
- Ingress: acb-api-ingressroute (Traefik), acb-api-certificate (cert-manager)
|
|
||||||
- CI: EventSource, Sensor, ServiceAccount+RBAC, WorkflowTemplates
|
|
||||||
- SealedSecrets: api-key, r2-credentials, bot-secrets, cloudflare-api-token, registry-credentials
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Added Discord/Slack alerting webhooks to Go API server (`cmd/acb-api/alerts.go`):
|
|
||||||
- `Alerter` module sends notifications to Discord and/or Slack incoming webhook URLs
|
|
||||||
- Discord embeds with color-coded severity (blue=info, yellow=warning, red=error) + timestamps
|
|
||||||
- Slack attachments with color-coded severity + footer
|
|
||||||
- Rate limiting with per-key dedup cooldown (5 min default) to prevent alert storms
|
|
||||||
- Garbage collection of expired dedup entries
|
|
||||||
- Helper methods: `BotMarkedInactive`, `BotRecovered`, `StaleJobsReaped`, `MatchError`
|
|
||||||
- Integrated into health checker ticker (alerts on bot inactive/recovered transitions)
|
|
||||||
- Integrated into stale job reaper ticker (alerts when stale jobs re-enqueued)
|
|
||||||
- Config via `ACB_DISCORD_WEBHOOK` and `ACB_SLACK_WEBHOOK` env vars
|
|
||||||
- 15 unit tests: enabled detection, Discord/Slack payload format, color codes, rate limiting,
|
|
||||||
cooldown expiry, no-dedup bypass, webhook errors, both-webhook dispatch, helper methods, GC
|
|
||||||
- Updated `.env.example` with Go API and alerting webhook configuration
|
|
||||||
- All tests pass (45 API tests total, 15 new + 30 existing)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Added Traefik IngressRoute, cert-manager Certificate, and CI/CD pipeline manifests (`deploy/k8s/`):
|
|
||||||
- `ingress/acb-api-ingressroute.yaml` — Traefik IngressRoute for `api.aicodebattle.com`
|
|
||||||
with CORS middleware (allow origins for aicodebattle.com), security headers, rate limiting (100 req/min burst 200)
|
|
||||||
- `ingress/acb-api-certificate.yaml` — cert-manager Certificate (Let's Encrypt prod, ECDSA P-256)
|
|
||||||
- `ci/event-source.yaml` — Argo Events webhook EventSource (port 12000)
|
|
||||||
- `ci/sensor.yaml` — Argo Events Sensor: triggers Argo Workflow on push to master
|
|
||||||
with DAG of parallel Kaniko builds for all 10 container images + site build
|
|
||||||
- `ci/workflow-template-build-image.yaml` — WorkflowTemplate: Kaniko build with layer caching
|
|
||||||
- `ci/workflow-template-build-site.yaml` — WorkflowTemplate: npm ci + build for web SPA
|
|
||||||
- `ci/service-account.yaml` — ServiceAccount + Role + RoleBinding for CI workflows
|
|
||||||
- `sealed-secrets/registry-credentials.yaml` — SealedSecret template for Forgejo registry auth
|
|
||||||
- All 30 K8s manifest files validated (valid YAML with correct apiVersion/kind)
|
|
||||||
- All tests pass (engine + worker + mapgen + api)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Built Go API server (`cmd/acb-api/`) — the K8s-native API service per plan architecture:
|
|
||||||
- HTTP server with graceful shutdown, configurable via environment variables
|
|
||||||
- PostgreSQL schema: `bots`, `matches`, `match_participants`, `jobs`, `rating_history` tables
|
|
||||||
- Health (`/health`) and readiness (`/ready`) endpoints checking PostgreSQL and Valkey
|
|
||||||
- Bot registration (`POST /api/register`) with health check, HMAC secret generation, AES-256-GCM encryption
|
|
||||||
- Key rotation (`POST /api/rotate-key`) with retire option
|
|
||||||
- Bot status (`GET /api/status/{bot_id}`) with conservative display rating
|
|
||||||
- Job claim (`POST /api/jobs/claim`) via Valkey BRPOP + PostgreSQL state update
|
|
||||||
- Job result submission (`POST /api/jobs/{job_id}/result`) with transaction, participant scores, Glicko-2 rating update
|
|
||||||
- Glicko-2 rating system in Go: multi-player pairwise adaptation, volatility update (Illinois algorithm)
|
|
||||||
- Background tickers: matchmaker (1 min), health checker (15 min), stale job reaper (5 min)
|
|
||||||
- Worker API key authentication (Bearer token or X-API-Key header)
|
|
||||||
- Dockerfile: multi-stage Go build, non-root user, Alpine runtime
|
|
||||||
- K8s deployment manifest + ClusterIP Service
|
|
||||||
- 30 unit tests: Glicko-2 (8 tests), crypto (5 tests), config (3 tests), server/handlers (14 tests)
|
|
||||||
- All tests pass (engine + worker + mapgen + api)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Fixed math bug: replaced broken Taylor series sin/cos approximations with
|
|
||||||
`math.Sin`/`math.Cos` in `engine/match.go` and `cmd/acb-mapgen/main.go`.
|
|
||||||
The Taylor series produced incorrect results for angles > π, causing
|
|
||||||
incorrect core/energy/wall placement in 3+ player maps.
|
|
||||||
- Replaced random wall scatter with cellular automata wall generation in
|
|
||||||
`cmd/acb-mapgen/main.go`:
|
|
||||||
- Seeds full grid at 40% density
|
|
||||||
- Runs 4 iterations of B5/S4 cellular automata smoothing
|
|
||||||
- Enforces rotational symmetry by mirroring sector 0
|
|
||||||
- Thins to target density
|
|
||||||
- Protected zones around cores (3-tile radius) and energy nodes
|
|
||||||
- Produces natural cave-like wall structures instead of scattered dots
|
|
||||||
- Added comprehensive map generation tests (`cmd/acb-mapgen/mapgen_test.go`):
|
|
||||||
- Connectivity validation across all player counts and 10 seeds each
|
|
||||||
- Core count and ownership verification
|
|
||||||
- Energy node/wall non-overlap
|
|
||||||
- Wall density bounds checking
|
|
||||||
- Disconnected map detection (BFS validation)
|
|
||||||
- Small grid generation
|
|
||||||
- Determinism (same seed = same map)
|
|
||||||
- Added dominance win condition tests (`engine/turn_test.go`):
|
|
||||||
- 100-turn consecutive dominance threshold verification
|
|
||||||
- Dominance counter reset when falling below 80%
|
|
||||||
- All tests pass (engine + worker + mapgen)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Added Kubernetes manifests for GitOps deployment via ArgoCD (`deploy/k8s/`)
|
|
||||||
- Namespace, ArgoCD Application with auto-sync and self-heal
|
|
||||||
- Deployments: match worker (2 replicas), index builder, 6 strategy bots
|
|
||||||
- ClusterIP Services for all 6 bots (cluster DNS: `acb-strategy-*.ai-code-battle.svc:8080`)
|
|
||||||
- SealedSecret templates: API key, R2 credentials, bot HMAC secrets, Cloudflare API token
|
|
||||||
- All manifests validated (20 files, valid YAML with correct apiVersion/kind)
|
|
||||||
- Container images from `forgejo.ardenone.com/ai-code-battle/` registry
|
|
||||||
- Health/readiness probes on all deployments
|
|
||||||
- Resource requests/limits on all containers
|
|
||||||
- All tests pass (engine + worker)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-26)
|
|
||||||
- Added Prometheus-compatible metrics endpoint to match worker (`cmd/acb-worker/metrics.go`)
|
|
||||||
- Counters: matches_total, match_errors_total, jobs_claimed/failed, replays_uploaded, poll_cycles, heartbeats
|
|
||||||
- Histograms: match_duration_seconds, replay_upload_duration_seconds, replay_size_bytes
|
|
||||||
- Worker info gauge with worker_id label
|
|
||||||
- `/health` and `/ready` endpoints on metrics HTTP server (default :9090)
|
|
||||||
- Configurable via `ACB_METRICS_ADDR` environment variable
|
|
||||||
- Instrumented worker execution flow with metrics recording
|
|
||||||
- Added comprehensive tests (`cmd/acb-worker/metrics_test.go`)
|
|
||||||
- Health/ready endpoint tests, counter accuracy, histogram bucket correctness
|
|
||||||
- Concurrency safety test (10 goroutines x 100 operations)
|
|
||||||
- All tests pass (engine + worker)
|
|
||||||
|
|
||||||
### Previous Changes (2026-03-24)
|
|
||||||
- Added GitHub Actions CI workflow (`.github/workflows/ci.yml`)
|
|
||||||
- Added `README.md` with project overview and quick start guide
|
|
||||||
- Added `.gitignore` and `package-lock.json` files
|
|
||||||
|
|
||||||
### Phase 6 Progress
|
|
||||||
|
|
||||||
- [x] Match worker container (`cmd/acb-worker/Dockerfile`)
|
|
||||||
- Multi-stage Go build
|
|
||||||
- Non-root user for security
|
|
||||||
- Environment variable configuration
|
|
||||||
- [x] Bot-host deployment (`docker-compose.bots.yml`)
|
|
||||||
- Orchestrates all 6 strategy bots
|
|
||||||
- Health checks for each bot
|
|
||||||
- Environment-based secret configuration
|
|
||||||
- [x] Worker deployment (`docker-compose.workers.yml`)
|
|
||||||
- Match worker with scaling support
|
|
||||||
- Index builder for periodic runs
|
|
||||||
- R2 and API configuration
|
|
||||||
- [x] Environment configuration (`.env.example`)
|
|
||||||
- Documented all required environment variables
|
|
||||||
- [x] Deployment documentation (`DEPLOYMENT.md`)
|
|
||||||
- Architecture overview
|
|
||||||
- Cloudflare setup instructions
|
|
||||||
- Container deployment commands
|
|
||||||
- Troubleshooting guide
|
|
||||||
- [x] D1 database schema and migrations
|
|
||||||
- Complete schema.sql with all tables from plan
|
|
||||||
- Added: predictions, predictor_stats, map_votes, replay_feedback, series, series_games, seasons
|
|
||||||
- Added evolution fields to bots table (evolved, island, generation, parent_ids)
|
|
||||||
- Created migrations/0001_initial.sql for D1 migrations
|
|
||||||
- Updated wrangler.toml with migrations_dir config
|
|
||||||
- [x] Monitoring endpoints
|
|
||||||
- `/health` - Liveness probe (always returns 200)
|
|
||||||
- `/ready` - Readiness probe (checks database connectivity, returns 503 if unavailable)
|
|
||||||
- Documented in DEPLOYMENT.md
|
|
||||||
- [x] Prometheus metrics endpoint (`cmd/acb-worker/metrics.go`)
|
|
||||||
- Counters: matches, errors, jobs, replays, polls, heartbeats
|
|
||||||
- Histograms: match duration, replay upload duration, replay size
|
|
||||||
- Worker info gauge with labels
|
|
||||||
- Separate HTTP server on configurable port (default :9090)
|
|
||||||
- Integrated into worker execution flow with full instrumentation
|
|
||||||
- [x] GitHub Actions CI workflow
|
|
||||||
- `.github/workflows/ci.yml` for automated testing
|
|
||||||
- Go tests with race detector
|
|
||||||
- TypeScript tests for worker-api and indexer
|
|
||||||
- Web build verification
|
|
||||||
- Go binary builds
|
|
||||||
- [x] Go API server (`cmd/acb-api/`)
|
|
||||||
- HTTP server with graceful shutdown and env-var configuration
|
|
||||||
- PostgreSQL schema with all core tables (bots, matches, match_participants, jobs, rating_history)
|
|
||||||
- `/health` and `/ready` endpoints (PostgreSQL + Valkey connectivity)
|
|
||||||
- Bot registration, key rotation, status endpoints
|
|
||||||
- Job claim (Valkey BRPOP) and result submission with Glicko-2 rating update
|
|
||||||
- Glicko-2 rating system: multi-player pairwise, volatility (Illinois algorithm)
|
|
||||||
- Background tickers: matchmaker (1 min), health checker (15 min), stale job reaper (5 min)
|
|
||||||
- AES-256-GCM encryption for shared secrets at rest
|
|
||||||
- Worker API key authentication
|
|
||||||
- Dockerfile + K8s Deployment + Service manifests
|
|
||||||
- 30 unit tests covering all components
|
|
||||||
- [x] Kubernetes manifests for ArgoCD GitOps (`deploy/k8s/`)
|
|
||||||
- `namespace.yaml` - Dedicated `ai-code-battle` namespace
|
|
||||||
- `argocd-application.yaml` - Auto-sync with prune and self-heal
|
|
||||||
- `deployments/acb-api.yaml` - Go API (2 replicas, :8080)
|
|
||||||
- `deployments/acb-worker.yaml` - Match worker (2 replicas, metrics on :9090)
|
|
||||||
- `deployments/acb-index-builder.yaml` - Index builder (1 replica, Recreate strategy)
|
|
||||||
- `deployments/acb-strategy-{random,gatherer,rusher,guardian,swarm,hunter}.yaml` - 6 strategy bots
|
|
||||||
- `services/acb-api.yaml` - ClusterIP service for Go API
|
|
||||||
- `services/acb-strategy-*.yaml` - ClusterIP services for bot DNS resolution
|
|
||||||
- `sealed-secrets/` - Templates for API key, R2 creds, bot secrets, Cloudflare token
|
|
||||||
- All containers from `forgejo.ardenone.com/ai-code-battle/` registry
|
|
||||||
- Health/readiness probes and resource limits on all deployments
|
|
||||||
- [x] Traefik IngressRoute + TLS (`deploy/k8s/ingress/`)
|
|
||||||
- `acb-api-ingressroute.yaml` - IngressRoute for `api.aicodebattle.com` (websecure entrypoint)
|
|
||||||
- CORS middleware: allow origins for aicodebattle.com, security headers (nosniff, DENY, strict-origin)
|
|
||||||
- Rate limiting middleware: 100 req/min, burst 200
|
|
||||||
- `acb-api-certificate.yaml` - cert-manager Certificate (Let's Encrypt prod, ECDSA P-256)
|
|
||||||
- [x] Argo Events + Workflows CI/CD pipeline (`deploy/k8s/ci/`)
|
|
||||||
- `event-source.yaml` - Webhook EventSource (port 12000)
|
|
||||||
- `sensor.yaml` - Sensor triggers on master push, submits build-all DAG Workflow
|
|
||||||
- `workflow-template-build-image.yaml` - Kaniko build with layer caching for container images
|
|
||||||
- `workflow-template-build-site.yaml` - npm build for web SPA (outputs dist/ artifact)
|
|
||||||
- `service-account.yaml` - CI ServiceAccount + RBAC (pods, workflows access)
|
|
||||||
- DAG builds all 10 images in parallel: acb-api, acb-worker, acb-indexer, 6 strategy bots, plus site build
|
|
||||||
- [x] Registry credentials SealedSecret template (`deploy/k8s/sealed-secrets/registry-credentials.yaml`)
|
|
||||||
- [x] Discord/Slack alerting webhooks (`cmd/acb-api/alerts.go`)
|
|
||||||
- Alerter module with Discord embeds and Slack attachments
|
|
||||||
- Color-coded severity levels (info/warning/error)
|
|
||||||
- Per-key rate limiting with configurable cooldown
|
|
||||||
- Integrated into health checker and stale job reaper tickers
|
|
||||||
- Helper methods for common alert events
|
|
||||||
- 15 unit tests covering all functionality
|
|
||||||
|
|
||||||
### Remaining Phase 6 Work (requires Cloudflare account access)
|
|
||||||
|
|
||||||
- [ ] Cloudflare Pages project creation and deployment
|
|
||||||
- [ ] R2 bucket creation and custom domain
|
|
||||||
- [ ] Worker API deployment via Wrangler (`wrangler deploy`)
|
|
||||||
- [ ] DNS configuration
|
|
||||||
|
|
||||||
### Phase 5 Completed ✅
|
|
||||||
|
|
||||||
- [x] SPA application shell (`web/app.html`)
|
|
||||||
- Navigation header with links to all sections
|
|
||||||
- Dark theme with CSS custom properties
|
|
||||||
- Responsive layout
|
|
||||||
- [x] Hash-based router (`web/src/router.ts`)
|
|
||||||
- Pattern matching with parameter extraction
|
|
||||||
- Navigation and history support
|
|
||||||
- [x] Page components (`web/src/pages/`)
|
|
||||||
- Home page with hero, features, quick links
|
|
||||||
- Leaderboard with ranking table
|
|
||||||
- Match history with match cards
|
|
||||||
- Bot directory with bot cards
|
|
||||||
- Bot profile with stats, rating chart, recent matches
|
|
||||||
- Registration form with API key display
|
|
||||||
- Replay viewer (integrated from Phase 3)
|
|
||||||
- Docs/Getting Started page
|
|
||||||
- [x] API client (`web/src/api-types.ts`)
|
|
||||||
- fetchLeaderboard()
|
|
||||||
- fetchBotDirectory()
|
|
||||||
- fetchBotProfile()
|
|
||||||
- fetchMatchIndex()
|
|
||||||
- registerBot()
|
|
||||||
- rotateApiKey()
|
|
||||||
- [x] Cloudflare Pages deployment configuration
|
|
||||||
- `web/pages.json` - Project configuration
|
|
||||||
- `web/public/_headers` - Cache control headers
|
|
||||||
- `web/public/robots.txt` - SEO
|
|
||||||
- `web/public/data/` - Placeholder index file structure
|
|
||||||
- [x] R2 bucket custom domain documentation
|
|
||||||
- Documented in `web/pages.json` data_paths section
|
|
||||||
|
|
||||||
### Phase 7 Completed ✅
|
|
||||||
|
|
||||||
- [x] Evolution pipeline (`cmd/acb-evolver/`)
|
|
||||||
- Programs database with island model (4 islands)
|
|
||||||
- MAP-Elites behavior grid integration
|
|
||||||
- Validation pipeline: syntax → schema → sandbox smoke test
|
|
||||||
- Evaluation arena: 10-match mini-tournament
|
|
||||||
- Promotion gate: Nash equilibrium computation + MAP-Elites niche fill
|
|
||||||
- Retirement policy: auto-retire low-rated evolved bots
|
|
||||||
- Live export: generates live.json for dashboard
|
|
||||||
- [x] LLM integration (`cmd/acb-evolver/internal/llm/`)
|
|
||||||
- Prompt builder for parent sampling and replay analysis
|
|
||||||
- Ensemble support (fast + strong model tiers)
|
|
||||||
- [x] Selector and prompt modules for evolution
|
|
||||||
|
|
||||||
### Phase 8 Completed ✅
|
|
||||||
|
|
||||||
- [x] WASM game engine (`cmd/acb-wasm/`)
|
|
||||||
- GOOS=js GOARCH=wasm build with JS bindings
|
|
||||||
- `loadState()`, `step()`, `runMatch()` API
|
|
||||||
- Pre-compiled strategy bot WASM builds
|
|
||||||
- [x] In-browser sandbox (`web/src/pages/sandbox.ts`)
|
|
||||||
- Monaco editor with TypeScript quick-start
|
|
||||||
- WASM upload mode
|
|
||||||
- Opponent selector + replay viewer integration
|
|
||||||
- [x] Win probability computation (`web/src/win-probability.ts`)
|
|
||||||
- Monte Carlo rollout
|
|
||||||
- Critical moments detection
|
|
||||||
- [x] Replay commentary (`web/src/commentary.ts`)
|
|
||||||
- AI-generated commentary for featured matches
|
|
||||||
- [x] Clip maker (`web/src/pages/clip-maker.ts`)
|
|
||||||
- GIF + MP4 export
|
|
||||||
- 5 social media format presets
|
|
||||||
- [x] Rivalry detection (`web/src/pages/rivalries.ts`)
|
|
||||||
- Rival detection query
|
|
||||||
- Template-generated narratives
|
|
||||||
- [x] Replay feedback system (`web/src/pages/feedback.ts`)
|
|
||||||
- Tagged annotations
|
|
||||||
- Feeds evolution pipeline
|
|
||||||
|
|
||||||
### Phase 9 Completed ✅
|
|
||||||
|
|
||||||
- [x] Predictions API (`cmd/acb-api/predictions.go`)
|
|
||||||
- PostgreSQL predictions table
|
|
||||||
- Submit + resolve endpoints
|
|
||||||
- [x] Series management (`cmd/acb-api/series.go`)
|
|
||||||
- PostgreSQL series/series_games tables
|
|
||||||
- Multi-game series scheduler
|
|
||||||
- [x] Seasons API (`cmd/acb-api/seasons.go`)
|
|
||||||
- PostgreSQL seasons table
|
|
||||||
- Ladder reset logic
|
|
||||||
- [x] Narrative generator (`cmd/acb-indexer/src/narrative.ts`)
|
|
||||||
- Rivalry narrative templates
|
|
||||||
- [x] Embeddable replay widget (`web/embed.html`, `web/src/embed.ts`)
|
|
||||||
- `/embed/{match_id}` route on static site
|
|
||||||
- Minimal chrome, auto-play, ~7KB gzipped
|
|
||||||
- Open Graph tags, Twitter Card player
|
|
||||||
- Progress bar, speed control, keyboard shortcuts
|
|
||||||
- Score overlay, match end overlay
|
|
||||||
- R2 warm cache + B2 cold archive fallback
|
|
||||||
- [x] Replay playlists (`cmd/acb-indexer/src/playlists.ts`, `web/src/pages/playlists.ts`)
|
|
||||||
- Auto-curated collections: featured, upsets, comebacks, domination, close games, long games, weekly
|
|
||||||
- Index builder generates playlists from match data
|
|
||||||
- SPA page for browsing playlists
|
|
||||||
- Embed code copy button
|
|
||||||
- Placeholder data directory
|
|
||||||
- [x] Map evolution pipeline (`cmd/acb-map-evolver/`)
|
|
||||||
- Parent selection by engagement × vote multiplier
|
|
||||||
- Crossover breeding with sector-based inheritance
|
|
||||||
- Symmetry-preserving mutation
|
|
||||||
- Validation: connectivity, density, energy access
|
|
||||||
- PostgreSQL tables: maps, map_votes, map_fairness
|
|
||||||
- [x] Bot profile cards (`cmd/acb-index-builder/cards.go`, `web/src/og-tags.ts`)
|
|
||||||
- Canvas-rendered PNG images (1200x630 for Open Graph)
|
|
||||||
- Displays: bot name, rating, win rate, W/L record, rank badge
|
|
||||||
- Evolved bot badge with island indicator
|
|
||||||
- Color-coded rating tiers (gold/silver/bronze/green/gray)
|
|
||||||
- Win rate color coding (green/blue/yellow/red)
|
|
||||||
- Generated by index builder during build cycle
|
|
||||||
- Upload to R2 warm cache + B2 cold archive
|
|
||||||
- Open Graph meta tags for social sharing
|
|
||||||
- Dynamic OG tag updates in SPA via `og-tags.ts`
|
|
||||||
- Shareable URLs: `https://aicodebattle.com/#/bot/{bot_id}`
|
|
||||||
|
|
||||||
### Phase 10 Completed ✅
|
### Phase 10 Completed ✅
|
||||||
|
|
||||||
|
|
@ -602,27 +68,63 @@
|
||||||
| WCAG accessibility standards for color and keyboard navigation | ✅ Complete |
|
| WCAG accessibility standards for color and keyboard navigation | ✅ Complete |
|
||||||
| Live evolution observatory streaming | ✅ Complete |
|
| Live evolution observatory streaming | ✅ Complete |
|
||||||
|
|
||||||
### Phase 4 Completed
|
### Phase 9 Completed ✅
|
||||||
|
|
||||||
### Phase 3 Completed
|
- [x] Bot profile cards (`cmd/acb-index-builder/cards.go`, `web/src/og-tags.ts`)
|
||||||
|
- Canvas-rendered PNG images (1200x630 for Open Graph)
|
||||||
|
- Displays: bot name, rating, win rate, W/L record, rank badge
|
||||||
|
- [x] Map evolution pipeline (`cmd/acb-map-evolver/`)
|
||||||
|
- Parent selection by engagement × vote multiplier
|
||||||
|
- Crossover breeding with sector-based inheritance
|
||||||
|
- Symmetry-preserving mutation
|
||||||
|
- [x] Replay playlists (`cmd/acb-index-builder/playlists.go`, `web/src/pages/playlists.ts`)
|
||||||
|
- Auto-curated collections: featured, upsets, comebacks, domination
|
||||||
|
- [x] Embeddable replay widget (`web/embed.html`, `web/src/embed.ts`)
|
||||||
|
|
||||||
### Phase 2 Completed
|
### Phase 8 Completed ✅
|
||||||
|
|
||||||
### Phase 5 Exit Criteria
|
- [x] WASM game engine (`cmd/acb-wasm/`)
|
||||||
|
- [x] In-browser sandbox (`web/src/pages/sandbox.ts`)
|
||||||
|
- [x] Win probability computation (`web/src/win-probability.ts`)
|
||||||
|
- [x] Replay commentary (`web/src/commentary.ts`)
|
||||||
|
- [x] Clip maker (`web/src/pages/clip-maker.ts`)
|
||||||
|
- [x] Rivalry detection (`web/src/pages/rivalries.ts`)
|
||||||
|
- [x] Replay feedback system (`web/src/pages/feedback.ts`)
|
||||||
|
|
||||||
| Criterion | Status |
|
### Phase 7 Completed ✅
|
||||||
|-----------|--------|
|
|
||||||
| SPA with navigation (leaderboard, matches, bots, register) | ✅ Complete |
|
|
||||||
| Home page with getting started info | ✅ Complete |
|
|
||||||
| Registration form with API key display | ✅ Complete |
|
|
||||||
| Bot profiles with rating history chart | ✅ Complete |
|
|
||||||
| Match history page | ✅ Complete |
|
|
||||||
| Leaderboard with rankings | ✅ Complete |
|
|
||||||
| Getting started / docs page | ✅ Complete |
|
|
||||||
| Cloudflare Pages deployment config | ✅ Complete |
|
|
||||||
| R2 bucket custom domain for replays | ✅ Documented |
|
|
||||||
|
|
||||||
### Phase 1 Completed
|
- [x] Evolution pipeline (`cmd/acb-evolver/`)
|
||||||
|
- Programs database with island model (4 islands)
|
||||||
|
- MAP-Elites behavior grid integration
|
||||||
|
- Validation pipeline: syntax → schema → sandbox smoke test
|
||||||
|
- Evaluation arena: 10-match mini-tournament
|
||||||
|
- Promotion gate: Nash equilibrium computation + MAP-Elites niche fill
|
||||||
|
- Live export: generates live.json for dashboard
|
||||||
|
- [x] LLM integration (`cmd/acb-evolver/internal/llm/`)
|
||||||
|
|
||||||
|
### Phase 6 Completed ✅
|
||||||
|
|
||||||
|
- [x] Go API server (`cmd/acb-api/`) — now a stub, full API deferred for v1
|
||||||
|
- [x] Match worker container (`cmd/acb-worker/Dockerfile`)
|
||||||
|
- [x] Discord/Slack alerting webhooks (`cmd/acb-api/alerts.go`)
|
||||||
|
- [x] Prometheus metrics endpoint (`cmd/acb-worker/metrics.go`)
|
||||||
|
- [x] GitHub Actions CI workflow (`.github/workflows/ci.yml`)
|
||||||
|
|
||||||
|
### Phase 5 Completed ✅
|
||||||
|
|
||||||
|
- [x] SPA application shell (`web/app.html`)
|
||||||
|
- [x] Hash-based router (`web/src/router.ts`)
|
||||||
|
- [x] Page components (`web/src/pages/`)
|
||||||
|
- [x] API client (`web/src/api-types.ts`)
|
||||||
|
- [x] Cloudflare Pages deployment configuration
|
||||||
|
|
||||||
|
### Phase 4 Completed ✅
|
||||||
|
|
||||||
|
### Phase 3 Completed ✅
|
||||||
|
|
||||||
|
### Phase 2 Completed ✅
|
||||||
|
|
||||||
|
### Phase 1 Completed ✅
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
|
|
@ -649,38 +151,29 @@ ai-code-battle/
|
||||||
│ ├── auth.go # HMAC authentication
|
│ ├── auth.go # HMAC authentication
|
||||||
│ └── *_test.go # Test files
|
│ └── *_test.go # Test files
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── acb-api/ # Go API server (K8s-native)
|
│ ├── acb-api/ # Go API server (stub - deferred for v1)
|
||||||
│ │ ├── main.go # Server entry point
|
│ │ ├── main.go # Server entry point
|
||||||
│ │ ├── server.go # Route registration
|
│ │ ├── server.go # Route registration (health/ready only)
|
||||||
│ │ ├── config.go # Environment configuration
|
│ │ ├── config.go # Environment configuration
|
||||||
│ │ ├── db.go # PostgreSQL schema
|
│ │ ├── db.go # PostgreSQL schema
|
||||||
│ │ ├── health.go # Health/ready endpoints
|
│ │ ├── health.go # Health/ready endpoints
|
||||||
│ │ ├── register.go # Bot registration, key rotation, status
|
|
||||||
│ │ ├── jobs.go # Job claim and result submission
|
|
||||||
│ │ ├── glicko2.go # Glicko-2 rating system
|
|
||||||
│ │ ├── crypto.go # ID generation, AES-256-GCM encryption
|
│ │ ├── crypto.go # ID generation, AES-256-GCM encryption
|
||||||
│ │ ├── tickers.go # Matchmaker, health checker, stale reaper
|
│ │ ├── alerts.go # Discord/Slack alerts
|
||||||
│ │ ├── Dockerfile # API container
|
│ │ ├── Dockerfile # API container
|
||||||
│ │ └── *_test.go # Test files (30 tests)
|
│ │ └── *_test.go # Test files
|
||||||
│ ├── acb-local/ # CLI match runner
|
│ ├── acb-local/ # CLI match runner
|
||||||
│ ├── acb-mapgen/ # Map generator
|
│ ├── acb-mapgen/ # Map generator
|
||||||
│ ├── acb-worker/ # Match execution worker
|
│ ├── acb-worker/ # Match execution worker
|
||||||
│ │ ├── main.go # Worker entry point
|
│ │ ├── main.go # Worker entry point
|
||||||
│ │ ├── api.go # Worker API client
|
│ │ ├── api.go # Worker API client
|
||||||
│ │ ├── api_test.go # API client tests
|
│ │ ├── metrics.go # Prometheus metrics
|
||||||
│ │ ├── r2.go # R2 upload client
|
│ │ ├── b2.go # B2 upload client
|
||||||
│ │ └── Dockerfile # Worker container
|
│ │ └── Dockerfile # Worker container
|
||||||
│ └── acb-indexer/ # Index builder
|
│ ├── acb-index-builder/ # Go index builder
|
||||||
│ ├── package.json
|
│ │ ├── main.go
|
||||||
│ ├── Dockerfile
|
│ │ ├── blog.go # Blog generation
|
||||||
│ └── src/
|
│ │ ├── cards.go # OG image generation
|
||||||
│ ├── index.ts # Entry point
|
│ │ └── playlists.go # Playlist generation
|
||||||
│ ├── api.ts # Worker API client
|
|
||||||
│ ├── generator.ts # Index file generator
|
|
||||||
│ ├── writer.ts # File system writer
|
|
||||||
│ ├── narrative.ts # Rivalry narrative generator
|
|
||||||
│ └── generator.test.ts
|
|
||||||
├── cmd/
|
|
||||||
│ ├── acb-evolver/ # Evolution pipeline
|
│ ├── acb-evolver/ # Evolution pipeline
|
||||||
│ │ ├── main.go # CLI entry point
|
│ │ ├── main.go # CLI entry point
|
||||||
│ │ └── internal/
|
│ │ └── internal/
|
||||||
|
|
@ -697,29 +190,15 @@ ai-code-battle/
|
||||||
│ │ ├── main.go # JS bindings
|
│ │ ├── main.go # JS bindings
|
||||||
│ │ ├── bots.go # Bot interface
|
│ │ ├── bots.go # Bot interface
|
||||||
│ │ ├── build.sh # Build script
|
│ │ ├── build.sh # Build script
|
||||||
│ │ ├── strategies/ # Strategy implementations
|
│ │ └── strategies/ # Strategy implementations
|
||||||
│ │ └── botmain/ # Per-bot main packages
|
│ ├── acb-matchmaker/ # Internal matchmaker
|
||||||
│ └── acb-matchmaker/ # Internal matchmaker
|
│ │ ├── main.go # Ticker orchestration
|
||||||
│ ├── main.go # Ticker orchestration
|
│ │ ├── tickers.go # Pairing, health, reaping
|
||||||
│ ├── tickers.go # Pairing, health, reaping
|
│ │ ├── config.go # Configuration
|
||||||
│ ├── config.go # Configuration
|
│ │ ├── crypto.go # Shared crypto
|
||||||
│ ├── crypto.go # Shared crypto
|
│ │ └── alerts.go # Discord/Slack alerts
|
||||||
│ └── alerts.go # Discord/Slack alerts
|
│ └── acb-map-evolver/ # Map evolution pipeline
|
||||||
├── worker-api/
|
│ └── main.go # CLI entry point
|
||||||
│ ├── package.json # npm dependencies
|
|
||||||
│ ├── wrangler.toml # Cloudflare Worker config
|
|
||||||
│ ├── schema.sql # Complete D1 schema (all tables)
|
|
||||||
│ ├── migrations/ # D1 migration files
|
|
||||||
│ │ └── 0001_initial.sql
|
|
||||||
│ └── src/
|
|
||||||
│ ├── index.ts # Router + cron dispatcher
|
|
||||||
│ ├── types.ts # TypeScript types
|
|
||||||
│ ├── glicko2.ts # Glicko-2 rating system
|
|
||||||
│ ├── glicko2.test.ts # Rating system tests
|
|
||||||
│ ├── jobs.ts # Job coordination endpoints
|
|
||||||
│ ├── bots.ts # Bot management endpoints
|
|
||||||
│ ├── export.ts # Data export endpoint
|
|
||||||
│ └── cron.ts # Cron handlers
|
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── package.json # npm dependencies
|
│ ├── package.json # npm dependencies
|
||||||
│ ├── tsconfig.json # TypeScript config
|
│ ├── tsconfig.json # TypeScript config
|
||||||
|
|
@ -727,13 +206,11 @@ ai-code-battle/
|
||||||
│ ├── pages.json # Cloudflare Pages project config
|
│ ├── pages.json # Cloudflare Pages project config
|
||||||
│ ├── index.html # Standalone replay viewer
|
│ ├── index.html # Standalone replay viewer
|
||||||
│ ├── app.html # SPA shell with navigation
|
│ ├── app.html # SPA shell with navigation
|
||||||
|
│ ├── embed.html # Embeddable replay widget
|
||||||
│ ├── public/ # Static assets (copied to dist/)
|
│ ├── public/ # Static assets (copied to dist/)
|
||||||
│ │ ├── _headers # Cloudflare cache headers
|
│ │ ├── _headers # Cloudflare cache headers
|
||||||
│ │ ├── robots.txt # SEO
|
│ │ ├── robots.txt # SEO
|
||||||
│ │ └── data/ # Placeholder index files
|
│ │ └── data/ # Index files
|
||||||
│ │ ├── leaderboard.json
|
|
||||||
│ │ ├── bots/index.json
|
|
||||||
│ │ └── matches/index.json
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── types.ts # Replay type definitions
|
│ ├── types.ts # Replay type definitions
|
||||||
│ ├── api-types.ts # API client and types
|
│ ├── api-types.ts # API client and types
|
||||||
|
|
@ -742,8 +219,10 @@ ai-code-battle/
|
||||||
│ ├── engine.ts # Browser game engine
|
│ ├── engine.ts # Browser game engine
|
||||||
│ ├── commentary.ts # AI replay commentary
|
│ ├── commentary.ts # AI replay commentary
|
||||||
│ ├── win-probability.ts # Monte Carlo win prob
|
│ ├── win-probability.ts # Monte Carlo win prob
|
||||||
|
│ ├── og-tags.ts # Dynamic OG tag updates
|
||||||
│ ├── main.ts # Standalone replay viewer
|
│ ├── main.ts # Standalone replay viewer
|
||||||
│ ├── app.ts # SPA entry point
|
│ ├── app.ts # SPA entry point
|
||||||
|
│ ├── embed.ts # Embeddable widget
|
||||||
│ └── pages/ # SPA page components
|
│ └── pages/ # SPA page components
|
||||||
│ ├── home.ts
|
│ ├── home.ts
|
||||||
│ ├── leaderboard.ts
|
│ ├── leaderboard.ts
|
||||||
|
|
@ -751,11 +230,14 @@ ai-code-battle/
|
||||||
│ ├── bots.ts
|
│ ├── bots.ts
|
||||||
│ ├── bot-profile.ts
|
│ ├── bot-profile.ts
|
||||||
│ ├── register.ts
|
│ ├── register.ts
|
||||||
│ ├── sandbox.ts # In-browser bot editor
|
│ ├── sandbox.ts
|
||||||
│ ├── evolution.ts # Evolution dashboard
|
│ ├── evolution.ts
|
||||||
│ ├── clip-maker.ts # GIF/MP4 export
|
│ ├── clip-maker.ts
|
||||||
│ ├── rivalries.ts # Rivalry pages
|
│ ├── rivalries.ts
|
||||||
│ └── feedback.ts # Replay feedback
|
│ ├── feedback.ts
|
||||||
|
│ ├── playlists.ts
|
||||||
|
│ ├── blog.ts
|
||||||
|
│ └── docs-api.ts
|
||||||
├── bots/
|
├── bots/
|
||||||
│ ├── random/ # Python - RandomBot
|
│ ├── random/ # Python - RandomBot
|
||||||
│ ├── gatherer/ # Go - GathererBot
|
│ ├── gatherer/ # Go - GathererBot
|
||||||
|
|
@ -763,25 +245,13 @@ ai-code-battle/
|
||||||
│ ├── guardian/ # PHP - GuardianBot
|
│ ├── guardian/ # PHP - GuardianBot
|
||||||
│ ├── swarm/ # TypeScript - SwarmBot
|
│ ├── swarm/ # TypeScript - SwarmBot
|
||||||
│ └── hunter/ # Java - HunterBot
|
│ └── hunter/ # Java - HunterBot
|
||||||
├── cluster-configuration/
|
|
||||||
│ └── apexalgo-iad/
|
|
||||||
│ └── ai-code-battle/ # K8s manifests (ArgoCD GitOps, flat structure)
|
|
||||||
│ ├── namespace.yml
|
|
||||||
│ ├── argocd-application.yml
|
|
||||||
│ ├── acb-worker-deployment.yml
|
|
||||||
│ ├── acb-api-deployment.yml + service.yml
|
|
||||||
│ ├── acb-index-builder-deployment.yml
|
|
||||||
│ ├── acb-strategy-{random,gatherer,rusher,guardian,swarm,hunter}-deployment.yml + service.yml
|
|
||||||
│ ├── acb-api-ingressroute.yml (Traefik + Middlewares)
|
|
||||||
│ ├── acb-api-certificate.yml
|
|
||||||
│ ├── acb-ci-{eventsource,sensor,serviceaccount}.yml
|
|
||||||
│ ├── acb-build-{image,site}-workflowtemplate.yml
|
|
||||||
│ └── acb-*-sealedsecret.yml (5 SealedSecret templates)
|
|
||||||
└── docs/
|
└── docs/
|
||||||
└── plan/
|
└── plan/
|
||||||
└── plan.md # Full implementation plan
|
└── plan.md # Full implementation plan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** K8s manifests are in the ardenone-cluster repo at `declarative-config/k8s/apexalgo-iad/ai-code-battle/`
|
||||||
|
|
||||||
## Strategy Bot Summary
|
## Strategy Bot Summary
|
||||||
|
|
||||||
| Bot | Language | Strategy | Expected Rank |
|
| Bot | Language | Strategy | Expected Rank |
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: Certificate
|
|
||||||
metadata:
|
|
||||||
name: acb-api-tls
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: tls
|
|
||||||
spec:
|
|
||||||
secretName: acb-api-tls
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
kind: ClusterIssuer
|
|
||||||
dnsNames:
|
|
||||||
- api.aicodebattle.com
|
|
||||||
privateKey:
|
|
||||||
algorithm: ECDSA
|
|
||||||
size: 256
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-api
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: api
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: api
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: api
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-api:latest
|
|
||||||
env:
|
|
||||||
- name: ACB_LISTEN_ADDR
|
|
||||||
value: ":8080"
|
|
||||||
- name: ACB_DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-postgres-credentials
|
|
||||||
key: database-url
|
|
||||||
- name: ACB_VALKEY_ADDR
|
|
||||||
value: "valkey-master.valkey.svc.cluster.local:6379"
|
|
||||||
- name: ACB_VALKEY_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-valkey-credentials
|
|
||||||
key: password
|
|
||||||
- name: ACB_WORKER_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-api-key
|
|
||||||
key: api-key
|
|
||||||
- name: ACB_ENCRYPTION_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-encryption-key
|
|
||||||
key: key
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /ready
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 128Mi
|
|
||||||
limits:
|
|
||||||
memory: 256Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: IngressRoute
|
|
||||||
metadata:
|
|
||||||
name: acb-api
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ingress
|
|
||||||
spec:
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
routes:
|
|
||||||
- match: Host(`api.aicodebattle.com`)
|
|
||||||
kind: Rule
|
|
||||||
services:
|
|
||||||
- name: acb-api
|
|
||||||
port: 8080
|
|
||||||
middlewares:
|
|
||||||
- name: acb-api-headers
|
|
||||||
- name: acb-api-ratelimit
|
|
||||||
tls:
|
|
||||||
secretName: acb-api-tls
|
|
||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: acb-api-headers
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
spec:
|
|
||||||
headers:
|
|
||||||
accessControlAllowMethods:
|
|
||||||
- GET
|
|
||||||
- POST
|
|
||||||
- OPTIONS
|
|
||||||
accessControlAllowOriginList:
|
|
||||||
- "https://aicodebattle.com"
|
|
||||||
- "https://www.aicodebattle.com"
|
|
||||||
accessControlAllowHeaders:
|
|
||||||
- Content-Type
|
|
||||||
- Authorization
|
|
||||||
- X-API-Key
|
|
||||||
accessControlMaxAge: 86400
|
|
||||||
customResponseHeaders:
|
|
||||||
X-Content-Type-Options: nosniff
|
|
||||||
X-Frame-Options: DENY
|
|
||||||
Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: acb-api-ratelimit
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
spec:
|
|
||||||
rateLimit:
|
|
||||||
average: 100
|
|
||||||
burst: 200
|
|
||||||
period: 1m
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# SealedSecret template — replace with actual sealed values via kubeseal
|
|
||||||
# Source secret keys:
|
|
||||||
# api-endpoint: Worker API endpoint URL
|
|
||||||
# api-key: Worker API authentication key
|
|
||||||
apiVersion: bitnami.com/v1alpha1
|
|
||||||
kind: SealedSecret
|
|
||||||
metadata:
|
|
||||||
name: acb-api-key
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api-key
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: secrets
|
|
||||||
spec:
|
|
||||||
encryptedData:
|
|
||||||
api-endpoint: REPLACE_WITH_SEALED_VALUE
|
|
||||||
api-key: REPLACE_WITH_SEALED_VALUE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: acb-api-key
|
|
||||||
namespace: ai-code-battle
|
|
||||||
type: Opaque
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-api
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: api
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-api
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# SealedSecret template — replace with actual sealed values via kubeseal
|
|
||||||
# Source secret keys: HMAC shared secrets for each strategy bot
|
|
||||||
apiVersion: bitnami.com/v1alpha1
|
|
||||||
kind: SealedSecret
|
|
||||||
metadata:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-bot-secrets
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: secrets
|
|
||||||
spec:
|
|
||||||
encryptedData:
|
|
||||||
random: REPLACE_WITH_SEALED_VALUE
|
|
||||||
gatherer: REPLACE_WITH_SEALED_VALUE
|
|
||||||
rusher: REPLACE_WITH_SEALED_VALUE
|
|
||||||
guardian: REPLACE_WITH_SEALED_VALUE
|
|
||||||
swarm: REPLACE_WITH_SEALED_VALUE
|
|
||||||
hunter: REPLACE_WITH_SEALED_VALUE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
namespace: ai-code-battle
|
|
||||||
type: Opaque
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: WorkflowTemplate
|
|
||||||
metadata:
|
|
||||||
name: acb-build-image
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-build-image
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
spec:
|
|
||||||
templates:
|
|
||||||
- name: kaniko-build
|
|
||||||
inputs:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
- name: dockerfile
|
|
||||||
- name: image
|
|
||||||
artifacts:
|
|
||||||
- name: source
|
|
||||||
git:
|
|
||||||
repo: https://forgejo.ardenone.com/ai-code-battle/ai-code-battle.git
|
|
||||||
revision: "{{workflow.parameters.commit-sha}}"
|
|
||||||
container:
|
|
||||||
image: gcr.io/kaniko-project/executor:v1.23.2
|
|
||||||
args:
|
|
||||||
- --context=/workspace/source/{{inputs.parameters.context}}
|
|
||||||
- --dockerfile=/workspace/source/{{inputs.parameters.dockerfile}}
|
|
||||||
- --destination={{inputs.parameters.image}}
|
|
||||||
- --cache=true
|
|
||||||
- --cache-repo=forgejo.ardenone.com/ai-code-battle/cache
|
|
||||||
- --snapshot-mode=redo
|
|
||||||
- --use-new-run
|
|
||||||
volumeMounts:
|
|
||||||
- name: registry-credentials
|
|
||||||
mountPath: /kaniko/.docker
|
|
||||||
readOnly: true
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 1Gi
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
volumes:
|
|
||||||
- name: registry-credentials
|
|
||||||
secret:
|
|
||||||
secretName: acb-registry-credentials
|
|
||||||
items:
|
|
||||||
- key: .dockerconfigjson
|
|
||||||
path: config.json
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: WorkflowTemplate
|
|
||||||
metadata:
|
|
||||||
name: acb-build-site
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-build-site
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
spec:
|
|
||||||
templates:
|
|
||||||
- name: npm-build
|
|
||||||
inputs:
|
|
||||||
parameters:
|
|
||||||
- name: commit-sha
|
|
||||||
artifacts:
|
|
||||||
- name: source
|
|
||||||
git:
|
|
||||||
repo: https://forgejo.ardenone.com/ai-code-battle/ai-code-battle.git
|
|
||||||
revision: "{{inputs.parameters.commit-sha}}"
|
|
||||||
script:
|
|
||||||
image: node:22-alpine
|
|
||||||
command: [sh]
|
|
||||||
source: |
|
|
||||||
set -e
|
|
||||||
cd /workspace/source/web
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
echo "Site build complete — dist/ ready for Pages deployment"
|
|
||||||
ls -la dist/
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 250m
|
|
||||||
memory: 512Mi
|
|
||||||
limits:
|
|
||||||
memory: 1Gi
|
|
||||||
outputs:
|
|
||||||
artifacts:
|
|
||||||
- name: site-dist
|
|
||||||
path: /workspace/source/web/dist
|
|
||||||
archive:
|
|
||||||
none: {}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: EventSource
|
|
||||||
metadata:
|
|
||||||
name: acb-webhook
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-webhook
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
spec:
|
|
||||||
service:
|
|
||||||
ports:
|
|
||||||
- port: 12000
|
|
||||||
targetPort: 12000
|
|
||||||
webhook:
|
|
||||||
acb-push:
|
|
||||||
port: "12000"
|
|
||||||
endpoint: /push
|
|
||||||
method: POST
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: Sensor
|
|
||||||
metadata:
|
|
||||||
name: acb-ci
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-ci
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
spec:
|
|
||||||
dependencies:
|
|
||||||
- name: push
|
|
||||||
eventSourceName: acb-webhook
|
|
||||||
eventName: acb-push
|
|
||||||
filters:
|
|
||||||
data:
|
|
||||||
- path: body.ref
|
|
||||||
type: string
|
|
||||||
value:
|
|
||||||
- "refs/heads/master"
|
|
||||||
triggers:
|
|
||||||
- template:
|
|
||||||
name: build-images
|
|
||||||
argoWorkflow:
|
|
||||||
operation: submit
|
|
||||||
source:
|
|
||||||
resource:
|
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: Workflow
|
|
||||||
metadata:
|
|
||||||
generateName: acb-build-
|
|
||||||
namespace: ai-code-battle
|
|
||||||
spec:
|
|
||||||
serviceAccountName: acb-ci
|
|
||||||
entrypoint: build-all
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: commit-sha
|
|
||||||
value: ""
|
|
||||||
- name: registry
|
|
||||||
value: forgejo.ardenone.com/ai-code-battle
|
|
||||||
templates:
|
|
||||||
- name: build-all
|
|
||||||
dag:
|
|
||||||
tasks:
|
|
||||||
- name: build-api
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: .
|
|
||||||
- name: dockerfile
|
|
||||||
value: cmd/acb-api/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-api:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-worker
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: .
|
|
||||||
- name: dockerfile
|
|
||||||
value: cmd/acb-worker/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-worker:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-indexer
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: .
|
|
||||||
- name: dockerfile
|
|
||||||
value: cmd/acb-indexer/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-indexer:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-random
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/random
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/random/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-random:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-gatherer
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/gatherer
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/gatherer/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-gatherer:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-rusher
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/rusher
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/rusher/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-rusher:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-guardian
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/guardian
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/guardian/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-guardian:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-swarm
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/swarm
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/swarm/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-swarm:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-bot-hunter
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-image
|
|
||||||
template: kaniko-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: context
|
|
||||||
value: bots/hunter
|
|
||||||
- name: dockerfile
|
|
||||||
value: bots/hunter/Dockerfile
|
|
||||||
- name: image
|
|
||||||
value: "{{workflow.parameters.registry}}/acb-strategy-hunter:{{workflow.parameters.commit-sha}}"
|
|
||||||
- name: build-site
|
|
||||||
templateRef:
|
|
||||||
name: acb-build-site
|
|
||||||
template: npm-build
|
|
||||||
arguments:
|
|
||||||
parameters:
|
|
||||||
- name: commit-sha
|
|
||||||
value: "{{workflow.parameters.commit-sha}}"
|
|
||||||
parameters:
|
|
||||||
- src:
|
|
||||||
dependencyName: push
|
|
||||||
dataKey: body.after
|
|
||||||
dest: spec.arguments.parameters.0.value
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: acb-ci
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-ci
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: Role
|
|
||||||
metadata:
|
|
||||||
name: acb-ci-workflow
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-ci
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods", "pods/log"]
|
|
||||||
verbs: ["get", "list", "watch"]
|
|
||||||
- apiGroups: ["argoproj.io"]
|
|
||||||
resources: ["workflows", "workflowtemplates"]
|
|
||||||
verbs: ["get", "list", "create"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: RoleBinding
|
|
||||||
metadata:
|
|
||||||
name: acb-ci-workflow
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-ci
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: acb-ci
|
|
||||||
namespace: ai-code-battle
|
|
||||||
roleRef:
|
|
||||||
kind: Role
|
|
||||||
name: acb-ci-workflow
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# SealedSecret template — replace with actual sealed values via kubeseal
|
|
||||||
# Source secret keys:
|
|
||||||
# token: Cloudflare API token (for wrangler pages deploy by index builder)
|
|
||||||
apiVersion: bitnami.com/v1alpha1
|
|
||||||
kind: SealedSecret
|
|
||||||
metadata:
|
|
||||||
name: acb-cloudflare-api-token
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-cloudflare-api-token
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: secrets
|
|
||||||
spec:
|
|
||||||
encryptedData:
|
|
||||||
token: REPLACE_WITH_SEALED_VALUE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: acb-cloudflare-api-token
|
|
||||||
namespace: ai-code-battle
|
|
||||||
type: Opaque
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-index-builder
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-index-builder
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: index-builder
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-index-builder
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-index-builder
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: index-builder
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: index-builder
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-index-builder:latest
|
|
||||||
env:
|
|
||||||
- name: ACB_DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-postgres-credentials
|
|
||||||
key: database-url
|
|
||||||
- name: ACB_OUTPUT_DIR
|
|
||||||
value: "/app/data"
|
|
||||||
- name: ACB_R2_ENDPOINT
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: endpoint
|
|
||||||
- name: ACB_R2_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: access-key
|
|
||||||
- name: ACB_R2_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: secret-key
|
|
||||||
- name: ACB_CLOUDFLARE_API_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-cloudflare-api-token
|
|
||||||
key: token
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 128Mi
|
|
||||||
limits:
|
|
||||||
memory: 256Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-matchmaker
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-matchmaker
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: matchmaker
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-matchmaker
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-matchmaker
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: matchmaker
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: matchmaker
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-matchmaker:latest
|
|
||||||
env:
|
|
||||||
- name: ACB_DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-database-url
|
|
||||||
key: url
|
|
||||||
- name: ACB_VALKEY_ADDR
|
|
||||||
value: "valkey.ai-code-battle.svc:6379"
|
|
||||||
- name: ACB_VALKEY_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-valkey-password
|
|
||||||
key: password
|
|
||||||
optional: true
|
|
||||||
- name: ACB_ENCRYPTION_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-api-key
|
|
||||||
key: encryption-key
|
|
||||||
optional: true
|
|
||||||
- name: ACB_DISCORD_WEBHOOK
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-alert-webhooks
|
|
||||||
key: discord
|
|
||||||
optional: true
|
|
||||||
- name: ACB_SLACK_WEBHOOK
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-alert-webhooks
|
|
||||||
key: slack
|
|
||||||
optional: true
|
|
||||||
- name: ACB_MATCHMAKER_INTERVAL
|
|
||||||
value: "60"
|
|
||||||
- name: ACB_HEALTHCHECK_INTERVAL
|
|
||||||
value: "900"
|
|
||||||
- name: ACB_REAPER_INTERVAL
|
|
||||||
value: "300"
|
|
||||||
- name: ACB_BOT_TIMEOUT
|
|
||||||
value: "5"
|
|
||||||
- name: ACB_STALE_JOB_MINUTES
|
|
||||||
value: "15"
|
|
||||||
- name: ACB_MAX_CONSEC_FAILS
|
|
||||||
value: "3"
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 128Mi
|
|
||||||
limits:
|
|
||||||
memory: 256Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# SealedSecret template — replace with actual sealed values via kubeseal
|
|
||||||
# Source secret keys:
|
|
||||||
# endpoint: R2 S3-compatible endpoint URL
|
|
||||||
# access-key: R2 access key ID
|
|
||||||
# secret-key: R2 secret access key
|
|
||||||
apiVersion: bitnami.com/v1alpha1
|
|
||||||
kind: SealedSecret
|
|
||||||
metadata:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-r2-credentials
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: secrets
|
|
||||||
spec:
|
|
||||||
encryptedData:
|
|
||||||
endpoint: REPLACE_WITH_SEALED_VALUE
|
|
||||||
access-key: REPLACE_WITH_SEALED_VALUE
|
|
||||||
secret-key: REPLACE_WITH_SEALED_VALUE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
namespace: ai-code-battle
|
|
||||||
type: Opaque
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# Template: replace encryptedData with actual SealedSecret values
|
|
||||||
# Generate with:
|
|
||||||
# kubectl create secret docker-registry acb-registry-credentials \
|
|
||||||
# --docker-server=forgejo.ardenone.com \
|
|
||||||
# --docker-username=<user> \
|
|
||||||
# --docker-password=<token> \
|
|
||||||
# --dry-run=client -o yaml | kubeseal --format yaml
|
|
||||||
apiVersion: bitnami.com/v1alpha1
|
|
||||||
kind: SealedSecret
|
|
||||||
metadata:
|
|
||||||
name: acb-registry-credentials
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-registry-credentials
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: ci
|
|
||||||
spec:
|
|
||||||
encryptedData:
|
|
||||||
.dockerconfigjson: REPLACE_WITH_SEALED_VALUE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: acb-registry-credentials
|
|
||||||
namespace: ai-code-battle
|
|
||||||
type: kubernetes.io/dockerconfigjson
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-gatherer
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-gatherer
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-gatherer
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-gatherer
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: gatherer
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-gatherer:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: gatherer
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-gatherer
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-gatherer
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-gatherer
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-guardian
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-guardian
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-guardian
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-guardian
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: guardian
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-guardian:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: guardian
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-guardian
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-guardian
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-guardian
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-hunter
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-hunter
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-hunter
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-hunter
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: hunter
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-hunter:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: hunter
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-hunter
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-hunter
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-hunter
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-random
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-random
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-random
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-random
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: random
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-random:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: random
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-random
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-random
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-random
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-rusher
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-rusher
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-rusher
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-rusher
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: rusher
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-rusher:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: rusher
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-rusher
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-rusher
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-rusher
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-swarm
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-swarm
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-swarm
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-swarm
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: swarm
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-strategy-swarm:latest
|
|
||||||
env:
|
|
||||||
- name: BOT_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: BOT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-bot-secrets
|
|
||||||
key: swarm
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: acb-strategy-swarm
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-strategy-swarm
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: strategy-bot
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: acb-strategy-swarm
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8080
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: acb-worker
|
|
||||||
namespace: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-worker
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: worker
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: acb-worker
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: acb-worker
|
|
||||||
app.kubernetes.io/part-of: ai-code-battle
|
|
||||||
app.kubernetes.io/component: worker
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: worker
|
|
||||||
image: forgejo.ardenone.com/ai-code-battle/acb-worker:latest
|
|
||||||
args:
|
|
||||||
- "-poll=5s"
|
|
||||||
- "-heartbeat=30s"
|
|
||||||
- "-timeout=3s"
|
|
||||||
env:
|
|
||||||
- name: ACB_API_ENDPOINT
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-api-key
|
|
||||||
key: api-endpoint
|
|
||||||
- name: ACB_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-api-key
|
|
||||||
key: api-key
|
|
||||||
- name: ACB_R2_ENDPOINT
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: endpoint
|
|
||||||
- name: ACB_R2_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: access-key
|
|
||||||
- name: ACB_R2_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: acb-r2-credentials
|
|
||||||
key: secret-key
|
|
||||||
- name: ACB_METRICS_ADDR
|
|
||||||
value: ":9090"
|
|
||||||
ports:
|
|
||||||
- name: metrics
|
|
||||||
containerPort: 9090
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: metrics
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /ready
|
|
||||||
port: metrics
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
memory: 512Mi
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: Application
|
|
||||||
metadata:
|
|
||||||
name: ai-code-battle
|
|
||||||
namespace: argocd
|
|
||||||
spec:
|
|
||||||
project: default
|
|
||||||
source:
|
|
||||||
repoURL: https://forgejo.ardenone.com/ai-code-battle/ai-code-battle.git
|
|
||||||
targetRevision: master
|
|
||||||
path: cluster-configuration/apexalgo-iad/ai-code-battle
|
|
||||||
directory:
|
|
||||||
recurse: false
|
|
||||||
destination:
|
|
||||||
server: https://kubernetes.default.svc
|
|
||||||
namespace: ai-code-battle
|
|
||||||
syncPolicy:
|
|
||||||
automated:
|
|
||||||
prune: true
|
|
||||||
selfHeal: true
|
|
||||||
syncOptions:
|
|
||||||
- CreateNamespace=false
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: ai-code-battle
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: ai-code-battle
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGFunc(t *testing.T) {
|
|
||||||
// g(0) should be 1
|
|
||||||
if g := gFunc(0); math.Abs(g-1.0) > 1e-10 {
|
|
||||||
t.Errorf("g(0) = %f, want 1.0", g)
|
|
||||||
}
|
|
||||||
|
|
||||||
// g(phi) should be between 0 and 1 for positive phi
|
|
||||||
if g := gFunc(1.0); g <= 0 || g >= 1.0 {
|
|
||||||
t.Errorf("g(1.0) = %f, want in (0, 1)", g)
|
|
||||||
}
|
|
||||||
|
|
||||||
// g should decrease as phi increases
|
|
||||||
g1 := gFunc(0.5)
|
|
||||||
g2 := gFunc(1.5)
|
|
||||||
if g1 <= g2 {
|
|
||||||
t.Errorf("g should decrease: g(0.5)=%f, g(1.5)=%f", g1, g2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEFunc(t *testing.T) {
|
|
||||||
// E(mu, mu, phi) should be 0.5 (equal ratings)
|
|
||||||
e := eFunc(0, 0, 1.0)
|
|
||||||
if math.Abs(e-0.5) > 1e-10 {
|
|
||||||
t.Errorf("E(0, 0, 1.0) = %f, want 0.5", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Higher mu should give higher expected score
|
|
||||||
eHigh := eFunc(1.0, 0, 1.0)
|
|
||||||
eLow := eFunc(-1.0, 0, 1.0)
|
|
||||||
if eHigh <= 0.5 || eLow >= 0.5 {
|
|
||||||
t.Errorf("expected eHigh>0.5, eLow<0.5, got %f, %f", eHigh, eLow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_TwoPlayers(t *testing.T) {
|
|
||||||
// Two equally rated players, player 0 wins
|
|
||||||
r := []Glicko2Rating{
|
|
||||||
{Mu: 1500, Phi: 350, Sigma: 0.06},
|
|
||||||
{Mu: 1500, Phi: 350, Sigma: 0.06},
|
|
||||||
}
|
|
||||||
scores := []float64{10, 5} // player 0 wins
|
|
||||||
|
|
||||||
newR := updateRatings(r, scores)
|
|
||||||
|
|
||||||
// Winner should gain rating
|
|
||||||
if newR[0].Mu <= r[0].Mu {
|
|
||||||
t.Errorf("winner mu should increase: %f -> %f", r[0].Mu, newR[0].Mu)
|
|
||||||
}
|
|
||||||
// Loser should lose rating
|
|
||||||
if newR[1].Mu >= r[1].Mu {
|
|
||||||
t.Errorf("loser mu should decrease: %f -> %f", r[1].Mu, newR[1].Mu)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RD should decrease for both (more information)
|
|
||||||
if newR[0].Phi >= r[0].Phi {
|
|
||||||
t.Errorf("winner phi should decrease: %f -> %f", r[0].Phi, newR[0].Phi)
|
|
||||||
}
|
|
||||||
if newR[1].Phi >= r[1].Phi {
|
|
||||||
t.Errorf("loser phi should decrease: %f -> %f", r[1].Phi, newR[1].Phi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_Draw(t *testing.T) {
|
|
||||||
r := []Glicko2Rating{
|
|
||||||
{Mu: 1500, Phi: 350, Sigma: 0.06},
|
|
||||||
{Mu: 1500, Phi: 350, Sigma: 0.06},
|
|
||||||
}
|
|
||||||
scores := []float64{5, 5} // draw
|
|
||||||
|
|
||||||
newR := updateRatings(r, scores)
|
|
||||||
|
|
||||||
// Equal ratings + draw = negligible mu change
|
|
||||||
diff := math.Abs(newR[0].Mu - newR[1].Mu)
|
|
||||||
if diff > 1.0 {
|
|
||||||
t.Errorf("draw should keep ratings close: %f vs %f (diff=%f)", newR[0].Mu, newR[1].Mu, diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both should be close to original
|
|
||||||
if math.Abs(newR[0].Mu-1500) > 5.0 {
|
|
||||||
t.Errorf("draw between equals should barely change rating: %f", newR[0].Mu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_UpsetGivesLargerGain(t *testing.T) {
|
|
||||||
// Lower-rated player beats higher-rated player
|
|
||||||
r := []Glicko2Rating{
|
|
||||||
{Mu: 1300, Phi: 100, Sigma: 0.06}, // underdog
|
|
||||||
{Mu: 1700, Phi: 100, Sigma: 0.06}, // favorite
|
|
||||||
}
|
|
||||||
scores := []float64{10, 5} // underdog wins
|
|
||||||
|
|
||||||
newR := updateRatings(r, scores)
|
|
||||||
|
|
||||||
underdogGain := newR[0].Mu - r[0].Mu
|
|
||||||
favoriteGain := r[1].Mu - newR[1].Mu
|
|
||||||
|
|
||||||
if underdogGain <= 0 {
|
|
||||||
t.Errorf("underdog should gain rating: %f", underdogGain)
|
|
||||||
}
|
|
||||||
if favoriteGain <= 0 {
|
|
||||||
t.Errorf("favorite should lose rating: %f", favoriteGain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now test expected win: higher-rated player beats lower
|
|
||||||
r2 := []Glicko2Rating{
|
|
||||||
{Mu: 1700, Phi: 100, Sigma: 0.06}, // favorite
|
|
||||||
{Mu: 1300, Phi: 100, Sigma: 0.06}, // underdog
|
|
||||||
}
|
|
||||||
scores2 := []float64{10, 5}
|
|
||||||
newR2 := updateRatings(r2, scores2)
|
|
||||||
expectedGain := newR2[0].Mu - r2[0].Mu
|
|
||||||
|
|
||||||
// Upset should give larger rating change than expected result
|
|
||||||
if underdogGain <= expectedGain {
|
|
||||||
t.Errorf("upset gain (%f) should exceed expected win gain (%f)", underdogGain, expectedGain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_MultiPlayer(t *testing.T) {
|
|
||||||
// 4-player match
|
|
||||||
r := []Glicko2Rating{
|
|
||||||
{Mu: 1500, Phi: 200, Sigma: 0.06},
|
|
||||||
{Mu: 1500, Phi: 200, Sigma: 0.06},
|
|
||||||
{Mu: 1500, Phi: 200, Sigma: 0.06},
|
|
||||||
{Mu: 1500, Phi: 200, Sigma: 0.06},
|
|
||||||
}
|
|
||||||
scores := []float64{20, 15, 10, 5}
|
|
||||||
|
|
||||||
newR := updateRatings(r, scores)
|
|
||||||
|
|
||||||
// Ratings should be ordered by score
|
|
||||||
for i := 0; i < len(newR)-1; i++ {
|
|
||||||
if newR[i].Mu <= newR[i+1].Mu {
|
|
||||||
t.Errorf("player %d (score=%0.f, mu=%f) should be rated above player %d (score=%0.f, mu=%f)",
|
|
||||||
i, scores[i], newR[i].Mu, i+1, scores[i+1], newR[i+1].Mu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_LowRDPlayersChangeMore(t *testing.T) {
|
|
||||||
// Lower RD (more certain) means the system gives more weight to the result
|
|
||||||
highRD := Glicko2Rating{Mu: 1500, Phi: 300, Sigma: 0.06}
|
|
||||||
lowRD := Glicko2Rating{Mu: 1500, Phi: 100, Sigma: 0.06}
|
|
||||||
|
|
||||||
// Both beat a 1500-rated opponent
|
|
||||||
opp := Glicko2Rating{Mu: 1500, Phi: 200, Sigma: 0.06}
|
|
||||||
|
|
||||||
r1 := updateRatings([]Glicko2Rating{highRD, opp}, []float64{10, 5})
|
|
||||||
r2 := updateRatings([]Glicko2Rating{lowRD, opp}, []float64{10, 5})
|
|
||||||
|
|
||||||
highRDGain := r1[0].Mu - highRD.Mu
|
|
||||||
lowRDGain := r2[0].Mu - lowRD.Mu
|
|
||||||
|
|
||||||
// High RD player should change more (less certainty = more adjustable)
|
|
||||||
if highRDGain <= lowRDGain {
|
|
||||||
t.Errorf("high RD player should gain more: %f vs %f", highRDGain, lowRDGain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRatings_Determinism(t *testing.T) {
|
|
||||||
r := []Glicko2Rating{
|
|
||||||
{Mu: 1600, Phi: 150, Sigma: 0.06},
|
|
||||||
{Mu: 1400, Phi: 250, Sigma: 0.06},
|
|
||||||
}
|
|
||||||
scores := []float64{8, 12}
|
|
||||||
|
|
||||||
r1 := updateRatings(r, scores)
|
|
||||||
r2 := updateRatings(r, scores)
|
|
||||||
|
|
||||||
for i := range r1 {
|
|
||||||
if r1[i].Mu != r2[i].Mu || r1[i].Phi != r2[i].Phi || r1[i].Sigma != r2[i].Sigma {
|
|
||||||
t.Errorf("ratings not deterministic at index %d: %+v vs %+v", i, r1[i], r2[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDisplayRating(t *testing.T) {
|
|
||||||
r := Glicko2Rating{Mu: 1500, Phi: 350, Sigma: 0.06}
|
|
||||||
display := r.DisplayRating()
|
|
||||||
expected := 1500.0 - 2*350.0
|
|
||||||
if math.Abs(display-expected) > 1e-10 {
|
|
||||||
t.Errorf("DisplayRating() = %f, want %f", display, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeVolatility_Convergence(t *testing.T) {
|
|
||||||
// Should not panic or infinite loop
|
|
||||||
sigma := computeVolatility(0.06, 1.0, 10.0, 5.0)
|
|
||||||
if sigma <= 0 || math.IsNaN(sigma) || math.IsInf(sigma, 0) {
|
|
||||||
t.Errorf("volatility should be positive finite: %f", sigma)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const valkeyJobQueue = "acb:jobs:pending"
|
|
||||||
|
|
||||||
type JobClaimRequest struct {
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobClaimResponse struct {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
MatchID string `json:"match_id"`
|
|
||||||
ConfigJSON json.RawMessage `json:"config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleJobClaim(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Authenticate worker
|
|
||||||
if !s.authenticateWorker(r) {
|
|
||||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req JobClaimRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.WorkerID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "worker_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Blocking pop from Valkey queue (short timeout for HTTP context)
|
|
||||||
result, err := s.rdb.BRPop(ctx, 4*time.Second, valkeyJobQueue).Result()
|
|
||||||
if err != nil {
|
|
||||||
// Timeout or empty queue
|
|
||||||
writeJSON(w, http.StatusNoContent, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobID := result[1] // BRPop returns [key, value]
|
|
||||||
|
|
||||||
// Fetch job details from PostgreSQL and mark as running
|
|
||||||
var resp JobClaimResponse
|
|
||||||
var configJSON []byte
|
|
||||||
err = s.db.QueryRowContext(r.Context(),
|
|
||||||
`UPDATE jobs SET status = 'running', worker_id = $1, claimed_at = NOW()
|
|
||||||
WHERE job_id = $2 AND status = 'pending'
|
|
||||||
RETURNING job_id, match_id, config_json`,
|
|
||||||
req.WorkerID, jobID,
|
|
||||||
).Scan(&resp.JobID, &resp.MatchID, &configJSON)
|
|
||||||
if err != nil {
|
|
||||||
// Job was already claimed or doesn't exist; put it back if it was something else
|
|
||||||
writeJSON(w, http.StatusNoContent, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp.ConfigJSON = configJSON
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobResultRequest struct {
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
Winner *int `json:"winner"`
|
|
||||||
Condition string `json:"condition"`
|
|
||||||
TurnCount int `json:"turn_count"`
|
|
||||||
Scores json.RawMessage `json:"scores"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleJobResult(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.authenticateWorker(r) {
|
|
||||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobID := r.PathValue("job_id")
|
|
||||||
|
|
||||||
var req JobResultRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "transaction error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Get match_id from job
|
|
||||||
var matchID string
|
|
||||||
err = tx.QueryRowContext(ctx,
|
|
||||||
`UPDATE jobs SET status = 'completed', completed_at = NOW()
|
|
||||||
WHERE job_id = $1 AND status = 'running'
|
|
||||||
RETURNING match_id`, jobID,
|
|
||||||
).Scan(&matchID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "job not found or not running")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update match
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`UPDATE matches SET status = 'completed', winner = $1, condition = $2,
|
|
||||||
turn_count = $3, scores_json = $4, completed_at = NOW()
|
|
||||||
WHERE match_id = $5`,
|
|
||||||
req.Winner, req.Condition, req.TurnCount, req.Scores, matchID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update match")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update participant scores
|
|
||||||
var scores []int
|
|
||||||
if err := json.Unmarshal(req.Scores, &scores); err == nil {
|
|
||||||
for slot, score := range scores {
|
|
||||||
_, _ = tx.ExecContext(ctx,
|
|
||||||
`UPDATE match_participants SET score = $1, status = 'completed'
|
|
||||||
WHERE match_id = $2 AND player_slot = $3`,
|
|
||||||
score, matchID, slot,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get participants for rating update
|
|
||||||
rows, err := tx.QueryContext(ctx,
|
|
||||||
`SELECT mp.bot_id, mp.player_slot, mp.score,
|
|
||||||
b.rating_mu, b.rating_phi, b.rating_sigma
|
|
||||||
FROM match_participants mp
|
|
||||||
JOIN bots b ON b.bot_id = mp.bot_id
|
|
||||||
WHERE mp.match_id = $1
|
|
||||||
ORDER BY mp.player_slot`, matchID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to fetch participants")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type participant struct {
|
|
||||||
botID string
|
|
||||||
slot int
|
|
||||||
score int
|
|
||||||
mu, phi float64
|
|
||||||
sigma float64
|
|
||||||
}
|
|
||||||
var participants []participant
|
|
||||||
for rows.Next() {
|
|
||||||
var p participant
|
|
||||||
if err := rows.Scan(&p.botID, &p.slot, &p.score, &p.mu, &p.phi, &p.sigma); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
writeError(w, http.StatusInternalServerError, "scan error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
participants = append(participants, p)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
// Update Glicko-2 ratings
|
|
||||||
if len(participants) >= 2 {
|
|
||||||
ratings := make([]Glicko2Rating, len(participants))
|
|
||||||
scores := make([]float64, len(participants))
|
|
||||||
for i, p := range participants {
|
|
||||||
ratings[i] = Glicko2Rating{Mu: p.mu, Phi: p.phi, Sigma: p.sigma}
|
|
||||||
scores[i] = float64(p.score)
|
|
||||||
}
|
|
||||||
|
|
||||||
newRatings := updateRatings(ratings, scores)
|
|
||||||
|
|
||||||
for i, p := range participants {
|
|
||||||
nr := newRatings[i]
|
|
||||||
_, _ = tx.ExecContext(ctx,
|
|
||||||
`UPDATE bots SET rating_mu = $1, rating_phi = $2, rating_sigma = $3, last_active = NOW()
|
|
||||||
WHERE bot_id = $4`,
|
|
||||||
nr.Mu, nr.Phi, nr.Sigma, p.botID,
|
|
||||||
)
|
|
||||||
displayRating := nr.Mu - 2*nr.Phi
|
|
||||||
_, _ = tx.ExecContext(ctx,
|
|
||||||
`INSERT INTO rating_history (bot_id, match_id, rating)
|
|
||||||
VALUES ($1, $2, $3)`,
|
|
||||||
p.botID, matchID, displayRating,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "commit error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) authenticateWorker(r *http.Request) bool {
|
|
||||||
if s.cfg.WorkerAPIKey == "" {
|
|
||||||
return true // no auth configured (dev mode)
|
|
||||||
}
|
|
||||||
key := r.Header.Get("Authorization")
|
|
||||||
if key == "" {
|
|
||||||
key = r.Header.Get("X-API-Key")
|
|
||||||
}
|
|
||||||
return key == "Bearer "+s.cfg.WorkerAPIKey || key == s.cfg.WorkerAPIKey
|
|
||||||
}
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleSubmitPrediction handles POST /api/predictions
|
|
||||||
func (s *Server) handleSubmitPrediction(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
MatchID string `json:"match_id"`
|
|
||||||
PredictorID string `json:"predictor_id"`
|
|
||||||
PredictedBot string `json:"predicted_bot"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.MatchID == "" || req.PredictorID == "" || req.PredictedBot == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "match_id, predictor_id, and predicted_bot are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Verify match exists and is pending/active
|
|
||||||
var matchStatus string
|
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT status FROM matches WHERE match_id = $1`, req.MatchID).Scan(&matchStatus)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "match not found")
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if matchStatus == "completed" {
|
|
||||||
writeError(w, http.StatusConflict, "match already completed; predictions closed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert prediction (one per predictor per match)
|
|
||||||
_, err = s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO predictions (match_id, predictor_id, predicted_bot)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (match_id, predictor_id)
|
|
||||||
DO UPDATE SET predicted_bot = EXCLUDED.predicted_bot
|
|
||||||
`, req.MatchID, req.PredictorID, req.PredictedBot)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to store prediction")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleResolvePredictions handles POST /api/predictions/{match_id}/resolve
|
|
||||||
// Called internally (worker or ticker) after a match completes.
|
|
||||||
func (s *Server) handleResolvePredictions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
matchID := r.PathValue("match_id")
|
|
||||||
if matchID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "missing match_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Get match winner
|
|
||||||
var winnerID sql.NullString
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT mp.bot_id FROM match_participants mp
|
|
||||||
JOIN matches m ON mp.match_id = m.match_id
|
|
||||||
WHERE m.match_id = $1
|
|
||||||
AND mp.player_slot = m.winner
|
|
||||||
`, matchID).Scan(&winnerID)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "match not found or has no winner")
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
winner := winnerID.String
|
|
||||||
|
|
||||||
// Get all unresolved predictions for this match
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT id, predictor_id, predicted_bot
|
|
||||||
FROM predictions
|
|
||||||
WHERE match_id = $1 AND correct IS NULL
|
|
||||||
`, matchID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type predRow struct {
|
|
||||||
id int64
|
|
||||||
predictorID string
|
|
||||||
predictedBot string
|
|
||||||
}
|
|
||||||
var preds []predRow
|
|
||||||
for rows.Next() {
|
|
||||||
var p predRow
|
|
||||||
if err := rows.Scan(&p.id, &p.predictorID, &p.predictedBot); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
preds = append(preds, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
resolved := 0
|
|
||||||
for _, p := range preds {
|
|
||||||
correct := p.predictedBot == winner
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
UPDATE predictions SET correct = $1, resolved_at = $2 WHERE id = $3
|
|
||||||
`, correct, now, p.id)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update predictor stats
|
|
||||||
if correct {
|
|
||||||
_, _ = s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak)
|
|
||||||
VALUES ($1, 1, 1, 1)
|
|
||||||
ON CONFLICT (predictor_id) DO UPDATE SET
|
|
||||||
correct = predictor_stats.correct + 1,
|
|
||||||
streak = predictor_stats.streak + 1,
|
|
||||||
best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
|
|
||||||
updated_at = NOW()
|
|
||||||
`, p.predictorID)
|
|
||||||
} else {
|
|
||||||
_, _ = s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO predictor_stats (predictor_id, incorrect, streak)
|
|
||||||
VALUES ($1, 1, 0)
|
|
||||||
ON CONFLICT (predictor_id) DO UPDATE SET
|
|
||||||
incorrect = predictor_stats.incorrect + 1,
|
|
||||||
streak = 0,
|
|
||||||
updated_at = NOW()
|
|
||||||
`, p.predictorID)
|
|
||||||
}
|
|
||||||
resolved++
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"resolved": resolved})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePredictionLeaderboard handles GET /api/predictions/leaderboard
|
|
||||||
func (s *Server) handlePredictionLeaderboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT predictor_id, correct, incorrect,
|
|
||||||
CASE WHEN (correct + incorrect) > 0
|
|
||||||
THEN ROUND(100.0 * correct / (correct + incorrect), 1)
|
|
||||||
ELSE 0 END AS accuracy,
|
|
||||||
streak, best_streak
|
|
||||||
FROM predictor_stats
|
|
||||||
WHERE (correct + incorrect) >= 5
|
|
||||||
ORDER BY accuracy DESC, correct DESC
|
|
||||||
LIMIT 100
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
PredictorID string `json:"predictor_id"`
|
|
||||||
Correct int `json:"correct"`
|
|
||||||
Incorrect int `json:"incorrect"`
|
|
||||||
Accuracy float64 `json:"accuracy"`
|
|
||||||
Streak int `json:"streak"`
|
|
||||||
BestStreak int `json:"best_streak"`
|
|
||||||
}
|
|
||||||
entries := make([]entry, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var e entry
|
|
||||||
if err := rows.Scan(&e.PredictorID, &e.Correct, &e.Incorrect, &e.Accuracy, &e.Streak, &e.BestStreak); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"leaderboard": entries,
|
|
||||||
"updated_at": time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetPredictions handles GET /api/predictions/{match_id}
|
|
||||||
func (s *Server) handleGetPredictions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
matchID := r.PathValue("match_id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT predictor_id, predicted_bot, correct
|
|
||||||
FROM predictions
|
|
||||||
WHERE match_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, matchID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type pred struct {
|
|
||||||
PredictorID string `json:"predictor_id"`
|
|
||||||
PredictedBot string `json:"predicted_bot"`
|
|
||||||
Correct *bool `json:"correct"`
|
|
||||||
}
|
|
||||||
preds := make([]pred, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var p pred
|
|
||||||
var correct sql.NullBool
|
|
||||||
if err := rows.Scan(&p.PredictorID, &p.PredictedBot, &correct); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if correct.Valid {
|
|
||||||
b := correct.Bool
|
|
||||||
p.Correct = &b
|
|
||||||
}
|
|
||||||
preds = append(preds, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"match_id": matchID,
|
|
||||||
"predictions": preds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var validBotName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{1,30}[a-zA-Z0-9]$`)
|
|
||||||
|
|
||||||
type RegisterRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
EndpointURL string `json:"endpoint_url"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterResponse struct {
|
|
||||||
BotID string `json:"bot_id"`
|
|
||||||
SharedSecret string `json:"shared_secret"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req RegisterRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
|
||||||
req.Owner = strings.TrimSpace(req.Owner)
|
|
||||||
req.EndpointURL = strings.TrimSpace(req.EndpointURL)
|
|
||||||
|
|
||||||
if !validBotName.MatchString(req.Name) {
|
|
||||||
writeError(w, http.StatusBadRequest, "name must be 3-32 alphanumeric/hyphen chars")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.EndpointURL == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "endpoint_url is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Owner == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "owner is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health check the bot endpoint
|
|
||||||
if err := s.checkBotHealth(req.EndpointURL); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("bot health check failed: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
botID, err := generateID("b_", 4) // b_ + 8 hex chars
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate bot ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
secret, err := generateSecret()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate shared secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt secret for storage
|
|
||||||
encryptedSecret := secret // default: store plaintext if no key
|
|
||||||
if s.cfg.EncryptionKey != "" {
|
|
||||||
encryptedSecret, err = encryptSecret(secret, s.cfg.EncryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.db.ExecContext(r.Context(),
|
|
||||||
`INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status, description, last_active)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, 'active', $6, NOW())`,
|
|
||||||
botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret, req.Description,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
|
||||||
writeError(w, http.StatusConflict, "bot name already taken")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to register bot")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, RegisterResponse{
|
|
||||||
BotID: botID,
|
|
||||||
SharedSecret: secret,
|
|
||||||
Message: "Bot registered. Save the shared_secret — it will not be shown again.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) checkBotHealth(endpointURL string) error {
|
|
||||||
url := strings.TrimRight(endpointURL, "/") + "/health"
|
|
||||||
client := &http.Client{Timeout: time.Duration(s.cfg.BotTimeoutSecs) * time.Second}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connection failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("expected 200, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type RotateKeyRequest struct {
|
|
||||||
BotID string `json:"bot_id"`
|
|
||||||
SharedSecret string `json:"shared_secret"`
|
|
||||||
Retire bool `json:"retire,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RotateKeyResponse struct {
|
|
||||||
NewSecret string `json:"new_secret,omitempty"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req RotateKeyRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify current secret
|
|
||||||
var storedSecret string
|
|
||||||
err := s.db.QueryRowContext(r.Context(),
|
|
||||||
`SELECT shared_secret FROM bots WHERE bot_id = $1`, req.BotID,
|
|
||||||
).Scan(&storedSecret)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "bot not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt stored secret for comparison
|
|
||||||
plainSecret := storedSecret
|
|
||||||
if s.cfg.EncryptionKey != "" {
|
|
||||||
plainSecret, err = decryptSecret(storedSecret, s.cfg.EncryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "decryption error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if plainSecret != req.SharedSecret {
|
|
||||||
writeError(w, http.StatusUnauthorized, "invalid shared secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Retire {
|
|
||||||
_, err = s.db.ExecContext(r.Context(),
|
|
||||||
`UPDATE bots SET status = 'retired' WHERE bot_id = $1`, req.BotID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to retire bot")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, RotateKeyResponse{Message: "Bot retired."})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newSecret, err := generateSecret()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate new secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptedSecret := newSecret
|
|
||||||
if s.cfg.EncryptionKey != "" {
|
|
||||||
encryptedSecret, err = encryptSecret(newSecret, s.cfg.EncryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.db.ExecContext(r.Context(),
|
|
||||||
`UPDATE bots SET shared_secret = $1 WHERE bot_id = $2`,
|
|
||||||
encryptedSecret, req.BotID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, RotateKeyResponse{
|
|
||||||
NewSecret: newSecret,
|
|
||||||
Message: "Secret rotated. Save the new secret — it will not be shown again.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleBotStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
botID := r.PathValue("bot_id")
|
|
||||||
|
|
||||||
var bot struct {
|
|
||||||
BotID string `json:"bot_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Rating float64 `json:"rating"`
|
|
||||||
RatingMu float64 `json:"rating_mu"`
|
|
||||||
RatingPhi float64 `json:"rating_phi"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
LastActive *string `json:"last_active,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var desc, lastActive *string
|
|
||||||
err := s.db.QueryRowContext(r.Context(),
|
|
||||||
`SELECT bot_id, name, owner, status, rating_mu, rating_phi, description, created_at, last_active
|
|
||||||
FROM bots WHERE bot_id = $1`, botID,
|
|
||||||
).Scan(&bot.BotID, &bot.Name, &bot.Owner, &bot.Status, &bot.RatingMu, &bot.RatingPhi,
|
|
||||||
&desc, &bot.CreatedAt, &lastActive)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "bot not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bot.Description = desc
|
|
||||||
bot.LastActive = lastActive
|
|
||||||
bot.Rating = bot.RatingMu - 2*bot.RatingPhi // conservative display rating
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, bot)
|
|
||||||
}
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleListSeasons handles GET /api/seasons
|
|
||||||
func (s *Server) handleListSeasons(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at, created_at
|
|
||||||
FROM seasons
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 20
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type seasonEntry struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Theme *string `json:"theme"`
|
|
||||||
RulesVersion string `json:"rules_version"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
ChampionID *string `json:"champion_id"`
|
|
||||||
StartsAt time.Time `json:"starts_at"`
|
|
||||||
EndsAt *time.Time `json:"ends_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
seasons := make([]seasonEntry, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var se seasonEntry
|
|
||||||
var theme, championID sql.NullString
|
|
||||||
var endsAt sql.NullTime
|
|
||||||
if err := rows.Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status,
|
|
||||||
&championID, &se.StartsAt, &endsAt, &se.CreatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if theme.Valid {
|
|
||||||
se.Theme = &theme.String
|
|
||||||
}
|
|
||||||
if championID.Valid {
|
|
||||||
se.ChampionID = &championID.String
|
|
||||||
}
|
|
||||||
if endsAt.Valid {
|
|
||||||
se.EndsAt = &endsAt.Time
|
|
||||||
}
|
|
||||||
seasons = append(seasons, se)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"seasons": seasons})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCreateSeason handles POST /api/seasons
|
|
||||||
func (s *Server) handleCreateSeason(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Theme string `json:"theme"`
|
|
||||||
RulesVersion string `json:"rules_version"`
|
|
||||||
EndsAt string `json:"ends_at"` // RFC3339
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Name == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RulesVersion == "" {
|
|
||||||
req.RulesVersion = "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var endsAt sql.NullTime
|
|
||||||
if req.EndsAt != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, req.EndsAt)
|
|
||||||
if err == nil {
|
|
||||||
endsAt = sql.NullTime{Time: t, Valid: true}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var id int64
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO seasons (name, theme, rules_version, ends_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id
|
|
||||||
`, req.Name, req.Theme, req.RulesVersion, endsAt).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create season")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"season_id": id, "ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetSeason handles GET /api/seasons/{id}
|
|
||||||
func (s *Server) handleGetSeason(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var se struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Theme *string `json:"theme"`
|
|
||||||
RulesVersion string `json:"rules_version"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
ChampionID *string `json:"champion_id"`
|
|
||||||
StartsAt time.Time `json:"starts_at"`
|
|
||||||
EndsAt *time.Time `json:"ends_at"`
|
|
||||||
}
|
|
||||||
var theme, championID sql.NullString
|
|
||||||
var endsAt sql.NullTime
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at
|
|
||||||
FROM seasons WHERE id = $1
|
|
||||||
`, id).Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status,
|
|
||||||
&championID, &se.StartsAt, &endsAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "season not found")
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if theme.Valid {
|
|
||||||
se.Theme = &theme.String
|
|
||||||
}
|
|
||||||
if championID.Valid {
|
|
||||||
se.ChampionID = &championID.String
|
|
||||||
}
|
|
||||||
if endsAt.Valid {
|
|
||||||
se.EndsAt = &endsAt.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get leaderboard snapshot for this season
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT ss.bot_id, b.name, ss.rank, ss.rating, ss.wins, ss.losses, ss.recorded_at
|
|
||||||
FROM season_snapshots ss
|
|
||||||
JOIN bots b ON ss.bot_id = b.bot_id
|
|
||||||
WHERE ss.season_id = $1
|
|
||||||
ORDER BY ss.rank
|
|
||||||
LIMIT 50
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type snap struct {
|
|
||||||
BotID string `json:"bot_id"`
|
|
||||||
BotName string `json:"bot_name"`
|
|
||||||
Rank int `json:"rank"`
|
|
||||||
Rating float64 `json:"rating"`
|
|
||||||
Wins int `json:"wins"`
|
|
||||||
Losses int `json:"losses"`
|
|
||||||
RecordedAt time.Time `json:"recorded_at"`
|
|
||||||
}
|
|
||||||
snapshots := make([]snap, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var sn snap
|
|
||||||
if err := rows.Scan(&sn.BotID, &sn.BotName, &sn.Rank, &sn.Rating, &sn.Wins, &sn.Losses, &sn.RecordedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
snapshots = append(snapshots, sn)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"season": se,
|
|
||||||
"standings": snapshots,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSnapshotSeason handles POST /api/seasons/{id}/snapshot
|
|
||||||
// Takes a snapshot of the current leaderboard for the season archive.
|
|
||||||
func (s *Server) handleSnapshotSeason(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Check season exists
|
|
||||||
var seasonName string
|
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT name FROM seasons WHERE id = $1`, id).Scan(&seasonName)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "season not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take snapshot of current leaderboard
|
|
||||||
_, err = s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses)
|
|
||||||
SELECT $1, bot_id,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY rating_mu DESC),
|
|
||||||
rating_mu,
|
|
||||||
(SELECT COUNT(*) FROM match_participants mp2
|
|
||||||
JOIN matches m2 ON mp2.match_id = m2.match_id
|
|
||||||
WHERE mp2.bot_id = b.bot_id AND m2.status = 'completed'
|
|
||||||
AND m2.winner = mp2.player_slot),
|
|
||||||
(SELECT COUNT(*) FROM match_participants mp3
|
|
||||||
JOIN matches m3 ON mp3.match_id = m3.match_id
|
|
||||||
WHERE mp3.bot_id = b.bot_id AND m3.status = 'completed'
|
|
||||||
AND m3.winner != mp3.player_slot AND m3.winner >= 0)
|
|
||||||
FROM bots b
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY rating_mu DESC
|
|
||||||
LIMIT 100
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to snapshot season")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCloseSeason handles POST /api/seasons/{id}/close
|
|
||||||
func (s *Server) handleCloseSeason(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Find current leader
|
|
||||||
var championID sql.NullString
|
|
||||||
_ = s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT bot_id FROM season_snapshots
|
|
||||||
WHERE season_id = $1
|
|
||||||
ORDER BY rank ASC LIMIT 1
|
|
||||||
`, id).Scan(&championID)
|
|
||||||
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
UPDATE seasons SET status = 'archived', champion_id = $1, ends_at = NOW()
|
|
||||||
WHERE id = $2
|
|
||||||
`, championID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to close season")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleCreateSeries handles POST /api/series
|
|
||||||
func (s *Server) handleCreateSeries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
BotAID string `json:"bot_a_id"`
|
|
||||||
BotBID string `json:"bot_b_id"`
|
|
||||||
Format int `json:"format"` // best of N
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.BotAID == "" || req.BotBID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "bot_a_id and bot_b_id are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Format < 1 {
|
|
||||||
req.Format = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var id int64
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO series (bot_a_id, bot_b_id, format)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id
|
|
||||||
`, req.BotAID, req.BotBID, req.Format).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create series")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"series_id": id, "ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListSeries handles GET /api/series
|
|
||||||
func (s *Server) handleListSeries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name,
|
|
||||||
s.format, s.a_wins, s.b_wins, s.status, s.winner_id,
|
|
||||||
s.created_at, s.updated_at
|
|
||||||
FROM series s
|
|
||||||
JOIN bots ba ON s.bot_a_id = ba.bot_id
|
|
||||||
JOIN bots bb ON s.bot_b_id = bb.bot_id
|
|
||||||
ORDER BY s.updated_at DESC
|
|
||||||
LIMIT 50
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type seriesEntry struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
BotAID string `json:"bot_a_id"`
|
|
||||||
BotAName string `json:"bot_a_name"`
|
|
||||||
BotBID string `json:"bot_b_id"`
|
|
||||||
BotBName string `json:"bot_b_name"`
|
|
||||||
Format int `json:"format"`
|
|
||||||
AWins int `json:"a_wins"`
|
|
||||||
BWins int `json:"b_wins"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
WinnerID *string `json:"winner_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
entries := make([]seriesEntry, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var e seriesEntry
|
|
||||||
var winnerID sql.NullString
|
|
||||||
if err := rows.Scan(&e.ID, &e.BotAID, &e.BotAName, &e.BotBID, &e.BotBName,
|
|
||||||
&e.Format, &e.AWins, &e.BWins, &e.Status, &winnerID,
|
|
||||||
&e.CreatedAt, &e.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if winnerID.Valid {
|
|
||||||
e.WinnerID = &winnerID.String
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"series": entries})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetSeries handles GET /api/series/{id}
|
|
||||||
func (s *Server) handleGetSeries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
type game struct {
|
|
||||||
MatchID string `json:"match_id"`
|
|
||||||
GameNum int `json:"game_num"`
|
|
||||||
WinnerID *string `json:"winner_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT match_id, game_num, winner_id, created_at
|
|
||||||
FROM series_games
|
|
||||||
WHERE series_id = $1
|
|
||||||
ORDER BY game_num
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
games := make([]game, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var g game
|
|
||||||
var winnerID sql.NullString
|
|
||||||
if err := rows.Scan(&g.MatchID, &g.GameNum, &winnerID, &g.CreatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if winnerID.Valid {
|
|
||||||
g.WinnerID = &winnerID.String
|
|
||||||
}
|
|
||||||
games = append(games, g)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get series header
|
|
||||||
var se struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
BotAID string `json:"bot_a_id"`
|
|
||||||
BotAName string `json:"bot_a_name"`
|
|
||||||
BotBID string `json:"bot_b_id"`
|
|
||||||
BotBName string `json:"bot_b_name"`
|
|
||||||
Format int `json:"format"`
|
|
||||||
AWins int `json:"a_wins"`
|
|
||||||
BWins int `json:"b_wins"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
WinnerID *string `json:"winner_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
var winnerID sql.NullString
|
|
||||||
err = s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name,
|
|
||||||
s.format, s.a_wins, s.b_wins, s.status, s.winner_id, s.created_at
|
|
||||||
FROM series s
|
|
||||||
JOIN bots ba ON s.bot_a_id = ba.bot_id
|
|
||||||
JOIN bots bb ON s.bot_b_id = bb.bot_id
|
|
||||||
WHERE s.id = $1
|
|
||||||
`, id).Scan(&se.ID, &se.BotAID, &se.BotAName, &se.BotBID, &se.BotBName,
|
|
||||||
&se.Format, &se.AWins, &se.BWins, &se.Status, &winnerID, &se.CreatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "series not found")
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if winnerID.Valid {
|
|
||||||
se.WinnerID = &winnerID.String
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"series": se,
|
|
||||||
"games": games,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddSeriesGame handles POST /api/series/{id}/games
|
|
||||||
func (s *Server) handleAddSeriesGame(w http.ResponseWriter, r *http.Request) {
|
|
||||||
seriesID := r.PathValue("id")
|
|
||||||
var req struct {
|
|
||||||
MatchID string `json:"match_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Get series info
|
|
||||||
var botAID, botBID string
|
|
||||||
var format, aWins, bWins int
|
|
||||||
var status string
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT bot_a_id, bot_b_id, format, a_wins, b_wins, status
|
|
||||||
FROM series WHERE id = $1
|
|
||||||
`, seriesID).Scan(&botAID, &botBID, &format, &aWins, &bWins, &status)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "series not found")
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if status != "active" {
|
|
||||||
writeError(w, http.StatusConflict, "series is not active")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get match winner
|
|
||||||
var matchWinnerSlot sql.NullInt64
|
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT winner FROM matches WHERE match_id = $1`, req.MatchID).Scan(&matchWinnerSlot)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeError(w, http.StatusNotFound, "match not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which bot won
|
|
||||||
var winnerBotID sql.NullString
|
|
||||||
if matchWinnerSlot.Valid {
|
|
||||||
slot := int(matchWinnerSlot.Int64)
|
|
||||||
if slot == 0 {
|
|
||||||
winnerBotID.String = botAID
|
|
||||||
winnerBotID.Valid = true
|
|
||||||
} else if slot == 1 {
|
|
||||||
winnerBotID.String = botBID
|
|
||||||
winnerBotID.Valid = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next game number
|
|
||||||
var gameNum int
|
|
||||||
_ = s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(MAX(game_num), 0) + 1 FROM series_games WHERE series_id = $1
|
|
||||||
`, seriesID).Scan(&gameNum)
|
|
||||||
|
|
||||||
// Insert game
|
|
||||||
_, err = s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO series_games (series_id, match_id, game_num, winner_id)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
`, seriesID, req.MatchID, gameNum, winnerBotID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to add game")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update win counts and check if series is decided
|
|
||||||
if winnerBotID.Valid {
|
|
||||||
if winnerBotID.String == botAID {
|
|
||||||
aWins++
|
|
||||||
} else {
|
|
||||||
bWins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toWin := (format / 2) + 1
|
|
||||||
newStatus := "active"
|
|
||||||
var seriesWinner sql.NullString
|
|
||||||
if aWins >= toWin {
|
|
||||||
newStatus = "completed"
|
|
||||||
seriesWinner.String = botAID
|
|
||||||
seriesWinner.Valid = true
|
|
||||||
} else if bWins >= toWin {
|
|
||||||
newStatus = "completed"
|
|
||||||
seriesWinner.String = botBID
|
|
||||||
seriesWinner.Valid = true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = s.db.ExecContext(ctx, `
|
|
||||||
UPDATE series SET a_wins=$1, b_wins=$2, status=$3, winner_id=$4, updated_at=NOW()
|
|
||||||
WHERE id = $5
|
|
||||||
`, aWins, bWins, newStatus, seriesWinner, seriesID)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"game_num": gameNum,
|
|
||||||
"a_wins": aWins,
|
|
||||||
"b_wins": bWins,
|
|
||||||
"status": newStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -8,21 +8,18 @@ import (
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Server is a stub for the v1 API.
|
||||||
|
// The full API (registration, job claim/result, ratings) is deferred.
|
||||||
|
// Matchmaking is handled by acb-matchmaker; workers communicate directly with PostgreSQL.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
// Note: alerter removed - alerting now handled by acb-matchmaker deployment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
mux.HandleFunc("GET /ready", s.handleReady)
|
mux.HandleFunc("GET /ready", s.handleReady)
|
||||||
mux.HandleFunc("POST /api/register", s.handleRegister)
|
|
||||||
mux.HandleFunc("POST /api/rotate-key", s.handleRotateKey)
|
|
||||||
mux.HandleFunc("GET /api/status/{bot_id}", s.handleBotStatus)
|
|
||||||
mux.HandleFunc("POST /api/jobs/claim", s.handleJobClaim)
|
|
||||||
mux.HandleFunc("POST /api/jobs/{job_id}/result", s.handleJobResult)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -9,8 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestServer creates a Server with no database or redis (for unit tests
|
// newTestServer creates a Server with no database or redis (for unit tests
|
||||||
// that don't need them). For handler tests that need DB, use the integration
|
// that don't need them).
|
||||||
// tests pattern with a test database.
|
|
||||||
func newTestServer() *Server {
|
func newTestServer() *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
|
|
@ -41,123 +39,6 @@ func TestHealthEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticateWorker(t *testing.T) {
|
|
||||||
srv := newTestServer()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
value string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"bearer", "Authorization", "Bearer test-key", true},
|
|
||||||
{"x-api-key", "X-API-Key", "test-key", true},
|
|
||||||
{"wrong key", "Authorization", "Bearer wrong", false},
|
|
||||||
{"no header", "", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
if tt.header != "" {
|
|
||||||
req.Header.Set(tt.header, tt.value)
|
|
||||||
}
|
|
||||||
got := srv.authenticateWorker(req)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("authenticateWorker() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthenticateWorker_NoKeyConfigured(t *testing.T) {
|
|
||||||
srv := &Server{cfg: Config{WorkerAPIKey: ""}}
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
if !srv.authenticateWorker(req) {
|
|
||||||
t.Error("with no key configured, all requests should be authenticated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterValidation(t *testing.T) {
|
|
||||||
srv := newTestServer()
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
srv.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body RegisterRequest
|
|
||||||
wantCode int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing name",
|
|
||||||
body: RegisterRequest{Name: "", EndpointURL: "http://example.com", Owner: "alice"},
|
|
||||||
wantCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "name too short",
|
|
||||||
body: RegisterRequest{Name: "ab", EndpointURL: "http://example.com", Owner: "alice"},
|
|
||||||
wantCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "name with spaces",
|
|
||||||
body: RegisterRequest{Name: "my bot", EndpointURL: "http://example.com", Owner: "alice"},
|
|
||||||
wantCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing endpoint",
|
|
||||||
body: RegisterRequest{Name: "valid-bot", EndpointURL: "", Owner: "alice"},
|
|
||||||
wantCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing owner",
|
|
||||||
body: RegisterRequest{Name: "valid-bot", EndpointURL: "http://example.com", Owner: ""},
|
|
||||||
wantCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
body, _ := json.Marshal(tt.body)
|
|
||||||
req := httptest.NewRequest("POST", "/api/register", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantCode {
|
|
||||||
t.Errorf("status = %d, want %d; body: %s", w.Code, tt.wantCode, w.Body.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidBotName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
valid bool
|
|
||||||
}{
|
|
||||||
{"simple", "mybot", true},
|
|
||||||
{"with-hyphen", "my-bot", true},
|
|
||||||
{"with-numbers", "bot123", true},
|
|
||||||
{"mixed", "My-Bot-42", true},
|
|
||||||
{"three-chars", "abc", true},
|
|
||||||
{"too-short", "ab", false},
|
|
||||||
{"starts-with-hyphen", "-bot", false},
|
|
||||||
{"ends-with-hyphen", "bot-", false},
|
|
||||||
{"spaces", "my bot", false},
|
|
||||||
{"special", "bot@123", false},
|
|
||||||
{"empty", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := validBotName.MatchString(tt.input)
|
|
||||||
if got != tt.valid {
|
|
||||||
t.Errorf("validBotName(%q) = %v, want %v", tt.input, got, tt.valid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteJSON(t *testing.T) {
|
func TestWriteJSON(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"key": "value"})
|
writeJSON(w, http.StatusCreated, map[string]string{"key": "value"})
|
||||||
|
|
@ -190,33 +71,3 @@ func TestWriteError(t *testing.T) {
|
||||||
t.Errorf("body = %v, want error=test error", body)
|
t.Errorf("body = %v, want error=test error", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobClaimRequiresAuth(t *testing.T) {
|
|
||||||
srv := newTestServer()
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
srv.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
body, _ := json.Marshal(JobClaimRequest{WorkerID: "w1"})
|
|
||||||
req := httptest.NewRequest("POST", "/api/jobs/claim", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("job claim without auth: status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJobResultRequiresAuth(t *testing.T) {
|
|
||||||
srv := newTestServer()
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
srv.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
body, _ := json.Marshal(JobResultRequest{WorkerID: "w1", Condition: "score", TurnCount: 100})
|
|
||||||
req := httptest.NewRequest("POST", "/api/jobs/j_12345678/result", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("job result without auth: status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
5
cmd/acb-indexer/.gitignore
vendored
5
cmd/acb-indexer/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.env
|
|
||||||
data/
|
|
||||||
*.log
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# AI Code Battle Index Builder Container
|
|
||||||
# Generates static JSON index files and deploys to Cloudflare Pages
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci --only=production
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY src/ ./src/
|
|
||||||
|
|
||||||
# Build TypeScript
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
# Environment variables (set at runtime)
|
|
||||||
# API_URL - Worker API URL (e.g., https://api.aicodebattle.com)
|
|
||||||
# API_KEY - Worker API key
|
|
||||||
# OUTPUT_DIR - Output directory (default: /app/data)
|
|
||||||
# DEPLOY_COMMAND - Optional deploy command (e.g., wrangler pages deploy)
|
|
||||||
|
|
||||||
ENV OUTPUT_DIR=/app/data
|
|
||||||
|
|
||||||
# Run the index builder
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
2232
cmd/acb-indexer/package-lock.json
generated
2232
cmd/acb-indexer/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "acb-indexer",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "AI Code Battle Index Builder - Generates static JSON index files for Cloudflare Pages",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "tsx src/index.ts",
|
|
||||||
"test": "vitest"
|
|
||||||
},
|
|
||||||
"keywords": ["aicodebattle", "indexer", "cloudflare-pages"],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^16.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.11.24",
|
|
||||||
"tsx": "^4.7.1",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"vitest": "^1.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
// API Client for fetching data from Worker API
|
|
||||||
|
|
||||||
import type { ApiClientConfig, ExportData } from './types.js';
|
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
private apiUrl: string;
|
|
||||||
private apiKey: string;
|
|
||||||
|
|
||||||
constructor(config: ApiClientConfig) {
|
|
||||||
this.apiUrl = config.apiUrl.replace(/\/$/, '');
|
|
||||||
this.apiKey = config.apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all data needed for index building
|
|
||||||
*/
|
|
||||||
async fetchExportData(): Promise<ExportData> {
|
|
||||||
const response = await fetch(`${this.apiUrl}/api/data/export`, {
|
|
||||||
headers: {
|
|
||||||
'X-API-Key': this.apiKey,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`API request failed: ${response.status} - ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json() as { success: boolean; data?: ExportData; error?: string };
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(`API returned error: ${result.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error('API returned no data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
// Index Generator Tests
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { IndexGenerator } from './generator.js';
|
|
||||||
import type { ExportData, ExportBot, ExportMatch } from './types.js';
|
|
||||||
|
|
||||||
function createMockData(): ExportData {
|
|
||||||
const bots: ExportBot[] = [
|
|
||||||
{
|
|
||||||
id: 'bot-1',
|
|
||||||
name: 'TestBot1',
|
|
||||||
owner_id: 'owner-1',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 50,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
matches_played: 10,
|
|
||||||
matches_won: 7,
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
updated_at: '2026-03-01T00:00:00Z',
|
|
||||||
health_status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bot-2',
|
|
||||||
name: 'TestBot2',
|
|
||||||
owner_id: 'owner-2',
|
|
||||||
rating: 1450,
|
|
||||||
rating_deviation: 60,
|
|
||||||
rating_volatility: 0.07,
|
|
||||||
matches_played: 5,
|
|
||||||
matches_won: 2,
|
|
||||||
created_at: '2026-01-15T00:00:00Z',
|
|
||||||
updated_at: '2026-03-01T00:00:00Z',
|
|
||||||
health_status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bot-3',
|
|
||||||
name: 'UnrankedBot',
|
|
||||||
owner_id: 'owner-3',
|
|
||||||
rating: 1200,
|
|
||||||
rating_deviation: 350,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
created_at: '2026-02-01T00:00:00Z',
|
|
||||||
updated_at: '2026-02-01T00:00:00Z',
|
|
||||||
health_status: 'unknown',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const matches: ExportMatch[] = [
|
|
||||||
{
|
|
||||||
id: 'match-1',
|
|
||||||
status: 'completed',
|
|
||||||
winner_id: 'bot-1',
|
|
||||||
turns: 50,
|
|
||||||
end_reason: 'domination',
|
|
||||||
map_id: 'map-1',
|
|
||||||
created_at: '2026-03-01T10:00:00Z',
|
|
||||||
completed_at: '2026-03-01T10:05:00Z',
|
|
||||||
participants: [
|
|
||||||
{
|
|
||||||
bot_id: 'bot-1',
|
|
||||||
player_index: 0,
|
|
||||||
score: 100,
|
|
||||||
rating_before: 1480,
|
|
||||||
rating_after: 1500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bot_id: 'bot-2',
|
|
||||||
player_index: 1,
|
|
||||||
score: 50,
|
|
||||||
rating_before: 1470,
|
|
||||||
rating_after: 1450,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
bots,
|
|
||||||
matches,
|
|
||||||
rating_history: [
|
|
||||||
{
|
|
||||||
bot_id: 'bot-1',
|
|
||||||
rating: 1480,
|
|
||||||
rating_deviation: 55,
|
|
||||||
recorded_at: '2026-02-15T00:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bot_id: 'bot-1',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 50,
|
|
||||||
recorded_at: '2026-03-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generated_at: '2026-03-24T08:00:00Z',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('IndexGenerator', () => {
|
|
||||||
it('generates leaderboard with correct rankings', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const leaderboard = generator.generateLeaderboard();
|
|
||||||
|
|
||||||
expect(leaderboard.updated_at).toBe('2026-03-24T08:00:00Z');
|
|
||||||
expect(leaderboard.entries).toHaveLength(2); // Only bots with matches
|
|
||||||
expect(leaderboard.entries[0].bot_id).toBe('bot-1');
|
|
||||||
expect(leaderboard.entries[0].rank).toBe(1);
|
|
||||||
expect(leaderboard.entries[0].rating).toBe(1500);
|
|
||||||
expect(leaderboard.entries[0].win_rate).toBe(70); // 7/10 * 100
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates bot directory', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const directory = generator.generateBotDirectory();
|
|
||||||
|
|
||||||
expect(directory.bots).toHaveLength(3);
|
|
||||||
expect(directory.bots[0].id).toBe('bot-1');
|
|
||||||
expect(directory.bots[0].name).toBe('TestBot1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates bot profile with rating history', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const profile = generator.generateBotProfile('bot-1');
|
|
||||||
|
|
||||||
expect(profile).not.toBeNull();
|
|
||||||
expect(profile!.id).toBe('bot-1');
|
|
||||||
expect(profile!.name).toBe('TestBot1');
|
|
||||||
expect(profile!.rating_history).toHaveLength(2);
|
|
||||||
expect(profile!.recent_matches).toHaveLength(1);
|
|
||||||
expect(profile!.recent_matches[0].participants[0].won).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for non-existent bot profile', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const profile = generator.generateBotProfile('non-existent');
|
|
||||||
|
|
||||||
expect(profile).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates match index', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const matchIndex = generator.generateMatchIndex();
|
|
||||||
|
|
||||||
expect(matchIndex.matches).toHaveLength(1);
|
|
||||||
expect(matchIndex.matches[0].id).toBe('match-1');
|
|
||||||
expect(matchIndex.matches[0].winner_id).toBe('bot-1');
|
|
||||||
expect(matchIndex.matches[0].participants).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates all indexes at once', () => {
|
|
||||||
const generator = new IndexGenerator(createMockData());
|
|
||||||
const all = generator.generateAll();
|
|
||||||
|
|
||||||
expect(all.leaderboard.entries).toHaveLength(2);
|
|
||||||
expect(all.botDirectory.bots).toHaveLength(3);
|
|
||||||
expect(all.botProfiles.size).toBe(3);
|
|
||||||
expect(all.matchIndex.matches).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// Index Generator - Creates static JSON index files
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExportData,
|
|
||||||
ExportBot,
|
|
||||||
ExportMatch,
|
|
||||||
LeaderboardIndex,
|
|
||||||
LeaderboardEntry,
|
|
||||||
BotDirectory,
|
|
||||||
BotDirectoryEntry,
|
|
||||||
BotProfile,
|
|
||||||
MatchIndex,
|
|
||||||
MatchSummary,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
export class IndexGenerator {
|
|
||||||
private data: ExportData;
|
|
||||||
private botNameMap: Map<string, string>;
|
|
||||||
|
|
||||||
constructor(data: ExportData) {
|
|
||||||
this.data = data;
|
|
||||||
this.botNameMap = new Map(data.bots.map(b => [b.id, b.name]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate leaderboard.json
|
|
||||||
*/
|
|
||||||
generateLeaderboard(): LeaderboardIndex {
|
|
||||||
const entries: LeaderboardEntry[] = this.data.bots
|
|
||||||
.filter(bot => bot.matches_played > 0)
|
|
||||||
.map((bot, index) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
bot_id: bot.id,
|
|
||||||
name: bot.name,
|
|
||||||
owner_id: bot.owner_id,
|
|
||||||
rating: Math.round(bot.rating),
|
|
||||||
rating_deviation: Math.round(bot.rating_deviation * 10) / 10,
|
|
||||||
matches_played: bot.matches_played,
|
|
||||||
matches_won: bot.matches_won,
|
|
||||||
win_rate: bot.matches_played > 0
|
|
||||||
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
|
|
||||||
: 0,
|
|
||||||
health_status: bot.health_status,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
updated_at: this.data.generated_at,
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate bots/index.json - bot directory
|
|
||||||
*/
|
|
||||||
generateBotDirectory(): BotDirectory {
|
|
||||||
const bots: BotDirectoryEntry[] = this.data.bots.map(bot => ({
|
|
||||||
id: bot.id,
|
|
||||||
name: bot.name,
|
|
||||||
rating: Math.round(bot.rating),
|
|
||||||
matches_played: bot.matches_played,
|
|
||||||
win_rate: bot.matches_played > 0
|
|
||||||
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
|
|
||||||
: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
updated_at: this.data.generated_at,
|
|
||||||
bots,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate individual bot profile
|
|
||||||
*/
|
|
||||||
generateBotProfile(botId: string): BotProfile | null {
|
|
||||||
const bot = this.data.bots.find(b => b.id === botId);
|
|
||||||
if (!bot) return null;
|
|
||||||
|
|
||||||
// Get rating history for this bot
|
|
||||||
const ratingHistory = this.data.rating_history
|
|
||||||
.filter(h => h.bot_id === botId)
|
|
||||||
.sort((a, b) => a.recorded_at.localeCompare(b.recorded_at));
|
|
||||||
|
|
||||||
// Get recent matches for this bot (last 20)
|
|
||||||
const recentMatches = this.data.matches
|
|
||||||
.filter(m => m.participants.some(p => p.bot_id === botId))
|
|
||||||
.slice(0, 20)
|
|
||||||
.map(m => this.generateMatchSummary(m));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: bot.id,
|
|
||||||
name: bot.name,
|
|
||||||
owner_id: bot.owner_id,
|
|
||||||
rating: Math.round(bot.rating),
|
|
||||||
rating_deviation: Math.round(bot.rating_deviation * 10) / 10,
|
|
||||||
rating_volatility: Math.round(bot.rating_volatility * 10000) / 10000,
|
|
||||||
matches_played: bot.matches_played,
|
|
||||||
matches_won: bot.matches_won,
|
|
||||||
win_rate: bot.matches_played > 0
|
|
||||||
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
|
|
||||||
: 0,
|
|
||||||
health_status: bot.health_status,
|
|
||||||
created_at: bot.created_at,
|
|
||||||
updated_at: bot.updated_at,
|
|
||||||
rating_history: ratingHistory,
|
|
||||||
recent_matches: recentMatches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate matches/index.json - recent match list
|
|
||||||
*/
|
|
||||||
generateMatchIndex(): MatchIndex {
|
|
||||||
const matches = this.data.matches.map(m => this.generateMatchSummary(m));
|
|
||||||
|
|
||||||
return {
|
|
||||||
updated_at: this.data.generated_at,
|
|
||||||
matches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate match summary for a single match
|
|
||||||
*/
|
|
||||||
private generateMatchSummary(match: ExportMatch): MatchSummary {
|
|
||||||
return {
|
|
||||||
id: match.id,
|
|
||||||
completed_at: match.completed_at,
|
|
||||||
participants: match.participants.map(p => ({
|
|
||||||
bot_id: p.bot_id,
|
|
||||||
name: this.botNameMap.get(p.bot_id) || 'Unknown',
|
|
||||||
score: p.score,
|
|
||||||
won: p.bot_id === match.winner_id,
|
|
||||||
})),
|
|
||||||
winner_id: match.winner_id,
|
|
||||||
turns: match.turns,
|
|
||||||
end_reason: match.end_reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all index files
|
|
||||||
*/
|
|
||||||
generateAll(): {
|
|
||||||
leaderboard: LeaderboardIndex;
|
|
||||||
botDirectory: BotDirectory;
|
|
||||||
botProfiles: Map<string, BotProfile>;
|
|
||||||
matchIndex: MatchIndex;
|
|
||||||
} {
|
|
||||||
const botProfiles = new Map<string, BotProfile>();
|
|
||||||
|
|
||||||
for (const bot of this.data.bots) {
|
|
||||||
const profile = this.generateBotProfile(bot.id);
|
|
||||||
if (profile) {
|
|
||||||
botProfiles.set(bot.id, profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
leaderboard: this.generateLeaderboard(),
|
|
||||||
botDirectory: this.generateBotDirectory(),
|
|
||||||
botProfiles,
|
|
||||||
matchIndex: this.generateMatchIndex(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// AI Code Battle Index Builder
|
|
||||||
// Fetches data from Worker API and generates static JSON index files
|
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
import 'dotenv/config';
|
|
||||||
import { ApiClient } from './api.js';
|
|
||||||
import { IndexGenerator } from './generator.js';
|
|
||||||
import { FileWriter } from './writer.js';
|
|
||||||
import type { EvolutionLiveData } from './types.js';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
interface Config {
|
|
||||||
apiUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
outputDir: string;
|
|
||||||
deployCommand?: string;
|
|
||||||
evolutionDataPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfig(): Config {
|
|
||||||
const apiUrl = process.env.API_URL;
|
|
||||||
const apiKey = process.env.API_KEY;
|
|
||||||
const outputDir = process.env.OUTPUT_DIR || './data';
|
|
||||||
const deployCommand = process.env.DEPLOY_COMMAND;
|
|
||||||
const evolutionDataPath = process.env.EVOLUTION_DATA_PATH;
|
|
||||||
|
|
||||||
if (!apiUrl) {
|
|
||||||
console.error('ERROR: API_URL environment variable is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.error('ERROR: API_KEY environment variable is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiUrl,
|
|
||||||
apiKey,
|
|
||||||
outputDir,
|
|
||||||
deployCommand,
|
|
||||||
evolutionDataPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runIndexBuilder(config: Config): Promise<void> {
|
|
||||||
console.log('AI Code Battle Index Builder');
|
|
||||||
console.log('============================');
|
|
||||||
console.log(`API URL: ${config.apiUrl}`);
|
|
||||||
console.log(`Output directory: ${config.outputDir}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Initialize components
|
|
||||||
const apiClient = new ApiClient({
|
|
||||||
apiUrl: config.apiUrl,
|
|
||||||
apiKey: config.apiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileWriter = new FileWriter(config.outputDir);
|
|
||||||
|
|
||||||
// Step 1: Fetch data from API
|
|
||||||
console.log('Fetching data from Worker API...');
|
|
||||||
const data = await apiClient.fetchExportData();
|
|
||||||
console.log(` - ${data.bots.length} bots`);
|
|
||||||
console.log(` - ${data.matches.length} matches`);
|
|
||||||
console.log(` - ${data.rating_history.length} rating history entries`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Step 2: Generate index files
|
|
||||||
console.log('Generating index files...');
|
|
||||||
const generator = new IndexGenerator(data);
|
|
||||||
const indexes = generator.generateAll();
|
|
||||||
|
|
||||||
// Step 3: Write files to disk
|
|
||||||
console.log('Writing index files...');
|
|
||||||
await fileWriter.writeAll(indexes);
|
|
||||||
|
|
||||||
// Step 4: Deploy (optional)
|
|
||||||
if (config.deployCommand) {
|
|
||||||
console.log('\nDeploying to Cloudflare Pages...');
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execAsync(config.deployCommand, {
|
|
||||||
cwd: config.outputDir,
|
|
||||||
});
|
|
||||||
if (stdout) console.log(stdout);
|
|
||||||
if (stderr) console.error(stderr);
|
|
||||||
console.log('Deploy complete!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Deploy failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const config = getConfig();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runIndexBuilder(config);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Index builder failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run if executed directly
|
|
||||||
main();
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
// Narrative Engine - generates weekly meta report blog posts from match data.
|
|
||||||
// Optionally enhances prose via the Anthropic API when ANTHROPIC_API_KEY is set.
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExportData,
|
|
||||||
ExportMatch,
|
|
||||||
ExportBot,
|
|
||||||
BlogPost,
|
|
||||||
BlogWeekStats,
|
|
||||||
BlogIndex,
|
|
||||||
EvolutionLiveData,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Week helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function startOfWeek(d: Date): Date {
|
|
||||||
const day = d.getUTCDay(); // 0=Sun
|
|
||||||
const diff = (day === 0 ? -6 : 1 - day); // Monday
|
|
||||||
const out = new Date(d);
|
|
||||||
out.setUTCDate(d.getUTCDate() + diff);
|
|
||||||
out.setUTCHours(0, 0, 0, 0);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoDate(d: Date): string {
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekSlug(weekStart: Date): string {
|
|
||||||
return `week-${isoDate(weekStart)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Stats extraction
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function matchesInWeek(matches: ExportMatch[], weekStart: Date): ExportMatch[] {
|
|
||||||
const start = weekStart.getTime();
|
|
||||||
const end = start + 7 * 24 * 60 * 60 * 1000;
|
|
||||||
return matches.filter(m => {
|
|
||||||
if (!m.completed_at) return false;
|
|
||||||
const t = new Date(m.completed_at).getTime();
|
|
||||||
return t >= start && t < end;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeWeekStats(
|
|
||||||
weekMatches: ExportMatch[],
|
|
||||||
bots: ExportBot[],
|
|
||||||
evo: EvolutionLiveData | null,
|
|
||||||
): BlogWeekStats {
|
|
||||||
const botMap = new Map<string, ExportBot>(bots.map(b => [b.id, b]));
|
|
||||||
|
|
||||||
// Top bot by rating
|
|
||||||
const sorted = [...bots].sort((a, b) => b.rating - a.rating);
|
|
||||||
const topBot = sorted[0];
|
|
||||||
|
|
||||||
// Match activity per bot
|
|
||||||
const activityCount = new Map<string, number>();
|
|
||||||
for (const m of weekMatches) {
|
|
||||||
for (const p of m.participants) {
|
|
||||||
activityCount.set(p.bot_id, (activityCount.get(p.bot_id) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mostActiveBot = topBot?.name ?? 'N/A';
|
|
||||||
let mostActiveBotMatches = 0;
|
|
||||||
for (const [id, count] of activityCount) {
|
|
||||||
if (count > mostActiveBotMatches) {
|
|
||||||
mostActiveBotMatches = count;
|
|
||||||
mostActiveBot = botMap.get(id)?.name ?? id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Biggest upset: lower-rated bot beats higher-rated by the largest margin
|
|
||||||
let biggestUpset: string | null = null;
|
|
||||||
let maxUpsetMargin = 0;
|
|
||||||
for (const m of weekMatches) {
|
|
||||||
if (!m.winner_id || m.participants.length < 2) continue;
|
|
||||||
const winner = m.participants.find(p => p.bot_id === m.winner_id);
|
|
||||||
if (!winner) continue;
|
|
||||||
const loser = m.participants.find(p => p.bot_id !== m.winner_id);
|
|
||||||
if (!loser) continue;
|
|
||||||
const winnerBot = botMap.get(winner.bot_id);
|
|
||||||
const loserBot = botMap.get(loser.bot_id);
|
|
||||||
if (!winnerBot || !loserBot) continue;
|
|
||||||
const margin = loserBot.rating - winnerBot.rating;
|
|
||||||
if (margin > maxUpsetMargin) {
|
|
||||||
maxUpsetMargin = margin;
|
|
||||||
biggestUpset = `${winnerBot.name} defeated ${loserBot.name} (+${Math.round(margin)} rating gap)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Island leader from evolution data
|
|
||||||
let islandLeader: string | null = null;
|
|
||||||
if (evo) {
|
|
||||||
let bestFitness = -Infinity;
|
|
||||||
for (const [island, stat] of Object.entries(evo.islands)) {
|
|
||||||
if (stat.best_fitness > bestFitness) {
|
|
||||||
bestFitness = stat.best_fitness;
|
|
||||||
islandLeader = island;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
matches_played: weekMatches.length,
|
|
||||||
top_bot: topBot?.name ?? 'N/A',
|
|
||||||
top_bot_rating: Math.round(topBot?.rating ?? 0),
|
|
||||||
biggest_upset: biggestUpset,
|
|
||||||
most_active_bot: mostActiveBot,
|
|
||||||
most_active_bot_matches: mostActiveBotMatches,
|
|
||||||
island_leader: islandLeader,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Template-based narrative (used when no LLM key is available)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function templateNarrative(weekStart: Date, stats: BlogWeekStats): { title: string; summary: string; body_html: string } {
|
|
||||||
const weekLabel = isoDate(weekStart);
|
|
||||||
const title = `Meta Report: Week of ${weekLabel}`;
|
|
||||||
|
|
||||||
const summary =
|
|
||||||
`This week ${stats.matches_played} matches were played. ` +
|
|
||||||
`${stats.top_bot} leads the leaderboard at ${stats.top_bot_rating} rating. ` +
|
|
||||||
(stats.biggest_upset
|
|
||||||
? `The biggest upset saw ${stats.biggest_upset}. `
|
|
||||||
: '') +
|
|
||||||
`${stats.most_active_bot} was the most active with ${stats.most_active_bot_matches} matches.`;
|
|
||||||
|
|
||||||
const upsetSection = stats.biggest_upset
|
|
||||||
? `<h3>Biggest Upset</h3>
|
|
||||||
<p>${stats.biggest_upset}.</p>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const evoSection = stats.island_leader
|
|
||||||
? `<h3>Evolution Observatory</h3>
|
|
||||||
<p>Island <strong>${stats.island_leader}</strong> leads the evolution pipeline this week.</p>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const body_html = `
|
|
||||||
<h2>Overview</h2>
|
|
||||||
<p>
|
|
||||||
The week of <strong>${weekLabel}</strong> produced <strong>${stats.matches_played}</strong> completed matches
|
|
||||||
on the AI Code Battle platform.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Leaderboard Snapshot</h3>
|
|
||||||
<p>
|
|
||||||
<strong>${stats.top_bot}</strong> holds the top position with a rating of
|
|
||||||
<strong>${stats.top_bot_rating}</strong>. The competition remains fierce as bots jockey
|
|
||||||
for position in the weekly rankings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Most Active Competitor</h3>
|
|
||||||
<p>
|
|
||||||
<strong>${stats.most_active_bot}</strong> played the most matches this week
|
|
||||||
(<strong>${stats.most_active_bot_matches}</strong> games), demonstrating consistent
|
|
||||||
availability and aggressive scheduling.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
${upsetSection}
|
|
||||||
|
|
||||||
${evoSection}
|
|
||||||
|
|
||||||
<h3>What to Watch</h3>
|
|
||||||
<p>
|
|
||||||
With the meta always shifting, next week promises fresh rivalries and strategy evolution.
|
|
||||||
Keep an eye on the <a href="#/evolution">Evolution Dashboard</a> for emerging program
|
|
||||||
lineages and the <a href="#/rivalries">Rivalries</a> page for head-to-head trends.
|
|
||||||
</p>
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
return { title, summary, body_html };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LLM-enhanced narrative (Anthropic API)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function llmNarrative(
|
|
||||||
weekStart: Date,
|
|
||||||
stats: BlogWeekStats,
|
|
||||||
templateResult: { title: string; summary: string; body_html: string },
|
|
||||||
): Promise<{ title: string; summary: string; body_html: string }> {
|
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
||||||
if (!apiKey) return templateResult;
|
|
||||||
|
|
||||||
const prompt = `You are a sports journalist covering an AI bot programming competition.
|
|
||||||
Write a short, engaging weekly meta report for the week of ${isoDate(weekStart)}.
|
|
||||||
|
|
||||||
Statistics:
|
|
||||||
- Matches played: ${stats.matches_played}
|
|
||||||
- Top bot: ${stats.top_bot} (rating: ${stats.top_bot_rating})
|
|
||||||
- Most active bot: ${stats.most_active_bot} (${stats.most_active_bot_matches} matches)
|
|
||||||
- Biggest upset: ${stats.biggest_upset ?? 'none this week'}
|
|
||||||
- Evolution island leader: ${stats.island_leader ?? 'data not available'}
|
|
||||||
|
|
||||||
Write:
|
|
||||||
1. A catchy title (one line, no markdown)
|
|
||||||
2. A one-paragraph summary (plain text, 2-3 sentences)
|
|
||||||
3. Full HTML body content (use <h2>, <h3>, <p> tags; no <html>/<body>/<head>)
|
|
||||||
|
|
||||||
Format your response as JSON with keys: title, summary, body_html`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'claude-haiku-4-5-20251001',
|
|
||||||
max_tokens: 1024,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.warn(`LLM API returned ${res.status}, falling back to template narrative`);
|
|
||||||
return templateResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json() as { content: Array<{ text: string }> };
|
|
||||||
const text = json.content[0]?.text ?? '';
|
|
||||||
|
|
||||||
// Extract JSON from response (may be wrapped in markdown code fences)
|
|
||||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
||||||
if (!jsonMatch) {
|
|
||||||
console.warn('LLM response did not contain JSON, using template');
|
|
||||||
return templateResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonMatch[0]) as { title?: string; summary?: string; body_html?: string };
|
|
||||||
return {
|
|
||||||
title: parsed.title ?? templateResult.title,
|
|
||||||
summary: parsed.summary ?? templateResult.summary,
|
|
||||||
body_html: parsed.body_html ?? templateResult.body_html,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('LLM narrative failed, using template:', err);
|
|
||||||
return templateResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function generateWeeklyPost(
|
|
||||||
data: ExportData,
|
|
||||||
evo: EvolutionLiveData | null,
|
|
||||||
weekStart?: Date,
|
|
||||||
): Promise<BlogPost> {
|
|
||||||
const now = new Date();
|
|
||||||
const week = weekStart ?? startOfWeek(now);
|
|
||||||
|
|
||||||
const weekMatches = matchesInWeek(data.matches, week);
|
|
||||||
const stats = computeWeekStats(weekMatches, data.bots, evo);
|
|
||||||
|
|
||||||
const template = templateNarrative(week, stats);
|
|
||||||
const narrative = await llmNarrative(week, stats, template);
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: weekSlug(week),
|
|
||||||
title: narrative.title,
|
|
||||||
published_at: now.toISOString(),
|
|
||||||
week_start: isoDate(week),
|
|
||||||
summary: narrative.summary,
|
|
||||||
body_html: narrative.body_html,
|
|
||||||
stats,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBlogIndex(posts: BlogPost[]): BlogIndex {
|
|
||||||
return {
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
posts: posts.sort((a, b) => b.week_start.localeCompare(a.week_start)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the start-of-week dates for the last N weeks.
|
|
||||||
*/
|
|
||||||
export function lastNWeekStarts(n: number, from?: Date): Date[] {
|
|
||||||
const base = startOfWeek(from ?? new Date());
|
|
||||||
const weeks: Date[] = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const d = new Date(base);
|
|
||||||
d.setUTCDate(base.getUTCDate() - i * 7);
|
|
||||||
weeks.push(d);
|
|
||||||
}
|
|
||||||
return weeks;
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
// Playlist Generator - Auto-curated replay collections
|
|
||||||
import type {
|
|
||||||
ExportMatch,
|
|
||||||
ExportBot,
|
|
||||||
Playlist,
|
|
||||||
PlaylistCategory,
|
|
||||||
PlaylistMatch,
|
|
||||||
PlaylistSummary,
|
|
||||||
PlaylistIndex,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
export class PlaylistGenerator {
|
|
||||||
private matches: ExportMatch[];
|
|
||||||
private bots: ExportBot[];
|
|
||||||
private botNameMap: Map<string, string>;
|
|
||||||
private now: string;
|
|
||||||
|
|
||||||
constructor(matches: ExportMatch[], bots: ExportBot[]) {
|
|
||||||
this.matches = matches.filter(m => m.status === 'completed');
|
|
||||||
this.bots = bots;
|
|
||||||
this.botNameMap = new Map(bots.map(b => [b.id, b.name]));
|
|
||||||
this.now = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all playlists
|
|
||||||
*/
|
|
||||||
generateAll(): Playlist[] {
|
|
||||||
return [
|
|
||||||
this.generateFeaturedPlaylist(),
|
|
||||||
this.generateUpsetsPlaylist(),
|
|
||||||
this.generateComebacksPlaylist(),
|
|
||||||
this.generateDominationPlaylist(),
|
|
||||||
this.generateCloseGamesPlaylist(),
|
|
||||||
this.generateLongGamesPlaylist(),
|
|
||||||
this.generateWeeklyBestPlaylist(),
|
|
||||||
].filter((p): p is Playlist => p !== null && p.matches.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate playlist index
|
|
||||||
*/
|
|
||||||
generateIndex(playlists: Playlist[]): PlaylistIndex {
|
|
||||||
return {
|
|
||||||
updated_at: this.now,
|
|
||||||
playlists: playlists.map(p => ({
|
|
||||||
slug: p.slug,
|
|
||||||
title: p.title,
|
|
||||||
description: p.description,
|
|
||||||
category: p.category,
|
|
||||||
match_count: p.match_count,
|
|
||||||
thumbnail_match_id: p.matches[0]?.match_id,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Featured matches - high-rated bot confrontations
|
|
||||||
*/
|
|
||||||
private generateFeaturedPlaylist(): Playlist {
|
|
||||||
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
|
|
||||||
const featured = this.matches
|
|
||||||
.filter(m => {
|
|
||||||
// Only 2-player matches between high-rated bots
|
|
||||||
if (m.participants.length !== 2) return false;
|
|
||||||
const ratings = m.participants.map(p => botRatingMap.get(p.bot_id) || 0);
|
|
||||||
return ratings.every(r => r > 1600);
|
|
||||||
})
|
|
||||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'featured',
|
|
||||||
title: 'Featured Matches',
|
|
||||||
description: 'High-level confrontations between top-rated bots',
|
|
||||||
category: 'featured',
|
|
||||||
match_count: featured.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: featured,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsets - lower-rated bot beats higher-rated opponent
|
|
||||||
*/
|
|
||||||
private generateUpsetsPlaylist(): Playlist {
|
|
||||||
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
|
|
||||||
const upsets = this.matches
|
|
||||||
.filter(m => {
|
|
||||||
if (m.participants.length !== 2 || !m.winner_id) return false;
|
|
||||||
const winnerRating = botRatingMap.get(m.winner_id) || 1500;
|
|
||||||
const loserId = m.participants.find(p => p.bot_id !== m.winner_id)?.bot_id;
|
|
||||||
if (!loserId) return false;
|
|
||||||
const loserRating = botRatingMap.get(loserId) || 1500;
|
|
||||||
// Upset: winner was at least 100 points lower rated
|
|
||||||
return winnerRating < loserRating - 100;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort by upset magnitude (largest first)
|
|
||||||
const aMag = this.getUpsetMagnitude(a, botRatingMap);
|
|
||||||
const bMag = this.getUpsetMagnitude(b, botRatingMap);
|
|
||||||
return bMag - aMag;
|
|
||||||
})
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'upsets',
|
|
||||||
title: 'Epic Upsets',
|
|
||||||
description: 'Unexpected victories where underdogs triumphed',
|
|
||||||
category: 'upsets',
|
|
||||||
match_count: upsets.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: upsets,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUpsetMagnitude(match: ExportMatch, ratingMap: Map<string, number>): number {
|
|
||||||
if (!match.winner_id) return 0;
|
|
||||||
const winnerRating = ratingMap.get(match.winner_id) || 1500;
|
|
||||||
const loserId = match.participants.find(p => p.bot_id !== match.winner_id)?.bot_id;
|
|
||||||
if (!loserId) return 0;
|
|
||||||
const loserRating = ratingMap.get(loserId) || 1500;
|
|
||||||
return loserRating - winnerRating;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comebacks - matches with large score swings
|
|
||||||
*/
|
|
||||||
private generateComebacksPlaylist(): Playlist {
|
|
||||||
// Comebacks are hard to detect without turn-by-turn data
|
|
||||||
// For now, use close final scores as a proxy
|
|
||||||
const closeMatches = this.matches
|
|
||||||
.filter(m => m.participants.length === 2)
|
|
||||||
.filter(m => {
|
|
||||||
const scores = m.participants.map(p => p.score);
|
|
||||||
const diff = Math.abs(scores[0] - scores[1]);
|
|
||||||
// Close game: score difference <= 2
|
|
||||||
return diff <= 2 && diff > 0;
|
|
||||||
})
|
|
||||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'comebacks',
|
|
||||||
title: 'Epic Comebacks',
|
|
||||||
description: 'Matches where fortunes shifted dramatically',
|
|
||||||
category: 'comebacks',
|
|
||||||
match_count: closeMatches.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: closeMatches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domination - massive score differences
|
|
||||||
*/
|
|
||||||
private generateDominationPlaylist(): Playlist {
|
|
||||||
const dominated = this.matches
|
|
||||||
.filter(m => m.participants.length === 2)
|
|
||||||
.filter(m => {
|
|
||||||
const scores = m.participants.map(p => p.score);
|
|
||||||
const diff = Math.abs(scores[0] - scores[1]);
|
|
||||||
// Domination: score difference >= 5
|
|
||||||
return diff >= 5;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort by domination magnitude
|
|
||||||
const aDiff = Math.abs(a.participants[0].score - a.participants[1].score);
|
|
||||||
const bDiff = Math.abs(b.participants[0].score - b.participants[1].score);
|
|
||||||
return bDiff - aDiff;
|
|
||||||
})
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'domination',
|
|
||||||
title: 'Total Domination',
|
|
||||||
description: 'One-sided victories with massive score differences',
|
|
||||||
category: 'domination',
|
|
||||||
match_count: dominated.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: dominated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close games - decided by a single point
|
|
||||||
*/
|
|
||||||
private generateCloseGamesPlaylist(): Playlist {
|
|
||||||
const close = this.matches
|
|
||||||
.filter(m => m.participants.length === 2)
|
|
||||||
.filter(m => {
|
|
||||||
const scores = m.participants.map(p => p.score);
|
|
||||||
const diff = Math.abs(scores[0] - scores[1]);
|
|
||||||
return diff === 1;
|
|
||||||
})
|
|
||||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'close-games',
|
|
||||||
title: 'Photo Finishes',
|
|
||||||
description: 'Matches decided by the thinnest of margins',
|
|
||||||
category: 'close_games',
|
|
||||||
match_count: close.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: close,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Long games - high turn counts
|
|
||||||
*/
|
|
||||||
private generateLongGamesPlaylist(): Playlist {
|
|
||||||
const longGames = this.matches
|
|
||||||
.filter(m => (m.turns || 0) >= 300)
|
|
||||||
.sort((a, b) => (b.turns || 0) - (a.turns || 0))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'long-games',
|
|
||||||
title: 'Marathon Matches',
|
|
||||||
description: 'Extended battles that went the distance',
|
|
||||||
category: 'long_games',
|
|
||||||
match_count: longGames.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: longGames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weekly best - most recent week's top matches
|
|
||||||
*/
|
|
||||||
private generateWeeklyBestPlaylist(): Playlist {
|
|
||||||
const oneWeekAgo = new Date();
|
|
||||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
||||||
const weekStart = oneWeekAgo.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const weeklyMatches = this.matches
|
|
||||||
.filter(m => (m.completed_at || '') >= weekStart)
|
|
||||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
|
||||||
.slice(0, 15)
|
|
||||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
|
||||||
|
|
||||||
// Generate title with date range
|
|
||||||
const now = new Date();
|
|
||||||
const weekEndStr = now.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: 'weekly-best',
|
|
||||||
title: `Best of the Week (${weekStart} to ${weekEndStr})`,
|
|
||||||
description: 'Top matches from the past 7 days',
|
|
||||||
category: 'weekly',
|
|
||||||
match_count: weeklyMatches.length,
|
|
||||||
created_at: this.now,
|
|
||||||
updated_at: this.now,
|
|
||||||
matches: weeklyMatches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a match to a playlist entry
|
|
||||||
*/
|
|
||||||
private matchToPlaylistEntry(match: ExportMatch, order: number): PlaylistMatch {
|
|
||||||
const winnerName = match.winner_id ? this.botNameMap.get(match.winner_id) : 'Draw';
|
|
||||||
const participants = match.participants
|
|
||||||
.map(p => this.botNameMap.get(p.bot_id) || 'Unknown')
|
|
||||||
.join(' vs ');
|
|
||||||
|
|
||||||
return {
|
|
||||||
match_id: match.id,
|
|
||||||
order,
|
|
||||||
title: `${participants} - ${winnerName} wins`,
|
|
||||||
thumbnail_url: `https://r2.aicodebattle.com/thumbnails/${match.id}.png`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
// Index Builder Types
|
|
||||||
|
|
||||||
export interface ApiClientConfig {
|
|
||||||
apiUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportBot {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
rating_volatility: number;
|
|
||||||
matches_played: number;
|
|
||||||
matches_won: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
health_status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMatch {
|
|
||||||
id: string;
|
|
||||||
status: string;
|
|
||||||
winner_id: string | null;
|
|
||||||
turns: number | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
map_id: string;
|
|
||||||
created_at: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
participants: ExportMatchParticipant[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMatchParticipant {
|
|
||||||
bot_id: string;
|
|
||||||
player_index: number;
|
|
||||||
score: number;
|
|
||||||
rating_before: number;
|
|
||||||
rating_after: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RatingHistoryEntry {
|
|
||||||
bot_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
recorded_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportData {
|
|
||||||
bots: ExportBot[];
|
|
||||||
matches: ExportMatch[];
|
|
||||||
rating_history: RatingHistoryEntry[];
|
|
||||||
generated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generated Index Types
|
|
||||||
|
|
||||||
export interface LeaderboardEntry {
|
|
||||||
rank: number;
|
|
||||||
bot_id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
matches_played: number;
|
|
||||||
matches_won: number;
|
|
||||||
win_rate: number;
|
|
||||||
health_status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaderboardIndex {
|
|
||||||
updated_at: string;
|
|
||||||
entries: LeaderboardEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotProfile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
rating_volatility: number;
|
|
||||||
matches_played: number;
|
|
||||||
matches_won: number;
|
|
||||||
win_rate: number;
|
|
||||||
health_status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
rating_history: RatingHistoryEntry[];
|
|
||||||
recent_matches: MatchSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotDirectoryEntry {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
rating: number;
|
|
||||||
matches_played: number;
|
|
||||||
win_rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotDirectory {
|
|
||||||
updated_at: string;
|
|
||||||
bots: BotDirectoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchSummary {
|
|
||||||
id: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
participants: {
|
|
||||||
bot_id: string;
|
|
||||||
name: string;
|
|
||||||
score: number;
|
|
||||||
won: boolean;
|
|
||||||
}[];
|
|
||||||
winner_id: string | null;
|
|
||||||
turns: number | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchIndex {
|
|
||||||
updated_at: string;
|
|
||||||
matches: MatchSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blog / Narrative Engine types
|
|
||||||
|
|
||||||
export interface BlogPost {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
published_at: string; // ISO 8601 date
|
|
||||||
week_start: string; // ISO 8601 date (Monday of the covered week)
|
|
||||||
summary: string; // one-paragraph plain-text teaser
|
|
||||||
body_html: string; // full HTML narrative content
|
|
||||||
stats: BlogWeekStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlogWeekStats {
|
|
||||||
matches_played: number;
|
|
||||||
top_bot: string;
|
|
||||||
top_bot_rating: number;
|
|
||||||
biggest_upset: string | null; // "BotA defeated BotB" or null
|
|
||||||
most_active_bot: string;
|
|
||||||
most_active_bot_matches: number;
|
|
||||||
island_leader: string | null; // leading evolution island
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlogIndex {
|
|
||||||
updated_at: string;
|
|
||||||
posts: BlogPost[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evolution dashboard types (written by acb-evolver live-export)
|
|
||||||
export interface EvolutionIslandStat {
|
|
||||||
count: number;
|
|
||||||
best_fitness: number;
|
|
||||||
avg_fitness: number;
|
|
||||||
diversity: number;
|
|
||||||
promoted_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionGenerationEntry {
|
|
||||||
generation: number;
|
|
||||||
island: string;
|
|
||||||
evaluated_at: string;
|
|
||||||
count: number;
|
|
||||||
promoted: number;
|
|
||||||
best_fitness: number;
|
|
||||||
avg_fitness: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionLineageNode {
|
|
||||||
id: number;
|
|
||||||
parent_ids: number[];
|
|
||||||
generation: number;
|
|
||||||
island: string;
|
|
||||||
fitness: number;
|
|
||||||
promoted: boolean;
|
|
||||||
language: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionMetaSnapshot {
|
|
||||||
generation: number;
|
|
||||||
island_counts: Record<string, number>;
|
|
||||||
island_best_fitness: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionLiveData {
|
|
||||||
updated_at: string;
|
|
||||||
total_programs: number;
|
|
||||||
promoted_count: number;
|
|
||||||
islands: Record<string, EvolutionIslandStat>;
|
|
||||||
generation_log: EvolutionGenerationEntry[];
|
|
||||||
lineage: EvolutionLineageNode[];
|
|
||||||
meta_snapshots: EvolutionMetaSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay Playlist types
|
|
||||||
|
|
||||||
export interface PlaylistMatch {
|
|
||||||
match_id: string;
|
|
||||||
order: number; // Position in playlist
|
|
||||||
title?: string; // Optional custom title (e.g., "The Upset")
|
|
||||||
thumbnail_url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Playlist {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: PlaylistCategory;
|
|
||||||
match_count: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
matches: PlaylistMatch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PlaylistCategory =
|
|
||||||
| 'featured' // Curated featured matches
|
|
||||||
| 'rivalry' // Matches between specific rivals
|
|
||||||
| 'upsets' // Unexpected outcomes
|
|
||||||
| 'comebacks' // Big turnarounds
|
|
||||||
| 'domination' // One-sided victories
|
|
||||||
| 'close_games' // Narrow wins
|
|
||||||
| 'long_games' // High turn counts
|
|
||||||
| 'tutorial' // Tutorial/example matches
|
|
||||||
| 'season' // Season highlights
|
|
||||||
| 'weekly'; // Weekly best
|
|
||||||
|
|
||||||
export interface PlaylistIndex {
|
|
||||||
updated_at: string;
|
|
||||||
playlists: PlaylistSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlaylistSummary {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: PlaylistCategory;
|
|
||||||
match_count: number;
|
|
||||||
thumbnail_match_id?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
// File Writer - Writes generated index files to disk
|
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import type { LeaderboardIndex, BotDirectory, BotProfile, MatchIndex, EvolutionLiveData } from './types.js';
|
|
||||||
|
|
||||||
export class FileWriter {
|
|
||||||
private outputDir: string;
|
|
||||||
|
|
||||||
constructor(outputDir: string) {
|
|
||||||
this.outputDir = outputDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure output directory structure exists
|
|
||||||
*/
|
|
||||||
async ensureDirectories(): Promise<void> {
|
|
||||||
const dirs = [
|
|
||||||
this.outputDir,
|
|
||||||
path.join(this.outputDir, 'bots'),
|
|
||||||
path.join(this.outputDir, 'matches'),
|
|
||||||
path.join(this.outputDir, 'evolution'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write JSON file
|
|
||||||
*/
|
|
||||||
private async writeJson(filePath: string, data: unknown): Promise<void> {
|
|
||||||
const content = JSON.stringify(data, null, 2);
|
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
|
||||||
console.log(`Wrote: ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write leaderboard.json
|
|
||||||
*/
|
|
||||||
async writeLeaderboard(leaderboard: LeaderboardIndex): Promise<void> {
|
|
||||||
const filePath = path.join(this.outputDir, 'leaderboard.json');
|
|
||||||
await this.writeJson(filePath, leaderboard);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write bots/index.json
|
|
||||||
*/
|
|
||||||
async writeBotDirectory(directory: BotDirectory): Promise<void> {
|
|
||||||
const filePath = path.join(this.outputDir, 'bots', 'index.json');
|
|
||||||
await this.writeJson(filePath, directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write individual bot profile
|
|
||||||
*/
|
|
||||||
async writeBotProfile(botId: string, profile: BotProfile): Promise<void> {
|
|
||||||
const filePath = path.join(this.outputDir, 'bots', `${botId}.json`);
|
|
||||||
await this.writeJson(filePath, profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write all bot profiles
|
|
||||||
*/
|
|
||||||
async writeBotProfiles(profiles: Map<string, BotProfile>): Promise<void> {
|
|
||||||
const writePromises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
for (const [botId, profile] of profiles) {
|
|
||||||
writePromises.push(this.writeBotProfile(botId, profile));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(writePromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write matches/index.json
|
|
||||||
*/
|
|
||||||
async writeMatchIndex(matchIndex: MatchIndex): Promise<void> {
|
|
||||||
const filePath = path.join(this.outputDir, 'matches', 'index.json');
|
|
||||||
await this.writeJson(filePath, matchIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write evolution/live.json
|
|
||||||
*/
|
|
||||||
async writeEvolutionLive(data: EvolutionLiveData): Promise<void> {
|
|
||||||
const filePath = path.join(this.outputDir, 'evolution', 'live.json');
|
|
||||||
await this.writeJson(filePath, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write all index files
|
|
||||||
*/
|
|
||||||
async writeAll(data: {
|
|
||||||
leaderboard: LeaderboardIndex;
|
|
||||||
botDirectory: BotDirectory;
|
|
||||||
botProfiles: Map<string, BotProfile>;
|
|
||||||
matchIndex: MatchIndex;
|
|
||||||
evolutionLive?: EvolutionLiveData;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.ensureDirectories();
|
|
||||||
|
|
||||||
await this.writeLeaderboard(data.leaderboard);
|
|
||||||
await this.writeBotDirectory(data.botDirectory);
|
|
||||||
await this.writeBotProfiles(data.botProfiles);
|
|
||||||
await this.writeMatchIndex(data.matchIndex);
|
|
||||||
|
|
||||||
if (data.evolutionLive) {
|
|
||||||
await this.writeEvolutionLive(data.evolutionLive);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nIndex generation complete!`);
|
|
||||||
console.log(` - ${data.leaderboard.entries.length} leaderboard entries`);
|
|
||||||
console.log(` - ${data.botProfiles.size} bot profiles`);
|
|
||||||
console.log(` - ${data.matchIndex.matches.length} matches`);
|
|
||||||
if (data.evolutionLive) {
|
|
||||||
console.log(` - ${data.evolutionLive.total_programs} evolution programs`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,12 @@
|
||||||
// API client for Worker API communication
|
// API types for acb-worker
|
||||||
|
// HTTP API client removed - worker now uses direct PostgreSQL writes
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIClient communicates with the Worker API.
|
// Job represents a pending job (kept for compatibility).
|
||||||
type APIClient struct {
|
|
||||||
endpoint string
|
|
||||||
apiKey string
|
|
||||||
httpClient *http.Client
|
|
||||||
maxRetries int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAPIClient creates a new API client.
|
|
||||||
func NewAPIClient(cfg *Config) *APIClient {
|
|
||||||
return &APIClient{
|
|
||||||
endpoint: cfg.APIEndpoint,
|
|
||||||
apiKey: cfg.APIKey,
|
|
||||||
httpClient: &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
maxRetries: cfg.MaxRetries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job represents a pending job from the API.
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MatchID string `json:"match_id"`
|
MatchID string `json:"match_id"`
|
||||||
|
|
@ -43,6 +18,7 @@ type Job struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobClaimResponse contains the data needed to execute a match.
|
// JobClaimResponse contains the data needed to execute a match.
|
||||||
|
// This maps to JobClaimData from db.go for compatibility.
|
||||||
type JobClaimResponse struct {
|
type JobClaimResponse struct {
|
||||||
Job Job `json:"job"`
|
Job Job `json:"job"`
|
||||||
Match Match `json:"match"`
|
Match Match `json:"match"`
|
||||||
|
|
@ -100,175 +76,97 @@ type BotSecret struct {
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIResponse is a generic API response.
|
// MatchResult represents the result of a match for submission.
|
||||||
type APIResponse struct {
|
type MatchResult struct {
|
||||||
Success bool `json:"success"`
|
WinnerID string `json:"winner_id"`
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
Turns int `json:"turns"`
|
||||||
Error string `json:"error,omitempty"`
|
EndReason string `json:"end_reason"`
|
||||||
|
Scores map[string]int `json:"scores"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNextJob fetches the next pending job.
|
// ConvertDBJobToJob converts a DBJob to Job type.
|
||||||
func (c *APIClient) GetNextJob(ctx context.Context) (*Job, error) {
|
func ConvertDBJobToJob(dbJob *DBJob) *Job {
|
||||||
resp, err := c.doRequest(ctx, "GET", "/api/jobs/next", nil)
|
if dbJob == nil {
|
||||||
if err != nil {
|
return nil
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return &Job{
|
||||||
var apiResp APIResponse
|
ID: dbJob.ID,
|
||||||
if err := json.Unmarshal(resp, &apiResp); err != nil {
|
MatchID: dbJob.MatchID,
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
Status: dbJob.Status,
|
||||||
|
WorkerID: dbJob.WorkerID,
|
||||||
|
ClaimedAt: dbJob.ClaimedAt,
|
||||||
|
HeartbeatAt: dbJob.HeartbeatAt,
|
||||||
|
CreatedAt: dbJob.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success {
|
|
||||||
return nil, fmt.Errorf("API error: %s", apiResp.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiResp.Data == nil {
|
|
||||||
return nil, nil // No pending jobs
|
|
||||||
}
|
|
||||||
|
|
||||||
var job Job
|
|
||||||
if err := json.Unmarshal(apiResp.Data, &job); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse job: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &job, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimJob claims a job for execution.
|
// ConvertDBClaimToResponse converts JobClaimData to JobClaimResponse.
|
||||||
func (c *APIClient) ClaimJob(ctx context.Context, jobID string, workerID string) (*JobClaimResponse, error) {
|
func ConvertDBClaimToResponse(data *JobClaimData) *JobClaimResponse {
|
||||||
body := map[string]string{"worker_id": workerID}
|
if data == nil {
|
||||||
|
return nil
|
||||||
resp, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/claim", body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp APIResponse
|
// Convert participants
|
||||||
if err := json.Unmarshal(resp, &apiResp); err != nil {
|
participants := make([]Participant, len(data.Participants))
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
botSecrets := make([]BotSecret, len(data.Participants))
|
||||||
}
|
bots := make([]BotInfo, len(data.Bots))
|
||||||
|
|
||||||
if !apiResp.Success {
|
for i, p := range data.Participants {
|
||||||
return nil, fmt.Errorf("API error: %s", apiResp.Error)
|
participants[i] = Participant{
|
||||||
}
|
ID: p.MatchID + "-" + p.BotID,
|
||||||
|
MatchID: p.MatchID,
|
||||||
var claimResp JobClaimResponse
|
BotID: p.BotID,
|
||||||
if err := json.Unmarshal(apiResp.Data, &claimResp); err != nil {
|
PlayerIndex: p.PlayerSlot,
|
||||||
return nil, fmt.Errorf("failed to parse claim response: %w", err)
|
Score: p.Score,
|
||||||
}
|
RatingBefore: int(p.RatingMuBefore),
|
||||||
|
RatingDeviationBefore: int(p.RatingPhiBefore),
|
||||||
return &claimResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat sends a heartbeat for a claimed job.
|
|
||||||
func (c *APIClient) Heartbeat(ctx context.Context, jobID string, workerID string) error {
|
|
||||||
body := map[string]string{"worker_id": workerID}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/heartbeat", body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitResult submits the result of a completed match.
|
|
||||||
func (c *APIClient) SubmitResult(ctx context.Context, jobID string, result *MatchResult, replayURL string) error {
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"winner_id": result.WinnerID,
|
|
||||||
"turns": result.Turns,
|
|
||||||
"end_reason": result.EndReason,
|
|
||||||
"replay_url": replayURL,
|
|
||||||
"scores": result.Scores,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/result", body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailJob marks a job as failed.
|
|
||||||
func (c *APIClient) FailJob(ctx context.Context, jobID string, workerID string, errorMessage string) error {
|
|
||||||
body := map[string]string{
|
|
||||||
"worker_id": workerID,
|
|
||||||
"error_message": errorMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "POST", "/api/jobs/"+jobID+"/fail", body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// doRequest makes an HTTP request with retries.
|
|
||||||
func (c *APIClient) doRequest(ctx context.Context, method string, path string, body interface{}) ([]byte, error) {
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-time.After(time.Second * time.Duration(attempt)):
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
botSecrets[i] = BotSecret{
|
||||||
resp, err := c.doSingleRequest(ctx, method, path, body)
|
BotID: p.BotID,
|
||||||
if err != nil {
|
Secret: "", // Will be filled from bots lookup
|
||||||
lastErr = err
|
|
||||||
// Check if it's a client error (don't retry)
|
|
||||||
if httpErr, ok := err.(*HTTPError); ok && httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("request failed after %d retries: %w", c.maxRetries, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doSingleRequest makes a single HTTP request.
|
|
||||||
func (c *APIClient) doSingleRequest(ctx context.Context, method string, path string, body interface{}) ([]byte, error) {
|
|
||||||
var reqBody io.Reader
|
|
||||||
if body != nil {
|
|
||||||
data, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
reqBody = bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, &HTTPError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Body: string(respBody),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return respBody, nil
|
// Convert bots and match secrets
|
||||||
}
|
botSecretMap := make(map[string]string)
|
||||||
|
for i, b := range data.Bots {
|
||||||
|
bots[i] = BotInfo{
|
||||||
|
ID: b.ID,
|
||||||
|
EndpointURL: b.EndpointURL,
|
||||||
|
}
|
||||||
|
botSecretMap[b.ID] = b.Secret
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPError represents an HTTP error response.
|
// Fill in secrets
|
||||||
type HTTPError struct {
|
for i, p := range data.Participants {
|
||||||
StatusCode int
|
botSecrets[i].Secret = botSecretMap[p.BotID]
|
||||||
Body string
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HTTPError) Error() string {
|
return &JobClaimResponse{
|
||||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
Job: Job{
|
||||||
|
ID: data.Job.ID,
|
||||||
|
MatchID: data.Job.MatchID,
|
||||||
|
Status: data.Job.Status,
|
||||||
|
WorkerID: data.Job.WorkerID,
|
||||||
|
ClaimedAt: data.Job.ClaimedAt,
|
||||||
|
CreatedAt: data.Job.CreatedAt,
|
||||||
|
},
|
||||||
|
Match: Match{
|
||||||
|
ID: data.Match.ID,
|
||||||
|
Status: data.Match.Status,
|
||||||
|
MapID: data.Match.MapID,
|
||||||
|
CreatedAt: data.Match.CreatedAt,
|
||||||
|
},
|
||||||
|
Participants: participants,
|
||||||
|
Map: MapData{
|
||||||
|
ID: data.Map.ID,
|
||||||
|
Width: data.Map.Width,
|
||||||
|
Height: data.Map.Height,
|
||||||
|
Walls: data.Map.Walls,
|
||||||
|
Spawns: data.Map.Spawns,
|
||||||
|
Cores: data.Map.Cores,
|
||||||
|
},
|
||||||
|
Bots: bots,
|
||||||
|
BotSecrets: botSecrets,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// R2 client for uploading replays
|
// B2 client for uploading replays to Backblaze B2 (cold archive)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -12,28 +12,28 @@ import (
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// R2Client handles R2 bucket operations.
|
// B2Client handles B2 bucket operations (S3-compatible).
|
||||||
type R2Client struct {
|
type B2Client struct {
|
||||||
client *s3.Client
|
client *s3.Client
|
||||||
bucket string
|
bucket string
|
||||||
endpoint string
|
endpoint string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewR2Client creates a new R2 client.
|
// NewB2Client creates a new B2 client.
|
||||||
func NewR2Client(cfg *Config) *R2Client {
|
func NewB2Client(cfg *Config) *B2Client {
|
||||||
// Create custom endpoint resolver for R2
|
// Create custom endpoint resolver for B2 (S3-compatible)
|
||||||
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||||
return aws.Endpoint{
|
return aws.Endpoint{
|
||||||
URL: cfg.R2Endpoint,
|
URL: cfg.B2Endpoint,
|
||||||
SigningRegion: "auto",
|
SigningRegion: cfg.B2Region,
|
||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load AWS config with R2 credentials
|
// Load AWS config with B2 credentials
|
||||||
awsCfg, err := config.LoadDefaultConfig(context.TODO(),
|
awsCfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||||
cfg.R2AccessKey,
|
cfg.B2AccessKey,
|
||||||
cfg.R2SecretKey,
|
cfg.B2SecretKey,
|
||||||
"",
|
"",
|
||||||
)),
|
)),
|
||||||
config.WithEndpointResolverWithOptions(customResolver),
|
config.WithEndpointResolverWithOptions(customResolver),
|
||||||
|
|
@ -42,15 +42,15 @@ func NewR2Client(cfg *Config) *R2Client {
|
||||||
panic(fmt.Sprintf("failed to load AWS config: %v", err))
|
panic(fmt.Sprintf("failed to load AWS config: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &R2Client{
|
return &B2Client{
|
||||||
client: s3.NewFromConfig(awsCfg),
|
client: s3.NewFromConfig(awsCfg),
|
||||||
bucket: cfg.R2Bucket,
|
bucket: cfg.B2Bucket,
|
||||||
endpoint: cfg.R2Endpoint,
|
endpoint: cfg.B2Endpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload uploads data to R2.
|
// Upload uploads data to B2.
|
||||||
func (c *R2Client) Upload(ctx context.Context, key string, data []byte, contentType string) error {
|
func (c *B2Client) Upload(ctx context.Context, key string, data []byte, contentType string) error {
|
||||||
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
|
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(c.bucket),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
|
|
@ -61,8 +61,8 @@ func (c *R2Client) Upload(ctx context.Context, key string, data []byte, contentT
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download downloads data from R2.
|
// Download downloads data from B2.
|
||||||
func (c *R2Client) Download(ctx context.Context, key string) ([]byte, error) {
|
func (c *B2Client) Download(ctx context.Context, key string) ([]byte, error) {
|
||||||
resp, err := c.client.GetObject(ctx, &s3.GetObjectInput{
|
resp, err := c.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
Bucket: aws.String(c.bucket),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
|
|
@ -80,8 +80,8 @@ func (c *R2Client) Download(ctx context.Context, key string) ([]byte, error) {
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete deletes an object from R2.
|
// Delete deletes an object from B2.
|
||||||
func (c *R2Client) Delete(ctx context.Context, key string) error {
|
func (c *B2Client) Delete(ctx context.Context, key string) error {
|
||||||
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
Bucket: aws.String(c.bucket),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
|
|
@ -90,7 +90,7 @@ func (c *R2Client) Delete(ctx context.Context, key string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List lists objects with a prefix.
|
// List lists objects with a prefix.
|
||||||
func (c *R2Client) List(ctx context.Context, prefix string) ([]string, error) {
|
func (c *B2Client) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
|
paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
|
||||||
|
|
@ -114,7 +114,7 @@ func (c *R2Client) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Endpoint returns the R2 endpoint URL.
|
// Endpoint returns the B2 endpoint URL.
|
||||||
func (c *R2Client) Endpoint() string {
|
func (c *B2Client) Endpoint() string {
|
||||||
return c.endpoint
|
return c.endpoint
|
||||||
}
|
}
|
||||||
469
cmd/acb-worker/db.go
Normal file
469
cmd/acb-worker/db.go
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
// PostgreSQL database client for match results and job coordination
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBClient handles PostgreSQL operations.
|
||||||
|
type DBClient struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDBClient creates a new database client.
|
||||||
|
func NewDBClient(databaseURL string) (*DBClient, error) {
|
||||||
|
db, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure connection pool
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DBClient{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (c *DBClient) Close() error {
|
||||||
|
return c.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBJob represents a pending job from the database.
|
||||||
|
type DBJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MatchID string `json:"match_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WorkerID *string `json:"worker_id"`
|
||||||
|
ClaimedAt *time.Time `json:"claimed_at"`
|
||||||
|
HeartbeatAt *time.Time `json:"heartbeat_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBMatch represents match metadata from the database.
|
||||||
|
type DBMatch struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Winner *int `json:"winner"` // player index
|
||||||
|
MapID string `json:"map_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBMatch represents match metadata.
|
||||||
|
type DBMatch struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Winner *int `json:"winner"` // player index
|
||||||
|
MapID string `json:"map_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBParticipant represents a match participant.
|
||||||
|
type DBParticipant struct {
|
||||||
|
MatchID string `json:"match_id"`
|
||||||
|
BotID string `json:"bot_id"`
|
||||||
|
PlayerSlot int `json:"player_slot"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
RatingMuBefore float64
|
||||||
|
RatingPhiBefore float64
|
||||||
|
RatingSigmaBefore float64
|
||||||
|
RatingMuAfter *float64
|
||||||
|
RatingPhiAfter *float64
|
||||||
|
RatingSigmaAfter *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBBotInfo contains bot endpoint and secret information.
|
||||||
|
type DBBotInfo struct {
|
||||||
|
ID string
|
||||||
|
EndpointURL string
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBMapData represents map configuration.
|
||||||
|
type DBMapData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Walls string `json:"walls"`
|
||||||
|
Spawns string `json:"spawns"`
|
||||||
|
Cores string `json:"cores"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobClaimData contains all data needed to execute a match.
|
||||||
|
type JobClaimData struct {
|
||||||
|
Job DBJob
|
||||||
|
Match DBMatch
|
||||||
|
Participants []DBParticipant
|
||||||
|
Map DBMapData
|
||||||
|
Bots []DBBotInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextJob fetches the next pending job from the database.
|
||||||
|
func (c *DBClient) GetNextJob(ctx context.Context) (*DBJob, error) {
|
||||||
|
query := `
|
||||||
|
SELECT job_id, match_id, status, worker_id, claimed_at, heartbeat_at, created_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
`
|
||||||
|
|
||||||
|
var job DBJob
|
||||||
|
err := c.db.QueryRowContext(ctx, query).Scan(
|
||||||
|
&job.ID, &job.MatchID, &job.Status, &job.WorkerID,
|
||||||
|
&job.ClaimedAt, &job.HeartbeatAt, &job.CreatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil // No pending jobs
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get next job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimJob claims a job and returns all data needed to execute the match.
|
||||||
|
func (c *DBClient) ClaimJob(ctx context.Context, jobID string, workerID string) (*JobClaimData, error) {
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'claimed', worker_id = $1, claimed_at = $2
|
||||||
|
WHERE job_id = $3 AND status = 'pending'
|
||||||
|
`, workerID, now, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to claim job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job details
|
||||||
|
var job DBJob
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT job_id, match_id, status, worker_id, claimed_at, heartbeat_at, created_at
|
||||||
|
FROM jobs WHERE job_id = $1
|
||||||
|
`, jobID).Scan(
|
||||||
|
&job.ID, &job.MatchID, &job.Status, &job.WorkerID,
|
||||||
|
&job.ClaimedAt, &job.HeartbeatAt, &job.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get match details
|
||||||
|
var match DBMatch
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT match_id, status, winner, map_id, created_at, completed_at
|
||||||
|
FROM matches WHERE match_id = $1
|
||||||
|
`, job.MatchID).Scan(
|
||||||
|
&match.ID, &match.Status, &match.Winner, &match.MapID,
|
||||||
|
&match.CreatedAt, &match.CompletedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get match: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get map data
|
||||||
|
var mapData DBMapData
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT map_id, grid_width, grid_height, map_json->>'walls' as walls,
|
||||||
|
map_json->>'spawns' as spawns, map_json->>'cores' as cores
|
||||||
|
FROM maps WHERE map_id = $1
|
||||||
|
`, match.MapID).Scan(
|
||||||
|
&mapData.ID, &mapData.Width, &mapData.Height,
|
||||||
|
&mapData.Walls, &mapData.Spawns, &mapData.Cores,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get map: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get participants
|
||||||
|
participantRows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT mp.match_id, mp.bot_id, mp.player_slot, mp.score,
|
||||||
|
b.rating_mu, b.rating_phi, b.rating_sigma
|
||||||
|
FROM match_participants mp
|
||||||
|
JOIN bots b ON mp.bot_id = b.bot_id
|
||||||
|
WHERE mp.match_id = $1
|
||||||
|
ORDER BY mp.player_slot
|
||||||
|
`, job.MatchID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get participants: %w", err)
|
||||||
|
}
|
||||||
|
defer participantRows.Close()
|
||||||
|
|
||||||
|
var participants []DBParticipant
|
||||||
|
var botIDs []string
|
||||||
|
for participantRows.Next() {
|
||||||
|
var p DBParticipant
|
||||||
|
err := participantRows.Scan(
|
||||||
|
&p.MatchID, &p.BotID, &p.PlayerSlot, &p.Score,
|
||||||
|
&p.RatingMuBefore, &p.RatingPhiBefore, &p.RatingSigmaBefore,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan participant: %w", err)
|
||||||
|
}
|
||||||
|
participants = append(participants, p)
|
||||||
|
botIDs = append(botIDs, p.BotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bot endpoints and secrets
|
||||||
|
botRows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT bot_id, endpoint_url, shared_secret
|
||||||
|
FROM bots WHERE bot_id = ANY($1)
|
||||||
|
`, botIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get bots: %w", err)
|
||||||
|
}
|
||||||
|
defer botRows.Close()
|
||||||
|
|
||||||
|
var bots []DBBotInfo
|
||||||
|
for botRows.Next() {
|
||||||
|
var b DBBotInfo
|
||||||
|
if err := botRows.Scan(&b.ID, &b.EndpointURL, &b.Secret); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan bot: %w", err)
|
||||||
|
}
|
||||||
|
bots = append(bots, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update match status to running
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE matches SET status = 'running' WHERE match_id = $1
|
||||||
|
`, job.MatchID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update match status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JobClaimData{
|
||||||
|
Job: job,
|
||||||
|
Match: match,
|
||||||
|
Participants: participants,
|
||||||
|
Map: mapData,
|
||||||
|
Bots: bots,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat updates the heartbeat timestamp for a job.
|
||||||
|
func (c *DBClient) Heartbeat(ctx context.Context, jobID string, workerID string) error {
|
||||||
|
result, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE jobs
|
||||||
|
SET heartbeat_at = NOW()
|
||||||
|
WHERE job_id = $1 AND worker_id = $2 AND status = 'claimed'
|
||||||
|
`, jobID, workerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send heartbeat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("job not found or not claimed by this worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitMatchResult writes the match result to the database and updates ratings.
|
||||||
|
func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result *MatchResult, replayURL string, ratingUpdates []RatingUpdate) error {
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Determine winner player index from result
|
||||||
|
var winnerIndex *int
|
||||||
|
if result.WinnerID != "" {
|
||||||
|
// Look up player slot for winner
|
||||||
|
var idx int
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT player_slot FROM match_participants WHERE match_id = (
|
||||||
|
SELECT match_id FROM jobs WHERE job_id = $1
|
||||||
|
) AND bot_id = $2
|
||||||
|
`, jobID, result.WinnerID).Scan(&idx)
|
||||||
|
if err == nil {
|
||||||
|
winnerIndex = &idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'completed', completed_at = $1
|
||||||
|
WHERE job_id = $2
|
||||||
|
`, now, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get match ID
|
||||||
|
var matchID string
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT match_id FROM jobs WHERE job_id = $1
|
||||||
|
`, jobID).Scan(&matchID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get match ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update match status and result
|
||||||
|
scoresJSON, _ := json.Marshal(result.Scores)
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE matches
|
||||||
|
SET status = 'completed', winner = $1, condition = $2,
|
||||||
|
turn_count = $3, scores_json = $4, completed_at = $5
|
||||||
|
WHERE match_id = $6
|
||||||
|
`, winnerIndex, result.EndReason, result.Turns, scoresJSON, now, matchID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update match: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update participant scores
|
||||||
|
for botID, score := range result.Scores {
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE match_participants
|
||||||
|
SET score = $1
|
||||||
|
WHERE match_id = $2 AND bot_id = $3
|
||||||
|
`, score, matchID, botID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update participant score: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rating updates (Glicko-2)
|
||||||
|
for _, update := range ratingUpdates {
|
||||||
|
// Update bot rating
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE bots
|
||||||
|
SET rating_mu = $1, rating_phi = $2, rating_sigma = $3, last_active = $4
|
||||||
|
WHERE bot_id = $5
|
||||||
|
`, update.Mu, update.Phi, update.Sigma, now, update.BotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update bot rating: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record rating history
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO rating_history (bot_id, match_id, rating, recorded_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (bot_id, match_id) DO UPDATE SET rating = $3, recorded_at = $4
|
||||||
|
`, update.BotID, matchID, update.DisplayRating, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to record rating history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update participant with rating after
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE match_participants
|
||||||
|
SET rating_mu_after = $1, rating_phi_after = $2, rating_sigma_after = $3
|
||||||
|
WHERE match_id = $4 AND bot_id = $5
|
||||||
|
`, update.Mu, update.Phi, update.Sigma, matchID, update.BotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update participant rating after: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailJob marks a job as failed.
|
||||||
|
func (c *DBClient) FailJob(ctx context.Context, jobID string, workerID string, errorMessage string) error {
|
||||||
|
result, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'failed', completed_at = NOW()
|
||||||
|
WHERE job_id = $1 AND worker_id = $2 AND status = 'claimed'
|
||||||
|
`, jobID, workerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fail job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("job not found or not claimed by this worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update match status
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
UPDATE matches
|
||||||
|
SET status = 'failed', completed_at = NOW()
|
||||||
|
WHERE match_id = (SELECT match_id FROM jobs WHERE job_id = $1)
|
||||||
|
`, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update match status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RatingUpdate represents a Glicko-2 rating update for a bot.
|
||||||
|
type RatingUpdate struct {
|
||||||
|
BotID string
|
||||||
|
Mu float64
|
||||||
|
Phi float64
|
||||||
|
Sigma float64
|
||||||
|
DisplayRating float64
|
||||||
|
RatingMuBefore float64
|
||||||
|
RatingPhiBefore float64
|
||||||
|
RatingDeviationChange float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBotRatings retrieves current ratings for a list of bots.
|
||||||
|
func (c *DBClient) GetBotRatings(ctx context.Context, botIDs []string) (map[string]Glicko2Rating, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT bot_id, rating_mu, rating_phi, rating_sigma
|
||||||
|
FROM bots WHERE bot_id = ANY($1)
|
||||||
|
`, botIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get bot ratings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
ratings := make(map[string]Glicko2Rating)
|
||||||
|
for rows.Next() {
|
||||||
|
var botID string
|
||||||
|
var r Glicko2Rating
|
||||||
|
if err := rows.Scan(&botID, &r.Mu, &r.Phi, &r.Sigma); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan rating: %w", err)
|
||||||
|
}
|
||||||
|
ratings[botID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratings, nil
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
// Glicko-2 Rating System Implementation for acb-worker
|
||||||
|
// Based on: http://www.glicko.net/glicko/glicko2.pdf
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "math"
|
import "math"
|
||||||
|
|
||||||
// Glicko-2 Rating System Implementation
|
|
||||||
// Based on: http://www.glicko.net/glicko/glicko2.pdf
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
glicko2Scale = 173.7178
|
glicko2Scale = 173.7178
|
||||||
glicko2Tau = 0.5
|
glicko2Tau = 0.5
|
||||||
|
|
@ -13,6 +12,7 @@ const (
|
||||||
glicko2Epsilon = 1e-6
|
glicko2Epsilon = 1e-6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Glicko2Rating represents a Glicko-2 rating.
|
||||||
type Glicko2Rating struct {
|
type Glicko2Rating struct {
|
||||||
Mu float64 `json:"mu"`
|
Mu float64 `json:"mu"`
|
||||||
Phi float64 `json:"phi"`
|
Phi float64 `json:"phi"`
|
||||||
|
|
@ -144,12 +144,12 @@ func updateSingleRating(r Glicko2Rating, opps []opponent) Glicko2Rating {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRatings computes new ratings for all participants in a multi-player match.
|
// UpdateRatings computes new ratings for all participants in a multi-player match.
|
||||||
// Scores are used pairwise: for each pair (i, j), player i gets:
|
// Scores are used pairwise: for each pair (i, j), player i gets:
|
||||||
// - 1.0 if scores[i] > scores[j]
|
// - 1.0 if scores[i] > scores[j]
|
||||||
// - 0.5 if scores[i] == scores[j]
|
// - 0.5 if scores[i] == scores[j]
|
||||||
// - 0.0 if scores[i] < scores[j]
|
// - 0.0 if scores[i] < scores[j]
|
||||||
func updateRatings(ratings []Glicko2Rating, scores []float64) []Glicko2Rating {
|
func UpdateRatings(ratings []Glicko2Rating, scores []float64) []Glicko2Rating {
|
||||||
n := len(ratings)
|
n := len(ratings)
|
||||||
if n < 2 {
|
if n < 2 {
|
||||||
return ratings
|
return ratings
|
||||||
|
|
@ -183,3 +183,29 @@ func updateRatings(ratings []Glicko2Rating, scores []float64) []Glicko2Rating {
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComputeRatingUpdates computes rating updates for match participants.
|
||||||
|
// botIDs, currentRatings, and scores must all have the same length.
|
||||||
|
func ComputeRatingUpdates(botIDs []string, currentRatings []Glicko2Rating, scores []float64) []RatingUpdate {
|
||||||
|
if len(botIDs) != len(currentRatings) || len(botIDs) != len(scores) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newRatings := UpdateRatings(currentRatings, scores)
|
||||||
|
updates := make([]RatingUpdate, len(botIDs))
|
||||||
|
|
||||||
|
for i := range botIDs {
|
||||||
|
updates[i] = RatingUpdate{
|
||||||
|
BotID: botIDs[i],
|
||||||
|
Mu: newRatings[i].Mu,
|
||||||
|
Phi: newRatings[i].Phi,
|
||||||
|
Sigma: newRatings[i].Sigma,
|
||||||
|
DisplayRating: newRatings[i].DisplayRating(),
|
||||||
|
RatingMuBefore: currentRatings[i].Mu,
|
||||||
|
RatingPhiBefore: currentRatings[i].Phi,
|
||||||
|
RatingDeviationChange: newRatings[i].Phi - currentRatings[i].Phi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
-- Migration: 0001_initial
|
|
||||||
-- Description: Initial database schema for AI Code Battle
|
|
||||||
-- Created: 2025-03-24
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Core Tables
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Bots table: stores registered bots
|
|
||||||
CREATE TABLE IF NOT EXISTS bots (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
endpoint_url TEXT NOT NULL,
|
|
||||||
api_key_hash TEXT NOT NULL,
|
|
||||||
rating REAL NOT NULL DEFAULT 1500.0,
|
|
||||||
rating_deviation REAL NOT NULL DEFAULT 350.0,
|
|
||||||
rating_volatility REAL NOT NULL DEFAULT 0.06,
|
|
||||||
evolved INTEGER NOT NULL DEFAULT 0,
|
|
||||||
island TEXT,
|
|
||||||
generation INTEGER,
|
|
||||||
parent_ids TEXT,
|
|
||||||
description TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_health_check TEXT,
|
|
||||||
health_status TEXT DEFAULT 'unknown',
|
|
||||||
matches_played INTEGER NOT NULL DEFAULT 0,
|
|
||||||
matches_won INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_rating ON bots(rating DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_evolved ON bots(evolved);
|
|
||||||
|
|
||||||
-- Matches table: stores match metadata
|
|
||||||
CREATE TABLE IF NOT EXISTS matches (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
winner_id TEXT,
|
|
||||||
turns INTEGER,
|
|
||||||
end_reason TEXT,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
scores_json TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_created ON matches(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_map ON matches(map_id);
|
|
||||||
|
|
||||||
-- Match participants: links bots to matches
|
|
||||||
CREATE TABLE IF NOT EXISTS match_participants (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
bot_id TEXT NOT NULL,
|
|
||||||
player_index INTEGER NOT NULL,
|
|
||||||
score INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT,
|
|
||||||
rating_before REAL NOT NULL,
|
|
||||||
rating_after REAL,
|
|
||||||
rating_deviation_before REAL NOT NULL,
|
|
||||||
rating_deviation_after REAL,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(match_id, bot_id),
|
|
||||||
UNIQUE(match_id, player_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_match_participants_match ON match_participants(match_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_match_participants_bot ON match_participants(bot_id);
|
|
||||||
|
|
||||||
-- Jobs table: match execution jobs for workers
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
worker_id TEXT,
|
|
||||||
claimed_at TEXT,
|
|
||||||
heartbeat_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
error_message TEXT,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_worker ON jobs(worker_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_heartbeat ON jobs(heartbeat_at);
|
|
||||||
|
|
||||||
-- Rating history: tracks rating changes over time
|
|
||||||
CREATE TABLE IF NOT EXISTS rating_history (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
bot_id TEXT NOT NULL,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
rating_before REAL NOT NULL,
|
|
||||||
rating_after REAL NOT NULL,
|
|
||||||
rating_deviation REAL NOT NULL,
|
|
||||||
recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rating_history_time ON rating_history(recorded_at DESC);
|
|
||||||
|
|
||||||
-- Maps table: stores generated maps
|
|
||||||
CREATE TABLE IF NOT EXISTS maps (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
player_count INTEGER NOT NULL DEFAULT 2,
|
|
||||||
walls TEXT NOT NULL,
|
|
||||||
spawns TEXT NOT NULL,
|
|
||||||
cores TEXT NOT NULL,
|
|
||||||
energy_nodes TEXT NOT NULL,
|
|
||||||
wall_density REAL NOT NULL DEFAULT 0.15,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
engagement_score REAL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_status ON maps(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_player_count ON maps(player_count);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_engagement ON maps(engagement_score DESC);
|
|
||||||
|
|
||||||
-- Bot secrets: stores API keys for bots (separate for security)
|
|
||||||
CREATE TABLE IF NOT EXISTS bot_secrets (
|
|
||||||
bot_id TEXT PRIMARY KEY,
|
|
||||||
api_key_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Prediction System
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Predictions: visitor predictions on match outcomes
|
|
||||||
CREATE TABLE IF NOT EXISTS predictions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
predictor_id TEXT NOT NULL,
|
|
||||||
predictor_name TEXT,
|
|
||||||
predicted_bot_id TEXT NOT NULL,
|
|
||||||
correct INTEGER,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (predicted_bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictions_match ON predictions(match_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictions_predictor ON predictions(predictor_id);
|
|
||||||
|
|
||||||
-- Predictor stats: aggregate prediction accuracy
|
|
||||||
CREATE TABLE IF NOT EXISTS predictor_stats (
|
|
||||||
predictor_id TEXT PRIMARY KEY,
|
|
||||||
predictor_name TEXT,
|
|
||||||
correct INTEGER NOT NULL DEFAULT 0,
|
|
||||||
incorrect INTEGER NOT NULL DEFAULT 0,
|
|
||||||
streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
best_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
rating REAL NOT NULL DEFAULT 1000.0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictor_stats_rating ON predictor_stats(rating DESC);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Map Voting
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Map votes: community voting on map quality
|
|
||||||
CREATE TABLE IF NOT EXISTS map_votes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
voter_id TEXT NOT NULL,
|
|
||||||
vote INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(map_id, voter_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_map_votes_map ON map_votes(map_id);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Replay Feedback
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Replay feedback: community annotations on replays
|
|
||||||
CREATE TABLE IF NOT EXISTS replay_feedback (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
turn INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
author TEXT NOT NULL,
|
|
||||||
upvotes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_match ON replay_feedback(match_id, turn);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_type ON replay_feedback(type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_upvotes ON replay_feedback(upvotes DESC);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Multi-Game Series
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Series: best-of-N match series between two bots
|
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
bot_a_id TEXT NOT NULL,
|
|
||||||
bot_b_id TEXT NOT NULL,
|
|
||||||
format INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
a_wins INTEGER NOT NULL DEFAULT 0,
|
|
||||||
b_wins INTEGER NOT NULL DEFAULT 0,
|
|
||||||
season_id TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
FOREIGN KEY (bot_a_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (bot_b_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_status ON series(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id);
|
|
||||||
|
|
||||||
-- Series games: individual games within a series
|
|
||||||
CREATE TABLE IF NOT EXISTS series_games (
|
|
||||||
series_id TEXT NOT NULL,
|
|
||||||
game_number INTEGER NOT NULL,
|
|
||||||
match_id TEXT,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
winner INTEGER,
|
|
||||||
PRIMARY KEY (series_id, game_number),
|
|
||||||
FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_games_match ON series_games(match_id);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Seasonal Rotations
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Seasons: seasonal leaderboards with rule variations
|
|
||||||
CREATE TABLE IF NOT EXISTS seasons (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
theme TEXT NOT NULL,
|
|
||||||
rules_version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT,
|
|
||||||
champion_id TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
FOREIGN KEY (champion_id) REFERENCES bots(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_seasons_status ON seasons(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_seasons_dates ON seasons(started_at, ended_at);
|
|
||||||
2881
worker-api/package-lock.json
generated
2881
worker-api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"name": "acb-api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "wrangler dev",
|
|
||||||
"deploy": "wrangler deploy",
|
|
||||||
"cf-typegen": "wrangler types",
|
|
||||||
"test": "vitest"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20250310.0",
|
|
||||||
"typescript": "^5.8.2",
|
|
||||||
"vitest": "^3.0.9",
|
|
||||||
"wrangler": "^4.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
-- AI Code Battle D1 Schema
|
|
||||||
-- Complete schema with all tables from the implementation plan
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Core Tables (Phase 4)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Bots table: stores registered bots
|
|
||||||
CREATE TABLE IF NOT EXISTS bots (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
endpoint_url TEXT NOT NULL,
|
|
||||||
api_key_hash TEXT NOT NULL,
|
|
||||||
rating REAL NOT NULL DEFAULT 1500.0,
|
|
||||||
rating_deviation REAL NOT NULL DEFAULT 350.0,
|
|
||||||
rating_volatility REAL NOT NULL DEFAULT 0.06,
|
|
||||||
evolved INTEGER NOT NULL DEFAULT 0,
|
|
||||||
island TEXT,
|
|
||||||
generation INTEGER,
|
|
||||||
parent_ids TEXT,
|
|
||||||
description TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_health_check TEXT,
|
|
||||||
health_status TEXT DEFAULT 'unknown',
|
|
||||||
matches_played INTEGER NOT NULL DEFAULT 0,
|
|
||||||
matches_won INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_rating ON bots(rating DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bots_evolved ON bots(evolved);
|
|
||||||
|
|
||||||
-- Matches table: stores match metadata
|
|
||||||
CREATE TABLE IF NOT EXISTS matches (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
winner_id TEXT,
|
|
||||||
turns INTEGER,
|
|
||||||
end_reason TEXT,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
scores_json TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_created ON matches(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_matches_map ON matches(map_id);
|
|
||||||
|
|
||||||
-- Match participants: links bots to matches
|
|
||||||
CREATE TABLE IF NOT EXISTS match_participants (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
bot_id TEXT NOT NULL,
|
|
||||||
player_index INTEGER NOT NULL,
|
|
||||||
score INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT,
|
|
||||||
rating_before REAL NOT NULL,
|
|
||||||
rating_after REAL,
|
|
||||||
rating_deviation_before REAL NOT NULL,
|
|
||||||
rating_deviation_after REAL,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(match_id, bot_id),
|
|
||||||
UNIQUE(match_id, player_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_match_participants_match ON match_participants(match_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_match_participants_bot ON match_participants(bot_id);
|
|
||||||
|
|
||||||
-- Jobs table: match execution jobs for workers
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
worker_id TEXT,
|
|
||||||
claimed_at TEXT,
|
|
||||||
heartbeat_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
error_message TEXT,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_worker ON jobs(worker_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_heartbeat ON jobs(heartbeat_at);
|
|
||||||
|
|
||||||
-- Rating history: tracks rating changes over time
|
|
||||||
CREATE TABLE IF NOT EXISTS rating_history (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
bot_id TEXT NOT NULL,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
rating_before REAL NOT NULL,
|
|
||||||
rating_after REAL NOT NULL,
|
|
||||||
rating_deviation REAL NOT NULL,
|
|
||||||
recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rating_history_time ON rating_history(recorded_at DESC);
|
|
||||||
|
|
||||||
-- Maps table: stores generated maps
|
|
||||||
CREATE TABLE IF NOT EXISTS maps (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
player_count INTEGER NOT NULL DEFAULT 2,
|
|
||||||
walls TEXT NOT NULL,
|
|
||||||
spawns TEXT NOT NULL,
|
|
||||||
cores TEXT NOT NULL,
|
|
||||||
energy_nodes TEXT NOT NULL,
|
|
||||||
wall_density REAL NOT NULL DEFAULT 0.15,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
engagement_score REAL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_status ON maps(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_player_count ON maps(player_count);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maps_engagement ON maps(engagement_score DESC);
|
|
||||||
|
|
||||||
-- Bot secrets: stores API keys for bots (separate for security)
|
|
||||||
CREATE TABLE IF NOT EXISTS bot_secrets (
|
|
||||||
bot_id TEXT PRIMARY KEY,
|
|
||||||
api_key_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Prediction System (Section 13.5)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Predictions: visitor predictions on match outcomes
|
|
||||||
CREATE TABLE IF NOT EXISTS predictions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
predictor_id TEXT NOT NULL,
|
|
||||||
predictor_name TEXT,
|
|
||||||
predicted_bot_id TEXT NOT NULL,
|
|
||||||
correct INTEGER,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (predicted_bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictions_match ON predictions(match_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictions_predictor ON predictions(predictor_id);
|
|
||||||
|
|
||||||
-- Predictor stats: aggregate prediction accuracy
|
|
||||||
CREATE TABLE IF NOT EXISTS predictor_stats (
|
|
||||||
predictor_id TEXT PRIMARY KEY,
|
|
||||||
predictor_name TEXT,
|
|
||||||
correct INTEGER NOT NULL DEFAULT 0,
|
|
||||||
incorrect INTEGER NOT NULL DEFAULT 0,
|
|
||||||
streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
best_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
rating REAL NOT NULL DEFAULT 1000.0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_predictor_stats_rating ON predictor_stats(rating DESC);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Map Voting (Section 13.6)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Map votes: community voting on map quality
|
|
||||||
CREATE TABLE IF NOT EXISTS map_votes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
voter_id TEXT NOT NULL,
|
|
||||||
vote INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(map_id, voter_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_map_votes_map ON map_votes(map_id);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Replay Feedback (Section 13.6)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Replay feedback: community annotations on replays
|
|
||||||
CREATE TABLE IF NOT EXISTS replay_feedback (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
match_id TEXT NOT NULL,
|
|
||||||
turn INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
author TEXT NOT NULL,
|
|
||||||
upvotes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_match ON replay_feedback(match_id, turn);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_type ON replay_feedback(type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_upvotes ON replay_feedback(upvotes DESC);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Multi-Game Series (Section 14.7)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Series: best-of-N match series between two bots
|
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
bot_a_id TEXT NOT NULL,
|
|
||||||
bot_b_id TEXT NOT NULL,
|
|
||||||
format INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
a_wins INTEGER NOT NULL DEFAULT 0,
|
|
||||||
b_wins INTEGER NOT NULL DEFAULT 0,
|
|
||||||
season_id TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
FOREIGN KEY (bot_a_id) REFERENCES bots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (bot_b_id) REFERENCES bots(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_status ON series(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id);
|
|
||||||
|
|
||||||
-- Series games: individual games within a series
|
|
||||||
CREATE TABLE IF NOT EXISTS series_games (
|
|
||||||
series_id TEXT NOT NULL,
|
|
||||||
game_number INTEGER NOT NULL,
|
|
||||||
match_id TEXT,
|
|
||||||
map_id TEXT NOT NULL,
|
|
||||||
winner INTEGER,
|
|
||||||
PRIMARY KEY (series_id, game_number),
|
|
||||||
FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_series_games_match ON series_games(match_id);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- Seasonal Rotations (Section 14.9)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- Seasons: seasonal leaderboards with rule variations
|
|
||||||
CREATE TABLE IF NOT EXISTS seasons (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
theme TEXT NOT NULL,
|
|
||||||
rules_version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT,
|
|
||||||
champion_id TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
FOREIGN KEY (champion_id) REFERENCES bots(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_seasons_status ON seasons(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_seasons_dates ON seasons(started_at, ended_at);
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
// Bot Management Endpoints
|
|
||||||
|
|
||||||
import type { Env, Bot, CreateBotRequest, ApiResponse } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random API key (256-bit, hex-encoded)
|
|
||||||
*/
|
|
||||||
function generateApiKey(): string {
|
|
||||||
const bytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
return Array.from(bytes)
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash an API key for storage
|
|
||||||
*/
|
|
||||||
async function hashApiKey(key: string): Promise<string> {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(key);
|
|
||||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
return Array.from(new Uint8Array(hash))
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/register - Register a new bot
|
|
||||||
*/
|
|
||||||
export async function registerBot(
|
|
||||||
env: Env,
|
|
||||||
request: CreateBotRequest
|
|
||||||
): Promise<ApiResponse<{ id: string; api_key: string }>> {
|
|
||||||
// Validate request
|
|
||||||
if (!request.name || !request.owner_id || !request.endpoint_url) {
|
|
||||||
return { success: false, error: 'Missing required fields' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate endpoint URL
|
|
||||||
try {
|
|
||||||
new URL(request.endpoint_url);
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: 'Invalid endpoint URL' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const botId = crypto.randomUUID();
|
|
||||||
const apiKey = generateApiKey();
|
|
||||||
const apiKeyHash = await hashApiKey(apiKey);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Check if owner already has a bot with this name
|
|
||||||
const existing = await env.DB.prepare(
|
|
||||||
'SELECT id FROM bots WHERE owner_id = ? AND name = ?'
|
|
||||||
)
|
|
||||||
.bind(request.owner_id, request.name)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return { success: false, error: 'Bot with this name already exists for this owner' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create bot
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO bots (id, name, owner_id, endpoint_url, api_key_hash, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
botId,
|
|
||||||
request.name,
|
|
||||||
request.owner_id,
|
|
||||||
request.endpoint_url,
|
|
||||||
apiKeyHash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Store API key hash separately
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO bot_secrets (bot_id, api_key_hash, created_at)
|
|
||||||
VALUES (?, ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(botId, apiKeyHash, now)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: botId,
|
|
||||||
api_key: apiKey, // Return the plain key only on creation
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/bots - List all bots
|
|
||||||
*/
|
|
||||||
export async function listBots(env: Env): Promise<ApiResponse<Bot[]>> {
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`SELECT
|
|
||||||
id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility,
|
|
||||||
created_at, updated_at, last_health_check, health_status, matches_played, matches_won
|
|
||||||
FROM bots
|
|
||||||
ORDER BY rating DESC`
|
|
||||||
).all<Bot>();
|
|
||||||
|
|
||||||
// Remove sensitive fields
|
|
||||||
const bots = (result.results || []).map((bot) => ({
|
|
||||||
...bot,
|
|
||||||
api_key_hash: '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { success: true, data: bots };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/bots/:id - Get bot details
|
|
||||||
*/
|
|
||||||
export async function getBot(env: Env, botId: string): Promise<ApiResponse<Bot>> {
|
|
||||||
const bot = await env.DB.prepare(
|
|
||||||
`SELECT
|
|
||||||
id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility,
|
|
||||||
created_at, updated_at, last_health_check, health_status, matches_played, matches_won
|
|
||||||
FROM bots
|
|
||||||
WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(botId)
|
|
||||||
.first<Bot>();
|
|
||||||
|
|
||||||
if (!bot) {
|
|
||||||
return { success: false, error: 'Bot not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: { ...bot, api_key_hash: '' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/bots/:id - Update bot details
|
|
||||||
*/
|
|
||||||
export async function updateBot(
|
|
||||||
env: Env,
|
|
||||||
botId: string,
|
|
||||||
updates: { name?: string; endpoint_url?: string }
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const setClauses: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
|
|
||||||
if (updates.name) {
|
|
||||||
setClauses.push('name = ?');
|
|
||||||
values.push(updates.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.endpoint_url) {
|
|
||||||
try {
|
|
||||||
new URL(updates.endpoint_url);
|
|
||||||
setClauses.push('endpoint_url = ?');
|
|
||||||
values.push(updates.endpoint_url);
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: 'Invalid endpoint URL' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setClauses.length === 0) {
|
|
||||||
return { success: false, error: 'No valid updates provided' };
|
|
||||||
}
|
|
||||||
|
|
||||||
setClauses.push('updated_at = ?');
|
|
||||||
values.push(now);
|
|
||||||
values.push(botId);
|
|
||||||
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`UPDATE bots SET ${setClauses.join(', ')} WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(...values)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
if (result.meta.changes === 0) {
|
|
||||||
return { success: false, error: 'Bot not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/rotate-key - Rotate bot API key
|
|
||||||
*/
|
|
||||||
export async function rotateApiKey(
|
|
||||||
env: Env,
|
|
||||||
botId: string,
|
|
||||||
ownerId: string
|
|
||||||
): Promise<ApiResponse<{ api_key: string }>> {
|
|
||||||
// Verify ownership
|
|
||||||
const bot = await env.DB.prepare('SELECT owner_id FROM bots WHERE id = ?')
|
|
||||||
.bind(botId)
|
|
||||||
.first<{ owner_id: string }>();
|
|
||||||
|
|
||||||
if (!bot) {
|
|
||||||
return { success: false, error: 'Bot not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bot.owner_id !== ownerId) {
|
|
||||||
return { success: false, error: 'Not authorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newApiKey = generateApiKey();
|
|
||||||
const apiKeyHash = await hashApiKey(newApiKey);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Update bot
|
|
||||||
await env.DB.prepare('UPDATE bots SET api_key_hash = ?, updated_at = ? WHERE id = ?')
|
|
||||||
.bind(apiKeyHash, now, botId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Update secret
|
|
||||||
await env.DB.prepare('UPDATE bot_secrets SET api_key_hash = ? WHERE bot_id = ?')
|
|
||||||
.bind(apiKeyHash, botId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return { success: true, data: { api_key: newApiKey } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/leaderboard - Get current leaderboard
|
|
||||||
*/
|
|
||||||
export async function getLeaderboard(env: Env): Promise<ApiResponse<Bot[]>> {
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`SELECT
|
|
||||||
id, name, owner_id, rating, rating_deviation, matches_played, matches_won,
|
|
||||||
created_at, updated_at, health_status
|
|
||||||
FROM bots
|
|
||||||
WHERE matches_played > 0
|
|
||||||
ORDER BY rating DESC
|
|
||||||
LIMIT 100`
|
|
||||||
).all<Bot>();
|
|
||||||
|
|
||||||
return { success: true, data: result.results || [] };
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
// Cron Job Handlers
|
|
||||||
|
|
||||||
import type { Env, Bot } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matchmaker cron: Create match jobs for bots that need games
|
|
||||||
* Runs every minute
|
|
||||||
*/
|
|
||||||
export async function runMatchmaker(env: Env): Promise<{ created: number }> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Get bots that are healthy and have played fewer than 10 matches today
|
|
||||||
// For simplicity, we'll just pair bots randomly for now
|
|
||||||
// A more sophisticated system would consider rating proximity
|
|
||||||
|
|
||||||
// Get active bots (healthy, played at least one match or registered recently)
|
|
||||||
const bots = await env.DB.prepare(
|
|
||||||
`SELECT id, rating, matches_played FROM bots
|
|
||||||
WHERE health_status = 'healthy'
|
|
||||||
ORDER BY RANDOM()
|
|
||||||
LIMIT 10`
|
|
||||||
).all<Bot>();
|
|
||||||
|
|
||||||
if (!bots.results || bots.results.length < 2) {
|
|
||||||
return { created: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a random map
|
|
||||||
const map = await env.DB.prepare(
|
|
||||||
'SELECT id FROM maps ORDER BY RANDOM() LIMIT 1'
|
|
||||||
).first<{ id: string }>();
|
|
||||||
|
|
||||||
if (!map) {
|
|
||||||
return { created: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let created = 0;
|
|
||||||
|
|
||||||
// Create matches in pairs
|
|
||||||
for (let i = 0; i < bots.results.length - 1; i += 2) {
|
|
||||||
const bot1 = bots.results[i];
|
|
||||||
const bot2 = bots.results[i + 1];
|
|
||||||
|
|
||||||
// Check if these bots already have a pending match together
|
|
||||||
const existingMatch = await env.DB.prepare(
|
|
||||||
`SELECT m.id FROM matches m
|
|
||||||
JOIN match_participants mp1 ON m.id = mp1.match_id
|
|
||||||
JOIN match_participants mp2 ON m.id = mp2.match_id
|
|
||||||
WHERE m.status = 'pending'
|
|
||||||
AND mp1.bot_id = ? AND mp2.bot_id = ?`
|
|
||||||
)
|
|
||||||
.bind(bot1.id, bot2.id)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existingMatch) {
|
|
||||||
continue; // Skip this pair
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create match
|
|
||||||
const matchId = crypto.randomUUID();
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO matches (id, status, map_id, created_at)
|
|
||||||
VALUES (?, 'pending', ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(matchId, map.id, now)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Get bot ratings for participants
|
|
||||||
const bot1Data = await env.DB.prepare(
|
|
||||||
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
|
||||||
)
|
|
||||||
.bind(bot1.id)
|
|
||||||
.first<{ rating: number; rating_deviation: number }>();
|
|
||||||
|
|
||||||
const bot2Data = await env.DB.prepare(
|
|
||||||
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
|
||||||
)
|
|
||||||
.bind(bot2.id)
|
|
||||||
.first<{ rating: number; rating_deviation: number }>();
|
|
||||||
|
|
||||||
if (!bot1Data || !bot2Data) continue;
|
|
||||||
|
|
||||||
// Create participants (player_index 0 and 1)
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
|
||||||
VALUES (?, ?, ?, 0, 0, ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(crypto.randomUUID(), matchId, bot1.id, bot1Data.rating, bot1Data.rating_deviation)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
|
||||||
VALUES (?, ?, ?, 1, 0, ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(crypto.randomUUID(), matchId, bot2.id, bot2Data.rating, bot2Data.rating_deviation)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Create job
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO jobs (id, match_id, status, created_at)
|
|
||||||
VALUES (?, ?, 'pending', ?)`
|
|
||||||
)
|
|
||||||
.bind(crypto.randomUUID(), matchId, now)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
created++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { created };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health checker cron: Ping bot endpoints to check health
|
|
||||||
* Runs every 15 minutes
|
|
||||||
*/
|
|
||||||
export async function runHealthChecker(env: Env): Promise<{ checked: number }> {
|
|
||||||
const bots = await env.DB.prepare(
|
|
||||||
`SELECT id, endpoint_url FROM bots WHERE health_status != 'unhealthy' OR last_health_check IS NULL`
|
|
||||||
).all<{ id: string; endpoint_url: string }>();
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
for (const bot of bots.results || []) {
|
|
||||||
try {
|
|
||||||
// Simple health check - just try to connect
|
|
||||||
const response = await fetch(bot.endpoint_url, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = response.ok ? 'healthy' : 'unhealthy';
|
|
||||||
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE bots SET health_status = ?, last_health_check = ? WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(status, now, bot.id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
checked++;
|
|
||||||
} catch {
|
|
||||||
// Connection failed
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE bots SET health_status = 'unhealthy', last_health_check = ? WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, bot.id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
checked++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { checked };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stale job reaper: Reclaim jobs that have timed out
|
|
||||||
* Runs every 5 minutes
|
|
||||||
*/
|
|
||||||
export async function runStaleJobReaper(env: Env): Promise<{ reclaimed: number }> {
|
|
||||||
const now = new Date();
|
|
||||||
const staleThreshold = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
|
||||||
const staleThresholdStr = staleThreshold.toISOString();
|
|
||||||
|
|
||||||
// Find jobs that have been claimed but haven't had a heartbeat in 5 minutes
|
|
||||||
const staleJobs = await env.DB.prepare(
|
|
||||||
`SELECT id, match_id FROM jobs
|
|
||||||
WHERE status = 'claimed'
|
|
||||||
AND heartbeat_at < ?`
|
|
||||||
)
|
|
||||||
.bind(staleThresholdStr)
|
|
||||||
.all<{ id: string; match_id: string }>();
|
|
||||||
|
|
||||||
let reclaimed = 0;
|
|
||||||
|
|
||||||
for (const job of staleJobs.results || []) {
|
|
||||||
// Reset the job to pending so another worker can claim it
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE jobs SET
|
|
||||||
status = 'pending',
|
|
||||||
worker_id = NULL,
|
|
||||||
claimed_at = NULL,
|
|
||||||
heartbeat_at = NULL
|
|
||||||
WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(job.id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Reset match status to pending
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE matches SET status = 'pending', started_at = NULL WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(job.match_id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
reclaimed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { reclaimed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch cron handler based on event type
|
|
||||||
*/
|
|
||||||
export async function handleCron(
|
|
||||||
env: Env,
|
|
||||||
cron: string
|
|
||||||
): Promise<{ success: boolean; result: unknown }> {
|
|
||||||
// Parse cron expression to determine which handler to run
|
|
||||||
// */1 * * * * = matchmaker (every minute)
|
|
||||||
// */5 * * * * = stale job reaper (every 5 minutes)
|
|
||||||
// */15 * * * * = health checker (every 15 minutes)
|
|
||||||
|
|
||||||
// The cron expression is passed, but we need to determine the type
|
|
||||||
// For simplicity, we'll check the pattern
|
|
||||||
if (cron === '*/1 * * * *' || cron.includes('*/1')) {
|
|
||||||
const result = await runMatchmaker(env);
|
|
||||||
return { success: true, result };
|
|
||||||
} else if (cron === '*/5 * * * *' || cron.includes('*/5')) {
|
|
||||||
const result = await runStaleJobReaper(env);
|
|
||||||
return { success: true, result };
|
|
||||||
} else if (cron === '*/15 * * * *' || cron.includes('*/15')) {
|
|
||||||
const result = await runHealthChecker(env);
|
|
||||||
return { success: true, result };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, result: 'Unknown cron pattern' };
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
// Data Export Endpoint for Index Builder
|
|
||||||
|
|
||||||
import type { Env, Bot, Match, MatchParticipant, ApiResponse } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export data for index building.
|
|
||||||
* This endpoint is called by the Rackspace index builder every ~90 minutes.
|
|
||||||
* It returns all data needed to generate the index JSON files.
|
|
||||||
*/
|
|
||||||
export interface ExportData {
|
|
||||||
bots: ExportBot[];
|
|
||||||
matches: ExportMatch[];
|
|
||||||
rating_history: RatingHistoryEntry[];
|
|
||||||
generated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportBot {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
rating_volatility: number;
|
|
||||||
matches_played: number;
|
|
||||||
matches_won: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
health_status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMatch {
|
|
||||||
id: string;
|
|
||||||
status: string;
|
|
||||||
winner_id: string | null;
|
|
||||||
turns: number | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
map_id: string;
|
|
||||||
created_at: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
participants: ExportMatchParticipant[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMatchParticipant {
|
|
||||||
bot_id: string;
|
|
||||||
player_index: number;
|
|
||||||
score: number;
|
|
||||||
rating_before: number;
|
|
||||||
rating_after: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RatingHistoryEntry {
|
|
||||||
bot_id: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
recorded_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/data/export - Export all data for index building
|
|
||||||
*/
|
|
||||||
export async function exportData(env: Env): Promise<ApiResponse<ExportData>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Fetch all bots
|
|
||||||
const botsResult = await env.DB.prepare(
|
|
||||||
`SELECT
|
|
||||||
id, name, owner_id, rating, rating_deviation, rating_volatility,
|
|
||||||
matches_played, matches_won, created_at, updated_at, health_status
|
|
||||||
FROM bots
|
|
||||||
ORDER BY rating DESC`
|
|
||||||
).all<ExportBot>();
|
|
||||||
|
|
||||||
// Fetch recent matches (last 1000 completed)
|
|
||||||
const matchesResult = await env.DB.prepare(
|
|
||||||
`SELECT id, status, winner_id, turns, end_reason, map_id, created_at, completed_at
|
|
||||||
FROM matches
|
|
||||||
WHERE status = 'completed'
|
|
||||||
ORDER BY completed_at DESC
|
|
||||||
LIMIT 1000`
|
|
||||||
).all<Match>();
|
|
||||||
|
|
||||||
// Fetch match participants for all matches
|
|
||||||
const matchIds = matchesResult.results.map(m => m.id);
|
|
||||||
let participants: MatchParticipant[] = [];
|
|
||||||
|
|
||||||
if (matchIds.length > 0) {
|
|
||||||
// Build query with proper parameter binding
|
|
||||||
const placeholders = matchIds.map(() => '?').join(',');
|
|
||||||
const participantsResult = await env.DB.prepare(
|
|
||||||
`SELECT bot_id, match_id, player_index, score, rating_before, rating_after
|
|
||||||
FROM match_participants
|
|
||||||
WHERE match_id IN (${placeholders})`
|
|
||||||
).bind(...matchIds).all<MatchParticipant>();
|
|
||||||
|
|
||||||
participants = participantsResult.results || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group participants by match_id
|
|
||||||
const participantsByMatch = new Map<string, MatchParticipant[]>();
|
|
||||||
for (const p of participants) {
|
|
||||||
if (!participantsByMatch.has(p.match_id)) {
|
|
||||||
participantsByMatch.set(p.match_id, []);
|
|
||||||
}
|
|
||||||
participantsByMatch.get(p.match_id)!.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build export matches with embedded participants
|
|
||||||
const exportMatches: ExportMatch[] = matchesResult.results.map(m => ({
|
|
||||||
id: m.id,
|
|
||||||
status: m.status,
|
|
||||||
winner_id: m.winner_id,
|
|
||||||
turns: m.turns,
|
|
||||||
end_reason: m.end_reason,
|
|
||||||
map_id: m.map_id,
|
|
||||||
created_at: m.created_at,
|
|
||||||
completed_at: m.completed_at,
|
|
||||||
participants: (participantsByMatch.get(m.id) || []).map(p => ({
|
|
||||||
bot_id: p.bot_id,
|
|
||||||
player_index: p.player_index,
|
|
||||||
score: p.score,
|
|
||||||
rating_before: p.rating_before,
|
|
||||||
rating_after: p.rating_after,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Fetch rating history (last 30 days)
|
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
const ratingHistoryResult = await env.DB.prepare(
|
|
||||||
`SELECT bot_id, rating, rating_deviation, recorded_at
|
|
||||||
FROM rating_history
|
|
||||||
WHERE recorded_at >= ?
|
|
||||||
ORDER BY bot_id, recorded_at ASC`
|
|
||||||
)
|
|
||||||
.bind(thirtyDaysAgo)
|
|
||||||
.all<RatingHistoryEntry>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
bots: botsResult.results || [],
|
|
||||||
matches: exportMatches,
|
|
||||||
rating_history: ratingHistoryResult.results || [],
|
|
||||||
generated_at: now,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
toGlicko2,
|
|
||||||
fromGlicko2,
|
|
||||||
updateRating,
|
|
||||||
g,
|
|
||||||
E,
|
|
||||||
} from './glicko2';
|
|
||||||
|
|
||||||
describe('Glicko-2 Rating System', () => {
|
|
||||||
describe('Scale Conversion', () => {
|
|
||||||
it('converts rating to Glicko-2 scale correctly', () => {
|
|
||||||
// Default rating 1500 should map to mu=0
|
|
||||||
const result = toGlicko2(1500, 350);
|
|
||||||
expect(result.mu).toBe(0);
|
|
||||||
expect(result.phi).toBeCloseTo(350 / 173.7178, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts rating above default correctly', () => {
|
|
||||||
const result = toGlicko2(1900, 100);
|
|
||||||
expect(result.mu).toBeCloseTo(400 / 173.7178, 10);
|
|
||||||
expect(result.phi).toBeCloseTo(100 / 173.7178, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts rating below default correctly', () => {
|
|
||||||
const result = toGlicko2(1300, 200);
|
|
||||||
expect(result.mu).toBeCloseTo(-200 / 173.7178, 10);
|
|
||||||
expect(result.phi).toBeCloseTo(200 / 173.7178, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips correctly', () => {
|
|
||||||
const originalRating = 1650;
|
|
||||||
const originalRd = 150;
|
|
||||||
|
|
||||||
const g2 = toGlicko2(originalRating, originalRd);
|
|
||||||
const result = fromGlicko2(g2);
|
|
||||||
|
|
||||||
expect(result.rating).toBeCloseTo(originalRating, 10);
|
|
||||||
expect(result.rd).toBeCloseTo(originalRd, 10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('g function', () => {
|
|
||||||
it('returns 1 when phi is 0', () => {
|
|
||||||
expect(g(0)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decreases as phi increases', () => {
|
|
||||||
const g1 = g(0.1);
|
|
||||||
const g2 = g(0.5);
|
|
||||||
const g3 = g(1.0);
|
|
||||||
|
|
||||||
expect(g1).toBeGreaterThan(g2);
|
|
||||||
expect(g2).toBeGreaterThan(g3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct values for known inputs', () => {
|
|
||||||
// g(0.2) = 1/sqrt(1 + 3*0.04/pi^2) ≈ 0.993976
|
|
||||||
expect(g(0.2)).toBeCloseTo(0.993976, 4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('E function', () => {
|
|
||||||
it('returns 0.5 when ratings are equal', () => {
|
|
||||||
const e = E(0, 0, 0.2);
|
|
||||||
expect(e).toBeCloseTo(0.5, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns > 0.5 when player rating is higher', () => {
|
|
||||||
const e = E(0.5, 0, 0.2); // Player rated higher
|
|
||||||
expect(e).toBeGreaterThan(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns < 0.5 when opponent rating is higher', () => {
|
|
||||||
const e = E(0, 0.5, 0.2); // Opponent rated higher
|
|
||||||
expect(e).toBeLessThan(0.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rating Updates', () => {
|
|
||||||
it('increases rating after win against equal opponent', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 200,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opponents = [
|
|
||||||
{ rating: 1500, rd: 200, score: 1 }, // Win
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = updateRating(bot, opponents);
|
|
||||||
|
|
||||||
// Rating should increase after winning
|
|
||||||
expect(result.rating).toBeGreaterThan(1500);
|
|
||||||
// RD should decrease after playing
|
|
||||||
expect(result.rd).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decreases rating after loss against equal opponent', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 200,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opponents = [
|
|
||||||
{ rating: 1500, rd: 200, score: 0 }, // Loss
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = updateRating(bot, opponents);
|
|
||||||
|
|
||||||
// Rating should decrease after losing
|
|
||||||
expect(result.rating).toBeLessThan(1500);
|
|
||||||
// RD should decrease after playing
|
|
||||||
expect(result.rd).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles draw correctly', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 200,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opponents = [
|
|
||||||
{ rating: 1500, rd: 200, score: 0.5 }, // Draw
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = updateRating(bot, opponents);
|
|
||||||
|
|
||||||
// Rating should stay roughly the same against equal opponent
|
|
||||||
expect(result.rating).toBeCloseTo(1500, 1);
|
|
||||||
// RD should decrease after playing
|
|
||||||
expect(result.rd).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles multiple opponents', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 200,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opponents = [
|
|
||||||
{ rating: 1600, rd: 150, score: 1 }, // Win vs higher rated
|
|
||||||
{ rating: 1400, rd: 150, score: 0 }, // Loss vs lower rated
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = updateRating(bot, opponents);
|
|
||||||
|
|
||||||
// Both rating and RD should be updated
|
|
||||||
expect(result.rating).toBeGreaterThan(0);
|
|
||||||
expect(result.rd).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increases RD when no games played (rating decay)', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 100,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = updateRating(bot, []);
|
|
||||||
|
|
||||||
// Rating should stay the same
|
|
||||||
expect(result.rating).toBe(1500);
|
|
||||||
// RD should increase (rating decay)
|
|
||||||
expect(result.rd).toBeGreaterThan(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('constrains RD to maximum', () => {
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 340,
|
|
||||||
rating_volatility: 0.5, // High volatility
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = updateRating(bot, []);
|
|
||||||
|
|
||||||
// RD should not exceed 350
|
|
||||||
expect(result.rd).toBeLessThanOrEqual(350);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Real-world scenarios', () => {
|
|
||||||
it('matches expected rating change from Glicko-2 paper example', () => {
|
|
||||||
// This is a simplified test based on the Glicko-2 paper
|
|
||||||
// Player with rating 1500, RD 200 playing against:
|
|
||||||
// - Opponent 1: 1400, 30, win (score=1)
|
|
||||||
// - Opponent 2: 1550, 100, loss (score=0)
|
|
||||||
// - Opponent 3: 1700, 300, loss (score=0)
|
|
||||||
|
|
||||||
const bot = {
|
|
||||||
id: 'test',
|
|
||||||
name: 'Test',
|
|
||||||
owner_id: 'owner',
|
|
||||||
endpoint_url: 'http://example.com',
|
|
||||||
api_key_hash: 'hash',
|
|
||||||
rating: 1500,
|
|
||||||
rating_deviation: 200,
|
|
||||||
rating_volatility: 0.06,
|
|
||||||
created_at: '2024-01-01',
|
|
||||||
updated_at: '2024-01-01',
|
|
||||||
last_health_check: null,
|
|
||||||
health_status: 'healthy' as const,
|
|
||||||
matches_played: 0,
|
|
||||||
matches_won: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opponents = [
|
|
||||||
{ rating: 1400, rd: 30, score: 1 },
|
|
||||||
{ rating: 1550, rd: 100, score: 0 },
|
|
||||||
{ rating: 1700, rd: 300, score: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = updateRating(bot, opponents);
|
|
||||||
|
|
||||||
// The new rating should be in a reasonable range
|
|
||||||
// Based on the paper, expected new rating is approximately 1464
|
|
||||||
expect(result.rating).toBeGreaterThan(1400);
|
|
||||||
expect(result.rating).toBeLessThan(1550);
|
|
||||||
expect(result.rd).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
// Glicko-2 Rating System Implementation
|
|
||||||
// Based on: http://www.glicko.net/glicko/glicko2.pdf
|
|
||||||
|
|
||||||
import type { Env, Bot, MatchParticipant } from './types';
|
|
||||||
|
|
||||||
// Glicko-2 constants
|
|
||||||
const SCALE = 173.7178; // Rating scale conversion factor
|
|
||||||
const TAU = 0.5; // System constant (constrains volatility change)
|
|
||||||
const DEFAULT_RATING = 1500;
|
|
||||||
const DEFAULT_RD = 350;
|
|
||||||
const DEFAULT_VOLATILITY = 0.06;
|
|
||||||
|
|
||||||
export interface Glicko2Rating {
|
|
||||||
mu: number; // Mean rating (Glicko-2 scale)
|
|
||||||
phi: number; // Rating deviation (Glicko-2 scale)
|
|
||||||
sigma: number; // Volatility
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert rating to Glicko-2 scale
|
|
||||||
*/
|
|
||||||
export function toGlicko2(rating: number, rd: number): Glicko2Rating {
|
|
||||||
return {
|
|
||||||
mu: (rating - DEFAULT_RATING) / SCALE,
|
|
||||||
phi: rd / SCALE,
|
|
||||||
sigma: DEFAULT_VOLATILITY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert from Glicko-2 scale to original scale
|
|
||||||
*/
|
|
||||||
export function fromGlicko2(g2: Glicko2Rating): { rating: number; rd: number } {
|
|
||||||
return {
|
|
||||||
rating: g2.mu * SCALE + DEFAULT_RATING,
|
|
||||||
rd: g2.phi * SCALE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute g(phi) function
|
|
||||||
*/
|
|
||||||
export function g(phi: number): number {
|
|
||||||
return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute E(mu, mu_j, phi_j) function
|
|
||||||
*/
|
|
||||||
export function E(mu: number, mu_j: number, phi_j: number): number {
|
|
||||||
return 1 / (1 + Math.exp(-g(phi_j) * (mu - mu_j)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute new rating deviation (Step 5/6)
|
|
||||||
*/
|
|
||||||
function computeNewPhi(phi: number, v: number): number {
|
|
||||||
const phiSquared = phi * phi;
|
|
||||||
const vInverse = 1 / v;
|
|
||||||
return 1 / Math.sqrt(1 / phiSquared + vInverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterative algorithm to compute new volatility (Step 5.4)
|
|
||||||
*/
|
|
||||||
function computeNewVolatility(
|
|
||||||
sigma: number,
|
|
||||||
phi: number,
|
|
||||||
v: number,
|
|
||||||
delta: number,
|
|
||||||
tau: number = TAU
|
|
||||||
): number {
|
|
||||||
let a = Math.log(sigma * sigma);
|
|
||||||
const epsilon = 0.000001;
|
|
||||||
|
|
||||||
const f = (x: number): number => {
|
|
||||||
const expX = Math.exp(x);
|
|
||||||
const tmp = phi * phi + v + expX;
|
|
||||||
return (
|
|
||||||
(expX * (delta * delta - phi * phi - v - expX)) / (2 * tmp * tmp) -
|
|
||||||
(x - a) / (tau * tau)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set initial bounds
|
|
||||||
let A = a;
|
|
||||||
let B: number;
|
|
||||||
if (delta * delta > phi * phi + v) {
|
|
||||||
B = Math.log(delta * delta - phi * phi - v);
|
|
||||||
} else {
|
|
||||||
let k = 1;
|
|
||||||
while (f(a - k * tau) < 0) {
|
|
||||||
k++;
|
|
||||||
}
|
|
||||||
B = a - k * tau;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Illinois algorithm
|
|
||||||
let fA = f(A);
|
|
||||||
let fB = f(B);
|
|
||||||
|
|
||||||
while (Math.abs(B - A) > epsilon) {
|
|
||||||
const C = A + ((A - B) * fA) / (fB - fA);
|
|
||||||
const fC = f(C);
|
|
||||||
|
|
||||||
if (fC * fB <= 0) {
|
|
||||||
A = B;
|
|
||||||
fA = fB;
|
|
||||||
} else {
|
|
||||||
fA = fA / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
B = C;
|
|
||||||
fB = fC;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.exp(A / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate rating updates for a bot after a match
|
|
||||||
* @param bot The bot whose rating to update
|
|
||||||
* @param opponents Array of opponent ratings and game outcomes (1=win, 0.5=draw, 0=loss)
|
|
||||||
* @returns New rating values
|
|
||||||
*/
|
|
||||||
export function updateRating(
|
|
||||||
bot: Bot,
|
|
||||||
opponents: Array<{
|
|
||||||
rating: number;
|
|
||||||
rd: number;
|
|
||||||
score: number;
|
|
||||||
}>
|
|
||||||
): { rating: number; rd: number; volatility: number } {
|
|
||||||
if (opponents.length === 0) {
|
|
||||||
// No games played - increase RD over time (rating decay)
|
|
||||||
const phi = bot.rating_deviation / SCALE;
|
|
||||||
const newPhi = Math.min(Math.sqrt(phi * phi + bot.rating_volatility * bot.rating_volatility), 350 / SCALE);
|
|
||||||
return {
|
|
||||||
rating: bot.rating,
|
|
||||||
rd: newPhi * SCALE,
|
|
||||||
volatility: bot.rating_volatility,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to Glicko-2 scale
|
|
||||||
const g2 = toGlicko2(bot.rating, bot.rating_deviation);
|
|
||||||
g2.sigma = bot.rating_volatility;
|
|
||||||
|
|
||||||
// Step 3: Compute v (variance of game outcomes)
|
|
||||||
let vInverse = 0;
|
|
||||||
for (const opp of opponents) {
|
|
||||||
const oppG2 = toGlicko2(opp.rating, opp.rd);
|
|
||||||
const gPhi = g(oppG2.phi);
|
|
||||||
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
|
|
||||||
vInverse += gPhi * gPhi * eValue * (1 - eValue);
|
|
||||||
}
|
|
||||||
const v = 1 / vInverse;
|
|
||||||
|
|
||||||
// Step 4: Compute delta (rating improvement)
|
|
||||||
let deltaSum = 0;
|
|
||||||
for (const opp of opponents) {
|
|
||||||
const oppG2 = toGlicko2(opp.rating, opp.rd);
|
|
||||||
const gPhi = g(oppG2.phi);
|
|
||||||
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
|
|
||||||
deltaSum += gPhi * (opp.score - eValue);
|
|
||||||
}
|
|
||||||
const delta = v * deltaSum;
|
|
||||||
|
|
||||||
// Step 5: Compute new volatility
|
|
||||||
const newSigma = computeNewVolatility(g2.sigma, g2.phi, v, delta);
|
|
||||||
|
|
||||||
// Step 6: Update phi
|
|
||||||
const phiStar = Math.sqrt(g2.phi * g2.phi + newSigma * newSigma);
|
|
||||||
|
|
||||||
// Step 7: Update phi and mu
|
|
||||||
const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
|
|
||||||
const newMu = g2.mu + newPhi * newPhi * deltaSum;
|
|
||||||
|
|
||||||
// Convert back
|
|
||||||
const result = fromGlicko2({ mu: newMu, phi: newPhi, sigma: newSigma });
|
|
||||||
|
|
||||||
return {
|
|
||||||
rating: result.rating,
|
|
||||||
rd: result.rd,
|
|
||||||
volatility: newSigma,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update ratings for all participants in a completed match
|
|
||||||
*/
|
|
||||||
export async function updateMatchRatings(
|
|
||||||
env: Env,
|
|
||||||
matchId: string,
|
|
||||||
participants: MatchParticipant[],
|
|
||||||
winnerId: string | null
|
|
||||||
): Promise<void> {
|
|
||||||
// Get all bots involved
|
|
||||||
const botIds = participants.map((p) => p.bot_id);
|
|
||||||
const placeholders = botIds.map(() => '?').join(',');
|
|
||||||
|
|
||||||
const bots = await env.DB.prepare(
|
|
||||||
`SELECT * FROM bots WHERE id IN (${placeholders})`
|
|
||||||
)
|
|
||||||
.bind(...botIds)
|
|
||||||
.all<Bot>();
|
|
||||||
|
|
||||||
if (!bots.results || bots.results.length !== participants.length) {
|
|
||||||
throw new Error('Could not find all participant bots');
|
|
||||||
}
|
|
||||||
|
|
||||||
const botMap = new Map(bots.results.map((b) => [b.id, b]));
|
|
||||||
|
|
||||||
// Calculate new ratings for each participant
|
|
||||||
const updates: Array<{
|
|
||||||
botId: string;
|
|
||||||
rating: number;
|
|
||||||
rd: number;
|
|
||||||
volatility: number;
|
|
||||||
won: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const participant of participants) {
|
|
||||||
const bot = botMap.get(participant.bot_id);
|
|
||||||
if (!bot) continue;
|
|
||||||
|
|
||||||
// Build opponent list
|
|
||||||
const opponents = participants
|
|
||||||
.filter((p) => p.bot_id !== participant.bot_id)
|
|
||||||
.map((opp) => {
|
|
||||||
const oppBot = botMap.get(opp.bot_id)!;
|
|
||||||
// Score: 1 for win, 0.5 for draw (if no winner), 0 for loss
|
|
||||||
let score = 0.5;
|
|
||||||
if (winnerId === participant.bot_id) {
|
|
||||||
score = 1;
|
|
||||||
} else if (winnerId === opp.bot_id) {
|
|
||||||
score = 0;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rating: oppBot.rating,
|
|
||||||
rd: oppBot.rating_deviation,
|
|
||||||
score,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const newRating = updateRating(bot, opponents);
|
|
||||||
const won = winnerId === participant.bot_id;
|
|
||||||
|
|
||||||
updates.push({
|
|
||||||
botId: participant.bot_id,
|
|
||||||
rating: newRating.rating,
|
|
||||||
rd: newRating.rd,
|
|
||||||
volatility: newRating.volatility,
|
|
||||||
won,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates in a batch
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
for (const update of updates) {
|
|
||||||
// Update bot rating
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE bots SET
|
|
||||||
rating = ?,
|
|
||||||
rating_deviation = ?,
|
|
||||||
rating_volatility = ?,
|
|
||||||
matches_played = matches_played + 1,
|
|
||||||
matches_won = matches_won + ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
update.rating,
|
|
||||||
update.rd,
|
|
||||||
update.volatility,
|
|
||||||
update.won ? 1 : 0,
|
|
||||||
now,
|
|
||||||
update.botId
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Update participant with rating change
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE match_participants SET
|
|
||||||
rating_after = ?,
|
|
||||||
rating_deviation_after = ?
|
|
||||||
WHERE match_id = ? AND bot_id = ?`
|
|
||||||
)
|
|
||||||
.bind(update.rating, update.rd, matchId, update.botId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Record rating history
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO rating_history (id, bot_id, match_id, rating_before, rating_after, rating_deviation, recorded_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
crypto.randomUUID(),
|
|
||||||
update.botId,
|
|
||||||
matchId,
|
|
||||||
botMap.get(update.botId)!.rating,
|
|
||||||
update.rating,
|
|
||||||
update.rd,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
// AI Code Battle Worker API
|
|
||||||
// Phase 4: Match Orchestration
|
|
||||||
|
|
||||||
import type { Env, ApiResponse, ClaimJobRequest, SubmitResultRequest, CreateBotRequest } from './types';
|
|
||||||
import { handleCron } from './cron';
|
|
||||||
import {
|
|
||||||
getNextJob,
|
|
||||||
claimJob,
|
|
||||||
heartbeatJob,
|
|
||||||
submitResult,
|
|
||||||
failJob,
|
|
||||||
} from './jobs';
|
|
||||||
import {
|
|
||||||
registerBot,
|
|
||||||
listBots,
|
|
||||||
getBot,
|
|
||||||
updateBot,
|
|
||||||
rotateApiKey,
|
|
||||||
getLeaderboard,
|
|
||||||
} from './bots';
|
|
||||||
import { exportData } from './export';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const path = url.pathname;
|
|
||||||
const method = request.method;
|
|
||||||
|
|
||||||
// CORS headers
|
|
||||||
const corsHeaders = {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle preflight
|
|
||||||
if (method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for JSON responses
|
|
||||||
const json = <T>(data: ApiResponse<T>, status = 200): Response => {
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
status,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...corsHeaders,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to verify API key
|
|
||||||
const verifyApiKey = async (): Promise<boolean> => {
|
|
||||||
const apiKey = request.headers.get('X-API-Key');
|
|
||||||
if (!apiKey) return false;
|
|
||||||
return apiKey === env.API_KEY;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to parse JSON body
|
|
||||||
const parseBody = async <T>(): Promise<T | null> => {
|
|
||||||
try {
|
|
||||||
return await request.json<T>();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Health check (liveness probe - always returns 200 if process is running)
|
|
||||||
if (path === '/health' || path === '/api/health') {
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
status: 'healthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Readiness check (checks if service can handle requests)
|
|
||||||
if (path === '/ready' || path === '/api/ready') {
|
|
||||||
try {
|
|
||||||
// Test database connectivity
|
|
||||||
const dbResult = await env.DB.prepare('SELECT 1 as ok').first();
|
|
||||||
const dbHealthy = dbResult?.ok === 1;
|
|
||||||
|
|
||||||
if (!dbHealthy) {
|
|
||||||
return json({
|
|
||||||
success: false,
|
|
||||||
data: {
|
|
||||||
status: 'not_ready',
|
|
||||||
database: 'error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
status: 'ready',
|
|
||||||
database: 'connected',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return json({
|
|
||||||
success: false,
|
|
||||||
data: {
|
|
||||||
status: 'not_ready',
|
|
||||||
database: 'error',
|
|
||||||
error: String(error),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}, 503);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Job Endpoints (require API key) ============
|
|
||||||
|
|
||||||
if (path === '/api/jobs/next' && method === 'GET') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const result = await getNextJob(env);
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/jobs\/[^/]+\/claim$/) && method === 'POST') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const jobId = path.split('/')[3];
|
|
||||||
const body = await parseBody<ClaimJobRequest>();
|
|
||||||
if (!body?.worker_id) {
|
|
||||||
return json({ success: false, error: 'Missing worker_id' }, 400);
|
|
||||||
}
|
|
||||||
const result = await claimJob(env, jobId, body.worker_id);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/jobs\/[^/]+\/heartbeat$/) && method === 'POST') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const jobId = path.split('/')[3];
|
|
||||||
const body = await parseBody<{ worker_id: string }>();
|
|
||||||
if (!body?.worker_id) {
|
|
||||||
return json({ success: false, error: 'Missing worker_id' }, 400);
|
|
||||||
}
|
|
||||||
const result = await heartbeatJob(env, jobId, body.worker_id);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/jobs\/[^/]+\/result$/) && method === 'POST') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const jobId = path.split('/')[3];
|
|
||||||
const body = await parseBody<SubmitResultRequest>();
|
|
||||||
if (!body) {
|
|
||||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
|
||||||
}
|
|
||||||
const result = await submitResult(env, jobId, body);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/jobs\/[^/]+\/fail$/) && method === 'POST') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const jobId = path.split('/')[3];
|
|
||||||
const body = await parseBody<{ worker_id: string; error_message: string }>();
|
|
||||||
if (!body?.worker_id || !body?.error_message) {
|
|
||||||
return json({ success: false, error: 'Missing required fields' }, 400);
|
|
||||||
}
|
|
||||||
const result = await failJob(env, jobId, body.worker_id, body.error_message);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Bot Endpoints (public or owner-verified) ============
|
|
||||||
|
|
||||||
if (path === '/api/register' && method === 'POST') {
|
|
||||||
const body = await parseBody<CreateBotRequest>();
|
|
||||||
if (!body) {
|
|
||||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
|
||||||
}
|
|
||||||
const result = await registerBot(env, body);
|
|
||||||
return json(result, result.success ? 201 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/bots' && method === 'GET') {
|
|
||||||
const result = await listBots(env);
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'GET') {
|
|
||||||
const botId = path.split('/')[3];
|
|
||||||
const result = await getBot(env, botId);
|
|
||||||
return json(result, result.success ? 200 : 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'PUT') {
|
|
||||||
const botId = path.split('/')[3];
|
|
||||||
const body = await parseBody<{ name?: string; endpoint_url?: string }>();
|
|
||||||
if (!body) {
|
|
||||||
return json({ success: false, error: 'Invalid request body' }, 400);
|
|
||||||
}
|
|
||||||
const result = await updateBot(env, botId, body);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/rotate-key' && method === 'POST') {
|
|
||||||
const body = await parseBody<{ bot_id: string; owner_id: string }>();
|
|
||||||
if (!body?.bot_id || !body?.owner_id) {
|
|
||||||
return json({ success: false, error: 'Missing required fields' }, 400);
|
|
||||||
}
|
|
||||||
const result = await rotateApiKey(env, body.bot_id, body.owner_id);
|
|
||||||
return json(result, result.success ? 200 : 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/leaderboard' && method === 'GET') {
|
|
||||||
const result = await getLeaderboard(env);
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Data Export Endpoint (for index builder) ============
|
|
||||||
|
|
||||||
if (path === '/api/data/export' && method === 'GET') {
|
|
||||||
if (!(await verifyApiKey())) {
|
|
||||||
return json({ success: false, error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
const result = await exportData(env);
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 404 for unmatched routes
|
|
||||||
return json({ success: false, error: 'Not found' }, 404);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Worker error:', error);
|
|
||||||
return json(
|
|
||||||
{ success: false, error: 'Internal server error' },
|
|
||||||
500
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
||||||
const cron = event.cron;
|
|
||||||
console.log(`Running scheduled task: ${cron}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await handleCron(env, cron);
|
|
||||||
console.log(`Cron result:`, result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Cron error:`, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
// Job Coordination Endpoints
|
|
||||||
|
|
||||||
import type { Env, Job, Match, MatchParticipant, JobClaimResponse, ApiResponse, SubmitResultRequest } from './types';
|
|
||||||
import { updateMatchRatings } from './glicko2';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jobs/next - Get next available job for worker
|
|
||||||
*/
|
|
||||||
export async function getNextJob(env: Env): Promise<ApiResponse<Job | null>> {
|
|
||||||
// Find a pending job, ordered by creation time
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`SELECT * FROM jobs
|
|
||||||
WHERE status = 'pending'
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 1`
|
|
||||||
).first<Job>();
|
|
||||||
|
|
||||||
return { success: true, data: result || null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jobs/:id/claim - Claim a job for execution
|
|
||||||
*/
|
|
||||||
export async function claimJob(
|
|
||||||
env: Env,
|
|
||||||
jobId: string,
|
|
||||||
workerId: string
|
|
||||||
): Promise<ApiResponse<JobClaimResponse>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Try to claim the job atomically
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`UPDATE jobs SET
|
|
||||||
status = 'claimed',
|
|
||||||
worker_id = ?,
|
|
||||||
claimed_at = ?,
|
|
||||||
heartbeat_at = ?
|
|
||||||
WHERE id = ? AND status = 'pending'`
|
|
||||||
)
|
|
||||||
.bind(workerId, now, now, jobId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
if (result.meta.changes === 0) {
|
|
||||||
return { success: false, error: 'Job not found or already claimed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the job details
|
|
||||||
const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?')
|
|
||||||
.bind(jobId)
|
|
||||||
.first<Job>();
|
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
return { success: false, error: 'Job not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get match details
|
|
||||||
const match = await env.DB.prepare('SELECT * FROM matches WHERE id = ?')
|
|
||||||
.bind(job.match_id)
|
|
||||||
.first<Match>();
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return { success: false, error: 'Match not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update match status to running
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE matches SET status = 'running', started_at = ? WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, match.id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Get participants with their ratings
|
|
||||||
const participants = await env.DB.prepare(
|
|
||||||
`SELECT * FROM match_participants WHERE match_id = ?`
|
|
||||||
)
|
|
||||||
.bind(match.id)
|
|
||||||
.all<MatchParticipant>();
|
|
||||||
|
|
||||||
// Get bot details (endpoint URLs)
|
|
||||||
const botIds = participants.results.map((p) => p.bot_id);
|
|
||||||
const placeholders = botIds.map(() => '?').join(',');
|
|
||||||
const bots = await env.DB.prepare(
|
|
||||||
`SELECT id, endpoint_url FROM bots WHERE id IN (${placeholders})`
|
|
||||||
)
|
|
||||||
.bind(...botIds)
|
|
||||||
.all<{ id: string; endpoint_url: string }>();
|
|
||||||
|
|
||||||
// Get bot secrets (API keys for HMAC auth)
|
|
||||||
const secrets = await env.DB.prepare(
|
|
||||||
`SELECT bot_id, api_key_hash as secret FROM bot_secrets WHERE bot_id IN (${placeholders})`
|
|
||||||
)
|
|
||||||
.bind(...botIds)
|
|
||||||
.all<{ bot_id: string; secret: string }>();
|
|
||||||
|
|
||||||
// Get map details
|
|
||||||
const map = await env.DB.prepare('SELECT * FROM maps WHERE id = ?')
|
|
||||||
.bind(match.map_id)
|
|
||||||
.first<{ id: string; width: number; height: number; walls: string; spawns: string; cores: string }>();
|
|
||||||
|
|
||||||
if (!map) {
|
|
||||||
return { success: false, error: 'Map not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
job: job,
|
|
||||||
match: match,
|
|
||||||
participants: participants.results,
|
|
||||||
map: map,
|
|
||||||
bots: bots.results,
|
|
||||||
bot_secrets: secrets.results,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jobs/:id/heartbeat - Update job heartbeat
|
|
||||||
*/
|
|
||||||
export async function heartbeatJob(
|
|
||||||
env: Env,
|
|
||||||
jobId: string,
|
|
||||||
workerId: string
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`UPDATE jobs SET heartbeat_at = ? WHERE id = ? AND worker_id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, jobId, workerId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
if (result.meta.changes === 0) {
|
|
||||||
return { success: false, error: 'Job not found or not owned by worker' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jobs/:id/result - Submit job result
|
|
||||||
*/
|
|
||||||
export async function submitResult(
|
|
||||||
env: Env,
|
|
||||||
jobId: string,
|
|
||||||
result: SubmitResultRequest
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Get the job
|
|
||||||
const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?')
|
|
||||||
.bind(jobId)
|
|
||||||
.first<Job>();
|
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
return { success: false, error: 'Job not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (job.status !== 'claimed' && job.status !== 'running') {
|
|
||||||
return { success: false, error: 'Job not in a valid state for result submission' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get participants
|
|
||||||
const participants = await env.DB.prepare(
|
|
||||||
'SELECT * FROM match_participants WHERE match_id = ?'
|
|
||||||
)
|
|
||||||
.bind(job.match_id)
|
|
||||||
.all<MatchParticipant>();
|
|
||||||
|
|
||||||
// Update scores
|
|
||||||
for (const [botId, score] of Object.entries(result.scores)) {
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE match_participants SET score = ? WHERE match_id = ? AND bot_id = ?`
|
|
||||||
)
|
|
||||||
.bind(score, job.match_id, botId)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ratings using Glicko-2
|
|
||||||
await updateMatchRatings(env, job.match_id, participants.results, result.winner_id);
|
|
||||||
|
|
||||||
// Update job status
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE jobs SET status = 'completed', completed_at = ? WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, jobId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Update match status
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE matches SET
|
|
||||||
status = 'completed',
|
|
||||||
winner_id = ?,
|
|
||||||
turns = ?,
|
|
||||||
end_reason = ?,
|
|
||||||
completed_at = ?
|
|
||||||
WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(result.winner_id, result.turns, result.end_reason, now, job.match_id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jobs/:id/fail - Mark job as failed
|
|
||||||
*/
|
|
||||||
export async function failJob(
|
|
||||||
env: Env,
|
|
||||||
jobId: string,
|
|
||||||
workerId: string,
|
|
||||||
errorMessage: string
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`UPDATE jobs SET
|
|
||||||
status = 'failed',
|
|
||||||
completed_at = ?,
|
|
||||||
error_message = ?
|
|
||||||
WHERE id = ? AND worker_id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, errorMessage, jobId, workerId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
if (result.meta.changes === 0) {
|
|
||||||
return { success: false, error: 'Job not found or not owned by worker' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update match status
|
|
||||||
const job = await env.DB.prepare('SELECT match_id FROM jobs WHERE id = ?')
|
|
||||||
.bind(jobId)
|
|
||||||
.first<{ match_id: string }>();
|
|
||||||
|
|
||||||
if (job) {
|
|
||||||
await env.DB.prepare(
|
|
||||||
`UPDATE matches SET status = 'failed', completed_at = ? WHERE id = ?`
|
|
||||||
)
|
|
||||||
.bind(now, job.match_id)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// AI Code Battle Worker Types
|
|
||||||
|
|
||||||
export interface Env {
|
|
||||||
DB: D1Database;
|
|
||||||
API_KEY: string;
|
|
||||||
ENVIRONMENT: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bot types
|
|
||||||
export interface Bot {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
endpoint_url: string;
|
|
||||||
api_key_hash: string;
|
|
||||||
rating: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
rating_volatility: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
last_health_check: string | null;
|
|
||||||
health_status: 'healthy' | 'unhealthy' | 'unknown';
|
|
||||||
matches_played: number;
|
|
||||||
matches_won: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateBotRequest {
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
endpoint_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match types
|
|
||||||
export type MatchStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
|
|
||||||
export interface Match {
|
|
||||||
id: string;
|
|
||||||
status: MatchStatus;
|
|
||||||
winner_id: string | null;
|
|
||||||
turns: number | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
map_id: string;
|
|
||||||
created_at: string;
|
|
||||||
started_at: string | null;
|
|
||||||
completed_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchParticipant {
|
|
||||||
id: string;
|
|
||||||
match_id: string;
|
|
||||||
bot_id: string;
|
|
||||||
player_index: number;
|
|
||||||
score: number;
|
|
||||||
rating_before: number;
|
|
||||||
rating_after: number | null;
|
|
||||||
rating_deviation_before: number;
|
|
||||||
rating_deviation_after: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job types
|
|
||||||
export type JobStatus = 'pending' | 'claimed' | 'running' | 'completed' | 'failed' | 'timeout';
|
|
||||||
|
|
||||||
export interface Job {
|
|
||||||
id: string;
|
|
||||||
match_id: string;
|
|
||||||
status: JobStatus;
|
|
||||||
worker_id: string | null;
|
|
||||||
claimed_at: string | null;
|
|
||||||
heartbeat_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
error_message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClaimJobRequest {
|
|
||||||
worker_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmitResultRequest {
|
|
||||||
winner_id: string;
|
|
||||||
turns: number;
|
|
||||||
end_reason: string;
|
|
||||||
replay_url: string;
|
|
||||||
scores: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rating types
|
|
||||||
export interface RatingChange {
|
|
||||||
bot_id: string;
|
|
||||||
rating_before: number;
|
|
||||||
rating_after: number;
|
|
||||||
rating_deviation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Response types
|
|
||||||
export interface ApiResponse<T = unknown> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobClaimResponse {
|
|
||||||
job: Job;
|
|
||||||
match: Match;
|
|
||||||
participants: MatchParticipant[];
|
|
||||||
map: {
|
|
||||||
id: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
walls: string;
|
|
||||||
spawns: string;
|
|
||||||
cores: string;
|
|
||||||
};
|
|
||||||
bots: Array<{
|
|
||||||
id: string;
|
|
||||||
endpoint_url: string;
|
|
||||||
}>;
|
|
||||||
bot_secrets: Array<{
|
|
||||||
bot_id: string;
|
|
||||||
secret: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"types": ["@cloudflare/workers-types"],
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
name = "acb-api"
|
|
||||||
main = "src/index.ts"
|
|
||||||
compatibility_date = "2025-03-10"
|
|
||||||
compatibility_flags = ["nodejs_compat"]
|
|
||||||
|
|
||||||
[[d1_databases]]
|
|
||||||
binding = "DB"
|
|
||||||
database_name = "acb-db"
|
|
||||||
database_id = "placeholder-will-be-set-on-deploy"
|
|
||||||
migrations_dir = "migrations"
|
|
||||||
|
|
||||||
[vars]
|
|
||||||
ENVIRONMENT = "development"
|
|
||||||
|
|
||||||
[triggers]
|
|
||||||
crons = [
|
|
||||||
"*/1 * * * *", # Matchmaker: every minute
|
|
||||||
"*/5 * * * *", # Stale job reaper: every 5 minutes
|
|
||||||
"*/15 * * * *" # Health checker: every 15 minutes
|
|
||||||
]
|
|
||||||
|
|
||||||
# API key for worker authentication (set via wrangler secret put API_KEY)
|
|
||||||
# [secrets]
|
|
||||||
# API_KEY
|
|
||||||
Loading…
Add table
Reference in a new issue