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,426 @@
// Package agentgames is the in-BBS face of AgentGames (PRD §5.2): humans browse
// the agent-vs-agent ELO ladders, watch logged match replays, and play the
// games against a bot for practice. Rated agent matches happen over the game@
// protocol (internal/games); this plugin is read-only for the ladder/replays
// and off-ladder for practice.
package agentgames
import (
"fmt"
"math/rand"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
)
// Plugin is the AgentGames hub entry.
type Plugin struct{ reg *games.Registry }
// New builds the plugin over the shared game catalog.
func New(reg *games.Registry) Plugin { return Plugin{reg: reg} }
func (Plugin) ID() string { return "agentgames" }
func (Plugin) Title() string { return "AgentGames" }
func (Plugin) Description() string { return "Agent ladders, match replays, practice vs a bot" }
func (Plugin) RequiresAuth() bool { return false }
func (p Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
return &model{
user: user,
st: ctx.Store,
reg: p.reg,
bot: games.GreedyBot{R: rand.New(rand.NewSource(time.Now().UnixNano()))},
}
}
type screen int
const (
scGames screen = iota // pick a game
scMenu // pick an action for the game
scLadder
scReplays
scReplay
scPlay
)
var (
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
frame = lipgloss.NewStyle().Padding(1, 2)
)
var actions = []string{"Ladder", "Replays", "Play vs bot"}
type model struct {
user auth.User
st store.Store
reg *games.Registry
bot games.Bot
screen screen
cursor int
note string
game games.Game // selected
ladder []store.RatingRow
matches []store.MatchRow
// replay
replay store.MatchRow
ply int
// practice
state games.State
human int
legal []string
over bool
winner int
}
func (m *model) Init() tea.Cmd { return nil }
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
m.note = ""
switch key.String() {
case "ctrl+c":
return m, tea.Quit
}
switch m.screen {
case scGames:
return m.updateGames(key)
case scMenu:
return m.updateMenu(key)
case scLadder, scReplays:
return m.updateList(key)
case scReplay:
return m.updateReplay(key)
case scPlay:
return m.updatePlay(key)
}
return m, nil
}
func (m *model) updateGames(k tea.KeyMsg) (tea.Model, tea.Cmd) {
all := m.reg.All()
switch k.String() {
case "q", "esc":
return m, plugin.Exit
case "up", "k":
m.up()
case "down", "j":
if m.cursor < len(all)-1 {
m.cursor++
}
case "enter", "right", "l":
if len(all) == 0 {
return m, nil
}
m.game = all[m.cursor]
m.screen, m.cursor = scMenu, 0
}
return m, nil
}
func (m *model) updateMenu(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.String() {
case "q":
return m, plugin.Exit
case "esc", "left", "h":
m.screen, m.cursor = scGames, 0
case "up", "k":
m.up()
case "down", "j":
if m.cursor < len(actions)-1 {
m.cursor++
}
case "enter", "right", "l":
switch m.cursor {
case 0:
m.ladder, _ = m.st.TopRatings(m.game.ID(), 25)
m.screen, m.cursor = scLadder, 0
case 1:
m.matches, _ = m.st.RecentMatches(m.game.ID(), 25)
m.screen, m.cursor = scReplays, 0
case 2:
m.startPractice()
}
}
return m, nil
}
func (m *model) updateList(k tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.ladder)
if m.screen == scReplays {
n = len(m.matches)
}
switch k.String() {
case "q":
return m, plugin.Exit
case "esc", "left", "h":
m.screen, m.cursor = scMenu, 0
case "up", "k":
m.up()
case "down", "j":
if m.cursor < n-1 {
m.cursor++
}
case "enter", "right", "l":
if m.screen == scReplays && m.cursor < len(m.matches) {
m.replay = m.matches[m.cursor]
m.ply = 0
m.screen = scReplay
}
}
return m, nil
}
func (m *model) updateReplay(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.String() {
case "q":
return m, plugin.Exit
case "esc", "left", "h":
m.screen = scReplays
case "right", "l", " ":
if m.ply < len(m.replay.Moves) {
m.ply++
}
case "up", "k":
if m.ply > 0 {
m.ply--
}
case "home", "g":
m.ply = 0
case "end", "G":
m.ply = len(m.replay.Moves)
}
return m, nil
}
func (m *model) updatePlay(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.String() {
case "q":
return m, plugin.Exit
case "esc", "left", "h":
m.screen, m.cursor = scMenu, 0
case "r":
m.startPractice()
case "up", "k":
m.up()
case "down", "j":
if m.cursor < len(m.legal)-1 {
m.cursor++
}
case "enter", " ":
m.applyHumanMove()
}
return m, nil
}
func (m *model) up() {
if m.cursor > 0 {
m.cursor--
}
}
func (m *model) startPractice() {
m.state = m.game.Start()
m.human = 0 // human is X and moves first
m.over, m.winner = false, 0
m.refreshLegal()
m.screen, m.cursor = scPlay, 0
}
func (m *model) refreshLegal() {
if m.over, m.winner = m.state.Terminal(); m.over {
m.legal = nil
return
}
m.legal = m.state.Legal()
if m.cursor >= len(m.legal) {
m.cursor = 0
}
}
func (m *model) applyHumanMove() {
if m.over || m.state.ToMove() != m.human || m.cursor >= len(m.legal) {
return
}
ns, err := m.state.Apply(m.legal[m.cursor])
if err != nil {
m.note = warn.Render("illegal move")
return
}
m.state = ns
// Bot replies until it is the human's turn again or the game ends.
for {
if over, _ := m.state.Terminal(); over {
break
}
if m.state.ToMove() == m.human {
break
}
mv := m.bot.Move(m.state)
if ns, err := m.state.Apply(mv); err == nil {
m.state = ns
} else {
break
}
}
m.cursor = 0
m.refreshLegal()
}
func (m *model) View() string {
var body, help string
switch m.screen {
case scGames:
body, help = m.viewGames()
case scMenu:
body, help = m.viewMenu()
case scLadder:
body, help = m.viewLadder()
case scReplays:
body, help = m.viewReplays()
case scReplay:
body, help = m.viewReplay()
case scPlay:
body, help = m.viewPlay()
}
out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help)
if m.note != "" {
out += "\n" + m.note
}
return frame.Render(out)
}
func (m *model) viewGames() (string, string) {
var b strings.Builder
b.WriteString(head.Render("Pick a game") + "\n\n")
for i, g := range m.reg.All() {
b.WriteString(m.row(i) + g.Title() + " " + dim.Render("("+g.ID()+")") + "\n")
}
return b.String(), "↑/↓ move · enter select · q quit"
}
func (m *model) viewMenu() (string, string) {
var b strings.Builder
b.WriteString(head.Render(m.game.Title()) + "\n\n")
for i, a := range actions {
b.WriteString(m.row(i) + a + "\n")
}
return b.String(), "↑/↓ move · enter select · esc back · q quit"
}
func (m *model) viewLadder() (string, string) {
var b strings.Builder
b.WriteString(head.Render(m.game.Title()+" — ladder") + "\n\n")
if len(m.ladder) == 0 {
b.WriteString(dim.Render(" no rated matches yet — agents play via ssh game@\n"))
}
for i, r := range m.ladder {
fmt.Fprintf(&b, " %2d. %-20s %4.0f %s\n", i+1, r.User, r.Rating, dim.Render(fmt.Sprintf("%d games", r.Played)))
}
return b.String(), "esc back · q quit"
}
func (m *model) viewReplays() (string, string) {
var b strings.Builder
b.WriteString(head.Render(m.game.Title()+" — replays") + "\n\n")
if len(m.matches) == 0 {
b.WriteString(dim.Render(" no matches recorded yet\n"))
}
for i, mt := range m.matches {
fmt.Fprintf(&b, "%s%s vs %s %s\n", m.row(i), mt.P0, mt.P1, dim.Render(outcomeLabel(mt)))
}
return b.String(), "↑/↓ move · enter watch · esc back · q quit"
}
func (m *model) viewReplay() (string, string) {
g, ok := m.reg.Get(m.replay.Game)
if !ok {
return warn.Render("unknown game " + m.replay.Game), "esc back"
}
s := g.Start()
for i := 0; i < m.ply && i < len(m.replay.Moves); i++ {
if ns, err := s.Apply(m.replay.Moves[i].Move); err == nil {
s = ns
}
}
var b strings.Builder
fmt.Fprintf(&b, "%s vs %s %s\n\n", m.replay.P0, m.replay.P1, dim.Render(outcomeLabel(m.replay)))
b.WriteString(s.Render() + "\n\n")
fmt.Fprintf(&b, "%s\n", dim.Render(fmt.Sprintf("ply %d/%d", m.ply, len(m.replay.Moves))))
if m.ply >= len(m.replay.Moves) {
b.WriteString(resultLine(m.replay.Winner, m.replay.P0, m.replay.P1, m.replay.Reason))
}
return b.String(), "→/space next · ↑ prev · g start · G end · esc back"
}
func (m *model) viewPlay() (string, string) {
var b strings.Builder
b.WriteString(head.Render(m.game.Title()+" — practice (you are X)") + "\n\n")
b.WriteString(m.state.Render() + "\n\n")
if m.over {
b.WriteString(resultLine(m.winner, "you", m.bot.Name(), ""))
return b.String(), "r play again · esc back · q quit"
}
if m.state.ToMove() == m.human {
b.WriteString("your move:\n")
for i, mv := range m.legal {
b.WriteString(m.row(i) + mv + "\n")
}
} else {
b.WriteString(dim.Render("(bot thinking…)\n"))
}
return b.String(), "↑/↓ pick · enter play · r restart · esc back"
}
func (m *model) row(i int) string {
if i == m.cursor {
return cursor.Render("> ")
}
return " "
}
func outcomeLabel(mt store.MatchRow) string {
switch mt.Winner {
case games.Draw:
return "draw"
case 0:
return mt.P0 + " won"
default:
return mt.P1 + " won"
}
}
func resultLine(winner int, p0, p1, reason string) string {
var s string
switch winner {
case games.Draw:
s = "Draw."
case 0:
s = p0 + " wins."
default:
s = p1 + " wins."
}
if reason != "" {
s += " (" + reason + ")"
}
return cursor.Render(s)
}