aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 07:11:58 -0400
committerhistoria <[not public]>2026-10-03 07:11:58 -0400
commit0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc (patch)
treeac0af51cc2513b1ddd8a280f360af820d1f097af /internal
parent6232d41e5639f9c38e47b2bb65266e343ca26e8f (diff)
downloadthehouseoficarus-0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc.tar.gz
feat: added big 6 wheel to the casino
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go2
-rw-r--r--internal/casino/big_six.go530
-rw-r--r--internal/casino/big_six_test.go293
-rw-r--r--internal/casino/blackjack.go6
-rw-r--r--internal/casino/core.go3
-rw-r--r--internal/casino/craps_ux.go2
-rw-r--r--internal/casino/registry.go14
-rw-r--r--internal/casino/table_hints.go2
-rw-r--r--internal/casino/table_ux_test.go2
-rw-r--r--internal/casino/types.go1
-rw-r--r--internal/game/big_six_test.go222
-rw-r--r--internal/game/blackjack_rules.go4
-rw-r--r--internal/game/casino.go35
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/casino_ux_test.go51
-rw-r--r--internal/game/cmd_registry.go99
-rw-r--r--internal/game/core_login_char.go4
-rw-r--r--internal/game/game.go16
-rw-r--r--internal/game/hardening_shutdown_save_error_test.go49
-rw-r--r--internal/game/look_casino.go2
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_big_six.go209
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()
+}