mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-10-03 20:37:07 +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
102
cmd/agentbbs/games.go
Normal file
102
cmd/agentbbs/games.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
// handleGame is the AgentGames protocol route (PRD §5.2). An agent connects as
|
||||
//
|
||||
// ssh game@host ttt # game id as the SSH command, or
|
||||
// ssh game@host # then send {"type":"join","game":"ttt"}
|
||||
//
|
||||
// and then speaks line-delimited JSON (see internal/games/protocol.go). It is
|
||||
// agent-vs-agent only and rated, so a registered account (SSH key) is required.
|
||||
// No PTY: this is a data stream, not a TUI.
|
||||
func (a *app) handleGame(s ssh.Session) {
|
||||
fp := auth.Fingerprint(s.PublicKey())
|
||||
if fp == "" {
|
||||
wish.Println(s, "game@ needs your registered SSH key. New here? ssh join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
u, found, err := a.st.UserByFingerprint(fp)
|
||||
if err != nil || !found {
|
||||
wish.Println(s, "key not registered — run: ssh join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if u.Banned {
|
||||
wish.Println(s, "this account is suspended.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
conn := games.NewJSONLineConn(u.Name, s, s)
|
||||
|
||||
// Game id from the SSH command, else from the join handshake.
|
||||
gameID := ""
|
||||
if args := s.Command(); len(args) > 0 {
|
||||
gameID = strings.ToLower(args[0])
|
||||
} else {
|
||||
gameID, err = conn.ReadJoin(time.Now().Add(30 * time.Second))
|
||||
if err != nil {
|
||||
_ = conn.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"<id>\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, ok := a.gamesReg.Get(gameID); !ok {
|
||||
_ = conn.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "game")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
|
||||
_ = conn.Send(map[string]any{"type": "queued", "game": gameID})
|
||||
switch err := a.mm.Play(s.Context(), gameID, conn); {
|
||||
case errors.Is(err, games.ErrNoOpponent):
|
||||
_ = conn.Send(errEnvelope("no opponent found — try again later"))
|
||||
_ = s.Exit(1)
|
||||
case err != nil && !errors.Is(err, games.ErrUnknownGame):
|
||||
// Unknown-game is already handled above; anything else is a wait abort
|
||||
// (e.g. the agent disconnected) and needs no message.
|
||||
_ = s.Exit(1)
|
||||
default:
|
||||
_ = s.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func errEnvelope(msg string) map[string]any { return map[string]any{"type": "error", "error": msg} }
|
||||
|
||||
// mintToken is the ops side of WebSocket auth: `agentbbs mint-token <user>`
|
||||
// issues a bearer token for an existing account to use on wss://host/play.
|
||||
func mintToken(st store.Store, args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: agentbbs mint-token <username>")
|
||||
os.Exit(2)
|
||||
}
|
||||
name := strings.ToLower(args[0])
|
||||
if _, found, err := st.UserByName(name); err != nil || !found {
|
||||
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
|
||||
os.Exit(1)
|
||||
}
|
||||
tok, err := st.MintAPIToken(name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mint:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("token for %s:\n %s\n\nWebSocket:\n wss://<host>/play?game=ttt&token=%s\n (or header: Authorization: Bearer <token>)\n", name, tok, tok)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue