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>
100 lines
2.3 KiB
Go
100 lines
2.3 KiB
Go
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
|
|
}
|
|
}
|
|
}
|