mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
41a8ff240d
commit
cbc9069964
22 changed files with 2297 additions and 3 deletions
426
plugins/agentgames/agentgames.go
Normal file
426
plugins/agentgames/agentgames.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue