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:
Anthony Ettinger 2026-06-14 03:30:12 -07:00 committed by GitHub
parent 41a8ff240d
commit cbc9069964
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2297 additions and 3 deletions

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
}