package game import ( "os" "path/filepath" "strings" "testing" "gopkg.in/yaml.v3" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) // casinoCrapsGame builds a one-room world with the named craps edition and // points the help loader at the shipped data dir, so the rules command and // the shipped-topic test exercise the real help pages. func casinoCrapsGame(t *testing.T, roomYAML string) (*Game, *net.Session, *casinoTestConn) { t.Helper() dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); 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), Ticks: engine.New(), DataDir: "../../data"}, 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) } // TestRulesAtTableShowsVariantHelpPage pins the "rules" contract: seated at // Simplified Craps, rules must open the variant's own short page — not the // whole craps manual with the edition buried in a section. func TestRulesAtTableShowsVariantHelpPage(t *testing.T) { room := "name: Simplified Salon\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.executeRules(sess, nil, "rules") text := stripANSI(string(conn.data)) if !strings.Contains(text, "Simplified Craps") { t.Fatalf("rules missing the variant page: %q", text) } if strings.Contains(text, "HOUSE EDITIONS") || strings.Contains(text, "bet 10 on dontcome") { t.Fatalf("rules leaked the bank manual into the variant page: %q", text) } } // TestRulesStandingInCasinoRoomAnswersRoomGame checks the unseated fallback: // standing in a one-table casino room answers that game's page. func TestRulesStandingInCasinoRoomAnswersRoomGame(t *testing.T) { room := "name: Crapless Salon\ncasino_tables:\n - id: cl\n game: craps\n craps:\n variant: crapless\n" g, sess, conn := casinoCrapsGame(t, room) g.executeRules(sess, nil, "rules") if text := stripANSI(string(conn.data)); !strings.Contains(text, "Crapless Craps") { t.Fatalf("standing rules did not answer the room's game: %q", text) } } // TestRulesOutsideCasinoFallsBackToGuide checks rooms with no casino content. func TestRulesOutsideCasinoFallsBackToGuide(t *testing.T) { g, sess, conn := casinoCrapsGame(t, "name: Void\n") g.executeRules(sess, nil, "rules") if text := stripANSI(string(conn.data)); !strings.Contains(text, "Casino quick guide") { t.Fatalf("rules outside a casino: %q", text) } } // 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) { room := "name: Bank Pit\ncasino_tables:\n - id: bank\n game: craps\n craps:\n variant: bank\n" g, sess, conn := casinoCrapsGame(t, room) 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)) 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 targets[gone] { t.Fatalf("come-out list advertised %q: %q", gone, text) } } // Establish a point: place/odds appear, bonus bets close. g.Casino.Action(1, "bank", "alice", "roll") conn.data = nil g.executeList(sess, nil, "list") text = stripANSI(string(conn.data)) targets = listTargets(text) if !targets["place 6"] || !targets["odds pass"] { t.Fatalf("point list missing place/odds: %q", text) } if targets["fire"] { t.Fatalf("point list still advertising closed bets: %q", text) } } // 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, "Odds") { t.Fatalf("single-bet list did not render the table header: %q", text) } if targets := listTargets(text); !targets["pass"] { t.Fatalf("single-bet table missing the wager: %q", text) } // 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") text = stripANSI(string(conn.data)) if targets := listTargets(text); !targets["bet "] { t.Fatalf("blackjack list: %q", text) } } // TestListAtShopKeepsShopMeaning guards the shop fallthrough: an explicit // shopmob argument always browses the shop. func TestListAtShopKeepsShopMeaning(t *testing.T) { g, sess, _ := casinoCrapsGame(t, "name: Void\n") g.executeList(sess, []string{"shopkeeper"}, "list shopkeeper") if text := stripANSI(string(sess.Conn.(*casinoTestConn).data)); !strings.Contains(text, "no shop") && !strings.Contains(text, "shop") { t.Fatalf("explicit list should reach the shop path: %q", text) } } // TestShippedCasinoHelpTopicsResolve walks every shipped casino table and // machine and pins that its rules help topic actually resolves in data/help. func TestShippedCasinoHelpTopicsResolve(t *testing.T) { dataDir := filepath.Join("..", "..", "data") helps, err := LoadHelp(dataDir) if err != nil { t.Fatal(err) } resolves := func(topic string) bool { for _, h := range helps { if matchesHelpTopic(h, topic) { return true } } return false } roomsDir := filepath.Join(dataDir, "rooms") err = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".yaml") { return err } raw, err := os.ReadFile(path) if err != nil { return err } var room world.Room if err := yaml.Unmarshal(raw, &room); err != nil { return err } for _, cfg := range room.CasinoTables { topic := cfg.HelpTopic() if !resolves(topic) { t.Errorf("%s: table %s (%s) help topic %q does not resolve", path, cfg.ID, cfg.Game, topic) } } for _, m := range room.CasinoMachines { if !resolves(string(m.Game)) { t.Errorf("%s: machine %s game %q does not resolve", path, m.ID, m.Game) } } return nil }) if err != nil { t.Fatal(err) } } // TestRulesAtMachineShowsSlotsHelp: a slot machine player typing rules gets // the cabinet's own guide, not a dead end. func TestRulesAtMachineShowsSlotsHelp(t *testing.T) { room := "name: Slots Salon\ncasino_machines:\n - id: puff\n game: slots\n" g, sess, conn := casinoCrapsGame(t, room) g.executePlay(sess, []string{"slots"}, "play slots") conn.data = nil g.executeRules(sess, nil, "rules") if text := stripANSI(string(conn.data)); !strings.Contains(text, "Slots quick guide") { t.Fatalf("machine rules: %q", text) } } // TestListRouletteAndBaccarat: roulette lists the whole target catalogue and // baccarat lists every outcome and side bet its variant offers. func TestListRouletteAndBaccarat(t *testing.T) { room := "name: Wheel Salon\ncasino_tables:\n - id: wheel\n game: roulette\n roulette:\n racetrack: true\n" g, sess, conn := casinoCrapsGame(t, room) g.executePlay(sess, []string{"roulette"}, "play roulette") conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) targets := listTargets(text) for _, want := range []string{"", "red / black", "voisins", "basket"} { if !targets[want] { t.Fatalf("roulette list missing %q: %q", want, text) } } room = "name: Crown Salon\ncasino_tables:\n - id: crown\n game: baccarat\n baccarat:\n variant: ez_baccarat\n dragon_7: true\n panda_8: true\n" g, sess, conn = casinoCrapsGame(t, room) g.executePlay(sess, []string{"bac"}, "play bac") conn.data = nil g.executeList(sess, nil, "list") text = stripANSI(string(conn.data)) 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) } }