Skip to content

Commit 93eaef1

Browse files
ralyodioclaude
andcommitted
feat(arcade): add Hangman built-in game + leaderboard
Hangman joins Snake as a built-in, leaderboard-backed TUI game (PRD §5.1). Endless mode: each solved word banks points (longer words and unused guesses score more) and deals a fresh word with full lives; the run ends when one word exhausts all six wrong guesses, persisting the total for members under the "hangman" score key. Guests play without persisting, same as Snake. Generalize the leaderboard board to take a game name and split the single "Leaderboard" row into per-game "Leaderboard — Snake" / "Leaderboard — Hangman" entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a56f3af commit 93eaef1

4 files changed

Lines changed: 314 additions & 12 deletions

File tree

plugins/arcade/arcade.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
22
// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon
33
// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games
4-
// (snake) feed the global leaderboards.
4+
// (snake, hangman) feed the global leaderboards.
55
package arcade
66

77
import (
@@ -19,10 +19,12 @@ import (
1919

2020
type Plugin struct{}
2121

22-
func (Plugin) ID() string { return "arcade" }
23-
func (Plugin) Title() string { return "Arcade" }
24-
func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" }
25-
func (Plugin) RequiresAuth() bool { return false }
22+
func (Plugin) ID() string { return "arcade" }
23+
func (Plugin) Title() string { return "Arcade" }
24+
func (Plugin) Description() string {
25+
return "DOOM, Space Invaders, Pac-Man, Tetris, snake, hangman & leaderboards"
26+
}
27+
func (Plugin) RequiresAuth() bool { return false }
2628

2729
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
2830
return newMenu(user, ctx)
@@ -134,10 +136,28 @@ func newMenu(user auth.User, ctx plugin.Context) *menu {
134136
},
135137
entry{
136138
section: "BUILT-IN",
137-
label: "Leaderboard",
138-
desc: "global top scores",
139+
label: "Hangman",
140+
desc: "built-in word game; high scores hit the global leaderboard",
141+
run: func(m *menu) (tea.Model, tea.Cmd) {
142+
m.child = newHangman(m.user, m.ctx)
143+
return m, m.child.Init()
144+
},
145+
},
146+
entry{
147+
section: "BUILT-IN",
148+
label: "Leaderboard — Snake",
149+
desc: "global top snake scores",
150+
run: func(m *menu) (tea.Model, tea.Cmd) {
151+
m.child = newBoard(m.ctx, "snake")
152+
return m, m.child.Init()
153+
},
154+
},
155+
entry{
156+
section: "BUILT-IN",
157+
label: "Leaderboard — Hangman",
158+
desc: "global top hangman scores",
139159
run: func(m *menu) (tea.Model, tea.Cmd) {
140-
m.child = newBoard(m.ctx)
160+
m.child = newBoard(m.ctx, "hangman")
141161
return m, m.child.Init()
142162
},
143163
},

plugins/arcade/board.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ import (
1010
"github.com/profullstack/agentbbs/internal/ui"
1111
)
1212

13-
// board renders the global top scores (PRD §5.1 leaderboards).
13+
// board renders the global top scores for one game (PRD §5.1 leaderboards).
1414
type board struct {
1515
ctx plugin.Context
16+
game string
1617
scores []store.Score
1718
err error
1819
}
1920

20-
func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} }
21+
func newBoard(ctx plugin.Context, game string) *board {
22+
return &board{ctx: ctx, game: game}
23+
}
2124

2225
func (b *board) Init() tea.Cmd {
2326
return func() tea.Msg {
24-
scores, err := b.ctx.Store.TopScores("snake", 10)
27+
scores, err := b.ctx.Store.TopScores(b.game, 10)
2528
return boardMsg{scores: scores, err: err}
2629
}
2730
}
@@ -42,7 +45,7 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4245
}
4346

4447
func (b *board) View() string {
45-
s := theme.Title("Leaderboard — snake") + "\n\n"
48+
s := theme.Title("Leaderboard — "+b.game) + "\n\n"
4649
switch {
4750
case b.err != nil:
4851
s += ui.Danger.Render("error: " + b.err.Error())

plugins/arcade/hangman.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package arcade
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
"strings"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
10+
11+
"github.com/profullstack/agentbbs/internal/auth"
12+
"github.com/profullstack/agentbbs/internal/plugin"
13+
)
14+
15+
// hangman is a built-in leaderboard game: guess the hidden word a letter at a
16+
// time before the gallows fills. It runs endless — each solved word banks
17+
// points and deals a fresh word with full lives; the run ends (and the score
18+
// persists for members) when a single word exhausts all six wrong guesses.
19+
type hangman struct {
20+
user auth.User
21+
ctx plugin.Context
22+
23+
word string // current word, upper-case A–Z
24+
guessed map[byte]bool // letters tried this word
25+
wrong int // wrong guesses on the current word
26+
score int64
27+
solved int // words solved this run
28+
won bool // current word fully revealed
29+
dead bool // ran out of guesses
30+
saved bool
31+
}
32+
33+
const hangmanMaxWrong = 6
34+
35+
// hangmanWords is the word bank — common, all-caps, letters only so the masked
36+
// display and A–Z input stay simple.
37+
var hangmanWords = []string{
38+
"TERMINAL", "SANDBOX", "KEYBOARD", "NETWORK", "PROTOCOL", "FIREWALL",
39+
"COMPILER", "VARIABLE", "FUNCTION", "POINTER", "BINARY", "KERNEL",
40+
"PACKET", "ROUTER", "CIPHER", "GALLOWS", "ARCADE", "INVADER",
41+
"GHOST", "MAZE", "ROCKET", "LASER", "CRATER", "PIXEL",
42+
"WIDGET", "BUBBLE", "GOPHER", "DAEMON", "SOCKET", "THREAD",
43+
"BUFFER", "MODEM", "CURSOR", "SYNTAX", "MODULE", "VECTOR",
44+
}
45+
46+
func newHangman(user auth.User, ctx plugin.Context) *hangman {
47+
h := &hangman{user: user, ctx: ctx}
48+
h.deal()
49+
return h
50+
}
51+
52+
// deal starts a fresh word with full lives.
53+
func (h *hangman) deal() {
54+
h.word = hangmanWords[rand.Intn(len(hangmanWords))]
55+
h.guessed = make(map[byte]bool)
56+
h.wrong = 0
57+
h.won = false
58+
}
59+
60+
// revealed reports whether every letter of the word has been guessed.
61+
func (h *hangman) revealed() bool {
62+
for i := 0; i < len(h.word); i++ {
63+
if !h.guessed[h.word[i]] {
64+
return false
65+
}
66+
}
67+
return true
68+
}
69+
70+
func (h *hangman) Init() tea.Cmd { return nil }
71+
72+
func (h *hangman) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73+
key, ok := msg.(tea.KeyMsg)
74+
if !ok {
75+
return h, nil
76+
}
77+
switch key.String() {
78+
case "q", "esc":
79+
return h, back
80+
case "r":
81+
if h.dead {
82+
return newHangman(h.user, h.ctx), nil
83+
}
84+
return h, nil
85+
case " ", "enter":
86+
if h.won {
87+
h.deal() // advance to the next word
88+
}
89+
return h, nil
90+
}
91+
92+
if h.dead || h.won {
93+
return h, nil
94+
}
95+
96+
// A single letter is a guess.
97+
s := key.String()
98+
if len(s) != 1 {
99+
return h, nil
100+
}
101+
c := s[0]
102+
if c >= 'a' && c <= 'z' {
103+
c -= 'a' - 'A'
104+
}
105+
if c < 'A' || c > 'Z' || h.guessed[c] {
106+
return h, nil
107+
}
108+
h.guessed[c] = true
109+
110+
if strings.IndexByte(h.word, c) < 0 {
111+
h.wrong++
112+
if h.wrong >= hangmanMaxWrong {
113+
h.dead = true
114+
// Guests play, members persist (PRD §5.1).
115+
if !h.saved && h.user.Kind != auth.Guest && h.user.StoreID > 0 && h.score > 0 {
116+
_ = h.ctx.Store.AddScore(h.user.StoreID, "hangman", h.score)
117+
h.saved = true
118+
}
119+
}
120+
return h, nil
121+
}
122+
123+
if h.revealed() {
124+
h.won = true
125+
h.solved++
126+
// Longer words and unused guesses are worth more.
127+
h.score += int64(len(h.word)*10 + (hangmanMaxWrong-h.wrong)*5)
128+
}
129+
return h, nil
130+
}
131+
132+
// hangmanStages are the gallows ASCII for 0..6 wrong guesses.
133+
var hangmanStages = []string{
134+
" +---+\n |\n |\n |\n ===",
135+
" +---+\n O |\n |\n |\n ===",
136+
" +---+\n O |\n | |\n |\n ===",
137+
" +---+\n O |\n /| |\n |\n ===",
138+
" +---+\n O |\n /|\\ |\n |\n ===",
139+
" +---+\n O |\n /|\\ |\n / |\n ===",
140+
" +---+\n O |\n /|\\ |\n / \\ |\n ===",
141+
}
142+
143+
var (
144+
hmWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")).Bold(true)
145+
hmWrongStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
146+
hmGoodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
147+
hmGallows = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
148+
)
149+
150+
func (h *hangman) View() string {
151+
out := fmt.Sprintf("Hangman — score %d · solved %d\n\n", h.score, h.solved)
152+
out += hmGallows.Render(hangmanStages[h.wrong]) + "\n\n"
153+
154+
// Masked word: reveal the whole thing once the round is over.
155+
var b strings.Builder
156+
for i := 0; i < len(h.word); i++ {
157+
if i > 0 {
158+
b.WriteByte(' ')
159+
}
160+
if h.guessed[h.word[i]] || h.dead {
161+
b.WriteByte(h.word[i])
162+
} else {
163+
b.WriteByte('_')
164+
}
165+
}
166+
out += hmWordStyle.Render(b.String()) + "\n\n"
167+
168+
// Wrong letters tried.
169+
var wrong []string
170+
for c := byte('A'); c <= 'Z'; c++ {
171+
if h.guessed[c] && strings.IndexByte(h.word, c) < 0 {
172+
wrong = append(wrong, string(c))
173+
}
174+
}
175+
out += fmt.Sprintf("misses (%d/%d): ", h.wrong, hangmanMaxWrong)
176+
if len(wrong) > 0 {
177+
out += hmWrongStyle.Render(strings.Join(wrong, " "))
178+
} else {
179+
out += hmGallows.Render("—")
180+
}
181+
out += "\n\n"
182+
183+
switch {
184+
case h.dead:
185+
out += hmWrongStyle.Render("☠ out of guesses — the word was "+h.word) + "\n"
186+
out += "r restart · q back"
187+
case h.won:
188+
out += hmGoodStyle.Render("✓ solved!") + " space next word · q back"
189+
default:
190+
out += "guess a letter · q back"
191+
}
192+
return lipgloss.NewStyle().Padding(1, 2).Render(out)
193+
}

plugins/arcade/hangman_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package arcade
2+
3+
import (
4+
"testing"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
8+
"github.com/profullstack/agentbbs/internal/auth"
9+
"github.com/profullstack/agentbbs/internal/plugin"
10+
)
11+
12+
// key builds a single-rune key press the way bubbletea delivers it.
13+
func key(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
14+
15+
// guest avoids the store path (only members persist), so ctx.Store can be nil.
16+
func newTestHangman(word string) *hangman {
17+
h := newHangman(auth.User{Kind: auth.Guest}, plugin.Context{})
18+
h.word = word
19+
h.guessed = make(map[byte]bool)
20+
h.wrong = 0
21+
h.won = false
22+
return h
23+
}
24+
25+
func TestHangmanSolveScores(t *testing.T) {
26+
h := newTestHangman("CAT")
27+
for _, r := range "CAT" {
28+
m, _ := h.Update(key(r))
29+
h = m.(*hangman)
30+
}
31+
if !h.won {
32+
t.Fatalf("expected won after guessing every letter")
33+
}
34+
if h.solved != 1 {
35+
t.Fatalf("solved = %d, want 1", h.solved)
36+
}
37+
// len 3 *10 + (6-0)*5 = 60.
38+
if h.score != 60 {
39+
t.Fatalf("score = %d, want 60", h.score)
40+
}
41+
if h.dead {
42+
t.Fatalf("should not be dead after a solve")
43+
}
44+
}
45+
46+
func TestHangmanWrongGuessIsCountedOnce(t *testing.T) {
47+
h := newTestHangman("CAT")
48+
for i := 0; i < 3; i++ { // repeat the same wrong letter
49+
m, _ := h.Update(key('Z'))
50+
h = m.(*hangman)
51+
}
52+
if h.wrong != 1 {
53+
t.Fatalf("wrong = %d, want 1 (repeat guesses must not stack)", h.wrong)
54+
}
55+
}
56+
57+
func TestHangmanDeathAfterSixMisses(t *testing.T) {
58+
h := newTestHangman("CAT")
59+
for _, r := range "BDEFGH" { // six letters absent from CAT
60+
m, _ := h.Update(key(r))
61+
h = m.(*hangman)
62+
}
63+
if h.wrong != hangmanMaxWrong {
64+
t.Fatalf("wrong = %d, want %d", h.wrong, hangmanMaxWrong)
65+
}
66+
if !h.dead {
67+
t.Fatalf("expected dead after %d misses", hangmanMaxWrong)
68+
}
69+
}
70+
71+
func TestHangmanLowercaseInputAndAdvance(t *testing.T) {
72+
h := newTestHangman("CAT")
73+
for _, r := range "cat" { // lowercase should still solve
74+
m, _ := h.Update(key(r))
75+
h = m.(*hangman)
76+
}
77+
if !h.won {
78+
t.Fatalf("lowercase input should solve the word")
79+
}
80+
// space deals a fresh word and clears the round flags.
81+
m, _ := h.Update(tea.KeyMsg{Type: tea.KeySpace})
82+
h = m.(*hangman)
83+
if h.won || h.wrong != 0 || len(h.guessed) != 0 {
84+
t.Fatalf("space should deal a fresh round: won=%v wrong=%d guessed=%d", h.won, h.wrong, len(h.guessed))
85+
}
86+
}

0 commit comments

Comments
 (0)