aboutsummaryrefslogtreecommitdiff
path: root/internal/game/video_poker_banks.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/video_poker_banks.go')
-rw-r--r--internal/game/video_poker_banks.go145
1 files changed, 145 insertions, 0 deletions
diff --git a/internal/game/video_poker_banks.go b/internal/game/video_poker_banks.go
new file mode 100644
index 0000000..bb18837
--- /dev/null
+++ b/internal/game/video_poker_banks.go
@@ -0,0 +1,145 @@
+package game
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+var _ casino.PokerWallet = casinoWallet{}
+
+type pokerBankFile struct {
+ Banks map[string][]int `yaml:"banks"`
+}
+
+func (g *Game) pokerBankPath(roomID int, cabinetID string) (string, error) {
+ if cabinetID == "" {
+ return "", fmt.Errorf("empty cabinet id")
+ }
+ for _, r := range cabinetID {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
+ return "", fmt.Errorf("invalid cabinet id %q", cabinetID)
+ }
+ }
+ return filepath.Join(g.DataDir, "casino", "video_poker_banks", fmt.Sprintf("%d_%s.yaml", roomID, cabinetID)), nil
+}
+
+// Missing is a new cabinet. Corrupt/unreadable state is NOT an empty bank:
+// refuse deals instead of silently destroying or replaying entitlements.
+func (w casinoWallet) LoadPokerBanks(roomID int, cabinetID string) (map[string][]int, error) {
+ path, err := w.g.pokerBankPath(roomID, cabinetID)
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return map[string][]int{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var file pokerBankFile
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ decoder.KnownFields(true)
+ if err := decoder.Decode(&file); err != nil {
+ return nil, err
+ }
+ for key, values := range file.Banks {
+ if len(strings.Split(key, "/")) != 4 || len(values) == 0 || len(values) > 10 {
+ return nil, fmt.Errorf("invalid multiplier bank %q", key)
+ }
+ for _, n := range values {
+ if n < 1 || n > 12 {
+ return nil, fmt.Errorf("invalid multiplier %d", n)
+ }
+ }
+ }
+ return file.Banks, nil
+}
+
+// PayPoker replaces the character payout/stake ledger AND the whole cabinet
+// bank in one undo-journal transaction, on the game owner goroutine. A crash
+// before commit restores both; startup then refunds the still-pending stake.
+// A committed payout cannot leave an old bank that another player can collect.
+func (w casinoWallet) PayPoker(amount int, stakeID string, roomID int, cabinetID string, banks map[string][]int) error {
+ candidate := *w.p
+ candidate.PendingWagers = append([]player.PendingWager(nil), w.p.PendingWagers...)
+ if stakeID == "" || len(takePendingWagers(&candidate, []string{stakeID})) != 1 {
+ return casino.ErrWalletPersistence
+ }
+ sum, err := addCreditsChecked(candidate.Credits, amount)
+ if err != nil {
+ return err
+ }
+ candidate.Credits = sum
+ path, err := w.g.pokerBankPath(roomID, cabinetID)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ for _, directory := range []string{filepath.Dir(path), filepath.Dir(filepath.Dir(path)), w.g.DataDir} {
+ d, err := os.Open(directory)
+ if err != nil {
+ return err
+ }
+ err = d.Sync()
+ d.Close()
+ if err != nil {
+ return err
+ }
+ }
+ bankData, err := yaml.Marshal(pokerBankFile{Banks: banks})
+ if err != nil {
+ return err
+ }
+ charData, err := w.g.AccountStore.MarshalCharacter(&candidate)
+ if err != nil {
+ return err
+ }
+ charPath := w.g.AccountStore.CharPath(w.p.Name)
+ if err := os.MkdirAll(w.g.journalDir(), 0700); err != nil {
+ return err
+ }
+ txn := w.g.newTxn()
+ if err := txn.Replace(charPath, charData, 0600); err != nil {
+ return err
+ }
+ if err := txn.Replace(path, bankData, 0600); err != nil {
+ return err
+ }
+ if err := txn.Commit(); err != nil {
+ // A commit-point/unlink error is ambiguous. Finish recovery before any
+ // retry or later character save, then reconcile the actual pair of files.
+ // If recovery/directory sync fails, fail closed, as startup recovery does.
+ w.g.recoverJournals()
+ dir, syncErr := os.Open(w.g.journalDir())
+ if syncErr == nil {
+ syncErr = dir.Sync()
+ dir.Close()
+ }
+ if syncErr != nil {
+ panic(fmt.Sprintf("poker journal durability: %v", syncErr))
+ }
+ actualChar, cerr := os.ReadFile(charPath)
+ actualBank, berr := os.ReadFile(path)
+ charApplied := cerr == nil && bytes.Equal(actualChar, charData)
+ bankApplied := berr == nil && bytes.Equal(actualBank, bankData)
+ if !charApplied {
+ return err
+ } // recovered before-images; no live mutation
+ if !bankApplied {
+ panic("poker settlement recovery left inconsistent files")
+ }
+ }
+ w.p.Credits = candidate.Credits
+ w.p.PendingWagers = candidate.PendingWagers
+ return nil
+}