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

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