diff options
| author | historia <[not public]> | 2026-09-15 17:04:48 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 17:04:48 -0400 |
| commit | 69fb90f6876d7f7dbe7be0d515c94849e5431caf (patch) | |
| tree | 15fb0f5897c537315d4283bb8265d28a13aae313 /internal | |
| parent | d89504c3df74706b387b8fa829dd291e31ca01c7 (diff) | |
| download | thehouseoficarus-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.go | 28 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 13 | ||||
| -rw-r--r-- | internal/game/cmd_stop.go | 15 | ||||
| -rw-r--r-- | internal/game/quit_casino_test.go | 185 |
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") + } +} |
