mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 23:07:28 +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
|
|
@ -46,6 +46,10 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
|
|||
// (see IsAdmin); the name itself confers nothing.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
||||
|
||||
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
||||
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
||||
var GameNames = map[string]bool{"game": true, "games": true}
|
||||
|
||||
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
||||
|
||||
|
|
@ -61,6 +65,9 @@ func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
|||
// IsAdminName reports whether the SSH username requests the admin console.
|
||||
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
||||
|
||||
// IsGameName reports whether the SSH username requests the AgentGames protocol.
|
||||
func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] }
|
||||
|
||||
// Admins returns the operator-configured admin allowlist: the lowercased,
|
||||
// comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can
|
||||
// only be granted by the operator (via env), never self-assigned in-band.
|
||||
|
|
|
|||
71
internal/games/bot.go
Normal file
71
internal/games/bot.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package games
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// Bot picks a move for the player to move in a position. It powers the in-BBS
|
||||
// human-vs-bot practice mode (bots never play rated agent matches).
|
||||
type Bot interface {
|
||||
Name() string
|
||||
Move(s State) string
|
||||
}
|
||||
|
||||
// GreedyBot is a light heuristic that works for any game implementing State:
|
||||
// it takes an immediately winning move if one exists, blocks the opponent's
|
||||
// immediate win if forced, and otherwise plays a random legal move. It is a
|
||||
// fine sparring partner without being game-specific.
|
||||
type GreedyBot struct{ R *rand.Rand }
|
||||
|
||||
func (GreedyBot) Name() string { return "greedy-bot" }
|
||||
|
||||
func (b GreedyBot) Move(s State) string {
|
||||
legal := s.Legal()
|
||||
if len(legal) == 0 {
|
||||
return ""
|
||||
}
|
||||
me := s.ToMove()
|
||||
|
||||
// 1) Win now if we can.
|
||||
for _, m := range legal {
|
||||
if ns, err := s.Apply(m); err == nil {
|
||||
if over, w := ns.Terminal(); over && w == me {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Block an opponent move that would let them win next turn.
|
||||
for _, m := range legal {
|
||||
ns, err := s.Apply(m)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if over, _ := ns.Terminal(); over {
|
||||
continue
|
||||
}
|
||||
if !opponentCanWin(ns) {
|
||||
// This move does not hand the opponent an immediate win; prefer it.
|
||||
return m
|
||||
}
|
||||
}
|
||||
// 3) Otherwise random.
|
||||
return legal[b.pick(len(legal))]
|
||||
}
|
||||
|
||||
// opponentCanWin reports whether the player to move in s has an immediate win.
|
||||
func opponentCanWin(s State) bool {
|
||||
opp := s.ToMove()
|
||||
for _, m := range s.Legal() {
|
||||
if ns, err := s.Apply(m); err == nil {
|
||||
if over, w := ns.Terminal(); over && w == opp {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b GreedyBot) pick(n int) int {
|
||||
if b.R != nil {
|
||||
return b.R.Intn(n)
|
||||
}
|
||||
return rand.Intn(n)
|
||||
}
|
||||
144
internal/games/connect4.go
Normal file
144
internal/games/connect4.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Connect4 is 7-column × 6-row Connect Four. Player 0 is X, player 1 is O.
|
||||
// Moves are column indices "0".."6"; a piece falls to the lowest empty row.
|
||||
type Connect4 struct{}
|
||||
|
||||
func (Connect4) ID() string { return "c4" }
|
||||
func (Connect4) Title() string { return "Connect 4" }
|
||||
func (Connect4) Start() State { return c4State{} }
|
||||
|
||||
const (
|
||||
c4Cols = 7
|
||||
c4Rows = 6
|
||||
)
|
||||
|
||||
// c4State stores cells row-major with row 0 at the TOP. -1 empty, else 0/1.
|
||||
type c4State struct {
|
||||
cells [c4Rows * c4Cols]int
|
||||
filled int // number of pieces placed (for draw detection)
|
||||
toMove int
|
||||
zero bool // marks an initialized empty board (cells default to 0, not -1)
|
||||
}
|
||||
|
||||
func (s c4State) at(r, c int) int {
|
||||
if !s.zero {
|
||||
return -1 // fresh board: all empty
|
||||
}
|
||||
return s.cells[r*c4Cols+c]
|
||||
}
|
||||
|
||||
func (s c4State) ToMove() int { return s.toMove }
|
||||
|
||||
func (s c4State) Legal() []string {
|
||||
if over, _ := s.Terminal(); over {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for c := 0; c < c4Cols; c++ {
|
||||
if s.at(0, c) == -1 {
|
||||
out = append(out, strconv.Itoa(c))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s c4State) Apply(move string) (State, error) {
|
||||
if over, _ := s.Terminal(); over {
|
||||
return nil, ErrIllegalMove
|
||||
}
|
||||
c, err := strconv.Atoi(move)
|
||||
if err != nil || c < 0 || c >= c4Cols || s.at(0, c) != -1 {
|
||||
return nil, ErrIllegalMove
|
||||
}
|
||||
ns := s.materialize()
|
||||
// Drop to the lowest empty row.
|
||||
row := c4Rows - 1
|
||||
for row >= 0 && ns.cells[row*c4Cols+c] != -1 {
|
||||
row--
|
||||
}
|
||||
ns.cells[row*c4Cols+c] = s.toMove
|
||||
ns.filled = s.filled + 1
|
||||
ns.toMove = 1 - s.toMove
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// materialize returns a copy whose backing array is explicitly filled with -1
|
||||
// for empties, so Apply can write into it.
|
||||
func (s c4State) materialize() c4State {
|
||||
if s.zero {
|
||||
return s
|
||||
}
|
||||
ns := s
|
||||
for i := range ns.cells {
|
||||
ns.cells[i] = -1
|
||||
}
|
||||
ns.zero = true
|
||||
return ns
|
||||
}
|
||||
|
||||
var c4Dirs = [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}} // →, ↓, ↘, ↙
|
||||
|
||||
func (s c4State) Terminal() (bool, int) {
|
||||
for r := 0; r < c4Rows; r++ {
|
||||
for c := 0; c < c4Cols; c++ {
|
||||
p := s.at(r, c)
|
||||
if p == -1 {
|
||||
continue
|
||||
}
|
||||
for _, d := range c4Dirs {
|
||||
if s.countRun(r, c, d[0], d[1], p) >= 4 {
|
||||
return true, p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.filled >= c4Rows*c4Cols {
|
||||
return true, Draw
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func (s c4State) countRun(r, c, dr, dc, p int) int {
|
||||
n := 0
|
||||
for r >= 0 && r < c4Rows && c >= 0 && c < c4Cols && s.at(r, c) == p {
|
||||
n++
|
||||
r += dr
|
||||
c += dc
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var c4Glyph = map[int]string{-1: ".", 0: "X", 1: "O"}
|
||||
|
||||
func (s c4State) Observe() map[string]any {
|
||||
board := make([][]string, c4Rows)
|
||||
for r := 0; r < c4Rows; r++ {
|
||||
board[r] = make([]string, c4Cols)
|
||||
for c := 0; c < c4Cols; c++ {
|
||||
board[r][c] = c4Glyph[s.at(r, c)]
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"board": board, // [row][col], row 0 is the top
|
||||
"toMove": s.toMove,
|
||||
"legal": s.Legal(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s c4State) Render() string {
|
||||
var b strings.Builder
|
||||
for r := 0; r < c4Rows; r++ {
|
||||
for c := 0; c < c4Cols; c++ {
|
||||
b.WriteString(" " + c4Glyph[s.at(r, c)])
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(" 0 1 2 3 4 5 6")
|
||||
return b.String()
|
||||
}
|
||||
44
internal/games/elo.go
Normal file
44
internal/games/elo.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ErrIllegalMove is returned by State.Apply for a move that is not legal in the
|
||||
// current position. The match driver treats it as a forfeit.
|
||||
var ErrIllegalMove = errors.New("illegal move")
|
||||
|
||||
// DefaultRating is the ELO a player starts at before their first rated match.
|
||||
const DefaultRating = 1500.0
|
||||
|
||||
// kFactor scales how far a single result moves a rating.
|
||||
const kFactor = 32.0
|
||||
|
||||
// Expected returns the expected score (0..1) for a player rated a against an
|
||||
// opponent rated b, per the standard logistic ELO model.
|
||||
func Expected(a, b float64) float64 {
|
||||
return 1.0 / (1.0 + math.Pow(10, (b-a)/400.0))
|
||||
}
|
||||
|
||||
// EloUpdate returns the new ratings for two players after a game, given the
|
||||
// score for player A (1 win, 0.5 draw, 0 loss). B's score is the complement.
|
||||
func EloUpdate(ra, rb, scoreA float64) (na, nb float64) {
|
||||
ea := Expected(ra, rb)
|
||||
eb := Expected(rb, ra)
|
||||
na = ra + kFactor*(scoreA-ea)
|
||||
nb = rb + kFactor*((1-scoreA)-eb)
|
||||
return na, nb
|
||||
}
|
||||
|
||||
// ScoreFor converts a terminal winner into player p's score (1/0.5/0).
|
||||
func ScoreFor(winner, p int) float64 {
|
||||
switch {
|
||||
case winner == Draw:
|
||||
return 0.5
|
||||
case winner == p:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
86
internal/games/games.go
Normal file
86
internal/games/games.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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
|
||||
}
|
||||
126
internal/games/games_test.go
Normal file
126
internal/games/games_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// play applies a sequence of moves, failing on any illegal one.
|
||||
func play(t *testing.T, s State, moves ...string) State {
|
||||
t.Helper()
|
||||
for _, m := range moves {
|
||||
ns, err := s.Apply(m)
|
||||
if err != nil {
|
||||
t.Fatalf("move %q illegal: %v", m, err)
|
||||
}
|
||||
s = ns
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTTTWinRow(t *testing.T) {
|
||||
// X: 0,1,2 ; O: 3,4
|
||||
s := play(t, TTT{}.Start(), "0", "3", "1", "4", "2")
|
||||
over, w := s.Terminal()
|
||||
if !over || w != 0 {
|
||||
t.Fatalf("expected X win, got over=%v w=%d", over, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTTDraw(t *testing.T) {
|
||||
// A standard drawn game.
|
||||
s := play(t, TTT{}.Start(), "0", "1", "2", "4", "3", "5", "7", "6", "8")
|
||||
over, w := s.Terminal()
|
||||
if !over || w != Draw {
|
||||
t.Fatalf("expected draw, got over=%v w=%d", over, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTTIllegal(t *testing.T) {
|
||||
s := play(t, TTT{}.Start(), "4")
|
||||
if _, err := s.Apply("4"); err != ErrIllegalMove {
|
||||
t.Fatalf("replaying an occupied cell should be illegal, got %v", err)
|
||||
}
|
||||
if _, err := s.Apply("9"); err != ErrIllegalMove {
|
||||
t.Fatalf("out-of-range move should be illegal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect4VerticalWin(t *testing.T) {
|
||||
// X drops col 0 four times; O drops col 1 between.
|
||||
s := play(t, Connect4{}.Start(), "0", "1", "0", "1", "0", "1", "0")
|
||||
over, w := s.Terminal()
|
||||
if !over || w != 0 {
|
||||
t.Fatalf("expected X vertical win, got over=%v w=%d", over, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect4HorizontalWin(t *testing.T) {
|
||||
// X fills cols 0-3 on the bottom row; O stacks col 6.
|
||||
s := play(t, Connect4{}.Start(), "0", "6", "1", "6", "2", "6", "3")
|
||||
over, w := s.Terminal()
|
||||
if !over || w != 0 {
|
||||
t.Fatalf("expected X horizontal win, got over=%v w=%d", over, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect4LegalAndFullColumn(t *testing.T) {
|
||||
s := Connect4{}.Start()
|
||||
// Fill column 0 (6 pieces) alternating; nobody connects 4 vertically
|
||||
// because players alternate, so the column just fills.
|
||||
s = play(t, s, "0", "0", "0", "0", "0", "0")
|
||||
for _, m := range s.Legal() {
|
||||
if m == "0" {
|
||||
t.Fatal("full column 0 should not be legal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEloUpdate(t *testing.T) {
|
||||
// Equal ratings, A wins → A gains exactly K/2, B loses K/2.
|
||||
na, nb := EloUpdate(1500, 1500, 1)
|
||||
if math.Abs(na-1516) > 1e-9 || math.Abs(nb-1484) > 1e-9 {
|
||||
t.Fatalf("equal-rating win: na=%.4f nb=%.4f", na, nb)
|
||||
}
|
||||
// Zero-sum: total rating is conserved.
|
||||
if math.Abs((na+nb)-3000) > 1e-9 {
|
||||
t.Fatalf("elo not zero-sum: %.4f", na+nb)
|
||||
}
|
||||
// A draw between equals is a no-op.
|
||||
da, db := EloUpdate(1500, 1500, 0.5)
|
||||
if math.Abs(da-1500) > 1e-9 || math.Abs(db-1500) > 1e-9 {
|
||||
t.Fatalf("equal draw should not move ratings: %.4f %.4f", da, db)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreedyBotTakesWin(t *testing.T) {
|
||||
// X to move with 0,1 played and a free 2 → bot must complete the row.
|
||||
s := play(t, TTT{}.Start(), "0", "4", "1", "5") // X:0,1 O:4,5, X to move
|
||||
bot := GreedyBot{R: rand.New(rand.NewSource(1))}
|
||||
if m := bot.Move(s); m != "2" {
|
||||
t.Fatalf("greedy bot should win at 2, played %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreedyBotBlocks(t *testing.T) {
|
||||
// O to move; X threatens 0,1 with 2 open → bot must block at 2.
|
||||
s := play(t, TTT{}.Start(), "0", "4", "1") // X:0,1 O:4, O to move
|
||||
bot := GreedyBot{R: rand.New(rand.NewSource(1))}
|
||||
if m := bot.Move(s); m != "2" {
|
||||
t.Fatalf("greedy bot should block at 2, played %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
r := Catalog()
|
||||
if _, ok := r.Get("ttt"); !ok {
|
||||
t.Fatal("ttt missing from catalog")
|
||||
}
|
||||
if _, ok := r.Get("c4"); !ok {
|
||||
t.Fatal("c4 missing from catalog")
|
||||
}
|
||||
if len(r.All()) != 2 {
|
||||
t.Fatalf("want 2 games, got %d", len(r.All()))
|
||||
}
|
||||
}
|
||||
158
internal/games/matchmaker.go
Normal file
158
internal/games/matchmaker.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoOpponent means nobody else joined the queue before the wait expired.
|
||||
var ErrNoOpponent = errors.New("no opponent found")
|
||||
|
||||
// ErrUnknownGame means the requested game id is not in the registry.
|
||||
var ErrUnknownGame = errors.New("unknown game")
|
||||
|
||||
// Store persists finished matches and tracks per-game ELO ratings. The SQLite
|
||||
// store implements it; the matchmaker stays storage-agnostic.
|
||||
type Store interface {
|
||||
// Rating returns a player's current rating for a game (DefaultRating if the
|
||||
// player has no rated history there).
|
||||
Rating(user, game string) (float64, error)
|
||||
// SaveMatch records a finished match and the updated ratings.
|
||||
SaveMatch(FinishedMatch) error
|
||||
}
|
||||
|
||||
// FinishedMatch is everything persisted about one completed match.
|
||||
type FinishedMatch struct {
|
||||
Game string
|
||||
Players [2]string
|
||||
Winner int
|
||||
Reason string
|
||||
Moves []Move
|
||||
RatingBefore [2]float64
|
||||
RatingAfter [2]float64
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
}
|
||||
|
||||
// Matchmaker pairs agents into agent-vs-agent matches per game.
|
||||
type Matchmaker struct {
|
||||
reg *Registry
|
||||
store Store // may be nil (matches are not persisted)
|
||||
moveTimeout time.Duration
|
||||
queueWait time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
queue map[string]*waiter // gameID -> the single player waiting
|
||||
}
|
||||
|
||||
type waiter struct {
|
||||
io PlayerIO
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewMatchmaker builds a matchmaker over a game registry. store may be nil.
|
||||
// moveTimeout bounds each ply; queueWait bounds how long a lone player waits
|
||||
// for an opponent before giving up.
|
||||
func NewMatchmaker(reg *Registry, store Store, moveTimeout, queueWait time.Duration) *Matchmaker {
|
||||
if moveTimeout <= 0 {
|
||||
moveTimeout = 15 * time.Second
|
||||
}
|
||||
if queueWait <= 0 {
|
||||
queueWait = 2 * time.Minute
|
||||
}
|
||||
return &Matchmaker{reg: reg, store: store, moveTimeout: moveTimeout, queueWait: queueWait, queue: map[string]*waiter{}}
|
||||
}
|
||||
|
||||
// Play enrolls a player in the queue for gameID and blocks until their match
|
||||
// finishes. A lone player waits up to queueWait for an opponent (or until ctx
|
||||
// is done, e.g. they disconnect) and then returns ErrNoOpponent. Once paired,
|
||||
// the match always runs to completion regardless of ctx/queueWait — those only
|
||||
// govern the waiting phase.
|
||||
func (mm *Matchmaker) Play(ctx context.Context, gameID string, io PlayerIO) error {
|
||||
g, ok := mm.reg.Get(gameID)
|
||||
if !ok {
|
||||
return ErrUnknownGame
|
||||
}
|
||||
|
||||
mm.mu.Lock()
|
||||
if w, waiting := mm.queue[gameID]; waiting && w.io.Name() != io.Name() {
|
||||
// An opponent is waiting — pair up and run the match.
|
||||
delete(mm.queue, gameID)
|
||||
mm.mu.Unlock()
|
||||
go mm.run(g, [2]PlayerIO{w.io, io}, w.done)
|
||||
<-w.done // both players unblock when the match completes
|
||||
return nil
|
||||
}
|
||||
self := &waiter{io: io, done: make(chan struct{})}
|
||||
mm.queue[gameID] = self
|
||||
mm.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(mm.queueWait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-self.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return mm.giveUp(gameID, self, ctx.Err())
|
||||
case <-timer.C:
|
||||
return mm.giveUp(gameID, self, ErrNoOpponent)
|
||||
}
|
||||
}
|
||||
|
||||
// giveUp removes a still-waiting player from the queue. If the player was
|
||||
// already paired (the match-start goroutine won the race), it instead waits
|
||||
// for that match to finish and reports success — the match must never be
|
||||
// abandoned mid-flight.
|
||||
func (mm *Matchmaker) giveUp(gameID string, self *waiter, reason error) error {
|
||||
mm.mu.Lock()
|
||||
stillQueued := mm.queue[gameID] == self
|
||||
if stillQueued {
|
||||
delete(mm.queue, gameID)
|
||||
}
|
||||
mm.mu.Unlock()
|
||||
if stillQueued {
|
||||
return reason
|
||||
}
|
||||
<-self.done // paired after all; let the match complete
|
||||
return nil
|
||||
}
|
||||
|
||||
// run plays one match, persists it, sends each player their result, then
|
||||
// releases both Play calls by closing done.
|
||||
func (mm *Matchmaker) run(g Game, p [2]PlayerIO, done chan struct{}) {
|
||||
defer close(done)
|
||||
start := time.Now()
|
||||
res := RunMatch(g, p, mm.moveTimeout)
|
||||
end := time.Now()
|
||||
|
||||
before := [2]float64{DefaultRating, DefaultRating}
|
||||
if mm.store != nil {
|
||||
for i := 0; i < 2; i++ {
|
||||
if r, err := mm.store.Rating(res.Players[i], g.ID()); err == nil {
|
||||
before[i] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
na, nb := EloUpdate(before[0], before[1], ScoreFor(res.Winner, 0))
|
||||
after := [2]float64{na, nb}
|
||||
|
||||
if mm.store != nil {
|
||||
_ = mm.store.SaveMatch(FinishedMatch{
|
||||
Game: g.ID(), Players: res.Players, Winner: res.Winner, Reason: res.Reason,
|
||||
Moves: res.Moves, RatingBefore: before, RatingAfter: after,
|
||||
StartedAt: start, EndedAt: end,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
_ = p[i].Send(ResultMsg{
|
||||
Type: "result",
|
||||
Winner: res.Winner,
|
||||
Outcome: Outcome(res.Winner, i),
|
||||
Reason: res.Reason,
|
||||
Rating: after[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
135
internal/games/matchmaker_test.go
Normal file
135
internal/games/matchmaker_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// firstLegalPlayer plays the first legal move it is offered — enough to drive a
|
||||
// full match deterministically through the protocol and matchmaker.
|
||||
type firstLegalPlayer struct {
|
||||
name string
|
||||
lastLegal []string
|
||||
hello *helloMsg
|
||||
result *ResultMsg
|
||||
}
|
||||
|
||||
func (p *firstLegalPlayer) Name() string { return p.name }
|
||||
|
||||
func (p *firstLegalPlayer) Send(v any) error {
|
||||
switch m := v.(type) {
|
||||
case helloMsg:
|
||||
p.hello = &m
|
||||
case stateMsg:
|
||||
if m.YourTurn {
|
||||
if legal, ok := m.Observation["legal"].([]string); ok {
|
||||
p.lastLegal = legal
|
||||
}
|
||||
}
|
||||
case ResultMsg:
|
||||
p.result = &m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *firstLegalPlayer) ReadMove(time.Time) (string, error) {
|
||||
if len(p.lastLegal) == 0 {
|
||||
return "", ErrClosed
|
||||
}
|
||||
return p.lastLegal[0], nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
ratings map[string]float64
|
||||
saved []FinishedMatch
|
||||
}
|
||||
|
||||
func (s *fakeStore) Rating(user, game string) (float64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if r, ok := s.ratings[user+"/"+game]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return DefaultRating, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) SaveMatch(fm FinishedMatch) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.saved = append(s.saved, fm)
|
||||
s.ratings[fm.Players[0]+"/"+fm.Game] = fm.RatingAfter[0]
|
||||
s.ratings[fm.Players[1]+"/"+fm.Game] = fm.RatingAfter[1]
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMatchmakerFullMatch(t *testing.T) {
|
||||
store := &fakeStore{ratings: map[string]float64{}}
|
||||
mm := NewMatchmaker(Catalog(), store, time.Second, time.Minute)
|
||||
|
||||
a := &firstLegalPlayer{name: "agent-a"}
|
||||
b := &firstLegalPlayer{name: "agent-b"}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", a) }()
|
||||
// Give a a moment to enter the queue so pairing is deterministic.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
go func() { defer wg.Done(); _ = mm.Play(context.Background(), "ttt", b) }()
|
||||
wg.Wait()
|
||||
|
||||
if len(store.saved) != 1 {
|
||||
t.Fatalf("want 1 saved match, got %d", len(store.saved))
|
||||
}
|
||||
fm := store.saved[0]
|
||||
if fm.Game != "ttt" || fm.Players != [2]string{"agent-a", "agent-b"} {
|
||||
t.Fatalf("unexpected match meta: %+v", fm)
|
||||
}
|
||||
// The recorded moves must replay to the same terminal winner.
|
||||
s := State(TTT{}.Start())
|
||||
for _, mv := range fm.Moves {
|
||||
ns, err := s.Apply(mv.Move)
|
||||
if err != nil {
|
||||
t.Fatalf("replay move %q illegal: %v", mv.Move, err)
|
||||
}
|
||||
s = ns
|
||||
}
|
||||
over, winner := s.Terminal()
|
||||
if !over {
|
||||
t.Fatal("replayed moves did not reach a terminal position")
|
||||
}
|
||||
if winner != fm.Winner {
|
||||
t.Fatalf("replay winner %d != recorded %d", winner, fm.Winner)
|
||||
}
|
||||
// ELO is zero-sum and both players were notified with matching ratings.
|
||||
if math.Abs((fm.RatingAfter[0]+fm.RatingAfter[1])-2*DefaultRating) > 1e-9 {
|
||||
t.Fatalf("elo not zero-sum: %v", fm.RatingAfter)
|
||||
}
|
||||
if a.result == nil || b.result == nil {
|
||||
t.Fatal("both players must receive a result message")
|
||||
}
|
||||
if a.result.Outcome == "win" && b.result.Outcome != "loss" {
|
||||
t.Fatalf("outcomes disagree: a=%s b=%s", a.result.Outcome, b.result.Outcome)
|
||||
}
|
||||
if a.hello == nil || a.hello.Player != 0 || b.hello.Player != 1 {
|
||||
t.Fatalf("hello player indices wrong: %+v %+v", a.hello, b.hello)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerNoOpponent(t *testing.T) {
|
||||
mm := NewMatchmaker(Catalog(), nil, time.Second, 30*time.Millisecond)
|
||||
err := mm.Play(context.Background(), "ttt", &firstLegalPlayer{name: "lonely"})
|
||||
if err != ErrNoOpponent {
|
||||
t.Fatalf("want ErrNoOpponent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerUnknownGame(t *testing.T) {
|
||||
mm := NewMatchmaker(Catalog(), nil, time.Second, time.Second)
|
||||
if err := mm.Play(context.Background(), "nope", &firstLegalPlayer{name: "x"}); err != ErrUnknownGame {
|
||||
t.Fatalf("want ErrUnknownGame, got %v", err)
|
||||
}
|
||||
}
|
||||
120
internal/games/protocol.go
Normal file
120
internal/games/protocol.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The wire protocol is line-delimited JSON (one object per message), identical
|
||||
// over SSH (`game@`) and WebSocket. Flow:
|
||||
//
|
||||
// → client sends {"type":"join","game":"ttt"} (transport handshake)
|
||||
// ← server {"type":"hello","player":0,"game":"ttt","opponent":"agent-bob"}
|
||||
// ← server {"type":"state","observation":{…},"yourTurn":true} (each ply)
|
||||
// → client {"type":"move","move":"4"} (only on your turn)
|
||||
// ← server {"type":"result","winner":0,"outcome":"win","rating":1516}
|
||||
//
|
||||
// Illegal moves, timeouts, and disconnects forfeit the match.
|
||||
|
||||
// ErrTimeout means a player did not move before the deadline. ErrClosed means
|
||||
// the player's connection ended mid-match. Both forfeit.
|
||||
var (
|
||||
ErrTimeout = errors.New("move timeout")
|
||||
ErrClosed = errors.New("connection closed")
|
||||
)
|
||||
|
||||
// PlayerIO is one player's transport for a match. Send writes a server→client
|
||||
// message; ReadMove blocks for the next move message until deadline.
|
||||
type PlayerIO interface {
|
||||
Name() string
|
||||
Send(v any) error
|
||||
ReadMove(deadline time.Time) (string, error)
|
||||
}
|
||||
|
||||
// Move is one ply in a match, for replay.
|
||||
type Move struct {
|
||||
Player int `json:"player"`
|
||||
Move string `json:"move"`
|
||||
}
|
||||
|
||||
// MatchResult is the outcome of a finished match.
|
||||
type MatchResult struct {
|
||||
Game string
|
||||
Players [2]string
|
||||
Winner int // 0, 1, or Draw
|
||||
Reason string // "" for a normal finish, else the forfeit cause
|
||||
Moves []Move
|
||||
}
|
||||
|
||||
// Outbound message envelopes.
|
||||
type helloMsg struct {
|
||||
Type string `json:"type"`
|
||||
Player int `json:"player"`
|
||||
Game string `json:"game"`
|
||||
Opponent string `json:"opponent"`
|
||||
}
|
||||
type stateMsg struct {
|
||||
Type string `json:"type"`
|
||||
Observation map[string]any `json:"observation"`
|
||||
YourTurn bool `json:"yourTurn"`
|
||||
}
|
||||
|
||||
// ResultMsg is the final per-player message. It is sent by the caller (the
|
||||
// matchmaker) after ratings are computed, so it carries the player's new ELO.
|
||||
type ResultMsg struct {
|
||||
Type string `json:"type"`
|
||||
Winner int `json:"winner"`
|
||||
Outcome string `json:"outcome"` // "win" | "loss" | "draw"
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
|
||||
// RunMatch drives a game to completion between two players, sending hello and
|
||||
// per-ply state messages. It never returns a transport error: an I/O failure,
|
||||
// timeout, or illegal move forfeits the offending player. The final result
|
||||
// message (with ratings) is the caller's responsibility.
|
||||
func RunMatch(g Game, p [2]PlayerIO, moveTimeout time.Duration) *MatchResult {
|
||||
res := &MatchResult{Game: g.ID(), Players: [2]string{p[0].Name(), p[1].Name()}}
|
||||
|
||||
_ = p[0].Send(helloMsg{Type: "hello", Player: 0, Game: g.ID(), Opponent: p[1].Name()})
|
||||
_ = p[1].Send(helloMsg{Type: "hello", Player: 1, Game: g.ID(), Opponent: p[0].Name()})
|
||||
|
||||
s := g.Start()
|
||||
for {
|
||||
if over, winner := s.Terminal(); over {
|
||||
res.Winner = winner
|
||||
return res
|
||||
}
|
||||
cur := s.ToMove()
|
||||
obs := s.Observe()
|
||||
_ = p[0].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 0})
|
||||
_ = p[1].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 1})
|
||||
|
||||
mv, err := p[cur].ReadMove(time.Now().Add(moveTimeout))
|
||||
if err != nil {
|
||||
res.Winner = 1 - cur
|
||||
res.Reason = "forfeit: " + err.Error()
|
||||
return res
|
||||
}
|
||||
ns, err := s.Apply(mv)
|
||||
if err != nil {
|
||||
res.Winner = 1 - cur
|
||||
res.Reason = "forfeit: illegal move"
|
||||
return res
|
||||
}
|
||||
res.Moves = append(res.Moves, Move{Player: cur, Move: mv})
|
||||
s = ns
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome returns the per-player outcome string for a winner code.
|
||||
func Outcome(winner, player int) string {
|
||||
switch {
|
||||
case winner == Draw:
|
||||
return "draw"
|
||||
case winner == player:
|
||||
return "win"
|
||||
default:
|
||||
return "loss"
|
||||
}
|
||||
}
|
||||
100
internal/games/transport.go
Normal file
100
internal/games/transport.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONLineConn adapts a byte stream (an SSH session, a WebSocket bridged to
|
||||
// io, etc.) into a PlayerIO speaking line-delimited JSON. A background reader
|
||||
// turns the blocking stream into a channel so ReadMove can honor deadlines.
|
||||
type JSONLineConn struct {
|
||||
name string
|
||||
wmu sync.Mutex
|
||||
w io.Writer
|
||||
lines chan []byte
|
||||
}
|
||||
|
||||
// NewJSONLineConn starts reading lines from r in the background. name is the
|
||||
// player's account name (used for logging and the ladder).
|
||||
func NewJSONLineConn(name string, r io.Reader, w io.Writer) *JSONLineConn {
|
||||
c := &JSONLineConn{name: name, w: w, lines: make(chan []byte, 4)}
|
||||
go c.readLoop(r)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *JSONLineConn) readLoop(r io.Reader) {
|
||||
defer close(c.lines)
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20) // up to 1 MiB per line
|
||||
for sc.Scan() {
|
||||
line := append([]byte(nil), sc.Bytes()...)
|
||||
c.lines <- line
|
||||
}
|
||||
}
|
||||
|
||||
func (c *JSONLineConn) Name() string { return c.name }
|
||||
|
||||
func (c *JSONLineConn) Send(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
if _, err := c.w.Write(append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type inbound struct {
|
||||
Type string `json:"type"`
|
||||
Move string `json:"move"`
|
||||
Game string `json:"game"`
|
||||
}
|
||||
|
||||
// ReadJoin reads the opening handshake and returns the requested game id.
|
||||
func (c *JSONLineConn) ReadJoin(deadline time.Time) (string, error) {
|
||||
timer := time.NewTimer(time.Until(deadline))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-c.lines:
|
||||
if !ok {
|
||||
return "", ErrClosed
|
||||
}
|
||||
var m inbound
|
||||
if json.Unmarshal(line, &m) == nil && m.Type == "join" && m.Game != "" {
|
||||
return m.Game, nil
|
||||
}
|
||||
// ignore noise until a valid join arrives or we time out
|
||||
case <-timer.C:
|
||||
return "", ErrTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadMove blocks for the next move message until deadline. Non-move messages
|
||||
// (pings, stray joins) are ignored.
|
||||
func (c *JSONLineConn) ReadMove(deadline time.Time) (string, error) {
|
||||
timer := time.NewTimer(time.Until(deadline))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-c.lines:
|
||||
if !ok {
|
||||
return "", ErrClosed
|
||||
}
|
||||
var m inbound
|
||||
if json.Unmarshal(line, &m) == nil && m.Type == "move" && m.Move != "" {
|
||||
return m.Move, nil
|
||||
}
|
||||
case <-timer.C:
|
||||
return "", ErrTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
63
internal/games/transport_test.go
Normal file
63
internal/games/transport_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJSONLineConnReadJoinAndMove(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
var out bytes.Buffer
|
||||
var mu sync.Mutex
|
||||
c := NewJSONLineConn("p", pr, writerFunc(func(b []byte) (int, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return out.Write(b)
|
||||
}))
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(pw, `{"type":"join","game":"ttt"}`+"\n")
|
||||
_, _ = io.WriteString(pw, `{"type":"ping"}`+"\n") // ignored noise
|
||||
_, _ = io.WriteString(pw, `{"type":"move","move":"4"}`+"\n")
|
||||
}()
|
||||
|
||||
g, err := c.ReadJoin(time.Now().Add(time.Second))
|
||||
if err != nil || g != "ttt" {
|
||||
t.Fatalf("ReadJoin = %q, %v", g, err)
|
||||
}
|
||||
mv, err := c.ReadMove(time.Now().Add(time.Second))
|
||||
if err != nil || mv != "4" {
|
||||
t.Fatalf("ReadMove = %q, %v", mv, err)
|
||||
}
|
||||
|
||||
if err := c.Send(map[string]string{"type": "ok"}); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
got := out.String()
|
||||
mu.Unlock()
|
||||
if !strings.HasSuffix(got, "\n") || !strings.Contains(got, `"type":"ok"`) {
|
||||
t.Fatalf("Send wrote %q", got)
|
||||
}
|
||||
|
||||
_ = pw.Close()
|
||||
if _, err := c.ReadMove(time.Now().Add(time.Second)); err != ErrClosed {
|
||||
t.Fatalf("closed stream should give ErrClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLineConnTimeout(t *testing.T) {
|
||||
pr, _ := io.Pipe()
|
||||
c := NewJSONLineConn("p", pr, io.Discard)
|
||||
if _, err := c.ReadMove(time.Now().Add(20 * time.Millisecond)); err != ErrTimeout {
|
||||
t.Fatalf("want ErrTimeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type writerFunc func([]byte) (int, error)
|
||||
|
||||
func (f writerFunc) Write(b []byte) (int, error) { return f(b) }
|
||||
98
internal/games/ttt.go
Normal file
98
internal/games/ttt.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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
|
||||
}
|
||||
159
internal/store/games_store.go
Normal file
159
internal/store/games_store.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
)
|
||||
|
||||
func (s *sqliteStore) Rating(user, game string) (float64, error) {
|
||||
var r float64
|
||||
err := s.db.QueryRow(`SELECT rating FROM game_ratings WHERE username = ? AND game = ?`, user, game).Scan(&r)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return games.DefaultRating, nil
|
||||
}
|
||||
if err != nil {
|
||||
return games.DefaultRating, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SaveMatch(fm games.FinishedMatch) error {
|
||||
moves, err := json.Marshal(fm.Moves)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO game_matches
|
||||
(game, p0, p1, winner, reason, moves, r0_before, r1_before, r0_after, r1_after, started_at, ended_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
fm.Game, fm.Players[0], fm.Players[1], fm.Winner, fm.Reason, string(moves),
|
||||
fm.RatingBefore[0], fm.RatingBefore[1], fm.RatingAfter[0], fm.RatingAfter[1],
|
||||
fm.StartedAt.UTC().Format(time.RFC3339), fm.EndedAt.UTC().Format(time.RFC3339),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := upsertRating(tx, fm.Players[i], fm.Game, fm.RatingAfter[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func upsertRating(tx *sql.Tx, user, game string, rating float64) error {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO game_ratings (username, game, rating, played) VALUES (?,?,?,1)
|
||||
ON CONFLICT(username, game) DO UPDATE SET
|
||||
rating = excluded.rating,
|
||||
played = game_ratings.played + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, user, game, rating)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) TopRatings(game string, n int) ([]RatingRow, error) {
|
||||
if n <= 0 {
|
||||
n = 20
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT username, rating, played FROM game_ratings
|
||||
WHERE game = ? ORDER BY rating DESC, played DESC LIMIT ?`, game, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RatingRow
|
||||
for rows.Next() {
|
||||
var r RatingRow
|
||||
if err := rows.Scan(&r.User, &r.Rating, &r.Played); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// matchCols is the column list (in struct order) for match SELECTs.
|
||||
const matchCols = `id, game, p0, p1, winner, reason, moves, r0_after, r1_after, started_at, ended_at`
|
||||
|
||||
func scanMatch(sc interface{ Scan(...any) error }) (MatchRow, error) {
|
||||
var m MatchRow
|
||||
var movesJSON, started, ended string
|
||||
if err := sc.Scan(&m.ID, &m.Game, &m.P0, &m.P1, &m.Winner, &m.Reason,
|
||||
&movesJSON, &m.RatingAfter[0], &m.RatingAfter[1], &started, &ended); err != nil {
|
||||
return MatchRow{}, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(movesJSON), &m.Moves)
|
||||
m.StartedAt, _ = time.Parse(time.RFC3339, started)
|
||||
m.EndedAt, _ = time.Parse(time.RFC3339, ended)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecentMatches(game string, n int) ([]MatchRow, error) {
|
||||
if n <= 0 {
|
||||
n = 20
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT `+matchCols+` FROM game_matches WHERE game = ? ORDER BY id DESC LIMIT ?`, game, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MatchRow
|
||||
for rows.Next() {
|
||||
m, err := scanMatch(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MatchByID(id int64) (MatchRow, bool, error) {
|
||||
m, err := scanMatch(s.db.QueryRow(`SELECT `+matchCols+` FROM game_matches WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return MatchRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return MatchRow{}, false, err
|
||||
}
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MintAPIToken(username string) (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b[:])
|
||||
if _, err := s.db.Exec(`INSERT INTO api_tokens (token, username) VALUES (?,?)`, token, username); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UserByToken(token string) (string, bool, error) {
|
||||
if token == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
var username string
|
||||
err := s.db.QueryRow(`SELECT username FROM api_tokens WHERE token = ?`, token).Scan(&username)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return username, true, nil
|
||||
}
|
||||
79
internal/store/games_store_test.go
Normal file
79
internal/store/games_store_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
)
|
||||
|
||||
func TestGameRatingsAndMatches(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
// Unrated players start at the default.
|
||||
if r, _ := st.Rating("agent-a", "ttt"); r != games.DefaultRating {
|
||||
t.Fatalf("default rating = %v", r)
|
||||
}
|
||||
|
||||
fm := games.FinishedMatch{
|
||||
Game: "ttt",
|
||||
Players: [2]string{"agent-a", "agent-b"},
|
||||
Winner: 0,
|
||||
Moves: []games.Move{{Player: 0, Move: "4"}, {Player: 1, Move: "0"}, {Player: 0, Move: "1"}},
|
||||
RatingBefore: [2]float64{1500, 1500},
|
||||
RatingAfter: [2]float64{1516, 1484},
|
||||
StartedAt: time.Now().Add(-time.Minute),
|
||||
EndedAt: time.Now(),
|
||||
}
|
||||
if err := st.SaveMatch(fm); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
// Ratings updated and games-played bumped.
|
||||
if r, _ := st.Rating("agent-a", "ttt"); r != 1516 {
|
||||
t.Fatalf("agent-a rating = %v, want 1516", r)
|
||||
}
|
||||
if r, _ := st.Rating("agent-b", "ttt"); r != 1484 {
|
||||
t.Fatalf("agent-b rating = %v, want 1484", r)
|
||||
}
|
||||
|
||||
board, err := st.TopRatings("ttt", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("top: %v", err)
|
||||
}
|
||||
if len(board) != 2 || board[0].User != "agent-a" || board[0].Played != 1 {
|
||||
t.Fatalf("ladder wrong: %+v", board)
|
||||
}
|
||||
|
||||
matches, err := st.RecentMatches("ttt", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("want 1 match, got %d", len(matches))
|
||||
}
|
||||
id := matches[0].ID
|
||||
|
||||
got, ok, err := st.MatchByID(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("match by id: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(got.Moves) != 3 || got.Moves[0].Move != "4" {
|
||||
t.Fatalf("moves not round-tripped: %+v", got.Moves)
|
||||
}
|
||||
if got.Winner != 0 || got.P0 != "agent-a" {
|
||||
t.Fatalf("match meta wrong: %+v", got)
|
||||
}
|
||||
|
||||
// A second match bumps games-played and re-rates.
|
||||
fm2 := fm
|
||||
fm2.RatingBefore = [2]float64{1516, 1484}
|
||||
fm2.RatingAfter = [2]float64{1530, 1470}
|
||||
if err := st.SaveMatch(fm2); err != nil {
|
||||
t.Fatalf("save2: %v", err)
|
||||
}
|
||||
board, _ = st.TopRatings("ttt", 10)
|
||||
if board[0].Played != 2 {
|
||||
t.Fatalf("played should be 2, got %d", board[0].Played)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
)
|
||||
|
||||
// User is a persisted account (member or agent; guests are never stored).
|
||||
|
|
@ -128,9 +130,52 @@ type Store interface {
|
|||
// SetPluginDisabled enables or disables a plugin by ID. Idempotent.
|
||||
SetPluginDisabled(id string, disabled bool) error
|
||||
|
||||
// AgentGames (PRD §5.2): per-game ELO ladder + replayable match log.
|
||||
|
||||
// Rating returns a player's current rating for a game, or
|
||||
// games.DefaultRating if they have no rated history there. It satisfies
|
||||
// games.Store so the matchmaker can read ratings.
|
||||
Rating(user, game string) (float64, error)
|
||||
// SaveMatch records a finished match and upserts both players' ratings.
|
||||
// It satisfies games.Store.
|
||||
SaveMatch(games.FinishedMatch) error
|
||||
// TopRatings returns the n highest-rated players for a game.
|
||||
TopRatings(game string, n int) ([]RatingRow, error)
|
||||
// RecentMatches returns the last n matches for a game, newest first.
|
||||
RecentMatches(game string, n int) ([]MatchRow, error)
|
||||
// MatchByID returns one match (with its moves, for replay).
|
||||
MatchByID(id int64) (MatchRow, bool, error)
|
||||
|
||||
// MintAPIToken creates and stores a fresh bearer token for the WebSocket
|
||||
// game endpoint, bound to username. Returns the token.
|
||||
MintAPIToken(username string) (string, error)
|
||||
// UserByToken resolves an API token to its account name.
|
||||
UserByToken(token string) (string, bool, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
// RatingRow is one ladder entry.
|
||||
type RatingRow struct {
|
||||
User string
|
||||
Rating float64
|
||||
Played int
|
||||
}
|
||||
|
||||
// MatchRow is a recorded match, including its moves for replay.
|
||||
type MatchRow struct {
|
||||
ID int64
|
||||
Game string
|
||||
P0 string
|
||||
P1 string
|
||||
Winner int
|
||||
Reason string
|
||||
Moves []games.Move
|
||||
RatingAfter [2]float64
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
}
|
||||
|
||||
// SessionRow is one connection record from the audit trail.
|
||||
type SessionRow struct {
|
||||
ID int64
|
||||
|
|
@ -305,6 +350,37 @@ CREATE TABLE IF NOT EXISTS plugin_state (
|
|||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS game_ratings (
|
||||
username TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
rating REAL NOT NULL,
|
||||
played INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (username, game)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_ratings_board ON game_ratings(game, rating DESC);
|
||||
CREATE TABLE IF NOT EXISTS game_matches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
game TEXT NOT NULL,
|
||||
p0 TEXT NOT NULL,
|
||||
p1 TEXT NOT NULL,
|
||||
winner INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
moves TEXT NOT NULL DEFAULT '[]',
|
||||
r0_before REAL NOT NULL DEFAULT 0,
|
||||
r1_before REAL NOT NULL DEFAULT 0,
|
||||
r0_after REAL NOT NULL DEFAULT 0,
|
||||
r1_after REAL NOT NULL DEFAULT 0,
|
||||
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
ended_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_matches_game ON game_matches(game, id DESC);
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue