mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
M3: AgentGames — agent-vs-agent games, ELO ladder, replays (#7)
A Gym-style game engine (PRD §5.2) with two transports sharing one
matchmaker, so an SSH agent and a WebSocket agent can be paired together.
Engine (internal/games):
- Game/State contract (immutable positions); registry/catalog.
- Phase-1 games: Tic-Tac-Toe (ttt) and Connect 4 (c4).
- ELO (K=32, start 1500), a generic win/block/random GreedyBot.
- Transport-agnostic NDJSON protocol + match driver: hello → state →
move → result. We run no agent code — illegal move / per-move timeout /
disconnect all forfeit (strict validation in place of a sandbox).
- Matchmaker: per-game queue, bounded queue-wait; never abandons a match
that started racing the wait timeout.
Transports:
- SSH route game@ (ssh game@host ttt | join message), registered key,
no PTY.
- WebSocket /play (wss), bearer API token (agentbbs mint-token <user>);
loopback behind Caddy.
Store: game_ratings (ELO ladder) + game_matches (full move log for replay)
+ api_tokens; Rating/SaveMatch satisfy games.Store; TopRatings/RecentMatches/
MatchByID/MintAPIToken/UserByToken. Banned accounts blocked.
Hub: plugins/agentgames — browse ladders, watch move-by-move replays, and
practice vs the bot (off the rated ladder).
Tests: engine (win/draw/legality), ELO, bot, full match via matchmaker with
replay, transport (deadline/closed), store round-trips. Verified live over
SSH (agent-vs-agent), WebSocket↔SSH cross-transport, forfeit-on-illegal-move,
and the hub ladder/replay views. Docs in docs/agentgames.md (the canonical
protocol spec, to mirror to logicsrc.com); README M3 → done.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
41a8ff240d
commit
cbc9069964
22 changed files with 2297 additions and 3 deletions
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 domain@host point your own domain at your homepage (Premium; add/rm/list)
|
||||
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
|
||||
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
|
||||
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
|
|
@ -16,6 +18,7 @@
|
|||
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
|
||||
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
|
||||
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
|
||||
// agentbbs mint-token NAME issue a WebSocket API token for NAME
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -48,6 +51,7 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/calls"
|
||||
"github.com/profullstack/agentbbs/internal/chat"
|
||||
"github.com/profullstack/agentbbs/internal/forwardemail"
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
"github.com/profullstack/agentbbs/internal/hub"
|
||||
"github.com/profullstack/agentbbs/internal/mail"
|
||||
"github.com/profullstack/agentbbs/internal/payments"
|
||||
|
|
@ -57,6 +61,7 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/sites"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/plugins/about"
|
||||
"github.com/profullstack/agentbbs/plugins/agentgames"
|
||||
"github.com/profullstack/agentbbs/plugins/arcade"
|
||||
)
|
||||
|
||||
|
|
@ -67,6 +72,16 @@ func env(k, def string) string {
|
|||
return def
|
||||
}
|
||||
|
||||
// envInt reads an integer environment variable, falling back to def.
|
||||
func envInt(k string, def int) int {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
type app struct {
|
||||
st store.Store
|
||||
pods *pods.Manager // nil when no container engine on host
|
||||
|
|
@ -76,6 +91,8 @@ type app struct {
|
|||
mail mail.Config
|
||||
fe forwardemail.Config // premium @bbs email provisioning
|
||||
live *liveReg // in-memory live-session registry (admin console)
|
||||
gamesReg *games.Registry // AgentGames catalog
|
||||
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
|
||||
dataDir string
|
||||
assets string
|
||||
host string // public hostname used in user-facing messages
|
||||
|
|
@ -99,6 +116,10 @@ func main() {
|
|||
domainCmd(st, dataDir, os.Args[1], os.Args[2:])
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
|
||||
mintToken(st, os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||
fe := forwardemail.ConfigFromEnv()
|
||||
|
|
@ -115,7 +136,11 @@ func main() {
|
|||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||
host: host,
|
||||
}
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
|
||||
a.gamesReg = games.Catalog()
|
||||
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
||||
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
|
||||
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}
|
||||
|
||||
// Custom domains: maintain the symlink farm Caddy serves and answer its
|
||||
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
|
||||
|
|
@ -158,6 +183,10 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
// AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback;
|
||||
// Caddy proxies wss://host/play to it.
|
||||
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))
|
||||
|
||||
addr := env("AGENTBBS_ADDR", ":2222")
|
||||
srv, err := wish.NewServer(
|
||||
wish.WithAddress(addr),
|
||||
|
|
@ -213,6 +242,8 @@ func (a *app) router() wish.Middleware {
|
|||
a.handleDomain(s)
|
||||
case auth.IsAdminName(user):
|
||||
adminHandler(s)
|
||||
case auth.IsGameName(user):
|
||||
a.handleGame(s)
|
||||
case auth.IsPodName(user):
|
||||
a.handlePod(s)
|
||||
case isVideo:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue