diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/baccarat.go | 2 | ||||
| -rw-r--r-- | internal/casino/big_six.go | 530 | ||||
| -rw-r--r-- | internal/casino/big_six_test.go | 293 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 6 | ||||
| -rw-r--r-- | internal/casino/core.go | 3 | ||||
| -rw-r--r-- | internal/casino/craps_ux.go | 2 | ||||
| -rw-r--r-- | internal/casino/registry.go | 14 | ||||
| -rw-r--r-- | internal/casino/table_hints.go | 2 | ||||
| -rw-r--r-- | internal/casino/table_ux_test.go | 2 | ||||
| -rw-r--r-- | internal/casino/types.go | 1 | ||||
| -rw-r--r-- | internal/game/big_six_test.go | 222 | ||||
| -rw-r--r-- | internal/game/blackjack_rules.go | 4 | ||||
| -rw-r--r-- | internal/game/casino.go | 35 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 8 | ||||
| -rw-r--r-- | internal/game/casino_ux_test.go | 51 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 99 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 4 | ||||
| -rw-r--r-- | internal/game/game.go | 16 | ||||
| -rw-r--r-- | internal/game/hardening_shutdown_save_error_test.go | 49 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 2 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/render_big_six.go | 209 |
22 files changed, 1513 insertions, 43 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index f1ba951..d385834 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -113,7 +113,7 @@ func (t *baccaratTable) menuEvent(name string) Event { } commands := []MenuCommand{} if p != nil && p.hasLastBet() { - commands = append(commands, MenuCommand{Command: "bet", Desc: "Repeat your last bets"}) + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last bets"}) } if p != nil && len(p.sides) > 0 { commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back your side bets"}) diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go new file mode 100644 index 0000000..6d16bbc --- /dev/null +++ b/internal/casino/big_six.go @@ -0,0 +1,530 @@ +package casino + +import ( + "fmt" + "math/big" + "math/rand" + "strings" +) + +// BigSixSpot describes a physical symbol and its profit odds. The dollar +// labels follow Draft Kings; counts and odds are adjusted for this house. +// Reference: https://wizardofodds.com/games/big-six/ (Draft Kings Rules). +type BigSixSpot struct { + Label string + Count, Pays int +} + +func BigSixSpots() []BigSixSpot { + return []BigSixSpot{ + {"1", 24, 1}, {"2", 12, 3}, {"5", 6, 7}, {"10", 4, 11}, {"20", 2, 23}, + {"SWORD", 1, 47}, {"SHIELD", 1, 47}, {"DOUBLE", 1, 0}, + } +} + +// Each ordinary symbol has count*(profit+1)=48. With N=51 and D=1, +// E = 48/51 + (2/51)*E = 48/49 = 97.95918367%, for EVERY target. +// DOUBLE is not a bet: it doubles all live stakes and spins again. No rounding. +func bigSixHouseEdge(_ TableConfig, bet string) HouseEdge { + if bigSixPayout(bet) > 0 { + return edge(1.0 / 49) + } + return unknownEdge("unrecognized Big 6 target") +} +func bigSixTarget(s string) string { + return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(s)), "$") +} +func bigSixPayout(s string) int { + for _, spot := range BigSixSpots() { + if spot.Label == bigSixTarget(s) && spot.Pays > 0 { + return spot.Pays + 1 + } + } + return 0 +} +func bigSixRules(TableConfig) string { + return "Big 6: bet on $1, $2, $5, $10, $20, SWORD or SHIELD. Profit odds: 1, 3, 7, 11, 23, 47, 47 to 1. DOUBLE doubles every live bet and respins without a cap. RTP 48/49 (97.959%). Alone, place any number of bets then spin; multiplayer betting lasts 25 ticks, even with no bets." +} + +// A fixed, interleaved physical wheel, never shuffled during a round. +var bigSixWheel = func() []string { + var wheel []string + for _, spot := range BigSixSpots() { + for i := 0; i < spot.Count; i++ { + wheel = append(wheel, spot.Label) + } + } + rand.New(rand.NewSource(601)).Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + return wheel +}() + +type bigSixBet struct { + spot string + wager Wager +} +type bigSixPlayer struct { + Participant + bets []bigSixBet + lastSpot string + lastBet int +} + +func (p *bigSixPlayer) participantPtr() *Participant { return &p.Participant } + +type bigSixFrame struct{ delay, move int } +type bigSixTable struct { + tableCore[*bigSixPlayer] + rng *rand.Rand + frames []bigSixFrame + frame, wait, position, winningIndex int + // Arbitrary precision keeps DOUBLE chains uncapped and prevents wraparound. + multiplier big.Int + lastResult *BigSixSnapshot +} +type BigSixSnapshot struct { + Players []string + Phase string + Pockets []string + Multiplier string + BettingTicks int + MinBet, MaxBet int +} + +func (*BigSixSnapshot) snapshot() {} +func newBigSixTable(room int, cfg TableConfig, rng *rand.Rand) *bigSixTable { + t := &bigSixTable{tableCore: newTableCore[*bigSixPlayer](&tableBase{RoomID: room, Config: cfg.Normalize()}), rng: rng} + t.multiplier.SetInt64(1) + return t +} +func (t *bigSixTable) active() int { + n := 0 + for _, p := range t.players.ordered() { + if p.Connected && !p.SittingOut { + n++ + } + } + return n +} +func (t *bigSixTable) openRound() []Event { + if t.phase == phaseWaiting && t.active() > 1 { + t.phase = phaseBetting + t.window.open(25) + return []Event{t.event("Big 6 betting is open for 25 ticks.", true)} + } + return nil +} +func (t *bigSixTable) view() *BigSixSnapshot { + s := &BigSixSnapshot{Phase: string(t.phase), Multiplier: t.multiplier.String(), BettingTicks: t.window.timer.remaining(), MinBet: t.Config.MinBet, MaxBet: t.Config.MaxBet} + for _, p := range t.players.ordered() { + s.Players = append(s.Players, p.Name) + } + return s +} +func (t *bigSixTable) strip(result bool) *BigSixSnapshot { + s := t.view() + count := 11 + if result { + s.Phase = "result" + count = 9 + } + for i := 0; i < count; i++ { + s.Pockets = append(s.Pockets, bigSixWheel[(t.position+i-count/2+len(bigSixWheel))%len(bigSixWheel)]) + } + return s +} +func (t *bigSixTable) board(_ string) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + return t.view() +} +func (t *bigSixTable) wagerRows() []WagerRow { + if t.phase == phasePlaying { + return nil + } + var rows []WagerRow + for _, s := range BigSixSpots() { + if s.Pays > 0 { + rows = append(rows, WagerRow{Bet: s.Label, Payout: fmt.Sprintf("%d:1", s.Pays), Win: "Wheel stops on " + s.Label}) + } + } + return rows +} +func (t *bigSixTable) wagers(_ string) []WagerRow { + t.mu.Lock() + defer t.mu.Unlock() + return t.wagerRows() +} +func (t *bigSixTable) commands(name string) []MenuCommand { + var c []MenuCommand + if p := t.players.get(name); p != nil && t.phase != phasePlaying { + c = append(c, MenuCommand{Command: "bet N on BET", Desc: "Add a wager"}) + if p.lastBet > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last wager"}) + } + if p.HasBet { + c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) + } + if t.phase == phaseWaiting { + c = append(c, MenuCommand{Command: "spin", Desc: "Start the wheel"}) + } + } + c = append(c, MenuCommand{Command: "table", Desc: "Show the felt"}, MenuCommand{Command: "stop", Desc: "Leave after your bets settle"}) + return withTableHints(c, t.wagerRows()) +} +func (t *bigSixTable) hints(name string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + return t.commands(name) +} +func (t *bigSixTable) menu(name string, board bool) Event { + e := t.betMenuEvent(name, t.commands(name)) + if board { + e.Snapshot = t.view() + } + return e +} +func (t *bigSixTable) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.players.has(name) { + return []Event{t.menu(name, true)} + } + if t.players.count() >= t.Config.MaxPlayers { + return []Event{t.private(name, "The Big 6 table is full.")} + } + e := t.addSeatLocked(name, &bigSixPlayer{Participant: Participant{Name: name, Connected: true}}) + events := append([]Event{e}, t.openRound()...) + return append(events, t.menu(name, true)) +} +func (t *bigSixTable) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return nil + } + if p.HasBet { + return []Event{t.private(name, "Your Big 6 wagers are committed until the round ends.")} + } + t.removeSeatLocked(name) + if t.players.count() == 0 { + t.reset() + } + return t.closeIfEmptyLocked([]Event{t.playerAction(name, name+" stands up.", "You stand up.")}) +} +func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing Big 6.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if spot == "" { + spot = p.lastSpot + } + spot = bigSixTarget(spot) + if bigSixPayout(spot) == 0 { + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD. DOUBLE is automatic, not a bet.")} + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + // Keep the original aggregate representable for shared menus and refunds. + if amount > int(^uint(0)>>1)-p.Wager.Total { + return []Event{t.private(name, "Your total stake is too large.")} + } + wager, err := w.Wager(amount) + if err != nil { + return []Event{t.private(name, walletErrorMessage(err))} + } + p.bets = append(p.bets, bigSixBet{spot, wager}) + p.HasBet = true + p.Wallet = w + p.Wager.Total += amount + p.lastSpot, p.lastBet, p.SittingOut = spot, amount, false + events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} + return append(events, t.openRound()...) +} + +// Motion is planned backwards from an independently sampled uniform outcome. +// Cosmetic randomness never consumes the table outcome RNG. Steps never grow +// and waits never shrink, so the wheel only ever slows. +// +// The final approach ticks one space at a time. The wheel caps its wait so +// it never crawls, but each click has a 50% chance of one more, so it can +// tease forever. Half the time the very last click does not advance at all: +// it re-prints the winning space. Only that last click may take a long pause. +func bigSixAnimation() []bigSixFrame { + var frames []bigSixFrame + // Opening blur: 3-6 prints of 4-8 spaces each, easing down. (Two steps + // shorter than before so the wheel reaches its final approach sooner.) + step := 4 + rand.Intn(5) // 4..8 + for n := 3 + rand.Intn(4); n > 0; n-- { + frames = append(frames, bigSixFrame{1, step}) + if step > 4 && rand.Intn(2) == 0 { + step-- + } + } + // The wheel eases down: longer waits, shorter hops. + step = min(step, 2+rand.Intn(3)) // 2..4 + for n := 1 + rand.Intn(2); n > 0; n-- { + frames = append(frames, bigSixFrame{2, step}) + } + step = min(step, 1+rand.Intn(2)) // 1..2 + frames = append(frames, bigSixFrame{3, step}) + // Final approach: single-space clicks. The wait is capped at maxApproachWait + // so the wheel keeps moving; after at least three clicks it may stop, but + // each further click has a 50% chance to continue. + const maxApproachWait = 4 + for clicks := 1; ; clicks++ { + frames = append(frames, bigSixFrame{maxApproachWait, 1}) + if clicks >= 3 && rand.Intn(2) == 0 { + break + } + } + // Half the time the wheel makes one final stationary click, printing the + // winning space again instead of ticking over to the next one. + if rand.Intn(2) == 0 { + frames = append(frames, bigSixFrame{maxApproachWait, 0}) + } + // Occasionally a long suspense pause before the final click. + if rand.Intn(3) == 0 { + frames[len(frames)-1].delay = 8 + rand.Intn(5) + } + return frames +} +func (t *bigSixTable) startSpin() Event { + t.phase = phasePlaying + t.window.close() + t.winningIndex = t.rng.Intn(len(bigSixWheel)) + t.frames = bigSixAnimation() + t.frame = 0 + distance := 0 + for _, f := range t.frames { + distance += f.move + } + t.position = (t.winningIndex - distance%len(bigSixWheel) + len(bigSixWheel)) % len(bigSixWheel) + t.wait = t.frames[0].delay + return t.event("No more bets! The brass Big 6 wheel begins to turn...", true) +} +func (t *bigSixTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase == phaseWaiting { + return t.openRound() + } + if t.phase == phaseBetting { + t.window.timer.tick() + if t.window.timer.expired() { + return []Event{t.startSpin()} + } + return nil + } + t.wait-- + if t.wait > 0 { + return nil + } + t.position = (t.position + t.frames[t.frame].move) % len(bigSixWheel) + t.frame++ + if t.frame == len(t.frames) { + return t.settle() + } + t.wait = t.frames[t.frame].delay + e := t.event("", true) + e.Snapshot = t.strip(false) + return []Event{e} +} +func (t *bigSixTable) settle() []Event { + spot := bigSixWheel[t.winningIndex] + t.position = t.winningIndex + e := t.event("The pointer stops on {casino_emphasis}"+spot+"{/}!", true) + e.Snapshot = t.strip(true) + t.lastResult = e.Snapshot.(*BigSixSnapshot) + events := []Event{e} + if spot == "DOUBLE" { + t.multiplier.Lsh(&t.multiplier, 1) + events = append(events, t.event("DOUBLE! Every live bet doubles; the same bets ride again. Stake multiplier: "+t.multiplier.String()+"x.", true)) + return append(events, t.startSpin()) + } + for _, p := range t.players.ordered() { + if !p.HasBet { + continue + } + total := new(big.Int) + var ids []string + var lines []SettlementLine + representable := true + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + returned := new(big.Int) + result := "loss" + if b.spot == spot { + returned.Mul(big.NewInt(int64(b.wager.Total)), &t.multiplier) + returned.Mul(returned, big.NewInt(int64(bigSixPayout(spot)))) + result = "win" + } + total.Add(total, returned) + if !returned.IsInt64() || returned.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 { + representable = false + } + odds := new(big.Int).Mul(&t.multiplier, big.NewInt(int64(bigSixPayout(b.spot)))) + odds.Sub(odds, big.NewInt(1)) + lines = append(lines, SettlementLine{Label: b.spot, Odds: odds.String() + ":1", Stake: b.wager.Total, Returned: int(returned.Int64()), Result: result}) + } + if !total.IsInt64() || total.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 { + representable = false + } + // One atomic save pays ALL bets and consumes exactly their ledger handles. + // Unrepresentable payouts stay pending for recovery, never wrap or clamp. + failed := !representable + if !failed { + failed = p.Wallet.PayCredits(int(total.Int64()), ids...) != nil + } + ev := t.playerAction(p.Name, p.Name+" settles their Big 6 wagers.", "") + if failed { + ev.PrivateMessage = payoutFailedMessage() + ev.Message = p.Name + "'s settlement is pending recovery." + } else { + ev.PrivateMessage = settlementText(lines) + ev.Settlement = lines + if t.multiplier.Cmp(big.NewInt(1)) > 0 { + ev.SettlementNote = "DOUBLE stake multiplier: " + t.multiplier.String() + "x; odds and net are relative to your original stake." + } + ev.Color = settlementTag(lines) + } + // The next-spin menu already shows the balance for seats that remain. + // Only a departing or disconnected seat needs it here, otherwise the + // wallet would print twice after a result. + ev.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) + events = append(events, ev) + } + t.reset() + events = append(events, t.finishControlsLocked()...) + events = append(events, t.openRound()...) + for _, p := range t.players.ordered() { + if p.Connected && !p.SittingOut { + events = append(events, t.menu(p.Name, false)) + } + } + return events +} +func (t *bigSixTable) reset() { + t.phase = phaseWaiting + t.window.close() + t.frames = nil + t.multiplier.SetInt64(1) + for _, p := range t.players.ordered() { + p.bets = nil + p.HasBet = false + p.Wager = Wager{} + } +} +func (t *bigSixTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing Big 6.")} + } + if action == "unbet" { + if t.phase == phasePlaying { + return []Event{t.private(name, "You cannot unbet after the wheel starts, including DOUBLE respins.")} + } + if !p.HasBet { + return []Event{t.private(name, "Nothing to take back.")} + } + var ids []string + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + } + if err := p.Wallet.PayCredits(p.Wager.Total, ids...); err != nil { + return []Event{t.private(name, payoutFailedMessage())} + } + total := p.Wager.Total + p.bets = nil + p.HasBet = false + p.Wager = Wager{} + return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))} + } + if action == "spin" { + if t.phase == phasePlaying { + return []Event{t.private(name, "The wheel is already spinning.")} + } + if t.phase == phaseBetting { + return []Event{t.private(name, fmt.Sprintf("The shared betting round has %d ticks remaining.", t.window.timer.remaining()))} + } + if !p.HasBet { + return []Event{t.private(name, "Place your bets before spinning.")} + } + return []Event{t.startSpin()} + } + if events, ok := t.controlLocked(name, action); ok { + return events + } + return []Event{t.private(name, "Place bets, then spin. Use rules for Big 6 help.")} +} +func (t *bigSixTable) rebet(name string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.players.get(name); p != nil && p.lastBet > 0 { + return p.lastSpot, p.lastBet + } + return "", t.Config.MinBet +} +func (t *bigSixTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + events := []Event{t.menu(name, true)} + if t.phase != phasePlaying && t.lastResult != nil { + e := t.private(name, "Last stop:") + e.Snapshot = t.lastResult + events = append(events, e) + } + return events +} +func (t *bigSixTable) markDisconnected(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return nil + } + // Once animation reveals positions, refunds would permit selective losses. + // Match the casino's disconnect-forfeit convention for exposed games. + var events []Event + if p.Wallet != nil { + var err error + if p.HasBet && t.phase == phasePlaying { + var ids []string + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + } + err = p.Wallet.PayCredits(0, ids...) + } else { + _, _, err = p.Wallet.RefundOutstanding() + } + if err != nil { + events = append(events, t.private(name, payoutFailedMessage())) + } + } + t.removeSeatLocked(name) + if t.players.count() == 0 { + t.reset() + } + return t.closeIfEmptyLocked(append(events, t.event(name+" stands up.", true))) +} +func (t *bigSixTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + events := t.refundAllLocked() + t.reset() + return events +} +func (t *bigSixTable) busy(_ string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.phase == phasePlaying +} diff --git a/internal/casino/big_six_test.go b/internal/casino/big_six_test.go new file mode 100644 index 0000000..cccad77 --- /dev/null +++ b/internal/casino/big_six_test.go @@ -0,0 +1,293 @@ +package casino + +import ( + "math" + "math/big" + "math/rand" + "strings" + "testing" +) + +func newBigSixTest() *bigSixTable { + return newBigSixTable(1, TableConfig{ID: "wheel", Game: GameBigSix}, rand.New(rand.NewSource(123))) +} +func forceBigSix(t *bigSixTable, spot string) []Event { + for i, s := range bigSixWheel { + if s == spot { + t.winningIndex = i + return t.settle() + } + } + panic("unknown test symbol") +} +func TestBigSixRTPAndRegistry(t *testing.T) { + cfg := TableConfig{ID: "wheel", Game: GameBigSix} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Normalize().BettingTicks != 25 || cfg.HelpTopic() != "big6" || newTable(1, cfg, newTestRand()) == nil { + t.Fatal("registration") + } + counts := map[string]int{} + for _, s := range bigSixWheel { + counts[s]++ + } + if len(bigSixWheel) != 51 || counts["DOUBLE"] != 1 { + t.Fatal(counts) + } + for _, s := range BigSixSpots() { + if counts[s.Label] != s.Count { + t.Fatal(s) + } + if s.Pays == 0 { + continue + } + rtp := float64(s.Count*(s.Pays+1)) / float64(len(bigSixWheel)-2*counts["DOUBLE"]) + if rtp < .978 || rtp > .985 || math.Abs(rtp-48.0/49) > 1e-12 { + t.Fatal(s, rtp) + } + if e := cfg.HouseEdge(s.Label); len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-rtp)) > 1e-12 { + t.Fatal(e) + } + } + if len(cfg.HouseEdge("DOUBLE").Parts) != 0 { + t.Fatal("DOUBLE is not a target") + } +} +func TestBigSixMultipleBetsAndDouble(t *testing.T) { + table := newBigSixTest() + table.join("a") + w := &recordingWallet{testWallet: testWallet{credits: 100}} + table.bet("a", "$1", 10, w) + table.bet("a", "sword", 2, w) + table.bet("a", "1", 3, w) + if table.phase != phaseWaiting || len(w.open) != 3 { + t.Fatal("bet started spin or lost wager") + } + table.action("a", "spin") + if table.phase != phasePlaying { + t.Fatal("spin") + } + forceBigSix(table, "DOUBLE") + forceBigSix(table, "DOUBLE") + if table.multiplier.String() != "4" || w.paid != 0 || len(w.open) != 3 { + t.Fatal("DOUBLE must ride all bets without cashing out") + } + table.bet("a", "2", 1, w) + table.action("a", "unbet") + if len(w.open) != 3 || len(table.players.get("a").bets) != 3 { + t.Fatal("bet/unbet accepted during respin") + } + events := forceBigSix(table, "1") + if w.paid != 104 || len(w.open) != 0 || len(w.payStakes) != 3 { + t.Fatal("wrong payout/ledger", w) + } + for _, e := range events { + if len(e.Settlement) > 0 { + if len(e.Settlement) != 3 || e.Settlement[0].Stake != 10 || e.Settlement[0].Returned != 80 || e.Settlement[0].Odds != "7:1" { + t.Fatal(e.Settlement) + } + } + } + if table.multiplier.String() != "1" || table.phase != phaseWaiting { + t.Fatal("not reset") + } +} +func TestBigSixUncappedDoubleAndSafeOverflow(t *testing.T) { + table := newBigSixTest() + table.join("a") + w := &testWallet{credits: 10} + table.bet("a", "1", 1, w) + table.action("a", "spin") + for i := 0; i < 130; i++ { + forceBigSix(table, "DOUBLE") + } + if table.multiplier.Cmp(new(big.Int).Lsh(big.NewInt(1), 130)) != 0 { + t.Fatal("DOUBLE capped or wrapped") + } + events := forceBigSix(table, "1") + if w.paid != 0 || len(w.open) != 1 { + t.Fatal("overflow must not pay or consume pending stake") + } + for _, e := range events { + if len(e.Settlement) > 0 { + t.Fatal("announced unbanked payout") + } + } + table.refundAll() + if len(w.open) != 0 { + t.Fatal("recovery did not refund") + } +} +func TestBigSixMultiplayerClockWithoutBets(t *testing.T) { + table := newBigSixTest() + table.join("a") + table.join("b") + if table.phase != phaseBetting || table.window.timer.remaining() != 25 { + t.Fatal("join must open betting") + } + table.action("a", "spin") + for i := 0; i < 24; i++ { + table.tick() + if table.phase != phaseBetting { + t.Fatal("early spin", i) + } + } + table.tick() + if table.phase != phasePlaying { + t.Fatal("empty round did not spin") + } + forceBigSix(table, "2") + if table.phase != phaseBetting || table.window.timer.remaining() != 25 { + t.Fatal("next window") + } +} +func TestBigSixAnimationGeometryAndTiming(t *testing.T) { + sawLong, sawVariation := false, false + length := 0 + for round := 0; round < 250; round++ { + table := newBigSixTest() + table.startSpin() + frames := append([]bigSixFrame(nil), table.frames...) + if length != 0 && length != len(frames) { + sawVariation = true + } + length = len(frames) + fast := 0 + prevStep, prevDelay := 10, 1 + for i, f := range frames { + if f.delay == 1 { + fast++ + } + if f.delay >= 8 { + sawLong = true + } + if f.move > prevStep || f.delay < prevDelay { + t.Fatalf("not slowing: %+v", frames) + } + if f.delay == 1 && (f.move < 4 || f.move > 8) || f.delay == 2 && (f.move < 2 || f.move > 4) || f.delay == 3 && (f.move < 1 || f.move > 2) { + t.Fatal(frames) + } + // Only the very last click may take a long pause; everything else + // is capped so the wheel never crawls. + if i < len(frames)-1 && f.delay > 4 { + t.Fatalf("wait uncapped before the last click: %+v", frames) + } + prevStep, prevDelay = f.move, f.delay + } + if fast < 3 || fast > 6 { + t.Fatal("fast count", fast) + } + // The final approach is all single-space clicks, with an optional + // stationary click at the very end. + approach := 0 + for i := len(frames) - 1; i >= 0 && frames[i].move <= 1; i-- { + approach++ + } + if approach < 3 { + t.Fatalf("wheel must click one space at least 3 times at rest: %+v", frames) + } + if frames[len(frames)-1].move > 1 { + t.Fatalf("final click must stay or advance one space: %+v", frames) + } + winner := table.winningIndex + position := table.position + for i, f := range frames { + for wait := 1; wait < f.delay; wait++ { + if e := table.tick(); len(e) != 0 { + t.Fatal("early frame") + } + } + events := table.tick() + position = (position + f.move) % len(bigSixWheel) + var s *BigSixSnapshot + for _, e := range events { + if v, ok := e.Snapshot.(*BigSixSnapshot); ok { + s = v + break + } + } + if s == nil { + t.Fatal("missing frame") + } + count := 11 + if i == len(frames)-1 { + count = 9 + if position != winner { + t.Fatal("outcome teleport") + } + } + if len(s.Pockets) != count { + t.Fatal("strip width", s) + } + for j, p := range s.Pockets { + if p != bigSixWheel[(position+j-count/2+51)%51] { + t.Fatal("invented neighbor") + } + } + } + } + if !sawLong || !sawVariation { + t.Fatal("no suspense variation") + } +} +func TestBigSixUnbetAndDisconnect(t *testing.T) { + for _, spinning := range []bool{false, true} { + table := newBigSixTest() + table.join("a") + w := &testWallet{credits: 100} + table.bet("a", "1", 10, w) + table.bet("a", "2", 20, w) + if spinning { + table.action("a", "spin") + forceBigSix(table, "DOUBLE") + } else { + table.action("a", "unbet") + if w.paid != 30 || len(w.open) != 0 || table.players.get("a").HasBet { + t.Fatal("unbet") + } + table.bet("a", "1", 10, w) + } + table.markDisconnected("a") + if len(w.open) != 0 || table.players.count() != 0 || table.phase != phaseWaiting { + t.Fatal("disconnect cleanup") + } + if spinning && w.paid != 0 { + t.Fatal("selective refund exploit") + } + } +} +func TestBigSixSettlementFailureAndDeparture(t *testing.T) { + table := newBigSixTest() + table.join("a") + w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + table.bet("a", "1", 10, w) + table.action("a", "spin") + events := forceBigSix(table, "1") + if len(w.open) != 1 { + t.Fatal("failed payout consumed stake") + } + found := false + for _, e := range events { + if strings.Contains(e.Message, "pending recovery") { + found = true + } + if len(e.Settlement) > 0 { + t.Fatal("fake receipt") + } + } + if !found { + t.Fatal("missing error") + } + table.refundAll() + table.bet("a", "1", 1, &testWallet{credits: 10}) + table.action("a", "leave_after") + table.action("a", "spin") + events = forceBigSix(table, "2") + if table.players.count() != 0 { + t.Fatal("queued departure") + } + if s, ok := events[0].Snapshot.(*BigSixSnapshot); !ok || len(s.Players) != 1 || s.Players[0] != "a" { + t.Fatal("departing player lost result visibility") + } +} diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 5656129..b5beb1e 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -112,12 +112,12 @@ func (t *blackjackTable) menuEvent(name string) Event { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.") } - amount, desc := t.Config.MinBet, "Table minimum" + amount, desc, betCmd := t.Config.MinBet, "Table minimum", "bet" if p := t.players.get(name); p != nil && p.lastBet > 0 { - amount, desc = p.lastBet, "Repeat wager" + amount, desc, betCmd = p.lastBet, "Repeat wager", "bet / rebet" } e := t.betMenuEvent(name, []MenuCommand{ - {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)}, + {Command: betCmd, Desc: fmt.Sprintf("%s: %d credits", desc, amount)}, {Command: "bet N", Desc: "Change wager; min/max accepted"}, {Command: "stop", Desc: "Leave; during a round, leave afterward"}, {Command: "table", Desc: "Check the table"}, diff --git a/internal/casino/core.go b/internal/casino/core.go index f51dde3..3956750 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -187,6 +187,9 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error // gameTitle renders a configured game name as a message heading. func gameTitle(game GameName) string { + if game == GameBigSix { + return "Big 6" + } name := string(game) if name == "" { return "Table" diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index e64feb3..9538e7c 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -16,7 +16,7 @@ func (t *crapsTable) commands(name string) []MenuCommand { // Pass is the only target, so the bare bet is a rebet. if p.lastBet > 0 { commands = append(commands, - MenuCommand{Command: "bet", Desc: fmt.Sprintf("Rebet %d on pass (also: rebet)", p.lastBet)}, + MenuCommand{Command: "bet / rebet", Desc: fmt.Sprintf("Rebet %d on pass", p.lastBet)}, MenuCommand{Command: "bet N", Desc: "New amount on pass; the dice roll 3 ticks after a bet"}) } else { commands = append(commands, MenuCommand{Command: "bet N", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) diff --git a/internal/casino/registry.go b/internal/casino/registry.go index a27a9dd..4366bc6 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -84,6 +84,20 @@ func init() { ruleText: func(c TableConfig) string { return c.CrapsRulesText() }, helpTopic: func(c TableConfig) string { return c.Craps.helpTopic() }, }, + GameBigSix: { + houseEdge: bigSixHouseEdge, + validate: func(c TableConfig) error { + if c.BettingTicks != 0 && c.BettingTicks != 25 { + return fmt.Errorf("Big 6 betting_ticks must be 25") + } + return nil + }, + normalize: func(c TableConfig) TableConfig { c.BettingTicks = 25; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newBigSixTable(room, c, rng) }, + ruleText: bigSixRules, + helpTopic: func(TableConfig) string { return "big6" }, + }, GameRoulette: { houseEdge: rouletteHouseEdge, validate: func(c TableConfig) error { diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index eafb05e..5b1d86e 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -75,7 +75,7 @@ func baccaratHintCommands(t *baccaratTable, name string) []MenuCommand { } commands := []MenuCommand{} if p != nil && p.hasLastBet() { - commands = append(commands, MenuCommand{Command: "bet", Desc: "Repeat your last bets"}) + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last bets"}) } if p != nil && len(p.sides) > 0 { commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back your side bets"}) diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go index f81d537..9589deb 100644 --- a/internal/casino/table_ux_test.go +++ b/internal/casino/table_ux_test.go @@ -223,7 +223,7 @@ func TestRebetMenusAndMidRoundJoin(t *testing.T) { } bac.bet("alice", "banker", 25, &testWallet{credits: 100}) menu := bac.menuEvent("alice").Menu - if !menu.Repeat || menu.Commands[0].Command != "bet" || !strings.Contains(menu.Commands[0].Desc, "Repeat") { + if !menu.Repeat || menu.Commands[0].Command != "bet / rebet" || !strings.Contains(menu.Commands[0].Desc, "Repeat") { t.Fatalf("missing rebet details: %+v", menu) } } diff --git a/internal/casino/types.go b/internal/casino/types.go index 570d0ac..8986dc1 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -20,6 +20,7 @@ const ( GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" + GameBigSix GameName = "big6" GameCraps GameName = "craps" ) diff --git a/internal/game/big_six_test.go b/internal/game/big_six_test.go new file mode 100644 index 0000000..6a83cdf --- /dev/null +++ b/internal/game/big_six_test.go @@ -0,0 +1,222 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestBigSixRendering(t *testing.T) { + for _, mode := range []string{"none", "ansi16", "xterm256"} { + for _, unicode := range []bool{false, true} { + s := &casino.BigSixSnapshot{Phase: "betting", Multiplier: "1", BettingTicks: 25, MinBet: 1, MaxBet: 10000} + board := renderBigSixSnapshot(s, mode, unicode) + if mode != "none" && !strings.Contains(board, "\x1b[2m") { + t.Fatal("payouts must be dim") + } + if mode == "none" && strings.Contains(board, "\x1b") { + t.Fatal("ANSI leaked into plain display") + } + for _, text := range []string{"$1", "$2", "$5", "$10", "$20", "SWORD", "SHIELD", "DOUBLE", "pays 47:1", "Bets:"} { + if !strings.Contains(board, text) { + t.Fatalf("missing %q: %s", text, board) + } + } + for _, gone := range []string{"B I G", "RTP", "Place any number", "Odds are profit", "double & respin"} { + if strings.Contains(board, gone) { + t.Fatalf("stale felt text %q: %s", gone, board) + } + } + if unicode && !strings.ContainsAny(board, "─│┌╭") { + t.Fatalf("Unicode felt is still ASCII: %s", board) + } + if !unicode && strings.ContainsAny(board, "─│┌╭") { + t.Fatalf("ASCII felt leaked Unicode: %s", board) + } + for _, phase := range []string{"playing", "result"} { + s.Phase = phase + s.Pockets = []string{"1", "2", "5", "10", "DOUBLE", "20", "SWORD", "SHIELD", "1"} + if phase == "playing" { + s.Pockets = append(s.Pockets, "1", "2") + } + text := renderBigSixSnapshot(s, mode, unicode) + if strings.Count(text, "\n") < 5 { + t.Fatal("not a multi-line wheel", text) + } + wall := "|" + if unicode { + wall = "│" + } + // The bill outline must continue through the number row. + bill := wall + " $1 " + if phase == "result" { + bill = wall + " $1 " + wall + } else { + bill = wall + " $1 " + wall + } + if !strings.Contains(text, bill) { + t.Fatalf("number row is missing the bill walls (%q): %s", bill, text) + } + // Every rail and separator uses the same dim-yellow border ink. + if mode != "none" { + yellow := color.Render(mode, color.ColorSpec{Fg: 3, Bg: -1, Dim: true}, wall) + if !strings.Contains(text, yellow) { + t.Fatalf("wheel separators are not the yellow border color: %s", text) + } + } + for _, line := range strings.Split(text, "\n") { + if color.VisibleLen(line) > 80 { + t.Fatalf("frame too wide (%d): %s", color.VisibleLen(line), line) + } + } + pointer := "\\/" + if unicode { + pointer = "▼" + } + if !strings.Contains(text, pointer) { + t.Fatal("no pointer") + } + if strings.ContainsAny(text, "◆") { + t.Fatalf("second pointer glyph still present: %s", text) + } + if strings.Contains(text, "* * *") || strings.Contains(text, "[ STOP ]") { + t.Fatalf("stale wheel result art: %s", text) + } + if phase == "result" { + lines := strings.Split(strings.TrimRight(text, " \n"), "\n") + if !strings.Contains(lines[len(lines)-1], "WINNER") { + t.Fatalf("WINNER must print below the entire wheel: %s", text) + } + } + } + for _, line := range strings.Split(board, "\n") { + if color.VisibleLen(line) > 80 { + t.Fatalf("felt too wide (%d): %s", color.VisibleLen(line), line) + } + if strings.Contains(line, "pays 23:1") && mode != "none" { + // The box walls stay bold; only the payout text is dim. + if !strings.Contains(line, "\x1b[1m") || !strings.Contains(line, "\x1b[2m") { + t.Fatalf("pays row walls/text have the wrong weight: %q", line) + } + } + } + } + } +} + +func TestBigSixUnicodeArtwork(t *testing.T) { + // Check the rendered cells, not just the source art: independent row + // centering must not misalign the blade or disconnect shield corners. + for _, tt := range []struct { + label string + want []string + }{ + {"SWORD", []string{ + "│ ▲ │", + "│ ║ │", + "│ ║ │", + "│ ╘══╬══╛ │", + "│ ╩ │", + }}, + {"SHIELD", []string{ + "│ ╭───────╮ │", + "│ │ ◇ │ │", + "│ ╰╮ ╭╯ │", + "│ ╰╮ ╭╯ │", + "│ ╰───╯ │", + }}, + } { + t.Run(tt.label, func(t *testing.T) { + panel := bigSixPanel(casino.BigSixSpot{Label: tt.label, Pays: 47}, 24, "none", true) + if got, want := strings.Join(panel[2:7], "\n"), strings.Join(tt.want, "\n"); got != want { + t.Fatalf("table artwork:\n%s\nwant:\n%s", got, want) + } + }) + } + + s := &casino.BigSixSnapshot{Phase: "result", Pockets: []string{"SWORD", "SHIELD"}} + lines := strings.Split(renderBigSixSnapshot(s, "none", true), "\n") + want := strings.Join([]string{ + "│ ▲ │╭─────╮│", + "│ ╘═╬═╛ │╰╮ ◇ ╭╯│", + "│ ╩ │ ╰───╯ │", + "│ SWORD │SHIELD │", + }, "\n") + if got := strings.Join(lines[2:6], "\n"); got != want { + t.Fatalf("wheel artwork:\n%s\nwant:\n%s", got, want) + } +} + +func TestBigSixSpinAbbreviation(t *testing.T) { + // "sp" is the blackjack split shorthand. Split is not a Big 6 command, so + // the abbreviation must instead match the only relevant verb: spin. + g, sess, _ := houseEdgeGame(t, "name: Wheel\ncasino_tables:\n - id: big6\n game: big6\n") + g.queue = NewCommandQueue() + g.executePlay(sess, []string{"big6"}, "play big6") + g.executeBet(sess, []string{"2", "on", "1"}, "bet 2 on 1") + if got, ok := g.casinoAbbreviation(sess, "sp"); !ok || got != "spin" { + t.Fatalf("sp at Big 6 resolved to %q (ok=%v), want spin", got, ok) + } + // Prefixes of other games' verbs (split, roll, hit, double) must not + // resolve at this table. + for _, input := range []string{"spl", "spli", "ro", "hi", "dd"} { + if got, ok := g.casinoAbbreviation(sess, input); ok { + t.Fatalf("Big 6 %q unexpectedly abbreviated to %q", input, got) + } + } + g.handleGameCommand(sess, "sp") + if qc, ok := g.queue.PeekActive("alice"); !ok || qc.Command != "spin" { + t.Fatalf("sp queued %+v (ok=%v), want spin", qc, ok) + } +} + +func TestBigSixCommandsAndVisibility(t *testing.T) { + g, sess, conn := houseEdgeGame(t, "name: Wheel\ncasino_tables:\n - id: big6\n game: big6\n") + g.executePlay(sess, []string{"big6"}, "play big6") + table, playing := g.Casino.Participation(sess.Player.Name) + if !playing || table == nil { + t.Fatal("join failed", string(conn.data)) + } + for _, target := range []string{"1", "$20", "sword", "shield"} { + g.executeBet(sess, []string{"2", "on", target}, "bet 2 on "+target) + } + if sess.Player.Credits != 92 || table.State() != "waiting" { + t.Fatal("bet/spin conflated", sess.Player.Credits, table.State(), string(conn.data)) + } + conn.data = nil + g.executeList(sess, nil, "list") + if !strings.Contains(string(conn.data), "SWORD") || !strings.Contains(string(conn.data), "47:1") { + t.Fatal("list missing targets", string(conn.data)) + } + conn.data = nil + g.executeTableStatus(sess, nil, "table") + if !strings.Contains(string(conn.data), "Bets:") { + t.Fatal("table not routed", string(conn.data)) + } + conn.data = nil + g.executeRules(sess, nil, "rules") + if !strings.Contains(string(conn.data), "48/49") { + t.Fatal("help not routed", string(conn.data)) + } + g.executeSpin(sess, nil, "spin") + if table.State() != "playing" || sess.Player.Credits != 92 { + t.Fatal("spin rebet/debit", string(conn.data)) + } + g.executeSpin(sess, nil, "spin") + if sess.Player.Credits != 92 { + t.Fatal("second spin debited") + } + beforePrompt := len(conn.data) + g.writePrompt(sess) + if len(conn.data) != beforePrompt { + t.Fatal("prompt interrupted wheel") + } + if !table.Busy(sess.Player.Name) { + t.Fatal("animation prompt handling") + } + if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "gone", Snapshot: &casino.BigSixSnapshot{Players: []string{sess.Player.Name}}}) { + t.Fatal("departing seat cannot see final frame") + } +} diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go index 7cea977..9e36466 100644 --- a/internal/game/blackjack_rules.go +++ b/internal/game/blackjack_rules.go @@ -22,6 +22,10 @@ func (g *Game) lookCrapsTable(sess *net.Session, target string) bool { return g.lookCasinoTable(sess, target, casino.GameCraps) } +func (g *Game) lookBigSixTable(sess *net.Session, target string) bool { + return g.lookCasinoTable(sess, target, casino.GameBigSix) +} + func (g *Game) lookRouletteTable(sess *net.Session, target string) bool { return g.lookCasinoTable(sess, target, casino.GameRoulette) } diff --git a/internal/game/casino.go b/internal/game/casino.go index 8f18614..6dce0bd 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -380,6 +380,21 @@ func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName str g.emitCasinoEvents(events) } +// Keep slots' spin-as-bet behavior, while Big 6 spins already committed wagers. +func (g *Game) executeSpin(sess *net.Session, args []string, rawInput string) { + if sess.Player != nil { + if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && table.Config.Game == casino.GameBigSix { + if len(args) != 0 { + sess.WriteLine("Use spin after placing your bets.") + return + } + g.executeTableAction(sess, "spin") + return + } + } + g.executeBet(sess, args, rawInput) +} + func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { @@ -537,7 +552,13 @@ func (g *Game) rouletteSpinning(sess *net.Session) bool { return false } table, playing := g.Casino.Participation(sess.Player.Name) - if !playing || table == nil || table.Config.Game != casino.GameRoulette { + if !playing || table == nil { + return false + } + if table.Config.Game == casino.GameBigSix { + return table.Busy(sess.Player.Name) + } + if table.Config.Game != casino.GameRoulette { return false } s, ok := table.Board(sess.Player.Name).(*casino.RouletteSnapshot) @@ -677,8 +698,8 @@ func (g *Game) casinoHintsVisible(sess *net.Session, menu *casino.MenuView) bool } } -// casinoBoardReminder splits a persistent-board menu (craps, roulette, video -// poker, Pai Gow) so its instructions print under the board they describe. +// casinoBoardReminder splits a persistent-board menu (craps, roulette, Big 6, +// video poker, Pai Gow) so its instructions print under the board they describe. // Every other game, and compact displays, keep their menu in one block. func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (reminder string, ok bool) { if category != "casino_menu" || menu == nil || sess.Player == nil { @@ -687,11 +708,13 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas _, isCraps := snapshot.(*casino.CrapsSnapshot) _, isPoker := snapshot.(*casino.VideoPokerSnapshot) _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) + b, isBigSix := snapshot.(*casino.BigSixSnapshot) + isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isPoker && !isCraps && !isPaiGow && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } - if !isPoker && !isCraps && !isPaiGow && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", false } @@ -780,6 +803,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) return renderVideoPoker(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.CrapsSnapshot: return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) + case *casino.BigSixSnapshot: + return renderBigSixSnapshot(s, mode, unicode) case *casino.RouletteSnapshot: return renderRouletteSnapshot(s, mode, unicode, size) case *casino.SlotSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index e44da54..bc1906f 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -66,6 +66,8 @@ func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bo func casinoTableAliases(cfg casino.TableConfig) []string { names := []string{string(cfg.Game)} switch cfg.Game { + case casino.GameBigSix: + names = append(names, "big-six", "big_six", "big six", "big 6") case casino.GameBlackjack: names = append(names, "bj") case casino.GameBaccarat: @@ -235,6 +237,12 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool { return true } } + case *casino.BigSixSnapshot: + for _, name := range s.Players { + if name == sess.Player.Name { + return true + } + } case *casino.RouletteSnapshot: for _, name := range s.Players { if name == sess.Player.Name { diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go index c83db27..423469c 100644 --- a/internal/game/casino_ux_test.go +++ b/internal/game/casino_ux_test.go @@ -93,23 +93,64 @@ func TestCasinoCommandAbbreviations(t *testing.T) { } g.executePlay(sess, []string{"bj"}, "play bj") for input, want := range map[string]string{ - "b": "bet", "be": "bet", "spl": "split", "su": "surrender", - "ins": "insurance", "ro": "roll", "wa": "watch", "ta": "table", - "li": "list", "ga": "game", + "b": "bet", "be": "bet", "spl": "split", "sp": "split", "su": "surrender", + "ins": "insurance", "wa": "watch", "ta": "table", + "li": "list", "ga": "game", "sto": "stop", "ru": "rules", + // The terse aliases resolve only because their verb is a blackjack verb. + "h": "hit", "st": "stand", "dd": "double", } { got, ok := g.casinoAbbreviation(sess, input) if !ok || got != want { t.Fatalf("%q resolved to %q (ok=%v), want %q", input, got, ok, want) } } - // stop and rules are full-word only, and movement keeps its own keys. - for _, input := range []string{"sto", "r", "ru", "n", "s"} { + // No other game's verbs leak into a blackjack table. + for _, input := range []string{"ro", "rol", "ra", "au", "dr", "pa", "ha", "ho"} { + if got, ok := g.casinoAbbreviation(sess, input); ok { + t.Fatalf("blackjack %q unexpectedly abbreviated to %q", input, got) + } + } + // Exact commands keep their meaning: movement, info, and whole words that + // are already real commands. + for _, input := range []string{"n", "s", "e", "w", "i", "l", "hit"} { if got, ok := g.casinoAbbreviation(sess, input); ok { t.Fatalf("%q unexpectedly abbreviated to %q", input, got) } } } +func TestCasinoAbbreviationsAreGameScoped(t *testing.T) { + // Baccarat has neither split nor spin, so "sp" matches nothing there. + g, sess, _ := uxCasinoGame(t) + g.executePlay(sess, []string{"bac"}, "play bac") + for _, input := range []string{"sp", "spl", "ro", "au"} { + if got, ok := g.casinoAbbreviation(sess, input); ok { + t.Fatalf("baccarat %q unexpectedly abbreviated to %q", input, got) + } + } + if got, ok := g.casinoAbbreviation(sess, "be"); !ok || got != "bet" { + t.Fatalf("baccarat be resolved to %q (ok=%v), want bet", got, ok) + } + + // Craps owns roll/autoroll; a blackjack table must not resolve them. + g2, sess2, _ := casinoCrapsGame(t, "name: Pit\ncasino_tables:\n - id: cr\n game: craps\n") + g2.executePlay(sess2, []string{"craps"}, "play craps") + if got, ok := g2.casinoAbbreviation(sess2, "ro"); !ok || got != "roll" { + t.Fatalf("craps ro resolved to %q (ok=%v), want roll", got, ok) + } + if got, ok := g2.casinoAbbreviation(sess2, "rol"); !ok || got != "roll" { + t.Fatalf("craps rol resolved to %q (ok=%v), want roll", got, ok) + } + if got, ok := g2.casinoAbbreviation(sess2, "au"); !ok || got != "autoroll" { + t.Fatalf("craps au resolved to %q (ok=%v), want autoroll", got, ok) + } + for _, input := range []string{"sp", "spl", "ins", "su"} { + if got, ok := g2.casinoAbbreviation(sess2, input); ok { + t.Fatalf("craps %q unexpectedly abbreviated to %q", input, got) + } + } +} + func TestAbbreviatedCasinoCommandReachesQueue(t *testing.T) { g, sess, _ := uxCasinoGame(t) g.queue = NewCommandQueue() diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 710dd92..5b60118 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -70,7 +70,7 @@ var commandRegistry = map[string]commandDef{ "dd": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "double") }, ClassActive}, "sp": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "split") }, ClassActive}, "bet": {(*Game).executeBet, ClassActive}, - "spin": {(*Game).executeBet, ClassActive}, + "spin": {(*Game).executeSpin, ClassActive}, "autobet": {(*Game).executeAutoBet, ClassActive}, "autospin": {(*Game).executeAutoBet, ClassActive}, "hit": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "hit") }, ClassActive}, @@ -240,35 +240,96 @@ func classifyCommand(cmd string) CommandClass { return ClassUnknown } -// casinoShortVerbs are the casino verbs a seated player may abbreviate to an -// unambiguous prefix. "stop" and "rules" are deliberately absent: their -// prefixes (st, r, ru) would otherwise shadow stand and movement, so those two -// commands must be typed in full. -var casinoShortVerbs = []string{ - "bet", "deal", "draw", "hold", "paytable", "denom", "denomination", - "hands", "feature", "game", "roll", "autoroll", "unbet", "table", - "status", "list", "watch", "decline", "hit", "stand", - "double", "split", "insurance", "surrender", "spin", "autobet", - "autospin", "rebet", +// casinoCommonVerbs are the generic controls a seated player may abbreviate +// at any casino game; they are not tied to one game's rules. +var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rules"} + +// casinoVerbsByGame scopes the rest of the abbreviable verbs to the game the +// player is actually in. A prefix can therefore never match another game's +// command: "sp" is split at a blackjack table, but spin at a Big 6 wheel, +// because "split" is not a Big 6 verb and "spin" is not a blackjack verb. +var casinoVerbsByGame = map[casino.GameName][]string{ + casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"}, + casino.GameBaccarat: {"bet", "rebet", "unbet"}, + casino.GameRoulette: {"bet", "rebet", "unbet"}, + casino.GameCraps: {"bet", "rebet", "unbet", "roll", "autoroll"}, + casino.GamePaiGow: {"bet", "rebet", "unbet"}, + casino.GameVideoPoker: {"bet", "deal", "draw", "hold", "paytable", "denom", "denomination", "hands", "feature"}, + casino.GameSlots: {"bet", "spin", "autospin", "autobet"}, + casino.GameBigSix: {"bet", "rebet", "unbet", "spin"}, +} + +// casinoShortAliases maps the registry's terse aliases onto the verb they +// stand for. An alias only applies when its verb belongs to the current game; +// otherwise the letters are re-read as a fresh prefix, so "sp" is split at a +// blackjack table and spin at a Big 6 wheel. +var casinoShortAliases = map[string]string{ + "h": "hit", "st": "stand", "dd": "double", "sp": "split", +} + +// casinoVerbsFor returns the game-scoped verb vocabulary for a seated player, +// or nil when they are not in a casino game. +func (g *Game) casinoVerbsFor(name string) []string { + if g.Casino == nil { + return nil + } + table, playing := g.Casino.Participation(name) + if !playing { + return nil + } + var game casino.GameName + switch { + case table != nil: + game = table.Config.Game + default: + machine, ok := g.Casino.MachineFor(name) + if !ok { + return nil + } + game = machine.Config.Game + } + verbs := append([]string(nil), casinoCommonVerbs...) + return append(verbs, casinoVerbsByGame[game]...) } -// casinoAbbreviation resolves a seated casino player's unambiguous command -// prefix to its full verb. An exact command (including the h/st/dd/sp aliases -// and video poker's d) always wins, so a prefix never shadows a real command; -// an ambiguous prefix resolves to nothing and falls through unchanged. +// casinoAbbreviation resolves a seated casino player's partial command to the +// closest verb that is actually relevant to the game they are in. Exact +// commands always win, so movement, look and the game's own real verbs are +// never shadowed; a short alias applies only when its verb belongs to this +// game; an ambiguous prefix resolves to nothing and falls through unchanged. func (g *Game) casinoAbbreviation(sess *net.Session, cmd string) (string, bool) { - if sess == nil || sess.Player == nil || cmd == "" || !g.inCasinoGame(sess.Player.Name) { + if sess == nil || sess.Player == nil || cmd == "" { + return "", false + } + verbs := g.casinoVerbsFor(sess.Player.Name) + if len(verbs) == 0 { return "", false } - if _, ok := commandRegistry[cmd]; ok { + // A real casino verb needs no abbreviation: the registry dispatches it. + for _, verb := range verbs { + if verb == cmd { + return "", false + } + } + if target, ok := casinoShortAliases[cmd]; ok { + for _, verb := range verbs { + if verb == target { + return target, true + } + } + // The alias names another game's verb: fall through and read the + // letters as a prefix of this game's vocabulary instead. + } else if _, ok := commandRegistry[cmd]; ok { + // Any other real command (movement, look, inventory, ...) wins over a + // casino prefix. return "", false } match := "" - for _, verb := range casinoShortVerbs { + for _, verb := range verbs { if !strings.HasPrefix(verb, cmd) { continue } - if match != "" { + if match != "" && match != verb { return "", false } match = verb diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 41e25b8..479e397 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -111,6 +111,10 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.showMenu(sess) return } + // Log the disk state before identity backfill, missing-room rescue, or + // recording this login's visit can change it. Paired with shutdown saves, + // this distinguishes lost saves from loading a different/older file. + log.Printf("character loaded: %s room=%d explored=%d file=%s", p.Name, p.RoomID, p.Stats.RoomsVisited.Len(), g.AccountStore.CharPath(name)) g.adoptCharacterIdentity(p, sess.Account.Name) if g.Hub != nil { diff --git a/internal/game/game.go b/internal/game/game.go index e5192c0..a3a9684 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -343,8 +343,13 @@ func (g *Game) saveAllOnlineCharacters() error { if p := sess.Player; p != nil { g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) - if err := g.AccountStore.SaveCharacter(p); err != nil && firstErr == nil { - firstErr = fmt.Errorf("save %s: %w", p.Name, err) + if err := g.AccountStore.SaveCharacter(p); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("save %s: %w", p.Name, err) + } + log.Printf("shutdown: character save failed: %s: %v", p.Name, err) + } else { + log.Printf("shutdown: character saved: %s room=%d explored=%d file=%s", p.Name, p.RoomID, p.Stats.RoomsVisited.Len(), g.AccountStore.CharPath(p.Name)) } } } @@ -619,9 +624,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } cmd := parts[0] - // Seated casino players may shorten any casino verb to an unambiguous - // prefix (b 1000 -> bet 1000). Exact commands always win, so movement and - // the existing short aliases are never shadowed. + // Seated casino players may shorten any of their game's verbs to an + // unambiguous prefix (b 1000 -> bet 1000, sp -> spin at Big 6). Only the + // current game's vocabulary is considered, and exact commands still win, so + // movement and non-casino commands are never shadowed. if canon, ok := g.casinoAbbreviation(sess, cmd); ok { cmd = canon parts[0] = canon diff --git a/internal/game/hardening_shutdown_save_error_test.go b/internal/game/hardening_shutdown_save_error_test.go index c75f6fc..5842f9b 100644 --- a/internal/game/hardening_shutdown_save_error_test.go +++ b/internal/game/hardening_shutdown_save_error_test.go @@ -7,10 +7,59 @@ package game import ( "os" "path/filepath" + "reflect" "strings" "testing" + "time" + + "thehouseoficarus/internal/player" ) +func TestShutdownPreservesOnlineLocationAndExploration(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + p.RoomID = 6101 + p.Stats.RecordRoomVisit(6101) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Fatal(err) + } + sess, _ := h.connect("alice") + wantRooms := []int{6101, 6182, 6183, 6199} + h.g.RunOnOwner(func() { + sess.Player.RoomID = 6199 + for _, id := range wantRooms { + sess.Player.Stats.RecordRoomVisit(id) + } + }) + // Deliberately leave disk at the older state. The signal shutdown path + // must persist the connected character before closing the transport, + // without requiring the player to quit or disconnect first. + var saved *player.Player + var loadErr error + h.g.SetTransportStop(func() { + saved, loadErr = player.NewAccountStore(h.dir).LoadCharacter("alice") + }) + h.g.RequestShutdown("signal") + select { + case <-h.g.ShutdownDone(): + case <-time.After(5 * time.Second): + t.Fatal("shutdown did not finish") + } + if err := h.g.ShutdownSaveError(); err != nil { + t.Fatal(err) + } + if loadErr != nil { + t.Fatal(loadErr) + } + if saved == nil { + t.Fatal("transport stop was not called") + } + if saved.RoomID != 6199 || !reflect.DeepEqual(saved.Stats.RoomsVisited.Keys(), wantRooms) { + t.Fatalf("shutdown lost progress: room=%d explored=%v", saved.RoomID, saved.Stats.RoomsVisited.Keys()) + } +} + // TestShutdownSaveErrorReportsFailedFinalSave makes the character save fail // (the character file is replaced by a directory, so the atomic replace cannot // land) and requires the shutdown sequence to record the failure on the Game. diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index 8d2b5c3..ec9ab61 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi fmt.Sprintf(" Seated %s", seated), } switch cfg.Game { - case casino.GameRoulette: + case casino.GameRoulette, casino.GameBigSix: // Look, like sitting down and the table command, shows the live // wheel: tracker, racetrack, and betting layout. if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" { diff --git a/internal/game/look_target.go b/internal/game/look_target.go index d296b35..9065ccc 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { + if g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { return } diff --git a/internal/game/render_big_six.go b/internal/game/render_big_six.go new file mode 100644 index 0000000..19579f0 --- /dev/null +++ b/internal/game/render_big_six.go @@ -0,0 +1,209 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func bigSixInk(mode, label, text string, dim bool) string { + fg := 2 + switch label { + case "2": + fg = 6 + case "5": + fg = 4 + case "10": + fg = 5 + case "20": + fg = 3 + case "SWORD": + fg = 15 + case "SHIELD": + fg = 6 + case "DOUBLE": + fg = 11 + } + return color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: !dim, Dim: dim}, text) +} +func bigSixLabel(s string) string { + if s == "SWORD" || s == "SHIELD" || s == "DOUBLE" { + return s + } + return "$" + s +} + +func bigSixPanel(spot casino.BigSixSpot, width int, mode string, unicode bool) []string { + b := rouletteBorders(unicode) + label := bigSixLabel(spot.Label) + art := []string{"$ $", ".--------.", "|" + rouletteCenter(label, 8) + "|", "'--------'", "$ $"} + if unicode { + art = []string{"$ $", "╭────────╮", "│" + rouletteCenter(label, 8) + "│", "╰────────╯", "$ $"} + } + switch spot.Label { + case "SWORD": + // Two full blade rows below the tip; the guard sits just above the grip. + art = []string{"/\\", "||", "||", "===++===", "()"} + if unicode { + art = []string{"▲", "║", "║", "╘══╬══╛", "╩"} + } + case "SHIELD": + art = []string{".--------.", "| /\\ |", "| \\/ |", "\\ /", "'------'"} + if unicode { + // Keep a fixed-width canvas: centering each tapered row separately + // would shift the corners apart and break the shield's outline. + art = []string{"╭───────╮", "│ ◇ │", "╰╮ ╭╯", " ╰╮ ╭╯ ", " ╰───╯ "} + } + } + lines := []string{b.tl + strings.Repeat(b.h, width) + b.tr, b.v + rouletteCenter(label, width) + b.v} + for _, a := range art { + lines = append(lines, b.v+rouletteCenter(a, width)+b.v) + } + lines = append(lines, b.bl+strings.Repeat(b.h, width)+b.br) + // The payout text is dim, but its box walls keep the same bold weight as + // the rest of the panel; otherwise the row's verticals look thin. + wall := bigSixInk(mode, spot.Label, b.v, false) + oddsLine := wall + bigSixInk(mode, spot.Label, rouletteCenter(fmt.Sprintf("pays %d:1", spot.Pays), width), true) + wall + lines = append(lines[:len(lines)-1], oddsLine, lines[len(lines)-1]) + for i := range lines { + if i == len(lines)-2 { + continue // oddsLine is already colored: bold walls, dim payout + } + lines[i] = bigSixInk(mode, spot.Label, lines[i], false) + } + return lines +} + +// Self-contained scrollback art, always within 80 columns. The last two +// betting panels expand into the space formerly occupied by DOUBLE; DOUBLE +// is an automatic feature, advertised in a shallow full-width banner instead. +func renderBigSixSnapshot(s *casino.BigSixSnapshot, mode string, unicode bool) string { + if len(s.Pockets) > 0 { + return renderBigSixStrip(s, mode, unicode) + } + if s.Phase == "playing" { + return "Big 6 wheel spinning | live stakes " + s.Multiplier + "x" + } + var out strings.Builder + spots := casino.BigSixSpots() + for _, row := range [][]casino.BigSixSpot{spots[:4], spots[4:7]} { + var panels [][]string + for _, spot := range row { + width := 15 + if spot.Label == "SWORD" || spot.Label == "SHIELD" { + width = 24 + } + panels = append(panels, bigSixPanel(spot, width, mode, unicode)) + } + for line := range panels[0] { + out.WriteString(" ") + for col, panel := range panels { + if col > 0 { + out.WriteByte(' ') + } + out.WriteString(panel[line]) + } + out.WriteByte('\n') + } + } + b := rouletteBorders(unicode) + const bannerWidth = 69 // 71 columns including borders, like both betting rows. + banner := []string{ + b.tl + strings.Repeat(b.h, bannerWidth) + b.tr, + b.v + rouletteCenter("DOUBLE", bannerWidth) + b.v, + b.v + rouletteCenter("Every live bet doubles and the wheel spins again", bannerWidth) + b.v, + b.v + rouletteCenter("Double again. And again. No cap.", bannerWidth) + b.v, + b.bl + strings.Repeat(b.h, bannerWidth) + b.br, + } + for _, line := range banner { + out.WriteString(" " + bigSixInk(mode, "DOUBLE", line, false) + "\n") + } + fmt.Fprintf(&out, " Bets: %s", betLimitsText(s.MinBet, s.MaxBet)) + return out.String() +} + +func renderBigSixStrip(s *casino.BigSixSnapshot, mode string, unicode bool) string { + result := s.Phase == "result" + width := 6 + if result { + width = 7 + } + n := len(s.Pockets) + center := n / 2 + pointer := 1 + center*(width+1) + width/2 + b := rouletteBorders(unicode) + wall := "|" + if unicode { + wall = "│" + } + // Every rail and separator on the wheel uses the same dim-yellow border + // ink; the per-pocket colors paint only the interiors. + borderInk := func(text string) string { return bigSixInk(mode, "20", text, true) } + var out strings.Builder + tip := "\\/" + if unicode { + tip = "▼" + } + // No leading/trailing newline: WriteLine supplies the sole frame separator. + out.WriteString(strings.Repeat(" ", pointer) + bigSixInk(mode, "DOUBLE", tip, false) + "\n") + top := b.tl + strings.Repeat(strings.Repeat(b.h, width)+b.teeTop, n-1) + strings.Repeat(b.h, width) + b.tr + bottom := b.bl + strings.Repeat(strings.Repeat(b.h, width)+b.teeBottom, n-1) + strings.Repeat(b.h, width) + b.br + out.WriteString(borderInk(top) + "\n") + rows := 3 + if result { + rows = 4 + } + for row := 0; row < rows; row++ { + out.WriteString(borderInk(b.v)) + for i, pocket := range s.Pockets { + label := bigSixLabel(pocket) + var art []string + if result { + // The bill's side walls continue through the number row so the + // outline stays complete once the wheel stops. + art = []string{".-----.", "| $ |", wall + rouletteCenter(label, 5) + wall, "'-----'"} + if unicode { + art = []string{"╭─────╮", "│ $ │", wall + rouletteCenter(label, 5) + wall, "╰─────╯"} + } + switch pocket { + case "SWORD": + art = []string{"/\\", "||", "--+--", label} + if unicode { + art = []string{"▲", "╘═╬═╛", "╩", label} + } + case "SHIELD": + art = []string{"/---\\", "| + |", "\\___/", label} + if unicode { + art = []string{"╭─────╮", "╰╮ ◇ ╭╯", " ╰───╯ ", label} + } + case "DOUBLE": + art = []string{"", "x 2", label, "RESPIN"} + if unicode { + art[1] = "× 2" + } + } + } else { + // Only the dollar symbols are drawn as banknotes; the logo and + // DOUBLE spaces show their name without the bill walls. + art = []string{".----.", wall + rouletteCenter(label, 4) + wall, "'----'"} + if unicode { + art = []string{"╭────╮", wall + rouletteCenter(label, 4) + wall, "╰────╯"} + } + switch pocket { + case "SWORD", "SHIELD", "DOUBLE": + art = []string{"", label, ""} + } + } + out.WriteString(bigSixInk(mode, pocket, rouletteCenter(art[row], width), result && i != center)) + out.WriteString(borderInk(b.v)) + } + out.WriteByte('\n') + } + out.WriteString(borderInk(bottom)) + if result { + out.WriteString("\n" + strings.Repeat(" ", pointer-3) + bigSixInk(mode, s.Pockets[center], "WINNER", false)) + } + return out.String() +} |
