diff options
Diffstat (limited to 'internal/game/house_edge_test.go')
| -rw-r--r-- | internal/game/house_edge_test.go | 214 |
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) + } +} |
