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", "sp": "split", "su": "surrender", "ins": "insurance", "wa": "watch", "ta": "table", "li": "list", "ga": "game", "sto": "stop", "ru": "rules", // The terse aliases resolve only because their verb is a blackjack verb. "h": "hit", "st": "stand", "dd": "double", } { got, ok := g.casinoAbbreviation(sess, input) if !ok || got != want { t.Fatalf("%q resolved to %q (ok=%v), want %q", input, got, ok, want) } } // No other game's verbs leak into a blackjack table. for _, input := range []string{"ro", "rol", "ra", "au", "dr", "pa", "ha", "ho"} { if got, ok := g.casinoAbbreviation(sess, input); ok { t.Fatalf("blackjack %q unexpectedly abbreviated to %q", input, got) } } // Exact commands keep their meaning: movement, info, and whole words that // are already real commands. for _, input := range []string{"n", "s", "e", "w", "i", "l", "hit"} { if got, ok := g.casinoAbbreviation(sess, input); ok { t.Fatalf("%q unexpectedly abbreviated to %q", input, got) } } } func TestCasinoAbbreviationsAreGameScoped(t *testing.T) { // Baccarat has neither split nor spin, so "sp" matches nothing there. g, sess, _ := uxCasinoGame(t) g.executePlay(sess, []string{"bac"}, "play bac") for _, input := range []string{"sp", "spl", "ro", "au"} { if got, ok := g.casinoAbbreviation(sess, input); ok { t.Fatalf("baccarat %q unexpectedly abbreviated to %q", input, got) } } if got, ok := g.casinoAbbreviation(sess, "be"); !ok || got != "bet" { t.Fatalf("baccarat be resolved to %q (ok=%v), want bet", got, ok) } // Craps owns roll/autoroll; a blackjack table must not resolve them. g2, sess2, _ := casinoCrapsGame(t, "name: Pit\ncasino_tables:\n - id: cr\n game: craps\n") g2.executePlay(sess2, []string{"craps"}, "play craps") if got, ok := g2.casinoAbbreviation(sess2, "ro"); !ok || got != "roll" { t.Fatalf("craps ro resolved to %q (ok=%v), want roll", got, ok) } if got, ok := g2.casinoAbbreviation(sess2, "rol"); !ok || got != "roll" { t.Fatalf("craps rol resolved to %q (ok=%v), want roll", got, ok) } if got, ok := g2.casinoAbbreviation(sess2, "au"); !ok || got != "autoroll" { t.Fatalf("craps au resolved to %q (ok=%v), want autoroll", got, ok) } for _, input := range []string{"sp", "spl", "ins", "su"} { if got, ok := g2.casinoAbbreviation(sess2, input); ok { t.Fatalf("craps %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") conn.data = nil g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) // The table command carries the blackjack felt and nothing else: no // balance, seat status, command menu, or rules reminder. if !strings.Contains(text, "LIMITS") || !strings.Contains(text, "BLACKJACK PAYS") { t.Fatalf("table felt missing: %q", text) } for _, clutter := range []string{"Available ", "Waiting for bets", "Learn to play", "stop", "rules"} { if strings.Contains(text, clutter) { t.Fatalf("table showed clutter %q: %q", clutter, text) } } } 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))) } } // TestCasinoTimeTextUsesTicks pins the presentation unit: authored "N ticks" // deadlines display as "Nt" rather than being rewritten into seconds. func TestCasinoTimeTextUsesTicks(t *testing.T) { g := &Game{} for in, want := range map[string]string{ "Your turn | 25 ticks remaining": "Your turn | 25t remaining", "Betting closes in 5 ticks.": "Betting closes in 5t.", "no deadline here": "no deadline here", } { if got := g.casinoTimeText(in); got != want { t.Fatalf("casinoTimeText(%q) = %q, want %q", in, got, want) } } }