From 516fd29da423432ef6dfded21a984f1d15dcab1a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 17:31:50 -0400 Subject: fix: slop casino and economy fixes --- internal/casino/correct_audit_fixes_test.go | 23 +++++++++++++++++++++ internal/casino/craps.go | 14 +++++++++++-- internal/casino/machine.go | 31 +++++++++++++++++++++++++++-- internal/casino/machine_test.go | 14 +++++++++++++ internal/casino/video_poker.go | 4 ++-- 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 internal/casino/correct_audit_fixes_test.go (limited to 'internal/casino') diff --git a/internal/casino/correct_audit_fixes_test.go b/internal/casino/correct_audit_fixes_test.go new file mode 100644 index 0000000..e5e3bd3 --- /dev/null +++ b/internal/casino/correct_audit_fixes_test.go @@ -0,0 +1,23 @@ +package casino + +import ( + "math/rand" + "testing" +) + +// Regression: when the shooter leaves and others remain, the next shooter must +// start a fresh come-out rather than inheriting the previous point and +// made/seen bonus progress (which blocked line and bonus bets). +func TestCrapsShooterChangeResetsHand(t *testing.T) { + tb := newCrapsTable(1, TableConfig{ID: "craps", Game: GameCraps, BettingTicks: 2}, rand.New(rand.NewSource(7))) + tb.join("alice") + tb.join("bob") + tb.point = 6 + tb.rolled = true + tb.made = map[int]bool{4: true} + tb.seen = map[int]bool{4: true} + tb.remove("alice") + if tb.point != 0 || tb.rolled || len(tb.made) != 0 || len(tb.seen) != 0 { + t.Fatalf("new shooter inherited hand: point=%d rolled=%v made=%v seen=%v", tb.point, tb.rolled, tb.made, tb.seen) + } +} diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 73c67c4..0abf24b 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -133,7 +133,8 @@ func (t *crapsTable) join(name string) []Event { return append([]Event{e, t.menu(name)}, timing...) } func (t *crapsTable) remove(name string) { - if len(t.queue) > 0 && t.queue[0] == name { + wasShooter := len(t.queue) > 0 && t.queue[0] == name + if wasShooter { // The queued roll belongs to the shooter; a departing shooter cancels it. t.pendingRollTicks = 0 } @@ -152,6 +153,15 @@ func (t *crapsTable) remove(name string) { t.seen = map[int]bool{} t.phase = phaseWaiting t.window.close() + } else if wasShooter { + // The next shooter starts a fresh come-out. Without this he inherits + // the previous shooter's point (blocking pass/dontpass and bonus + // bets) and made/seen bonus progress. The shared countdown is left + // alone: it belongs to the table, not the shooter. + t.point = 0 + t.rolled = false + t.made = map[int]bool{} + t.seen = map[int]bool{} } } func (t *crapsTable) leave(name string) []Event { @@ -245,7 +255,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { } limit := t.Config.Craps.oddsLimit(b.number, against) if b.odds.Total > 0 { - continue + return reject("Odds already placed on that bet.") } requested := amount amount = roundUpTo(amount, payoutDen) diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 0733384..93ab8aa 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -357,7 +357,9 @@ func (m *Machine) finishSpin() []Event { m.phase = machineSpinning m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false - events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...) + if m.freeSpins > 0 { + events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...) + } } else { m.inFreeSpins = false if !payFailed && m.player.Wallet != nil { @@ -382,9 +384,34 @@ func (m *Machine) leave(name string) []Event { if m.phase != machineReady { return m.private("You're in the middle of a spin!") } + bank := int(m.featureBank) + wallet := m.player.Wallet + stakeFailed := m.stakeFailed m.resetSeat() - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} + // Banked free-spin winnings must not vanish when the player stands up: + // pay them like markDisconnected/refundAll do, or report the forfeiture. + if bank > 0 && wallet != nil { + switch { + case stakeFailed: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the round could not be persisted.", bank), + Color: "casino_action", Public: false}) + case wallet.PayCredits(bank) != nil: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank), + Color: "casino_action", Public: false}) + default: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings.", bank), + Color: "casino_action", Public: false}) + } + } + return events } // resetSeat frees the machine, discarding any in-flight spin and autobet diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 1e8cdad..8942936 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -14,6 +14,20 @@ func TestBetRangeTextFormatsUnlimitedMax(t *testing.T) { } } +// Regression: standing up while a feature was banked silently dropped the +// credits; leave must settle them like markDisconnected does. +func TestMachineLeavePaysBankedFeature(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + w := &testWallet{} + m.player = &Participant{Name: "alice", Connected: true, Wallet: w} + m.featureBank = 25 + m.phase = machineReady + _ = m.leave("alice") + if w.paid != 25 { + t.Fatalf("banked feature not paid on leave: paid = %d, want 25", w.paid) + } +} + func TestMachineSetBetUnsetDefaultMaxMessage(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, rand.New(rand.NewSource(7))) m.player = &Participant{Name: "alice", Connected: true} diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index 4cb9883..98bfda1 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -279,7 +279,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { if wallet, ok := w.(PokerWallet); ok { banks, err := wallet.LoadPokerBanks(t.RoomID, t.Config.ID) if err != nil { - return []Event{t.private(name, "Machine bank unavailable; try again later: "+err.Error())} + return []Event{t.private(name, "Machine bank unavailable; try again later.")} } if banks != nil { t.banks = banks @@ -308,7 +308,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { } stake, err := w.Wager(p.cost()) if err != nil { - return []Event{t.private(name, "The wager could not be placed: "+err.Error())} + return []Event{t.private(name, walletErrorMessage(err, "Not enough chips and credits for that wager."))} } p.wallet, p.wager, p.lastBet = w, stake, p.loaded p.bankKey = t.multiplierKey(p) -- cgit v1.2.3