mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
A modern BBS over SSH for humans and AI agents (docs/PRD.md), plus the
pods addendum (docs/pods.md). Go + charmbracelet (wish/bubbletea).
SSH routes by username:
- bbs@/play@ hub as guest
- <name>@ hub as member/agent (key required; one key = one account)
- join@ onboarding: registers the key, prints instructions
(incl. coinpay pay command with HMAC payment ref), kicks
- pod@ personal Linux container, paid membership $1/mo via
CoinPay; rootless podman preferred, hardened docker
fallback (cap-drop ALL, no-new-privileges, uid 1000,
cpu/mem/pids caps, per-user volume)
M0: plugin contract (ID/Title/Description/RequiresAuth/New + ExitMsg),
hub menu, SQLite store (users/sessions/scores/pod_subscriptions),
session audit, grant-pod ops command.
M1 arcade: doom-ascii + Freedoom via scripts/fetch-assets.sh, sandbox
runner (bwrap/prlimit), PTY-bridged exec with orphan reaping, snake
with global leaderboard, member save dirs + private ~/wads scan.
Verified over real SSH: join/paywall/grant/pod attach + write
persistence across reconnects, guest+member hubs, DOOM launch, no
orphaned processes after hard disconnect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
5.3 KiB
Go
201 lines
5.3 KiB
Go
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
|
|
// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in
|
|
// TUI games (snake) feed the global leaderboards.
|
|
package arcade
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/profullstack/agentbbs/internal/auth"
|
|
"github.com/profullstack/agentbbs/internal/plugin"
|
|
)
|
|
|
|
type Plugin struct{}
|
|
|
|
func (Plugin) ID() string { return "arcade" }
|
|
func (Plugin) Title() string { return "Arcade" }
|
|
func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" }
|
|
func (Plugin) RequiresAuth() bool { return false }
|
|
|
|
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
|
return newMenu(user, ctx)
|
|
}
|
|
|
|
// entry is one row in the arcade menu.
|
|
type entry struct {
|
|
label string
|
|
desc string
|
|
run func(m *menu) (tea.Model, tea.Cmd)
|
|
}
|
|
|
|
type menu struct {
|
|
user auth.User
|
|
ctx plugin.Context
|
|
entries []entry
|
|
cursor int
|
|
width int
|
|
height int
|
|
note string
|
|
child tea.Model // snake / leaderboard take over here
|
|
}
|
|
|
|
var (
|
|
tStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fbbf24"))
|
|
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
cStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24"))
|
|
eStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
|
)
|
|
|
|
func newMenu(user auth.User, ctx plugin.Context) *menu {
|
|
m := &menu{user: user, ctx: ctx}
|
|
for _, wad := range findWADs(ctx, user) {
|
|
wad := wad
|
|
m.entries = append(m.entries, entry{
|
|
label: "DOOM — " + filepath.Base(wad),
|
|
desc: "doom-ascii in a sandbox (24-bit color terminal recommended)",
|
|
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) },
|
|
})
|
|
}
|
|
if len(m.entries) == 0 {
|
|
m.entries = append(m.entries, entry{
|
|
label: "DOOM — not installed",
|
|
desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom",
|
|
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil },
|
|
})
|
|
}
|
|
m.entries = append(m.entries,
|
|
entry{
|
|
label: "Snake",
|
|
desc: "built-in; high scores hit the global leaderboard",
|
|
run: func(m *menu) (tea.Model, tea.Cmd) {
|
|
m.child = newSnake(m.user, m.ctx, m.width, m.height)
|
|
return m, m.child.Init()
|
|
},
|
|
},
|
|
entry{
|
|
label: "Leaderboard",
|
|
desc: "global top scores",
|
|
run: func(m *menu) (tea.Model, tea.Cmd) {
|
|
m.child = newBoard(m.ctx)
|
|
return m, m.child.Init()
|
|
},
|
|
},
|
|
)
|
|
return m
|
|
}
|
|
|
|
// findWADs lists platform WADs plus the member's own ~/wads (PRD §5.1, §9.1).
|
|
func findWADs(ctx plugin.Context, user auth.User) []string {
|
|
if doomBin(ctx) == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
dirs := []string{filepath.Join(ctx.AssetsDir, "wads")}
|
|
if user.Kind != auth.Guest && ctx.DataDir != "" {
|
|
dirs = append(dirs, filepath.Join(ctx.DataDir, "wads"))
|
|
}
|
|
for _, dir := range dirs {
|
|
matches, _ := filepath.Glob(filepath.Join(dir, "*.wad"))
|
|
matchesUpper, _ := filepath.Glob(filepath.Join(dir, "*.WAD"))
|
|
out = append(out, append(matches, matchesUpper...)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func doomBin(ctx plugin.Context) string {
|
|
p := filepath.Join(ctx.AssetsDir, "bin", "doom_ascii")
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// launchDoom suspends the TUI and bridges the session to a sandboxed
|
|
// doom-ascii on a real PTY. Savegames land in the per-user work dir.
|
|
func (m *menu) launchDoom(wad string) tea.Cmd {
|
|
bin := doomBin(m.ctx)
|
|
work := m.ctx.DataDir
|
|
if work == "" { // guests: throwaway saves
|
|
work, _ = os.MkdirTemp("", "agentbbs-guest-doom-")
|
|
} else {
|
|
work = filepath.Join(work, "doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))
|
|
_ = os.MkdirAll(work, 0o755)
|
|
}
|
|
cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad)
|
|
return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg {
|
|
return doomDoneMsg{err: err}
|
|
})
|
|
}
|
|
|
|
type doomDoneMsg struct{ err error }
|
|
|
|
func (m *menu) Init() tea.Cmd { return nil }
|
|
|
|
func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
|
m.width, m.height = ws.Width, ws.Height
|
|
}
|
|
if m.child != nil {
|
|
if _, ok := msg.(backMsg); ok {
|
|
m.child = nil
|
|
return m, nil
|
|
}
|
|
next, cmd := m.child.Update(msg)
|
|
m.child = next
|
|
return m, cmd
|
|
}
|
|
switch msg := msg.(type) {
|
|
case doomDoneMsg:
|
|
if msg.err != nil {
|
|
m.note = "doom exited: " + msg.err.Error()
|
|
}
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
m.note = ""
|
|
switch msg.String() {
|
|
case "q", "esc":
|
|
return m, plugin.Exit
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "down", "j":
|
|
if m.cursor < len(m.entries)-1 {
|
|
m.cursor++
|
|
}
|
|
case "enter":
|
|
return m.entries[m.cursor].run(m)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *menu) View() string {
|
|
if m.child != nil {
|
|
return m.child.View()
|
|
}
|
|
s := tStyle.Render("Arcade") + "\n\n"
|
|
for i, e := range m.entries {
|
|
cur := " "
|
|
if i == m.cursor {
|
|
cur = cStyle.Render("> ")
|
|
}
|
|
s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc))
|
|
}
|
|
s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub")
|
|
if m.note != "" {
|
|
s += "\n" + eStyle.Render(m.note)
|
|
}
|
|
return lipgloss.NewStyle().Padding(1, 2).Render(s)
|
|
}
|
|
|
|
// backMsg returns from a child (snake/leaderboard) to the arcade menu.
|
|
type backMsg struct{}
|
|
|
|
func back() tea.Msg { return backMsg{} }
|