mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37: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
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],
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue