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>
79 lines
2 KiB
Go
79 lines
2 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/profullstack/agentbbs/internal/games"
|
|
)
|
|
|
|
func TestGameRatingsAndMatches(t *testing.T) {
|
|
st := openTest(t)
|
|
|
|
// Unrated players start at the default.
|
|
if r, _ := st.Rating("agent-a", "ttt"); r != games.DefaultRating {
|
|
t.Fatalf("default rating = %v", r)
|
|
}
|
|
|
|
fm := games.FinishedMatch{
|
|
Game: "ttt",
|
|
Players: [2]string{"agent-a", "agent-b"},
|
|
Winner: 0,
|
|
Moves: []games.Move{{Player: 0, Move: "4"}, {Player: 1, Move: "0"}, {Player: 0, Move: "1"}},
|
|
RatingBefore: [2]float64{1500, 1500},
|
|
RatingAfter: [2]float64{1516, 1484},
|
|
StartedAt: time.Now().Add(-time.Minute),
|
|
EndedAt: time.Now(),
|
|
}
|
|
if err := st.SaveMatch(fm); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
// Ratings updated and games-played bumped.
|
|
if r, _ := st.Rating("agent-a", "ttt"); r != 1516 {
|
|
t.Fatalf("agent-a rating = %v, want 1516", r)
|
|
}
|
|
if r, _ := st.Rating("agent-b", "ttt"); r != 1484 {
|
|
t.Fatalf("agent-b rating = %v, want 1484", r)
|
|
}
|
|
|
|
board, err := st.TopRatings("ttt", 10)
|
|
if err != nil {
|
|
t.Fatalf("top: %v", err)
|
|
}
|
|
if len(board) != 2 || board[0].User != "agent-a" || board[0].Played != 1 {
|
|
t.Fatalf("ladder wrong: %+v", board)
|
|
}
|
|
|
|
matches, err := st.RecentMatches("ttt", 10)
|
|
if err != nil {
|
|
t.Fatalf("recent: %v", err)
|
|
}
|
|
if len(matches) != 1 {
|
|
t.Fatalf("want 1 match, got %d", len(matches))
|
|
}
|
|
id := matches[0].ID
|
|
|
|
got, ok, err := st.MatchByID(id)
|
|
if err != nil || !ok {
|
|
t.Fatalf("match by id: ok=%v err=%v", ok, err)
|
|
}
|
|
if len(got.Moves) != 3 || got.Moves[0].Move != "4" {
|
|
t.Fatalf("moves not round-tripped: %+v", got.Moves)
|
|
}
|
|
if got.Winner != 0 || got.P0 != "agent-a" {
|
|
t.Fatalf("match meta wrong: %+v", got)
|
|
}
|
|
|
|
// A second match bumps games-played and re-rates.
|
|
fm2 := fm
|
|
fm2.RatingBefore = [2]float64{1516, 1484}
|
|
fm2.RatingAfter = [2]float64{1530, 1470}
|
|
if err := st.SaveMatch(fm2); err != nil {
|
|
t.Fatalf("save2: %v", err)
|
|
}
|
|
board, _ = st.TopRatings("ttt", 10)
|
|
if board[0].Played != 2 {
|
|
t.Fatalf("played should be 2, got %d", board[0].Played)
|
|
}
|
|
}
|