aboutsummaryrefslogtreecommitdiff
path: root/internal
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
parentd89504c3df74706b387b8fa829dd291e31ca01c7 (diff)
downloadthehouseoficarus-69fb90f6876d7f7dbe7be0d515c94849e5431caf.tar.gz
cmd_quit gracefully departs casino games; cmd_stop cancels a pending quit
Diffstat (limited to 'internal')
-rw-r--r--internal/game/casino.go28
-rw-r--r--internal/game/cmd_quit.go13
-rw-r--r--internal/game/cmd_stop.go15
-rw-r--r--internal/game/quit_casino_test.go185
4 files changed, 226 insertions, 15 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 7442178..3edb26b 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -94,6 +94,34 @@ func (w casinoWallet) PayCredits(amount int) error {
// then credits.
func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }
+// leaveCasinoGames departs every casino game the player participates in
+// using the graceful leave policy each game defines for player-initiated
+// exits (the same path the "stop" command takes): a table with a wager
+// committed to the round in flight refuses and emits its own refusal
+// message, machines stop auto-bet or are left outright. It reports whether
+// the player is now clear of all casino games; false means a departure is
+// blocked mid-round and the caller (quit) must abort. Nil-safe for test
+// scaffolding without a Casino manager.
+func (g *Game) leaveCasinoGames(sess *net.Session, p *player.Player) bool {
+ if g.Casino == nil {
+ return true
+ }
+ if table, playing := g.Casino.Participation(p.Name); playing {
+ if table != nil {
+ g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name))
+ } else if machine, ok := g.Casino.MachineFor(p.Name); ok {
+ if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped {
+ g.emitCasinoEvents(events)
+ } else {
+ g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
+ }
+ }
+ _, stillPlaying := g.Casino.Participation(p.Name)
+ return !stillPlaying
+ }
+ return true
+}
+
// inCasinoGame reports whether the player is currently seated at any casino
// table or machine. Nil-safe for test scaffolding without a Casino manager.
func (g *Game) inCasinoGame(name string) bool {
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 80ad351..24519f8 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -12,16 +12,19 @@ func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) {
func (g *Game) doQuit(sess *net.Session) {
p := sess.Player
- // Combat blocks quitting outright; check it before forfeiting any casino
- // wager so a cancelled quit never costs the player their round.
+ // Combat blocks quitting outright; check it before departing any casino
+ // game so a cancelled quit never costs the player their round.
if g.Combat.Get(p.Name) != nil {
sess.WriteLine("You can't rest during combat!")
return
}
- if _, playing := g.Casino.Participation(p.Name); playing {
- sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!"))
- g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name))
+ // Depart casino games the way "stop" does: a table with a wager committed
+ // to the round in flight refuses and the quit is aborted, so deliberate
+ // exits never forfeit a stake or reveal cards mid-hand. Forfeit-on-leave
+ // stays reserved for genuine disconnects (hub.OnDisconnect).
+ if !g.leaveCasinoGames(sess, p) {
+ return
}
g.cancelAction(p)
diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go
index 5c347e6..b05e1fc 100644
--- a/internal/game/cmd_stop.go
+++ b/internal/game/cmd_stop.go
@@ -6,16 +6,11 @@ import (
func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) {
p := sess.Player
- if table, playing := g.Casino.Participation(p.Name); playing {
- if table != nil {
- g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name))
- } else if machine, ok := g.Casino.MachineFor(p.Name); ok {
- if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped {
- g.emitCasinoEvents(events)
- return
- }
- g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
- }
+ // "stop" is an instant command, so it executes outside the command
+ // queue; cancel a pending quit countdown explicitly.
+ g.cancelRest(p.Name)
+ if _, playing := g.Casino.Participation(p.Name); playing {
+ g.leaveCasinoGames(sess, p)
return
}
ss, _ := g.safespot.Get(p.Name)
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")
+ }
+}