aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go21
-rw-r--r--internal/casino/baccarat_test.go4
-rw-r--r--internal/casino/blackjack.go75
-rw-r--r--internal/casino/blackjack_test.go4
-rw-r--r--internal/casino/machine.go2
-rw-r--r--internal/casino/roulette.go13
-rw-r--r--internal/casino/table.go2
-rw-r--r--internal/casino/table_ux.go127
-rw-r--r--internal/casino/table_ux_test.go12
-rw-r--r--internal/config/config.go8
-rw-r--r--internal/game/casino.go84
-rw-r--r--internal/game/casino_test.go42
-rw-r--r--internal/game/casino_ux_test.go2
-rw-r--r--internal/game/render_roulette.go53
-rw-r--r--internal/game/render_roulette_test.go49
-rw-r--r--internal/game/render_roulette_tracker.go2
-rw-r--r--internal/game/render_roulette_tracker_test.go5
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"} {