mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
The Update key switch bound "r" to restart, but returned early even when the game was still active, so lowercase r could never be entered as a letter guess (Shift+R worked because "R" didn't match the case). Now r only restarts when dead; otherwise it falls through to the guess logic. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
5.2 KiB
Go
194 lines
5.2 KiB
Go
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":
|
||
// Only "restart" once dead; during play, r is a normal letter guess
|
||
// and must fall through to the guessing logic below.
|
||
if h.dead {
|
||
return newHangman(h.user, h.ctx), 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)
|
||
}
|