mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
This commit is contained in:
parent
41a8ff240d
commit
cbc9069964
22 changed files with 2297 additions and 3 deletions
159
internal/store/games_store.go
Normal file
159
internal/store/games_store.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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
|
||||
}
|
||||
79
internal/store/games_store_test.go
Normal file
79
internal/store/games_store_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
)
|
||||
|
||||
func TestGameRatingsAndMatches(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
// Unrated players start at the default.
|
||||
if r, _ := st.Rating("agent-a", "ttt"); r != games.DefaultRating {
|
||||
t.Fatalf("default rating = %v", r)
|
||||
}
|
||||
|
||||
fm := games.FinishedMatch{
|
||||
Game: "ttt",
|
||||
Players: [2]string{"agent-a", "agent-b"},
|
||||
Winner: 0,
|
||||
Moves: []games.Move{{Player: 0, Move: "4"}, {Player: 1, Move: "0"}, {Player: 0, Move: "1"}},
|
||||
RatingBefore: [2]float64{1500, 1500},
|
||||
RatingAfter: [2]float64{1516, 1484},
|
||||
StartedAt: time.Now().Add(-time.Minute),
|
||||
EndedAt: time.Now(),
|
||||
}
|
||||
if err := st.SaveMatch(fm); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
// Ratings updated and games-played bumped.
|
||||
if r, _ := st.Rating("agent-a", "ttt"); r != 1516 {
|
||||
t.Fatalf("agent-a rating = %v, want 1516", r)
|
||||
}
|
||||
if r, _ := st.Rating("agent-b", "ttt"); r != 1484 {
|
||||
t.Fatalf("agent-b rating = %v, want 1484", r)
|
||||
}
|
||||
|
||||
board, err := st.TopRatings("ttt", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("top: %v", err)
|
||||
}
|
||||
if len(board) != 2 || board[0].User != "agent-a" || board[0].Played != 1 {
|
||||
t.Fatalf("ladder wrong: %+v", board)
|
||||
}
|
||||
|
||||
matches, err := st.RecentMatches("ttt", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("want 1 match, got %d", len(matches))
|
||||
}
|
||||
id := matches[0].ID
|
||||
|
||||
got, ok, err := st.MatchByID(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("match by id: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(got.Moves) != 3 || got.Moves[0].Move != "4" {
|
||||
t.Fatalf("moves not round-tripped: %+v", got.Moves)
|
||||
}
|
||||
if got.Winner != 0 || got.P0 != "agent-a" {
|
||||
t.Fatalf("match meta wrong: %+v", got)
|
||||
}
|
||||
|
||||
// A second match bumps games-played and re-rates.
|
||||
fm2 := fm
|
||||
fm2.RatingBefore = [2]float64{1516, 1484}
|
||||
fm2.RatingAfter = [2]float64{1530, 1470}
|
||||
if err := st.SaveMatch(fm2); err != nil {
|
||||
t.Fatalf("save2: %v", err)
|
||||
}
|
||||
board, _ = st.TopRatings("ttt", 10)
|
||||
if board[0].Played != 2 {
|
||||
t.Fatalf("played should be 2, got %d", board[0].Played)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
)
|
||||
|
||||
// User is a persisted account (member or agent; guests are never stored).
|
||||
|
|
@ -128,9 +130,52 @@ type Store interface {
|
|||
// SetPluginDisabled enables or disables a plugin by ID. Idempotent.
|
||||
SetPluginDisabled(id string, disabled bool) error
|
||||
|
||||
// AgentGames (PRD §5.2): per-game ELO ladder + replayable match log.
|
||||
|
||||
// Rating returns a player's current rating for a game, or
|
||||
// games.DefaultRating if they have no rated history there. It satisfies
|
||||
// games.Store so the matchmaker can read ratings.
|
||||
Rating(user, game string) (float64, error)
|
||||
// SaveMatch records a finished match and upserts both players' ratings.
|
||||
// It satisfies games.Store.
|
||||
SaveMatch(games.FinishedMatch) error
|
||||
// TopRatings returns the n highest-rated players for a game.
|
||||
TopRatings(game string, n int) ([]RatingRow, error)
|
||||
// RecentMatches returns the last n matches for a game, newest first.
|
||||
RecentMatches(game string, n int) ([]MatchRow, error)
|
||||
// MatchByID returns one match (with its moves, for replay).
|
||||
MatchByID(id int64) (MatchRow, bool, error)
|
||||
|
||||
// MintAPIToken creates and stores a fresh bearer token for the WebSocket
|
||||
// game endpoint, bound to username. Returns the token.
|
||||
MintAPIToken(username string) (string, error)
|
||||
// UserByToken resolves an API token to its account name.
|
||||
UserByToken(token string) (string, bool, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
// RatingRow is one ladder entry.
|
||||
type RatingRow struct {
|
||||
User string
|
||||
Rating float64
|
||||
Played int
|
||||
}
|
||||
|
||||
// MatchRow is a recorded match, including its moves for replay.
|
||||
type MatchRow struct {
|
||||
ID int64
|
||||
Game string
|
||||
P0 string
|
||||
P1 string
|
||||
Winner int
|
||||
Reason string
|
||||
Moves []games.Move
|
||||
RatingAfter [2]float64
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
}
|
||||
|
||||
// SessionRow is one connection record from the audit trail.
|
||||
type SessionRow struct {
|
||||
ID int64
|
||||
|
|
@ -305,6 +350,37 @@ CREATE TABLE IF NOT EXISTS plugin_state (
|
|||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS game_ratings (
|
||||
username TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
rating REAL NOT NULL,
|
||||
played INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (username, game)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_ratings_board ON game_ratings(game, rating DESC);
|
||||
CREATE TABLE IF NOT EXISTS game_matches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
game TEXT NOT NULL,
|
||||
p0 TEXT NOT NULL,
|
||||
p1 TEXT NOT NULL,
|
||||
winner INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
moves TEXT NOT NULL DEFAULT '[]',
|
||||
r0_before REAL NOT NULL DEFAULT 0,
|
||||
r1_before REAL NOT NULL DEFAULT 0,
|
||||
r0_after REAL NOT NULL DEFAULT 0,
|
||||
r1_after REAL NOT NULL DEFAULT 0,
|
||||
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
ended_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_matches_game ON game_matches(game, id DESC);
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue