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
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