mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
63 lines
1.5 KiB
Go
63 lines
1.5 KiB
Go
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) }
|