Add himalaya + meli mail clients and Shedding Snake arcade game (#88)

Mail clients (internal/mailclients): launch himalaya and meli as
alternatives to the built-in AgentMail reader, pointed at the member's
mailbox with the same master IMAP / loopback-SMTP creds. They run
host-side (secrets never enter a pod), from a throwaway per-session
config that is deleted on exit; operators can override the config
template and argv via env. Wired into the hub ("Mail · Himalaya/Meli",
locked when the binary is absent) and the mail@ route
(ssh -t mail@host himalaya|meli).

Shedding Snake (plugins/arcade): a molting twist on Snake inspired by
cha.rlie.co/shedding-snake. The snake barely grows — each apple sheds
its whole body as a permanent field of scales you must not bite. Walls
wrap, speed ramps up, scales age fresh-teal → dusty-gray, and molt
counts feed a global "shedsnake" leaderboard. Grace period lets you
slither off a fresh molt.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-10 02:30:12 -07:00 committed by GitHub
parent ff9eef907d
commit b51fa5c0e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 947 additions and 0 deletions

View file

@ -73,6 +73,7 @@ import (
"github.com/profullstack/agentbbs/internal/ircpass" "github.com/profullstack/agentbbs/internal/ircpass"
"github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/mailbox" "github.com/profullstack/agentbbs/internal/mailbox"
"github.com/profullstack/agentbbs/internal/mailclients"
"github.com/profullstack/agentbbs/internal/mailu" "github.com/profullstack/agentbbs/internal/mailu"
"github.com/profullstack/agentbbs/internal/motd" "github.com/profullstack/agentbbs/internal/motd"
"github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/news"
@ -620,6 +621,26 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
}}, }},
}) })
// Alternative mail clients: himalaya and meli, pointed at the same mailbox as
// the built-in AgentMail reader. Same lock rules as Mail, plus a lock when the
// binary isn't installed on this host so the entry explains itself.
for _, mc := range []mailclients.Client{mailclients.Himalaya, mailclients.Meli} {
mc := mc
lock := mailLock
if lock == "" && !mc.Available() {
lock = mc.Name() + " is not installed on this host"
}
apps = append(apps, hub.SessionApp{
Title: "Mail · " + mc.Title(),
Description: mc.Name() + " on your " + a.mailAddress(su.Name) + " mailbox",
Locked: lock,
Cmd: sessionExec{run: func() error {
_ = a.ensureMailbox(su)
return mc.Run(s, a.mailBackendFor(su))
}},
})
}
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod. // Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
torLock := "" torLock := ""
switch { switch {
@ -1668,6 +1689,51 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil
} }
// mailBackendFor resolves the same IMAP/SMTP connection mailClientFor uses, but
// as a plain mailclients.Backend so third-party clients (himalaya, meli) can be
// pointed at the member's mailbox. It reads the identical env so all three
// clients behave the same; the master secret stays on the host (these clients
// run host-side, never in a pod — see internal/mailclients).
func (a *app) mailBackendFor(su store.User) mailclients.Backend {
login := a.mailAddress(su.Name)
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
login = a.mailAddress(su.Name) + "*" + master
}
imapHost, imapPort := splitHostPort(env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), 993)
smtpHost, smtpPort := splitHostPort(env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), 25)
return mailclients.Backend{
Name: su.Name,
Address: a.mailAddress(su.Name),
DisplayName: su.Name,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPPlaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
// The default submission path is the trusted, unauthenticated loopback
// relay (127.0.0.1:25) the gateway uses — no STARTTLS. Operators pointing
// at a real submission port should set AGENTBBS_*_CONFIG_TEMPLATE.
SMTPPlaintext: os.Getenv("AGENTBBS_MAIL_SMTP_STARTTLS") != "1",
Login: login,
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
}
}
// splitHostPort splits a "host:port" into its parts, falling back to def when no
// port is present or it doesn't parse. Unlike net.SplitHostPort it tolerates a
// bare host.
func splitHostPort(addr string, def int) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, def
}
port, err := strconv.Atoi(portStr)
if err != nil {
return host, def
}
return host, port
}
// handleMail routes a Founding Lifetime member into AgentMail: an interactive // handleMail routes a Founding Lifetime member into AgentMail: an interactive
// TUI when a PTY is present and no command is given, or the JSON bot mode // TUI when a PTY is present and no command is given, or the JSON bot mode
// (ssh mail@host <cmd>, or no PTY) for agents. // (ssh mail@host <cmd>, or no PTY) for agents.
@ -1704,6 +1770,30 @@ func (a *app) handleMail(s ssh.Session) {
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail") sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
defer func() { _ = a.st.EndSession(sessID) }() defer func() { _ = a.st.EndSession(sessID) }()
// `ssh -t mail@host himalaya|meli` opens an alternative client instead of the
// built-in AgentMail reader. Requires a PTY (both are interactive).
if args := s.Command(); len(args) > 0 {
var mc mailclients.Client
switch strings.ToLower(args[0]) {
case "himalaya":
mc = mailclients.Himalaya
case "meli":
mc = mailclients.Meli
}
if mc.Name() != "" {
if _, _, hasPty := s.Pty(); !hasPty {
wish.Println(s, mc.Name()+" needs an interactive terminal — connect with: ssh -t "+mc.Name()+"@... (use: ssh -t mail@"+a.host+" "+mc.Name()+")")
_ = s.Exit(1)
return
}
if err := mc.Run(s, a.mailBackendFor(u)); err != nil {
wish.Println(s, "mail: "+err.Error())
_ = s.Exit(1)
}
return
}
}
c, err := a.mailClientFor(u) c, err := a.mailClientFor(u)
if err != nil { if err != nil {
wish.Println(s, "mail: "+err.Error()) wish.Println(s, "mail: "+err.Error())

View file

@ -0,0 +1,312 @@
// Package mailclients launches third-party terminal email clients — himalaya
// (https://github.com/pimalaya/himalaya) and meli (https://meli-email.org) —
// against a member's AgentBBS mailbox, as alternatives to the built-in
// AgentMail reader (internal/mailbox).
//
// Like the built-in client, these run ON THE HOST, not inside the member's pod:
// AgentBBS mailboxes are opened with a single Dovecot master-user secret and
// submitted through the loopback relay, and neither of those must ever leak into
// a pod (see docs/mail.md). We generate a throwaway per-session config that
// points the client at exactly the same IMAP/SMTP paths the built-in client
// uses, attach the client to the SSH PTY, and delete the config on exit.
//
// Client config schemas drift between releases, so every field the default
// template renders is also overridable: set AGENTBBS_HIMALAYA_CONFIG_TEMPLATE /
// AGENTBBS_MELI_CONFIG_TEMPLATE to a template path to replace the built-in
// config wholesale, and AGENTBBS_HIMALAYA_ARGS / AGENTBBS_MELI_ARGS to change
// the argv. Placeholders available to a template are documented on Backend.
package mailclients
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/ssh"
"github.com/creack/pty"
)
// Backend is the resolved connection the launched client is pointed at. It
// mirrors mailbox.IMAPConfig: the login is the Dovecot master-user login
// ("<addr>*<master>"), the password is the master secret, and SMTP is the
// unauthenticated loopback relay. The caller builds this from the same env the
// built-in client reads so all three clients behave identically.
//
// Template placeholders (for AGENTBBS_*_CONFIG_TEMPLATE overrides):
//
// {{name}} bare handle, e.g. alice
// {{address}} full address, e.g. alice@bbs.profullstack.com
// {{display_name}} display name (defaults to {{name}})
// {{login}} IMAP login (master-user form)
// {{password}} IMAP/SMTP password (master secret)
// {{imap_host}} IMAP host
// {{imap_port}} IMAP port
// {{imap_encryption}} "none" when plaintext, else "tls"
// {{smtp_host}} SMTP host
// {{smtp_port}} SMTP port
// {{smtp_encryption}} "none" when the relay is loopback/plaintext, else "start-tls"
type Backend struct {
Name string // bare handle
Address string // full email address
DisplayName string // defaults to Name when empty
IMAPHost string
IMAPPort int
IMAPPlaintext bool // dial IMAP without TLS (loopback → Dovecot master login)
SMTPHost string
SMTPPort int
// SMTPPlaintext is true for the unauthenticated loopback relay (no STARTTLS).
SMTPPlaintext bool
Login string // IMAP login (may be "<addr>*<master>")
Password string // master secret
}
func (b Backend) display() string {
if b.DisplayName != "" {
return b.DisplayName
}
return b.Name
}
func (b Backend) imapEncryption() string {
if b.IMAPPlaintext {
return "none"
}
return "tls"
}
func (b Backend) smtpEncryption() string {
if b.SMTPPlaintext {
return "none"
}
return "start-tls"
}
// placeholders returns the template substitution map.
func (b Backend) placeholders() map[string]string {
return map[string]string{
"{{name}}": b.Name,
"{{address}}": b.Address,
"{{display_name}}": b.display(),
"{{login}}": b.Login,
"{{password}}": b.Password,
"{{imap_host}}": b.IMAPHost,
"{{imap_port}}": strconv.Itoa(b.IMAPPort),
"{{imap_encryption}}": b.imapEncryption(),
"{{smtp_host}}": b.SMTPHost,
"{{smtp_port}}": strconv.Itoa(b.SMTPPort),
"{{smtp_encryption}}": b.smtpEncryption(),
}
}
func render(tmpl string, ph map[string]string) string {
out := tmpl
for k, v := range ph {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// Client identifies one of the supported third-party clients.
type Client struct {
name string // "himalaya" | "meli"
title string // display title, e.g. "Himalaya"
binEnv string // env var overriding the binary path
binName string // default binary name looked up on PATH
argsEnv string // env var overriding the argv (space-split)
tmplEnv string // env var overriding the config template
configRel string // config path relative to the session HOME (XDG_CONFIG_HOME)
defArgs func(cfg string) []string
template string
}
// Himalaya is the pimalaya CLI email client.
var Himalaya = Client{
name: "himalaya",
title: "Himalaya",
binEnv: "AGENTBBS_HIMALAYA_BIN",
binName: "himalaya",
argsEnv: "AGENTBBS_HIMALAYA_ARGS",
tmplEnv: "AGENTBBS_HIMALAYA_CONFIG_TEMPLATE",
configRel: ".config/himalaya/config.toml",
defArgs: func(cfg string) []string { return []string{"-c", cfg} },
template: himalayaTemplate,
}
// Meli is the meli-email.org full-screen TUI email client.
var Meli = Client{
name: "meli",
title: "Meli",
binEnv: "AGENTBBS_MELI_BIN",
binName: "meli",
argsEnv: "AGENTBBS_MELI_ARGS",
tmplEnv: "AGENTBBS_MELI_CONFIG_TEMPLATE",
configRel: ".config/meli/config.toml",
defArgs: func(cfg string) []string { return []string{"-c", cfg} },
template: meliTemplate,
}
// Available reports whether the client binary can be found, so the hub can lock
// the entry with a helpful reason instead of failing on launch.
func (c Client) Available() bool { _, err := c.binary(); return err == nil }
// Name is the client's short name ("himalaya" / "meli").
func (c Client) Name() string { return c.name }
// Title is the client's display title, e.g. "Himalaya".
func (c Client) Title() string { return c.title }
func (c Client) binary() (string, error) {
if p := os.Getenv(c.binEnv); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("%s: %s not found (%s)", c.name, p, c.binEnv)
}
return p, nil
}
p, err := exec.LookPath(c.binName)
if err != nil {
return "", fmt.Errorf("%s is not installed on this host (looked for %q on PATH; set %s to override)", c.name, c.binName, c.binEnv)
}
return p, nil
}
func (c Client) configTemplate() (string, error) {
if p := os.Getenv(c.tmplEnv); p != "" {
b, err := os.ReadFile(p)
if err != nil {
return "", fmt.Errorf("%s: reading %s: %w", c.name, c.tmplEnv, err)
}
return string(b), nil
}
return c.template, nil
}
func (c Client) argv(bin, cfg string) []string {
if v := os.Getenv(c.argsEnv); strings.TrimSpace(v) != "" {
// The override is the argv AFTER the binary; render {{config}} so callers
// can place the -c flag wherever the client wants it.
fields := strings.Fields(v)
for i, f := range fields {
fields[i] = strings.ReplaceAll(f, "{{config}}", cfg)
}
return append([]string{bin}, fields...)
}
return append([]string{bin}, c.defArgs(cfg)...)
}
// Run generates a throwaway config for be, launches the client attached to the
// SSH session's PTY, and cleans up on exit. A PTY is required (ssh -t).
func (c Client) Run(s ssh.Session, be Backend) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
return fmt.Errorf("%s needs an interactive terminal — connect with: ssh -t", c.name)
}
bin, err := c.binary()
if err != nil {
return err
}
tmpl, err := c.configTemplate()
if err != nil {
return err
}
home, err := os.MkdirTemp("", "agentbbs-"+c.name+"-")
if err != nil {
return fmt.Errorf("%s: temp dir: %w", c.name, err)
}
// The config holds the master secret; make sure it is only ever mode 0600 and
// removed when the session ends, even on panic.
defer os.RemoveAll(home)
cfgPath := filepath.Join(home, filepath.FromSlash(c.configRel))
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil {
return fmt.Errorf("%s: config dir: %w", c.name, err)
}
if err := os.WriteFile(cfgPath, []byte(render(tmpl, be.placeholders())), 0o600); err != nil {
return fmt.Errorf("%s: write config: %w", c.name, err)
}
argv := c.argv(bin, cfgPath)
cmd := exec.Command(argv[0], argv[1:]...)
// Isolate the client entirely inside the temp HOME so it can never read or
// write the host account's real dotfiles, and so its cache/state is disposable.
cmd.Env = append(os.Environ(),
"HOME="+home,
"XDG_CONFIG_HOME="+filepath.Join(home, ".config"),
"XDG_DATA_HOME="+filepath.Join(home, ".local", "share"),
"XDG_CACHE_HOME="+filepath.Join(home, ".cache"),
"XDG_STATE_HOME="+filepath.Join(home, ".local", "state"),
"TERM="+ptyReq.Term,
)
f, err := pty.Start(cmd)
if err != nil {
return fmt.Errorf("%s: start: %w", c.name, err)
}
defer f.Close()
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
go func() {
for w := range winCh {
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
}
}()
go func() { _, _ = io.Copy(f, s) }() // ssh -> client
_, _ = io.Copy(s, f) // client -> ssh
return cmd.Wait()
}
// himalayaTemplate is himalaya's TOML config (schema v1.x). The SMTP relay is
// the unauthenticated loopback submission the gateway uses, so no send auth is
// configured; override AGENTBBS_HIMALAYA_CONFIG_TEMPLATE if your relay differs.
const himalayaTemplate = `# Generated per-session by AgentBBS do not edit; changes are discarded.
[accounts.agentbbs]
default = true
email = "{{address}}"
display-name = "{{display_name}}"
backend.type = "imap"
backend.host = "{{imap_host}}"
backend.port = {{imap_port}}
backend.encryption.type = "{{imap_encryption}}"
backend.login = "{{login}}"
backend.auth.type = "password"
backend.auth.raw = "{{password}}"
message.send.backend.type = "smtp"
message.send.backend.host = "{{smtp_host}}"
message.send.backend.port = {{smtp_port}}
message.send.backend.encryption.type = "{{smtp_encryption}}"
`
// meliTemplate is meli's TOML config. IMAP uses the master-user login; sending
// goes through the loopback relay with no auth/TLS (type = "none").
const meliTemplate = `# Generated per-session by AgentBBS do not edit; changes are discarded.
[accounts.agentbbs]
root_mailbox = "INBOX"
format = "imap"
identity = "{{address}}"
display_name = "{{display_name}}"
subscribed_mailboxes = ["INBOX", "Sent", "Drafts", "Trash", "Junk"]
server_hostname = "{{imap_host}}"
server_username = "{{login}}"
server_password = "{{password}}"
server_port = {{imap_port}}
use_starttls = false
danger_accept_invalid_certs = true
[accounts.agentbbs.send_mail]
hostname = "{{smtp_host}}"
port = {{smtp_port}}
[accounts.agentbbs.send_mail.auth]
type = "none"
[accounts.agentbbs.send_mail.security]
type = "none"
`

View file

@ -0,0 +1,126 @@
package mailclients
import (
"os"
"strings"
"testing"
)
func testBackend() Backend {
return Backend{
Name: "alice",
Address: "alice@bbs.profullstack.com",
DisplayName: "alice",
IMAPHost: "127.0.0.1",
IMAPPort: 143,
IMAPPlaintext: true,
SMTPHost: "127.0.0.1",
SMTPPort: 25,
SMTPPlaintext: true,
Login: "alice@bbs.profullstack.com*gateway",
Password: "s3cr3t",
}
}
func TestHimalayaTemplateRenders(t *testing.T) {
out := render(Himalaya.template, testBackend().placeholders())
for _, want := range []string{
`email = "alice@bbs.profullstack.com"`,
`backend.host = "127.0.0.1"`,
`backend.port = 143`,
`backend.encryption.type = "none"`, // plaintext IMAP
`backend.login = "alice@bbs.profullstack.com*gateway"`,
`backend.auth.raw = "s3cr3t"`,
`message.send.backend.port = 25`,
`message.send.backend.encryption.type = "none"`, // loopback relay
} {
if !strings.Contains(out, want) {
t.Errorf("himalaya config missing %q\n---\n%s", want, out)
}
}
// No placeholder should survive rendering.
if strings.Contains(out, "{{") {
t.Errorf("unrendered placeholder in himalaya config:\n%s", out)
}
}
func TestMeliTemplateRenders(t *testing.T) {
out := render(Meli.template, testBackend().placeholders())
for _, want := range []string{
`identity = "alice@bbs.profullstack.com"`,
`server_hostname = "127.0.0.1"`,
`server_username = "alice@bbs.profullstack.com*gateway"`,
`server_password = "s3cr3t"`,
`server_port = 143`,
`port = 25`,
`type = "none"`,
} {
if !strings.Contains(out, want) {
t.Errorf("meli config missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "{{") {
t.Errorf("unrendered placeholder in meli config:\n%s", out)
}
}
func TestEncryptionSwitchesWithTLS(t *testing.T) {
be := testBackend()
be.IMAPPlaintext = false
be.SMTPPlaintext = false
out := render(Himalaya.template, be.placeholders())
if !strings.Contains(out, `backend.encryption.type = "tls"`) {
t.Errorf("expected tls IMAP encryption, got:\n%s", out)
}
if !strings.Contains(out, `message.send.backend.encryption.type = "start-tls"`) {
t.Errorf("expected start-tls SMTP encryption, got:\n%s", out)
}
}
func TestConfigTemplateOverride(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "tmpl-*.toml")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString("custom {{address}}\n"); err != nil {
t.Fatal(err)
}
_ = f.Close()
t.Setenv(Himalaya.tmplEnv, f.Name())
tmpl, err := Himalaya.configTemplate()
if err != nil {
t.Fatal(err)
}
out := render(tmpl, testBackend().placeholders())
if out != "custom alice@bbs.profullstack.com\n" {
t.Errorf("override not used, got %q", out)
}
}
func TestArgvOverride(t *testing.T) {
// Default argv places -c <config>.
got := Meli.argv("/usr/bin/meli", "/cfg/x.toml")
want := []string{"/usr/bin/meli", "-c", "/cfg/x.toml"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("default argv = %v, want %v", got, want)
}
// Override with a {{config}} placeholder.
t.Setenv(Meli.argsEnv, "--config {{config}} envelope list")
got = Meli.argv("/usr/bin/meli", "/cfg/x.toml")
want = []string{"/usr/bin/meli", "--config", "/cfg/x.toml", "envelope", "list"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("override argv = %v, want %v", got, want)
}
}
func TestBinaryEnvOverrideMissing(t *testing.T) {
t.Setenv(Himalaya.binEnv, "/nonexistent/himalaya-binary")
if _, err := Himalaya.binary(); err == nil {
t.Error("expected error for missing binary override")
}
if Himalaya.Available() {
t.Error("Available() should be false when the override path is missing")
}
}

View file

@ -134,6 +134,15 @@ func newMenu(user auth.User, ctx plugin.Context) *menu {
return m, m.child.Init() return m, m.child.Init()
}, },
}, },
entry{
section: "BUILT-IN",
label: "Shedding Snake",
desc: "snake that molts — every apple sheds your skin as obstacles; walls wrap",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newShedSnake(m.user, m.ctx, m.width, m.height)
return m, m.child.Init()
},
},
entry{ entry{
section: "BUILT-IN", section: "BUILT-IN",
label: "Hangman", label: "Hangman",
@ -152,6 +161,15 @@ func newMenu(user auth.User, ctx plugin.Context) *menu {
return m, m.child.Init() return m, m.child.Init()
}, },
}, },
entry{
section: "BUILT-IN",
label: "Leaderboard — Shedding Snake",
desc: "global top molt counts",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx, "shedsnake")
return m, m.child.Init()
},
},
entry{ entry{
section: "BUILT-IN", section: "BUILT-IN",
label: "Leaderboard — Hangman", label: "Leaderboard — Hangman",

297
plugins/arcade/shedsnake.go Normal file
View file

@ -0,0 +1,297 @@
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"
)
// shedsnake is "Shedding Snake": a molting twist on Snake (inspired by
// cha.rlie.co/shedding-snake). The snake never really grows — instead, every
// time it eats it SHEDS its whole body as a permanent field of scales on the
// board, then slithers on a little longer and a little faster. The board fills
// with your own cast-off skins; you die by biting a shed scale or yourself.
// Wrapping walls (toroidal) make it Snake × Tron-trails.
//
// Score is the number of molts shed. It feeds its own global leaderboard under
// the "shedsnake" game id (see arcade.go).
type shedsnake struct {
user auth.User
ctx plugin.Context
w, h int // board size in cells
body []pos // head is body[0]
dir pos // current heading
next pos // buffered heading (applied on tick, prevents 180° suicide)
food pos // apple
skin map[pos]int // shed scales → the molt generation that left them
gen int // molts shed so far == score
speedMS int // tick interval; drops as you shed
dead bool
saved bool
flash int // countdown of frames to show the "molt!" banner
}
type shedTickMsg time.Time
func shedTick(ms int) tea.Cmd {
return tea.Tick(time.Duration(ms)*time.Millisecond, func(t time.Time) tea.Msg { return shedTickMsg(t) })
}
const (
shedStartLen = 6 // starting body length
shedStartMS = 130 // starting tick interval
shedMinMS = 55 // fastest it gets
shedSpeedStep = 6 // ms shaved off per molt
shedGrow = 1 // body cells gained per molt
)
func newShedSnake(user auth.User, ctx plugin.Context, termW, termH int) *shedsnake {
w, h := 34, 18
if termW > 0 && termW/2-4 < w {
w = termW/2 - 4
}
if termH > 0 && termH-8 < h {
h = termH - 8
}
if w < 12 {
w = 12
}
if h < 10 {
h = 10
}
s := &shedsnake{
user: user, ctx: ctx, w: w, h: h,
dir: pos{1, 0}, next: pos{1, 0},
skin: map[pos]int{},
speedMS: shedStartMS,
}
// Lay the body out to the left of a centered head so the first move is safe.
hx, hy := w/2, h/2
for i := 0; i < shedStartLen; i++ {
s.body = append(s.body, pos{hx - i, hy})
}
s.placeFood()
return s
}
// placeFood drops the apple on an empty cell (not on the body or any scale).
func (s *shedsnake) placeFood() {
for {
p := pos{rand.Intn(s.w), rand.Intn(s.h)}
if s.onBody(p) {
continue
}
if _, isSkin := s.skin[p]; isSkin {
continue
}
s.food = p
return
}
}
func (s *shedsnake) onBody(p pos) bool {
for _, b := range s.body {
if b == p {
return true
}
}
return false
}
// wrap keeps a coordinate on the toroidal board.
func (s *shedsnake) wrap(p pos) pos {
if p.x < 0 {
p.x = s.w - 1
} else if p.x >= s.w {
p.x = 0
}
if p.y < 0 {
p.y = s.h - 1
} else if p.y >= s.h {
p.y = 0
}
return p
}
func (s *shedsnake) Init() tea.Cmd { return shedTick(s.speedMS) }
func (s *shedsnake) 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.next = pos{0, -1}
}
case "down", "s":
if s.dir.y == 0 {
s.next = pos{0, 1}
}
case "left", "a":
if s.dir.x == 0 {
s.next = pos{-1, 0}
}
case "right", "d":
if s.dir.x == 0 {
s.next = pos{1, 0}
}
case "r":
if s.dead {
ns := newShedSnake(s.user, s.ctx, 0, 0)
ns.w, ns.h = s.w, s.h
return ns, ns.Init()
}
}
case shedTickMsg:
if s.dead {
return s, nil
}
if s.flash > 0 {
s.flash--
}
s.dir = s.next
head := s.wrap(pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y})
// Death: bite yourself, or bite a shed scale you're not already sitting on.
if s.onBody(head) {
return s, s.die()
}
if _, isSkin := s.skin[head]; isSkin && !s.onBody(head) {
return s, s.die()
}
ate := head == s.food
s.body = append([]pos{head}, s.body...)
s.body = s.body[:len(s.body)-1] // fixed-length move; molt() is the only growth
if ate {
s.molt() // shed the whole body as scales, grow a touch, speed up
s.placeFood()
}
return s, shedTick(s.speedMS)
}
return s, nil
}
// molt is the heart of the game: cast the entire current body onto the board as
// a permanent field of scales, then grow a touch and quicken. The freshly-shed
// scales sit under the body and only turn lethal once the snake slithers off
// them (Update's death check skips scales still under the body).
func (s *shedsnake) molt() {
s.gen++
for _, b := range s.body {
if _, exists := s.skin[b]; !exists {
s.skin[b] = s.gen
}
}
// Grow: keep the extra tail cells this turn instead of trimming.
for i := 0; i < shedGrow; i++ {
s.body = append(s.body, s.body[len(s.body)-1])
}
if s.speedMS > shedMinMS {
s.speedMS -= shedSpeedStep
if s.speedMS < shedMinMS {
s.speedMS = shedMinMS
}
}
s.flash = 6
}
func (s *shedsnake) die() tea.Cmd {
s.dead = true
// Guests play, members persist (PRD §5.1). Score is molts shed.
if !s.saved && s.user.Kind != auth.Guest && s.user.StoreID > 0 && s.gen > 0 {
_ = s.ctx.Store.AddScore(s.user.StoreID, "shedsnake", int64(s.gen))
s.saved = true
}
return nil
}
var (
shedHeadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bbf7d0")).Bold(true)
shedBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
shedFoodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Bold(true)
shedWallStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
shedFlashStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#facc15")).Bold(true)
// Shed scales age from fresh teal-green to old dusty gray, so the board reads
// as geological layers of cast-off skin. Indexed by (gen - moltGen) bucket.
shedScaleAges = []lipgloss.Style{
lipgloss.NewStyle().Foreground(lipgloss.Color("35")), // freshest
lipgloss.NewStyle().Foreground(lipgloss.Color("29")),
lipgloss.NewStyle().Foreground(lipgloss.Color("23")),
lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
lipgloss.NewStyle().Foreground(lipgloss.Color("237")), // oldest
}
)
// scaleStyle picks a color for a scale based on how many molts ago it was shed.
func (s *shedsnake) scaleStyle(moltGen int) lipgloss.Style {
age := s.gen - moltGen
if age < 0 {
age = 0
}
if age >= len(shedScaleAges) {
age = len(shedScaleAges) - 1
}
return shedScaleAges[age]
}
func (s *shedsnake) View() string {
out := fmt.Sprintf("%s · molts %d · length %d · speed %d",
shedHeadStyle.Render("Shedding Snake"), s.gen, len(s.body), shedStartMS-s.speedMS)
switch {
case s.dead:
out += shedWallStyle.Render(" ") + "☠ shed your last (r restart · q back)"
case s.flash > 0:
out += " " + shedFlashStyle.Render("✦ molt! ✦")
}
out += "\n" + shedWallStyle.Render("┌"+repeat("──", s.w)+"┐") + "\n"
// Index the body once for O(1) head/body lookups per cell.
headPos := s.body[0]
bodySet := make(map[pos]struct{}, len(s.body))
for _, b := range s.body {
bodySet[b] = struct{}{}
}
for y := 0; y < s.h; y++ {
row := shedWallStyle.Render("│")
for x := 0; x < s.w; x++ {
p := pos{x, y}
switch {
case p == headPos:
row += shedHeadStyle.Render("██")
case contains(bodySet, p):
row += shedBodyStyle.Render("▓▓")
case p == s.food:
row += shedFoodStyle.Render("◆ ")
default:
if g, ok := s.skin[p]; ok {
row += s.scaleStyle(g).Render("░░")
} else {
row += " "
}
}
}
out += row + shedWallStyle.Render("│") + "\n"
}
out += shedWallStyle.Render("└"+repeat("──", s.w)+"┘") + "\n"
out += shedWallStyle.Render(" eat to shed your skin — the board fills with scales you must not bite. walls wrap.")
return lipgloss.NewStyle().Padding(1, 2).Render(out)
}
func contains(set map[pos]struct{}, p pos) bool {
_, ok := set[p]
return ok
}

View file

@ -0,0 +1,104 @@
package arcade
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
func newTestShed() *shedsnake {
s := newShedSnake(auth.User{Kind: auth.Guest}, plugin.Context{}, 0, 0)
return s
}
// step advances one game tick.
func (s *shedsnake) step() *shedsnake {
next, _ := s.Update(shedTickMsg{})
return next.(*shedsnake)
}
func TestShedMoltsOnEat(t *testing.T) {
s := newTestShed()
startLen := len(s.body)
startSpeed := s.speedMS
// Put the apple directly in front of the head so the next tick eats it.
head := s.wrap(pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y})
s.food = head
s = s.step()
if s.gen != 1 {
t.Fatalf("expected 1 molt after eating, got %d", s.gen)
}
// The whole body at molt time (startLen cells, straight → all unique) is shed.
if len(s.skin) != startLen {
t.Fatalf("expected %d shed scales, got %d", startLen, len(s.skin))
}
// The cell the snake ate on is part of the shed skin it stands on.
if _, ok := s.skin[head]; !ok {
t.Errorf("head cell %v where it ate should be a scale", head)
}
if len(s.body) != startLen+shedGrow {
t.Errorf("body should grow by %d on molt: got %d want %d", shedGrow, len(s.body), startLen+shedGrow)
}
if s.speedMS >= startSpeed {
t.Errorf("speed should increase (interval shrink) on molt: %d !< %d", s.speedMS, startSpeed)
}
}
func TestShedGracePeriodNoInstantDeath(t *testing.T) {
// After molting, the fresh scales sit under the body; the snake must be able
// to slither off them without dying.
s := newTestShed()
s.food = s.wrap(pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y})
s = s.step() // eat + molt
if s.dead {
t.Fatal("died on the tick it molted")
}
// Slither straight for a few ticks over its own fresh scales.
for i := 0; i < len(s.body)+2; i++ {
s.food = pos{-9, -9} // keep food away
s = s.step()
if s.dead {
t.Fatalf("died sliding off fresh molt at tick %d", i)
}
}
}
func TestShedDeathOnBitingOldScale(t *testing.T) {
s := newTestShed()
// Plant an old scale one cell ahead of the head; heading right into it kills.
ahead := s.wrap(pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y})
s.skin[ahead] = 0 // an old generation, not under the body
s.food = pos{-9, -9}
s = s.step()
if !s.dead {
t.Fatal("expected death from biting a shed scale")
}
}
func TestShedWraps(t *testing.T) {
s := newTestShed()
// Drive the head to the right edge, then one more step wraps to x=0.
s.body = []pos{{s.w - 1, 3}, {s.w - 2, 3}, {s.w - 3, 3}}
s.dir, s.next = pos{1, 0}, pos{1, 0}
s.food = pos{-9, -9}
s = s.step()
if s.body[0].x != 0 {
t.Fatalf("head should wrap to x=0, got x=%d", s.body[0].x)
}
}
func TestShedNo180(t *testing.T) {
// Pressing the reverse direction must not fold the snake back on itself.
s := newTestShed() // heading right
next, _ := s.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) // left
s = next.(*shedsnake)
if s.next == (pos{-1, 0}) {
t.Fatal("180° reversal should be ignored while moving horizontally")
}
}