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 }