From b956013bc03d92928d6dc70473b19aafdba5a540 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 10 Jul 2026 00:40:03 +0000 Subject: [PATCH] Add himalaya + meli mail clients and Shedding Snake arcade game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/agentbbs/main.go | 90 +++++++ internal/mailclients/mailclients.go | 312 +++++++++++++++++++++++ internal/mailclients/mailclients_test.go | 126 +++++++++ plugins/arcade/arcade.go | 18 ++ plugins/arcade/shedsnake.go | 297 +++++++++++++++++++++ plugins/arcade/shedsnake_test.go | 104 ++++++++ 6 files changed, 947 insertions(+) create mode 100644 internal/mailclients/mailclients.go create mode 100644 internal/mailclients/mailclients_test.go create mode 100644 plugins/arcade/shedsnake.go create mode 100644 plugins/arcade/shedsnake_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 81f2b16..0ce1372 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -73,6 +73,7 @@ import ( "github.com/profullstack/agentbbs/internal/ircpass" "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mailbox" + "github.com/profullstack/agentbbs/internal/mailclients" "github.com/profullstack/agentbbs/internal/mailu" "github.com/profullstack/agentbbs/internal/motd" "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. torLock := "" 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 } +// 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 // TUI when a PTY is present and no command is given, or the JSON bot mode // (ssh mail@host , 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") 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) if err != nil { wish.Println(s, "mail: "+err.Error()) diff --git a/internal/mailclients/mailclients.go b/internal/mailclients/mailclients.go new file mode 100644 index 0000000..32dd01d --- /dev/null +++ b/internal/mailclients/mailclients.go @@ -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 +// ("*"), 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 "*") + 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" +` diff --git a/internal/mailclients/mailclients_test.go b/internal/mailclients/mailclients_test.go new file mode 100644 index 0000000..1791fce --- /dev/null +++ b/internal/mailclients/mailclients_test.go @@ -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 . + 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") + } +} diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index ad26ea3..71feba2 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -134,6 +134,15 @@ func newMenu(user auth.User, ctx plugin.Context) *menu { 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{ section: "BUILT-IN", label: "Hangman", @@ -152,6 +161,15 @@ func newMenu(user auth.User, ctx plugin.Context) *menu { 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{ section: "BUILT-IN", label: "Leaderboard — Hangman", diff --git a/plugins/arcade/shedsnake.go b/plugins/arcade/shedsnake.go new file mode 100644 index 0000000..c44d6d3 --- /dev/null +++ b/plugins/arcade/shedsnake.go @@ -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 +} diff --git a/plugins/arcade/shedsnake_test.go b/plugins/arcade/shedsnake_test.go new file mode 100644 index 0000000..66b3743 --- /dev/null +++ b/plugins/arcade/shedsnake_test.go @@ -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") + } +}