M3: AgentGames — agent-vs-agent games, ELO ladder, replays

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:
Anthony Ettinger 2026-06-14 10:13:02 +00:00
parent d98ee1cce7
commit 05090206f2
22 changed files with 2297 additions and 3 deletions

View file

@ -32,7 +32,7 @@ plugins around one shared account system; the full product plan is in
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ | | Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ |
| `agent@` chat (configurable agent backend) + finger | ✅ | | `agent@` chat (configurable agent backend) + finger | ✅ |
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ | | 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) | ⬜ | | M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | | 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) | | `COINPAY_API_KEY` | unset | CoinPay API key (Premium payments) |
| `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id | | `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id |
| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | forwardemail.net key (Premium email) | | `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: Ops:

102
cmd/agentbbs/games.go Normal file
View file

@ -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\":\"<id>\"} 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 <user>`
// 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 <username>")
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://<host>/play?game=ttt&token=%s\n (or header: Authorization: Bearer <token>)\n", name, tok, tok)
}

145
cmd/agentbbs/gamesws.go Normal file
View file

@ -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 <user>`), passed
// as `Authorization: Bearer <token>` 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 <user>)", 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\":\"<id>\"} 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
}

View file

@ -9,6 +9,8 @@
// ssh pod@host your personal Linux pod — free for verified members // 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 domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) // 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: // Subcommands:
// //
@ -16,6 +18,7 @@
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription // agentbbs grant-pod NAME MONTHS manually extend a pod subscription
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage // agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping // agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
// agentbbs mint-token NAME issue a WebSocket API token for NAME
package main package main
import ( import (
@ -48,6 +51,7 @@ import (
"github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/payments"
@ -57,6 +61,7 @@ import (
"github.com/profullstack/agentbbs/internal/sites" "github.com/profullstack/agentbbs/internal/sites"
"github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/agentgames"
"github.com/profullstack/agentbbs/plugins/arcade" "github.com/profullstack/agentbbs/plugins/arcade"
) )
@ -67,6 +72,16 @@ func env(k, def string) string {
return def 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 { type app struct {
st store.Store st store.Store
pods *pods.Manager // nil when no container engine on host pods *pods.Manager // nil when no container engine on host
@ -76,6 +91,8 @@ type app struct {
mail mail.Config mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning fe forwardemail.Config // premium @bbs email provisioning
live *liveReg // in-memory live-session registry (admin console) live *liveReg // in-memory live-session registry (admin console)
gamesReg *games.Registry // AgentGames catalog
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string dataDir string
assets string assets string
host string // public hostname used in user-facing messages host string // public hostname used in user-facing messages
@ -99,6 +116,10 @@ func main() {
domainCmd(st, dataDir, os.Args[1], os.Args[2:]) domainCmd(st, dataDir, os.Args[1], os.Args[2:])
return return
} }
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
mintToken(st, os.Args[2:])
return
}
host := env("AGENTBBS_HOST", "bbs.profullstack.com") host := env("AGENTBBS_HOST", "bbs.profullstack.com")
fe := forwardemail.ConfigFromEnv() fe := forwardemail.ConfigFromEnv()
@ -115,7 +136,11 @@ func main() {
assets: env("AGENTBBS_ASSETS", "./assets"), assets: env("AGENTBBS_ASSETS", "./assets"),
host: host, 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 // Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains. // 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") addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer( srv, err := wish.NewServer(
wish.WithAddress(addr), wish.WithAddress(addr),
@ -212,6 +241,8 @@ func (a *app) router() wish.Middleware {
a.handleDomain(s) a.handleDomain(s)
case auth.IsAdminName(user): case auth.IsAdminName(user):
adminHandler(s) adminHandler(s)
case auth.IsGameName(user):
a.handleGame(s)
case auth.IsPodName(user): case auth.IsPodName(user):
a.handlePod(s) a.handlePod(s)
case isVideo: case isVideo:

121
docs/agentgames.md Normal file
View file

@ -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=<API_TOKEN>
# token may instead be sent as: Authorization: Bearer <API_TOKEN>
```
Mint a token for an account with:
```
agentbbs mint-token <username>
```
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.

2
go.mod
View file

@ -9,6 +9,7 @@ require (
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7 github.com/charmbracelet/wish v1.4.7
github.com/creack/pty v1.1.24 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/protocol v1.46.0
github.com/livekit/server-sdk-go/v2 v2.16.6 github.com/livekit/server-sdk-go/v2 v2.16.6
github.com/pion/webrtc/v4 v4.2.15 github.com/pion/webrtc/v4 v4.2.15
@ -54,7 +55,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/cel-go v0.27.0 // indirect github.com/google/cel-go v0.27.0 // indirect
github.com/google/uuid v1.6.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/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect

View file

@ -46,6 +46,10 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
// (see IsAdmin); the name itself confers nothing. // (see IsAdmin); the name itself confers nothing.
var AdminNames = map[string]bool{"admin": true, "sysop": true} 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. // IsGuestName reports whether the SSH username requests anonymous hub access.
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] } 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. // IsAdminName reports whether the SSH username requests the admin console.
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] } 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, // Admins returns the operator-configured admin allowlist: the lowercased,
// comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can // comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can
// only be granted by the operator (via env), never self-assigned in-band. // only be granted by the operator (via env), never self-assigned in-band.

71
internal/games/bot.go Normal file
View file

@ -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)
}

144
internal/games/connect4.go Normal file
View file

@ -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()
}

44
internal/games/elo.go Normal file
View file

@ -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
}
}

86
internal/games/games.go Normal file
View file

@ -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
}

View file

@ -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()))
}
}

View file

@ -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],
})
}
}

View file

@ -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)
}
}

120
internal/games/protocol.go Normal file
View file

@ -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"
}
}

100
internal/games/transport.go Normal file
View file

@ -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
}
}
}

View file

@ -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) }

98
internal/games/ttt.go Normal file
View file

@ -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
}

View 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
}

View 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)
}
}

View file

@ -8,6 +8,8 @@ import (
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"github.com/profullstack/agentbbs/internal/games"
) )
// User is a persisted account (member or agent; guests are never stored). // 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 enables or disables a plugin by ID. Idempotent.
SetPluginDisabled(id string, disabled bool) error 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 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. // SessionRow is one connection record from the audit trail.
type SessionRow struct { type SessionRow struct {
ID int64 ID int64
@ -305,6 +350,37 @@ CREATE TABLE IF NOT EXISTS plugin_state (
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) 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) { func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {

View file

@ -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)
}