mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +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>
86 lines
2.8 KiB
Go
86 lines
2.8 KiB
Go
// Package games is the AgentGames engine (PRD §5.2): two-player,
|
|
// perfect-information, turn-based games behind a small Gym-style contract that
|
|
// is exposed to agents as line-delimited JSON (see protocol.go). The same
|
|
// engine backs agent-vs-agent matches over SSH/WebSocket, the ELO ladder, the
|
|
// replay store, and the in-BBS human-vs-bot practice mode.
|
|
//
|
|
// We never execute agent code: an agent is a remote client that sends move
|
|
// tokens, which we validate against the state's legal moves. The security
|
|
// posture (PRD §5.2 "untrusted agent input") is therefore strict validation,
|
|
// per-move deadlines, and forfeit-on-illegal-move — not a per-match container.
|
|
package games
|
|
|
|
import "sort"
|
|
|
|
// Result codes for a terminal position's winner.
|
|
const (
|
|
Draw = -1 // the game ended with no winner
|
|
)
|
|
|
|
// Game is a two-player, perfect-information, turn-based game.
|
|
type Game interface {
|
|
ID() string // stable token, e.g. "ttt"
|
|
Title() string // human label, e.g. "Tic-Tac-Toe"
|
|
Start() State // the initial position (player 0 to move)
|
|
}
|
|
|
|
// State is an immutable game position. Apply returns a new State so positions
|
|
// can be cloned and replayed freely.
|
|
type State interface {
|
|
// ToMove is the player (0 or 1) to move; meaningful only when not terminal.
|
|
ToMove() int
|
|
// Legal lists the legal move tokens for the player to move.
|
|
Legal() []string
|
|
// Apply plays move for the player to move, returning the next position.
|
|
// It returns ErrIllegalMove if the move is not currently legal.
|
|
Apply(move string) (State, error)
|
|
// Terminal reports whether the game is over and, if so, the winner (0 or 1)
|
|
// or Draw.
|
|
Terminal() (over bool, winner int)
|
|
// Observe is the JSON-able observation handed to agents: at least the board,
|
|
// whose turn it is, and the legal moves.
|
|
Observe() map[string]any
|
|
// Render is a human-readable board for the TUI and replay viewer.
|
|
Render() string
|
|
}
|
|
|
|
// Registry is an ordered set of games, looked up by ID.
|
|
type Registry struct {
|
|
byID map[string]Game
|
|
order []Game
|
|
}
|
|
|
|
// Catalog is the v1 game catalog (PRD §5.2 phase 1).
|
|
func Catalog() *Registry { return NewRegistry(TTT{}, Connect4{}) }
|
|
|
|
// NewRegistry builds a registry from the given games, preserving order.
|
|
func NewRegistry(gs ...Game) *Registry {
|
|
r := &Registry{byID: make(map[string]Game, len(gs))}
|
|
for _, g := range gs {
|
|
if _, dup := r.byID[g.ID()]; dup {
|
|
continue
|
|
}
|
|
r.byID[g.ID()] = g
|
|
r.order = append(r.order, g)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Get returns the game with this ID.
|
|
func (r *Registry) Get(id string) (Game, bool) {
|
|
g, ok := r.byID[id]
|
|
return g, ok
|
|
}
|
|
|
|
// All returns the games in registration order.
|
|
func (r *Registry) All() []Game { return append([]Game(nil), r.order...) }
|
|
|
|
// IDs returns the registered game IDs, sorted.
|
|
func (r *Registry) IDs() []string {
|
|
out := make([]string, 0, len(r.byID))
|
|
for id := range r.byID {
|
|
out = append(out, id)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|