aboutsummaryrefslogtreecommitdiff
path: root/internal/game/quit_casino_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 17:04:48 -0400
committerhistoria <[not public]>2026-09-15 17:04:48 -0400
commit69fb90f6876d7f7dbe7be0d515c94849e5431caf (patch)
tree15fb0f5897c537315d4283bb8265d28a13aae313 /internal/game/quit_casino_test.go
parentd89504c3df74706b387b8fa829dd291e31ca01c7 (diff)
downloadthehouseoficarus-69fb90f6876d7f7dbe7be0d515c94849e5431caf.tar.gz
cmd_quit gracefully departs casino games; cmd_stop cancels a pending quit
Diffstat (limited to 'internal/game/quit_casino_test.go')
-rw-r--r--internal/game/quit_casino_test.go185
1 files changed, 185 insertions, 0 deletions
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
new file mode 100644
index 0000000..a62e9cf
--- /dev/null
+++ b/internal/game/quit_casino_test.go
@@ -0,0 +1,185 @@
+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
+// durable wallet (economy journal + 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(),
+ Economy: newEconomyJournal(t.TempDir()),
+ 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(name string) bool {
+ _, ok := g.restTimers[name]
+ 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("alice") {
+ 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("alice") {
+ 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("alice") {
+ 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("alice") {
+ 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("alice") {
+ t.Fatal("stop did not cancel the pending quit")
+ }
+}
+
+// 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")
+ }
+}