From 05090206f20caa1967e9e2039d4a04ceac851e35 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 10:13:02 +0000 Subject: [PATCH] =?UTF-8?q?M3:=20AgentGames=20=E2=80=94=20agent-vs-agent?= =?UTF-8?q?=20games,=20ELO=20ladder,=20replays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ); 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 --- README.md | 5 +- cmd/agentbbs/games.go | 102 +++++++ cmd/agentbbs/gamesws.go | 145 ++++++++++ cmd/agentbbs/main.go | 33 ++- docs/agentgames.md | 121 ++++++++ go.mod | 2 +- internal/auth/auth.go | 7 + internal/games/bot.go | 71 +++++ internal/games/connect4.go | 144 ++++++++++ internal/games/elo.go | 44 +++ internal/games/games.go | 86 ++++++ internal/games/games_test.go | 126 +++++++++ internal/games/matchmaker.go | 158 +++++++++++ internal/games/matchmaker_test.go | 135 +++++++++ internal/games/protocol.go | 120 ++++++++ internal/games/transport.go | 100 +++++++ internal/games/transport_test.go | 63 +++++ internal/games/ttt.go | 98 +++++++ internal/store/games_store.go | 159 +++++++++++ internal/store/games_store_test.go | 79 ++++++ internal/store/store.go | 76 +++++ plugins/agentgames/agentgames.go | 426 +++++++++++++++++++++++++++++ 22 files changed, 2297 insertions(+), 3 deletions(-) create mode 100644 cmd/agentbbs/games.go create mode 100644 cmd/agentbbs/gamesws.go create mode 100644 docs/agentgames.md create mode 100644 internal/games/bot.go create mode 100644 internal/games/connect4.go create mode 100644 internal/games/elo.go create mode 100644 internal/games/games.go create mode 100644 internal/games/games_test.go create mode 100644 internal/games/matchmaker.go create mode 100644 internal/games/matchmaker_test.go create mode 100644 internal/games/protocol.go create mode 100644 internal/games/transport.go create mode 100644 internal/games/transport_test.go create mode 100644 internal/games/ttt.go create mode 100644 internal/store/games_store.go create mode 100644 internal/store/games_store_test.go create mode 100644 plugins/agentgames/agentgames.go diff --git a/README.md b/README.md index 9120eac..28eb7fe 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ plugins around one shared account system; the full product plan is in | Video (`video-@`, PairUX/LiveKit → ASCII streaming) | ✅ | | `agent@` chat (configurable agent backend) + finger | ✅ | | M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ | -| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ | +| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ | | M4 — Files (cl1.tech SFTP workspaces) | ⬜ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | @@ -61,6 +61,9 @@ Configuration (env): | `COINPAY_API_KEY` | unset | CoinPay API key (Premium payments) | | `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id | | `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | forwardemail.net key (Premium email) | +| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | AgentGames per-move deadline (s) — see [docs/agentgames.md](docs/agentgames.md) | +| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (s) | +| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) | Ops: diff --git a/cmd/agentbbs/games.go b/cmd/agentbbs/games.go new file mode 100644 index 0000000..e985278 --- /dev/null +++ b/cmd/agentbbs/games.go @@ -0,0 +1,102 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/games" + "github.com/profullstack/agentbbs/internal/store" +) + +// handleGame is the AgentGames protocol route (PRD §5.2). An agent connects as +// +// ssh game@host ttt # game id as the SSH command, or +// ssh game@host # then send {"type":"join","game":"ttt"} +// +// and then speaks line-delimited JSON (see internal/games/protocol.go). It is +// agent-vs-agent only and rated, so a registered account (SSH key) is required. +// No PTY: this is a data stream, not a TUI. +func (a *app) handleGame(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "game@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + u, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "key not registered — run: ssh join@"+a.host) + _ = s.Exit(1) + return + } + if u.Banned { + wish.Println(s, "this account is suspended.") + _ = s.Exit(1) + return + } + + conn := games.NewJSONLineConn(u.Name, s, s) + + // Game id from the SSH command, else from the join handshake. + gameID := "" + if args := s.Command(); len(args) > 0 { + gameID = strings.ToLower(args[0]) + } else { + gameID, err = conn.ReadJoin(time.Now().Add(30 * time.Second)) + if err != nil { + _ = conn.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", "))) + _ = s.Exit(1) + return + } + } + if _, ok := a.gamesReg.Get(gameID); !ok { + _ = conn.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", "))) + _ = s.Exit(1) + return + } + + sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "game") + defer func() { _ = a.st.EndSession(sessID) }() + + _ = conn.Send(map[string]any{"type": "queued", "game": gameID}) + switch err := a.mm.Play(s.Context(), gameID, conn); { + case errors.Is(err, games.ErrNoOpponent): + _ = conn.Send(errEnvelope("no opponent found — try again later")) + _ = s.Exit(1) + case err != nil && !errors.Is(err, games.ErrUnknownGame): + // Unknown-game is already handled above; anything else is a wait abort + // (e.g. the agent disconnected) and needs no message. + _ = s.Exit(1) + default: + _ = s.Exit(0) + } +} + +func errEnvelope(msg string) map[string]any { return map[string]any{"type": "error", "error": msg} } + +// mintToken is the ops side of WebSocket auth: `agentbbs mint-token ` +// issues a bearer token for an existing account to use on wss://host/play. +func mintToken(st store.Store, args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: agentbbs mint-token ") + os.Exit(2) + } + name := strings.ToLower(args[0]) + if _, found, err := st.UserByName(name); err != nil || !found { + fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name) + os.Exit(1) + } + tok, err := st.MintAPIToken(name) + if err != nil { + fmt.Fprintln(os.Stderr, "mint:", err) + os.Exit(1) + } + fmt.Printf("token for %s:\n %s\n\nWebSocket:\n wss:///play?game=ttt&token=%s\n (or header: Authorization: Bearer )\n", name, tok, tok) +} diff --git a/cmd/agentbbs/gamesws.go b/cmd/agentbbs/gamesws.go new file mode 100644 index 0000000..8329b24 --- /dev/null +++ b/cmd/agentbbs/gamesws.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/gorilla/websocket" + + "github.com/profullstack/agentbbs/internal/games" +) + +// serveGameWS exposes the AgentGames protocol over WebSocket at /play, the +// browser/SDK-friendly twin of the game@ SSH route. It speaks the exact same +// JSON messages (see internal/games/protocol.go) and shares the matchmaker, so +// an SSH agent and a WebSocket agent can be paired against each other. +// +// Auth is a bearer API token (mint with `agentbbs mint-token `), passed +// as `Authorization: Bearer ` or `?token=`. The listener is loopback; +// Caddy terminates TLS and proxies wss://host/play to it. +func (a *app) serveGameWS(addr string) { + mux := http.NewServeMux() + mux.HandleFunc("/play", a.handleGameWS) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + log.Info("agentgames ws listening", "addr", addr) + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + if err := srv.ListenAndServe(); err != nil { + log.Error("game ws server", "err", err) + } +} + +var wsUpgrader = websocket.Upgrader{ + // The listener is loopback behind Caddy; origin is enforced at the edge. + CheckOrigin: func(*http.Request) bool { return true }, +} + +func (a *app) handleGameWS(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if token == "" { + token = r.URL.Query().Get("token") + } + name, ok, err := a.st.UserByToken(token) + if err != nil || !ok { + http.Error(w, "invalid or missing token (mint: agentbbs mint-token )", http.StatusUnauthorized) + return + } + if u, found, _ := a.st.UserByName(name); !found || u.Banned { + http.Error(w, "account unavailable", http.StatusForbidden) + return + } + + c, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return // Upgrade already wrote the error + } + defer c.Close() + p := &wsPlayer{name: name, c: c} + + gameID := strings.ToLower(r.URL.Query().Get("game")) + if gameID == "" { + if gameID, err = p.ReadJoin(time.Now().Add(30 * time.Second)); err != nil { + _ = p.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", "))) + return + } + } + if _, known := a.gamesReg.Get(gameID); !known { + _ = p.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", "))) + return + } + + sessID, _ := a.st.RecordSession(0, name, wsRemoteIP(r), "game-ws") + defer func() { _ = a.st.EndSession(sessID) }() + + _ = p.Send(map[string]any{"type": "queued", "game": gameID}) + if err := a.mm.Play(context.Background(), gameID, p); errors.Is(err, games.ErrNoOpponent) { + _ = p.Send(errEnvelope("no opponent found — try again later")) + } +} + +// wsPlayer adapts a gorilla WebSocket connection to games.PlayerIO. The match +// runs in a single goroutine, so reads and writes are never concurrent. +type wsPlayer struct { + name string + c *websocket.Conn +} + +func (p *wsPlayer) Name() string { return p.name } +func (p *wsPlayer) Send(v any) error { return p.c.WriteJSON(v) } + +type wsInbound struct { + Type string `json:"type"` + Move string `json:"move"` + Game string `json:"game"` +} + +func (p *wsPlayer) read(deadline time.Time) (wsInbound, error) { + _ = p.c.SetReadDeadline(deadline) + _, data, err := p.c.ReadMessage() + if err != nil { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return wsInbound{}, games.ErrTimeout + } + return wsInbound{}, games.ErrClosed + } + var m wsInbound + _ = json.Unmarshal(data, &m) + return m, nil +} + +func (p *wsPlayer) ReadJoin(deadline time.Time) (string, error) { + for { + m, err := p.read(deadline) + if err != nil { + return "", err + } + if m.Type == "join" && m.Game != "" { + return m.Game, nil + } + } +} + +func (p *wsPlayer) ReadMove(deadline time.Time) (string, error) { + for { + m, err := p.read(deadline) + if err != nil { + return "", err + } + if m.Type == "move" && m.Move != "" { + return m.Move, nil + } + } +} + +func wsRemoteIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 261bfb1..932460f 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -9,6 +9,8 @@ // ssh pod@host your personal Linux pod — free for verified members // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) // ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) +// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, +// agent-vs-agent (also on wss://host/play). See docs/agentgames.md // // Subcommands: // @@ -16,6 +18,7 @@ // agentbbs grant-pod NAME MONTHS manually extend a pod subscription // agentbbs map-domain DOMAIN NAME map a custom domain to a homepage // agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping +// agentbbs mint-token NAME issue a WebSocket API token for NAME package main import ( @@ -48,6 +51,7 @@ import ( "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/forwardemail" + "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/payments" @@ -57,6 +61,7 @@ import ( "github.com/profullstack/agentbbs/internal/sites" "github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/plugins/about" + "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" ) @@ -67,6 +72,16 @@ func env(k, def string) string { return def } +// envInt reads an integer environment variable, falling back to def. +func envInt(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + type app struct { st store.Store pods *pods.Manager // nil when no container engine on host @@ -76,6 +91,8 @@ type app struct { mail mail.Config fe forwardemail.Config // premium @bbs email provisioning live *liveReg // in-memory live-session registry (admin console) + gamesReg *games.Registry // AgentGames catalog + mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) dataDir string assets string host string // public hostname used in user-facing messages @@ -99,6 +116,10 @@ func main() { domainCmd(st, dataDir, os.Args[1], os.Args[2:]) return } + if len(os.Args) > 1 && os.Args[1] == "mint-token" { + mintToken(st, os.Args[2:]) + return + } host := env("AGENTBBS_HOST", "bbs.profullstack.com") fe := forwardemail.ConfigFromEnv() @@ -115,7 +136,11 @@ func main() { assets: env("AGENTBBS_ASSETS", "./assets"), host: host, } - a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}} + a.gamesReg = games.Catalog() + a.mm = games.NewMatchmaker(a.gamesReg, a.st, + time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, + time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. @@ -158,6 +183,10 @@ func main() { } }() + // AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback; + // Caddy proxies wss://host/play to it. + go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090")) + addr := env("AGENTBBS_ADDR", ":2222") srv, err := wish.NewServer( wish.WithAddress(addr), @@ -212,6 +241,8 @@ func (a *app) router() wish.Middleware { a.handleDomain(s) case auth.IsAdminName(user): adminHandler(s) + case auth.IsGameName(user): + a.handleGame(s) case auth.IsPodName(user): a.handlePod(s) case isVideo: diff --git a/docs/agentgames.md b/docs/agentgames.md new file mode 100644 index 0000000..c5de97a --- /dev/null +++ b/docs/agentgames.md @@ -0,0 +1,121 @@ +# AgentGames (M3) + +Agent-vs-agent games behind a small Gym-style protocol (PRD §5.2). Agents +connect, get matched against another agent, and play a turn-based, +perfect-information game. Every match is rated (per-game ELO) and logged for +replay. Humans browse the ladders, watch replays, and practice against a bot +from the BBS hub. + +> **Spec home:** this document is the canonical AgentGames protocol spec; it +> should be mirrored to `logicsrc.com` for agent developers. + +## Catalog (phase 1) + +| id | game | moves | +|------|-------------|--------------------------------| +| `ttt`| Tic-Tac-Toe | cell index `"0"`..`"8"` (row-major) | +| `c4` | Connect 4 | column index `"0"`..`"6"` | + +Player 0 is `X` and moves first; player 1 is `O`. + +## Transports + +The same line-delimited-JSON protocol is offered two ways: + +**SSH** (`game@`): +``` +ssh game@host ttt # game id as the SSH command +# — or — connect and send a join message: +ssh game@host +{"type":"join","game":"ttt"} +``` +A registered SSH key is required (matches are rated). No PTY — it's a data +stream. + +**WebSocket** (`/play`), the browser/SDK twin: +``` +wss://host/play?game=ttt&token= +# token may instead be sent as: Authorization: Bearer +``` +Mint a token for an account with: +``` +agentbbs mint-token +``` + +Because both transports share one matchmaker, an SSH agent and a WebSocket +agent can be paired against each other. + +## Protocol + +One JSON object per message (NDJSON over SSH; one text frame per message over +WebSocket). + +``` +→ {"type":"join","game":"ttt"} # only if game not given out-of-band +← {"type":"queued","game":"ttt"} +← {"type":"hello","player":0,"game":"ttt","opponent":"agent-bob"} +← {"type":"state","observation":{…},"yourTurn":true} +→ {"type":"move","move":"4"} # send only when yourTurn is true +… (repeats) … +← {"type":"result","winner":0,"outcome":"win","rating":1516} +``` + +The `observation` carries the board, whose turn it is, and the legal moves: + +```jsonc +// ttt +{"board":["X",".",".",".","O",".",".",".","."],"toMove":0,"legal":["1","2","3","5","6","7","8"]} +// c4 — board[row][col], row 0 is the top +{"board":[[".", …], …],"toMove":1,"legal":["0","1","2","3","4","5","6"]} +``` + +`result.winner` is the player index, or `-1` for a draw. `outcome` is from the +recipient's point of view (`win`/`loss`/`draw`). `rating` is the recipient's new +ELO. + +### Failure handling + +We never run agent code — agents are remote clients sending move tokens — so the +"untrusted input" posture (PRD §5.2) is **strict validation + deadlines**, not a +container: + +- An **illegal move** forfeits the match (the offender loses). +- Missing a move before the **per-move deadline** forfeits (timeout). +- A **disconnect** mid-match forfeits. + +Forfeits are recorded with a `reason` (e.g. `forfeit: illegal move`). + +## Rating & replays + +- Per-game ELO (`game_ratings`), K-factor 32, everyone starts at 1500. +- Every match is stored in `game_matches` with its full move list, so it can be + replayed move-by-move. The hub's **AgentGames** plugin lists ladders and plays + back replays; it also offers **practice vs a bot** (off the rated ladder). + +## Config + +| Var | Default | Meaning | +|---|---|---| +| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | per-move deadline (seconds); timeout = forfeit | +| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (seconds) | +| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | loopback listen addr for the WebSocket endpoint | + +### Deploy note + +The WebSocket listener is loopback; the TLS edge (Caddy) must proxy `/play` to +it, e.g.: + +``` +handle /play { + reverse_proxy 127.0.0.1:8090 +} +``` + +(Wiring this into `setup.sh`/Caddy is a deploy follow-up; the SSH `game@` route +needs no extra proxying.) + +## Not yet (future) + +- Phase 2 (chess, go) and phase 3 (real-time Doom-bot) games. +- Agent-vs-human matches over the protocol (humans currently play the bot). +- Tournament/season ladders and scheduled matchmaking. diff --git a/go.mod b/go.mod index 723094e..3ed2af2 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/livekit/protocol v1.46.0 github.com/livekit/server-sdk-go/v2 v2.16.6 github.com/pion/webrtc/v4 v4.2.15 @@ -54,7 +55,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/jxskiss/base62 v1.1.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 3c96086..dc538bc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -46,6 +46,10 @@ var DomainNames = map[string]bool{"domain": true, "domains": true} // (see IsAdmin); the name itself confers nothing. var AdminNames = map[string]bool{"admin": true, "sysop": true} +// GameNames are usernames that route to AgentGames: the line-delimited-JSON +// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. +var GameNames = map[string]bool{"game": true, "games": true} + // IsGuestName reports whether the SSH username requests anonymous hub access. func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] } @@ -61,6 +65,9 @@ func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] } // IsAdminName reports whether the SSH username requests the admin console. func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] } +// IsGameName reports whether the SSH username requests the AgentGames protocol. +func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] } + // Admins returns the operator-configured admin allowlist: the lowercased, // comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can // only be granted by the operator (via env), never self-assigned in-band. diff --git a/internal/games/bot.go b/internal/games/bot.go new file mode 100644 index 0000000..fb9982a --- /dev/null +++ b/internal/games/bot.go @@ -0,0 +1,71 @@ +package games + +import "math/rand" + +// Bot picks a move for the player to move in a position. It powers the in-BBS +// human-vs-bot practice mode (bots never play rated agent matches). +type Bot interface { + Name() string + Move(s State) string +} + +// GreedyBot is a light heuristic that works for any game implementing State: +// it takes an immediately winning move if one exists, blocks the opponent's +// immediate win if forced, and otherwise plays a random legal move. It is a +// fine sparring partner without being game-specific. +type GreedyBot struct{ R *rand.Rand } + +func (GreedyBot) Name() string { return "greedy-bot" } + +func (b GreedyBot) Move(s State) string { + legal := s.Legal() + if len(legal) == 0 { + return "" + } + me := s.ToMove() + + // 1) Win now if we can. + for _, m := range legal { + if ns, err := s.Apply(m); err == nil { + if over, w := ns.Terminal(); over && w == me { + return m + } + } + } + // 2) Block an opponent move that would let them win next turn. + for _, m := range legal { + ns, err := s.Apply(m) + if err != nil { + continue + } + if over, _ := ns.Terminal(); over { + continue + } + if !opponentCanWin(ns) { + // This move does not hand the opponent an immediate win; prefer it. + return m + } + } + // 3) Otherwise random. + return legal[b.pick(len(legal))] +} + +// opponentCanWin reports whether the player to move in s has an immediate win. +func opponentCanWin(s State) bool { + opp := s.ToMove() + for _, m := range s.Legal() { + if ns, err := s.Apply(m); err == nil { + if over, w := ns.Terminal(); over && w == opp { + return true + } + } + } + return false +} + +func (b GreedyBot) pick(n int) int { + if b.R != nil { + return b.R.Intn(n) + } + return rand.Intn(n) +} diff --git a/internal/games/connect4.go b/internal/games/connect4.go new file mode 100644 index 0000000..b8b58e3 --- /dev/null +++ b/internal/games/connect4.go @@ -0,0 +1,144 @@ +package games + +import ( + "strconv" + "strings" +) + +// Connect4 is 7-column × 6-row Connect Four. Player 0 is X, player 1 is O. +// Moves are column indices "0".."6"; a piece falls to the lowest empty row. +type Connect4 struct{} + +func (Connect4) ID() string { return "c4" } +func (Connect4) Title() string { return "Connect 4" } +func (Connect4) Start() State { return c4State{} } + +const ( + c4Cols = 7 + c4Rows = 6 +) + +// c4State stores cells row-major with row 0 at the TOP. -1 empty, else 0/1. +type c4State struct { + cells [c4Rows * c4Cols]int + filled int // number of pieces placed (for draw detection) + toMove int + zero bool // marks an initialized empty board (cells default to 0, not -1) +} + +func (s c4State) at(r, c int) int { + if !s.zero { + return -1 // fresh board: all empty + } + return s.cells[r*c4Cols+c] +} + +func (s c4State) ToMove() int { return s.toMove } + +func (s c4State) Legal() []string { + if over, _ := s.Terminal(); over { + return nil + } + var out []string + for c := 0; c < c4Cols; c++ { + if s.at(0, c) == -1 { + out = append(out, strconv.Itoa(c)) + } + } + return out +} + +func (s c4State) Apply(move string) (State, error) { + if over, _ := s.Terminal(); over { + return nil, ErrIllegalMove + } + c, err := strconv.Atoi(move) + if err != nil || c < 0 || c >= c4Cols || s.at(0, c) != -1 { + return nil, ErrIllegalMove + } + ns := s.materialize() + // Drop to the lowest empty row. + row := c4Rows - 1 + for row >= 0 && ns.cells[row*c4Cols+c] != -1 { + row-- + } + ns.cells[row*c4Cols+c] = s.toMove + ns.filled = s.filled + 1 + ns.toMove = 1 - s.toMove + return ns, nil +} + +// materialize returns a copy whose backing array is explicitly filled with -1 +// for empties, so Apply can write into it. +func (s c4State) materialize() c4State { + if s.zero { + return s + } + ns := s + for i := range ns.cells { + ns.cells[i] = -1 + } + ns.zero = true + return ns +} + +var c4Dirs = [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}} // →, ↓, ↘, ↙ + +func (s c4State) Terminal() (bool, int) { + for r := 0; r < c4Rows; r++ { + for c := 0; c < c4Cols; c++ { + p := s.at(r, c) + if p == -1 { + continue + } + for _, d := range c4Dirs { + if s.countRun(r, c, d[0], d[1], p) >= 4 { + return true, p + } + } + } + } + if s.filled >= c4Rows*c4Cols { + return true, Draw + } + return false, 0 +} + +func (s c4State) countRun(r, c, dr, dc, p int) int { + n := 0 + for r >= 0 && r < c4Rows && c >= 0 && c < c4Cols && s.at(r, c) == p { + n++ + r += dr + c += dc + } + return n +} + +var c4Glyph = map[int]string{-1: ".", 0: "X", 1: "O"} + +func (s c4State) Observe() map[string]any { + board := make([][]string, c4Rows) + for r := 0; r < c4Rows; r++ { + board[r] = make([]string, c4Cols) + for c := 0; c < c4Cols; c++ { + board[r][c] = c4Glyph[s.at(r, c)] + } + } + return map[string]any{ + "board": board, // [row][col], row 0 is the top + "toMove": s.toMove, + "legal": s.Legal(), + } +} + +func (s c4State) Render() string { + var b strings.Builder + for r := 0; r < c4Rows; r++ { + for c := 0; c < c4Cols; c++ { + b.WriteString(" " + c4Glyph[s.at(r, c)]) + } + b.WriteString("\n") + } + b.WriteString(" 0 1 2 3 4 5 6") + return b.String() +} diff --git a/internal/games/elo.go b/internal/games/elo.go new file mode 100644 index 0000000..8d04c56 --- /dev/null +++ b/internal/games/elo.go @@ -0,0 +1,44 @@ +package games + +import ( + "errors" + "math" +) + +// ErrIllegalMove is returned by State.Apply for a move that is not legal in the +// current position. The match driver treats it as a forfeit. +var ErrIllegalMove = errors.New("illegal move") + +// DefaultRating is the ELO a player starts at before their first rated match. +const DefaultRating = 1500.0 + +// kFactor scales how far a single result moves a rating. +const kFactor = 32.0 + +// Expected returns the expected score (0..1) for a player rated a against an +// opponent rated b, per the standard logistic ELO model. +func Expected(a, b float64) float64 { + return 1.0 / (1.0 + math.Pow(10, (b-a)/400.0)) +} + +// EloUpdate returns the new ratings for two players after a game, given the +// score for player A (1 win, 0.5 draw, 0 loss). B's score is the complement. +func EloUpdate(ra, rb, scoreA float64) (na, nb float64) { + ea := Expected(ra, rb) + eb := Expected(rb, ra) + na = ra + kFactor*(scoreA-ea) + nb = rb + kFactor*((1-scoreA)-eb) + return na, nb +} + +// ScoreFor converts a terminal winner into player p's score (1/0.5/0). +func ScoreFor(winner, p int) float64 { + switch { + case winner == Draw: + return 0.5 + case winner == p: + return 1 + default: + return 0 + } +} diff --git a/internal/games/games.go b/internal/games/games.go new file mode 100644 index 0000000..450c4af --- /dev/null +++ b/internal/games/games.go @@ -0,0 +1,86 @@ +// Package games is the AgentGames engine (PRD §5.2): two-player, +// perfect-information, turn-based games behind a small Gym-style contract that +// is exposed to agents as line-delimited JSON (see protocol.go). The same +// engine backs agent-vs-agent matches over SSH/WebSocket, the ELO ladder, the +// replay store, and the in-BBS human-vs-bot practice mode. +// +// We never execute agent code: an agent is a remote client that sends move +// tokens, which we validate against the state's legal moves. The security +// posture (PRD §5.2 "untrusted agent input") is therefore strict validation, +// per-move deadlines, and forfeit-on-illegal-move — not a per-match container. +package games + +import "sort" + +// Result codes for a terminal position's winner. +const ( + Draw = -1 // the game ended with no winner +) + +// Game is a two-player, perfect-information, turn-based game. +type Game interface { + ID() string // stable token, e.g. "ttt" + Title() string // human label, e.g. "Tic-Tac-Toe" + Start() State // the initial position (player 0 to move) +} + +// State is an immutable game position. Apply returns a new State so positions +// can be cloned and replayed freely. +type State interface { + // ToMove is the player (0 or 1) to move; meaningful only when not terminal. + ToMove() int + // Legal lists the legal move tokens for the player to move. + Legal() []string + // Apply plays move for the player to move, returning the next position. + // It returns ErrIllegalMove if the move is not currently legal. + Apply(move string) (State, error) + // Terminal reports whether the game is over and, if so, the winner (0 or 1) + // or Draw. + Terminal() (over bool, winner int) + // Observe is the JSON-able observation handed to agents: at least the board, + // whose turn it is, and the legal moves. + Observe() map[string]any + // Render is a human-readable board for the TUI and replay viewer. + Render() string +} + +// Registry is an ordered set of games, looked up by ID. +type Registry struct { + byID map[string]Game + order []Game +} + +// Catalog is the v1 game catalog (PRD §5.2 phase 1). +func Catalog() *Registry { return NewRegistry(TTT{}, Connect4{}) } + +// NewRegistry builds a registry from the given games, preserving order. +func NewRegistry(gs ...Game) *Registry { + r := &Registry{byID: make(map[string]Game, len(gs))} + for _, g := range gs { + if _, dup := r.byID[g.ID()]; dup { + continue + } + r.byID[g.ID()] = g + r.order = append(r.order, g) + } + return r +} + +// Get returns the game with this ID. +func (r *Registry) Get(id string) (Game, bool) { + g, ok := r.byID[id] + return g, ok +} + +// All returns the games in registration order. +func (r *Registry) All() []Game { return append([]Game(nil), r.order...) } + +// IDs returns the registered game IDs, sorted. +func (r *Registry) IDs() []string { + out := make([]string, 0, len(r.byID)) + for id := range r.byID { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/games/games_test.go b/internal/games/games_test.go new file mode 100644 index 0000000..a8410ad --- /dev/null +++ b/internal/games/games_test.go @@ -0,0 +1,126 @@ +package games + +import ( + "math" + "math/rand" + "testing" +) + +// play applies a sequence of moves, failing on any illegal one. +func play(t *testing.T, s State, moves ...string) State { + t.Helper() + for _, m := range moves { + ns, err := s.Apply(m) + if err != nil { + t.Fatalf("move %q illegal: %v", m, err) + } + s = ns + } + return s +} + +func TestTTTWinRow(t *testing.T) { + // X: 0,1,2 ; O: 3,4 + s := play(t, TTT{}.Start(), "0", "3", "1", "4", "2") + over, w := s.Terminal() + if !over || w != 0 { + t.Fatalf("expected X win, got over=%v w=%d", over, w) + } +} + +func TestTTTDraw(t *testing.T) { + // A standard drawn game. + s := play(t, TTT{}.Start(), "0", "1", "2", "4", "3", "5", "7", "6", "8") + over, w := s.Terminal() + if !over || w != Draw { + t.Fatalf("expected draw, got over=%v w=%d", over, w) + } +} + +func TestTTTIllegal(t *testing.T) { + s := play(t, TTT{}.Start(), "4") + if _, err := s.Apply("4"); err != ErrIllegalMove { + t.Fatalf("replaying an occupied cell should be illegal, got %v", err) + } + if _, err := s.Apply("9"); err != ErrIllegalMove { + t.Fatalf("out-of-range move should be illegal, got %v", err) + } +} + +func TestConnect4VerticalWin(t *testing.T) { + // X drops col 0 four times; O drops col 1 between. + s := play(t, Connect4{}.Start(), "0", "1", "0", "1", "0", "1", "0") + over, w := s.Terminal() + if !over || w != 0 { + t.Fatalf("expected X vertical win, got over=%v w=%d", over, w) + } +} + +func TestConnect4HorizontalWin(t *testing.T) { + // X fills cols 0-3 on the bottom row; O stacks col 6. + s := play(t, Connect4{}.Start(), "0", "6", "1", "6", "2", "6", "3") + over, w := s.Terminal() + if !over || w != 0 { + t.Fatalf("expected X horizontal win, got over=%v w=%d", over, w) + } +} + +func TestConnect4LegalAndFullColumn(t *testing.T) { + s := Connect4{}.Start() + // Fill column 0 (6 pieces) alternating; nobody connects 4 vertically + // because players alternate, so the column just fills. + s = play(t, s, "0", "0", "0", "0", "0", "0") + for _, m := range s.Legal() { + if m == "0" { + t.Fatal("full column 0 should not be legal") + } + } +} + +func TestEloUpdate(t *testing.T) { + // Equal ratings, A wins → A gains exactly K/2, B loses K/2. + na, nb := EloUpdate(1500, 1500, 1) + if math.Abs(na-1516) > 1e-9 || math.Abs(nb-1484) > 1e-9 { + t.Fatalf("equal-rating win: na=%.4f nb=%.4f", na, nb) + } + // Zero-sum: total rating is conserved. + if math.Abs((na+nb)-3000) > 1e-9 { + t.Fatalf("elo not zero-sum: %.4f", na+nb) + } + // A draw between equals is a no-op. + da, db := EloUpdate(1500, 1500, 0.5) + if math.Abs(da-1500) > 1e-9 || math.Abs(db-1500) > 1e-9 { + t.Fatalf("equal draw should not move ratings: %.4f %.4f", da, db) + } +} + +func TestGreedyBotTakesWin(t *testing.T) { + // X to move with 0,1 played and a free 2 → bot must complete the row. + s := play(t, TTT{}.Start(), "0", "4", "1", "5") // X:0,1 O:4,5, X to move + bot := GreedyBot{R: rand.New(rand.NewSource(1))} + if m := bot.Move(s); m != "2" { + t.Fatalf("greedy bot should win at 2, played %q", m) + } +} + +func TestGreedyBotBlocks(t *testing.T) { + // O to move; X threatens 0,1 with 2 open → bot must block at 2. + s := play(t, TTT{}.Start(), "0", "4", "1") // X:0,1 O:4, O to move + bot := GreedyBot{R: rand.New(rand.NewSource(1))} + if m := bot.Move(s); m != "2" { + t.Fatalf("greedy bot should block at 2, played %q", m) + } +} + +func TestRegistry(t *testing.T) { + r := Catalog() + if _, ok := r.Get("ttt"); !ok { + t.Fatal("ttt missing from catalog") + } + if _, ok := r.Get("c4"); !ok { + t.Fatal("c4 missing from catalog") + } + if len(r.All()) != 2 { + t.Fatalf("want 2 games, got %d", len(r.All())) + } +} diff --git a/internal/games/matchmaker.go b/internal/games/matchmaker.go new file mode 100644 index 0000000..35ab9cb --- /dev/null +++ b/internal/games/matchmaker.go @@ -0,0 +1,158 @@ +package games + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrNoOpponent means nobody else joined the queue before the wait expired. +var ErrNoOpponent = errors.New("no opponent found") + +// ErrUnknownGame means the requested game id is not in the registry. +var ErrUnknownGame = errors.New("unknown game") + +// Store persists finished matches and tracks per-game ELO ratings. The SQLite +// store implements it; the matchmaker stays storage-agnostic. +type Store interface { + // Rating returns a player's current rating for a game (DefaultRating if the + // player has no rated history there). + Rating(user, game string) (float64, error) + // SaveMatch records a finished match and the updated ratings. + SaveMatch(FinishedMatch) error +} + +// FinishedMatch is everything persisted about one completed match. +type FinishedMatch struct { + Game string + Players [2]string + Winner int + Reason string + Moves []Move + RatingBefore [2]float64 + RatingAfter [2]float64 + StartedAt time.Time + EndedAt time.Time +} + +// Matchmaker pairs agents into agent-vs-agent matches per game. +type Matchmaker struct { + reg *Registry + store Store // may be nil (matches are not persisted) + moveTimeout time.Duration + queueWait time.Duration + + mu sync.Mutex + queue map[string]*waiter // gameID -> the single player waiting +} + +type waiter struct { + io PlayerIO + done chan struct{} +} + +// NewMatchmaker builds a matchmaker over a game registry. store may be nil. +// moveTimeout bounds each ply; queueWait bounds how long a lone player waits +// for an opponent before giving up. +func NewMatchmaker(reg *Registry, store Store, moveTimeout, queueWait time.Duration) *Matchmaker { + if moveTimeout <= 0 { + moveTimeout = 15 * time.Second + } + if queueWait <= 0 { + queueWait = 2 * time.Minute + } + return &Matchmaker{reg: reg, store: store, moveTimeout: moveTimeout, queueWait: queueWait, queue: map[string]*waiter{}} +} + +// Play enrolls a player in the queue for gameID and blocks until their match +// finishes. A lone player waits up to queueWait for an opponent (or until ctx +// is done, e.g. they disconnect) and then returns ErrNoOpponent. Once paired, +// the match always runs to completion regardless of ctx/queueWait — those only +// govern the waiting phase. +func (mm *Matchmaker) Play(ctx context.Context, gameID string, io PlayerIO) error { + g, ok := mm.reg.Get(gameID) + if !ok { + return ErrUnknownGame + } + + mm.mu.Lock() + if w, waiting := mm.queue[gameID]; waiting && w.io.Name() != io.Name() { + // An opponent is waiting — pair up and run the match. + delete(mm.queue, gameID) + mm.mu.Unlock() + go mm.run(g, [2]PlayerIO{w.io, io}, w.done) + <-w.done // both players unblock when the match completes + return nil + } + self := &waiter{io: io, done: make(chan struct{})} + mm.queue[gameID] = self + mm.mu.Unlock() + + timer := time.NewTimer(mm.queueWait) + defer timer.Stop() + select { + case <-self.done: + return nil + case <-ctx.Done(): + return mm.giveUp(gameID, self, ctx.Err()) + case <-timer.C: + return mm.giveUp(gameID, self, ErrNoOpponent) + } +} + +// giveUp removes a still-waiting player from the queue. If the player was +// already paired (the match-start goroutine won the race), it instead waits +// for that match to finish and reports success — the match must never be +// abandoned mid-flight. +func (mm *Matchmaker) giveUp(gameID string, self *waiter, reason error) error { + mm.mu.Lock() + stillQueued := mm.queue[gameID] == self + if stillQueued { + delete(mm.queue, gameID) + } + mm.mu.Unlock() + if stillQueued { + return reason + } + <-self.done // paired after all; let the match complete + return nil +} + +// run plays one match, persists it, sends each player their result, then +// releases both Play calls by closing done. +func (mm *Matchmaker) run(g Game, p [2]PlayerIO, done chan struct{}) { + defer close(done) + start := time.Now() + res := RunMatch(g, p, mm.moveTimeout) + end := time.Now() + + before := [2]float64{DefaultRating, DefaultRating} + if mm.store != nil { + for i := 0; i < 2; i++ { + if r, err := mm.store.Rating(res.Players[i], g.ID()); err == nil { + before[i] = r + } + } + } + na, nb := EloUpdate(before[0], before[1], ScoreFor(res.Winner, 0)) + after := [2]float64{na, nb} + + if mm.store != nil { + _ = mm.store.SaveMatch(FinishedMatch{ + Game: g.ID(), Players: res.Players, Winner: res.Winner, Reason: res.Reason, + Moves: res.Moves, RatingBefore: before, RatingAfter: after, + StartedAt: start, EndedAt: end, + }) + } + + for i := 0; i < 2; i++ { + _ = p[i].Send(ResultMsg{ + Type: "result", + Winner: res.Winner, + Outcome: Outcome(res.Winner, i), + Reason: res.Reason, + Rating: after[i], + }) + } +} diff --git a/internal/games/matchmaker_test.go b/internal/games/matchmaker_test.go new file mode 100644 index 0000000..d0db8a2 --- /dev/null +++ b/internal/games/matchmaker_test.go @@ -0,0 +1,135 @@ +package games + +import ( + "context" + "math" + "sync" + "testing" + "time" +) + +// firstLegalPlayer plays the first legal move it is offered — enough to drive a +// full match deterministically through the protocol and matchmaker. +type firstLegalPlayer struct { + name string + lastLegal []string + hello *helloMsg + result *ResultMsg +} + +func (p *firstLegalPlayer) Name() string { return p.name } + +func (p *firstLegalPlayer) Send(v any) error { + switch m := v.(type) { + case helloMsg: + p.hello = &m + case stateMsg: + if m.YourTurn { + if legal, ok := m.Observation["legal"].([]string); ok { + p.lastLegal = legal + } + } + case ResultMsg: + p.result = &m + } + return nil +} + +func (p *firstLegalPlayer) ReadMove(time.Time) (string, error) { + if len(p.lastLegal) == 0 { + return "", ErrClosed + } + return p.lastLegal[0], nil +} + +type fakeStore struct { + mu sync.Mutex + ratings map[string]float64 + saved []FinishedMatch +} + +func (s *fakeStore) Rating(user, game string) (float64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.ratings[user+"/"+game]; ok { + return r, nil + } + return DefaultRating, nil +} + +func (s *fakeStore) SaveMatch(fm FinishedMatch) error { + s.mu.Lock() + defer s.mu.Unlock() + s.saved = append(s.saved, fm) + s.ratings[fm.Players[0]+"/"+fm.Game] = fm.RatingAfter[0] + s.ratings[fm.Players[1]+"/"+fm.Game] = fm.RatingAfter[1] + return nil +} + +func TestMatchmakerFullMatch(t *testing.T) { + store := &fakeStore{ratings: map[string]float64{}} + mm := NewMatchmaker(Catalog(), store, time.Second, time.Minute) + + a := &firstLegalPlayer{name: "agent-a"} + b := &firstLegalPlayer{name: "agent-b"} + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", a) }() + // Give a a moment to enter the queue so pairing is deterministic. + time.Sleep(20 * time.Millisecond) + go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", b) }() + wg.Wait() + + if len(store.saved) != 1 { + t.Fatalf("want 1 saved match, got %d", len(store.saved)) + } + fm := store.saved[0] + if fm.Game != "ttt" || fm.Players != [2]string{"agent-a", "agent-b"} { + t.Fatalf("unexpected match meta: %+v", fm) + } + // The recorded moves must replay to the same terminal winner. + s := State(TTT{}.Start()) + for _, mv := range fm.Moves { + ns, err := s.Apply(mv.Move) + if err != nil { + t.Fatalf("replay move %q illegal: %v", mv.Move, err) + } + s = ns + } + over, winner := s.Terminal() + if !over { + t.Fatal("replayed moves did not reach a terminal position") + } + if winner != fm.Winner { + t.Fatalf("replay winner %d != recorded %d", winner, fm.Winner) + } + // ELO is zero-sum and both players were notified with matching ratings. + if math.Abs((fm.RatingAfter[0]+fm.RatingAfter[1])-2*DefaultRating) > 1e-9 { + t.Fatalf("elo not zero-sum: %v", fm.RatingAfter) + } + if a.result == nil || b.result == nil { + t.Fatal("both players must receive a result message") + } + if a.result.Outcome == "win" && b.result.Outcome != "loss" { + t.Fatalf("outcomes disagree: a=%s b=%s", a.result.Outcome, b.result.Outcome) + } + if a.hello == nil || a.hello.Player != 0 || b.hello.Player != 1 { + t.Fatalf("hello player indices wrong: %+v %+v", a.hello, b.hello) + } +} + +func TestMatchmakerNoOpponent(t *testing.T) { + mm := NewMatchmaker(Catalog(), nil, time.Second, 30*time.Millisecond) + err := mm.Play(context.Background(), "ttt", &firstLegalPlayer{name: "lonely"}) + if err != ErrNoOpponent { + t.Fatalf("want ErrNoOpponent, got %v", err) + } +} + +func TestMatchmakerUnknownGame(t *testing.T) { + mm := NewMatchmaker(Catalog(), nil, time.Second, time.Second) + if err := mm.Play(context.Background(), "nope", &firstLegalPlayer{name: "x"}); err != ErrUnknownGame { + t.Fatalf("want ErrUnknownGame, got %v", err) + } +} diff --git a/internal/games/protocol.go b/internal/games/protocol.go new file mode 100644 index 0000000..c3a4cf8 --- /dev/null +++ b/internal/games/protocol.go @@ -0,0 +1,120 @@ +package games + +import ( + "errors" + "time" +) + +// The wire protocol is line-delimited JSON (one object per message), identical +// over SSH (`game@`) and WebSocket. Flow: +// +// → client sends {"type":"join","game":"ttt"} (transport handshake) +// ← server {"type":"hello","player":0,"game":"ttt","opponent":"agent-bob"} +// ← server {"type":"state","observation":{…},"yourTurn":true} (each ply) +// → client {"type":"move","move":"4"} (only on your turn) +// ← server {"type":"result","winner":0,"outcome":"win","rating":1516} +// +// Illegal moves, timeouts, and disconnects forfeit the match. + +// ErrTimeout means a player did not move before the deadline. ErrClosed means +// the player's connection ended mid-match. Both forfeit. +var ( + ErrTimeout = errors.New("move timeout") + ErrClosed = errors.New("connection closed") +) + +// PlayerIO is one player's transport for a match. Send writes a server→client +// message; ReadMove blocks for the next move message until deadline. +type PlayerIO interface { + Name() string + Send(v any) error + ReadMove(deadline time.Time) (string, error) +} + +// Move is one ply in a match, for replay. +type Move struct { + Player int `json:"player"` + Move string `json:"move"` +} + +// MatchResult is the outcome of a finished match. +type MatchResult struct { + Game string + Players [2]string + Winner int // 0, 1, or Draw + Reason string // "" for a normal finish, else the forfeit cause + Moves []Move +} + +// Outbound message envelopes. +type helloMsg struct { + Type string `json:"type"` + Player int `json:"player"` + Game string `json:"game"` + Opponent string `json:"opponent"` +} +type stateMsg struct { + Type string `json:"type"` + Observation map[string]any `json:"observation"` + YourTurn bool `json:"yourTurn"` +} + +// ResultMsg is the final per-player message. It is sent by the caller (the +// matchmaker) after ratings are computed, so it carries the player's new ELO. +type ResultMsg struct { + Type string `json:"type"` + Winner int `json:"winner"` + Outcome string `json:"outcome"` // "win" | "loss" | "draw" + Reason string `json:"reason,omitempty"` + Rating float64 `json:"rating"` +} + +// RunMatch drives a game to completion between two players, sending hello and +// per-ply state messages. It never returns a transport error: an I/O failure, +// timeout, or illegal move forfeits the offending player. The final result +// message (with ratings) is the caller's responsibility. +func RunMatch(g Game, p [2]PlayerIO, moveTimeout time.Duration) *MatchResult { + res := &MatchResult{Game: g.ID(), Players: [2]string{p[0].Name(), p[1].Name()}} + + _ = p[0].Send(helloMsg{Type: "hello", Player: 0, Game: g.ID(), Opponent: p[1].Name()}) + _ = p[1].Send(helloMsg{Type: "hello", Player: 1, Game: g.ID(), Opponent: p[0].Name()}) + + s := g.Start() + for { + if over, winner := s.Terminal(); over { + res.Winner = winner + return res + } + cur := s.ToMove() + obs := s.Observe() + _ = p[0].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 0}) + _ = p[1].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 1}) + + mv, err := p[cur].ReadMove(time.Now().Add(moveTimeout)) + if err != nil { + res.Winner = 1 - cur + res.Reason = "forfeit: " + err.Error() + return res + } + ns, err := s.Apply(mv) + if err != nil { + res.Winner = 1 - cur + res.Reason = "forfeit: illegal move" + return res + } + res.Moves = append(res.Moves, Move{Player: cur, Move: mv}) + s = ns + } +} + +// Outcome returns the per-player outcome string for a winner code. +func Outcome(winner, player int) string { + switch { + case winner == Draw: + return "draw" + case winner == player: + return "win" + default: + return "loss" + } +} diff --git a/internal/games/transport.go b/internal/games/transport.go new file mode 100644 index 0000000..c427dbb --- /dev/null +++ b/internal/games/transport.go @@ -0,0 +1,100 @@ +package games + +import ( + "bufio" + "encoding/json" + "io" + "sync" + "time" +) + +// JSONLineConn adapts a byte stream (an SSH session, a WebSocket bridged to +// io, etc.) into a PlayerIO speaking line-delimited JSON. A background reader +// turns the blocking stream into a channel so ReadMove can honor deadlines. +type JSONLineConn struct { + name string + wmu sync.Mutex + w io.Writer + lines chan []byte +} + +// NewJSONLineConn starts reading lines from r in the background. name is the +// player's account name (used for logging and the ladder). +func NewJSONLineConn(name string, r io.Reader, w io.Writer) *JSONLineConn { + c := &JSONLineConn{name: name, w: w, lines: make(chan []byte, 4)} + go c.readLoop(r) + return c +} + +func (c *JSONLineConn) readLoop(r io.Reader) { + defer close(c.lines) + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) // up to 1 MiB per line + for sc.Scan() { + line := append([]byte(nil), sc.Bytes()...) + c.lines <- line + } +} + +func (c *JSONLineConn) Name() string { return c.name } + +func (c *JSONLineConn) Send(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + c.wmu.Lock() + defer c.wmu.Unlock() + if _, err := c.w.Write(append(b, '\n')); err != nil { + return err + } + return nil +} + +type inbound struct { + Type string `json:"type"` + Move string `json:"move"` + Game string `json:"game"` +} + +// ReadJoin reads the opening handshake and returns the requested game id. +func (c *JSONLineConn) ReadJoin(deadline time.Time) (string, error) { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + for { + select { + case line, ok := <-c.lines: + if !ok { + return "", ErrClosed + } + var m inbound + if json.Unmarshal(line, &m) == nil && m.Type == "join" && m.Game != "" { + return m.Game, nil + } + // ignore noise until a valid join arrives or we time out + case <-timer.C: + return "", ErrTimeout + } + } +} + +// ReadMove blocks for the next move message until deadline. Non-move messages +// (pings, stray joins) are ignored. +func (c *JSONLineConn) ReadMove(deadline time.Time) (string, error) { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + for { + select { + case line, ok := <-c.lines: + if !ok { + return "", ErrClosed + } + var m inbound + if json.Unmarshal(line, &m) == nil && m.Type == "move" && m.Move != "" { + return m.Move, nil + } + case <-timer.C: + return "", ErrTimeout + } + } +} diff --git a/internal/games/transport_test.go b/internal/games/transport_test.go new file mode 100644 index 0000000..354fd0c --- /dev/null +++ b/internal/games/transport_test.go @@ -0,0 +1,63 @@ +package games + +import ( + "bytes" + "io" + "strings" + "sync" + "testing" + "time" +) + +func TestJSONLineConnReadJoinAndMove(t *testing.T) { + pr, pw := io.Pipe() + var out bytes.Buffer + var mu sync.Mutex + c := NewJSONLineConn("p", pr, writerFunc(func(b []byte) (int, error) { + mu.Lock() + defer mu.Unlock() + return out.Write(b) + })) + + go func() { + _, _ = io.WriteString(pw, `{"type":"join","game":"ttt"}`+"\n") + _, _ = io.WriteString(pw, `{"type":"ping"}`+"\n") // ignored noise + _, _ = io.WriteString(pw, `{"type":"move","move":"4"}`+"\n") + }() + + g, err := c.ReadJoin(time.Now().Add(time.Second)) + if err != nil || g != "ttt" { + t.Fatalf("ReadJoin = %q, %v", g, err) + } + mv, err := c.ReadMove(time.Now().Add(time.Second)) + if err != nil || mv != "4" { + t.Fatalf("ReadMove = %q, %v", mv, err) + } + + if err := c.Send(map[string]string{"type": "ok"}); err != nil { + t.Fatalf("send: %v", err) + } + mu.Lock() + got := out.String() + mu.Unlock() + if !strings.HasSuffix(got, "\n") || !strings.Contains(got, `"type":"ok"`) { + t.Fatalf("Send wrote %q", got) + } + + _ = pw.Close() + if _, err := c.ReadMove(time.Now().Add(time.Second)); err != ErrClosed { + t.Fatalf("closed stream should give ErrClosed, got %v", err) + } +} + +func TestJSONLineConnTimeout(t *testing.T) { + pr, _ := io.Pipe() + c := NewJSONLineConn("p", pr, io.Discard) + if _, err := c.ReadMove(time.Now().Add(20 * time.Millisecond)); err != ErrTimeout { + t.Fatalf("want ErrTimeout, got %v", err) + } +} + +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(b []byte) (int, error) { return f(b) } diff --git a/internal/games/ttt.go b/internal/games/ttt.go new file mode 100644 index 0000000..a5be61a --- /dev/null +++ b/internal/games/ttt.go @@ -0,0 +1,98 @@ +package games + +import "strconv" + +// TTT is 3×3 tic-tac-toe. Player 0 is X, player 1 is O. Moves are cell indices +// "0".."8" in row-major order. +type TTT struct{} + +func (TTT) ID() string { return "ttt" } +func (TTT) Title() string { return "Tic-Tac-Toe" } +func (TTT) Start() State { + return tttState{cells: [9]int{-1, -1, -1, -1, -1, -1, -1, -1, -1}, toMove: 0} +} + +type tttState struct { + cells [9]int // -1 empty, else player 0/1 + toMove int +} + +var tttLines = [8][3]int{ + {0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows + {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // cols + {0, 4, 8}, {2, 4, 6}, // diagonals +} + +func (s tttState) ToMove() int { return s.toMove } + +func (s tttState) Legal() []string { + if over, _ := s.Terminal(); over { + return nil + } + var out []string + for i, c := range s.cells { + if c == -1 { + out = append(out, strconv.Itoa(i)) + } + } + return out +} + +func (s tttState) Apply(move string) (State, error) { + if over, _ := s.Terminal(); over { + return nil, ErrIllegalMove + } + i, err := strconv.Atoi(move) + if err != nil || i < 0 || i > 8 || s.cells[i] != -1 { + return nil, ErrIllegalMove + } + ns := s + ns.cells[i] = s.toMove + ns.toMove = 1 - s.toMove + return ns, nil +} + +func (s tttState) Terminal() (bool, int) { + for _, ln := range tttLines { + a := s.cells[ln[0]] + if a != -1 && a == s.cells[ln[1]] && a == s.cells[ln[2]] { + return true, a + } + } + for _, c := range s.cells { + if c == -1 { + return false, 0 // moves remain + } + } + return true, Draw +} + +var tttGlyph = map[int]string{-1: ".", 0: "X", 1: "O"} + +func (s tttState) Observe() map[string]any { + board := make([]string, 9) + for i, c := range s.cells { + board[i] = tttGlyph[c] + } + return map[string]any{ + "board": board, // row-major, "." / "X" / "O" + "toMove": s.toMove, + "legal": s.Legal(), + } +} + +func (s tttState) Render() string { + out := "" + for r := 0; r < 3; r++ { + for c := 0; c < 3; c++ { + out += " " + tttGlyph[s.cells[r*3+c]] + " " + if c < 2 { + out += "|" + } + } + if r < 2 { + out += "\n-----------\n" + } + } + return out +} diff --git a/internal/store/games_store.go b/internal/store/games_store.go new file mode 100644 index 0000000..74186c0 --- /dev/null +++ b/internal/store/games_store.go @@ -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 +} diff --git a/internal/store/games_store_test.go b/internal/store/games_store_test.go new file mode 100644 index 0000000..b1df826 --- /dev/null +++ b/internal/store/games_store_test.go @@ -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) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 40eea83..b11b776 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) { diff --git a/plugins/agentgames/agentgames.go b/plugins/agentgames/agentgames.go new file mode 100644 index 0000000..eca6d38 --- /dev/null +++ b/plugins/agentgames/agentgames.go @@ -0,0 +1,426 @@ +// Package agentgames is the in-BBS face of AgentGames (PRD §5.2): humans browse +// the agent-vs-agent ELO ladders, watch logged match replays, and play the +// games against a bot for practice. Rated agent matches happen over the game@ +// protocol (internal/games); this plugin is read-only for the ladder/replays +// and off-ladder for practice. +package agentgames + +import ( + "fmt" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/games" + "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/store" +) + +// Plugin is the AgentGames hub entry. +type Plugin struct{ reg *games.Registry } + +// New builds the plugin over the shared game catalog. +func New(reg *games.Registry) Plugin { return Plugin{reg: reg} } + +func (Plugin) ID() string { return "agentgames" } +func (Plugin) Title() string { return "AgentGames" } +func (Plugin) Description() string { return "Agent ladders, match replays, practice vs a bot" } +func (Plugin) RequiresAuth() bool { return false } + +func (p Plugin) New(user auth.User, ctx plugin.Context) tea.Model { + return &model{ + user: user, + st: ctx.Store, + reg: p.reg, + bot: games.GreedyBot{R: rand.New(rand.NewSource(time.Now().UnixNano()))}, + } +} + +type screen int + +const ( + scGames screen = iota // pick a game + scMenu // pick an action for the game + scLadder + scReplays + scReplay + scPlay +) + +var ( + title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) + warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + frame = lipgloss.NewStyle().Padding(1, 2) +) + +var actions = []string{"Ladder", "Replays", "Play vs bot"} + +type model struct { + user auth.User + st store.Store + reg *games.Registry + bot games.Bot + + screen screen + cursor int + note string + + game games.Game // selected + + ladder []store.RatingRow + matches []store.MatchRow + + // replay + replay store.MatchRow + ply int + + // practice + state games.State + human int + legal []string + over bool + winner int +} + +func (m *model) Init() tea.Cmd { return nil } + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + m.note = "" + switch key.String() { + case "ctrl+c": + return m, tea.Quit + } + switch m.screen { + case scGames: + return m.updateGames(key) + case scMenu: + return m.updateMenu(key) + case scLadder, scReplays: + return m.updateList(key) + case scReplay: + return m.updateReplay(key) + case scPlay: + return m.updatePlay(key) + } + return m, nil +} + +func (m *model) updateGames(k tea.KeyMsg) (tea.Model, tea.Cmd) { + all := m.reg.All() + switch k.String() { + case "q", "esc": + return m, plugin.Exit + case "up", "k": + m.up() + case "down", "j": + if m.cursor < len(all)-1 { + m.cursor++ + } + case "enter", "right", "l": + if len(all) == 0 { + return m, nil + } + m.game = all[m.cursor] + m.screen, m.cursor = scMenu, 0 + } + return m, nil +} + +func (m *model) updateMenu(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "q": + return m, plugin.Exit + case "esc", "left", "h": + m.screen, m.cursor = scGames, 0 + case "up", "k": + m.up() + case "down", "j": + if m.cursor < len(actions)-1 { + m.cursor++ + } + case "enter", "right", "l": + switch m.cursor { + case 0: + m.ladder, _ = m.st.TopRatings(m.game.ID(), 25) + m.screen, m.cursor = scLadder, 0 + case 1: + m.matches, _ = m.st.RecentMatches(m.game.ID(), 25) + m.screen, m.cursor = scReplays, 0 + case 2: + m.startPractice() + } + } + return m, nil +} + +func (m *model) updateList(k tea.KeyMsg) (tea.Model, tea.Cmd) { + n := len(m.ladder) + if m.screen == scReplays { + n = len(m.matches) + } + switch k.String() { + case "q": + return m, plugin.Exit + case "esc", "left", "h": + m.screen, m.cursor = scMenu, 0 + case "up", "k": + m.up() + case "down", "j": + if m.cursor < n-1 { + m.cursor++ + } + case "enter", "right", "l": + if m.screen == scReplays && m.cursor < len(m.matches) { + m.replay = m.matches[m.cursor] + m.ply = 0 + m.screen = scReplay + } + } + return m, nil +} + +func (m *model) updateReplay(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "q": + return m, plugin.Exit + case "esc", "left", "h": + m.screen = scReplays + case "right", "l", " ": + if m.ply < len(m.replay.Moves) { + m.ply++ + } + case "up", "k": + if m.ply > 0 { + m.ply-- + } + case "home", "g": + m.ply = 0 + case "end", "G": + m.ply = len(m.replay.Moves) + } + return m, nil +} + +func (m *model) updatePlay(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "q": + return m, plugin.Exit + case "esc", "left", "h": + m.screen, m.cursor = scMenu, 0 + case "r": + m.startPractice() + case "up", "k": + m.up() + case "down", "j": + if m.cursor < len(m.legal)-1 { + m.cursor++ + } + case "enter", " ": + m.applyHumanMove() + } + return m, nil +} + +func (m *model) up() { + if m.cursor > 0 { + m.cursor-- + } +} + +func (m *model) startPractice() { + m.state = m.game.Start() + m.human = 0 // human is X and moves first + m.over, m.winner = false, 0 + m.refreshLegal() + m.screen, m.cursor = scPlay, 0 +} + +func (m *model) refreshLegal() { + if m.over, m.winner = m.state.Terminal(); m.over { + m.legal = nil + return + } + m.legal = m.state.Legal() + if m.cursor >= len(m.legal) { + m.cursor = 0 + } +} + +func (m *model) applyHumanMove() { + if m.over || m.state.ToMove() != m.human || m.cursor >= len(m.legal) { + return + } + ns, err := m.state.Apply(m.legal[m.cursor]) + if err != nil { + m.note = warn.Render("illegal move") + return + } + m.state = ns + // Bot replies until it is the human's turn again or the game ends. + for { + if over, _ := m.state.Terminal(); over { + break + } + if m.state.ToMove() == m.human { + break + } + mv := m.bot.Move(m.state) + if ns, err := m.state.Apply(mv); err == nil { + m.state = ns + } else { + break + } + } + m.cursor = 0 + m.refreshLegal() +} + +func (m *model) View() string { + var body, help string + switch m.screen { + case scGames: + body, help = m.viewGames() + case scMenu: + body, help = m.viewMenu() + case scLadder: + body, help = m.viewLadder() + case scReplays: + body, help = m.viewReplays() + case scReplay: + body, help = m.viewReplay() + case scPlay: + body, help = m.viewPlay() + } + out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help) + if m.note != "" { + out += "\n" + m.note + } + return frame.Render(out) +} + +func (m *model) viewGames() (string, string) { + var b strings.Builder + b.WriteString(head.Render("Pick a game") + "\n\n") + for i, g := range m.reg.All() { + b.WriteString(m.row(i) + g.Title() + " " + dim.Render("("+g.ID()+")") + "\n") + } + return b.String(), "↑/↓ move · enter select · q quit" +} + +func (m *model) viewMenu() (string, string) { + var b strings.Builder + b.WriteString(head.Render(m.game.Title()) + "\n\n") + for i, a := range actions { + b.WriteString(m.row(i) + a + "\n") + } + return b.String(), "↑/↓ move · enter select · esc back · q quit" +} + +func (m *model) viewLadder() (string, string) { + var b strings.Builder + b.WriteString(head.Render(m.game.Title()+" — ladder") + "\n\n") + if len(m.ladder) == 0 { + b.WriteString(dim.Render(" no rated matches yet — agents play via ssh game@\n")) + } + for i, r := range m.ladder { + fmt.Fprintf(&b, " %2d. %-20s %4.0f %s\n", i+1, r.User, r.Rating, dim.Render(fmt.Sprintf("%d games", r.Played))) + } + return b.String(), "esc back · q quit" +} + +func (m *model) viewReplays() (string, string) { + var b strings.Builder + b.WriteString(head.Render(m.game.Title()+" — replays") + "\n\n") + if len(m.matches) == 0 { + b.WriteString(dim.Render(" no matches recorded yet\n")) + } + for i, mt := range m.matches { + fmt.Fprintf(&b, "%s%s vs %s %s\n", m.row(i), mt.P0, mt.P1, dim.Render(outcomeLabel(mt))) + } + return b.String(), "↑/↓ move · enter watch · esc back · q quit" +} + +func (m *model) viewReplay() (string, string) { + g, ok := m.reg.Get(m.replay.Game) + if !ok { + return warn.Render("unknown game " + m.replay.Game), "esc back" + } + s := g.Start() + for i := 0; i < m.ply && i < len(m.replay.Moves); i++ { + if ns, err := s.Apply(m.replay.Moves[i].Move); err == nil { + s = ns + } + } + var b strings.Builder + fmt.Fprintf(&b, "%s vs %s %s\n\n", m.replay.P0, m.replay.P1, dim.Render(outcomeLabel(m.replay))) + b.WriteString(s.Render() + "\n\n") + fmt.Fprintf(&b, "%s\n", dim.Render(fmt.Sprintf("ply %d/%d", m.ply, len(m.replay.Moves)))) + if m.ply >= len(m.replay.Moves) { + b.WriteString(resultLine(m.replay.Winner, m.replay.P0, m.replay.P1, m.replay.Reason)) + } + return b.String(), "→/space next · ↑ prev · g start · G end · esc back" +} + +func (m *model) viewPlay() (string, string) { + var b strings.Builder + b.WriteString(head.Render(m.game.Title()+" — practice (you are X)") + "\n\n") + b.WriteString(m.state.Render() + "\n\n") + if m.over { + b.WriteString(resultLine(m.winner, "you", m.bot.Name(), "")) + return b.String(), "r play again · esc back · q quit" + } + if m.state.ToMove() == m.human { + b.WriteString("your move:\n") + for i, mv := range m.legal { + b.WriteString(m.row(i) + mv + "\n") + } + } else { + b.WriteString(dim.Render("(bot thinking…)\n")) + } + return b.String(), "↑/↓ pick · enter play · r restart · esc back" +} + +func (m *model) row(i int) string { + if i == m.cursor { + return cursor.Render("> ") + } + return " " +} + +func outcomeLabel(mt store.MatchRow) string { + switch mt.Winner { + case games.Draw: + return "draw" + case 0: + return mt.P0 + " won" + default: + return mt.P1 + " won" + } +} + +func resultLine(winner int, p0, p1, reason string) string { + var s string + switch winner { + case games.Draw: + s = "Draw." + case 0: + s = p0 + " wins." + default: + s = p1 + " wins." + } + if reason != "" { + s += " (" + reason + ")" + } + return cursor.Render(s) +}