package game import ( "os" "path/filepath" "regexp" "strings" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func stripANSI(s string) string { return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "") } func uxCasinoGame(t *testing.T) (*Game, *net.Session, *casinoTestConn) { t.Helper() dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { t.Fatal(err) } room := "name: Casino\ncasino_tables:\n - id: bj1\n game: blackjack\n min_bet: 1\n max_bet: 100\n blackjack:\n action_ticks: 25\n - id: bac1\n game: baccarat\n min_bet: 5\n max_bet: 100\n" if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 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), AccountStore: player.NewAccountStore(dir), Ticks: engine.New()}, 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) } func TestPayoutCommandRemoved(t *testing.T) { if class := classifyCommand("payout"); class != ClassUnknown { t.Fatalf("payout is still registered as %v", class) } } func TestPlaySoleChoiceNeedsNoName(t *testing.T) { g, sess, _ := casinoCrapsGame(t, "name: Solo\ncasino_tables:\n - id: solo\n game: blackjack\n min_bet: 1\n max_bet: 100\n") g.executePlay(sess, nil, "play") table, ok := g.Casino.Participation("alice") if !ok || table == nil || table.Config.ID != "solo" { t.Fatal("play with a sole choice did not seat the player") } } func TestVideoPokerConciseAliases(t *testing.T) { room := "name: Parlor\ncasino_tables:\n" + "- id: vp\n game: video_poker\n max_players: 1\n video_poker: {cabinet: classic, payout: low}\n" + "- id: ux\n game: video_poker\n max_players: 1\n video_poker: {cabinet: ultimate_x_single, payout: low}\n" + "- id: uxm\n game: video_poker\n max_players: 1\n video_poker: {cabinet: ultimate_x_multi, payout: low}\n" + "- id: stp\n game: video_poker\n max_players: 1\n video_poker: {cabinet: super_times_pay, payout: low}\n" + "- id: hr\n game: video_poker\n max_players: 1\n video_poker: {cabinet: hot_roll, payout: low}\n" g, _, _ := casinoCrapsGame(t, room) choices := g.casinoChoices(1) for input, want := range map[string]string{ "vp": "vp", "poker": "vp", "classic": "vp", "video_poker": "vp", "ux": "ux", "ultimate_x": "ux", "ultimate_x_single": "ux", "uxm": "uxm", "ultimate_x_multi": "uxm", "ultimate_x_multi_hand": "uxm", "stp": "stp", "super_times": "stp", "super_times_pay": "stp", "hr": "hr", "hot_roll": "hr", } { choice, ok := resolveCasinoChoice(choices, input) if !ok || choice.address != want { t.Fatalf("play %s resolved to %q (ok=%v), want %q", input, choice.address, ok, want) } } } func TestCasinoCommandAbbreviations(t *testing.T) { g, sess, _ := uxCasinoGame(t) // Unseated players get no casino shortcuts. if got, ok := g.casinoAbbreviation(sess, "b"); ok { t.Fatalf("unseated abbreviation resolved to %q", got) } g.executePlay(sess, []string{"bj"}, "play bj") for input, want := range map[string]string{ "b": "bet", "be": "bet", "spl": "split", "su": "surrender", "ins": "insurance", "ro": "roll", "wa": "watch", "ta": "table", "li": "list", "ga": "game", } { got, ok := g.casinoAbbreviation(sess, input) if !ok || got != want { t.Fatalf("%q resolved to %q (ok=%v), want %q", input, got, ok, want) } } // stop and rules are full-word only, and movement keeps its own keys. for _, input := range []string{"sto", "r", "ru", "n", "s"} { if got, ok := g.casinoAbbreviation(sess, input); ok { t.Fatalf("%q unexpectedly abbreviated to %q", input, got) } } } func TestAbbreviatedCasinoCommandReachesQueue(t *testing.T) { g, sess, _ := uxCasinoGame(t) g.queue = NewCommandQueue() g.executePlay(sess, []string{"bj", "10"}, "play bj 10") g.handleGameCommand(sess, "b 25") qc, ok := g.queue.PeekActive("alice") if !ok || qc.Command != "bet" || qc.Args != "25" { t.Fatalf("b 25 queued %+v (ok=%v), want bet 25", qc, ok) } } func TestPlayAliasesJoinGames(t *testing.T) { for input, game := range map[string]string{"bj": "blackjack", "black": "blackjack", "bac": "baccarat", "baccarat": "baccarat"} { g, sess, conn := uxCasinoGame(t) g.executePlay(sess, strings.Fields(input), "play "+input) table, ok := g.Casino.Participation("alice") if !ok || table == nil || string(table.Config.Game) != game { t.Fatalf("play %s: participation=%v game=%q", input, ok, game) } if !strings.Contains(stripANSI(string(conn.data)), "Available ") { t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data))) } } } func TestPlayAmbiguousAndUnknownListChoices(t *testing.T) { g, sess, conn := uxCasinoGame(t) _ = g for _, input := range []string{"", "b", "zzz"} { conn.data = nil args := []string{} if input != "" { args = strings.Fields("play " + input)[1:] } g.executePlay(sess, args, "play "+input) text := stripANSI(string(conn.data)) if !strings.Contains(text, "play blackjack") || !strings.Contains(text, "play baccarat") { t.Fatalf("play %q: expected choice list, got %q", input, text) } } } func TestPlayWithAmountJoinsAndBets(t *testing.T) { g, sess, conn := uxCasinoGame(t) g.executePlay(sess, []string{"bj", "25"}, "play bj 25") if !strings.Contains(stripANSI(string(conn.data)), "You bet 25 credits.") { t.Fatalf("missing wager confirmation: %q", stripANSI(string(conn.data))) } } func TestTableStatusShowsStateAndRules(t *testing.T) { g, sess, conn := uxCasinoGame(t) g.executePlay(sess, []string{"bj"}, "play bj") g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) if !strings.Contains(text, "LIMITS") || !strings.Contains(text, "Waiting for bets") { t.Fatalf("status missing overview: %q", text) } conn.data = nil g.executeTableStatus(sess, []string{"rules"}, "table rules") if !strings.Contains(stripANSI(string(conn.data)), "BLACKJACK PAYS") { t.Fatalf("table rules missing: %q", stripANSI(string(conn.data))) } } func TestWatchAndSpectatorDetailFlow(t *testing.T) { g, sess, conn := uxCasinoGame(t) g.executePlay(sess, []string{"bj", "10"}, "play bj 10") // Alice, seated, sees the deal; watch off is still acknowledged. if _, seated := g.Casino.Participation("alice"); !seated { t.Fatal("alice not seated") } g.executeWatch(sess, []string{"off"}, "watch off") if !strings.Contains(stripANSI(string(conn.data)), "stop watching") { t.Fatalf("watch off not confirmed: %q", stripANSI(string(conn.data))) } } func TestBlackjackShortcutsReachTheTable(t *testing.T) { g, sess, conn := uxCasinoGame(t) cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100, Blackjack: casino.BlackjackConfig{ActionTicks: 3}} g.Casino.Join(1, cfg, "alice") // Rounds deal instantly with a lone bettor; dealer aces and naturals vary // by seed, so repeat short rounds until a hit actually lands. hit := false for round := 0; round < 12 && !hit; round++ { g.executeBet(sess, nil, "rebet") g.executeTableAction(sess, "decline") for attempt := 0; attempt < 3 && !hit; attempt++ { g.executeTableAction(sess, "h") hit = strings.Contains(stripANSI(string(conn.data)), "You hit") } g.executeTableAction(sess, "st") } if _, seated := g.Casino.Participation("alice"); !seated { t.Fatal("shortcut actions should never remove the seat") } if !hit { t.Fatalf("shortcuts did not hit: %q", stripANSI(string(conn.data))) } } func TestRebetRepeatsWager(t *testing.T) { g, sess, conn := uxCasinoGame(t) cfg := casino.TableConfig{ID: "bac", Game: casino.GameBaccarat, MinBet: 5, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Bet(1, "bac", "alice", "banker", 20, casinoWallet{g: g, p: sess.Player}) conn.data = nil // The first round deals and settles instantly with a single bettor, so a // fresh betting window opens; rebet should repeat 20 on banker. g.executeBet(sess, nil, "rebet") if !strings.Contains(stripANSI(string(conn.data)), "on banker") { t.Fatalf("rebet did not target banker: %q", stripANSI(string(conn.data))) } }