diff options
| author | historia <[not public]> | 2026-10-01 22:47:41 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-01 22:47:41 -0400 |
| commit | 8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (patch) | |
| tree | 63698c0618f382a553d4558a33e3d2087039c378 /internal/game | |
| parent | 6d9908b29c8187196d23b126b1165c7e0bd0fc76 (diff) | |
| download | thehouseoficarus-8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0.tar.gz | |
feat: consistent wager outcome table across casino games
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 21 | ||||
| -rw-r--r-- | internal/game/pai_gow_test.go | 2 | ||||
| -rw-r--r-- | internal/game/render_casino_settlement.go | 185 | ||||
| -rw-r--r-- | internal/game/render_casino_settlement_test.go | 161 | ||||
| -rw-r--r-- | internal/game/render_pai_gow.go | 15 | ||||
| -rw-r--r-- | internal/game/render_video_poker.go | 2 | ||||
| -rw-r--r-- | internal/game/video_poker_render_test.go | 2 |
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) |
