aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 17:55:57 -0400
committerhistoria <[not public]>2026-09-15 17:55:57 -0400
commitcd95de60364aba8f5bdf8ec86f7234a9903eacc8 (patch)
treef05785d477330bdf3bfe60e03ac33042842b87fb /internal
parent69fb90f6876d7f7dbe7be0d515c94849e5431caf (diff)
downloadthehouseoficarus-cd95de60364aba8f5bdf8ec86f7234a9903eacc8.tar.gz
feat: quit now allows quit in middle of casino game
Diffstat (limited to 'internal')
-rw-r--r--internal/game/cmd_quit.go39
-rw-r--r--internal/game/quit_casino_test.go112
-rw-r--r--internal/game/render_help.go2
3 files changed, 151 insertions, 2 deletions
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 24519f8..85e3510 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -1,11 +1,17 @@
package game
import (
+ "strings"
+
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
)
func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) {
+ if len(args) > 0 && strings.EqualFold(args[0], "now") {
+ g.doQuitNow(sess)
+ return
+ }
g.doQuit(sess)
}
@@ -22,11 +28,39 @@ func (g *Game) doQuit(sess *net.Session) {
// 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).
+ // stays reserved for genuine disconnects and "quit now".
if !g.leaveCasinoGames(sess, p) {
+ sess.WriteLine(g.colorize(sess, "warning", "Type \"quit now\" to log out anyway — your bet will be forfeited."))
+ return
+ }
+
+ g.beginQuitCountdown(sess)
+}
+
+// doQuitNow force-quits past a table's mid-round refusal. The logout
+// countdown starts immediately, but the seat and wager are only forfeited
+// on the countdown's final tick, immediately before the disconnect — until
+// then any active or free command (or "stop") cancels the countdown and
+// leaves the wager intact.
+func (g *Game) doQuitNow(sess *net.Session) {
+ p := sess.Player
+ if g.Combat.Get(p.Name) != nil {
+ sess.WriteLine("You can't rest during combat!")
return
}
+ if g.inCasinoGame(p.Name) {
+ sess.WriteLine(g.colorize(sess, "warning", "WARNING: Logging out mid-game will forfeit your bet! Type \"stop\" to cancel."))
+ }
+ g.beginQuitCountdown(sess)
+}
+// beginQuitCountdown runs the shared logout sequence: stop any current
+// action, then count ten ticks down to the final tick that severs the
+// session. Casino participation (a "quit now" mid-game) is resolved on
+// that final tick via MarkDisconnected, so the countdown stays cancellable
+// the whole way down.
+func (g *Game) beginQuitCountdown(sess *net.Session) {
+ p := sess.Player
g.cancelAction(p)
g.cancelRest(p.Name)
@@ -44,6 +78,9 @@ func (g *Game) doQuit(sess *net.Session) {
case 0:
g.cancelRest(p.Name)
g.restoreGodPlayer(p)
+ // Forfeit any committed casino wager and free the seat only
+ // now — immediately before the disconnect. Settling games only
+ // pay seated bettors, so this forfeits a mid-round stake.
g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name))
g.AccountStore.SaveCharacter(p)
g.charsMu.Lock()
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
index a62e9cf..f055e07 100644
--- a/internal/game/quit_casino_test.go
+++ b/internal/game/quit_casino_test.go
@@ -150,6 +150,118 @@ func TestStopCancelsQuit(t *testing.T) {
}
}
+// 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.
+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})
+ 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("alice") {
+ 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("alice") {
+ 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("alice") {
+ 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) {
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 2d3a304..c7d0aa0 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -70,7 +70,7 @@ var commandList = []cmdEntry{
{"prompt", "Instant", "Set custom command prompt"},
{"pull / push", "Active", "Interact with objects"},
{"queued", "Instant", "Show pending tick actions"},
- {"quit", "Active", "Rest and disconnect"},
+ {"quit", "Active", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"},
{"remove / unwear / unwield", "Free", "Unequip items"},
{"say", "Instant", "Chat with players in your room"},
{"score / sc", "Instant", "View your stats and skills"},