mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
Merge pull request #49 from profullstack/feat/arcade-hangman
feat(arcade): add Hangman built-in game + leaderboard
This commit is contained in:
commit
c5489a458e
4 changed files with 314 additions and 12 deletions
|
|
@ -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 (
|
||||
|
|
@ -21,7 +21,9 @@ 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) 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 {
|
||||
|
|
@ -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 = 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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
193
plugins/arcade/hangman.go
Normal file
193
plugins/arcade/hangman.go
Normal 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 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)
|
||||
}
|
||||
86
plugins/arcade/hangman_test.go
Normal file
86
plugins/arcade/hangman_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue