mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
AgentBBS: M0 core hub, M1 arcade, pods with CoinPay membership
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>
This commit is contained in:
commit
f3b085a08f
21 changed files with 2405 additions and 0 deletions
201
plugins/arcade/arcade.go
Normal file
201
plugins/arcade/arcade.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// 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{} }
|
||||
58
plugins/arcade/board.go
Normal file
58
plugins/arcade/board.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package arcade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
// board renders the global top scores (PRD §5.1 leaderboards).
|
||||
type board struct {
|
||||
ctx plugin.Context
|
||||
scores []store.Score
|
||||
err error
|
||||
}
|
||||
|
||||
func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} }
|
||||
|
||||
func (b *board) Init() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
scores, err := b.ctx.Store.TopScores("snake", 10)
|
||||
return boardMsg{scores: scores, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
type boardMsg struct {
|
||||
scores []store.Score
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case boardMsg:
|
||||
b.scores, b.err = msg.scores, msg.err
|
||||
case tea.KeyMsg:
|
||||
return b, back
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *board) View() string {
|
||||
s := tStyle.Render("Leaderboard — snake") + "\n\n"
|
||||
switch {
|
||||
case b.err != nil:
|
||||
s += eStyle.Render("error: " + b.err.Error())
|
||||
case len(b.scores) == 0:
|
||||
s += dStyle.Render("no scores yet — be the first")
|
||||
default:
|
||||
for i, sc := range b.scores {
|
||||
s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score)
|
||||
}
|
||||
}
|
||||
s += "\n" + dStyle.Render("any key to return")
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(s)
|
||||
}
|
||||
63
plugins/arcade/ptyexec.go
Normal file
63
plugins/arcade/ptyexec.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package arcade
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// ptyExec is a tea.ExecCommand that runs the child on a real host PTY and
|
||||
// bridges it to the session streams. Needed because doom-ascii (like most
|
||||
// raw-mode terminal programs) demands an actual TTY, and over SSH the
|
||||
// bubbletea program's stdin/stdout are session streams, not a host TTY.
|
||||
type ptyExec struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newPtyExec(cmd *exec.Cmd, width, height int) *ptyExec {
|
||||
return &ptyExec{cmd: cmd, width: width, height: height}
|
||||
}
|
||||
|
||||
func (p *ptyExec) SetStdin(r io.Reader) { p.stdin = r }
|
||||
func (p *ptyExec) SetStdout(w io.Writer) { p.stdout = w }
|
||||
func (p *ptyExec) SetStderr(io.Writer) {}
|
||||
|
||||
var _ tea.ExecCommand = (*ptyExec)(nil)
|
||||
|
||||
func (p *ptyExec) Run() error {
|
||||
f, err := pty.StartWithSize(p.cmd, &pty.Winsize{
|
||||
Rows: uint16(max(p.height, 24)),
|
||||
Cols: uint16(max(p.width, 80)),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { _, _ = io.Copy(p.stdout, f); close(done) }()
|
||||
go func() {
|
||||
_, _ = io.Copy(f, p.stdin)
|
||||
// Session input is gone (disconnect): don't leave the game orphaned
|
||||
// on the host (PRD §7 S3 — abandoned sessions are reaped).
|
||||
if p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
}()
|
||||
|
||||
err = p.cmd.Wait()
|
||||
<-done
|
||||
return err
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
169
plugins/arcade/snake.go
Normal file
169
plugins/arcade/snake.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package arcade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
)
|
||||
|
||||
// snake is the built-in leaderboard game: simple, fair, trivially judged.
|
||||
type snake struct {
|
||||
user auth.User
|
||||
ctx plugin.Context
|
||||
|
||||
w, h int // board size in cells
|
||||
body []pos
|
||||
dir pos
|
||||
food pos
|
||||
score int64
|
||||
dead bool
|
||||
saved bool
|
||||
}
|
||||
|
||||
type pos struct{ x, y int }
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func newSnake(user auth.User, ctx plugin.Context, termW, termH int) *snake {
|
||||
w, h := 32, 16
|
||||
if termW > 0 && termW/2-4 < w {
|
||||
w = termW/2 - 4
|
||||
}
|
||||
if termH > 0 && termH-8 < h {
|
||||
h = termH - 8
|
||||
}
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
if h < 8 {
|
||||
h = 8
|
||||
}
|
||||
s := &snake{user: user, ctx: ctx, w: w, h: h,
|
||||
body: []pos{{w / 2, h / 2}}, dir: pos{1, 0}}
|
||||
s.placeFood()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *snake) placeFood() {
|
||||
for {
|
||||
p := pos{rand.Intn(s.w), rand.Intn(s.h)}
|
||||
if !s.hits(p) {
|
||||
s.food = p
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *snake) hits(p pos) bool {
|
||||
for _, b := range s.body {
|
||||
if b == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *snake) Init() tea.Cmd { return tick() }
|
||||
|
||||
func (s *snake) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
return s, back
|
||||
case "up", "w":
|
||||
if s.dir.y == 0 {
|
||||
s.dir = pos{0, -1}
|
||||
}
|
||||
case "down", "s":
|
||||
if s.dir.y == 0 {
|
||||
s.dir = pos{0, 1}
|
||||
}
|
||||
case "left", "a":
|
||||
if s.dir.x == 0 {
|
||||
s.dir = pos{-1, 0}
|
||||
}
|
||||
case "right", "d":
|
||||
if s.dir.x == 0 {
|
||||
s.dir = pos{1, 0}
|
||||
}
|
||||
case "r":
|
||||
if s.dead {
|
||||
ns := newSnake(s.user, s.ctx, 0, 0)
|
||||
ns.w, ns.h = s.w, s.h
|
||||
return ns, ns.Init()
|
||||
}
|
||||
}
|
||||
case tickMsg:
|
||||
if s.dead {
|
||||
return s, nil
|
||||
}
|
||||
head := pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y}
|
||||
if head.x < 0 || head.y < 0 || head.x >= s.w || head.y >= s.h || s.hits(head) {
|
||||
s.dead = true
|
||||
// Guests play, members persist (PRD §5.1).
|
||||
if !s.saved && s.user.Kind != auth.Guest && s.user.StoreID > 0 && s.score > 0 {
|
||||
_ = s.ctx.Store.AddScore(s.user.StoreID, "snake", s.score)
|
||||
s.saved = true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
s.body = append([]pos{head}, s.body...)
|
||||
if head == s.food {
|
||||
s.score += 10
|
||||
s.placeFood()
|
||||
} else {
|
||||
s.body = s.body[:len(s.body)-1]
|
||||
}
|
||||
return s, tick()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
var (
|
||||
snakeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
foodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
wallStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
)
|
||||
|
||||
func (s *snake) View() string {
|
||||
out := fmt.Sprintf("Snake — score %d", s.score)
|
||||
if s.dead {
|
||||
out += " ☠ dead (r restart · q back)"
|
||||
}
|
||||
out += "\n" + wallStyle.Render("┌"+repeat("──", s.w)+"┐") + "\n"
|
||||
for y := 0; y < s.h; y++ {
|
||||
row := wallStyle.Render("│")
|
||||
for x := 0; x < s.w; x++ {
|
||||
switch {
|
||||
case s.hits(pos{x, y}):
|
||||
row += snakeStyle.Render("██")
|
||||
case s.food == pos{x, y}:
|
||||
row += foodStyle.Render("◆ ")
|
||||
default:
|
||||
row += " "
|
||||
}
|
||||
}
|
||||
out += row + wallStyle.Render("│") + "\n"
|
||||
}
|
||||
out += wallStyle.Render("└" + repeat("──", s.w) + "┘")
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(out)
|
||||
}
|
||||
|
||||
func repeat(s string, n int) string {
|
||||
out := ""
|
||||
for i := 0; i < n; i++ {
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue