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

A Gym-style game engine (PRD §5.2) with two transports sharing one
matchmaker, so an SSH agent and a WebSocket agent can be paired together.

Engine (internal/games):
  - Game/State contract (immutable positions); registry/catalog.
  - Phase-1 games: Tic-Tac-Toe (ttt) and Connect 4 (c4).
  - ELO (K=32, start 1500), a generic win/block/random GreedyBot.
  - Transport-agnostic NDJSON protocol + match driver: hello → state →
    move → result. We run no agent code — illegal move / per-move timeout /
    disconnect all forfeit (strict validation in place of a sandbox).
  - Matchmaker: per-game queue, bounded queue-wait; never abandons a match
    that started racing the wait timeout.

Transports:
  - SSH route game@ (ssh game@host ttt | join message), registered key,
    no PTY.
  - WebSocket /play (wss), bearer API token (agentbbs mint-token <user>);
    loopback behind Caddy.

Store: game_ratings (ELO ladder) + game_matches (full move log for replay)
+ api_tokens; Rating/SaveMatch satisfy games.Store; TopRatings/RecentMatches/
MatchByID/MintAPIToken/UserByToken. Banned accounts blocked.

Hub: plugins/agentgames — browse ladders, watch move-by-move replays, and
practice vs the bot (off the rated ladder).

Tests: engine (win/draw/legality), ELO, bot, full match via matchmaker with
replay, transport (deadline/closed), store round-trips. Verified live over
SSH (agent-vs-agent), WebSocket↔SSH cross-transport, forfeit-on-illegal-move,
and the hub ladder/replay views. Docs in docs/agentgames.md (the canonical
protocol spec, to mirror to logicsrc.com); README M3 → done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 10:13:02 +00:00
parent d98ee1cce7
commit 05090206f2
22 changed files with 2297 additions and 3 deletions

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

@ -0,0 +1,71 @@
package games
import "math/rand"
// Bot picks a move for the player to move in a position. It powers the in-BBS
// human-vs-bot practice mode (bots never play rated agent matches).
type Bot interface {
Name() string
Move(s State) string
}
// GreedyBot is a light heuristic that works for any game implementing State:
// it takes an immediately winning move if one exists, blocks the opponent's
// immediate win if forced, and otherwise plays a random legal move. It is a
// fine sparring partner without being game-specific.
type GreedyBot struct{ R *rand.Rand }
func (GreedyBot) Name() string { return "greedy-bot" }
func (b GreedyBot) Move(s State) string {
legal := s.Legal()
if len(legal) == 0 {
return ""
}
me := s.ToMove()
// 1) Win now if we can.
for _, m := range legal {
if ns, err := s.Apply(m); err == nil {
if over, w := ns.Terminal(); over && w == me {
return m
}
}
}
// 2) Block an opponent move that would let them win next turn.
for _, m := range legal {
ns, err := s.Apply(m)
if err != nil {
continue
}
if over, _ := ns.Terminal(); over {
continue
}
if !opponentCanWin(ns) {
// This move does not hand the opponent an immediate win; prefer it.
return m
}
}
// 3) Otherwise random.
return legal[b.pick(len(legal))]
}
// opponentCanWin reports whether the player to move in s has an immediate win.
func opponentCanWin(s State) bool {
opp := s.ToMove()
for _, m := range s.Legal() {
if ns, err := s.Apply(m); err == nil {
if over, w := ns.Terminal(); over && w == opp {
return true
}
}
}
return false
}
func (b GreedyBot) pick(n int) int {
if b.R != nil {
return b.R.Intn(n)
}
return rand.Intn(n)
}

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

@ -0,0 +1,144 @@
package games
import (
"strconv"
"strings"
)
// Connect4 is 7-column × 6-row Connect Four. Player 0 is X, player 1 is O.
// Moves are column indices "0".."6"; a piece falls to the lowest empty row.
type Connect4 struct{}
func (Connect4) ID() string { return "c4" }
func (Connect4) Title() string { return "Connect 4" }
func (Connect4) Start() State { return c4State{} }
const (
c4Cols = 7
c4Rows = 6
)
// c4State stores cells row-major with row 0 at the TOP. -1 empty, else 0/1.
type c4State struct {
cells [c4Rows * c4Cols]int
filled int // number of pieces placed (for draw detection)
toMove int
zero bool // marks an initialized empty board (cells default to 0, not -1)
}
func (s c4State) at(r, c int) int {
if !s.zero {
return -1 // fresh board: all empty
}
return s.cells[r*c4Cols+c]
}
func (s c4State) ToMove() int { return s.toMove }
func (s c4State) Legal() []string {
if over, _ := s.Terminal(); over {
return nil
}
var out []string
for c := 0; c < c4Cols; c++ {
if s.at(0, c) == -1 {
out = append(out, strconv.Itoa(c))
}
}
return out
}
func (s c4State) Apply(move string) (State, error) {
if over, _ := s.Terminal(); over {
return nil, ErrIllegalMove
}
c, err := strconv.Atoi(move)
if err != nil || c < 0 || c >= c4Cols || s.at(0, c) != -1 {
return nil, ErrIllegalMove
}
ns := s.materialize()
// Drop to the lowest empty row.
row := c4Rows - 1
for row >= 0 && ns.cells[row*c4Cols+c] != -1 {
row--
}
ns.cells[row*c4Cols+c] = s.toMove
ns.filled = s.filled + 1
ns.toMove = 1 - s.toMove
return ns, nil
}
// materialize returns a copy whose backing array is explicitly filled with -1
// for empties, so Apply can write into it.
func (s c4State) materialize() c4State {
if s.zero {
return s
}
ns := s
for i := range ns.cells {
ns.cells[i] = -1
}
ns.zero = true
return ns
}
var c4Dirs = [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}} // →, ↓, ↘, ↙
func (s c4State) Terminal() (bool, int) {
for r := 0; r < c4Rows; r++ {
for c := 0; c < c4Cols; c++ {
p := s.at(r, c)
if p == -1 {
continue
}
for _, d := range c4Dirs {
if s.countRun(r, c, d[0], d[1], p) >= 4 {
return true, p
}
}
}
}
if s.filled >= c4Rows*c4Cols {
return true, Draw
}
return false, 0
}
func (s c4State) countRun(r, c, dr, dc, p int) int {
n := 0
for r >= 0 && r < c4Rows && c >= 0 && c < c4Cols && s.at(r, c) == p {
n++
r += dr
c += dc
}
return n
}
var c4Glyph = map[int]string{-1: ".", 0: "X", 1: "O"}
func (s c4State) Observe() map[string]any {
board := make([][]string, c4Rows)
for r := 0; r < c4Rows; r++ {
board[r] = make([]string, c4Cols)
for c := 0; c < c4Cols; c++ {
board[r][c] = c4Glyph[s.at(r, c)]
}
}
return map[string]any{
"board": board, // [row][col], row 0 is the top
"toMove": s.toMove,
"legal": s.Legal(),
}
}
func (s c4State) Render() string {
var b strings.Builder
for r := 0; r < c4Rows; r++ {
for c := 0; c < c4Cols; c++ {
b.WriteString(" " + c4Glyph[s.at(r, c)])
}
b.WriteString("\n")
}
b.WriteString(" 0 1 2 3 4 5 6")
return b.String()
}

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

@ -0,0 +1,44 @@
package games
import (
"errors"
"math"
)
// ErrIllegalMove is returned by State.Apply for a move that is not legal in the
// current position. The match driver treats it as a forfeit.
var ErrIllegalMove = errors.New("illegal move")
// DefaultRating is the ELO a player starts at before their first rated match.
const DefaultRating = 1500.0
// kFactor scales how far a single result moves a rating.
const kFactor = 32.0
// Expected returns the expected score (0..1) for a player rated a against an
// opponent rated b, per the standard logistic ELO model.
func Expected(a, b float64) float64 {
return 1.0 / (1.0 + math.Pow(10, (b-a)/400.0))
}
// EloUpdate returns the new ratings for two players after a game, given the
// score for player A (1 win, 0.5 draw, 0 loss). B's score is the complement.
func EloUpdate(ra, rb, scoreA float64) (na, nb float64) {
ea := Expected(ra, rb)
eb := Expected(rb, ra)
na = ra + kFactor*(scoreA-ea)
nb = rb + kFactor*((1-scoreA)-eb)
return na, nb
}
// ScoreFor converts a terminal winner into player p's score (1/0.5/0).
func ScoreFor(winner, p int) float64 {
switch {
case winner == Draw:
return 0.5
case winner == p:
return 1
default:
return 0
}
}

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

@ -0,0 +1,86 @@
// Package games is the AgentGames engine (PRD §5.2): two-player,
// perfect-information, turn-based games behind a small Gym-style contract that
// is exposed to agents as line-delimited JSON (see protocol.go). The same
// engine backs agent-vs-agent matches over SSH/WebSocket, the ELO ladder, the
// replay store, and the in-BBS human-vs-bot practice mode.
//
// We never execute agent code: an agent is a remote client that sends move
// tokens, which we validate against the state's legal moves. The security
// posture (PRD §5.2 "untrusted agent input") is therefore strict validation,
// per-move deadlines, and forfeit-on-illegal-move — not a per-match container.
package games
import "sort"
// Result codes for a terminal position's winner.
const (
Draw = -1 // the game ended with no winner
)
// Game is a two-player, perfect-information, turn-based game.
type Game interface {
ID() string // stable token, e.g. "ttt"
Title() string // human label, e.g. "Tic-Tac-Toe"
Start() State // the initial position (player 0 to move)
}
// State is an immutable game position. Apply returns a new State so positions
// can be cloned and replayed freely.
type State interface {
// ToMove is the player (0 or 1) to move; meaningful only when not terminal.
ToMove() int
// Legal lists the legal move tokens for the player to move.
Legal() []string
// Apply plays move for the player to move, returning the next position.
// It returns ErrIllegalMove if the move is not currently legal.
Apply(move string) (State, error)
// Terminal reports whether the game is over and, if so, the winner (0 or 1)
// or Draw.
Terminal() (over bool, winner int)
// Observe is the JSON-able observation handed to agents: at least the board,
// whose turn it is, and the legal moves.
Observe() map[string]any
// Render is a human-readable board for the TUI and replay viewer.
Render() string
}
// Registry is an ordered set of games, looked up by ID.
type Registry struct {
byID map[string]Game
order []Game
}
// Catalog is the v1 game catalog (PRD §5.2 phase 1).
func Catalog() *Registry { return NewRegistry(TTT{}, Connect4{}) }
// NewRegistry builds a registry from the given games, preserving order.
func NewRegistry(gs ...Game) *Registry {
r := &Registry{byID: make(map[string]Game, len(gs))}
for _, g := range gs {
if _, dup := r.byID[g.ID()]; dup {
continue
}
r.byID[g.ID()] = g
r.order = append(r.order, g)
}
return r
}
// Get returns the game with this ID.
func (r *Registry) Get(id string) (Game, bool) {
g, ok := r.byID[id]
return g, ok
}
// All returns the games in registration order.
func (r *Registry) All() []Game { return append([]Game(nil), r.order...) }
// IDs returns the registered game IDs, sorted.
func (r *Registry) IDs() []string {
out := make([]string, 0, len(r.byID))
for id := range r.byID {
out = append(out, id)
}
sort.Strings(out)
return out
}

View file

@ -0,0 +1,126 @@
package games
import (
"math"
"math/rand"
"testing"
)
// play applies a sequence of moves, failing on any illegal one.
func play(t *testing.T, s State, moves ...string) State {
t.Helper()
for _, m := range moves {
ns, err := s.Apply(m)
if err != nil {
t.Fatalf("move %q illegal: %v", m, err)
}
s = ns
}
return s
}
func TestTTTWinRow(t *testing.T) {
// X: 0,1,2 ; O: 3,4
s := play(t, TTT{}.Start(), "0", "3", "1", "4", "2")
over, w := s.Terminal()
if !over || w != 0 {
t.Fatalf("expected X win, got over=%v w=%d", over, w)
}
}
func TestTTTDraw(t *testing.T) {
// A standard drawn game.
s := play(t, TTT{}.Start(), "0", "1", "2", "4", "3", "5", "7", "6", "8")
over, w := s.Terminal()
if !over || w != Draw {
t.Fatalf("expected draw, got over=%v w=%d", over, w)
}
}
func TestTTTIllegal(t *testing.T) {
s := play(t, TTT{}.Start(), "4")
if _, err := s.Apply("4"); err != ErrIllegalMove {
t.Fatalf("replaying an occupied cell should be illegal, got %v", err)
}
if _, err := s.Apply("9"); err != ErrIllegalMove {
t.Fatalf("out-of-range move should be illegal, got %v", err)
}
}
func TestConnect4VerticalWin(t *testing.T) {
// X drops col 0 four times; O drops col 1 between.
s := play(t, Connect4{}.Start(), "0", "1", "0", "1", "0", "1", "0")
over, w := s.Terminal()
if !over || w != 0 {
t.Fatalf("expected X vertical win, got over=%v w=%d", over, w)
}
}
func TestConnect4HorizontalWin(t *testing.T) {
// X fills cols 0-3 on the bottom row; O stacks col 6.
s := play(t, Connect4{}.Start(), "0", "6", "1", "6", "2", "6", "3")
over, w := s.Terminal()
if !over || w != 0 {
t.Fatalf("expected X horizontal win, got over=%v w=%d", over, w)
}
}
func TestConnect4LegalAndFullColumn(t *testing.T) {
s := Connect4{}.Start()
// Fill column 0 (6 pieces) alternating; nobody connects 4 vertically
// because players alternate, so the column just fills.
s = play(t, s, "0", "0", "0", "0", "0", "0")
for _, m := range s.Legal() {
if m == "0" {
t.Fatal("full column 0 should not be legal")
}
}
}
func TestEloUpdate(t *testing.T) {
// Equal ratings, A wins → A gains exactly K/2, B loses K/2.
na, nb := EloUpdate(1500, 1500, 1)
if math.Abs(na-1516) > 1e-9 || math.Abs(nb-1484) > 1e-9 {
t.Fatalf("equal-rating win: na=%.4f nb=%.4f", na, nb)
}
// Zero-sum: total rating is conserved.
if math.Abs((na+nb)-3000) > 1e-9 {
t.Fatalf("elo not zero-sum: %.4f", na+nb)
}
// A draw between equals is a no-op.
da, db := EloUpdate(1500, 1500, 0.5)
if math.Abs(da-1500) > 1e-9 || math.Abs(db-1500) > 1e-9 {
t.Fatalf("equal draw should not move ratings: %.4f %.4f", da, db)
}
}
func TestGreedyBotTakesWin(t *testing.T) {
// X to move with 0,1 played and a free 2 → bot must complete the row.
s := play(t, TTT{}.Start(), "0", "4", "1", "5") // X:0,1 O:4,5, X to move
bot := GreedyBot{R: rand.New(rand.NewSource(1))}
if m := bot.Move(s); m != "2" {
t.Fatalf("greedy bot should win at 2, played %q", m)
}
}
func TestGreedyBotBlocks(t *testing.T) {
// O to move; X threatens 0,1 with 2 open → bot must block at 2.
s := play(t, TTT{}.Start(), "0", "4", "1") // X:0,1 O:4, O to move
bot := GreedyBot{R: rand.New(rand.NewSource(1))}
if m := bot.Move(s); m != "2" {
t.Fatalf("greedy bot should block at 2, played %q", m)
}
}
func TestRegistry(t *testing.T) {
r := Catalog()
if _, ok := r.Get("ttt"); !ok {
t.Fatal("ttt missing from catalog")
}
if _, ok := r.Get("c4"); !ok {
t.Fatal("c4 missing from catalog")
}
if len(r.All()) != 2 {
t.Fatalf("want 2 games, got %d", len(r.All()))
}
}

View file

@ -0,0 +1,158 @@
package games
import (
"context"
"errors"
"sync"
"time"
)
// ErrNoOpponent means nobody else joined the queue before the wait expired.
var ErrNoOpponent = errors.New("no opponent found")
// ErrUnknownGame means the requested game id is not in the registry.
var ErrUnknownGame = errors.New("unknown game")
// Store persists finished matches and tracks per-game ELO ratings. The SQLite
// store implements it; the matchmaker stays storage-agnostic.
type Store interface {
// Rating returns a player's current rating for a game (DefaultRating if the
// player has no rated history there).
Rating(user, game string) (float64, error)
// SaveMatch records a finished match and the updated ratings.
SaveMatch(FinishedMatch) error
}
// FinishedMatch is everything persisted about one completed match.
type FinishedMatch struct {
Game string
Players [2]string
Winner int
Reason string
Moves []Move
RatingBefore [2]float64
RatingAfter [2]float64
StartedAt time.Time
EndedAt time.Time
}
// Matchmaker pairs agents into agent-vs-agent matches per game.
type Matchmaker struct {
reg *Registry
store Store // may be nil (matches are not persisted)
moveTimeout time.Duration
queueWait time.Duration
mu sync.Mutex
queue map[string]*waiter // gameID -> the single player waiting
}
type waiter struct {
io PlayerIO
done chan struct{}
}
// NewMatchmaker builds a matchmaker over a game registry. store may be nil.
// moveTimeout bounds each ply; queueWait bounds how long a lone player waits
// for an opponent before giving up.
func NewMatchmaker(reg *Registry, store Store, moveTimeout, queueWait time.Duration) *Matchmaker {
if moveTimeout <= 0 {
moveTimeout = 15 * time.Second
}
if queueWait <= 0 {
queueWait = 2 * time.Minute
}
return &Matchmaker{reg: reg, store: store, moveTimeout: moveTimeout, queueWait: queueWait, queue: map[string]*waiter{}}
}
// Play enrolls a player in the queue for gameID and blocks until their match
// finishes. A lone player waits up to queueWait for an opponent (or until ctx
// is done, e.g. they disconnect) and then returns ErrNoOpponent. Once paired,
// the match always runs to completion regardless of ctx/queueWait — those only
// govern the waiting phase.
func (mm *Matchmaker) Play(ctx context.Context, gameID string, io PlayerIO) error {
g, ok := mm.reg.Get(gameID)
if !ok {
return ErrUnknownGame
}
mm.mu.Lock()
if w, waiting := mm.queue[gameID]; waiting && w.io.Name() != io.Name() {
// An opponent is waiting — pair up and run the match.
delete(mm.queue, gameID)
mm.mu.Unlock()
go mm.run(g, [2]PlayerIO{w.io, io}, w.done)
<-w.done // both players unblock when the match completes
return nil
}
self := &waiter{io: io, done: make(chan struct{})}
mm.queue[gameID] = self
mm.mu.Unlock()
timer := time.NewTimer(mm.queueWait)
defer timer.Stop()
select {
case <-self.done:
return nil
case <-ctx.Done():
return mm.giveUp(gameID, self, ctx.Err())
case <-timer.C:
return mm.giveUp(gameID, self, ErrNoOpponent)
}
}
// giveUp removes a still-waiting player from the queue. If the player was
// already paired (the match-start goroutine won the race), it instead waits
// for that match to finish and reports success — the match must never be
// abandoned mid-flight.
func (mm *Matchmaker) giveUp(gameID string, self *waiter, reason error) error {
mm.mu.Lock()
stillQueued := mm.queue[gameID] == self
if stillQueued {
delete(mm.queue, gameID)
}
mm.mu.Unlock()
if stillQueued {
return reason
}
<-self.done // paired after all; let the match complete
return nil
}
// run plays one match, persists it, sends each player their result, then
// releases both Play calls by closing done.
func (mm *Matchmaker) run(g Game, p [2]PlayerIO, done chan struct{}) {
defer close(done)
start := time.Now()
res := RunMatch(g, p, mm.moveTimeout)
end := time.Now()
before := [2]float64{DefaultRating, DefaultRating}
if mm.store != nil {
for i := 0; i < 2; i++ {
if r, err := mm.store.Rating(res.Players[i], g.ID()); err == nil {
before[i] = r
}
}
}
na, nb := EloUpdate(before[0], before[1], ScoreFor(res.Winner, 0))
after := [2]float64{na, nb}
if mm.store != nil {
_ = mm.store.SaveMatch(FinishedMatch{
Game: g.ID(), Players: res.Players, Winner: res.Winner, Reason: res.Reason,
Moves: res.Moves, RatingBefore: before, RatingAfter: after,
StartedAt: start, EndedAt: end,
})
}
for i := 0; i < 2; i++ {
_ = p[i].Send(ResultMsg{
Type: "result",
Winner: res.Winner,
Outcome: Outcome(res.Winner, i),
Reason: res.Reason,
Rating: after[i],
})
}
}

View file

@ -0,0 +1,135 @@
package games
import (
"context"
"math"
"sync"
"testing"
"time"
)
// firstLegalPlayer plays the first legal move it is offered — enough to drive a
// full match deterministically through the protocol and matchmaker.
type firstLegalPlayer struct {
name string
lastLegal []string
hello *helloMsg
result *ResultMsg
}
func (p *firstLegalPlayer) Name() string { return p.name }
func (p *firstLegalPlayer) Send(v any) error {
switch m := v.(type) {
case helloMsg:
p.hello = &m
case stateMsg:
if m.YourTurn {
if legal, ok := m.Observation["legal"].([]string); ok {
p.lastLegal = legal
}
}
case ResultMsg:
p.result = &m
}
return nil
}
func (p *firstLegalPlayer) ReadMove(time.Time) (string, error) {
if len(p.lastLegal) == 0 {
return "", ErrClosed
}
return p.lastLegal[0], nil
}
type fakeStore struct {
mu sync.Mutex
ratings map[string]float64
saved []FinishedMatch
}
func (s *fakeStore) Rating(user, game string) (float64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.ratings[user+"/"+game]; ok {
return r, nil
}
return DefaultRating, nil
}
func (s *fakeStore) SaveMatch(fm FinishedMatch) error {
s.mu.Lock()
defer s.mu.Unlock()
s.saved = append(s.saved, fm)
s.ratings[fm.Players[0]+"/"+fm.Game] = fm.RatingAfter[0]
s.ratings[fm.Players[1]+"/"+fm.Game] = fm.RatingAfter[1]
return nil
}
func TestMatchmakerFullMatch(t *testing.T) {
store := &fakeStore{ratings: map[string]float64{}}
mm := NewMatchmaker(Catalog(), store, time.Second, time.Minute)
a := &firstLegalPlayer{name: "agent-a"}
b := &firstLegalPlayer{name: "agent-b"}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", a) }()
// Give a a moment to enter the queue so pairing is deterministic.
time.Sleep(20 * time.Millisecond)
go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", b) }()
wg.Wait()
if len(store.saved) != 1 {
t.Fatalf("want 1 saved match, got %d", len(store.saved))
}
fm := store.saved[0]
if fm.Game != "ttt" || fm.Players != [2]string{"agent-a", "agent-b"} {
t.Fatalf("unexpected match meta: %+v", fm)
}
// The recorded moves must replay to the same terminal winner.
s := State(TTT{}.Start())
for _, mv := range fm.Moves {
ns, err := s.Apply(mv.Move)
if err != nil {
t.Fatalf("replay move %q illegal: %v", mv.Move, err)
}
s = ns
}
over, winner := s.Terminal()
if !over {
t.Fatal("replayed moves did not reach a terminal position")
}
if winner != fm.Winner {
t.Fatalf("replay winner %d != recorded %d", winner, fm.Winner)
}
// ELO is zero-sum and both players were notified with matching ratings.
if math.Abs((fm.RatingAfter[0]+fm.RatingAfter[1])-2*DefaultRating) > 1e-9 {
t.Fatalf("elo not zero-sum: %v", fm.RatingAfter)
}
if a.result == nil || b.result == nil {
t.Fatal("both players must receive a result message")
}
if a.result.Outcome == "win" && b.result.Outcome != "loss" {
t.Fatalf("outcomes disagree: a=%s b=%s", a.result.Outcome, b.result.Outcome)
}
if a.hello == nil || a.hello.Player != 0 || b.hello.Player != 1 {
t.Fatalf("hello player indices wrong: %+v %+v", a.hello, b.hello)
}
}
func TestMatchmakerNoOpponent(t *testing.T) {
mm := NewMatchmaker(Catalog(), nil, time.Second, 30*time.Millisecond)
err := mm.Play(context.Background(), "ttt", &firstLegalPlayer{name: "lonely"})
if err != ErrNoOpponent {
t.Fatalf("want ErrNoOpponent, got %v", err)
}
}
func TestMatchmakerUnknownGame(t *testing.T) {
mm := NewMatchmaker(Catalog(), nil, time.Second, time.Second)
if err := mm.Play(context.Background(), "nope", &firstLegalPlayer{name: "x"}); err != ErrUnknownGame {
t.Fatalf("want ErrUnknownGame, got %v", err)
}
}

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

@ -0,0 +1,120 @@
package games
import (
"errors"
"time"
)
// The wire protocol is line-delimited JSON (one object per message), identical
// over SSH (`game@`) and WebSocket. Flow:
//
// → client sends {"type":"join","game":"ttt"} (transport handshake)
// ← server {"type":"hello","player":0,"game":"ttt","opponent":"agent-bob"}
// ← server {"type":"state","observation":{…},"yourTurn":true} (each ply)
// → client {"type":"move","move":"4"} (only on your turn)
// ← server {"type":"result","winner":0,"outcome":"win","rating":1516}
//
// Illegal moves, timeouts, and disconnects forfeit the match.
// ErrTimeout means a player did not move before the deadline. ErrClosed means
// the player's connection ended mid-match. Both forfeit.
var (
ErrTimeout = errors.New("move timeout")
ErrClosed = errors.New("connection closed")
)
// PlayerIO is one player's transport for a match. Send writes a server→client
// message; ReadMove blocks for the next move message until deadline.
type PlayerIO interface {
Name() string
Send(v any) error
ReadMove(deadline time.Time) (string, error)
}
// Move is one ply in a match, for replay.
type Move struct {
Player int `json:"player"`
Move string `json:"move"`
}
// MatchResult is the outcome of a finished match.
type MatchResult struct {
Game string
Players [2]string
Winner int // 0, 1, or Draw
Reason string // "" for a normal finish, else the forfeit cause
Moves []Move
}
// Outbound message envelopes.
type helloMsg struct {
Type string `json:"type"`
Player int `json:"player"`
Game string `json:"game"`
Opponent string `json:"opponent"`
}
type stateMsg struct {
Type string `json:"type"`
Observation map[string]any `json:"observation"`
YourTurn bool `json:"yourTurn"`
}
// ResultMsg is the final per-player message. It is sent by the caller (the
// matchmaker) after ratings are computed, so it carries the player's new ELO.
type ResultMsg struct {
Type string `json:"type"`
Winner int `json:"winner"`
Outcome string `json:"outcome"` // "win" | "loss" | "draw"
Reason string `json:"reason,omitempty"`
Rating float64 `json:"rating"`
}
// RunMatch drives a game to completion between two players, sending hello and
// per-ply state messages. It never returns a transport error: an I/O failure,
// timeout, or illegal move forfeits the offending player. The final result
// message (with ratings) is the caller's responsibility.
func RunMatch(g Game, p [2]PlayerIO, moveTimeout time.Duration) *MatchResult {
res := &MatchResult{Game: g.ID(), Players: [2]string{p[0].Name(), p[1].Name()}}
_ = p[0].Send(helloMsg{Type: "hello", Player: 0, Game: g.ID(), Opponent: p[1].Name()})
_ = p[1].Send(helloMsg{Type: "hello", Player: 1, Game: g.ID(), Opponent: p[0].Name()})
s := g.Start()
for {
if over, winner := s.Terminal(); over {
res.Winner = winner
return res
}
cur := s.ToMove()
obs := s.Observe()
_ = p[0].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 0})
_ = p[1].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 1})
mv, err := p[cur].ReadMove(time.Now().Add(moveTimeout))
if err != nil {
res.Winner = 1 - cur
res.Reason = "forfeit: " + err.Error()
return res
}
ns, err := s.Apply(mv)
if err != nil {
res.Winner = 1 - cur
res.Reason = "forfeit: illegal move"
return res
}
res.Moves = append(res.Moves, Move{Player: cur, Move: mv})
s = ns
}
}
// Outcome returns the per-player outcome string for a winner code.
func Outcome(winner, player int) string {
switch {
case winner == Draw:
return "draw"
case winner == player:
return "win"
default:
return "loss"
}
}

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

@ -0,0 +1,100 @@
package games
import (
"bufio"
"encoding/json"
"io"
"sync"
"time"
)
// JSONLineConn adapts a byte stream (an SSH session, a WebSocket bridged to
// io, etc.) into a PlayerIO speaking line-delimited JSON. A background reader
// turns the blocking stream into a channel so ReadMove can honor deadlines.
type JSONLineConn struct {
name string
wmu sync.Mutex
w io.Writer
lines chan []byte
}
// NewJSONLineConn starts reading lines from r in the background. name is the
// player's account name (used for logging and the ladder).
func NewJSONLineConn(name string, r io.Reader, w io.Writer) *JSONLineConn {
c := &JSONLineConn{name: name, w: w, lines: make(chan []byte, 4)}
go c.readLoop(r)
return c
}
func (c *JSONLineConn) readLoop(r io.Reader) {
defer close(c.lines)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20) // up to 1 MiB per line
for sc.Scan() {
line := append([]byte(nil), sc.Bytes()...)
c.lines <- line
}
}
func (c *JSONLineConn) Name() string { return c.name }
func (c *JSONLineConn) Send(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
c.wmu.Lock()
defer c.wmu.Unlock()
if _, err := c.w.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
type inbound struct {
Type string `json:"type"`
Move string `json:"move"`
Game string `json:"game"`
}
// ReadJoin reads the opening handshake and returns the requested game id.
func (c *JSONLineConn) ReadJoin(deadline time.Time) (string, error) {
timer := time.NewTimer(time.Until(deadline))
defer timer.Stop()
for {
select {
case line, ok := <-c.lines:
if !ok {
return "", ErrClosed
}
var m inbound
if json.Unmarshal(line, &m) == nil && m.Type == "join" && m.Game != "" {
return m.Game, nil
}
// ignore noise until a valid join arrives or we time out
case <-timer.C:
return "", ErrTimeout
}
}
}
// ReadMove blocks for the next move message until deadline. Non-move messages
// (pings, stray joins) are ignored.
func (c *JSONLineConn) ReadMove(deadline time.Time) (string, error) {
timer := time.NewTimer(time.Until(deadline))
defer timer.Stop()
for {
select {
case line, ok := <-c.lines:
if !ok {
return "", ErrClosed
}
var m inbound
if json.Unmarshal(line, &m) == nil && m.Type == "move" && m.Move != "" {
return m.Move, nil
}
case <-timer.C:
return "", ErrTimeout
}
}
}

View file

@ -0,0 +1,63 @@
package games
import (
"bytes"
"io"
"strings"
"sync"
"testing"
"time"
)
func TestJSONLineConnReadJoinAndMove(t *testing.T) {
pr, pw := io.Pipe()
var out bytes.Buffer
var mu sync.Mutex
c := NewJSONLineConn("p", pr, writerFunc(func(b []byte) (int, error) {
mu.Lock()
defer mu.Unlock()
return out.Write(b)
}))
go func() {
_, _ = io.WriteString(pw, `{"type":"join","game":"ttt"}`+"\n")
_, _ = io.WriteString(pw, `{"type":"ping"}`+"\n") // ignored noise
_, _ = io.WriteString(pw, `{"type":"move","move":"4"}`+"\n")
}()
g, err := c.ReadJoin(time.Now().Add(time.Second))
if err != nil || g != "ttt" {
t.Fatalf("ReadJoin = %q, %v", g, err)
}
mv, err := c.ReadMove(time.Now().Add(time.Second))
if err != nil || mv != "4" {
t.Fatalf("ReadMove = %q, %v", mv, err)
}
if err := c.Send(map[string]string{"type": "ok"}); err != nil {
t.Fatalf("send: %v", err)
}
mu.Lock()
got := out.String()
mu.Unlock()
if !strings.HasSuffix(got, "\n") || !strings.Contains(got, `"type":"ok"`) {
t.Fatalf("Send wrote %q", got)
}
_ = pw.Close()
if _, err := c.ReadMove(time.Now().Add(time.Second)); err != ErrClosed {
t.Fatalf("closed stream should give ErrClosed, got %v", err)
}
}
func TestJSONLineConnTimeout(t *testing.T) {
pr, _ := io.Pipe()
c := NewJSONLineConn("p", pr, io.Discard)
if _, err := c.ReadMove(time.Now().Add(20 * time.Millisecond)); err != ErrTimeout {
t.Fatalf("want ErrTimeout, got %v", err)
}
}
type writerFunc func([]byte) (int, error)
func (f writerFunc) Write(b []byte) (int, error) { return f(b) }

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

@ -0,0 +1,98 @@
package games
import "strconv"
// TTT is 3×3 tic-tac-toe. Player 0 is X, player 1 is O. Moves are cell indices
// "0".."8" in row-major order.
type TTT struct{}
func (TTT) ID() string { return "ttt" }
func (TTT) Title() string { return "Tic-Tac-Toe" }
func (TTT) Start() State {
return tttState{cells: [9]int{-1, -1, -1, -1, -1, -1, -1, -1, -1}, toMove: 0}
}
type tttState struct {
cells [9]int // -1 empty, else player 0/1
toMove int
}
var tttLines = [8][3]int{
{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows
{0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // cols
{0, 4, 8}, {2, 4, 6}, // diagonals
}
func (s tttState) ToMove() int { return s.toMove }
func (s tttState) Legal() []string {
if over, _ := s.Terminal(); over {
return nil
}
var out []string
for i, c := range s.cells {
if c == -1 {
out = append(out, strconv.Itoa(i))
}
}
return out
}
func (s tttState) Apply(move string) (State, error) {
if over, _ := s.Terminal(); over {
return nil, ErrIllegalMove
}
i, err := strconv.Atoi(move)
if err != nil || i < 0 || i > 8 || s.cells[i] != -1 {
return nil, ErrIllegalMove
}
ns := s
ns.cells[i] = s.toMove
ns.toMove = 1 - s.toMove
return ns, nil
}
func (s tttState) Terminal() (bool, int) {
for _, ln := range tttLines {
a := s.cells[ln[0]]
if a != -1 && a == s.cells[ln[1]] && a == s.cells[ln[2]] {
return true, a
}
}
for _, c := range s.cells {
if c == -1 {
return false, 0 // moves remain
}
}
return true, Draw
}
var tttGlyph = map[int]string{-1: ".", 0: "X", 1: "O"}
func (s tttState) Observe() map[string]any {
board := make([]string, 9)
for i, c := range s.cells {
board[i] = tttGlyph[c]
}
return map[string]any{
"board": board, // row-major, "." / "X" / "O"
"toMove": s.toMove,
"legal": s.Legal(),
}
}
func (s tttState) Render() string {
out := ""
for r := 0; r < 3; r++ {
for c := 0; c < 3; c++ {
out += " " + tttGlyph[s.cells[r*3+c]] + " "
if c < 2 {
out += "|"
}
}
if r < 2 {
out += "\n-----------\n"
}
}
return out
}