package game import ( "os" "path/filepath" "reflect" "strings" "testing" "gopkg.in/yaml.v3" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/persistence" ) const uxTestRoom = "name: Cabinets\ncasino_tables:\n - id: ux_multi\n game: video_poker\n video_poker:\n cabinet: ultimate_x_multi\n" func TestPokerBankAtomicSettlementAndRetry(t *testing.T) { g, sess, _ := casinoCrapsGame(t, uxTestRoom) g.DataDir = t.TempDir() w := casinoWallet{g: g, p: sess.Player} stake, err := w.Wager(10) if err != nil { t.Fatal(err) } banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}} // A failure before journal creation cannot apply either half. if err := os.WriteFile(g.journalDir(), []byte("blocked"), 0600); err != nil { t.Fatal(err) } if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil { t.Fatal("accepted failed persistence") } if sess.Player.Credits != 90 || len(sess.Player.PendingWagers) != 1 { t.Fatal("failure mutated character") } loaded, err := w.LoadPokerBanks(1, "ux_multi") if err != nil || len(loaded) != 0 { t.Fatalf("bank changed: %v %v", loaded, err) } if err := os.Remove(g.journalDir()); err != nil { t.Fatal(err) } if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err != nil { t.Fatal(err) } disk, err := g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } loaded, err = w.LoadPokerBanks(1, "ux_multi") if err != nil || disk.Credits != 140 || sess.Player.Credits != 140 || len(disk.PendingWagers) != 0 || !reflect.DeepEqual(loaded, banks) { t.Fatalf("settlement: %v %+v", err, disk) } if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil { t.Fatal("duplicate stake paid") } } func TestPokerSeatsPassMachineBankAcrossRestart(t *testing.T) { g, sess, _ := casinoCrapsGame(t, uxTestRoom) g.DataDir = t.TempDir() w := casinoWallet{g: g, p: sess.Player} stake, err := w.Wager(1) if err != nil { t.Fatal(err) } banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}} if err := w.PayPoker(0, stake.ID, 1, "ux_multi", banks); err != nil { t.Fatal(err) } // Restart the casino manager: the bank comes from disk, not a player map. g.Casino = casino.NewManager(2) g.executePlay(sess, []string{"ux_multi"}, "play ux_multi") table, ok := g.Casino.Participation("alice") if !ok || table == nil { t.Fatal("not seated") } g.Casino.Join(1, table.Config, "bob") if _, playing := g.Casino.Participation("bob"); playing { t.Fatal("second occupant admitted") } snap := table.Status("alice")[0].Snapshot.(*casino.VideoPokerSnapshot) if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) { t.Fatalf("bank not displayed on join: %v", snap.Next) } if !g.leaveCasinoGames(sess.Player) { t.Fatal("could not leave") } g.Casino.Join(1, table.Config, "bob") snap = table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot) if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) { t.Fatalf("next occupant did not inherit: %v", snap.Next) } // Settings keep separate banks; merely viewing them never consumes them. g.Casino.Action(1, table.Config.ID, "bob", "denom 10") if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; len(got) != 0 { t.Fatalf("wrong denomination bank: %v", got) } g.Casino.Action(1, table.Config.ID, "bob", "denom 1") if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; !reflect.DeepEqual(got, []int{2, 3, 4}) { t.Fatal("bank lost on switch") } } func TestPokerCorruptBankRefusesDeal(t *testing.T) { g, sess, conn := casinoCrapsGame(t, uxTestRoom) g.DataDir = t.TempDir() path, _ := g.pokerBankPath(1, "ux_multi") if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("banks: [broken"), 0600); err != nil { t.Fatal(err) } g.executePlay(sess, []string{"ux_multi"}, "play ux_multi") g.executeBet(sess, []string{"max"}, "bet max") g.executeTableAction(sess, "deal") if sess.Player.Credits != 100 || len(sess.Player.PendingWagers) != 0 || !strings.Contains(string(conn.data), "Machine bank unavailable") { t.Fatal("corrupt bank did not block deal") } } func TestPokerInterruptedTransactionRecovery(t *testing.T) { g, sess, _ := casinoCrapsGame(t, uxTestRoom) g.DataDir = t.TempDir() w := casinoWallet{g: g, p: sess.Player} stake, err := w.Wager(10) if err != nil { t.Fatal(err) } path, _ := g.pokerBankPath(1, "ux_multi") if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } before, _ := yaml.Marshal(pokerBankFile{Banks: map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}}}) if err := os.WriteFile(path, before, 0600); err != nil { t.Fatal(err) } next := *sess.Player next.Credits += 50 next.PendingWagers = nil charData, _ := g.AccountStore.MarshalCharacter(&next) bankData, _ := yaml.Marshal(pokerBankFile{}) txn := g.newTxn() if err := txn.Replace(g.AccountStore.CharPath("alice"), charData, 0600); err != nil { t.Fatal(err) } if err := txn.Replace(path, bankData, 0600); err != nil { t.Fatal(err) } journal, err := txn.MarshalJournal() if err != nil { t.Fatal(err) } if err := os.MkdirAll(g.journalDir(), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(g.journalDir(), "interrupted.json"), journal, 0600); err != nil { t.Fatal(err) } // Process died after applying just the character replacement. if err := persistence.WriteFile(g.AccountStore.CharPath("alice"), charData, 0600); err != nil { t.Fatal(err) } g.recoverJournals() restored, err := g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if restored.Credits != 90 || len(restored.PendingWagers) != 1 || restored.PendingWagers[0].ID != stake.ID { t.Fatal("character before-image not restored") } restoredBank, err := w.LoadPokerBanks(1, "ux_multi") if err != nil || !reflect.DeepEqual(restoredBank["jacks_or_better/full/1/3"], []int{2, 3, 4}) { t.Fatal("bank before-image not restored") } }