mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
* feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script - auth: add `root` as an admin-console route alias (alongside admin/sysop); still gated by $AGENTBBS_ADMINS — the name confers nothing on its own. - pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps its default capability set, so apt/chown/su work without disabling the download sandbox. - scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so they pick up the current container profile on next `ssh pod@`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(arcade,ui): 80s arcade classics + shared menu theme Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console), Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary lights each game up; missing games are skipped with a discovery hint. Installed on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh behind FETCH_ARCADE (default on). UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description shown only for the focused row) adopted by the hub and arcade menus; the hub groups rows under Features/Sessions headers and the arcade under DOOM/ARCADE/BUILT-IN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
427 lines
9.7 KiB
Go
427 lines
9.7 KiB
Go
// Package agentgames is the in-BBS face of AgentGames (PRD §5.2): humans browse
|
||
// the agent-vs-agent ELO ladders, watch logged match replays, and play the
|
||
// games against a bot for practice. Rated agent matches happen over the game@
|
||
// protocol (internal/games); this plugin is read-only for the ladder/replays
|
||
// and off-ladder for practice.
|
||
package agentgames
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"github.com/profullstack/agentbbs/internal/auth"
|
||
"github.com/profullstack/agentbbs/internal/games"
|
||
"github.com/profullstack/agentbbs/internal/plugin"
|
||
"github.com/profullstack/agentbbs/internal/store"
|
||
"github.com/profullstack/agentbbs/internal/ui"
|
||
)
|
||
|
||
// Plugin is the AgentGames hub entry.
|
||
type Plugin struct{ reg *games.Registry }
|
||
|
||
// New builds the plugin over the shared game catalog.
|
||
func New(reg *games.Registry) Plugin { return Plugin{reg: reg} }
|
||
|
||
func (Plugin) ID() string { return "agentgames" }
|
||
func (Plugin) Title() string { return "AgentGames" }
|
||
func (Plugin) Description() string { return "Agent ladders, match replays, practice vs a bot" }
|
||
func (Plugin) RequiresAuth() bool { return false }
|
||
|
||
func (p Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
||
return &model{
|
||
user: user,
|
||
st: ctx.Store,
|
||
reg: p.reg,
|
||
bot: games.GreedyBot{R: rand.New(rand.NewSource(time.Now().UnixNano()))},
|
||
}
|
||
}
|
||
|
||
type screen int
|
||
|
||
const (
|
||
scGames screen = iota // pick a game
|
||
scMenu // pick an action for the game
|
||
scLadder
|
||
scReplays
|
||
scReplay
|
||
scPlay
|
||
)
|
||
|
||
var (
|
||
title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||
dim = ui.Dim
|
||
cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||
head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
|
||
warn = ui.Danger
|
||
frame = ui.Frame
|
||
)
|
||
|
||
var actions = []string{"Ladder", "Replays", "Play vs bot"}
|
||
|
||
type model struct {
|
||
user auth.User
|
||
st store.Store
|
||
reg *games.Registry
|
||
bot games.Bot
|
||
|
||
screen screen
|
||
cursor int
|
||
note string
|
||
|
||
game games.Game // selected
|
||
|
||
ladder []store.RatingRow
|
||
matches []store.MatchRow
|
||
|
||
// replay
|
||
replay store.MatchRow
|
||
ply int
|
||
|
||
// practice
|
||
state games.State
|
||
human int
|
||
legal []string
|
||
over bool
|
||
winner int
|
||
}
|
||
|
||
func (m *model) Init() tea.Cmd { return nil }
|
||
|
||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
key, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
m.note = ""
|
||
switch key.String() {
|
||
case "ctrl+c":
|
||
return m, tea.Quit
|
||
}
|
||
switch m.screen {
|
||
case scGames:
|
||
return m.updateGames(key)
|
||
case scMenu:
|
||
return m.updateMenu(key)
|
||
case scLadder, scReplays:
|
||
return m.updateList(key)
|
||
case scReplay:
|
||
return m.updateReplay(key)
|
||
case scPlay:
|
||
return m.updatePlay(key)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) updateGames(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
all := m.reg.All()
|
||
switch k.String() {
|
||
case "q", "esc":
|
||
return m, plugin.Exit
|
||
case "up", "k":
|
||
m.up()
|
||
case "down", "j":
|
||
if m.cursor < len(all)-1 {
|
||
m.cursor++
|
||
}
|
||
case "enter", "right", "l":
|
||
if len(all) == 0 {
|
||
return m, nil
|
||
}
|
||
m.game = all[m.cursor]
|
||
m.screen, m.cursor = scMenu, 0
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) updateMenu(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch k.String() {
|
||
case "q":
|
||
return m, plugin.Exit
|
||
case "esc", "left", "h":
|
||
m.screen, m.cursor = scGames, 0
|
||
case "up", "k":
|
||
m.up()
|
||
case "down", "j":
|
||
if m.cursor < len(actions)-1 {
|
||
m.cursor++
|
||
}
|
||
case "enter", "right", "l":
|
||
switch m.cursor {
|
||
case 0:
|
||
m.ladder, _ = m.st.TopRatings(m.game.ID(), 25)
|
||
m.screen, m.cursor = scLadder, 0
|
||
case 1:
|
||
m.matches, _ = m.st.RecentMatches(m.game.ID(), 25)
|
||
m.screen, m.cursor = scReplays, 0
|
||
case 2:
|
||
m.startPractice()
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) updateList(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
n := len(m.ladder)
|
||
if m.screen == scReplays {
|
||
n = len(m.matches)
|
||
}
|
||
switch k.String() {
|
||
case "q":
|
||
return m, plugin.Exit
|
||
case "esc", "left", "h":
|
||
m.screen, m.cursor = scMenu, 0
|
||
case "up", "k":
|
||
m.up()
|
||
case "down", "j":
|
||
if m.cursor < n-1 {
|
||
m.cursor++
|
||
}
|
||
case "enter", "right", "l":
|
||
if m.screen == scReplays && m.cursor < len(m.matches) {
|
||
m.replay = m.matches[m.cursor]
|
||
m.ply = 0
|
||
m.screen = scReplay
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) updateReplay(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch k.String() {
|
||
case "q":
|
||
return m, plugin.Exit
|
||
case "esc", "left", "h":
|
||
m.screen = scReplays
|
||
case "right", "l", " ":
|
||
if m.ply < len(m.replay.Moves) {
|
||
m.ply++
|
||
}
|
||
case "up", "k":
|
||
if m.ply > 0 {
|
||
m.ply--
|
||
}
|
||
case "home", "g":
|
||
m.ply = 0
|
||
case "end", "G":
|
||
m.ply = len(m.replay.Moves)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) updatePlay(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch k.String() {
|
||
case "q":
|
||
return m, plugin.Exit
|
||
case "esc", "left", "h":
|
||
m.screen, m.cursor = scMenu, 0
|
||
case "r":
|
||
m.startPractice()
|
||
case "up", "k":
|
||
m.up()
|
||
case "down", "j":
|
||
if m.cursor < len(m.legal)-1 {
|
||
m.cursor++
|
||
}
|
||
case "enter", " ":
|
||
m.applyHumanMove()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) up() {
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
}
|
||
}
|
||
|
||
func (m *model) startPractice() {
|
||
m.state = m.game.Start()
|
||
m.human = 0 // human is X and moves first
|
||
m.over, m.winner = false, 0
|
||
m.refreshLegal()
|
||
m.screen, m.cursor = scPlay, 0
|
||
}
|
||
|
||
func (m *model) refreshLegal() {
|
||
if m.over, m.winner = m.state.Terminal(); m.over {
|
||
m.legal = nil
|
||
return
|
||
}
|
||
m.legal = m.state.Legal()
|
||
if m.cursor >= len(m.legal) {
|
||
m.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (m *model) applyHumanMove() {
|
||
if m.over || m.state.ToMove() != m.human || m.cursor >= len(m.legal) {
|
||
return
|
||
}
|
||
ns, err := m.state.Apply(m.legal[m.cursor])
|
||
if err != nil {
|
||
m.note = warn.Render("illegal move")
|
||
return
|
||
}
|
||
m.state = ns
|
||
// Bot replies until it is the human's turn again or the game ends.
|
||
for {
|
||
if over, _ := m.state.Terminal(); over {
|
||
break
|
||
}
|
||
if m.state.ToMove() == m.human {
|
||
break
|
||
}
|
||
mv := m.bot.Move(m.state)
|
||
if ns, err := m.state.Apply(mv); err == nil {
|
||
m.state = ns
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
m.cursor = 0
|
||
m.refreshLegal()
|
||
}
|
||
|
||
func (m *model) View() string {
|
||
var body, help string
|
||
switch m.screen {
|
||
case scGames:
|
||
body, help = m.viewGames()
|
||
case scMenu:
|
||
body, help = m.viewMenu()
|
||
case scLadder:
|
||
body, help = m.viewLadder()
|
||
case scReplays:
|
||
body, help = m.viewReplays()
|
||
case scReplay:
|
||
body, help = m.viewReplay()
|
||
case scPlay:
|
||
body, help = m.viewPlay()
|
||
}
|
||
out := title.Render("AgentGames") + "\n\n" + body + "\n" + ui.KeyBar(help)
|
||
if m.note != "" {
|
||
out += "\n" + m.note
|
||
}
|
||
return frame.Render(out)
|
||
}
|
||
|
||
func (m *model) viewGames() (string, string) {
|
||
var b strings.Builder
|
||
b.WriteString(head.Render("Pick a game") + "\n\n")
|
||
for i, g := range m.reg.All() {
|
||
b.WriteString(m.row(i) + g.Title() + " " + dim.Render("("+g.ID()+")") + "\n")
|
||
}
|
||
return b.String(), "↑/↓ move · enter select · q quit"
|
||
}
|
||
|
||
func (m *model) viewMenu() (string, string) {
|
||
var b strings.Builder
|
||
b.WriteString(head.Render(m.game.Title()) + "\n\n")
|
||
for i, a := range actions {
|
||
b.WriteString(m.row(i) + a + "\n")
|
||
}
|
||
return b.String(), "↑/↓ move · enter select · esc back · q quit"
|
||
}
|
||
|
||
func (m *model) viewLadder() (string, string) {
|
||
var b strings.Builder
|
||
b.WriteString(head.Render(m.game.Title()+" — ladder") + "\n\n")
|
||
if len(m.ladder) == 0 {
|
||
b.WriteString(dim.Render(" no rated matches yet — agents play via ssh game@\n"))
|
||
}
|
||
for i, r := range m.ladder {
|
||
fmt.Fprintf(&b, " %2d. %-20s %4.0f %s\n", i+1, r.User, r.Rating, dim.Render(fmt.Sprintf("%d games", r.Played)))
|
||
}
|
||
return b.String(), "esc back · q quit"
|
||
}
|
||
|
||
func (m *model) viewReplays() (string, string) {
|
||
var b strings.Builder
|
||
b.WriteString(head.Render(m.game.Title()+" — replays") + "\n\n")
|
||
if len(m.matches) == 0 {
|
||
b.WriteString(dim.Render(" no matches recorded yet\n"))
|
||
}
|
||
for i, mt := range m.matches {
|
||
fmt.Fprintf(&b, "%s%s vs %s %s\n", m.row(i), mt.P0, mt.P1, dim.Render(outcomeLabel(mt)))
|
||
}
|
||
return b.String(), "↑/↓ move · enter watch · esc back · q quit"
|
||
}
|
||
|
||
func (m *model) viewReplay() (string, string) {
|
||
g, ok := m.reg.Get(m.replay.Game)
|
||
if !ok {
|
||
return warn.Render("unknown game " + m.replay.Game), "esc back"
|
||
}
|
||
s := g.Start()
|
||
for i := 0; i < m.ply && i < len(m.replay.Moves); i++ {
|
||
if ns, err := s.Apply(m.replay.Moves[i].Move); err == nil {
|
||
s = ns
|
||
}
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "%s vs %s %s\n\n", m.replay.P0, m.replay.P1, dim.Render(outcomeLabel(m.replay)))
|
||
b.WriteString(s.Render() + "\n\n")
|
||
fmt.Fprintf(&b, "%s\n", dim.Render(fmt.Sprintf("ply %d/%d", m.ply, len(m.replay.Moves))))
|
||
if m.ply >= len(m.replay.Moves) {
|
||
b.WriteString(resultLine(m.replay.Winner, m.replay.P0, m.replay.P1, m.replay.Reason))
|
||
}
|
||
return b.String(), "→/space next · ↑ prev · g start · G end · esc back"
|
||
}
|
||
|
||
func (m *model) viewPlay() (string, string) {
|
||
var b strings.Builder
|
||
b.WriteString(head.Render(m.game.Title()+" — practice (you are X)") + "\n\n")
|
||
b.WriteString(m.state.Render() + "\n\n")
|
||
if m.over {
|
||
b.WriteString(resultLine(m.winner, "you", m.bot.Name(), ""))
|
||
return b.String(), "r play again · esc back · q quit"
|
||
}
|
||
if m.state.ToMove() == m.human {
|
||
b.WriteString("your move:\n")
|
||
for i, mv := range m.legal {
|
||
b.WriteString(m.row(i) + mv + "\n")
|
||
}
|
||
} else {
|
||
b.WriteString(dim.Render("(bot thinking…)\n"))
|
||
}
|
||
return b.String(), "↑/↓ pick · enter play · r restart · esc back"
|
||
}
|
||
|
||
func (m *model) row(i int) string {
|
||
if i == m.cursor {
|
||
return cursor.Render("❯ ")
|
||
}
|
||
return " "
|
||
}
|
||
|
||
func outcomeLabel(mt store.MatchRow) string {
|
||
switch mt.Winner {
|
||
case games.Draw:
|
||
return "draw"
|
||
case 0:
|
||
return mt.P0 + " won"
|
||
default:
|
||
return mt.P1 + " won"
|
||
}
|
||
}
|
||
|
||
func resultLine(winner int, p0, p1, reason string) string {
|
||
var s string
|
||
switch winner {
|
||
case games.Draw:
|
||
s = "Draw."
|
||
case 0:
|
||
s = p0 + " wins."
|
||
default:
|
||
s = p1 + " wins."
|
||
}
|
||
if reason != "" {
|
||
s += " (" + reason + ")"
|
||
}
|
||
return cursor.Render(s)
|
||
}
|