diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index 477b796..ad26ea3 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -1,7 +1,7 @@ // Package arcade is the flagship plugin (PRD §5.1): classic terminal games. // 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 -// (snake) feed the global leaderboards. +// (snake, hangman) feed the global leaderboards. package arcade import ( @@ -19,10 +19,12 @@ import ( type Plugin struct{} -func (Plugin) ID() string { return "arcade" } -func (Plugin) Title() string { return "Arcade" } -func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" } -func (Plugin) RequiresAuth() bool { return false } +func (Plugin) ID() string { return "arcade" } +func (Plugin) Title() string { return "Arcade" } +func (Plugin) Description() string { + return "DOOM, Space Invaders, Pac-Man, Tetris, snake, hangman & leaderboards" +} +func (Plugin) RequiresAuth() bool { return false } func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { return newMenu(user, ctx) @@ -134,10 +136,28 @@ func newMenu(user auth.User, ctx plugin.Context) *menu { }, entry{ section: "BUILT-IN", - label: "Leaderboard", - desc: "global top scores", + label: "Hangman", + desc: "built-in word game; high scores hit the global leaderboard", + run: func(m *menu) (tea.Model, tea.Cmd) { + 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) + m.child = newBoard(m.ctx, "hangman") return m, m.child.Init() }, }, diff --git a/plugins/arcade/board.go b/plugins/arcade/board.go index 024af2e..702fb3c 100644 --- a/plugins/arcade/board.go +++ b/plugins/arcade/board.go @@ -10,18 +10,21 @@ import ( "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 { ctx plugin.Context + game string scores []store.Score 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 { 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} } } @@ -42,7 +45,7 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (b *board) View() string { - s := theme.Title("Leaderboard — snake") + "\n\n" + s := theme.Title("Leaderboard — "+b.game) + "\n\n" switch { case b.err != nil: s += ui.Danger.Render("error: " + b.err.Error()) diff --git a/plugins/arcade/hangman.go b/plugins/arcade/hangman.go new file mode 100644 index 0000000..508dbbe --- /dev/null +++ b/plugins/arcade/hangman.go @@ -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 A–Z + 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 A–Z 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) +} diff --git a/plugins/arcade/hangman_test.go b/plugins/arcade/hangman_test.go new file mode 100644 index 0000000..d9851a7 --- /dev/null +++ b/plugins/arcade/hangman_test.go @@ -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)) + } +}