diff options
| -rw-r--r-- | building_guide/roulette.md | 33 | ||||
| -rw-r--r-- | config.yaml | 8 | ||||
| -rw-r--r-- | data/help/roulette.yaml | 2 | ||||
| -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 |
20 files changed, 450 insertions, 98 deletions
diff --git a/building_guide/roulette.md b/building_guide/roulette.md index 10c68ce..e3794e2 100644 --- a/building_guide/roulette.md +++ b/building_guide/roulette.md @@ -32,8 +32,10 @@ mode and Unicode preference stay per player (the engine snapshot never embeds ANSI). The views and betting tracker share one snapshot renderer: - **Tracker**: a five-line, 74-column strip above the betting board, rendered - by `render_roulette_tracker.go`. It shows the latest eight hits (newest first), - three hot/cold pockets with hit counts, and red/black/zero percentages. + by `render_roulette_tracker.go`. The betting snapshot leads with a blank line + so the tracker is separated from the prose or menu that precedes it. It shows + the latest eight hits (newest first), three hot/cold pockets with hit counts, + and red/black/zero percentages. All stats use a table-local rolling window of 50 completed spins. Zeros are included in the denominator; ties use numeric order (0, 00, 1, ...). History is copied into snapshots, never sampled before settlement, and @@ -41,7 +43,7 @@ ANSI). The views and betting tracker share one snapshot renderer: History is copied into snapshots, never sampled before settlement, and survives round resets but not table replacement or server restart. - **Snapshot cadence**: the board and tracker are drawn once per round by the - menu event (on join and after settlement, the "Next spin" menu) and by an + betting menu (on join and after settlement) and by an explicit `table`/`table rules`. Placing a wager only confirms the wager; it does not re-send the board snapshot. - **Betting board**: numbered grid with zeros in the left lane, red/black @@ -51,9 +53,18 @@ ANSI). The views and betting tracker share one snapshot renderer: `renderRouletteRacetrack` above the board: a numbered oval in physical clockwise wheel order, with 26 at the right tip. Interior dividers meet each call bet's pockets, left to right: Tiers du Cylindre, Orphelins, - Voisins du Zero, Zero. Connected box-drawing steps separate Tiers from - Orphelins; Zero has a curved boundary within Voisins. Neighbors is not - drawn on the board, but remains in the payout reference and help. + Voisins du Zero, Zero. Labels are centered on the middle row; a label too + wide to leave a blank cell against its dividers wraps onto the rows above + and below it (e.g. "Tiers du" / "Cylindre"), and only the glyphs are + written so the centering padding never paints over the dividers. Connected + box-drawing steps separate Tiers from Orphelins; Zero has a curved boundary + within Voisins. Neighbors is not drawn on the board, but remains in the + payout reference and help. +- **Menu ordering**: the roulette betting menu prints its balance and + heading, then the tracker and board, and only then the wager reminder + (`bet <amount> on <target>`, `bet / rebet`, `table / sitout / stop`) so the + instructions follow the table they describe. Other games keep their menu + above the snapshot. - **Spinning**: a stylised 17x9 wheel (wider than tall so it reads as a circle in a terminal cell grid). Only the rim is colored: solid red pockets alternate with a very dark blue that stands in for black (`rouletteWheelBlack`), because @@ -115,6 +126,16 @@ Failures leave the stake pending and do not claim that payment succeeded. Shutdown and eviction refund outstanding stakes and reset the round. Disconnect refunds are safe because no partial outcome is sampled or exposed. +Settlements are rendered by `settlementText` in `internal/casino/table_ux.go`: +a colored win/loss/push headline gives the round's net once (the old +"Net ... | ... returned (stake included)" split is gone), then each named bet +— a blackjack hand, insurance, or the table's spot — is listed with its own +net. Wins are green, losses red, pushes white, and a natural keeps the jackpot +gradient. The engine emits inline color tags (`{casino_result_win}WIN{/}`), +which `Game.casinoProse` expands per recipient against the session palette, so +the engine still never embeds ANSI. Casino prose defaults to white +(`casino_action`), leaving color for emphasized outcomes. + ## Assessment and boundaries This is a sound foundation for more testing-stage table games, not a blanket diff --git a/config.yaml b/config.yaml index 95992cb..99497ff 100644 --- a/config.yaml +++ b/config.yaml @@ -62,15 +62,19 @@ default_colors: say: "FF" global: "49" broadcast: "89" - casino_action: "6E" + casino_action: "FF" casino_warning: "C4 bold" # Red deadlines and unavailable actions casino_menu: "FA" casino_win: "FA" casino_payout: "bold FA" + casino_result_win: "6C bold" # Settlement headline: win + casino_result_loss: "C4 bold" # Settlement headline: loss + casino_result_push: "FF" # Settlement headline: push + casino_emphasis: "FF bold" # Bold white highlight, e.g. the landing pocket casino_big_win: "B3" casino_jackpot_win: "g:220,226,46,51,129,201 bold" casino_chips: "B3" - casino_credits: "6E" + casino_credits: "B3" dialog: "6E" dialog_options: "bold" command: "0A bold" diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml index 1fbb280..fae288c 100644 --- a/data/help/roulette.yaml +++ b/data/help/roulette.yaml @@ -67,7 +67,7 @@ description: | winnings return as credits. Displayed returns include your stake; odds above are profit odds. - The tracker above the board shows the newest 8 hits (newest first). + The tracker above the board shows the newest 8 hits, newest first. Hot/cold lists the 3 most/least frequent pockets over the last 50 spins; parentheses show hit counts. Ties use number order (0, 00, 1, ...). Red/black/zero percentages use all spins in that same window, including 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"} { |
