aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 22:47:41 -0400
committerhistoria <[not public]>2026-10-01 22:47:41 -0400
commit8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (patch)
tree63698c0618f382a553d4558a33e3d2087039c378 /internal/game
parent6d9908b29c8187196d23b126b1165c7e0bd0fc76 (diff)
downloadthehouseoficarus-8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0.tar.gz
feat: consistent wager outcome table across casino games
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go21
-rw-r--r--internal/game/pai_gow_test.go2
-rw-r--r--internal/game/render_casino_settlement.go185
-rw-r--r--internal/game/render_casino_settlement_test.go161
-rw-r--r--internal/game/render_pai_gow.go15
-rw-r--r--internal/game/render_video_poker.go2
-rw-r--r--internal/game/video_poker_render_test.go2
7 files changed, 368 insertions, 20 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index b15c965..8238b7c 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -546,7 +546,7 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil {
+ if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 {
continue
}
category := event.Color
@@ -583,7 +583,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if text == "" {
text = event.Message
}
- if text == "" && event.Menu == nil && event.Snapshot == nil {
+ if text == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 {
continue
}
g.charsMu.Lock()
@@ -595,10 +595,25 @@ 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)
rendered := g.renderCasinoSnapshot(sess, event.Snapshot)
+ if len(event.Settlement) > 0 {
+ receipt := renderCasinoSettlement(event.Settlement, sess.Player.OptionBool("unicode"), func(tag, text string) string { return g.colorize(sess, tag, text) })
+ if event.SettlementNote != "" {
+ receipt += "\n" + g.casinoProse(sess, "casino_warning", event.SettlementNote)
+ }
+ // Reveal first, then the receipt, then the next-action menu. A
+ // settlement's legacy prose is only a non-UI fallback, not a second payout.
+ rendered = composeTableThenReminder(rendered, receipt)
+ if event.Menu == nil {
+ privateMessage = ""
+ }
+ }
if reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok {
privateMessage = composeTableThenReminder(rendered, reminder)
} else if rendered != "" {
- privateMessage += "\n" + rendered
+ if privateMessage != "" {
+ privateMessage += "\n"
+ }
+ privateMessage += rendered
}
if event.ShowBalance {
privateMessage += "\n" + g.casinoBalance(sess)
diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go
index b8a9c22..f0440b7 100644
--- a/internal/game/pai_gow_test.go
+++ b/internal/game/pai_gow_test.go
@@ -142,7 +142,7 @@ func TestPaiGowShowdownOrderNamesAndColors(t *testing.T) {
if !(high >= 0 && high < dealerHigh && dealerHigh < low && low < dealerLow) {
t.Fatalf("showdown order wrong:\n%s", clean)
}
- for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND", "Net +25 credits."} {
+ for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND"} {
if !strings.Contains(clean, want) {
t.Fatalf("showdown missing %q:\n%s", want, clean)
}
diff --git a/internal/game/render_casino_settlement.go b/internal/game/render_casino_settlement.go
new file mode 100644
index 0000000..bfe101c
--- /dev/null
+++ b/internal/game/render_casino_settlement.go
@@ -0,0 +1,185 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// renderCasinoSettlement is shared by every game. Paint is supplied by the
+// recipient's palette; widths are measured before adding ANSI sequences.
+// The receipt fits the MUD's minimum 80-column wrap width. Long labels wrap
+// rather than losing the hand number or wager identity through truncation.
+func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint func(string, string) string) string {
+ if len(lines) == 0 {
+ return ""
+ }
+ dash := "—"
+ h, v, tl, tr, bl, br, tj, bj, lj, rj, cross := "─", "│", "╭", "╮", "╰", "╯", "┬", "┴", "├", "┤", "┼"
+ if !unicode {
+ dash = "-"
+ h, v, tl, tr, bl, br, tj, bj, lj, rj, cross = "-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"
+ }
+ type row struct {
+ cells [5]string
+ resultTag, netTag string
+ }
+ rows := make([]row, 0, len(lines))
+ widths := [5]int{5, 6, 4, 6, 3}
+ var staked, returned, active int64
+ settled := 0
+ netTag := func(net int64) string {
+ if net > 0 {
+ return "casino_result_win"
+ }
+ if net < 0 {
+ return "casino_result_loss"
+ }
+ return "casino_result_push"
+ }
+ for _, line := range lines {
+ label, odds := line.Label, line.Odds
+ if label == "" {
+ label = "Wager"
+ }
+ if odds == "" {
+ odds = "Varies"
+ }
+ if !unicode {
+ label = strings.NewReplacer("·", "-", "—", "-").Replace(label)
+ odds = strings.ReplaceAll(odds, "—", "-")
+ }
+ net := int64(line.Returned) - int64(line.Stake)
+ result, rt := "LOSS", "casino_result_loss"
+ switch {
+ case strings.HasPrefix(line.Result, "win"), line.Result == "blackjack":
+ result, rt = "WIN", "casino_result_win"
+ case strings.HasPrefix(line.Result, "push"):
+ result, rt = "PUSH", "casino_result_push"
+ case line.Result == "insurance":
+ rt = netTag(net)
+ if net > 0 {
+ result = "WIN"
+ } else if net == 0 {
+ result = "PUSH"
+ }
+ }
+ nt, amount := netTag(net), fmt.Sprintf("%+d", net)
+ if net == 0 {
+ amount = "0"
+ }
+ if line.Unresolved {
+ result, rt, nt, amount = "UNRESOLVED", "warning", "warning", dash
+ active += int64(line.Stake)
+ } else {
+ staked += int64(line.Stake)
+ returned += int64(line.Returned)
+ settled++
+ }
+ r := row{[5]string{label, strconv.Itoa(line.Stake), odds, result, amount}, rt, nt}
+ rows = append(rows, r)
+ for i, s := range r.cells {
+ widths[i] = max(widths[i], color.VisibleLen(s))
+ }
+ }
+ widths[0] = min(widths[0], 24)
+ widths[2] = min(widths[2], 10)
+ // Borders and cell padding occupy sixteen columns.
+ widths[0] = min(widths[0], 79-16-widths[1]-widths[2]-widths[3]-widths[4])
+ widths[0] = max(widths[0], 5)
+ inner := 4
+ for _, w := range widths {
+ inner += w + 2
+ }
+ // A readable footer without forcing tiny one-wager receipts into a wide box.
+ inner = max(inner, 50)
+ sum := 4
+ for _, w := range widths {
+ sum += w + 2
+ }
+ widths[0] += inner - sum
+ border := func(left, join, right string) string {
+ parts := make([]string, 5)
+ for i, w := range widths {
+ parts[i] = strings.Repeat(h, w+2)
+ }
+ return paint("dim", left+strings.Join(parts, join)+right)
+ }
+ pad := func(s string, w int, right bool) string {
+ spaces := strings.Repeat(" ", max(0, w-color.VisibleLen(s)))
+ if right {
+ return spaces + s
+ }
+ return s + spaces
+ }
+ formatRow := func(cells [5]string, rt, nt string) string {
+ parts := make([]string, 5)
+ for i, s := range cells {
+ tag := "casino_action"
+ if i == 3 {
+ tag = rt
+ }
+ if i == 4 {
+ tag = nt
+ }
+ parts[i] = " " + paint(tag, pad(s, widths[i], i == 1 || i == 2 || i == 4)) + " "
+ }
+ return paint("dim", v) + strings.Join(parts, paint("dim", v)) + paint("dim", v)
+ }
+ out := []string{border(tl, tj, tr), formatRow([5]string{"Wager", "Staked", "Odds", "Result", "Net"}, "casino_action", "casino_action"), border(lj, cross, rj)}
+ for _, r := range rows {
+ // Wrapping uses runes, matching the existing MUD visible-width contract.
+ remaining := r.cells
+ for {
+ var cells [5]string
+ more := false
+ for i, s := range remaining {
+ runes := []rune(strings.Join(strings.Fields(s), " "))
+ n := min(len(runes), widths[i])
+ cells[i] = string(runes[:n])
+ remaining[i] = string(runes[n:])
+ more = more || len(runes) > n
+ }
+ out = append(out, formatRow(cells, r.resultTag, r.netTag))
+ if !more {
+ break
+ }
+ }
+ }
+ out = append(out, border(lj, bj, rj))
+ footer := func(text, tag string) {
+ out = append(out, paint("dim", v)+" "+paint(tag, pad(text, inner-2, false))+" "+paint("dim", v))
+ }
+ summary := fmt.Sprintf("Staked: %d Returned: %d", staked, returned)
+ if active > 0 {
+ summary = fmt.Sprintf("Settled: %d Returned: %d Active: %d", staked, returned, active)
+ }
+ // Large multi-hand totals can exceed one footer line; never wrap the frame.
+ for color.VisibleLen(summary) > inner-2 {
+ cut := strings.LastIndex(summary[:inner-2], " ")
+ if cut < 0 {
+ cut = inner - 2
+ }
+ footer(summary[:cut], "casino_action")
+ summary = strings.TrimSpace(summary[cut:])
+ }
+ footer(summary, "casino_action")
+ net := returned - staked
+ title, amount := "NET EVEN", "0 credits"
+ if net > 0 {
+ title, amount = "NET PROFIT", fmt.Sprintf("+%d credits", net)
+ }
+ if net < 0 {
+ title, amount = "NET LOSS", fmt.Sprintf("%d credits", net)
+ }
+ tag := netTag(net)
+ if settled == 0 {
+ title, amount, tag = "NET PENDING", dash, "warning"
+ }
+ footer(title+strings.Repeat(" ", max(1, inner-2-len(title)-color.VisibleLen(amount)))+amount, tag)
+ out = append(out, paint("dim", bl+strings.Repeat(h, inner)+br))
+ return strings.Join(out, "\n")
+}
diff --git a/internal/game/render_casino_settlement_test.go b/internal/game/render_casino_settlement_test.go
new file mode 100644
index 0000000..f26a174
--- /dev/null
+++ b/internal/game/render_casino_settlement_test.go
@@ -0,0 +1,161 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func plainReceipt(_ string, text string) string { return text }
+
+func TestCasinoReceiptAccountingAndBorders(t *testing.T) {
+ lines := []casino.SettlementLine{
+ {Label: "Pass line", Stake: 100, Returned: 200, Odds: "1:1", Result: "win"},
+ {Label: "Field", Stake: 25, Odds: "Varies", Result: "loss"},
+ {Label: "Place 6", Stake: 60, Odds: "7:6", Unresolved: true},
+ {Label: "Push", Stake: 10, Returned: 10, Odds: "1:1", Result: "push"},
+ }
+ for _, unicode := range []bool{true, false} {
+ got := renderCasinoSettlement(lines, unicode, plainReceipt)
+ for _, want := range []string{"Wager", "Staked", "Odds", "WIN", "LOSS", "PUSH", "UNRESOLVED", "Settled: 135", "Returned: 210", "Active: 60", "NET PROFIT", "+75 credits"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "PAYOUT") || strings.Contains(got, "-60") {
+ t.Fatal(got)
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width || width > 79 {
+ t.Fatalf("misaligned width=%d:\n%s", width, got)
+ }
+ if !unicode {
+ for _, r := range line {
+ if r > 127 {
+ t.Fatalf("non-ASCII %q", line)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestCasinoReceiptColorIsIndependentOfGameResult(t *testing.T) {
+ var tags []string
+ got := renderCasinoSettlement([]casino.SettlementLine{{Label: "Bonus hand", Stake: 10, Returned: 5, Odds: "Varies", Result: "win"}}, true, func(tag, text string) string {
+ tags = append(tags, tag+":"+strings.TrimSpace(text))
+ return color.Render("ansi", color.ColorSpec{Fg: 2, Bg: -1}, text)
+ })
+ for _, want := range []string{"casino_result_win:WIN", "casino_result_loss:-5"} {
+ found := false
+ for _, tag := range tags {
+ found = found || tag == want
+ }
+ if !found {
+ t.Fatalf("missing %q: %v", want, tags)
+ }
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width {
+ t.Fatal(got)
+ }
+ }
+}
+
+func TestCasinoReceiptLargeTotalsAndLongLabels(t *testing.T) {
+ var lines []casino.SettlementLine
+ for i := 1; i <= 100; i++ {
+ lines = append(lines, casino.SettlementLine{Label: fmt.Sprintf("Hand %d - Four aces with a long kicker description", i), Stake: 1000000000, Returned: 2000000000, Odds: "19:20", Result: "win"})
+ }
+ lines = append(lines, casino.SettlementLine{Label: "Active", Stake: 1000000000, Odds: "1:1", Unresolved: true})
+ got := renderCasinoSettlement(lines, true, plainReceipt)
+ for _, want := range []string{"Hand 100", "100000000000", "200000000000", "+100000000000 credits"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %s", want)
+ }
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width || width > 79 {
+ t.Fatalf("width %d; bad line %q", width, line)
+ }
+ }
+}
+
+func TestCasinoReceiptRevealThenReceiptThenMenu(t *testing.T) {
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "none", "unicode": false}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{Deps: Deps{World: world.New(t.TempDir())}, Combat: combat.NewTracker(), loggedInChars: map[string]*net.Session{"alice": sess}}
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", Color: "casino_menu",
+ Snapshot: &casino.VideoPokerSnapshot{Config: (casino.VideoPokerConfig{}).Normalize(), Result: "REVEAL MARKER"},
+ Menu: &casino.MenuView{Heading: "NEXT ACTION MARKER"},
+ Settlement: []casino.SettlementLine{{Label: "Hand", Stake: 10, Returned: 20, Odds: "1:1", Result: "win"}},
+ }})
+ got := string(conn.data)
+ reveal, receipt, menu := strings.Index(got, "REVEAL MARKER"), strings.Index(got, "NET PROFIT"), strings.Index(got, "NEXT ACTION MARKER")
+ if reveal < 0 || receipt < reveal || menu < receipt {
+ t.Fatal(got)
+ }
+}
+
+func TestCasinoReceiptIsPrivateEvenOnPublicResult(t *testing.T) {
+ hub := net.NewHub()
+ g := &Game{Hub: hub, Casino: casino.NewManager(1), loggedInChars: map[string]*net.Session{}}
+ conns := map[string]*casinoTestConn{}
+ for _, name := range []string{"alice", "bob"} {
+ p := player.New(name)
+ p.Options = map[string]any{"color": "none"}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ hub.Add(sess)
+ hub.EnterRoom(sess, 1)
+ g.loggedInChars[name] = sess
+ conns[name] = conn
+ }
+ g.emitCasinoEvents([]casino.Event{{RoomID: 1, TableID: "test", Public: true, Message: "Alice wins.", PrivateTo: "alice", Settlement: []casino.SettlementLine{{Label: "SECRET WAGER", Stake: 10, Returned: 20, Result: "win"}}}})
+ if !strings.Contains(string(conns["alice"].data), "SECRET WAGER") || strings.Contains(string(conns["bob"].data), "SECRET WAGER") || !strings.Contains(string(conns["bob"].data), "Alice wins.") {
+ t.Fatalf("alice=%s bob=%s", conns["alice"].data, conns["bob"].data)
+ }
+}
+
+func TestCasinoReceiptUnresolvedIsNotPush(t *testing.T) {
+ got := renderCasinoSettlement([]casino.SettlementLine{{Label: "Pass line", Stake: 100, Unresolved: true}}, false, plainReceipt)
+ if !strings.Contains(got, "NET PENDING") || strings.Contains(got, "NET EVEN") || strings.Contains(got, "PUSH") {
+ t.Fatal(got)
+ }
+}
+
+func TestCasinoSettlementReplacesFallbackAndPreservesPartialFailure(t *testing.T) {
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "none", "unicode": false}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{loggedInChars: map[string]*net.Session{"alice": sess}}
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", PrivateMessage: "OLD PAYOUT", Settlement: []casino.SettlementLine{{Label: "Blackjack", Odds: "3:2", Stake: 100, Returned: 250, Result: "blackjack"}}, SettlementNote: "Another wager is pending recovery."}})
+ got := string(conn.data)
+ for _, want := range []string{"Blackjack", "3:2", "+150 credits", "pending recovery"} {
+ if !strings.Contains(got, want) {
+ t.Fatal(got)
+ }
+ }
+ if strings.Contains(got, "OLD PAYOUT") || strings.Contains(got, "\x1b[") || strings.Contains(got, "╭") {
+ t.Fatal(got)
+ }
+ conn.data = nil
+ // Structured-only events must survive the empty-message filters.
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", Settlement: []casino.SettlementLine{{Label: "Spin", Stake: 10, Result: "loss"}}}})
+ if !strings.Contains(string(conn.data), "NET LOSS") {
+ t.Fatal(string(conn.data))
+ }
+}
diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go
index b125a68..390a53f 100644
--- a/internal/game/render_pai_gow.go
+++ b/internal/game/render_pai_gow.go
@@ -176,21 +176,8 @@ func paiGowOutcome(sd *casino.PaiGowShowdown, mode string) []string {
lines = append(lines, "Push Ace High: "+sd.PushAce+".")
}
}
- if sd.ShowEnvy && sd.Envy > 0 {
- lines = append(lines, "Envy: "+color.Render(mode, paiGowWin, fmt.Sprintf("+%d credits", sd.Envy)))
- }
- switch {
- case sd.Pending:
+ if sd.Pending {
lines = append(lines, color.Render(mode, paiGowLoss, "PAYMENT PENDING — type settle to retry the same result."))
- case sd.Paid:
- net := sd.Payout - sd.Stake
- netSpec, netText := paiGowPush, "Net 0 credits."
- if net > 0 {
- netSpec, netText = paiGowWin, fmt.Sprintf("Net +%d credits.", net)
- } else if net < 0 {
- netSpec, netText = paiGowLoss, fmt.Sprintf("Net %d credits.", net)
- }
- lines = append(lines, color.Render(mode, netSpec, netText))
}
return lines
}
diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go
index b8262c8..719b36a 100644
--- a/internal/game/render_video_poker.go
+++ b/internal/game/render_video_poker.go
@@ -168,7 +168,7 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s
text := "No win"
fg := 15
if line.Coins > 0 {
- text = fmt.Sprintf("WIN: %s — %d coins", line.Category, line.Coins)
+ text = line.Category
fg = pokerYellow
}
if line.Multiplier > 1 {
diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go
index 8195e21..88c8474 100644
--- a/internal/game/video_poker_render_test.go
+++ b/internal/game/video_poker_render_test.go
@@ -86,7 +86,7 @@ func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) {
line.Category = "Royal flush"
line.Coins = 4000
fg = pokerYellow
- text = "WIN: Royal flush — 4000 coins"
+ text = "Royal flush"
}
snap := &casino.VideoPokerSnapshot{Config: cfg, Hands: hands, Lines: []casino.PokerLine{line}, Multiplier: 1}
out := renderVideoPoker(snap, "xterm256", true, 7, "", nil)