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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue