mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 23:07:28 +00:00
M3: AgentGames — agent-vs-agent games, ELO ladder, replays (#7)
A Gym-style game engine (PRD §5.2) with two transports sharing one
matchmaker, so an SSH agent and a WebSocket agent can be paired together.
Engine (internal/games):
- Game/State contract (immutable positions); registry/catalog.
- Phase-1 games: Tic-Tac-Toe (ttt) and Connect 4 (c4).
- ELO (K=32, start 1500), a generic win/block/random GreedyBot.
- Transport-agnostic NDJSON protocol + match driver: hello → state →
move → result. We run no agent code — illegal move / per-move timeout /
disconnect all forfeit (strict validation in place of a sandbox).
- Matchmaker: per-game queue, bounded queue-wait; never abandons a match
that started racing the wait timeout.
Transports:
- SSH route game@ (ssh game@host ttt | join message), registered key,
no PTY.
- WebSocket /play (wss), bearer API token (agentbbs mint-token <user>);
loopback behind Caddy.
Store: game_ratings (ELO ladder) + game_matches (full move log for replay)
+ api_tokens; Rating/SaveMatch satisfy games.Store; TopRatings/RecentMatches/
MatchByID/MintAPIToken/UserByToken. Banned accounts blocked.
Hub: plugins/agentgames — browse ladders, watch move-by-move replays, and
practice vs the bot (off the rated ladder).
Tests: engine (win/draw/legality), ELO, bot, full match via matchmaker with
replay, transport (deadline/closed), store round-trips. Verified live over
SSH (agent-vs-agent), WebSocket↔SSH cross-transport, forfeit-on-illegal-move,
and the hub ladder/replay views. Docs in docs/agentgames.md (the canonical
protocol spec, to mirror to logicsrc.com); README M3 → done.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
41a8ff240d
commit
cbc9069964
22 changed files with 2297 additions and 3 deletions
|
|
@ -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
102
cmd/agentbbs/games.go
Normal 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
145
cmd/agentbbs/gamesws.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
@ -213,6 +242,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
121
docs/agentgames.md
Normal 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
2
go.mod
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
71
internal/games/bot.go
Normal 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
144
internal/games/connect4.go
Normal 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
44
internal/games/elo.go
Normal 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
86
internal/games/games.go
Normal 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
|
||||||
|
}
|
||||||
126
internal/games/games_test.go
Normal file
126
internal/games/games_test.go
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
158
internal/games/matchmaker.go
Normal file
158
internal/games/matchmaker.go
Normal 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],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/games/matchmaker_test.go
Normal file
135
internal/games/matchmaker_test.go
Normal 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
120
internal/games/protocol.go
Normal 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
100
internal/games/transport.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/games/transport_test.go
Normal file
63
internal/games/transport_test.go
Normal 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
98
internal/games/ttt.go
Normal 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
|
||||||
|
}
|
||||||
159
internal/store/games_store.go
Normal file
159
internal/store/games_store.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/profullstack/agentbbs/internal/games"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *sqliteStore) Rating(user, game string) (float64, error) {
|
||||||
|
var r float64
|
||||||
|
err := s.db.QueryRow(`SELECT rating FROM game_ratings WHERE username = ? AND game = ?`, user, game).Scan(&r)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return games.DefaultRating, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return games.DefaultRating, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) SaveMatch(fm games.FinishedMatch) error {
|
||||||
|
moves, err := json.Marshal(fm.Moves)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO game_matches
|
||||||
|
(game, p0, p1, winner, reason, moves, r0_before, r1_before, r0_after, r1_after, started_at, ended_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
fm.Game, fm.Players[0], fm.Players[1], fm.Winner, fm.Reason, string(moves),
|
||||||
|
fm.RatingBefore[0], fm.RatingBefore[1], fm.RatingAfter[0], fm.RatingAfter[1],
|
||||||
|
fm.StartedAt.UTC().Format(time.RFC3339), fm.EndedAt.UTC().Format(time.RFC3339),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := upsertRating(tx, fm.Players[i], fm.Game, fm.RatingAfter[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertRating(tx *sql.Tx, user, game string, rating float64) error {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO game_ratings (username, game, rating, played) VALUES (?,?,?,1)
|
||||||
|
ON CONFLICT(username, game) DO UPDATE SET
|
||||||
|
rating = excluded.rating,
|
||||||
|
played = game_ratings.played + 1,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, user, game, rating)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) TopRatings(game string, n int) ([]RatingRow, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 20
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT username, rating, played FROM game_ratings
|
||||||
|
WHERE game = ? ORDER BY rating DESC, played DESC LIMIT ?`, game, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []RatingRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r RatingRow
|
||||||
|
if err := rows.Scan(&r.User, &r.Rating, &r.Played); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCols is the column list (in struct order) for match SELECTs.
|
||||||
|
const matchCols = `id, game, p0, p1, winner, reason, moves, r0_after, r1_after, started_at, ended_at`
|
||||||
|
|
||||||
|
func scanMatch(sc interface{ Scan(...any) error }) (MatchRow, error) {
|
||||||
|
var m MatchRow
|
||||||
|
var movesJSON, started, ended string
|
||||||
|
if err := sc.Scan(&m.ID, &m.Game, &m.P0, &m.P1, &m.Winner, &m.Reason,
|
||||||
|
&movesJSON, &m.RatingAfter[0], &m.RatingAfter[1], &started, &ended); err != nil {
|
||||||
|
return MatchRow{}, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(movesJSON), &m.Moves)
|
||||||
|
m.StartedAt, _ = time.Parse(time.RFC3339, started)
|
||||||
|
m.EndedAt, _ = time.Parse(time.RFC3339, ended)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) RecentMatches(game string, n int) ([]MatchRow, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 20
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`SELECT `+matchCols+` FROM game_matches WHERE game = ? ORDER BY id DESC LIMIT ?`, game, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []MatchRow
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := scanMatch(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) MatchByID(id int64) (MatchRow, bool, error) {
|
||||||
|
m, err := scanMatch(s.db.QueryRow(`SELECT `+matchCols+` FROM game_matches WHERE id = ?`, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return MatchRow{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return MatchRow{}, false, err
|
||||||
|
}
|
||||||
|
return m, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) MintAPIToken(username string) (string, error) {
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(b[:])
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO api_tokens (token, username) VALUES (?,?)`, token, username); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) UserByToken(token string) (string, bool, error) {
|
||||||
|
if token == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
var username string
|
||||||
|
err := s.db.QueryRow(`SELECT username FROM api_tokens WHERE token = ?`, token).Scan(&username)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return username, true, nil
|
||||||
|
}
|
||||||
79
internal/store/games_store_test.go
Normal file
79
internal/store/games_store_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/profullstack/agentbbs/internal/games"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGameRatingsAndMatches(t *testing.T) {
|
||||||
|
st := openTest(t)
|
||||||
|
|
||||||
|
// Unrated players start at the default.
|
||||||
|
if r, _ := st.Rating("agent-a", "ttt"); r != games.DefaultRating {
|
||||||
|
t.Fatalf("default rating = %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm := games.FinishedMatch{
|
||||||
|
Game: "ttt",
|
||||||
|
Players: [2]string{"agent-a", "agent-b"},
|
||||||
|
Winner: 0,
|
||||||
|
Moves: []games.Move{{Player: 0, Move: "4"}, {Player: 1, Move: "0"}, {Player: 0, Move: "1"}},
|
||||||
|
RatingBefore: [2]float64{1500, 1500},
|
||||||
|
RatingAfter: [2]float64{1516, 1484},
|
||||||
|
StartedAt: time.Now().Add(-time.Minute),
|
||||||
|
EndedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := st.SaveMatch(fm); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratings updated and games-played bumped.
|
||||||
|
if r, _ := st.Rating("agent-a", "ttt"); r != 1516 {
|
||||||
|
t.Fatalf("agent-a rating = %v, want 1516", r)
|
||||||
|
}
|
||||||
|
if r, _ := st.Rating("agent-b", "ttt"); r != 1484 {
|
||||||
|
t.Fatalf("agent-b rating = %v, want 1484", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := st.TopRatings("ttt", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("top: %v", err)
|
||||||
|
}
|
||||||
|
if len(board) != 2 || board[0].User != "agent-a" || board[0].Played != 1 {
|
||||||
|
t.Fatalf("ladder wrong: %+v", board)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := st.RecentMatches("ttt", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("want 1 match, got %d", len(matches))
|
||||||
|
}
|
||||||
|
id := matches[0].ID
|
||||||
|
|
||||||
|
got, ok, err := st.MatchByID(id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("match by id: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if len(got.Moves) != 3 || got.Moves[0].Move != "4" {
|
||||||
|
t.Fatalf("moves not round-tripped: %+v", got.Moves)
|
||||||
|
}
|
||||||
|
if got.Winner != 0 || got.P0 != "agent-a" {
|
||||||
|
t.Fatalf("match meta wrong: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second match bumps games-played and re-rates.
|
||||||
|
fm2 := fm
|
||||||
|
fm2.RatingBefore = [2]float64{1516, 1484}
|
||||||
|
fm2.RatingAfter = [2]float64{1530, 1470}
|
||||||
|
if err := st.SaveMatch(fm2); err != nil {
|
||||||
|
t.Fatalf("save2: %v", err)
|
||||||
|
}
|
||||||
|
board, _ = st.TopRatings("ttt", 10)
|
||||||
|
if board[0].Played != 2 {
|
||||||
|
t.Fatalf("played should be 2, got %d", board[0].Played)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"time"
|
"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) {
|
||||||
|
|
|
||||||
426
plugins/agentgames/agentgames.go
Normal file
426
plugins/agentgames/agentgames.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue