package casino import ( "fmt" "testing" ) type testWallet struct { chips, credits, paid int open []Wager seq int } func (w *testWallet) Wager(amount int) (Wager, error) { if w.chips+w.credits < amount { return Wager{}, ErrInsufficientFunds } chips := amount if chips > w.chips { chips = w.chips } w.chips -= chips credits := amount - chips w.credits -= credits w.seq++ wager := Wager{Total: amount, Chips: chips, Credits: credits, ID: fmt.Sprintf("stake-%02d", w.seq)} w.open = append(w.open, wager) return wager, nil } // PayCredits mirrors the production wallet: it banks the payout and, in the // same step, consumes the stakes the settlement resolves. func (w *testWallet) PayCredits(amount int, stakeIDs ...string) error { w.paid += amount for _, id := range stakeIDs { w.ResolveStake(id) } return nil } // ResolveStake settles one outstanding stake by handle, mirroring the // production wallet. func (w *testWallet) ResolveStake(stakeID string) { kept := w.open[:0] for _, wager := range w.open { if wager.ID != stakeID { kept = append(kept, wager) } } w.open = kept } // ResolveStakesByIDs consumes the listed stakes in one step, mirroring the // production wallet. func (w *testWallet) ResolveStakesByIDs(stakeIDs ...string) { for _, id := range stakeIDs { w.ResolveStake(id) } } // ResolveStakes mirrors the production wallet: every outstanding stake is // settled (no longer refundable). func (w *testWallet) ResolveStakes() { w.open = nil } // RefundOutstanding returns every outstanding stake to the balances and // resolves the stakes, mirroring the production refund path: credits are // paid through PayCredits (recorded in paid), chips return as chips. func (w *testWallet) RefundOutstanding() (chips, credits int, err error) { for _, wager := range w.open { chips += wager.Chips credits += wager.Credits } w.open = nil w.chips += chips if credits > 0 { w.paid += credits } return chips, credits, nil } func TestMixedWagerUsesChipsFirst(t *testing.T) { w := &testWallet{chips: 7, credits: 50} wager, err := w.Wager(12) if err != nil || wager.Chips != 7 || wager.Credits != 5 || w.chips != 0 || w.credits != 45 { t.Fatalf("unexpected wager: %+v wallet=%+v", wager, w) } } func TestJoinUnknownTableGameRejected(t *testing.T) { m := NewManager(1) events := m.Join(1, TableConfig{ID: "slots", Game: GameSlots}, "alice") if len(events) != 1 || !contains(events[0].Message, "not available") { t.Fatalf("joining an unsupported table game: %+v", events) } if m.Table(1, "slots") != nil { t.Fatal("unsupported game was cached as a table") } } func TestMachineAutobetCountdownAndStop(t *testing.T) { m := NewManager(1) cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1} m.JoinMachine(1, cfg, "alice") w := &testWallet{credits: 100} if events := m.SetMachineBet(1, "slots", "alice", 10, w); len(events) == 0 { t.Fatalf("setting a bet did not start a spin: %+v", events) } for m.MachineBusy("alice") { m.Tick() } if events := m.EnableMachineAutoBet(1, "slots", "alice", w); len(events) != 0 { t.Fatalf("enabling autobet emitted events before countdown: %+v", events) } for i, want := range []string{"Autobet enabled!", `Type "stop" to stop playing`, "Betting... 10 credits."} { events := m.Tick() if len(events) == 0 || eventText(events) != want { t.Fatalf("tick %d message=%q events=%+v, want %q", i+1, eventText(events), events, want) } } if !m.MachineBusy("alice") { t.Fatal("autobet did not start a spin after countdown") } if events, stopped := m.StopMachineAutoBet(1, "slots", "alice"); !stopped || len(events) != 1 { t.Fatalf("stop autobet events=%+v stopped=%v", events, stopped) } if _, playing := m.MachineFor("alice"); !playing { t.Fatal("stopping autobet removed the player from the machine") } } func eventText(events []Event) string { for _, event := range events { if event.PrivateMessage != "" { return event.PrivateMessage } if event.Message != "" { return event.Message } } return "" } // TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs: a room rekey must move // the manager key and the shared table identity together, and must not // truncate a table ID that legally contains whitespace. func TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs(t *testing.T) { m := NewManager(1) const id = "high stakes bj" cfg := TableConfig{ID: id, Game: GameBlackjack} m.Join(1, cfg, "alice") table := m.Table(1, id) if table == nil { t.Fatal("table was not created for a valid whitespace-containing ID") } m.RekeyRooms(map[int]int{1: 12}) if m.Table(1, id) != nil { t.Fatal("table left under the old room key") } moved := m.Table(12, id) if moved != table { t.Fatalf("rekey lost the table (or truncated its key): Table(12, %q)=%p want %p", id, moved, table) } if moved.RoomID != 12 { t.Fatalf("moved table RoomID = %d, want 12", moved.RoomID) } // The game shares the table's base, so events must route to the new room. events := moved.join("bob") if len(events) == 0 || events[0].RoomID != 12 { t.Fatalf("post-rekey events route to the wrong room: %+v", events) } // The old, truncated key must not have been created. if m.Table(12, "high") != nil { t.Fatal("rekey created a truncated table key from a whitespace ID") } }