diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/baccarat.go | 21 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 75 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/machine.go | 2 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 13 | ||||
| -rw-r--r-- | internal/casino/table.go | 2 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 127 | ||||
| -rw-r--r-- | internal/casino/table_ux_test.go | 12 | ||||
| -rw-r--r-- | internal/config/config.go | 8 | ||||
| -rw-r--r-- | internal/game/casino.go | 84 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 42 | ||||
| -rw-r--r-- | internal/game/casino_ux_test.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 53 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 49 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker_test.go | 5 |
17 files changed, 416 insertions, 89 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index ad56da4..56677b2 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -186,7 +186,11 @@ func (t *baccaratTable) deal() []Event { // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) publicResult, _ := baccaratResultMessages(p.Name, result, payout) - privateResult := resultText(result, p.Wager.Total, payout) + line := settlementLine{Label: p.lastSpot, Result: result, Stake: p.Wager.Total, Returned: payout} + if result == "win" && p.lastSpot == baccaratSpotTie { + line.Tag = "casino_jackpot_win" + } + privateResult := settlementText([]settlementLine{line}) if payoutFailed { privateResult = payoutFailedMessage() publicResult = p.Name + "’s settlement is pending recovery." @@ -195,11 +199,16 @@ func (t *baccaratTable) deal() []Event { // Returning players get their balance in the next bet menu; only // departures/sit-outs need it attached to the settlement. settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) - if result == "win" { - if p.lastSpot == baccaratSpotTie { - settlement.Color = "casino_jackpot_win" - } else { - settlement.Color = "casino_big_win" + if !payoutFailed { + switch { + case line.Tag != "": + settlement.Color = line.Tag + case result == "win": + settlement.Color = "casino_result_win" + case result == "push": + settlement.Color = "casino_result_push" + default: + settlement.Color = "casino_result_loss" } } events = append(events, settlement) diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index f947f5b..dce1629 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -173,7 +173,7 @@ func TestBaccaratSettlementShowsBalanceOnlyWithoutNextMenu(t *testing.T) { events := table.bet("alice", baccaratSpotPlayer, 10, &testWallet{credits: 100}) found := false for _, e := range events { - if e.PrivateTo == "alice" && strings.Contains(e.PrivateMessage, "WIN | Net") { + if e.PrivateTo == "alice" && strings.Contains(e.PrivateMessage, "{casino_result_win}WIN{/}") { found = true if e.ShowBalance != leaving { t.Fatalf("leaving=%v settlement ShowBalance=%v: %+v", leaving, e.ShowBalance, e) @@ -379,7 +379,7 @@ func TestBaccaratMenuAndActions(t *testing.T) { menu = event.Menu } } - if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Next hand | Limits") { + if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Limits") { t.Fatalf("join menu=%+v, want a baccarat betting menu", menu) } if len(menu.Commands) != 4 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 0a5c0d1..5fb8b0b 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -668,28 +668,22 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } - roundNet, roundFailed := int64(0), false + needsBalance := p.LeaveAfter || p.SitOutAfter || !p.Connected + lines := make([]settlementLine, 0, len(p.hands)+1) for i, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before // announcing, and leave the stakes pending on any failure so a // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { - roundFailed = true - settlement := t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage()) - colorizeBlackjackSettlement(&settlement, result) - events = append(events, settlement) + events = append(events, t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage())) continue } - publicResult, _ := blackjackResultMessages(p.Name, result, payout) - roundNet += int64(payout) - int64(hand.bet) - privateResult := resultText(result, hand.bet, payout) + label := "" if len(p.hands) > 1 { - privateResult = fmt.Sprintf("Hand %d/%d | %s", i+1, len(p.hands), privateResult) + label = fmt.Sprintf("Hand %d/%d", i+1, len(p.hands)) } - settlement := t.playerAction(p.Name, publicResult, privateResult) - colorizeBlackjackSettlement(&settlement, result) - events = append(events, settlement) + lines = append(lines, settlementLine{Label: label, Result: result, Stake: hand.bet, Returned: payout}) } if p.insurance > 0 { insurancePayout := 0 @@ -698,27 +692,19 @@ func (t *blackjackTable) settle() []Event { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { - roundFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) } else { - roundNet += int64(insurancePayout) - int64(p.insurance) - events = append(events, t.private(p.Name, resultText("insurance", p.insurance, insurancePayout))) + lines = append(lines, settlementLine{Label: "Insurance", Result: "insurance", Stake: p.insurance, Returned: insurancePayout}) } } - if !roundFailed { - // A single settled hand with no insurance already reported its net - // in the per-hand result line, so a summary is only noise. Keep it - // for split hands and insurance, and whenever the player will not - // receive the next-round menu that carries their balance. - needsBalance := p.LeaveAfter || p.SitOutAfter || !p.Connected - if len(p.hands) > 1 || p.insurance > 0 || needsBalance { - e := t.private(p.Name, fmt.Sprintf("Round complete | Net %+d credits (insurance included)", roundNet)) - e.ShowBalance = needsBalance - if roundNet > 0 { - e.Color = "casino_big_win" - } - events = append(events, e) - } + // One settlement per player reports the round's net once and then + // names every hand (and insurance) that produced it. A single hand + // keeps the headline only; splits and insurance get the itemized list. + if len(lines) > 0 { + settlement := t.playerAction(p.Name, blackjackPublicResult(p.Name, lines), settlementText(lines)) + settlement.ShowBalance = needsBalance + settlement.Color = settlementTag(lines) + events = append(events, settlement) } // Each hand's stakes were already consumed per-handle by // settleStakeOutcome (a payout removes them in PayCredits; a loss @@ -780,13 +766,19 @@ func (t *blackjackTable) resetRound() { } } -// colorizeBlackjackSettlement paints a winning settlement with the big-win -// (or jackpot, for a natural) color. -func colorizeBlackjackSettlement(e *Event, result string) { - if result == "blackjack" { - e.Color = "casino_jackpot_win" - } else if result == "win" { - e.Color = "casino_big_win" +// blackjackPublicResult summarizes a player's round for the room: the net is +// not named, only the outcome. +func blackjackPublicResult(name string, lines []settlementLine) string { + if len(lines) == 1 && lines[0].Result == "blackjack" { + return name + " gets blackjack!" + } + switch { + case settlementNet(lines) > 0: + return name + " wins!" + case settlementNet(lines) < 0: + return name + " loses." + default: + return name + " pushes." } } @@ -861,17 +853,6 @@ func handScore(hand *blackjackHand) string { return fmt.Sprintf("Soft %d", total) } -func blackjackResultMessages(name, result string, payout int) (string, string) { - switch result { - case "win", "blackjack": - return name + " wins!", fmt.Sprintf("You win! +%d credits", payout) - case "push": - return name + " pushes.", fmt.Sprintf("You push. +%d credits", payout) - default: - return name + " loses.", "You lose!" - } -} - // markDisconnected removes the player from the table. A wager is forfeited // only when a hand is actually in flight (cards are face up, so a free // refund would hand back information the round carries); a stake placed in diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 08069ef..aff87c8 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -175,7 +175,7 @@ func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { if w.paid != 15 { t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid) } - if !hasMessage(events, "INSURANCE | Net +10 credits | 15 returned") { + if !hasMessage(events, "Insurance") || !hasMessage(events, "{casino_result_win}+10 credits{/}") { t.Fatalf("missing insurance payout message: %+v", events) } if table.phase != blackjackWaiting { @@ -285,7 +285,7 @@ func TestBlackjackDisconnectActivePassesPlay(t *testing.T) { } // Bob stands; round settles. settle := table.action("bob", "stand") - if !hasMessage(settle, "LOSS | Net -10 credits | 0 returned") { + if !hasMessage(settle, "{casino_result_loss}LOSS{/}") || !hasMessage(settle, "{casino_payout}-10 credits{/}") { t.Fatalf("bob 16 vs dealer 17: expected loss, got %+v", settle) } if wb.paid != 0 { diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 7821975..f8730be 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -303,7 +303,7 @@ func (m *Machine) finishSpin() []Event { message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow) } winEvents := m.private(message) - winEvents[0].Color = "casino_payout" + winEvents[0].Color = "casino_result_win" if payNow > m.spinBet { winEvents[0].Color = "casino_big_win" } diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 44c5f30..b4dcdf0 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -495,7 +495,6 @@ func (t *rouletteTable) menuEvent(name string) Event { {Command: "bet / rebet", Desc: "Repeat your previous wager"}, {Command: "table / sitout / stop", Desc: "View layout / sit out / leave after this spin"}, }) - e.Menu.Heading = strings.Replace(e.Menu.Heading, "Next hand", "Next spin", 1) e.Snapshot = t.view() return e } @@ -622,7 +621,7 @@ func (t *rouletteTable) settle() []Event { wheel[(index+1)%n], wheel[(index+2)%n], } t.lastResult = s - e := t.event("The ball lands in "+pocket+"!", true) + e := t.event("The ball lands in {casino_emphasis}"+pocket+"{/}!", true) e.Snapshot = s events := []Event{e} for _, p := range t.players.ordered() { @@ -645,7 +644,7 @@ func (t *rouletteTable) settle() []Event { if !failed { failed = p.Wallet.PayCredits(payout, p.Wager.ID) != nil } - text := resultText(result, p.Wager.Total, payout) + text := resultTextLabel(p.lastSpot, result, p.Wager.Total, payout) public := p.Name + " " + result + "." if failed { text = payoutFailedMessage() @@ -655,8 +654,12 @@ func (t *rouletteTable) settle() []Event { // Returning players get their balance in the next-spin menu. Only // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) - if result == "win" && !failed { - event.Color = "casino_big_win" + if !failed { + if result == "win" { + event.Color = "casino_result_win" + } else { + event.Color = "casino_result_loss" + } } events = append(events, event) } diff --git a/internal/casino/table.go b/internal/casino/table.go index 17732e0..ddf357f 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -54,7 +54,7 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve // betMenuEvent builds the shared "place your bet" menu heading for a table // game; only the command list differs per game. func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event { - heading := fmt.Sprintf("%s | Next hand | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet)) + heading := fmt.Sprintf("%s | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet)) e := b.private(name, heading) e.Color = "casino_menu" e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands} diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 002d9f0..b4a13ab 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -201,13 +201,126 @@ func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event { return e } -func resultText(result string, stake, returned int) string { - label := strings.ToUpper(result) - switch result { +// settlementLine is one settled bet within a round. Label names the bet for +// the itemized line (empty for a lone anonymous hand); Result is the engine's +// settlement word ("win", "loss", "bust", "push", "blackjack", +// "surrender", "insurance"). +type settlementLine struct { + Label string + Result string + Stake int + Returned int + Tag string // optional color-category override (jackpot, for example) +} + +func (l settlementLine) net() int { return l.Returned - l.Stake } + +// resultTextLabel renders a settled wager as a colored outcome plus its net, +// naming the bet on its own line. +func resultTextLabel(label, result string, stake, returned int) string { + return settlementText([]settlementLine{{Label: label, Result: result, Stake: stake, Returned: returned}}) +} + +// settlementText renders a settlement as a colored headline followed by one +// line per named bet. The net is the only figure shown: the old +// "Net ... | ... returned (stake included)" split is gone. Wins are green, +// losses red and pushes white, with the headline taking the round's overall +// result and each line its own. +func settlementText(lines []settlementLine) string { + net := 0 + for _, line := range lines { + net += line.net() + } + var sb strings.Builder + fmt.Fprintf(&sb, "{%s}%s{/} {casino_payout}%s{/}", settlementTag(lines), settlementLabel(lines), creditsText(net)) + for _, line := range lines { + if line.Label == "" { + continue + } + fmt.Fprintf(&sb, "\n {casino_action}%s:{/} {%s}%s{/}", line.Label, lineTag(line), creditsText(line.net())) + } + return sb.String() +} + +// creditsText formats a signed credit total with the right plural. +func creditsText(n int) string { + word := "credits" + if n == 1 || n == -1 { + word = "credit" + } + return fmt.Sprintf("%+d %s", n, word) +} + +// settlementLabel returns the headline word for a round. +func settlementLabel(lines []settlementLine) string { + if len(lines) == 1 { + switch lines[0].Result { + case "blackjack": + return "BLACKJACK" + case "surrender": + return "SURRENDER" + } + } + switch { + case settlementNet(lines) > 0: + return "WIN" + case settlementNet(lines) < 0: + return "LOSS" + default: + return "PUSH" + } +} + +// settlementTag returns the headline's color tag. A lone natural keeps the +// jackpot gradient; otherwise the net decides green, red or white. +func settlementTag(lines []settlementLine) string { + if len(lines) == 1 && lines[0].Tag != "" { + return lines[0].Tag + } + if len(lines) == 1 && lines[0].Result == "blackjack" { + return "casino_jackpot_win" + } + switch { + case settlementNet(lines) > 0: + return "casino_result_win" + case settlementNet(lines) < 0: + return "casino_result_loss" + default: + return "casino_result_push" + } +} + +// lineTag colors one bet's outcome. Insurance is judged by its net because the +// same result word covers a paid and a lost premium. +func lineTag(line settlementLine) string { + if line.Tag != "" { + return line.Tag + } + switch line.Result { case "blackjack": - label = "BLACKJACK" - case "bust", "loss", "dealer blackjack": - label = "LOSS" + return "casino_jackpot_win" + case "push": + return "casino_result_push" + case "win": + return "casino_result_win" + case "insurance": + switch { + case line.net() > 0: + return "casino_result_win" + case line.net() < 0: + return "casino_result_loss" + default: + return "casino_result_push" + } + default: + return "casino_result_loss" + } +} + +func settlementNet(lines []settlementLine) int { + net := 0 + for _, line := range lines { + net += line.net() } - return fmt.Sprintf("%s | Net %+d credits | %d returned (stake included)", label, returned-stake, returned) + return net } diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go index 812bed0..cb13300 100644 --- a/internal/casino/table_ux_test.go +++ b/internal/casino/table_ux_test.go @@ -136,7 +136,7 @@ func TestQueuedLeaveAndSitoutPreserveCommittedBlackjackHands(t *testing.T) { t.Fatalf("%s altered committed hand", action) } events := table.action("alice", "stand") - if !hasMessage(events, "Net -10 credits") { + if !hasMessage(events, "{casino_payout}-10 credits{/}") { t.Fatalf("missing settlement: %+v", events) } if action == "leave_after" && table.hasParticipant("alice") { @@ -154,7 +154,13 @@ func TestSplitAndInsuranceRoundNetIncludesEveryStake(t *testing.T) { p.hands = []*blackjackHand{{bet: 20, stood: true, cards: []card{{rank: "10"}, {rank: "10"}}}, {bet: 10, bust: true, cards: []card{{rank: "10"}, {rank: "10"}, {rank: "5"}}}} p.insurance = 5 events := table.settle() - for _, text := range []string{"Hand 1/2 | WIN | Net +20 credits | 40 returned", "Hand 2/2 | LOSS | Net -10 credits", "INSURANCE | Net -5 credits", "Round complete | Net +5 credits"} { + for _, text := range []string{ + "{casino_result_win}WIN{/}", + "{casino_payout}+5 credits{/}", + "Hand 1/2:{/} {casino_result_win}+20 credits{/}", + "Hand 2/2:{/} {casino_result_loss}-10 credits{/}", + "Insurance:{/} {casino_result_loss}-5 credits{/}", + } { if !hasMessage(events, text) { t.Fatalf("missing %q in %+v", text, events) } @@ -184,7 +190,7 @@ func TestDepartingSettlementCarriesBalanceSummary(t *testing.T) { events := table.settle() found := false for _, e := range events { - if strings.Contains(e.Message, "Round complete") && e.ShowBalance { + if strings.Contains(e.PrivateMessage, "LOSS") && e.ShowBalance { found = true } } diff --git a/internal/config/config.go b/internal/config/config.go index d765c5b..ff081c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,10 +78,16 @@ func DefaultColors() ColorsConfig { "dim": "F5 dim", "warning": "B3", "casino_warning": "C4 bold", - "casino_action": "6E", + "casino_action": "FF", "casino_menu": "FA", "casino_chips": "B3", "casino_credits": "B3", + "casino_win": "FA", + "casino_payout": "bold FA", + "casino_result_win": "6C bold", + "casino_result_loss": "C4 bold", + "casino_result_push": "FF", + "casino_emphasis": "FF bold", "casino_big_win": "0A bold", "casino_jackpot_win": "E2 bold", "farm_grow": "6C", diff --git a/internal/game/casino.go b/internal/game/casino.go index 9282653..08695e7 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -563,7 +563,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { // Style prose before composing it with independently styled art. // Wrapping the finished message leaks casino_action into borders // and plain spaces between a snapshot's own color resets. - message := g.colorize(sess, category, g.casinoTimeText(event.Message)) + message := g.casinoProse(sess, category, g.casinoTimeText(event.Message)) if details { if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { message += "\n" + rendered @@ -592,7 +592,10 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { } privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text)) privateMessage = g.renderCasinoMessage(sess, category, event.Menu, privateMessage) - if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + rendered := g.renderCasinoSnapshot(sess, event.Snapshot) + if header, reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok { + privateMessage = composeTableThenReminder(header, rendered, reminder) + } else if rendered != "" { privateMessage += "\n" + rendered } if event.ShowBalance { @@ -605,13 +608,66 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { } } +// casinoBoardReminder splits a roulette menu so its wager instructions can be +// printed under the betting board, which is the table the reminder refers to. +// 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) (header, reminder string, ok bool) { + if category != "casino_menu" || menu == nil || sess.Player == nil { + return "", "", false + } + s, isRoulette := snapshot.(*casino.RouletteSnapshot) + if !isRoulette || s.Phase == "playing" || s.Phase == "result" { + return "", "", false + } + if sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + // The compact reminder is already a short block; leave it intact. + return "", "", false + } + balance := "" + if menu.ShowBalance { + balance = g.casinoBalance(sess) + } + heading := g.casinoTimeText(menu.Heading) + if heading == "" { + heading = message + } + commands := append([]casino.MenuCommand(nil), menu.Commands...) + for i := range commands { + if commands[i].Cost > casinoAffordable(sess.Player) { + commands[i].Desc += " [unavailable: insufficient funds]" + } + } + return g.casinoMenuHeader(sess, balance, heading), g.casinoMenuCommands(sess, commands), true +} + +// composeTableThenReminder joins a menu header, the board graphic, and the +// wager reminder. The board snapshot already leads with a blank line, so only +// the reminder needs an extra gap before it. +func composeTableThenReminder(header, table, reminder string) string { + out := header + if table != "" { + out += "\n" + table + } + if reminder != "" { + out += "\n\n" + reminder + } + return out +} + // renderCasinoMessage colorizes a casino message unless it is an // already-composed structured menu. func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if category == "casino_menu" && menu != nil { return message } - return g.colorize(sess, category, message) + return g.casinoProse(sess, category, message) +} + +// casinoProse colorizes casino event text, expanding inline color tags such as +// {casino_result_win}WIN{/} against the session's palette. The event's color +// category is the default for untagged text. +func (g *Game) casinoProse(sess *net.Session, category, text string) string { + return g.expandTagsDefault(sess, g.resolveColor(sess, category), text) } func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { @@ -701,6 +757,22 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) // optional balance line, a heading, and an aligned command list with each // command in the "command" color and its description in plain text. func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, commands []casino.MenuCommand) string { + head := g.casinoMenuHeader(sess, balance, heading) + body := g.casinoMenuCommands(sess, commands) + switch { + case head == "": + return body + case body == "": + return head + default: + return head + "\n\n" + body + } +} + +// casinoMenuHeader formats the balance and heading that introduce a casino +// menu. It is split from the command list so the roulette board can be drawn +// between them. +func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) string { var sb strings.Builder if balance != "" { sb.WriteString(balance) @@ -710,12 +782,18 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, sb.WriteString(g.colorize(sess, "casino_action", heading)) sb.WriteString("\n\n") } + return strings.TrimRight(sb.String(), "\n") +} + +// casinoMenuCommands formats an aligned, colored command list. +func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string { width := 0 for _, c := range commands { if n := utf8.RuneCountInString(c.Command); n > width { width = n } } + var sb strings.Builder for _, c := range commands { padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command)) category := "command" diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 3a1fc41..2f72d07 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -246,3 +246,45 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) { t.Fatalf("light card edges flipped: top=%q bottom=%q", plainLines[1], plainLines[7]) } } + +// Casino settlements carry inline color tags for the outcome and each bet. +// The presentation layer must expand them per recipient (white prose, green +// win, red loss) rather than leaking the raw tags. +func TestCasinoProseExpandsSettlementTags(t *testing.T) { + g := &Game{} + p := player.New("alice") + p.Options = map[string]any{"color": "xterm256"} + sess := &net.Session{Player: p} + got := g.casinoProse(sess, "casino_result_loss", + "{casino_result_loss}LOSS{/} {casino_payout}-1 credit{/}\n {casino_action}red:{/} {casino_result_loss}-1 credit{/}") + if strings.Contains(got, "{") { + t.Fatalf("raw tag leaked into rendered text: %q", got) + } + for _, want := range []string{"LOSS", "-1 credit", "red:"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } + // White label, red loss, and the payout color on the headline total. + if !strings.Contains(got, color.FgCode("xterm256", 255)) { + t.Fatalf("bet label is not white: %q", got) + } + if !strings.Contains(got, color.FgCode("xterm256", 196)) { + t.Fatalf("loss is not bold red: %q", got) + } + if !strings.Contains(got, color.StyleCode("bold")) { + t.Fatalf("loss/amount is not bold: %q", got) + } +} + +// The landing pocket is the only bolded part of the spin announcement. +func TestCasinoProseBoldsLandingPocket(t *testing.T) { + g := &Game{} + p := player.New("alice") + p.Options = map[string]any{"color": "xterm256"} + sess := &net.Session{Player: p} + got := g.casinoProse(sess, "casino_action", "The ball lands in {casino_emphasis}20{/}!") + if !strings.Contains(got, color.StyleCode("bold")+color.FgCode("xterm256", 255)+"20") { + t.Fatalf("landing pocket is not bold white: %q", got) + } +} diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go index 046d7d3..0d5ad63 100644 --- a/internal/game/casino_ux_test.go +++ b/internal/game/casino_ux_test.go @@ -55,7 +55,7 @@ func TestPlayAliasesJoinGames(t *testing.T) { if !ok || table == nil || string(table.Config.Game) != game { t.Fatalf("play %s: participation=%v game=%q", input, ok, game) } - if !strings.Contains(stripANSI(string(conn.data)), "Next hand") { + if !strings.Contains(stripANSI(string(conn.data)), "Limits between") { t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data))) } } diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index ce541c1..d69b5bf 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -22,7 +22,9 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo case "result": return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n" default: - return renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode) + // A blank line above the tracker separates it from the prose or menu + // that precedes the board. + return "\n" + renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode) } } @@ -174,6 +176,18 @@ func renderRouletteRacetrack(mode string, unicode bool) string { ink(58, 5, b.v) ink(58, 6, bl+tr) ink(59, 7, bl+tr) + // Labels are centered on the middle row; a label too wide to leave a blank + // cell against its dividers is wrapped onto the rows above and below it. + // Only the glyphs are written: the centering padding must not be painted + // over the step and curve dividers that share these rows. + labelX := func(left, right int, text string) int { + width := right - left - 1 + pad := max(0, width-color.VisibleLen(text)) + return left + 1 + pad/2 + } + inkCenter := func(y, left, right int, text string) { + ink(labelX(left, right, text), y, text) + } for _, label := range []struct { left, right int text string @@ -181,7 +195,17 @@ func renderRouletteRacetrack(mode string, unicode bool) string { {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, } { - ink(label.left+1, 5, rouletteCenter(label.text, label.right-label.left-1)) + lines := rouletteWrapLabel(label.text, label.right-label.left-3) + if len(lines) == 1 { + inkCenter(5, label.left, label.right, lines[0]) + continue + } + // Wrapped lines share the first line's left edge so the two words read + // as one label instead of drifting apart under independent centering. + x := labelX(label.left, label.right, lines[0]) + for i, line := range lines { + ink(x, 4+2*i, line) + } } lines := make([]string, height) for y, row := range grid { @@ -190,6 +214,31 @@ func renderRouletteRacetrack(mode string, unicode bool) string { return strings.Join(lines, "\n") } +// rouletteWrapLabel breaks a call-bet label on spaces so each line leaves a +// blank cell against the track dividers. The caller knows the labels are short +// enough to wrap onto at most two lines; a single over-long word is left whole +// rather than hyphenated. +func rouletteWrapLabel(text string, limit int) []string { + words := strings.Fields(text) + var lines []string + cur := "" + for _, word := range words { + switch { + case cur == "": + cur = word + case len(cur)+1+len(word) <= limit: + cur += " " + word + default: + lines = append(lines, cur) + cur = word + } + } + if cur != "" { + lines = append(lines, cur) + } + return lines +} + // renderRouletteLayout draws the betting board. Green is reserved for the // 0/00 lane: every other cell is red, black, or plain. The odds reminder is // not part of the board (it is shown by the "table" command), so the board diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index 3577ec9..fcfd9c7 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -238,7 +238,7 @@ func TestRouletteRacetrackPanel(t *testing.T) { for _, unicode := range []bool{false, true} { art := renderRouletteLayout(false, true, mode, unicode) plain := plainRoulette(art) - for _, want := range []string{"Tiers du Cylindre", "Orphelins", "Voisins du Zero", "Zero"} { + for _, want := range []string{"Tiers du", "Cylindre", "Orphelins", "Voisins du Zero", "Jeu"} { if !strings.Contains(plain, want) { t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain) } @@ -494,14 +494,22 @@ func TestRouletteRacetrackSections(t *testing.T) { } } } + // Labels wrap onto the rows around the middle so no glyph touches a + // divider; every field keeps a blank cell on both sides of its text. for _, label := range []struct { - left, right int - text string + row, left, right int + text string }{ - {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, + {4, 5, 20, "Tiers du"}, {6, 5, 22, "Cylindre"}, + {5, 23, 40, "Orphelins"}, {5, 41, 58, "Voisins du Zero"}, + {4, 60, 68, "Jeu"}, {6, 60, 68, "Zero"}, } { - if strings.TrimSpace(segment(5, label.left+1, label.right)) != label.text { - t.Errorf("misplaced %s label", label.text) + field := segment(label.row, label.left, label.right) + if strings.TrimSpace(field) != label.text { + t.Errorf("misplaced %q label on row %d: %q", label.text, label.row, field) + } + if !strings.HasPrefix(field, " ") || !strings.HasSuffix(field, " ") { + t.Errorf("%q label touches a divider on row %d: %q", label.text, label.row, field) } } // Every step joins orthogonally; no font-dependent diagonal gaps. @@ -580,3 +588,32 @@ func TestRouletteWheelBlackAndSpacing(t *testing.T) { } } } + +// The roulette menu draws the board before the wager reminder so the +// instructions follow the table they describe, and the tracker gets a blank +// line above it instead of butting into the heading. +func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) { + g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub(), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} + p := player.New("alice") + p.RoomID, p.Credits = 1, 1000 + p.Options = map[string]any{"color": "none", "unicode": true} + conn := &casinoTestConn{} + sess := &net.Session{Player: p, Conn: conn} + g.loggedInChars = map[string]*net.Session{"alice": sess} + g.Hub.EnterRoom(sess, 1) + cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}} + g.emitCasinoEvents(g.Casino.Join(1, cfg, p.Name)) + out := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + tracker := strings.Index(out, "History: last 0 spins") + board := strings.Index(out, "R = red, B = black, G = green") + reminder := strings.Index(out, "bet <amount> on <target>") + if tracker < 0 || board < 0 || reminder < 0 { + t.Fatalf("missing tracker, board, or reminder:\n%s", out) + } + if !(tracker < board && board < reminder) { + t.Fatalf("expected tracker before board before reminder, got %d/%d/%d:\n%s", tracker, board, reminder, out) + } + if !strings.Contains(out, "\n\n┌─ History: last 0 spins") { + t.Fatalf("tracker is not preceded by a blank line:\n%s", out) + } +} diff --git a/internal/game/render_roulette_tracker.go b/internal/game/render_roulette_tracker.go index c432924..3602224 100644 --- a/internal/game/render_roulette_tracker.go +++ b/internal/game/render_roulette_tracker.go @@ -39,7 +39,7 @@ func renderRouletteTracker(s *casino.RouletteSnapshot, mode string, unicode bool } recentText := "-- awaiting the first spin --" if len(recent) > 0 { - recentText = strings.Join(recent, " ") + ink(" newest first") + recentText = strings.Join(recent, " ") } // Numeric tie-breaking keeps the display stable. Cold includes pockets // not yet hit, but we never label an unhit pocket as hot. diff --git a/internal/game/render_roulette_tracker_test.go b/internal/game/render_roulette_tracker_test.go index 3bae930..a629b57 100644 --- a/internal/game/render_roulette_tracker_test.go +++ b/internal/game/render_roulette_tracker_test.go @@ -46,6 +46,9 @@ func TestRouletteTrackerFormats(t *testing.T) { if strings.Contains(plain, "History, not odds") { t.Fatal("tracker should not carry the removed caption") } + if strings.Contains(plain, "newest first") { + t.Fatal("tracker should not label the recent order") + } if len(history) == 0 { for _, want := range []string{"last 0 spins", "awaiting the first spin", "Hot: --", "Cold: --", "Red --", "Black --"} { if !strings.Contains(plain, want) { @@ -89,7 +92,7 @@ func TestRouletteTrackerAboveBettingBoardsOnly(t *testing.T) { s := &casino.RouletteSnapshot{Racetrack: racetrack, Phase: "betting", History: []string{"17"}, Wheel: casino.WheelOrder(false)} art := renderRouletteSnapshot(s, "none", true, 7) tracker := renderRouletteTracker(s, "none", true) - if !strings.HasPrefix(art, tracker+"\n") { + if !strings.HasPrefix(art, "\n"+tracker+"\n") { t.Fatal("tracker not above board") } for _, phase := range []string{"playing", "result"} { |
