mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
ff9eef907d
commit
b51fa5c0e6
6 changed files with 947 additions and 0 deletions
|
|
@ -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())
|
||||||
|
|
|
||||||
312
internal/mailclients/mailclients.go
Normal file
312
internal/mailclients/mailclients.go
Normal 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"
|
||||||
|
`
|
||||||
126
internal/mailclients/mailclients_test.go
Normal file
126
internal/mailclients/mailclients_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
297
plugins/arcade/shedsnake.go
Normal 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
|
||||||
|
}
|
||||||
104
plugins/arcade/shedsnake_test.go
Normal file
104
plugins/arcade/shedsnake_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue