agentbbs/internal/games/games.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

86 lines
2.8 KiB
Go

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