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

98 lines
2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}