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

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