diff options
Diffstat (limited to 'internal/game/video_poker_banks.go')
| -rw-r--r-- | internal/game/video_poker_banks.go | 145 |
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 +} |
