aboutsummaryrefslogtreecommitdiff
path: root/internal/game/house_edge_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/house_edge_test.go')
-rw-r--r--internal/game/house_edge_test.go214
1 files changed, 214 insertions, 0 deletions
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)
+ }
+}