ai-code-battle/cmd/acb-api/db.go
jedarden 6bfd3e6679 feat(api): implement POST /api/request-enrichment endpoint
Per plan §13.3, implements user-requested AI replay commentary with:
- HMAC bot authentication via shared_secret
- Rate limiting: 5 requests/day per bot
- Match validation (exists and completed)
- Idempotency via enrichment_requested_at column
- Enqueues to Valkey for acb-enrichment service
- Returns 202 Accepted with estimated wait time

Also adds:
- AllowN() method to ratelimit package for multi-token checks
- enrichment_requested_at column to matches table (idempotency)
- enrichLtr rate limiter (5/day per bot)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 02:58:11 -04:00

323 lines
14 KiB
Go

package main
import (
"context"
"database/sql"
)
const schemaSQL = `
-- ---- Phase 9 tables ----
CREATE TABLE IF NOT EXISTS predictions (
id BIGSERIAL PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
predictor_id VARCHAR(64) NOT NULL,
predicted_bot VARCHAR(16) NOT NULL,
confidence SMALLINT CHECK (confidence >= 1 AND confidence <= 100),
correct BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
UNIQUE(match_id, predictor_id)
);
CREATE INDEX IF NOT EXISTS idx_predictions_match ON predictions(match_id);
CREATE INDEX IF NOT EXISTS idx_predictions_predictor ON predictions(predictor_id);
CREATE TABLE IF NOT EXISTS predictor_stats (
predictor_id VARCHAR(64) PRIMARY KEY,
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,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS series (
id BIGSERIAL PRIMARY KEY,
bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
format INTEGER NOT NULL DEFAULT 5, -- best of N (3, 5, 7...)
a_wins INTEGER NOT NULL DEFAULT 0,
b_wins INTEGER NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
winner_id VARCHAR(16),
season_id BIGINT REFERENCES seasons(id),
bracket_round VARCHAR(32), -- 'quarterfinal', 'semifinal', 'final' for championship
bracket_position INTEGER, -- position within the bracket round (0-based)
featured BOOLEAN NOT NULL DEFAULT FALSE, -- weekly featured series (Friday 20:00 UTC)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id);
CREATE INDEX IF NOT EXISTS idx_series_status ON series(status);
CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id);
CREATE INDEX IF NOT EXISTS idx_series_bracket ON series(season_id, bracket_round) WHERE bracket_round IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_series_featured ON series(featured, created_at DESC) WHERE featured = TRUE;
-- Add bracket columns if they don't exist (idempotent migration)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_round') THEN
ALTER TABLE series ADD COLUMN bracket_round VARCHAR(32);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_position') THEN
ALTER TABLE series ADD COLUMN bracket_position INTEGER;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'featured') THEN
ALTER TABLE series ADD COLUMN featured BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
-- Add missing foreign key constraints (CREATE TABLE IF NOT EXISTS doesn't add FKs to existing tables)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'replay_feedback_match_id_fkey') THEN
ALTER TABLE replay_feedback ADD CONSTRAINT replay_feedback_match_id_fkey FOREIGN KEY (match_id) REFERENCES matches(match_id);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS series_games (
id BIGSERIAL PRIMARY KEY,
series_id BIGINT NOT NULL REFERENCES series(id),
match_id VARCHAR(32) REFERENCES matches(match_id),
game_num INTEGER NOT NULL,
winner_id VARCHAR(16),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_series_games_series ON series_games(series_id);
CREATE TABLE IF NOT EXISTS seasons (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
theme VARCHAR(128),
rules_version VARCHAR(32) NOT NULL DEFAULT '1.0',
status VARCHAR(16) NOT NULL DEFAULT 'active',
champion_id VARCHAR(16),
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS season_snapshots (
id BIGSERIAL PRIMARY KEY,
season_id BIGINT NOT NULL REFERENCES seasons(id),
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
rank INTEGER NOT NULL,
rating DOUBLE PRECISION NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_season_snapshots_season ON season_snapshots(season_id, rank);
-- Map engagement scores (written by acb-mapgen or evolution pipeline)
CREATE TABLE IF NOT EXISTS map_scores (
map_id VARCHAR(32) PRIMARY KEY,
engagement DOUBLE PRECISION NOT NULL DEFAULT 0.0,
symmetry_score DOUBLE PRECISION NOT NULL DEFAULT 0.0,
wall_density DOUBLE PRECISION NOT NULL DEFAULT 0.0,
last_used_at TIMESTAMPTZ,
match_count INTEGER NOT NULL DEFAULT 0,
avg_turns DOUBLE PRECISION,
scored_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Map lifecycle management (§14.6 Map Evolution)
CREATE TABLE IF NOT EXISTS maps (
map_id VARCHAR(32) PRIMARY KEY,
player_count INTEGER NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active, probation, retired, classic
engagement DOUBLE PRECISION NOT NULL DEFAULT 0.0,
wall_density DOUBLE PRECISION NOT NULL DEFAULT 0.0,
energy_count INTEGER NOT NULL DEFAULT 0,
grid_width INTEGER NOT NULL,
grid_height INTEGER NOT NULL,
map_json JSONB NOT NULL, -- Full map layout with walls, energy, cores
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
retired_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_maps_status ON maps(status, player_count);
CREATE INDEX IF NOT EXISTS idx_maps_engagement ON maps(player_count, engagement DESC);
-- User voting on maps (§14.6 Map Evolution)
CREATE TABLE IF NOT EXISTS map_votes (
id BIGSERIAL PRIMARY KEY,
map_id VARCHAR(32) NOT NULL REFERENCES maps(map_id) ON DELETE CASCADE,
voter_id VARCHAR(64) NOT NULL, -- localStorage UUID
vote SMALLINT NOT NULL, -- +1 or -1
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(map_id, voter_id)
);
CREATE INDEX IF NOT EXISTS idx_map_votes_map ON map_votes(map_id);
-- Positional fairness tracking (§14.6 Map Evolution)
CREATE TABLE IF NOT EXISTS map_fairness (
map_id VARCHAR(32) NOT NULL REFERENCES maps(map_id) ON DELETE CASCADE,
player_slot INTEGER NOT NULL,
games INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
last_check TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (map_id, player_slot)
);
CREATE TABLE IF NOT EXISTS bots (
bot_id VARCHAR(16) PRIMARY KEY,
name VARCHAR(32) UNIQUE NOT NULL,
owner VARCHAR(128) NOT NULL,
endpoint_url TEXT NOT NULL,
shared_secret TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
rating_mu DOUBLE PRECISION NOT NULL DEFAULT 1500.0,
rating_phi DOUBLE PRECISION NOT NULL DEFAULT 350.0,
rating_sigma DOUBLE PRECISION NOT NULL DEFAULT 0.06,
evolved BOOLEAN NOT NULL DEFAULT FALSE,
island VARCHAR(16),
generation INTEGER,
parent_ids JSONB,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_active TIMESTAMPTZ,
consec_fails INTEGER NOT NULL DEFAULT 0,
archetype VARCHAR(64),
crash_strikes INTEGER NOT NULL DEFAULT 0,
cooldown_until TIMESTAMPTZ,
debug_public BOOLEAN NOT NULL DEFAULT FALSE
);
-- Add debug_public column if it doesn't exist (idempotent migration)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'bots' AND column_name = 'debug_public') THEN
ALTER TABLE bots ADD COLUMN debug_public BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS matches (
match_id VARCHAR(32) PRIMARY KEY,
map_id VARCHAR(32) NOT NULL,
map_seed BIGINT,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
winner INTEGER,
condition VARCHAR(32),
turn_count INTEGER,
scores_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS match_participants (
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
player_slot INTEGER NOT NULL,
score INTEGER,
status VARCHAR(16),
PRIMARY KEY (match_id, bot_id)
);
CREATE TABLE IF NOT EXISTS jobs (
job_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
status VARCHAR(16) NOT NULL DEFAULT 'pending',
worker_id VARCHAR(64),
config_json JSONB NOT NULL,
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rating_history (
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
rating DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bot_id, match_id)
);
CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id, recorded_at);
CREATE TABLE IF NOT EXISTS programs (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
language VARCHAR(32) NOT NULL,
island VARCHAR(16) NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
parent_ids JSONB NOT NULL DEFAULT '[]',
behavior_vector DOUBLE PRECISION[] NOT NULL DEFAULT '{}',
fitness DOUBLE PRECISION NOT NULL DEFAULT 0.0,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island);
CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC);
-- Curated playlist definitions (§14.4 Replay Playlists)
CREATE TABLE IF NOT EXISTS playlists (
slug VARCHAR(64) PRIMARY KEY,
title VARCHAR(128) NOT NULL,
description TEXT NOT NULL DEFAULT '',
category VARCHAR(32) NOT NULL DEFAULT 'featured',
is_auto BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS playlist_matches (
playlist_slug VARCHAR(64) NOT NULL REFERENCES playlists(slug) ON DELETE CASCADE,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
sort_order INTEGER NOT NULL DEFAULT 0,
curation_tag TEXT NOT NULL DEFAULT '',
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playlist_slug, match_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_matches_playlist ON playlist_matches(playlist_slug, sort_order);
-- Add combat_turns column to matches if it doesn't exist (idempotent migration)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'combat_turns') THEN
ALTER TABLE matches ADD COLUMN combat_turns INTEGER NOT NULL DEFAULT 0;
END IF;
END $$;
-- Add enrichment_requested_at column to matches for idempotency (§13.3)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'enrichment_requested_at') THEN
ALTER TABLE matches ADD COLUMN enrichment_requested_at TIMESTAMPTZ;
END IF;
END $$;
-- Community replay feedback (plan §13.6, §8.3)
CREATE TABLE IF NOT EXISTS replay_feedback (
feedback_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
turn INTEGER NOT NULL,
type VARCHAR(16) NOT NULL CHECK (type IN ('insight', 'mistake', 'idea', 'highlight')),
body TEXT NOT NULL,
author VARCHAR(128) NOT NULL,
upvotes INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feedback_match ON replay_feedback(match_id, turn);
-- Upvote deduplication: one upvote per visitor per feedback item
CREATE TABLE IF NOT EXISTS feedback_upvotes (
feedback_id VARCHAR(32) NOT NULL REFERENCES replay_feedback(feedback_id) ON DELETE CASCADE,
voter_id VARCHAR(36) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (feedback_id, voter_id)
);
-- User-requested replay enrichment (plan §13.3)
CREATE TABLE IF NOT EXISTS enrichment_requests (
request_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
error_msg TEXT,
UNIQUE(match_id, bot_id)
);
CREATE INDEX IF NOT EXISTS idx_enrichment_requests_status ON enrichment_requests(status, requested_at);
CREATE INDEX IF NOT EXISTS idx_enrichment_requests_bot ON enrichment_requests(bot_id, requested_at);
`
func ensureSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, schemaSQL)
return err
}