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

@ -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",

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")
}
}