From 0ec8f3692960a064616e630c914058298b22897b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 01:38:58 -0400 Subject: feat: replaced roulette wheel graphic with line-by-line 'spin' --- internal/casino/casino_review_regression_test.go | 89 ++++++++++++++++++++ internal/casino/craps.go | 15 +++- internal/casino/registry.go | 2 +- internal/casino/roulette.go | 77 ++++++++++++++--- internal/casino/roulette_animation_test.go | 103 +++++++++++++++++++++++ internal/casino/video_poker.go | 22 +++-- internal/casino/video_poker_rules.go | 4 +- internal/game/casino.go | 19 ++++- internal/game/game.go | 8 +- internal/game/render_roulette.go | 27 +++++- internal/game/render_roulette_test.go | 5 +- internal/game/render_video_poker.go | 14 ++- internal/game/roulette_animation_test.go | 102 ++++++++++++++++++++++ internal/game/video_poker_alignment_test.go | 35 ++++++++ internal/game/video_poker_render_test.go | 2 +- 15 files changed, 489 insertions(+), 35 deletions(-) create mode 100644 internal/casino/casino_review_regression_test.go create mode 100644 internal/casino/roulette_animation_test.go create mode 100644 internal/game/roulette_animation_test.go create mode 100644 internal/game/video_poker_alignment_test.go (limited to 'internal') diff --git a/internal/casino/casino_review_regression_test.go b/internal/casino/casino_review_regression_test.go new file mode 100644 index 0000000..8860bd7 --- /dev/null +++ b/internal/casino/casino_review_regression_test.go @@ -0,0 +1,89 @@ +package casino + +import ( + "reflect" + "strings" + "testing" +) + +func TestCrapsRoundedOddsRespectTableMaximum(t *testing.T) { + for _, kind := range []string{"pass", "dontpass"} { + t.Run(kind, func(t *testing.T) { + table := crapsFixture() + table.Config.MaxBet = 11 + table.join("a") + w := &testWallet{chips: 100} + table.bet("a", kind, 10, w) + table.resolveRoll(3, 3) + // Eleven rounds to 15 for pass odds, or 12 for don't odds. + events := table.bet("a", "odds "+kind, 11, w) + if !hasMessage(events, "above the table maximum") || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 { + t.Fatalf("rounded odds bypassed maximum: %+v", events) + } + }) + } +} + +func TestCrapsFailedUnbetPreservesAllLiveWagers(t *testing.T) { + for _, retry := range []bool{false, true} { + t.Run(map[bool]string{false: "roll", true: "retry"}[retry], func(t *testing.T) { + table := crapsFixture() + table.join("a") + w := &pokerTestWallet{testWallet: testWallet{chips: 1000}} + table.bet("a", "pass", 10, w) + table.resolveRoll(3, 3) + table.bet("a", "odds pass", 10, w) + table.bet("a", "place 8", 12, w) + before := table.view().Bets + w.fail = true + events := table.action("a", "unbet") + if !hasMessage(events, "All wagers remain live") || !reflect.DeepEqual(before, table.view().Bets) || len(w.open) != 3 || !table.players.get("a").HasBet { + t.Fatalf("failed refund changed live wagers: %+v", events) + } + w.fail = false + if retry { + table.action("a", "unbet") + table.action("a", "unbet") + if w.paid != 22 || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 { + t.Fatalf("refund retry was not exact: %+v", w) + } + } else { + table.resolveRoll(3, 3) + if w.paid != 42 || len(w.open) != 1 { + t.Fatalf("preserved pass/odds failed to settle: %+v", w) + } + } + }) + } +} + +func TestPokerPreparationClearsPreviousBonusPresentation(t *testing.T) { + for _, command := range []string{"coins", "coins 1", "unbet", "denom 10", "hands 5", "game ddb", "feature off"} { + t.Run(command, func(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetHotRoll}) + pokerDeal(t, table, w) + table.action("alice", "draw") + p := table.players["alice"] + p.multiplier, p.dice = 12, [2]int{6, 6} + p.loaded = 5 + events := table.action("alice", command) + snap, ok := events[0].Snapshot.(*VideoPokerSnapshot) + if !ok { + t.Fatalf("command rejected: %+v", events) + } + if snap.Multiplier != 1 || snap.Dice != [2]int{} || snap.NoWin || snap.Held != [5]bool{} || len(snap.Cards) != 0 || len(snap.Lines) != 0 || len(snap.Current) != 0 { + t.Fatalf("stale result after %s: %+v", command, snap) + } + if len(w.open) != 0 { + t.Fatal("preparation created a wager") + } + }) + } +} + +func TestBonusPokerDeluxeRuleDescribesFlatQuadAward(t *testing.T) { + cfg := (VideoPokerConfig{Variant: "bonus_poker_deluxe"}).Normalize() + if rule := cfg.PairRule(); !strings.Contains(rule, "80 per coin") || strings.Contains(rule, "by rank") { + t.Fatalf("incorrect Deluxe rule: %s", rule) + } +} diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 90c95c9..6fa6c79 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -249,6 +249,9 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { } requested := amount amount = roundUpTo(amount, payoutDen) + if amount > t.Config.MaxBet { + return reject(fmt.Sprintf("That wager rounds up to %d, above the table maximum.", amount)) + } // Compare without multiplying a potentially very large stake by Nx. if (amount-1)/limit >= b.stake.Total { return reject("Odds already placed or above the free-odds limit.") @@ -557,7 +560,6 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event { if b.odds.Total > 0 { total += b.odds.Total ids = append(ids, b.odds.ID) - b.odds = Wager{} } live = append(live, b) default: @@ -571,12 +573,17 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event { if total == 0 { return []Event{t.private(p.Name, "Nothing removable on the table.")} } + // Keep the live wagers intact until the atomic refund succeeds. On a + // failed save they still belong to the next roll, or a later unbet retry. + if err := p.Wallet.PayCredits(total, ids...); err != nil { + return []Event{t.private(p.Name, "The house could not take down your bets. All wagers remain live; try unbet again.")} + } + for _, b := range live { + b.odds = Wager{} + } p.bets = live p.HasBet = len(live) > 0 e := t.private(p.Name, fmt.Sprintf("You take down {casino_chips}%d{/} credits of optional bets; contracts stay up.", total)) - if err := p.Wallet.PayCredits(total, ids...); err != nil { - e.Message = payoutFailedMessage() - } events := []Event{e, t.betSummary(p)} events = append(events, t.syncRoundClock()...) if t.players.has(p.Name) { diff --git a/internal/casino/registry.go b/internal/casino/registry.go index dbf3b31..a27a9dd 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -97,7 +97,7 @@ func init() { }, normalize: func(c TableConfig) TableConfig { if c.Roulette.SpinTicks == 0 { - c.Roulette.SpinTicks = 3 + c.Roulette.SpinTicks = 8 } return c }, diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index fa87800..783d22f 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -474,6 +474,7 @@ type rouletteTable struct { tableCore[*roulettePlayer] rng *rand.Rand spinRemaining int + winningIndex int // selected once at spin start, shared by final frame and settlement lastResult *RouletteSnapshot history []string } @@ -601,11 +602,7 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true) events = append(events, e) } else { - t.phase = phasePlaying - t.spinRemaining = t.Config.Roulette.SpinTicks - e := t.event("No more bets! The roulette wheel spins...", true) - e.Snapshot = t.view() - events = append(events, e) + events = append(events, t.startSpin()) } } return events @@ -618,6 +615,14 @@ func (t *rouletteTable) tick() []Event { if t.spinRemaining <= 0 { return t.settle() } + frame := t.Config.Roulette.SpinTicks - t.spinRemaining + if frame <= 5 { + // Cosmetic randomness must not consume the outcome RNG. + s := t.spinFrame(frame, rand.Intn(3) == 0, rand.Intn(10), 1+rand.Intn(36)) + e := t.event("", true) + e.Snapshot = s + return []Event{e} + } return nil } if t.phase != phaseBetting { @@ -636,15 +641,64 @@ func (t *rouletteTable) tick() []Event { t.resetRound() return nil } + return []Event{t.startSpin()} +} + +func (t *rouletteTable) startSpin() Event { t.phase = phasePlaying t.spinRemaining = t.Config.Roulette.SpinTicks - e := t.event("No more bets! The roulette wheel spins...", true) - e.Snapshot = t.view() - return []Event{e} + t.winningIndex = t.rng.Intn(len(rouletteWheel(t.Config.Roulette.DoubleZero))) + return t.event("No more bets! The roulette wheel spins...", true) } + +// spinFrame keeps cosmetic motion separate from the committed outcome. The +// final strip uses physical wheel neighbors, including American same-color +// neighbors around zero pockets, rather than inventing alternating colors. +func (t *rouletteTable) spinFrame(frame int, showBall bool, ball, green int) *RouletteSnapshot { + s := t.view() + s.SpinFrame, s.BallPosition = frame, -1 + if frame == 5 { + s.BallPosition = 4 + wheel := rouletteWheel(t.Config.Roulette.DoubleZero) + // Frame four starts black. Slide the final window to start red, + // keeping the ball and both adjacent pockets inside the strip. + for offset := 0; offset < 8; offset++ { + position := 1 + (3+offset)%8 + start := wheel[(t.winningIndex-position+len(wheel))%len(wheel)] + n, _ := strconv.Atoi(start) + if rouletteRed(n) { + s.BallPosition = position + break + } + } + for i := range s.SpinPockets { + s.SpinPockets[i] = wheel[(t.winningIndex+i-s.BallPosition+len(wheel))%len(wheel)] + } + return s + } + if showBall { + s.BallPosition = ball + } + parity := frame + for i := range s.SpinPockets { + // Insert green between alternating neighbors, never replace one of + // them (which would leave equal colors on either side). + if i == green && i > 0 && i < len(s.SpinPockets)-1 { + s.SpinPockets[i] = "0" + continue + } + s.SpinPockets[i] = "1" // red + if parity%2 == 0 { + s.SpinPockets[i] = "2" // black + } + parity++ + } + return s +} + func (t *rouletteTable) settle() []Event { wheel := rouletteWheel(t.Config.Roulette.DoubleZero) - index := t.rng.Intn(len(wheel)) + index := t.winningIndex pocket := wheel[index] t.history = append(t.history, pocket) if len(t.history) > rouletteHistoryLimit { @@ -786,7 +840,10 @@ func (t *rouletteTable) refundAll() []Event { // RouletteSnapshot describes the view without coupling the engine to terminal // colors, Unicode preferences, or card-art settings. type RouletteSnapshot struct { - DoubleZero bool + DoubleZero bool + SpinFrame int // 1–5 for animation events; zero for ordinary snapshots + BallPosition int // -1 when the ball is not visible + SpinPockets [10]string // cosmetic pockets, or actual wheel order on the final frame // Racetrack reports that the table also offers the French call bets and // neighbours bets, so the renderer draws the racetrack panel. Racetrack bool diff --git a/internal/casino/roulette_animation_test.go b/internal/casino/roulette_animation_test.go new file mode 100644 index 0000000..33d0e32 --- /dev/null +++ b/internal/casino/roulette_animation_test.go @@ -0,0 +1,103 @@ +package casino + +import ( + "strconv" + "testing" +) + +func TestRouletteAnimationTiming(t *testing.T) { + for _, multiplayer := range []bool{false, true} { + table := rouletteFixture() + table.Config.Roulette.SpinTicks = 8 + table.join("a") + if multiplayer { + table.join("b") + } + events := table.bet("a", "red", 10, &testWallet{chips: 100}) + if multiplayer { + table.tick() + events = table.tick() + } + for _, e := range events { + if e.Snapshot != nil { + t.Fatal("spin announcement contains old wheel") + } + } + var final *RouletteSnapshot + for tick := 1; tick <= 8; tick++ { + events = table.tick() + switch { + case tick <= 5: + if len(events) != 1 { + t.Fatalf("tick %d: missing frame", tick) + } + s := events[0].Snapshot.(*RouletteSnapshot) + if s.SpinFrame != tick || s.BallPosition < -1 || s.BallPosition >= 10 { + t.Fatalf("bad frame: %+v", s) + } + final = s + case tick < 8: + if len(events) != 0 { + t.Fatalf("tick %d: pause interrupted", tick) + } + default: + if len(events) == 0 || events[0].Snapshot.(*RouletteSnapshot).Phase != "result" { + t.Fatal("missing result at tick 8") + } + result := events[0].Snapshot.(*RouletteSnapshot) + if final.BallPosition < 0 || final.SpinPockets[final.BallPosition] != result.Pockets[2] { + t.Fatal("final ball disagrees with result") + } + } + } + } +} + +func TestRouletteSpinFramePockets(t *testing.T) { + table := rouletteFixture() + for frame := 1; frame <= 4; frame++ { + for green := 1; green <= 36; green++ { + for _, showBall := range []bool{false, true} { + s := table.spinFrame(frame, showBall, 3, green) + if (s.BallPosition == 3) != showBall || (!showBall && s.BallPosition != -1) { + t.Fatal("ball visibility ignored") + } + want := "1" + if frame%2 == 0 { + want = "2" + } + if s.SpinPockets[0] != want { + t.Fatal("starting color did not alternate") + } + for i, pocket := range s.SpinPockets { + if pocket == "0" && (i == 0 || i == 9 || s.SpinPockets[i-1] == s.SpinPockets[i+1]) { + t.Fatalf("green not between red and black: %v", s.SpinPockets) + } + } + } + } + } + for _, double := range []bool{false, true} { + table.Config.Roulette.DoubleZero = double + wheel := rouletteWheel(double) + for index, winner := range wheel { + table.winningIndex = index + s := table.spinFrame(5, false, 0, 0) + start, _ := strconv.Atoi(s.SpinPockets[0]) + if !rouletteRed(start) { + t.Fatal("final strip must start red after the black-starting fourth strip") + } + if s.BallPosition < 1 || s.BallPosition >= 9 || s.SpinPockets[s.BallPosition] != winner { + t.Fatalf("missing winning ball for %s", winner) + } + for i, pocket := range s.SpinPockets { + if pocket != wheel[(index+i-s.BallPosition+len(wheel))%len(wheel)] { + t.Fatalf("wrong physical neighbors for %s", winner) + } + } + if !double && winner == "26" && s.SpinPockets[s.BallPosition+1] != "0" { + t.Fatal("26 must have green to its right") + } + } + } +} diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index 7e79b58..d2578b8 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -63,6 +63,15 @@ type pokerPlayer struct { result, bankKey string } +// clearResult clears the previous hand's presentation when preparing a new +// wager. Loaded coins and the cabinet's earned multiplier banks are unchanged. +func (p *pokerPlayer) clearResult() { + p.lines, p.remaining, p.current, p.next = nil, nil, nil, nil + p.result, p.payout, p.drawn = "", 0, false + p.held = [5]bool{} + p.multiplier, p.dice = 1, [2]int{} +} + // cost is the full currency price of dealing the loaded wager. func (p *pokerPlayer) cost() int { coins := p.loaded @@ -378,8 +387,7 @@ func (t *videoPokerTable) action(name, action string) []Event { return []Event{t.private(name, "Nothing to take back.")} } p.loaded = 0 - p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil - p.multiplier, p.dice = 1, [2]int{} + p.clearResult() return t.statusLocked(name, true) case "coins": if len(fields) > 2 { @@ -403,8 +411,7 @@ func (t *videoPokerTable) action(name, action string) []Event { n = min(5, p.loaded+add) } p.loaded, p.lastBet = n, n - p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil - p.multiplier, p.dice = 1, [2]int{} + p.clearResult() return t.statusLocked(name, len(fields) > 1) case "denom", "denomination": if len(fields) != 2 { @@ -415,7 +422,7 @@ func (t *videoPokerTable) action(name, action string) []Event { break } p.denomination = n - p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + p.clearResult() return t.statusLocked(name, true) case "hands": if len(fields) != 2 { @@ -428,7 +435,7 @@ func (t *videoPokerTable) action(name, action string) []Event { for _, allowed := range CabinetHandCounts(t.settings.Cabinet, p.variant) { if n == allowed { p.hands = n - p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + p.clearResult() return t.statusLocked(name, true) } } @@ -445,13 +452,14 @@ func (t *videoPokerTable) action(name, action string) []Event { return []Event{t.private(name, "That game is not available on this cabinet. Games: "+t.settings.GameChoices())} } p.config, p.variant, p.hands = cfg, variant, CabinetHandCounts(cfg.Cabinet, variant)[0] - p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + p.clearResult() return t.statusLocked(name, true) case "feature": if len(fields) != 2 || cabinetFeature(t.settings.Cabinet) == "" || fields[1] != "on" && fields[1] != "off" { break } p.featureOn = fields[1] == "on" + p.clearResult() return t.statusLocked(name, false) } return []Event{t.private(name, "Use bet [1-4|max], unbet, deal, game , denom 1|10|50, hands , paytable, or feature on|off on a bonus cabinet.")} diff --git a/internal/casino/video_poker_rules.go b/internal/casino/video_poker_rules.go index 29ab5c3..55cdf8e 100644 --- a/internal/casino/video_poker_rules.go +++ b/internal/casino/video_poker_rules.go @@ -149,7 +149,9 @@ func (c VideoPokerConfig) PairRule() string { return "Every 2 is wild. Three of a kind is the lowest win; only the highest category pays." case "joker_poker": return "One joker is wild. A pair of kings or aces is the lowest win; a wild royal is not a natural royal." - case "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus": + case "bonus_poker_deluxe": + return "A pair of jacks or better is the lowest win; every four of a kind pays 80 per coin." + case "bonus_poker", "double_bonus", "double_double_bonus": return "A pair of jacks or better is the lowest win; four of a kind carries bonus rates by rank." } return "A pair of jacks or better is the lowest win." diff --git a/internal/game/casino.go b/internal/game/casino.go index 5e09d48..0b16114 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -539,6 +539,20 @@ func (g *Game) executeTableAction(sess *net.Session, action string) { g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action)) } +// Keep the automatic post-command prompt out of a seated player's spin +// animation. Settlement menus resume prompting once the table leaves playing. +func (g *Game) rouletteSpinning(sess *net.Session) bool { + if g.Casino == nil || sess == nil || sess.Player == nil { + return false + } + table, playing := g.Casino.Participation(sess.Player.Name) + if !playing || table == nil || table.Config.Game != casino.GameRoulette { + return false + } + s, ok := table.Board(sess.Player.Name).(*casino.RouletteSnapshot) + return ok && s.Phase == "playing" +} + func (g *Game) CasinoTick() { if g.Casino != nil { g.emitCasinoEvents(g.Casino.Tick()) @@ -569,7 +583,10 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { message := g.casinoProse(sess, category, g.casinoTimeText(event.Message)) if details { if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { - message += "\n" + rendered + if message != "" { + message += "\n" + } + message += rendered } } if message != "" { diff --git a/internal/game/game.go b/internal/game/game.go index 3357077..e5192c0 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -573,11 +573,15 @@ func playerDataFileName(relPath string) (name, kind string, ok bool) { } func (g *Game) writePrompt(sess *net.Session) { - sess.WritePrompt(g.promptStr(sess)) + if !g.rouletteSpinning(sess) { + sess.WritePrompt(g.promptStr(sess)) + } } func (g *Game) reprompt(sess *net.Session) { - sess.Reprompt(g.promptStr(sess)) + if !g.rouletteSpinning(sess) { + sess.Reprompt(g.promptStr(sess)) + } } func (g *Game) handleGameCommand(sess *net.Session, input string) { diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 218a91f..a0f1e04 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -16,8 +16,10 @@ import ( func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string { switch s.Phase { case "playing": - // A blank line on either side keeps the wheel from crowding the prose. - return "\n" + renderRouletteWheel(s.Wheel, mode, unicode) + "\n" + if s.SpinFrame == 0 { + return "" + } + return renderRouletteSpin(s, mode, unicode) case "result": return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n" default: @@ -27,6 +29,27 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo } } +// A scrolling strip works in telnet scrollback without cursor-control escapes. +func renderRouletteSpin(s *casino.RouletteSnapshot, mode string, unicode bool) string { + var line strings.Builder + for i, pocket := range s.SpinPockets { + fg, _ := roulettePocketColor(pocket) + if fg == 0 { + fg = 8 // visible charcoal rather than invisible black on a dark terminal + } + text := "█" + if !unicode { + text = "#" + } + if i == s.BallPosition { + line.WriteString(roulettePaint(mode, ".", fg)) + } else { + line.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1}, text)) + } + } + return line.String() +} + func rouletteInk(mode, text string) string { return color.Render(mode, color.ColorSpec{Fg: 15, Bg: -1}, text) } diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index 90cee0c..e4fe650 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -25,7 +25,7 @@ func TestRouletteArtModes(t *testing.T) { t.Run(fmt.Sprintf("%s/unicode=%v/double=%v", mode, unicode, double), func(t *testing.T) { wheelOrder := casino.WheelOrder(double) s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder} - for _, phase := range []string{"betting", "playing", "result"} { + for _, phase := range []string{"betting", "result"} { s.Phase = phase s.Pockets = [5]string{"25", "26", "0", "32", "15"} art := renderRouletteSnapshot(s, mode, unicode, 7) @@ -377,7 +377,7 @@ func TestRouletteEventColorsDoNotWrapArt(t *testing.T) { // Use an emphatic blue background to detect whole-message color wrapping. sess.Account = &player.Account{Colors: map[string]string{"casino_action": "0F bg:04 bold"}} g.Casino.Join(1, casino.TableConfig{ID: "r", Game: casino.GameRoulette}, p.Name) - e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", Players: []string{p.Name}}} + e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 1, Players: []string{p.Name}}} if !public { e.PrivateTo = p.Name } @@ -575,7 +575,6 @@ func TestRouletteWheelBlackAndSpacing(t *testing.T) { phase string want string }{ - {"playing", renderRouletteWheel(casino.WheelOrder(false), "none", true)}, {"result", renderRouletteResult([5]string{"25", "26", "0", "32", "15"}, "none", true, 7)}, } { s := &casino.RouletteSnapshot{Wheel: casino.WheelOrder(false), Phase: tc.phase, Pockets: [5]string{"25", "26", "0", "32", "15"}} diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go index 719b36a..045bdc7 100644 --- a/internal/game/render_video_poker.go +++ b/internal/game/render_video_poker.go @@ -161,8 +161,16 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s if i == 0 && len(art) > 0 && decorated { art[0] += " " + badge + bonus } - if len(s.Lines) > 1 && len(art) > 0 { - art[0] = fmt.Sprintf("%3d: ", i+1) + art[0] + indent := "" + if len(s.Lines) > 1 { + indent = " " + for row := range art { + prefix := indent + if row == 0 { + prefix = fmt.Sprintf("%3d: ", i+1) + } + art[row] = prefix + art[row] + } } lines = append(lines, art...) text := "No win" @@ -178,7 +186,7 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s text += fmt.Sprintf(" | earns %dx", s.Next[i]) } // Results always follow the cards, including compact hundred-play. - lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text)) + lines = append(lines, indent+color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text)) } if s.Result != "" { fg := pokerYellow diff --git a/internal/game/roulette_animation_test.go b/internal/game/roulette_animation_test.go new file mode 100644 index 0000000..96f14bb --- /dev/null +++ b/internal/game/roulette_animation_test.go @@ -0,0 +1,102 @@ +package game + +import ( + "strings" + "testing" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" +) + +func TestRouletteSpinOutputHasNoPromptOrBlankLines(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.Hub = net.NewHub() + g.Hub.EnterRoom(sess, 1) + sess.Player.Options = map[string]any{"color": "none", "unicode": true} + g.emitCasinoEvents(g.Casino.Join(1, casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}, sess.Player.Name)) + sess.ClearPrompt() + conn.data = nil + g.executeBet(sess, []string{"2", "on", "2"}, "bet 2 on 2") + g.writePrompt(sess) // the command queue's post-command prompt + text := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if !strings.HasSuffix(text, "The roulette wheel spins...\n") { + t.Fatalf("prompt after spin announcement: %q", text) + } + before := len(conn.data) + g.reprompt(sess) + if len(conn.data) != before { + t.Fatal("reprompt interrupted animation") + } + for tick := 1; tick <= 5; tick++ { + conn.data = nil + g.CasinoTick() + text = strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if strings.Count(text, "\n") != 1 || !strings.HasSuffix(text, "\n") || color.VisibleLen(strings.TrimSuffix(text, "\n")) != 10 { + t.Fatalf("tick %d: expected exactly one strip: %q", tick, text) + } + if tick == 5 && strings.Count(text, ".") != 1 { + t.Fatal("final strip missing ball") + } + } + for tick := 6; tick <= 7; tick++ { + conn.data = nil + g.CasinoTick() + if len(conn.data) != 0 { + t.Fatalf("tick %d: expected silence: %q", tick, conn.data) + } + } + g.CasinoTick() + if !strings.Contains(string(conn.data), "The ball lands in") || g.rouletteSpinning(sess) { + t.Fatal("result did not end animation") + } + sess.ClearPrompt() + conn.data = nil + g.writePrompt(sess) + if len(conn.data) == 0 { + t.Fatal("prompt did not resume") + } +} + +func TestRouletteSpinStrip(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + for _, ball := range []int{-1, 5} { + s := &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 1, BallPosition: ball, SpinPockets: [10]string{"1", "2", "1", "2", "1", "26", "0", "32", "15", "19"}} + art := renderRouletteSnapshot(s, mode, unicode, 7) + plain := plainRoulette(art) + dots := 0 + if ball >= 0 { + dots = 1 + } + if color.VisibleLen(art) != 10 || strings.Count(plain, ".") != dots || strings.Contains(plain, "\n") { + t.Fatalf("bad strip: %q", art) + } + block := "█" + if !unicode { + block = "#" + } + if strings.Count(plain, block) != 10-dots { + t.Fatalf("bad blocks: %q", plain) + } + if mode != "none" && ball >= 0 && !strings.Contains(art, roulettePaint(mode, ".", 8)) { + t.Fatalf("26 ball is not black: %q", art) + } + } + for _, pocket := range []string{"0", "00", "26", "32"} { + s := &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 5, BallPosition: 4} + s.SpinPockets[4] = pocket + art := renderRouletteSnapshot(s, mode, unicode, 7) + fg, _ := roulettePocketColor(pocket) + if fg == 0 { + fg = 8 + } + if !strings.Contains(art, roulettePaint(mode, ".", fg)) { + t.Fatalf("wrong ball color for %s: %q", pocket, art) + } + } + } + } + if got := renderRouletteSnapshot(&casino.RouletteSnapshot{Phase: "playing"}, "none", true, 7); got != "" { + t.Fatalf("old wheel still shown: %q", got) + } +} diff --git a/internal/game/video_poker_alignment_test.go b/internal/game/video_poker_alignment_test.go new file mode 100644 index 0000000..0163fac --- /dev/null +++ b/internal/game/video_poker_alignment_test.go @@ -0,0 +1,35 @@ +package game + +import ( + "fmt" + "strings" + "testing" + + "thehouseoficarus/internal/casino" +) + +func TestPokerMultiHandCardRowsAlignWithLabels(t *testing.T) { + cards := []casino.CardView{{Rank: "A", Suit: "hearts"}, {Rank: "K", Suit: "spades"}, {Rank: "Q", Suit: "diamonds"}, {Rank: "J", Suit: "clubs"}, {Rank: "10", Suit: "hearts"}} + for _, size := range []int{1, 3, 5} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + hand := casino.PokerLine{Cards: cards, Category: "Straight", Coins: 20, Multiplier: 1} + snap := &casino.VideoPokerSnapshot{Config: (casino.VideoPokerConfig{Cabinet: casino.CabinetClassic}).Normalize(), Hands: 3, Lines: []casino.PokerLine{hand, hand, hand}} + out := renderVideoPoker(snap, "none", true, size, "", nil) + art := renderPokerCards(cards, nil, "none", true, min(5, cardRenderSize(true, size)), "", nil) + for n := 1; n <= 3; n++ { + rows := append([]string(nil), art...) + for i := range rows { + prefix := " " + if i == 0 { + prefix = fmt.Sprintf("%3d: ", n) + } + rows[i] = prefix + rows[i] + } + want := strings.Join(rows, "\n") + "\n Straight" + if !strings.Contains(out, want) { + t.Fatalf("hand %d is misaligned:\n%s", n, out) + } + } + }) + } +} diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go index 88c8474..1f76d60 100644 --- a/internal/game/video_poker_render_test.go +++ b/internal/game/video_poker_render_test.go @@ -100,7 +100,7 @@ func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) { } result := strings.Index(plain, text) // The result starts its own line and follows the card/badge line. - if result < 0 || result == 0 || plain[result-1] != '\n' { + if result <= 0 || strings.TrimSpace(plain[strings.LastIndex(plain[:result], "\n")+1:result]) != "" { t.Fatalf("result not below cards: %s", plain) } if hands == 100 && !strings.Contains(plain, "♥") { -- cgit v1.2.3