Skip to content

Commit cbc9069

Browse files
ralyodioclaude
andauthored
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>
1 parent 41a8ff2 commit cbc9069

22 files changed

Lines changed: 2297 additions & 3 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ plugins around one shared account system; the full product plan is in
3232
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) ||
3333
| `agent@` chat (configurable agent backend) + finger ||
3434
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) ||
35-
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | |
35+
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | |
3636
| M4 — Files (cl1.tech SFTP workspaces) ||
3737
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) ||
3838

@@ -61,6 +61,9 @@ Configuration (env):
6161
| `COINPAY_API_KEY` | unset | CoinPay API key (Premium payments) |
6262
| `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id |
6363
| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | forwardemail.net key (Premium email) |
64+
| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | AgentGames per-move deadline (s) — see [docs/agentgames.md](docs/agentgames.md) |
65+
| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (s) |
66+
| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) |
6467

6568
Ops:
6669

cmd/agentbbs/games.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"time"
9+
10+
"github.com/charmbracelet/ssh"
11+
"github.com/charmbracelet/wish"
12+
13+
"github.com/profullstack/agentbbs/internal/auth"
14+
"github.com/profullstack/agentbbs/internal/games"
15+
"github.com/profullstack/agentbbs/internal/store"
16+
)
17+
18+
// handleGame is the AgentGames protocol route (PRD §5.2). An agent connects as
19+
//
20+
// ssh game@host ttt # game id as the SSH command, or
21+
// ssh game@host # then send {"type":"join","game":"ttt"}
22+
//
23+
// and then speaks line-delimited JSON (see internal/games/protocol.go). It is
24+
// agent-vs-agent only and rated, so a registered account (SSH key) is required.
25+
// No PTY: this is a data stream, not a TUI.
26+
func (a *app) handleGame(s ssh.Session) {
27+
fp := auth.Fingerprint(s.PublicKey())
28+
if fp == "" {
29+
wish.Println(s, "game@ needs your registered SSH key. New here? ssh join@"+a.host)
30+
_ = s.Exit(1)
31+
return
32+
}
33+
u, found, err := a.st.UserByFingerprint(fp)
34+
if err != nil || !found {
35+
wish.Println(s, "key not registered — run: ssh join@"+a.host)
36+
_ = s.Exit(1)
37+
return
38+
}
39+
if u.Banned {
40+
wish.Println(s, "this account is suspended.")
41+
_ = s.Exit(1)
42+
return
43+
}
44+
45+
conn := games.NewJSONLineConn(u.Name, s, s)
46+
47+
// Game id from the SSH command, else from the join handshake.
48+
gameID := ""
49+
if args := s.Command(); len(args) > 0 {
50+
gameID = strings.ToLower(args[0])
51+
} else {
52+
gameID, err = conn.ReadJoin(time.Now().Add(30 * time.Second))
53+
if err != nil {
54+
_ = conn.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"<id>\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
55+
_ = s.Exit(1)
56+
return
57+
}
58+
}
59+
if _, ok := a.gamesReg.Get(gameID); !ok {
60+
_ = conn.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
61+
_ = s.Exit(1)
62+
return
63+
}
64+
65+
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "game")
66+
defer func() { _ = a.st.EndSession(sessID) }()
67+
68+
_ = conn.Send(map[string]any{"type": "queued", "game": gameID})
69+
switch err := a.mm.Play(s.Context(), gameID, conn); {
70+
case errors.Is(err, games.ErrNoOpponent):
71+
_ = conn.Send(errEnvelope("no opponent found — try again later"))
72+
_ = s.Exit(1)
73+
case err != nil && !errors.Is(err, games.ErrUnknownGame):
74+
// Unknown-game is already handled above; anything else is a wait abort
75+
// (e.g. the agent disconnected) and needs no message.
76+
_ = s.Exit(1)
77+
default:
78+
_ = s.Exit(0)
79+
}
80+
}
81+
82+
func errEnvelope(msg string) map[string]any { return map[string]any{"type": "error", "error": msg} }
83+
84+
// mintToken is the ops side of WebSocket auth: `agentbbs mint-token <user>`
85+
// issues a bearer token for an existing account to use on wss://host/play.
86+
func mintToken(st store.Store, args []string) {
87+
if len(args) < 1 {
88+
fmt.Fprintln(os.Stderr, "usage: agentbbs mint-token <username>")
89+
os.Exit(2)
90+
}
91+
name := strings.ToLower(args[0])
92+
if _, found, err := st.UserByName(name); err != nil || !found {
93+
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
94+
os.Exit(1)
95+
}
96+
tok, err := st.MintAPIToken(name)
97+
if err != nil {
98+
fmt.Fprintln(os.Stderr, "mint:", err)
99+
os.Exit(1)
100+
}
101+
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)
102+
}

cmd/agentbbs/gamesws.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
"github.com/charmbracelet/log"
13+
"github.com/gorilla/websocket"
14+
15+
"github.com/profullstack/agentbbs/internal/games"
16+
)
17+
18+
// serveGameWS exposes the AgentGames protocol over WebSocket at /play, the
19+
// browser/SDK-friendly twin of the game@ SSH route. It speaks the exact same
20+
// JSON messages (see internal/games/protocol.go) and shares the matchmaker, so
21+
// an SSH agent and a WebSocket agent can be paired against each other.
22+
//
23+
// Auth is a bearer API token (mint with `agentbbs mint-token <user>`), passed
24+
// as `Authorization: Bearer <token>` or `?token=`. The listener is loopback;
25+
// Caddy terminates TLS and proxies wss://host/play to it.
26+
func (a *app) serveGameWS(addr string) {
27+
mux := http.NewServeMux()
28+
mux.HandleFunc("/play", a.handleGameWS)
29+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
30+
log.Info("agentgames ws listening", "addr", addr)
31+
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
32+
if err := srv.ListenAndServe(); err != nil {
33+
log.Error("game ws server", "err", err)
34+
}
35+
}
36+
37+
var wsUpgrader = websocket.Upgrader{
38+
// The listener is loopback behind Caddy; origin is enforced at the edge.
39+
CheckOrigin: func(*http.Request) bool { return true },
40+
}
41+
42+
func (a *app) handleGameWS(w http.ResponseWriter, r *http.Request) {
43+
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
44+
if token == "" {
45+
token = r.URL.Query().Get("token")
46+
}
47+
name, ok, err := a.st.UserByToken(token)
48+
if err != nil || !ok {
49+
http.Error(w, "invalid or missing token (mint: agentbbs mint-token <user>)", http.StatusUnauthorized)
50+
return
51+
}
52+
if u, found, _ := a.st.UserByName(name); !found || u.Banned {
53+
http.Error(w, "account unavailable", http.StatusForbidden)
54+
return
55+
}
56+
57+
c, err := wsUpgrader.Upgrade(w, r, nil)
58+
if err != nil {
59+
return // Upgrade already wrote the error
60+
}
61+
defer c.Close()
62+
p := &wsPlayer{name: name, c: c}
63+
64+
gameID := strings.ToLower(r.URL.Query().Get("game"))
65+
if gameID == "" {
66+
if gameID, err = p.ReadJoin(time.Now().Add(30 * time.Second)); err != nil {
67+
_ = p.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"<id>\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
68+
return
69+
}
70+
}
71+
if _, known := a.gamesReg.Get(gameID); !known {
72+
_ = p.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
73+
return
74+
}
75+
76+
sessID, _ := a.st.RecordSession(0, name, wsRemoteIP(r), "game-ws")
77+
defer func() { _ = a.st.EndSession(sessID) }()
78+
79+
_ = p.Send(map[string]any{"type": "queued", "game": gameID})
80+
if err := a.mm.Play(context.Background(), gameID, p); errors.Is(err, games.ErrNoOpponent) {
81+
_ = p.Send(errEnvelope("no opponent found — try again later"))
82+
}
83+
}
84+
85+
// wsPlayer adapts a gorilla WebSocket connection to games.PlayerIO. The match
86+
// runs in a single goroutine, so reads and writes are never concurrent.
87+
type wsPlayer struct {
88+
name string
89+
c *websocket.Conn
90+
}
91+
92+
func (p *wsPlayer) Name() string { return p.name }
93+
func (p *wsPlayer) Send(v any) error { return p.c.WriteJSON(v) }
94+
95+
type wsInbound struct {
96+
Type string `json:"type"`
97+
Move string `json:"move"`
98+
Game string `json:"game"`
99+
}
100+
101+
func (p *wsPlayer) read(deadline time.Time) (wsInbound, error) {
102+
_ = p.c.SetReadDeadline(deadline)
103+
_, data, err := p.c.ReadMessage()
104+
if err != nil {
105+
var ne net.Error
106+
if errors.As(err, &ne) && ne.Timeout() {
107+
return wsInbound{}, games.ErrTimeout
108+
}
109+
return wsInbound{}, games.ErrClosed
110+
}
111+
var m wsInbound
112+
_ = json.Unmarshal(data, &m)
113+
return m, nil
114+
}
115+
116+
func (p *wsPlayer) ReadJoin(deadline time.Time) (string, error) {
117+
for {
118+
m, err := p.read(deadline)
119+
if err != nil {
120+
return "", err
121+
}
122+
if m.Type == "join" && m.Game != "" {
123+
return m.Game, nil
124+
}
125+
}
126+
}
127+
128+
func (p *wsPlayer) ReadMove(deadline time.Time) (string, error) {
129+
for {
130+
m, err := p.read(deadline)
131+
if err != nil {
132+
return "", err
133+
}
134+
if m.Type == "move" && m.Move != "" {
135+
return m.Move, nil
136+
}
137+
}
138+
}
139+
140+
func wsRemoteIP(r *http.Request) string {
141+
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
142+
return host
143+
}
144+
return r.RemoteAddr
145+
}

cmd/agentbbs/main.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
// ssh pod@host your personal Linux pod — free for verified members
1010
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
1111
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
12+
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
13+
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
1214
//
1315
// Subcommands:
1416
//
1517
// agentbbs serve (default)
1618
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
1719
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
1820
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
21+
// agentbbs mint-token NAME issue a WebSocket API token for NAME
1922
package main
2023

2124
import (
@@ -48,6 +51,7 @@ import (
4851
"github.com/profullstack/agentbbs/internal/calls"
4952
"github.com/profullstack/agentbbs/internal/chat"
5053
"github.com/profullstack/agentbbs/internal/forwardemail"
54+
"github.com/profullstack/agentbbs/internal/games"
5155
"github.com/profullstack/agentbbs/internal/hub"
5256
"github.com/profullstack/agentbbs/internal/mail"
5357
"github.com/profullstack/agentbbs/internal/payments"
@@ -57,6 +61,7 @@ import (
5761
"github.com/profullstack/agentbbs/internal/sites"
5862
"github.com/profullstack/agentbbs/internal/store"
5963
"github.com/profullstack/agentbbs/plugins/about"
64+
"github.com/profullstack/agentbbs/plugins/agentgames"
6065
"github.com/profullstack/agentbbs/plugins/arcade"
6166
)
6267

@@ -67,6 +72,16 @@ func env(k, def string) string {
6772
return def
6873
}
6974

75+
// envInt reads an integer environment variable, falling back to def.
76+
func envInt(k string, def int) int {
77+
if v := os.Getenv(k); v != "" {
78+
if n, err := strconv.Atoi(v); err == nil {
79+
return n
80+
}
81+
}
82+
return def
83+
}
84+
7085
type app struct {
7186
st store.Store
7287
pods *pods.Manager // nil when no container engine on host
@@ -76,6 +91,8 @@ type app struct {
7691
mail mail.Config
7792
fe forwardemail.Config // premium @bbs email provisioning
7893
live *liveReg // in-memory live-session registry (admin console)
94+
gamesReg *games.Registry // AgentGames catalog
95+
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
7996
dataDir string
8097
assets string
8198
host string // public hostname used in user-facing messages
@@ -99,6 +116,10 @@ func main() {
99116
domainCmd(st, dataDir, os.Args[1], os.Args[2:])
100117
return
101118
}
119+
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
120+
mintToken(st, os.Args[2:])
121+
return
122+
}
102123

103124
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
104125
fe := forwardemail.ConfigFromEnv()
@@ -115,7 +136,11 @@ func main() {
115136
assets: env("AGENTBBS_ASSETS", "./assets"),
116137
host: host,
117138
}
118-
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
139+
a.gamesReg = games.Catalog()
140+
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
141+
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
142+
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
143+
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}
119144

120145
// Custom domains: maintain the symlink farm Caddy serves and answer its
121146
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
@@ -158,6 +183,10 @@ func main() {
158183
}
159184
}()
160185

186+
// AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback;
187+
// Caddy proxies wss://host/play to it.
188+
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))
189+
161190
addr := env("AGENTBBS_ADDR", ":2222")
162191
srv, err := wish.NewServer(
163192
wish.WithAddress(addr),
@@ -213,6 +242,8 @@ func (a *app) router() wish.Middleware {
213242
a.handleDomain(s)
214243
case auth.IsAdminName(user):
215244
adminHandler(s)
245+
case auth.IsGameName(user):
246+
a.handleGame(s)
216247
case auth.IsPodName(user):
217248
a.handlePod(s)
218249
case isVideo:

0 commit comments

Comments
 (0)