agentbbs/internal/games/transport.go
Anthony Ettinger cbc9069964
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>
2026-06-14 03:30:12 -07:00

100 lines
2.3 KiB
Go

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