package game import ( "os" "path/filepath" "strings" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // quitTestGame builds the minimal live state quit and stop need: a casino // manager, a combat tracker, a tick engine for the rest countdown, and a // wallet backed by the account store so wagers can commit. func quitTestGame(t *testing.T) (*Game, *net.Session) { t.Helper() dataDir := t.TempDir() // Character saves (wallet persistence) need the player directory. if err := os.MkdirAll(filepath.Join(dataDir, "players", "characters"), 0o755); err != nil { t.Fatal(err) } sess := &net.Session{Player: player.New("alice"), Conn: &casinoTestConn{}} g := &Game{ Deps: Deps{AccountStore: player.NewAccountStore(dataDir), Ticks: engine.New()}, Casino: casino.NewManager(1), Combat: combat.NewTracker(), safespot: NewSafespotManager(), loggedInChars: map[string]*net.Session{"alice": sess}, restTimers: make(map[string]uint64), } sess.Player.Credits = 1000 return g, sess } func (g *Game) quitResting() bool { _, ok := g.restTimers["alice"] return ok } // A wager committed to the round must block a deliberate quit: the player // stays seated with the stake intact and the quit countdown never starts. func TestQuitBlockedMidRoundKeepsWager(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") // second seat opens the betting window conn := sess.Conn.(*casinoTestConn) if events := g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player}); len(events) == 0 { t.Fatal("bet produced no events") } conn.data = nil g.doQuit(sess) if !strings.Contains(string(conn.data), "middle of a blackjack round") { t.Fatalf("expected mid-round refusal, got %q", string(conn.data)) } if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("quit mid-round removed the player from the table") } if g.quitResting() { t.Fatal("blocked quit started the rest countdown") } // The stake is still committed to the round, not refunded. if events := g.Casino.Leave(1, "blackjack", "alice"); len(events) == 0 || !strings.Contains(events[0].Message, "middle of a blackjack round") { t.Fatalf("wager was not still committed after the blocked quit: %+v", events) } } // Baccarat blocks a mid-round quit the same way. func TestQuitBlockedMidRoundBaccarat(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.TableConfig{ID: "baccarat", Game: casino.GameBaccarat, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") // second seat opens the betting window conn := sess.Conn.(*casinoTestConn) g.Casino.Bet(1, "baccarat", "alice", "player", 10, casinoWallet{g: g, p: sess.Player}) conn.data = nil g.doQuit(sess) if !strings.Contains(string(conn.data), "middle of a baccarat round") { t.Fatalf("expected mid-round refusal, got %q", string(conn.data)) } if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("quit mid-round removed the player from the baccarat table") } if g.quitResting() { t.Fatal("blocked quit started the rest countdown") } } // With no wager committed, quit leaves the table gracefully (the same // stand-up the table emits for "stop") and starts the rest countdown. func TestQuitLeavesTableBetweenRounds(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") conn := sess.Conn.(*casinoTestConn) g.doQuit(sess) if !strings.Contains(string(conn.data), "You stand up.") { t.Fatalf("expected a graceful stand-up, got %q", string(conn.data)) } if _, playing := g.Casino.Participation("alice"); playing { t.Fatal("quit did not free the table seat") } if !g.quitResting() { t.Fatal("quit did not start the rest countdown") } } // Quit at a slot machine stops any auto-bet (or leaves the machine) and // starts the rest countdown; it never force-disconnects the player. func TestQuitLeavesMachine(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1, MaxBet: 100} g.Casino.JoinMachine(1, cfg, "alice") conn := sess.Conn.(*casinoTestConn) g.doQuit(sess) if _, playing := g.Casino.Participation("alice"); playing { t.Fatal("quit did not free the machine") } if !g.quitResting() { t.Fatal("quit did not start the rest countdown") } if strings.Contains(string(conn.data), "WARNING") { t.Fatalf("quit at a machine must not warn or forfeit: %q", string(conn.data)) } } // "stop" is an instant command, so it must cancel a pending quit countdown // explicitly. func TestStopCancelsQuit(t *testing.T) { g, sess := quitTestGame(t) g.restTimers["alice"] = 42 g.executeStop(sess, nil, "stop") if g.quitResting() { t.Fatal("stop did not cancel the pending quit") } } // Plain quit mid-round points the player at "quit now" and its forfeit. func TestQuitMidRoundMentionsQuitNow(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player}) conn := sess.Conn.(*casinoTestConn) conn.data = nil g.doQuit(sess) if !strings.Contains(string(conn.data), `"quit now"`) || !strings.Contains(string(conn.data), "forfeit") { t.Fatalf("expected a quit-now reminder with the forfeit warning, got %q", string(conn.data)) } } // "quit now" starts the logout countdown while the player keeps their seat // and wager; the stake is forfeited and the seat freed only on the final // tick, immediately before the disconnect. The round must be IN FLIGHT for // that: a stake placed in the betting window has had no card dealt for it and // is refunded on disconnect (see markDisconnected). func TestQuitNowForfeitsOnlyAtLogout(t *testing.T) { g, sess := quitTestGame(t) sess.Account = &player.Account{Name: "alice"} cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player}) // Bob bets too so the round actually starts and alice holds a dealt hand. bobWallet := player.New("bob") bobWallet.Credits = 1000 g.Casino.Bet(1, "blackjack", "bob", "", 10, casinoWallet{g: g, p: bobWallet}) wallet := sess.Player g.doQuitNow(sess) // Still seated, still committed, countdown running. if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("quit now freed the seat before the logout tick") } if !g.quitResting() { t.Fatal("quit now did not start the rest countdown") } // Drive the countdown to its final tick (fires on the 11th engine tick). for i := 0; i < 15 && sess.Player != nil; i++ { g.Ticks.Tick() } if _, playing := g.Casino.Participation("alice"); playing { t.Fatal("quit now did not free the seat at the logout tick") } // Alice's stake was debited when she bet and never refunded or paid: the // forfeit. Bob remains seated with his round untouched. if wallet.Credits != 990 { t.Fatalf("alice's credits were disturbed by the forfeit: %d", wallet.Credits) } if _, playing := g.Casino.Participation("bob"); !playing { t.Fatal("quit now removed the wrong player") } if sess.Player != nil || sess.State() != net.StateMenu { t.Fatal("quit now did not log the player out") } if _, stillLoggedIn := g.loggedInChars["alice"]; stillLoggedIn { t.Fatal("quit now did not remove the session from the logged-in map") } if g.quitResting() { t.Fatal("countdown timer was not cleared after logout") } } // Until the logout tick, "stop" cancels a pending quit-now and leaves the // wager intact: the player keeps their seat mid-round. func TestQuitNowCancelledByStopMidRound(t *testing.T) { g, sess := quitTestGame(t) cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player}) g.doQuitNow(sess) g.executeStop(sess, nil, "stop") if g.quitResting() { t.Fatal("stop did not cancel the pending quit-now countdown") } if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("cancelled quit-now still freed the seat") } // The wager is still committed after the cancellation. if events := g.Casino.Leave(1, "blackjack", "alice"); len(events) == 0 || !strings.Contains(events[0].Message, "middle of a blackjack round") { t.Fatalf("wager was not still committed after the cancelled quit-now: %+v", events) } } // A quit-now at a machine also holds the machine until the logout tick. func TestQuitNowAtMachineHoldsUntilLogout(t *testing.T) { g, sess := quitTestGame(t) sess.Account = &player.Account{Name: "alice"} cfg := casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1, MaxBet: 100} g.Casino.JoinMachine(1, cfg, "alice") g.doQuitNow(sess) if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("quit now freed the machine before the logout tick") } for i := 0; i < 15 && sess.Player != nil; i++ { g.Ticks.Tick() } if _, playing := g.Casino.Participation("alice"); playing { t.Fatal("quit now did not free the machine at the logout tick") } if sess.Player != nil || sess.State() != net.StateMenu { t.Fatal("quit now did not log the player out") } } // Stop and quit share one departure path: both refuse mid-round and both // leave gracefully between rounds. func TestStopMatchesQuitCasinoPolicy(t *testing.T) { cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} // Mid-round: stop refuses just like quit. g, sess := quitTestGame(t) g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player}) g.restTimers["alice"] = 42 conn := sess.Conn.(*casinoTestConn) conn.data = nil g.executeStop(sess, nil, "stop") if !strings.Contains(string(conn.data), "middle of a blackjack round") { t.Fatalf("expected mid-round refusal, got %q", string(conn.data)) } if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("stop mid-round removed the player from the table") } // Between rounds: stop stands the player up. g2, sess2 := quitTestGame(t) g2.Casino.Join(1, cfg, "alice") conn2 := sess2.Conn.(*casinoTestConn) g2.executeStop(sess2, nil, "stop") if !strings.Contains(string(conn2.data), "You stand up.") { t.Fatalf("expected a graceful stand-up, got %q", string(conn2.data)) } if _, playing := g2.Casino.Participation("alice"); playing { t.Fatal("stop did not free the table seat") } }