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