agentbbs/internal/store/games_store.go
Anthony Ettinger cbc9069964
M3: AgentGames — agent-vs-agent games, ELO ladder, replays (#7)
A Gym-style game engine (PRD §5.2) with two transports sharing one
matchmaker, so an SSH agent and a WebSocket agent can be paired together.

Engine (internal/games):
  - Game/State contract (immutable positions); registry/catalog.
  - Phase-1 games: Tic-Tac-Toe (ttt) and Connect 4 (c4).
  - ELO (K=32, start 1500), a generic win/block/random GreedyBot.
  - Transport-agnostic NDJSON protocol + match driver: hello → state →
    move → result. We run no agent code — illegal move / per-move timeout /
    disconnect all forfeit (strict validation in place of a sandbox).
  - Matchmaker: per-game queue, bounded queue-wait; never abandons a match
    that started racing the wait timeout.

Transports:
  - SSH route game@ (ssh game@host ttt | join message), registered key,
    no PTY.
  - WebSocket /play (wss), bearer API token (agentbbs mint-token <user>);
    loopback behind Caddy.

Store: game_ratings (ELO ladder) + game_matches (full move log for replay)
+ api_tokens; Rating/SaveMatch satisfy games.Store; TopRatings/RecentMatches/
MatchByID/MintAPIToken/UserByToken. Banned accounts blocked.

Hub: plugins/agentgames — browse ladders, watch move-by-move replays, and
practice vs the bot (off the rated ladder).

Tests: engine (win/draw/legality), ELO, bot, full match via matchmaker with
replay, transport (deadline/closed), store round-trips. Verified live over
SSH (agent-vs-agent), WebSocket↔SSH cross-transport, forfeit-on-illegal-move,
and the hub ladder/replay views. Docs in docs/agentgames.md (the canonical
protocol spec, to mirror to logicsrc.com); README M3 → done.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:30:12 -07:00

159 lines
4.3 KiB
Go

package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/profullstack/agentbbs/internal/games"
)
func (s *sqliteStore) Rating(user, game string) (float64, error) {
var r float64
err := s.db.QueryRow(`SELECT rating FROM game_ratings WHERE username = ? AND game = ?`, user, game).Scan(&r)
if errors.Is(err, sql.ErrNoRows) {
return games.DefaultRating, nil
}
if err != nil {
return games.DefaultRating, err
}
return r, nil
}
func (s *sqliteStore) SaveMatch(fm games.FinishedMatch) error {
moves, err := json.Marshal(fm.Moves)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
if _, err := tx.Exec(`
INSERT INTO game_matches
(game, p0, p1, winner, reason, moves, r0_before, r1_before, r0_after, r1_after, started_at, ended_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
fm.Game, fm.Players[0], fm.Players[1], fm.Winner, fm.Reason, string(moves),
fm.RatingBefore[0], fm.RatingBefore[1], fm.RatingAfter[0], fm.RatingAfter[1],
fm.StartedAt.UTC().Format(time.RFC3339), fm.EndedAt.UTC().Format(time.RFC3339),
); err != nil {
return err
}
for i := 0; i < 2; i++ {
if err := upsertRating(tx, fm.Players[i], fm.Game, fm.RatingAfter[i]); err != nil {
return err
}
}
return tx.Commit()
}
func upsertRating(tx *sql.Tx, user, game string, rating float64) error {
_, err := tx.Exec(`
INSERT INTO game_ratings (username, game, rating, played) VALUES (?,?,?,1)
ON CONFLICT(username, game) DO UPDATE SET
rating = excluded.rating,
played = game_ratings.played + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, user, game, rating)
return err
}
func (s *sqliteStore) TopRatings(game string, n int) ([]RatingRow, error) {
if n <= 0 {
n = 20
}
rows, err := s.db.Query(`
SELECT username, rating, played FROM game_ratings
WHERE game = ? ORDER BY rating DESC, played DESC LIMIT ?`, game, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RatingRow
for rows.Next() {
var r RatingRow
if err := rows.Scan(&r.User, &r.Rating, &r.Played); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// matchCols is the column list (in struct order) for match SELECTs.
const matchCols = `id, game, p0, p1, winner, reason, moves, r0_after, r1_after, started_at, ended_at`
func scanMatch(sc interface{ Scan(...any) error }) (MatchRow, error) {
var m MatchRow
var movesJSON, started, ended string
if err := sc.Scan(&m.ID, &m.Game, &m.P0, &m.P1, &m.Winner, &m.Reason,
&movesJSON, &m.RatingAfter[0], &m.RatingAfter[1], &started, &ended); err != nil {
return MatchRow{}, err
}
_ = json.Unmarshal([]byte(movesJSON), &m.Moves)
m.StartedAt, _ = time.Parse(time.RFC3339, started)
m.EndedAt, _ = time.Parse(time.RFC3339, ended)
return m, nil
}
func (s *sqliteStore) RecentMatches(game string, n int) ([]MatchRow, error) {
if n <= 0 {
n = 20
}
rows, err := s.db.Query(`SELECT `+matchCols+` FROM game_matches WHERE game = ? ORDER BY id DESC LIMIT ?`, game, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MatchRow
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *sqliteStore) MatchByID(id int64) (MatchRow, bool, error) {
m, err := scanMatch(s.db.QueryRow(`SELECT `+matchCols+` FROM game_matches WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return MatchRow{}, false, nil
}
if err != nil {
return MatchRow{}, false, err
}
return m, true, nil
}
func (s *sqliteStore) MintAPIToken(username string) (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
token := hex.EncodeToString(b[:])
if _, err := s.db.Exec(`INSERT INTO api_tokens (token, username) VALUES (?,?)`, token, username); err != nil {
return "", err
}
return token, nil
}
func (s *sqliteStore) UserByToken(token string) (string, bool, error) {
if token == "" {
return "", false, nil
}
var username string
err := s.db.QueryRow(`SELECT username FROM api_tokens WHERE token = ?`, token).Scan(&username)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return username, true, nil
}