diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/game/cmd_quit.go | 39 | ||||
| -rw-r--r-- | internal/game/quit_casino_test.go | 112 | ||||
| -rw-r--r-- | internal/game/render_help.go | 2 |
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"}, |
