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>
144 lines
3 KiB
Go
144 lines
3 KiB
Go
package games
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Connect4 is 7-column × 6-row Connect Four. Player 0 is X, player 1 is O.
|
||
// Moves are column indices "0".."6"; a piece falls to the lowest empty row.
|
||
type Connect4 struct{}
|
||
|
||
func (Connect4) ID() string { return "c4" }
|
||
func (Connect4) Title() string { return "Connect 4" }
|
||
func (Connect4) Start() State { return c4State{} }
|
||
|
||
const (
|
||
c4Cols = 7
|
||
c4Rows = 6
|
||
)
|
||
|
||
// c4State stores cells row-major with row 0 at the TOP. -1 empty, else 0/1.
|
||
type c4State struct {
|
||
cells [c4Rows * c4Cols]int
|
||
filled int // number of pieces placed (for draw detection)
|
||
toMove int
|
||
zero bool // marks an initialized empty board (cells default to 0, not -1)
|
||
}
|
||
|
||
func (s c4State) at(r, c int) int {
|
||
if !s.zero {
|
||
return -1 // fresh board: all empty
|
||
}
|
||
return s.cells[r*c4Cols+c]
|
||
}
|
||
|
||
func (s c4State) ToMove() int { return s.toMove }
|
||
|
||
func (s c4State) Legal() []string {
|
||
if over, _ := s.Terminal(); over {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for c := 0; c < c4Cols; c++ {
|
||
if s.at(0, c) == -1 {
|
||
out = append(out, strconv.Itoa(c))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s c4State) Apply(move string) (State, error) {
|
||
if over, _ := s.Terminal(); over {
|
||
return nil, ErrIllegalMove
|
||
}
|
||
c, err := strconv.Atoi(move)
|
||
if err != nil || c < 0 || c >= c4Cols || s.at(0, c) != -1 {
|
||
return nil, ErrIllegalMove
|
||
}
|
||
ns := s.materialize()
|
||
// Drop to the lowest empty row.
|
||
row := c4Rows - 1
|
||
for row >= 0 && ns.cells[row*c4Cols+c] != -1 {
|
||
row--
|
||
}
|
||
ns.cells[row*c4Cols+c] = s.toMove
|
||
ns.filled = s.filled + 1
|
||
ns.toMove = 1 - s.toMove
|
||
return ns, nil
|
||
}
|
||
|
||
// materialize returns a copy whose backing array is explicitly filled with -1
|
||
// for empties, so Apply can write into it.
|
||
func (s c4State) materialize() c4State {
|
||
if s.zero {
|
||
return s
|
||
}
|
||
ns := s
|
||
for i := range ns.cells {
|
||
ns.cells[i] = -1
|
||
}
|
||
ns.zero = true
|
||
return ns
|
||
}
|
||
|
||
var c4Dirs = [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}} // →, ↓, ↘, ↙
|
||
|
||
func (s c4State) Terminal() (bool, int) {
|
||
for r := 0; r < c4Rows; r++ {
|
||
for c := 0; c < c4Cols; c++ {
|
||
p := s.at(r, c)
|
||
if p == -1 {
|
||
continue
|
||
}
|
||
for _, d := range c4Dirs {
|
||
if s.countRun(r, c, d[0], d[1], p) >= 4 {
|
||
return true, p
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if s.filled >= c4Rows*c4Cols {
|
||
return true, Draw
|
||
}
|
||
return false, 0
|
||
}
|
||
|
||
func (s c4State) countRun(r, c, dr, dc, p int) int {
|
||
n := 0
|
||
for r >= 0 && r < c4Rows && c >= 0 && c < c4Cols && s.at(r, c) == p {
|
||
n++
|
||
r += dr
|
||
c += dc
|
||
}
|
||
return n
|
||
}
|
||
|
||
var c4Glyph = map[int]string{-1: ".", 0: "X", 1: "O"}
|
||
|
||
func (s c4State) Observe() map[string]any {
|
||
board := make([][]string, c4Rows)
|
||
for r := 0; r < c4Rows; r++ {
|
||
board[r] = make([]string, c4Cols)
|
||
for c := 0; c < c4Cols; c++ {
|
||
board[r][c] = c4Glyph[s.at(r, c)]
|
||
}
|
||
}
|
||
return map[string]any{
|
||
"board": board, // [row][col], row 0 is the top
|
||
"toMove": s.toMove,
|
||
"legal": s.Legal(),
|
||
}
|
||
}
|
||
|
||
func (s c4State) Render() string {
|
||
var b strings.Builder
|
||
for r := 0; r < c4Rows; r++ {
|
||
for c := 0; c < c4Cols; c++ {
|
||
b.WriteString(" " + c4Glyph[s.at(r, c)])
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(" 0 1 2 3 4 5 6")
|
||
return b.String()
|
||
}
|