games: a gallows you can bet on
Hangman, and it plays for chips — which the plan had down as a free game, on the grounds that trivia has no euro coupling in gogobee. But a free game in a casino reads as a demo, so it stakes like everything else. The idea that makes it a casino game rather than hangman with a wager stapled on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a tenth off what a win is worth, because those are the same event and showing them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands back less than the stake, and the rake still comes out of winnings only. State.Pays() is the number the felt quotes and the number settle() lands on. They were briefly two sums, and the table spent an afternoon advertising a pre-rake payout it didn't honour. Two things the storage layer had already decided for us, and one it hadn't: game_live_hands is keyed on the player, so "one game at a time" holds across games for free (a live hangman 409s a blackjack deal, stake intact). But table() unmarshalled every live row as a blackjack hand, which does not fail on a hangman row — it quietly yields an empty hand. It dispatches on the game now. commit() is the settle path both games share, and casinoRoutes() the one route list, since the dev rig wires its own mux and a second copy is a copy that stops including the newest game. Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on it; six wrong took the stake and no more; a reload mid-phrase brought back the board, the limbs, the multiple, the spent keys and the chips on the spot. The browser found the two bugs a Go test can't — a lives counter under the house rack, and a word wrapping early because the rack's clearance was on the whole column instead of the one row beside it.
This commit is contained in:
395
internal/games/hangman/hangman.go
Normal file
395
internal/games/hangman/hangman.go
Normal file
@@ -0,0 +1,395 @@
|
||||
// Package hangman is a pure hangman engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
|
||||
// an error means the move was illegal and nothing else. No HTTP, no timers, no
|
||||
// player names. The state is a plain value, so a game survives a redeploy and
|
||||
// replays from its seed.
|
||||
//
|
||||
// The casino version differs from the one gogobee plays in Matrix in one way
|
||||
// that matters: there is money on it. That makes the gallows a payout meter as
|
||||
// well as a death clock — every wrong guess takes a tenth off what a win is
|
||||
// worth. You can still win from five wrong, you just won't win much.
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("hangman: the game is already over")
|
||||
ErrNotALetter = errors.New("hangman: that is not a letter")
|
||||
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
|
||||
ErrUnknownMove = errors.New("hangman: unknown move")
|
||||
ErrBadBet = errors.New("hangman: bet must be positive")
|
||||
ErrEmptySolution = errors.New("hangman: guess something")
|
||||
ErrUnknownTier = errors.New("hangman: no such tier")
|
||||
)
|
||||
|
||||
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
|
||||
// two legs. It is not a tier setting — the drawing has six parts, so the game
|
||||
// has six lives, and the tiers vary what a win pays instead.
|
||||
const MaxWrong = 6
|
||||
|
||||
// Decay is what one wrong guess costs, as a fraction of the tier's base
|
||||
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
|
||||
const Decay = 0.10
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
|
||||
// there is less of them to read, and fewer distinct letters to hit by accident.
|
||||
// A long phrase mostly reveals itself.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Min int `json:"min"` // phrase length, in characters
|
||||
Max int `json:"max"` // inclusive
|
||||
Base float64 `json:"base"` // what a win pays, before any wrong guesses
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
|
||||
// long, so none of them runs thin.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
|
||||
Blurb: "A handful of letters and nothing to go on."},
|
||||
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
|
||||
Blurb: "Long enough to read once it starts falling open."},
|
||||
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
|
||||
Blurb: "It gives itself away. It also pays the least."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeSolved Outcome = "solved" // guessed the phrase outright
|
||||
OutcomeFilled Outcome = "filled" // revealed the last letter
|
||||
OutcomeHung Outcome = "hung" // six wrong
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
|
||||
|
||||
// State is one game. The phrase is in here, which is exactly why this value
|
||||
// never leaves the server — the browser gets a Masked() view of it instead.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Phrase string `json:"phrase"`
|
||||
Runes []rune `json:"runes"` // the phrase, indexable safely
|
||||
Shown []bool `json:"shown"` // one per rune: is it face up
|
||||
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
|
||||
Wrong []rune `json:"wrong"` // just the ones that missed
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates: a letter turning over, a limb being
|
||||
// drawn. The engine emits them rather than drawing anything itself.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
|
||||
Letter string `json:"letter,omitempty"` // the letter guessed
|
||||
At []int `json:"at,omitempty"` // which rune positions it turned over
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: a single letter, or the whole phrase.
|
||||
type Move struct {
|
||||
Letter string `json:"letter"`
|
||||
Solve string `json:"solve"`
|
||||
}
|
||||
|
||||
// New starts a game on a phrase drawn from the tier's shelf of the bank.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
phrase, err := drawPhrase(t, rng)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
return start(bet, t, phrase, rakePct)
|
||||
}
|
||||
|
||||
// start builds the opening state for a known phrase. Split out from New so a
|
||||
// test can pin the phrase instead of the seed.
|
||||
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
rs := []rune(phrase)
|
||||
s := State{
|
||||
Tier: t, Phrase: phrase, Runes: rs,
|
||||
Shown: make([]bool, len(rs)),
|
||||
RakePct: rakePct,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
// Spaces and punctuation are never guessed — they start face up, because a
|
||||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||
for i, r := range rs {
|
||||
if !isGuessable(r) {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
}
|
||||
return s, []Event{{Kind: "start"}}, nil
|
||||
}
|
||||
|
||||
// isGuessable reports whether a rune is one you'd guess: a letter or a digit.
|
||||
// Everything else is scaffolding and is shown from the start.
|
||||
func isGuessable(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new state and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
switch {
|
||||
case m.Solve != "":
|
||||
return applySolve(s, m.Solve)
|
||||
case m.Letter != "":
|
||||
return applyLetter(s, m.Letter)
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
}
|
||||
|
||||
// applyLetter guesses one letter.
|
||||
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
||||
if len(rs) != 1 || !isGuessable(rs[0]) {
|
||||
return s, nil, ErrNotALetter
|
||||
}
|
||||
g := rs[0]
|
||||
if containsRune(s.Tried, g) {
|
||||
// Not a miss — a no-op. Charging a life for a letter the board already
|
||||
// shows you tried is punishing a mis-click, not a bad guess.
|
||||
return s, nil, ErrAlreadyTried
|
||||
}
|
||||
s.Tried = append(s.Tried, g)
|
||||
|
||||
var at []int
|
||||
for i, r := range s.Runes {
|
||||
if !s.Shown[i] && foldEq(r, g) {
|
||||
s.Shown[i] = true
|
||||
at = append(at, i)
|
||||
}
|
||||
}
|
||||
|
||||
evs := []Event{}
|
||||
if len(at) > 0 {
|
||||
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
|
||||
if s.filled() {
|
||||
s.settle(OutcomeFilled, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
s.Wrong = append(s.Wrong, g)
|
||||
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
|
||||
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
|
||||
// solving would be a free roll you could spam until it landed.
|
||||
func applySolve(s State, attempt string) (State, []Event, error) {
|
||||
if strings.TrimSpace(attempt) == "" {
|
||||
return s, nil, ErrEmptySolution
|
||||
}
|
||||
evs := []Event{{Kind: "solve", Text: attempt}}
|
||||
|
||||
if normalize(attempt) == normalize(s.Phrase) {
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
s.settle(OutcomeSolved, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// A wrong solve is a miss with no letter attached to it.
|
||||
s.Wrong = append(s.Wrong, '·')
|
||||
evs = append(evs, Event{Kind: "miss", Text: attempt})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Multiple is what a win is worth right now: the tier's base, less a tenth of
|
||||
// it for every wrong guess so far. Floored at 1, so a win never hands back less
|
||||
// than the stake — a player who fought through five wrong guesses and got there
|
||||
// has not earned a loss.
|
||||
func (s State) Multiple() float64 {
|
||||
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
|
||||
if m < 1 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Pays is what a win *right now* would actually put back on the player's stack:
|
||||
// the stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists because the felt shows this number while the game is still running,
|
||||
// and settle() is the only other thing that computes it. If the two ever
|
||||
// disagreed the table would be quoting a payout it doesn't honour — so settle
|
||||
// calls this rather than doing the sum a second time.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // a win never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
rake = 0
|
||||
}
|
||||
profit -= rake
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// Rake taken on a win right now — the other half of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
return 0
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Lives is how many wrong guesses are left.
|
||||
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
|
||||
|
||||
// filled reports whether every guessable rune is face up.
|
||||
func (s State) filled() bool {
|
||||
for _, up := range s.Shown {
|
||||
if !up {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as blackjack: the rake comes out of
|
||||
// winnings, never out of the stake. A loss is never charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
|
||||
// The phrase goes face up when it's over, win or lose. Losing without ever
|
||||
// being told the answer is the one thing hangman must never do.
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Masked is the phrase as the player may see it: revealed runes as themselves,
|
||||
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
|
||||
func (s State) Masked() string {
|
||||
var b strings.Builder
|
||||
for i, r := range s.Runes {
|
||||
if s.Shown[i] {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clone deep-copies the slices, so a derived state shares no backing array with
|
||||
// the one it came from and a state can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Runes = append([]rune(nil), s.Runes...)
|
||||
s.Shown = append([]bool(nil), s.Shown...)
|
||||
s.Tried = append([]rune(nil), s.Tried...)
|
||||
s.Wrong = append([]rune(nil), s.Wrong...)
|
||||
return s
|
||||
}
|
||||
|
||||
// normalize flattens a phrase for comparison: case, spacing and punctuation all
|
||||
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if isGuessable(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// foldEq compares two runes the way a guess should: case-insensitively.
|
||||
func foldEq(a, b rune) bool {
|
||||
return unicode.ToLower(a) == unicode.ToLower(b)
|
||||
}
|
||||
|
||||
func containsRune(rs []rune, r rune) bool {
|
||||
for _, x := range rs {
|
||||
if foldEq(x, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
369
internal/games/hangman/hangman_test.go
Normal file
369
internal/games/hangman/hangman_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tierShort is the tier most tests play on: base 2.6.
|
||||
func tierShort(t *testing.T) Tier {
|
||||
t.Helper()
|
||||
tr, err := TierBySlug("short")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// play runs a sequence of single-letter guesses against a pinned phrase.
|
||||
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
|
||||
t.Helper()
|
||||
s, _, err := start(bet, tierShort(t), phrase, rake)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range guesses {
|
||||
next, _, err := ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatalf("guess %q: %v", g, err)
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStartShowsScaffoldingOnly(t *testing.T) {
|
||||
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "______ ____"; got != want {
|
||||
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "start" {
|
||||
t.Errorf("events = %+v, want one start", evs)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
|
||||
// Punctuation shows from the start; a digit is guessable like a letter.
|
||||
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "_____ _!"; got != want {
|
||||
t.Fatalf("masked = %q, want %q", got, want)
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Letter: "9"})
|
||||
if err != nil {
|
||||
t.Fatalf("guessing a digit: %v", err)
|
||||
}
|
||||
if got, want := next.Masked(), "_____ 9!"; got != want {
|
||||
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitRevealsEveryOccurrence(t *testing.T) {
|
||||
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
|
||||
s := play(t, "Blue Shell", 100, 0.05, "l")
|
||||
if got, want := s.Masked(), "_l__ ___ll"; got != want {
|
||||
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("a hit cost a life: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
|
||||
tr := tierShort(t)
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
want := tr.Base * 0.9
|
||||
if got := s.Multiple(); got != want {
|
||||
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
_, _, err := ApplyMove(s, Move{Letter: "z"})
|
||||
if err != ErrAlreadyTried {
|
||||
t.Fatalf("err = %v, want ErrAlreadyTried", err)
|
||||
}
|
||||
// And the state the caller holds is untouched: a mis-click is not a life.
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
|
||||
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", s.Payout)
|
||||
}
|
||||
if s.Net() != -100 {
|
||||
t.Errorf("net = %d, want -100", s.Net())
|
||||
}
|
||||
if strings.Contains(s.Masked(), "_") {
|
||||
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
|
||||
// "Blue Shell" — every distinct letter, no misses.
|
||||
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
|
||||
if s.Outcome != OutcomeFilled {
|
||||
t.Fatalf("outcome = %s, want filled", s.Outcome)
|
||||
}
|
||||
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
|
||||
if s.Payout != 260 {
|
||||
t.Errorf("payout = %d, want 260", s.Payout)
|
||||
}
|
||||
if s.Net() != 160 {
|
||||
t.Errorf("net = %d, want 160", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two wrong first: 2.6 -> 2.08.
|
||||
for _, g := range []string{"z", "x"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Fatalf("outcome = %s, want solved", s.Outcome)
|
||||
}
|
||||
if s.Payout != 208 {
|
||||
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSolveCostsALife(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Errorf("phase = %s, want playing", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
|
||||
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
|
||||
long, err := TierBySlug("long")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Five clean misses. Every letter in the pangram is in the pangram, so the
|
||||
// only things guaranteed to miss it are digits.
|
||||
for _, g := range []string{"1", "2", "3", "4", "5"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := s.Multiple(); got != 1 {
|
||||
t.Fatalf("multiple = %v, want 1 — the floor", got)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != 100 {
|
||||
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
|
||||
}
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
|
||||
if s.Rake != 8 {
|
||||
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
|
||||
}
|
||||
if s.Payout != 252 {
|
||||
t.Errorf("payout = %d, want 252", s.Payout)
|
||||
}
|
||||
if s.Payout < s.Bet {
|
||||
t.Error("the rake ate into the stake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
|
||||
// The table shows Pays() while the game is still running. If that number and
|
||||
// the one settle() lands on ever came apart, the felt would be advertising a
|
||||
// payout the house doesn't honour. Walk a game and check they agree at every
|
||||
// step — including after a miss, which is where they'd drift.
|
||||
for _, wrong := range []int{0, 1, 2, 3} {
|
||||
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
misses := []string{"z", "x", "q", "y"}
|
||||
for i := 0; i < wrong; i++ {
|
||||
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quoted := s.Pays() // what the felt is telling the player right now
|
||||
|
||||
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != quoted {
|
||||
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
|
||||
}
|
||||
if s.Payout != s.Bet+s.Net() {
|
||||
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
|
||||
t.Errorf("err = %v, want ErrGameOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageGuessesAreRefused(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
t.Errorf("move %+v was accepted", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := s.Masked()
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Masked() != before {
|
||||
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
|
||||
}
|
||||
if len(s.Tried) != 0 {
|
||||
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
|
||||
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
|
||||
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
|
||||
t.Fatalf("round trip changed the game: %+v", back)
|
||||
}
|
||||
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Outcome != OutcomeSolved {
|
||||
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
|
||||
// The seed is in the audit log so a disputed game can be replayed. That is
|
||||
// only true if the phrase comes back the same.
|
||||
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Phrase != two.Phrase {
|
||||
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDrawsFromTheRightShelf(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for _, tr := range Tiers {
|
||||
for i := 0; i < 50; i++ {
|
||||
s, _, err := New(100, tr, 0.05, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tr.Slug, err)
|
||||
}
|
||||
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
|
||||
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
|
||||
// If someone edits phrases.txt and empties a tier, the game 500s at the
|
||||
// table rather than here. Catch it here.
|
||||
for _, tr := range Tiers {
|
||||
if n := Shelf(tr.Slug); n < 20 {
|
||||
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/games/hangman/phrases.go
Normal file
68
internal/games/hangman/phrases.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
|
||||
// an env var, which meant the game was one missing file away from not existing.
|
||||
// Embedding it means the casino cannot start without its phrases, which is the
|
||||
// correct relationship between a game and the thing it is about.
|
||||
//
|
||||
//go:embed phrases.txt
|
||||
var phrasesTxt string
|
||||
|
||||
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
|
||||
// someone edits phrases.txt down past a tier, and it is a programming error
|
||||
// rather than anything a player did — but it's an error, not a panic, because a
|
||||
// casino that won't boot is worse than one game being shut.
|
||||
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
|
||||
|
||||
var (
|
||||
shelvesOnce sync.Once
|
||||
shelves map[string][]string // tier slug -> the phrases that fit it
|
||||
)
|
||||
|
||||
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
|
||||
// are dropped, and so is anything too short to be a game — the tiers' own Min
|
||||
// is the floor.
|
||||
func load() {
|
||||
shelves = make(map[string][]string, len(Tiers))
|
||||
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n := len([]rune(line))
|
||||
for _, t := range Tiers {
|
||||
if n >= t.Min && n <= t.Max {
|
||||
shelves[t.Slug] = append(shelves[t.Slug], line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
|
||||
// hasn't been edited out from under a tier.
|
||||
func Shelf(slug string) int {
|
||||
shelvesOnce.Do(load)
|
||||
return len(shelves[slug])
|
||||
}
|
||||
|
||||
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
|
||||
// the package global, so a game replays exactly from the seed in its audit row.
|
||||
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
|
||||
shelvesOnce.Do(load)
|
||||
shelf := shelves[t.Slug]
|
||||
if len(shelf) == 0 {
|
||||
return "", ErrNoPhrases
|
||||
}
|
||||
return shelf[rng.IntN(len(shelf))], nil
|
||||
}
|
||||
237
internal/games/hangman/phrases.txt
Normal file
237
internal/games/hangman/phrases.txt
Normal file
@@ -0,0 +1,237 @@
|
||||
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||
#
|
||||
# Tiers are assigned automatically by character count at load time.
|
||||
# Section headers below are for human readability only -- the bot ignores them.
|
||||
#
|
||||
# Easy: 8-20 characters
|
||||
# Medium: 21-40 characters
|
||||
# Hard: 41-80 characters
|
||||
#
|
||||
# Add community phrases via: !hangman submit [phrase]
|
||||
# All submissions require LLM approval before entering the pool.
|
||||
# This file can be edited directly. Bot reloads on restart.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EASY (8-20 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Finish Him!
|
||||
Game Over
|
||||
Insert Coin
|
||||
Hadouken!
|
||||
Fatality!
|
||||
Big Boss
|
||||
Konami Code
|
||||
Warp Zone
|
||||
Blue Shell
|
||||
God Mode
|
||||
BFG 9000
|
||||
Cacodemon
|
||||
Quad Damage
|
||||
Samus Aran
|
||||
Morph Ball
|
||||
Mother Brain
|
||||
Dracula!
|
||||
Simon Belmont
|
||||
Ecclesia
|
||||
Outer Heaven
|
||||
Spread Gun
|
||||
Power Pellet
|
||||
Checkpoint
|
||||
Rocket Jump
|
||||
Mushroom Kingdom
|
||||
Princess Peach
|
||||
Bowser's Castle
|
||||
Fire Flower
|
||||
Varia Suit
|
||||
Space Jump
|
||||
Vampire Killer
|
||||
Holy Water
|
||||
Trevor Belmont
|
||||
Soma Cruz
|
||||
Julius Belmont
|
||||
Waluigi!
|
||||
Richter!
|
||||
Phantoon
|
||||
Speed Run
|
||||
High Score
|
||||
Continue?
|
||||
Press Start
|
||||
Ryu Hayabusa
|
||||
Plasma Gun
|
||||
What is a man?
|
||||
Serious Sam
|
||||
Shoryuken!
|
||||
Duck Hunt
|
||||
The cake is a lie
|
||||
War never changes
|
||||
Would you kindly?
|
||||
Do a barrel roll!
|
||||
Praise the Sun!
|
||||
A winner is you
|
||||
You're pretty good
|
||||
La-Li-Lu-Le-Lo
|
||||
I am error
|
||||
Leeroy Jenkins!
|
||||
For the Horde!
|
||||
For the Alliance!
|
||||
Frostmourne hungers
|
||||
Falcon Punch!
|
||||
Rip and tear!
|
||||
Vic Viper
|
||||
Salamander!
|
||||
Parodius Da!
|
||||
We'll bang, okay?
|
||||
What you say!!
|
||||
You spoony bard!
|
||||
One-Winged Angel
|
||||
Morning Star
|
||||
Glyph Union
|
||||
TwinBee, scramble!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIUM (21-40 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Stay a while and listen
|
||||
It's dangerous to go alone!
|
||||
Kept you waiting, huh?
|
||||
A man chooses, a slave obeys
|
||||
Metal Gear?! Metal Gear!!
|
||||
We're not tools of the government
|
||||
The winds of destruction
|
||||
Who are the Patriots?
|
||||
This is good, isn't it?
|
||||
You have died of dysentery
|
||||
The Triforce of Courage
|
||||
Ganon has broken the seal!
|
||||
May the wind guide you home
|
||||
Dodongo dislikes smoke
|
||||
The right man in the wrong place
|
||||
Nothing is true, everything is permitted
|
||||
I used to be an adventurer like you
|
||||
You can't hide from the Grim Reaper
|
||||
All your base are belong to us!
|
||||
Somebody set up us the bomb!
|
||||
For great justice, take off every Zig!
|
||||
How are you gentlemen!!
|
||||
Kain has betrayed us!
|
||||
Garland will knock you all down!
|
||||
You are not prepared!
|
||||
I am Uther the Lightbringer!
|
||||
Order of Ecclesia calls
|
||||
The Dark Lord rises again!
|
||||
Shanoa, bearer of glyphs
|
||||
In this world, it's kill or be killed
|
||||
Despite everything, it's still you
|
||||
The Underground is your home now
|
||||
Papyrus demands a battle!
|
||||
I'm going to make spaghetti!
|
||||
Toriel will protect you
|
||||
Estus Flask replenished
|
||||
The age of fire fades
|
||||
Prepare to die, undead one
|
||||
Can't let you do that, Star Fox!
|
||||
Andross' empire spans the Lylat system!
|
||||
Captain Falcon, show me your moves!
|
||||
OBJECTION! That testimony is a lie!
|
||||
Hold it! I have new evidence!
|
||||
Phoenix Wright, attorney at law!
|
||||
Does this unit have a soul?
|
||||
Shepard, the Reapers are coming!
|
||||
Tali'Zorah vas Normandy!
|
||||
Gruntilda shall not be defeated!
|
||||
K. Rool has stolen the banana hoard!
|
||||
Kirby, hero of Dream Land!
|
||||
Meta Knight awaits your challenge!
|
||||
Congraturation! This story is happy end.
|
||||
Cecil has become a Paladin
|
||||
Cloud Strife, SOLDIER First Class
|
||||
Time compression is inevitable
|
||||
You require more vespene gas
|
||||
Nuclear launch detected
|
||||
You must construct additional pylons
|
||||
I'm Commander Shepard!
|
||||
War... War has changed.
|
||||
Rip and tear until it is done!
|
||||
Dawn of Sorrow awaits
|
||||
Do you feel like a hero yet?
|
||||
You're a monster. You know that, Walker?
|
||||
Halo... it's not a natural formation.
|
||||
Whip it good, Belmont!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HARD (41-80 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
What is a man? A miserable pile of secrets!
|
||||
Die monster! You don't belong in this world!
|
||||
My name is Dracula, and I bid you welcome.
|
||||
You have no chance to survive make your time!
|
||||
You've met with a terrible fate, haven't you?
|
||||
Fear the old blood... and welcome, good hunter.
|
||||
Welcome to the Liandri Grand Tournament!
|
||||
I am the very model of a scientist Salarian!
|
||||
Metroid Prime has escaped into the impact crater!
|
||||
I want to be the very best, like no one ever was
|
||||
The world needs only one Big Boss... and one Snake.
|
||||
Snake, do you think love can bloom on a battlefield?
|
||||
Thank you Mario, but our princess is in another castle!
|
||||
You must gather your party before venturing forth
|
||||
Snake, we're not tools of the government, or anyone else
|
||||
Did I ever tell you what the definition of insanity is?
|
||||
They're everywhere! The demons... they won't stop coming!
|
||||
Dracula! Your time has come! The Vampire Killer strikes!
|
||||
Link... I'm Navi, your fairy companion! Listen!
|
||||
Hero of Time, your destiny awaits in the Sacred Realm
|
||||
Master Chief, finish the fight. Earth is counting on you.
|
||||
Outer Heaven... a place where warriors can find purpose
|
||||
We passed the point of no return a long time ago, Snake
|
||||
Liquid! I was the one who was meant to be the successor!
|
||||
Revolver Ocelot is a triple agent working for the Patriots
|
||||
Phazon corruption detected! Seek immediate medical attention!
|
||||
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||
Praise the Chosen Undead, for they shall link the fire!
|
||||
Ganon is the evil king who stole the Triforce of Power!
|
||||
The legendary soldier who defied his genes... Big Boss
|
||||
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||
Humanity restored! The bonfire blazes with newfound strength!
|
||||
You are the last line of defense against an infinite demonic army
|
||||
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||
All we did was give meaning to the nuclear age by using nukes!
|
||||
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||
You are the Chosen Undead, fated to succeed where so many have failed
|
||||
The price of living in the past is a slow death in the present
|
||||
A sword wields no strength unless the hands that hold it have courage
|
||||
The flow of time is always cruel, its speed seems different for each person
|
||||
Hey! Listen! There's something important you need to know!
|
||||
We are born of the blood, made men by the blood, undone by the blood
|
||||
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
I used to be an adventurer like you, then I took an arrow in the knee
|
||||
I'm not the only one who's responsible. The humans are just as guilty!
|
||||
The Skaarj have invaded, and you must fight your way to freedom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||
I need scissors! 61!
|
||||
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||
Reference in New Issue
Block a user