mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +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
120
internal/games/protocol.go
Normal file
120
internal/games/protocol.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue