diff options
| author | historia <[not public]> | 2026-10-02 01:54:37 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-02 01:54:37 -0400 |
| commit | d3a305d1d7962f82af2397760cf8008758bddd1d (patch) | |
| tree | f91f0deb342c20f2993e5366f772c3432648f426 /internal/game | |
| parent | 8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (diff) | |
| download | thehouseoficarus-d3a305d1d7962f82af2397760cf8008758bddd1d.tar.gz | |
feat: better 'list' bet table, multiple bets in baccarat
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 6 | ||||
| -rw-r--r-- | internal/game/casino_edge_list_test.go | 97 | ||||
| -rw-r--r-- | internal/game/casino_house_edge.go | 190 | ||||
| -rw-r--r-- | internal/game/casino_rules_list_test.go | 84 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 41 | ||||
| -rw-r--r-- | internal/game/house_edge_test.go | 214 | ||||
| -rw-r--r-- | internal/game/render_baccarat_felt.go | 2 | ||||
| -rw-r--r-- | internal/game/render_baccarat_felt_test.go | 2 | ||||
| -rw-r--r-- | internal/game/video_poker_test.go | 2 |
9 files changed, 596 insertions, 42 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index 8238b7c..21f9627 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -419,6 +419,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " ")))) return } + // Baccarat repeats every remembered side bet and the main bet on a bare + // "bet"; games with one wager keep the generic single-rebet path below. + if table.Config.Game == casino.GameBaccarat && len(args) == 0 { + g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p})) + return + } // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>. // BEFORE an "on" keyword, a token that parses as an amount (or min/max) // sets the wager and any other token is the bet target; AFTER "on", the diff --git a/internal/game/casino_edge_list_test.go b/internal/game/casino_edge_list_test.go new file mode 100644 index 0000000..ff0b965 --- /dev/null +++ b/internal/game/casino_edge_list_test.go @@ -0,0 +1,97 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/player" +) + +func TestEdgeListAllTableGames(t *testing.T) { + for _, game := range []string{"blackjack", "baccarat", "roulette", "craps", "paigow", "video_poker"} { + t.Run(game, func(t *testing.T) { + g, sess, conn := houseEdgeGame(t, "name: Casino\ncasino_tables:\n - id: test\n game: "+game+"\n") + writeHouseEdgeManual(t, g, sess, 2) + g.executePlay(sess, []string{game}, "play "+game) + if strings.Contains(stripANSI(string(conn.data)), "RTP") { + t.Fatal("readout on join") + } + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "RTP") { + t.Fatalf("no RTP column for %s: %s", game, text) + } + assertRTPColumn(t, text) + sess.Player.Inventory = nil + conn.data = nil + g.executeList(sess, nil, "list") + if strings.Contains(stripANSI(string(conn.data)), "RTP") { + t.Fatal("readout after reference removed") + } + }) + } +} + +func TestEdgeListBlackjackActionsOmitRTP(t *testing.T) { + g, sess, conn := houseEdgeGame(t, "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n") + writeHouseEdgeManual(t, g, sess, 2) + g.executePlay(sess, []string{"bj"}, "play bj") + table, _ := g.Casino.Participation(sess.Player.Name) + for i := 0; i < 10 && len(table.Wagers(sess.Player.Name)) == 0; i++ { + g.executeBet(sess, []string{"1"}, "bet 1") + } + rows := table.Wagers(sess.Player.Name) + if len(rows) == 0 { + t.Skip("no action rows this deal") + } + insurance := false + for _, r := range rows { + if r.Bet == "insurance" { + insurance = true + } + } + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if insurance { + // Insurance is a real wager and carries an RTP. + if !strings.Contains(text, "RTP") { + t.Fatalf("insurance list lost its RTP:\n%s", text) + } + return + } + if strings.Contains(text, "RTP") { + t.Fatalf("action-only list showed an RTP column:\n%s", text) + } +} + +func TestEdgeListRealInstruments(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomRoulette) + g.ItemStore = item.NewItemStore("../../data") + sess.Player.Inventory = map[int]*player.InventorySlot{ + 0: {ItemID: "house_edge_tile", Quantity: 1}, + 1: {ItemID: "house_edge_computus", Quantity: 1}, + } + g.executePlay(sess, []string{"roulette"}, "play roulette") + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if strings.Count(text, "97.30%") != 12 { + t.Fatalf("best instrument not used: %s", text) + } + sess.Player.Inventory[1].Quantity = 0 + conn.data = nil + g.executeList(sess, nil, "list") + text = stripANSI(string(conn.data)) + if strings.Count(text, "97%") != 12 { + t.Fatalf("tile not used: %s", text) + } + sess.Player.Inventory[0].Quantity = 0 + conn.data = nil + g.executeList(sess, nil, "list") + if strings.Contains(stripANSI(string(conn.data)), "RTP") { + t.Fatal("zero-quantity item activated") + } +} diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go new file mode 100644 index 0000000..8bc3a5b --- /dev/null +++ b/internal/game/casino_house_edge.go @@ -0,0 +1,190 @@ +package game + +import ( + "fmt" + "math" + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +// Only carried, positive-quantity items count. The most precise instrument wins. +func (g *Game) casinoEdgePrecision(sess *net.Session) (int, bool) { + if sess == nil || sess.Player == nil || g.ItemStore == nil { + return 0, false + } + precision, found := 0, false + for _, slot := range sess.Player.Inventory { + if slot == nil || slot.Quantity <= 0 { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.HouseEdgePrecision == nil { + continue + } + p := *def.HouseEdgePrecision + if p != 0 && p != 2 { + continue + } + precision = max(precision, p) + found = true + } + return precision, found +} + +// Convert the unrounded loss, not the displayed/rounded house edge. +func casinoRTP(e casino.HouseEdge, precision int) string { + if len(e.Parts) != 1 { + return "—" + } + value := math.Round((1-e.Parts[0].Loss)*100*math.Pow10(precision)) / math.Pow10(precision) + if value == 0 { + value = 0 + } // no negative zero + return fmt.Sprintf("%.*f%%", precision, value) +} + +type casinoListRow struct { + bet, odds, win string + edge casino.HouseEdge +} + +// Only remove redundant commission prose. Exceptions such as Banker 6 paying +// half or three-card 7 pushing are rules, not redundant explanations. +func conciseCasinoOdds(odds string) string { + if at := strings.Index(odds, " ("); at >= 0 && strings.HasSuffix(odds, "% commission)") { + return odds[:at] + } + return odds +} + +func (g *Game) casinoListRows(sess *net.Session, t *casino.Table, wagers []casino.WagerRow) []casinoListRow { + _, enabled := g.casinoEdgePrecision(sess) + var rows []casinoListRow + for _, wager := range wagers { + row := casinoListRow{bet: wager.Bet, odds: conciseCasinoOdds(wager.Payout), win: wager.Win} + if enabled { + row.edge = t.HouseEdge(sess.Player.Name, wager.Bet) + } + if len(row.edge.Parts) <= 1 { + rows = append(rows, row) + continue + } + // Different target returns need distinct rows, not a list of unlabeled RTPs. + for _, part := range row.edge.Parts { + entry := row + if strings.Contains(wager.Bet, "<n>") { + entry.bet = strings.ReplaceAll(wager.Bet, "<n>", part.Label) + } else { + entry.win = part.Label + "; " + entry.win + } + entry.edge = casino.HouseEdge{Parts: []casino.EdgePart{part}} + rows = append(rows, entry) + } + } + return rows +} + +// Explicit list-only presentation. Wrap plain cells before applying color and +// handing them to the existing ANSI-aware table renderer; never truncate rules. +// The table stands alone: list names no heading and no balance above it. +func (g *Game) showCasinoListTable(sess *net.Session, hint string, rows []casinoListRow) { + precision, hasItem := g.casinoEdgePrecision(sess) + // The item adds a column only when at least one listed wager has a real + // analysis; decision actions such as hit/stand have none. + rtp := false + if hasItem { + for _, r := range rows { + if len(r.edge.Parts) > 0 { + rtp = true + break + } + } + } + headers := []string{"Bet", "Odds", "Win / action"} + categories := []string{"command", "casino_payout", ""} + if rtp { + headers = append(headers, "RTP") + categories = append(categories, "casino_emphasis") + } + cells := make([][]string, 0, len(rows)) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = utf8.RuneCountInString(h) + } + for _, r := range rows { + odds := r.odds + if odds == "" { + odds = "—" + } + row := []string{r.bet, odds, r.win} + if rtp { + row = append(row, casinoRTP(r.edge, precision)) + } + for i, c := range row { + widths[i] = max(widths[i], utf8.RuneCountInString(c)) + } + cells = append(cells, row) + } + width, unicode := 80, true + if p := sess.Player; p != nil { + unicode = p.OptionBool("unicode") + if wrap := p.OptionInt("wrap_width"); wrap > 0 { + width = min(width, wrap) + } + } + budget := max(len(headers), width-3*len(headers)-1) + for { + sum, widest := 0, 0 + for i, w := range widths { + sum += w + if w > widths[widest] { + widest = i + } + } + if sum <= budget || widths[widest] <= 1 { + break + } + widths[widest]-- + } + table := ui.Table{} + // Headers wrap too, so narrow terminals never lose a column label. + all := append([][]string{headers}, cells...) + for rowIndex, row := range all { + wrapped := make([][]string, len(row)) + height := 1 + for i, c := range row { + wrapped[i] = casinoWrapWords(c, widths[i]) + height = max(height, len(wrapped[i])) + } + for line := 0; line < height; line++ { + physical := make([]string, len(row)) + for i, parts := range wrapped { + if line >= len(parts) { + continue + } + category := categories[i] + if rowIndex == 0 { + category = "casino_action" + } + physical[i] = parts[line] + if category != "" { + physical[i] = g.colorize(sess, category, physical[i]) + } + } + if rowIndex == 0 && line == 0 { + table.Columns = physical + } else { + table.Rows = append(table.Rows, physical) + } + } + } + out := table.Render(unicode, width) + if hint != "" { + out = append(out, g.colorize(sess, "command", hint)) + } + sess.WriteLine(strings.Join(out, "\n")) +} diff --git a/internal/game/casino_rules_list_test.go b/internal/game/casino_rules_list_test.go index 3d91ecf..e83c277 100644 --- a/internal/game/casino_rules_list_test.go +++ b/internal/game/casino_rules_list_test.go @@ -85,6 +85,28 @@ func TestRulesOutsideCasinoFallsBackToGuide(t *testing.T) { } // TestListFollowsGameState covers the list contract end to end: come-out +// listTargets returns the bet names rendered in the first table column, +// working for both the unicode and ASCII table borders. +func listTargets(text string) map[string]bool { + targets := map[string]bool{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "║") && !strings.HasPrefix(line, "|") { + continue + } + line = strings.TrimPrefix(strings.TrimPrefix(line, "║"), "|") + for _, sep := range []string{"│", "|"} { + if at := strings.Index(line, sep); at >= 0 { + line = line[:at] + } + } + if cell := strings.TrimSpace(line); cell != "" { + targets[cell] = true + } + } + return targets +} + // lists the line and one-roll bets, a point adds place/odds rows, bonus // bets close after the first roll, and single-bet editions print one line. func TestListFollowsGameState(t *testing.T) { @@ -95,13 +117,14 @@ func TestListFollowsGameState(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) - for _, want := range []string{"bet <amount> on pass", "bet <amount> on dontpass", "bet <amount> on field", "bet <amount> on craps", "bet <amount> on fire"} { - if !strings.Contains(text, want) { + targets := listTargets(text) + for _, want := range []string{"pass", "dontpass", "field", "craps", "fire"} { + if !targets[want] { t.Fatalf("come-out list missing %q: %q", want, text) } } for _, gone := range []string{"place 6", "odds pass"} { - if strings.Contains(text, gone) { + if targets[gone] { t.Fatalf("come-out list advertised %q: %q", gone, text) } } @@ -111,37 +134,39 @@ func TestListFollowsGameState(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text = stripANSI(string(conn.data)) - if !strings.Contains(text, "place 6") || !strings.Contains(text, "odds pass") { + targets = listTargets(text) + if !targets["place 6"] || !targets["odds pass"] { t.Fatalf("point list missing place/odds: %q", text) } - if strings.Contains(text, "bet <amount> on pass\n") || strings.Contains(text, "fire") { + if targets["fire"] { t.Fatalf("point list still advertising closed bets: %q", text) } } -// TestListSingleBetGamesStayBrief: editions with one wager (Simplified) and -// one-wager games (blackjack) answer in one line, no table. -func TestListSingleBetGamesStayBrief(t *testing.T) { +// TestListSingleBetGamesRenderOneRow: editions with one wager (Simplified) +// and one-wager games (blackjack) answer with a one-row table. +func TestListSingleBetGamesRenderOneRow(t *testing.T) { room := "name: Simple\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n" g, sess, conn := casinoCrapsGame(t, room) g.executePlay(sess, []string{"craps"}, "play craps") conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) - if strings.Contains(text, "Bet") && strings.Contains(text, "Pays") && strings.Contains(text, "Win if") { - t.Fatalf("single-bet game rendered a table: %q", text) + if !strings.Contains(text, "Odds") { + t.Fatalf("single-bet list did not render the table header: %q", text) } - if !strings.Contains(text, "bet <amount> on pass") { - t.Fatalf("single-bet line missing the wager: %q", text) + if targets := listTargets(text); !targets["pass"] { + t.Fatalf("single-bet table missing the wager: %q", text) } - // Blackjack: one wager, no table, and the list command still answers. + // Blackjack: one wager, one row, and the list command still answers. room = "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n" g, sess, conn = casinoCrapsGame(t, room) g.executePlay(sess, []string{"bj"}, "play bj") conn.data = nil g.executeList(sess, nil, "list") - if text := stripANSI(string(conn.data)); !strings.Contains(text, "only wager") { + text = stripANSI(string(conn.data)) + if targets := listTargets(text); !targets["bet <amount>"] { t.Fatalf("blackjack list: %q", text) } } @@ -225,8 +250,9 @@ func TestListRouletteAndBaccarat(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) - for _, want := range []string{"bet <amount> on <number>", "bet <amount> on red / black", "bet <amount> on voisins", "bet <amount> on basket"} { - if !strings.Contains(text, want) { + targets := listTargets(text) + for _, want := range []string{"<number>", "red / black", "voisins", "basket"} { + if !targets[want] { t.Fatalf("roulette list missing %q: %q", want, text) } } @@ -237,9 +263,31 @@ func TestListRouletteAndBaccarat(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text = stripANSI(string(conn.data)) - for _, want := range []string{"bet <amount> on banker (b)", "1:1 (3-card 7 pushes)", "bet <amount> on dragon_7", "bet <amount> on panda_8"} { - if !strings.Contains(text, want) { + targets = listTargets(text) + for _, want := range []string{"banker", "dragon_7", "panda_8"} { + if !targets[want] { t.Fatalf("baccarat list missing %q: %q", want, text) } } + if !strings.Contains(text, "1:1 (3-card 7 pushes)") { + t.Fatalf("baccarat list dropped the EZ banker rule: %q", text) + } +} + +// The commission is implied by the 0.95:1 ratio and must not be repeated. +func TestListBaccaratDropsCommissionProse(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: B\ncasino_tables:\n - id: b\n game: baccarat\n") + g.executePlay(sess, []string{"b"}, "play b") + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "commission") { + t.Fatalf("list repeated the implied commission: %q", text) + } + if targets := listTargets(text); !targets["banker"] { + t.Fatalf("baccarat list lost the banker row: %q", text) + } + if !strings.Contains(text, "0.95:1") { + t.Fatalf("baccarat list lost the banker ratio: %q", text) + } } diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 6d666f7..28fc361 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -365,7 +365,11 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) { return } if machine, ok := g.Casino.MachineFor(p.Name); ok { - sess.WriteLine(g.colorize(sess, "casino_action", "A "+casinoGameLabel(machine.Config.Game)+" takes one wager: bet <amount> (or bet max), then spin or autospin.")) + row := casinoListRow{bet: "bet <amount> / max", odds: "paytable", win: "spin / autospin"} + if _, enabled := g.casinoEdgePrecision(sess); enabled { + row.edge = machine.Config.HouseEdge() + } + g.showCasinoListTable(sess, "", []casinoListRow{row}) return } if table, playing := g.Casino.Participation(p.Name); playing && table != nil { @@ -375,9 +379,8 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) { g.executeShopList(sess, args, rawInput) } -// showCasinoWagerList renders the game's live wager list. Two or more rows -// become a brief table; a single row is one line; an empty list means the -// only bet is the ordinary amount-only wager. +// showCasinoWagerList renders the live bets/actions as a compact table. +// An empty wager catalogue can still represent an ordinary amount-only bet. func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { if t.Config.Game == casino.GameVideoPoker { for _, h := range t.Hints(sess.Player.Name) { @@ -386,24 +389,18 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { return } } - sess.WriteLine("This cabinet has one wager: load coins with bet [1-4|max], then deal. Use paytable for payouts.") + rows := g.casinoListRows(sess, t, []casino.WagerRow{{Bet: "main", Payout: "paytable", Win: "deal"}}) + rows[0].bet = "bet [1-4|max]" + g.showCasinoListTable(sess, "", rows) return } rows := t.Wagers(sess.Player.Name) // Blackjack rows are actions, not targets for a new bet. if t.Config.Game == casino.GameBlackjack && len(rows) > 0 { - commands := make([]casino.MenuCommand, 0, len(rows)) - for _, r := range rows { - commands = append(commands, casino.MenuCommand{Command: r.Bet, Desc: strings.Trim(r.Payout+" | "+r.Win, " |")}) - } - sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), "Available actions", commands)) + g.showCasinoListTable(sess, "", g.casinoListRows(sess, t, rows)) return } - switch { - case len(rows) == 1: - r := rows[0] - sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet <amount> on "+r.Bet+" — "+r.Payout+" — "+r.Win+".")) - case len(rows) == 0: + if len(rows) == 0 { canBet := false for _, h := range t.Hints(sess.Player.Name) { if strings.HasPrefix(h.Command, "bet ") { @@ -414,14 +411,16 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { sess.WriteLine(g.colorize(sess, "casino_action", "No new wagers are available right now.")) return } - sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet <amount>") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").") - default: - commands := make([]casino.MenuCommand, 0, len(rows)) - for _, r := range rows { - commands = append(commands, casino.MenuCommand{Command: "bet <amount> on " + r.Bet, Desc: r.Payout + " | " + r.Win}) + wager := casino.WagerRow{Bet: "main", Win: betRangeWords(t.Config.MinBet, t.Config.MaxBet)} + if t.Config.Game == casino.GameBlackjack { + wager.Payout = fmt.Sprintf("1:1; BJ %g:1", t.Config.Blackjack.Normalize().BlackjackPayout) } - sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), t.Config.Label()+" — available bets", commands)) + list := g.casinoListRows(sess, t, []casino.WagerRow{wager}) + list[0].bet = "bet <amount>" + g.showCasinoListTable(sess, "", list) + return } + g.showCasinoListTable(sess, "bet <amount> on <bet>", g.casinoListRows(sess, t, rows)) } // showCasinoGameLook appends the live game state and hint menu for the diff --git a/internal/game/house_edge_test.go b/internal/game/house_edge_test.go new file mode 100644 index 0000000..82aa966 --- /dev/null +++ b/internal/game/house_edge_test.go @@ -0,0 +1,214 @@ +package game + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// houseEdgeGame runs a Game over a copy of the shipped data directory (so +// the real item, room and help definitions load) plus one synthetic room 1. +func houseEdgeGame(t *testing.T, roomYAML string) (*Game, *net.Session, *casinoTestConn) { + t.Helper() + dir := t.TempDir() + for _, name := range []string{"rooms", "objects", "help", "items", "mobs", "recipes"} { + source := filepath.Join("../../data", name) + entries, err := os.ReadDir(source) + if err != nil { + continue // data subdirectory not shipped in this repository state + } else if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(source, entry.Name())) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name, entry.Name()), data, 0644); err != nil { + t.Fatal(err) + } + } + } + return houseEdgeGameIn(t, dir, roomYAML) +} + +func houseEdgeGameIn(t *testing.T, dir, roomYAML string) (*Game, *net.Session, *casinoTestConn) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(roomYAML), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0o755); err != nil { + t.Fatal(err) + } + p := player.New("alice") + sess := &net.Session{Player: p, Conn: &casinoTestConn{}} + g := &Game{ + Deps: Deps{ + World: world.New(dir), ObjectStore: object.NewObjectStore(dir), + MobStore: world.NewMobStore(dir), AccountStore: player.NewAccountStore(dir), + DataDir: dir, ItemStore: item.NewItemStore(dir), + }, + Casino: casino.NewManager(1), + Combat: combat.NewTracker(), + safespot: NewSafespotManager(), + restTimers: make(map[string]uint64), + loggedInChars: map[string]*net.Session{"alice": sess}, + } + p.Credits = 100 + p.RoomID = 1 + return g, sess, sess.Conn.(*casinoTestConn) +} + +// writeHouseEdgeManual writes the named reference item into the copied data +// directory, reloads the store and places one in the player's inventory. +func writeHouseEdgeManual(t *testing.T, g *Game, sess *net.Session, precision int) { + t.Helper() + if err := os.MkdirAll(filepath.Join(g.Deps.DataDir, "items"), 0o755); err != nil { + t.Fatal(err) + } + yaml := []byte("name: edge reference\nhouse_edge_precision: " + strconv.Itoa(precision) + "\nvalue: 0\n") + if err := os.WriteFile(filepath.Join(g.Deps.DataDir, "items", "edge_reference.yaml"), yaml, 0644); err != nil { + t.Fatal(err) + } + g.ItemStore.Reload(g.Deps.DataDir) + sess.Player.Inventory = map[int]*player.InventorySlot{0: {ItemID: "edge_reference", Quantity: 1}} +} + +var rtpCellRE = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?%$`) + +// The RTP column carries only percentages: no notes, labels or explanations. +func assertRTPColumn(t *testing.T, text string) { + t.Helper() + if !strings.Contains(text, "RTP") { + t.Fatalf("no RTP column:\n%s", text) + } + for _, line := range strings.Split(text, "\n") { + if !strings.Contains(line, "│") { + continue + } + fields := strings.Split(line, "│") + cell := strings.TrimSpace(fields[len(fields)-1]) + cell = strings.TrimSpace(strings.Trim(cell, "║")) + if cell == "" || cell == "RTP" || cell == "—" { + continue + } + if !rtpCellRE.MatchString(cell) { + t.Fatalf("RTP cell is not a bare percentage: %q in %q", cell, line) + } + } +} + +const roomRoulette = "name: RT\ncasino_tables:\n - id: rt\n game: roulette\n" +const roomCraps = "name: CR\ncasino_tables:\n - id: cr\n game: craps\n" +const roomSlots = "name: MB\ncasino_machines:\n - id: m1\n game: slots\n" + +// Merely joining must never display the item's readout. +func TestHouseEdgeJoinSilentTable(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomRoulette) + writeHouseEdgeManual(t, g, sess, 0) + g.executePlay(sess, []string{"roulette"}, "play roulette") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "RTP") { + t.Fatalf("join displayed an edge:\n%s", text) + } + conn.data = nil + g.executeList(sess, nil, "list") + text = stripANSI(string(conn.data)) + if strings.Count(text, "97%") != 12 { + t.Fatalf("list missing the twelve roulette RTPs:\n%s", text) + } + assertRTPColumn(t, text) +} + +// TestHouseEdgeListAnnotatesEveryRow: the list command carries the same +// figures on each row while the state-based list is up. +func TestHouseEdgeListAnnotatesEveryRow(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomCraps) + writeHouseEdgeManual(t, g, sess, 0) + g.executePlay(sess, []string{"craps"}, "play craps") + g.executeBet(sess, []string{"10", "pass"}, "bet 10 pass") + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if strings.Count(text, "│") < 8 { + t.Fatalf("list did not annotate the wagers:\n%s", text) + } + assertRTPColumn(t, text) +} + +// Paytable is not list and must not carry the item's readout. +func TestHouseEdgePaytableSilentVideoPoker(t *testing.T) { + g, sess, conn := houseEdgeGame(t, "name: VP\ncasino_tables:\n - id: vp\n game: video_poker\n") + writeHouseEdgeManual(t, g, sess, 2) + g.executePlay(sess, []string{"vp"}, "play vp") + conn.data = nil + g.executeTableAction(sess, "paytable") + if text := stripANSI(string(conn.data)); strings.Contains(text, "RTP") { + t.Fatalf("paytable displayed the edge:\n%s", text) + } + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "99.54%") { + t.Fatalf("video poker list missing RTP: %s", text) + } + assertRTPColumn(t, text) +} + +// Slots are silent on join and show two-decimal precision on list. +func TestHouseEdgeMachineJoinSilentListPrecision(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomSlots) + writeHouseEdgeManual(t, g, sess, 2) + g.executePlay(sess, []string{"slots"}, "play slots") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "RTP") { + t.Fatalf("machine join displayed edge: %s", text) + } + conn.data = nil + g.executeList(sess, nil, "list") + text = stripANSI(string(conn.data)) + if !strings.Contains(text, "97.00%") { + t.Fatalf("machine list missing precision: %s", text) + } + assertRTPColumn(t, text) +} + +// TestHouseEdgeAbsentSilentWithoutItem pins the privacy contract: no item, +// no figures, not even on the same screens. +func TestHouseEdgeAbsentSilentWithoutItem(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomRoulette) + g.executePlay(sess, []string{"roulette"}, "play roulette") + g.executeList(sess, nil, "list") + if text := stripANSI(string(conn.data)); strings.Contains(text, "RTP") { + t.Fatalf("edge shown without any item:\n%s", text) + } +} + +// TestHouseEdgeMachineList: the machine's one-line list also carries it. +func TestHouseEdgeMachineList(t *testing.T) { + g, sess, conn := houseEdgeGame(t, roomSlots) + writeHouseEdgeManual(t, g, sess, 0) + g.executePlay(sess, []string{"m1"}, "play m1") + conn.data = nil + g.executeList(sess, nil, "list") + if text := stripANSI(string(conn.data)); !strings.Contains(text, "RTP") { + t.Fatalf("machine list missing the edge:\n%s", text) + } +} diff --git a/internal/game/render_baccarat_felt.go b/internal/game/render_baccarat_felt.go index 84f91e3..4757764 100644 --- a/internal/game/render_baccarat_felt.go +++ b/internal/game/render_baccarat_felt.go @@ -67,7 +67,7 @@ func renderBaccaratRulesFelt(cfg casino.TableConfig, mode string, unicode bool) "PLAYER: DRAWS 0-5; STANDS 6-7. BANKER VS A STANDING PLAYER: DRAWS 0-5.", "BANKER VS A DRAWING PLAYER: 0-2 ALWAYS DRAWS; 3 DRAWS UNLESS VS 8;", "4 DRAWS VS 2-7; 5 DRAWS VS 4-7; 6 DRAWS VS 6-7; 7 STANDS.", - "ONE WAGER PER PLAYER PER ROUND; SIDE BETS SETTLE INDEPENDENTLY.", + "ONE MAIN BET (PLAYER/BANKER/TIE) PLUS SIDE BETS; SIDE BETS FIRST.", } for i, line := range draw { c.text(3, 17+i, line, crapsInk) diff --git a/internal/game/render_baccarat_felt_test.go b/internal/game/render_baccarat_felt_test.go index dabaa36..a3bb01c 100644 --- a/internal/game/render_baccarat_felt_test.go +++ b/internal/game/render_baccarat_felt_test.go @@ -30,7 +30,7 @@ func TestBaccaratRulesFeltGeometryAndPhrases(t *testing.T) { "DRAGON 7", "PANDA 8", "SUPER 6", "NOT OFFERED", "DECKS", "SHUFFLE AT", "BET LIMITS", "1-100 CREDITS", "35 TICKS", "DRAWING — AUTOMATIC PUNTO BANCO", "DRAWS 0-5", "6 DRAWS VS 6-7", - "ONE WAGER PER PLAYER PER ROUND", + "ONE MAIN BET (PLAYER/BANKER/TIE) PLUS SIDE BETS", } { if !strings.Contains(art, want) { t.Fatalf("missing %q in unicode=%v felt", want, unicode) diff --git a/internal/game/video_poker_test.go b/internal/game/video_poker_test.go index f2ea43f..8ec44d7 100644 --- a/internal/game/video_poker_test.go +++ b/internal/game/video_poker_test.go @@ -73,7 +73,7 @@ func TestBlackjackListUsesRealActionCommands(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) - if strings.Contains(text, "bet <amount> on") || !strings.Contains(text, "Available actions") { + if strings.Contains(text, "bet <amount> on") || len(listTargets(text)) == 0 { t.Fatal(text) } } |
