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) } }