Merge pull request #49 from profullstack/feat/arcade-hangman

feat(arcade): add Hangman built-in game + leaderboard
This commit is contained in:
Anthony Ettinger 2026-06-22 07:59:57 -07:00 committed by GitHub
commit c5489a458e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 314 additions and 12 deletions

View file

@ -1,7 +1,7 @@
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games. // Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon // DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon
// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games // Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games
// (snake) feed the global leaderboards. // (snake, hangman) feed the global leaderboards.
package arcade package arcade
import ( import (
@ -21,7 +21,9 @@ type Plugin struct{}
func (Plugin) ID() string { return "arcade" } func (Plugin) ID() string { return "arcade" }
func (Plugin) Title() string { return "Arcade" } func (Plugin) Title() string { return "Arcade" }
func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" } func (Plugin) Description() string {
return "DOOM, Space Invaders, Pac-Man, Tetris, snake, hangman & leaderboards"
}
func (Plugin) RequiresAuth() bool { return false } func (Plugin) RequiresAuth() bool { return false }
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
@ -134,10 +136,28 @@ func newMenu(user auth.User, ctx plugin.Context) *menu {
}, },
entry{ entry{
section: "BUILT-IN", section: "BUILT-IN",
label: "Leaderboard", label: "Hangman",
desc: "global top scores", desc: "built-in word game; high scores hit the global leaderboard",
run: func(m *menu) (tea.Model, tea.Cmd) { run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx) m.child = newHangman(m.user, m.ctx)
return m, m.child.Init()
},
},
entry{
section: "BUILT-IN",
label: "Leaderboard — Snake",
desc: "global top snake scores",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx, "snake")
return m, m.child.Init()
},
},
entry{
section: "BUILT-IN",
label: "Leaderboard — Hangman",
desc: "global top hangman scores",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx, "hangman")
return m, m.child.Init() return m, m.child.Init()
}, },
}, },

View file

@ -10,18 +10,21 @@ import (
"github.com/profullstack/agentbbs/internal/ui" "github.com/profullstack/agentbbs/internal/ui"
) )
// board renders the global top scores (PRD §5.1 leaderboards). // board renders the global top scores for one game (PRD §5.1 leaderboards).
type board struct { type board struct {
ctx plugin.Context ctx plugin.Context
game string
scores []store.Score scores []store.Score
err error err error
} }
func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} } func newBoard(ctx plugin.Context, game string) *board {
return &board{ctx: ctx, game: game}
}
func (b *board) Init() tea.Cmd { func (b *board) Init() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
scores, err := b.ctx.Store.TopScores("snake", 10) scores, err := b.ctx.Store.TopScores(b.game, 10)
return boardMsg{scores: scores, err: err} return boardMsg{scores: scores, err: err}
} }
} }
@ -42,7 +45,7 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (b *board) View() string { func (b *board) View() string {
s := theme.Title("Leaderboard — snake") + "\n\n" s := theme.Title("Leaderboard — "+b.game) + "\n\n"
switch { switch {
case b.err != nil: case b.err != nil:
s += ui.Danger.Render("error: " + b.err.Error()) s += ui.Danger.Render("error: " + b.err.Error())

193
plugins/arcade/hangman.go Normal file
View file

@ -0,0 +1,193 @@
package arcade
import (
"fmt"
"math/rand"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
// hangman is a built-in leaderboard game: guess the hidden word a letter at a
// time before the gallows fills. It runs endless — each solved word banks
// points and deals a fresh word with full lives; the run ends (and the score
// persists for members) when a single word exhausts all six wrong guesses.
type hangman struct {
user auth.User
ctx plugin.Context
word string // current word, upper-case AZ
guessed map[byte]bool // letters tried this word
wrong int // wrong guesses on the current word
score int64
solved int // words solved this run
won bool // current word fully revealed
dead bool // ran out of guesses
saved bool
}
const hangmanMaxWrong = 6
// hangmanWords is the word bank — common, all-caps, letters only so the masked
// display and AZ input stay simple.
var hangmanWords = []string{
"TERMINAL", "SANDBOX", "KEYBOARD", "NETWORK", "PROTOCOL", "FIREWALL",
"COMPILER", "VARIABLE", "FUNCTION", "POINTER", "BINARY", "KERNEL",
"PACKET", "ROUTER", "CIPHER", "GALLOWS", "ARCADE", "INVADER",
"GHOST", "MAZE", "ROCKET", "LASER", "CRATER", "PIXEL",
"WIDGET", "BUBBLE", "GOPHER", "DAEMON", "SOCKET", "THREAD",
"BUFFER", "MODEM", "CURSOR", "SYNTAX", "MODULE", "VECTOR",
}
func newHangman(user auth.User, ctx plugin.Context) *hangman {
h := &hangman{user: user, ctx: ctx}
h.deal()
return h
}
// deal starts a fresh word with full lives.
func (h *hangman) deal() {
h.word = hangmanWords[rand.Intn(len(hangmanWords))]
h.guessed = make(map[byte]bool)
h.wrong = 0
h.won = false
}
// revealed reports whether every letter of the word has been guessed.
func (h *hangman) revealed() bool {
for i := 0; i < len(h.word); i++ {
if !h.guessed[h.word[i]] {
return false
}
}
return true
}
func (h *hangman) Init() tea.Cmd { return nil }
func (h *hangman) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return h, nil
}
switch key.String() {
case "q", "esc":
return h, back
case "r":
if h.dead {
return newHangman(h.user, h.ctx), nil
}
return h, nil
case " ", "enter":
if h.won {
h.deal() // advance to the next word
}
return h, nil
}
if h.dead || h.won {
return h, nil
}
// A single letter is a guess.
s := key.String()
if len(s) != 1 {
return h, nil
}
c := s[0]
if c >= 'a' && c <= 'z' {
c -= 'a' - 'A'
}
if c < 'A' || c > 'Z' || h.guessed[c] {
return h, nil
}
h.guessed[c] = true
if strings.IndexByte(h.word, c) < 0 {
h.wrong++
if h.wrong >= hangmanMaxWrong {
h.dead = true
// Guests play, members persist (PRD §5.1).
if !h.saved && h.user.Kind != auth.Guest && h.user.StoreID > 0 && h.score > 0 {
_ = h.ctx.Store.AddScore(h.user.StoreID, "hangman", h.score)
h.saved = true
}
}
return h, nil
}
if h.revealed() {
h.won = true
h.solved++
// Longer words and unused guesses are worth more.
h.score += int64(len(h.word)*10 + (hangmanMaxWrong-h.wrong)*5)
}
return h, nil
}
// hangmanStages are the gallows ASCII for 0..6 wrong guesses.
var hangmanStages = []string{
" +---+\n |\n |\n |\n ===",
" +---+\n O |\n |\n |\n ===",
" +---+\n O |\n | |\n |\n ===",
" +---+\n O |\n /| |\n |\n ===",
" +---+\n O |\n /|\\ |\n |\n ===",
" +---+\n O |\n /|\\ |\n / |\n ===",
" +---+\n O |\n /|\\ |\n / \\ |\n ===",
}
var (
hmWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")).Bold(true)
hmWrongStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
hmGoodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
hmGallows = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)
func (h *hangman) View() string {
out := fmt.Sprintf("Hangman — score %d · solved %d\n\n", h.score, h.solved)
out += hmGallows.Render(hangmanStages[h.wrong]) + "\n\n"
// Masked word: reveal the whole thing once the round is over.
var b strings.Builder
for i := 0; i < len(h.word); i++ {
if i > 0 {
b.WriteByte(' ')
}
if h.guessed[h.word[i]] || h.dead {
b.WriteByte(h.word[i])
} else {
b.WriteByte('_')
}
}
out += hmWordStyle.Render(b.String()) + "\n\n"
// Wrong letters tried.
var wrong []string
for c := byte('A'); c <= 'Z'; c++ {
if h.guessed[c] && strings.IndexByte(h.word, c) < 0 {
wrong = append(wrong, string(c))
}
}
out += fmt.Sprintf("misses (%d/%d): ", h.wrong, hangmanMaxWrong)
if len(wrong) > 0 {
out += hmWrongStyle.Render(strings.Join(wrong, " "))
} else {
out += hmGallows.Render("—")
}
out += "\n\n"
switch {
case h.dead:
out += hmWrongStyle.Render("☠ out of guesses — the word was "+h.word) + "\n"
out += "r restart · q back"
case h.won:
out += hmGoodStyle.Render("✓ solved!") + " space next word · q back"
default:
out += "guess a letter · q back"
}
return lipgloss.NewStyle().Padding(1, 2).Render(out)
}

View file

@ -0,0 +1,86 @@
package arcade
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
// key builds a single-rune key press the way bubbletea delivers it.
func key(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
// guest avoids the store path (only members persist), so ctx.Store can be nil.
func newTestHangman(word string) *hangman {
h := newHangman(auth.User{Kind: auth.Guest}, plugin.Context{})
h.word = word
h.guessed = make(map[byte]bool)
h.wrong = 0
h.won = false
return h
}
func TestHangmanSolveScores(t *testing.T) {
h := newTestHangman("CAT")
for _, r := range "CAT" {
m, _ := h.Update(key(r))
h = m.(*hangman)
}
if !h.won {
t.Fatalf("expected won after guessing every letter")
}
if h.solved != 1 {
t.Fatalf("solved = %d, want 1", h.solved)
}
// len 3 *10 + (6-0)*5 = 60.
if h.score != 60 {
t.Fatalf("score = %d, want 60", h.score)
}
if h.dead {
t.Fatalf("should not be dead after a solve")
}
}
func TestHangmanWrongGuessIsCountedOnce(t *testing.T) {
h := newTestHangman("CAT")
for i := 0; i < 3; i++ { // repeat the same wrong letter
m, _ := h.Update(key('Z'))
h = m.(*hangman)
}
if h.wrong != 1 {
t.Fatalf("wrong = %d, want 1 (repeat guesses must not stack)", h.wrong)
}
}
func TestHangmanDeathAfterSixMisses(t *testing.T) {
h := newTestHangman("CAT")
for _, r := range "BDEFGH" { // six letters absent from CAT
m, _ := h.Update(key(r))
h = m.(*hangman)
}
if h.wrong != hangmanMaxWrong {
t.Fatalf("wrong = %d, want %d", h.wrong, hangmanMaxWrong)
}
if !h.dead {
t.Fatalf("expected dead after %d misses", hangmanMaxWrong)
}
}
func TestHangmanLowercaseInputAndAdvance(t *testing.T) {
h := newTestHangman("CAT")
for _, r := range "cat" { // lowercase should still solve
m, _ := h.Update(key(r))
h = m.(*hangman)
}
if !h.won {
t.Fatalf("lowercase input should solve the word")
}
// space deals a fresh word and clears the round flags.
m, _ := h.Update(tea.KeyMsg{Type: tea.KeySpace})
h = m.(*hangman)
if h.won || h.wrong != 0 || len(h.guessed) != 0 {
t.Fatalf("space should deal a fresh round: won=%v wrong=%d guessed=%d", h.won, h.wrong, len(h.guessed))
}
}