aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go6
-rw-r--r--internal/game/casino_edge_list_test.go97
-rw-r--r--internal/game/casino_house_edge.go190
-rw-r--r--internal/game/casino_rules_list_test.go84
-rw-r--r--internal/game/casino_ux.go41
-rw-r--r--internal/game/house_edge_test.go214
-rw-r--r--internal/game/render_baccarat_felt.go2
-rw-r--r--internal/game/render_baccarat_felt_test.go2
-rw-r--r--internal/game/video_poker_test.go2
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)
}
}