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>
120 lines
3.7 KiB
Go
120 lines
3.7 KiB
Go
package games
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// The wire protocol is line-delimited JSON (one object per message), identical
|
|
// over SSH (`game@`) and WebSocket. Flow:
|
|
//
|
|
// → client sends {"type":"join","game":"ttt"} (transport handshake)
|
|
// ← server {"type":"hello","player":0,"game":"ttt","opponent":"agent-bob"}
|
|
// ← server {"type":"state","observation":{…},"yourTurn":true} (each ply)
|
|
// → client {"type":"move","move":"4"} (only on your turn)
|
|
// ← server {"type":"result","winner":0,"outcome":"win","rating":1516}
|
|
//
|
|
// Illegal moves, timeouts, and disconnects forfeit the match.
|
|
|
|
// ErrTimeout means a player did not move before the deadline. ErrClosed means
|
|
// the player's connection ended mid-match. Both forfeit.
|
|
var (
|
|
ErrTimeout = errors.New("move timeout")
|
|
ErrClosed = errors.New("connection closed")
|
|
)
|
|
|
|
// PlayerIO is one player's transport for a match. Send writes a server→client
|
|
// message; ReadMove blocks for the next move message until deadline.
|
|
type PlayerIO interface {
|
|
Name() string
|
|
Send(v any) error
|
|
ReadMove(deadline time.Time) (string, error)
|
|
}
|
|
|
|
// Move is one ply in a match, for replay.
|
|
type Move struct {
|
|
Player int `json:"player"`
|
|
Move string `json:"move"`
|
|
}
|
|
|
|
// MatchResult is the outcome of a finished match.
|
|
type MatchResult struct {
|
|
Game string
|
|
Players [2]string
|
|
Winner int // 0, 1, or Draw
|
|
Reason string // "" for a normal finish, else the forfeit cause
|
|
Moves []Move
|
|
}
|
|
|
|
// Outbound message envelopes.
|
|
type helloMsg struct {
|
|
Type string `json:"type"`
|
|
Player int `json:"player"`
|
|
Game string `json:"game"`
|
|
Opponent string `json:"opponent"`
|
|
}
|
|
type stateMsg struct {
|
|
Type string `json:"type"`
|
|
Observation map[string]any `json:"observation"`
|
|
YourTurn bool `json:"yourTurn"`
|
|
}
|
|
|
|
// ResultMsg is the final per-player message. It is sent by the caller (the
|
|
// matchmaker) after ratings are computed, so it carries the player's new ELO.
|
|
type ResultMsg struct {
|
|
Type string `json:"type"`
|
|
Winner int `json:"winner"`
|
|
Outcome string `json:"outcome"` // "win" | "loss" | "draw"
|
|
Reason string `json:"reason,omitempty"`
|
|
Rating float64 `json:"rating"`
|
|
}
|
|
|
|
// RunMatch drives a game to completion between two players, sending hello and
|
|
// per-ply state messages. It never returns a transport error: an I/O failure,
|
|
// timeout, or illegal move forfeits the offending player. The final result
|
|
// message (with ratings) is the caller's responsibility.
|
|
func RunMatch(g Game, p [2]PlayerIO, moveTimeout time.Duration) *MatchResult {
|
|
res := &MatchResult{Game: g.ID(), Players: [2]string{p[0].Name(), p[1].Name()}}
|
|
|
|
_ = p[0].Send(helloMsg{Type: "hello", Player: 0, Game: g.ID(), Opponent: p[1].Name()})
|
|
_ = p[1].Send(helloMsg{Type: "hello", Player: 1, Game: g.ID(), Opponent: p[0].Name()})
|
|
|
|
s := g.Start()
|
|
for {
|
|
if over, winner := s.Terminal(); over {
|
|
res.Winner = winner
|
|
return res
|
|
}
|
|
cur := s.ToMove()
|
|
obs := s.Observe()
|
|
_ = p[0].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 0})
|
|
_ = p[1].Send(stateMsg{Type: "state", Observation: obs, YourTurn: cur == 1})
|
|
|
|
mv, err := p[cur].ReadMove(time.Now().Add(moveTimeout))
|
|
if err != nil {
|
|
res.Winner = 1 - cur
|
|
res.Reason = "forfeit: " + err.Error()
|
|
return res
|
|
}
|
|
ns, err := s.Apply(mv)
|
|
if err != nil {
|
|
res.Winner = 1 - cur
|
|
res.Reason = "forfeit: illegal move"
|
|
return res
|
|
}
|
|
res.Moves = append(res.Moves, Move{Player: cur, Move: mv})
|
|
s = ns
|
|
}
|
|
}
|
|
|
|
// Outcome returns the per-player outcome string for a winner code.
|
|
func Outcome(winner, player int) string {
|
|
switch {
|
|
case winner == Draw:
|
|
return "draw"
|
|
case winner == player:
|
|
return "win"
|
|
default:
|
|
return "loss"
|
|
}
|
|
}
|