package player import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/item" "thehouseoficarus/internal/persistence" ) type AccountStore struct { dataDir string } func NewAccountStore(dataDir string) *AccountStore { return &AccountStore{dataDir: dataDir} } func (s *AccountStore) AccountPath(name string) string { return filepath.Join(s.dataDir, "players", "accounts", name+".yaml") } func (s *AccountStore) LoadAccount(name string) (*Account, error) { path := s.AccountPath(name) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("account not found: %s", name) } var acc Account if err := yaml.Unmarshal(data, &acc); err != nil { return nil, fmt.Errorf("parse account: %w", err) } acc.Name = name return &acc, nil } func (s *AccountStore) SaveAccount(acc *Account) error { data, err := s.MarshalAccount(acc) if err != nil { return err } return writePrivate(s.AccountPath(acc.Name), data) } // writePrivate writes data atomically and then forces 0600. persistence.WriteFile // deliberately preserves an existing file's mode, so an account/character file // created before this rule (or by an external tool) would otherwise stay // world-readable through every later save and password rehash. func writePrivate(path string, data []byte) error { if err := persistence.WriteFile(path, data, 0600); err != nil { return err } // The file was already renamed into place; a chmod failure is a // post-replacement durability-uncertain condition, not "nothing // happened". if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("%w: chmod: %v", persistence.ErrDurabilityUncertain, err) } return nil } // MarshalAccount serializes an account; callers that own the account (the // game owner, or transaction code holding it) may use this directly. func (s *AccountStore) MarshalAccount(acc *Account) ([]byte, error) { return yaml.Marshal(acc) } func (s *AccountStore) AccountExists(name string) bool { _, err := s.FindAccount(name) return err == nil } // isPathLikeName reports whether name is unsafe to join into a data-file path. // Callers are expected to validate names (player.ValidName) before calling the // store; this is a defence-in-depth backstop for the raw path primitive. func isPathLikeName(name string) bool { return name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") || strings.ContainsRune(name, 0) } func (s *AccountStore) FindAccount(name string) (string, error) { if isPathLikeName(name) { return "", fmt.Errorf("account not found: %s", name) } path := s.AccountPath(name) if _, err := os.Stat(path); err == nil { return name, nil } dir := filepath.Join(s.dataDir, "players", "accounts") entries, err := os.ReadDir(dir) if err != nil { return "", fmt.Errorf("account not found: %s", name) } lower := strings.ToLower(name) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } base := strings.TrimSuffix(e.Name(), ".yaml") if strings.ToLower(base) == lower { return base, nil } } return "", fmt.Errorf("account not found: %s", name) } // characterInventorySlots is the on-disk inventory capacity, matching // Player.FreeSlots and Player.FirstFreeSlot in player.go. const characterInventorySlots = MaxInventorySlots func (s *AccountStore) CharPath(name string) string { return filepath.Join(s.dataDir, "players", "characters", name+".yaml") } func (s *AccountStore) LoadCharacter(name string) (*Player, error) { path := s.CharPath(name) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("character not found: %s", name) } var p Player if err := yaml.Unmarshal(data, &p); err != nil { return nil, fmt.Errorf("parse character: %w", err) } p.Name = name if p.Skills == nil { p.Skills = make(map[SkillName]int) } if p.Equipment == nil { p.Equipment = make(map[item.EquipSlot]string) } if p.Inventory == nil { p.Inventory = make(map[int]*InventorySlot) } sanitizeInventory(p.Inventory) sanitizeBank(p.Bank) if p.MapSymbols == nil { p.MapSymbols = make(map[int]MapSymbolData) } return &p, nil } // sanitizeInventory drops inventory entries a hand-edited or corrupt save can // carry that no gameplay path can reach or free: nil slots, keys outside the // 0..characterInventorySlots-1 range, and non-positive quantities. FreeSlots // already ignores invalid entries, but FirstFreeSlot and the on-disk save still // need the map itself to be clean, so sanitize here as well. func sanitizeInventory(slots map[int]*InventorySlot) { for i, slot := range slots { if slot == nil || slot.Quantity <= 0 || i < 0 || i >= characterInventorySlots { delete(slots, i) } } } // sanitizeBank applies the same rule to the bank, which unlike the inventory // grows past 28 slots (NextBankSlot), so only negative keys are out of range. func sanitizeBank(slots map[int]*InventorySlot) { for i, slot := range slots { if slot == nil || slot.Quantity <= 0 || i < 0 { delete(slots, i) } } } func (s *AccountStore) SaveCharacter(p *Player) error { data, err := s.MarshalCharacter(p) if err != nil { return err } return writePrivate(s.CharPath(p.Name), data) } // MarshalCharacter serializes a character for a same-directory temporary // atomic write. It serializes a value snapshot so saving god-mode stats // never temporarily changes the live player; the caller still owns all // referenced maps (serialize on the owner goroutine). func (s *AccountStore) MarshalCharacter(p *Player) ([]byte, error) { snapshot := *p if snapshot.GodMode && snapshot.GodBackup != nil { snapshot.Skills = snapshot.GodBackup if snapshot.HP > snapshot.MaxHP() { snapshot.HP = snapshot.MaxHP() } } return yaml.Marshal(&snapshot) } func (s *AccountStore) CharacterExists(name string) bool { _, err := s.FindCharacter(name) return err == nil } func (s *AccountStore) FindCharacter(name string) (string, error) { if isPathLikeName(name) { return "", fmt.Errorf("character not found: %s", name) } path := s.CharPath(name) if _, err := os.Stat(path); err == nil { return name, nil } dir := filepath.Join(s.dataDir, "players", "characters") entries, err := os.ReadDir(dir) if err != nil { return "", fmt.Errorf("character not found: %s", name) } lower := strings.ToLower(name) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } base := strings.TrimSuffix(e.Name(), ".yaml") if strings.ToLower(base) == lower { return base, nil } } return "", fmt.Errorf("character not found: %s", name) } // ListAccountNames returns the stem of each account YAML file. func (s *AccountStore) ListAccountNames() ([]string, error) { dir := filepath.Join(s.dataDir, "players", "accounts") entries, err := os.ReadDir(dir) if err != nil { return nil, err } var names []string for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } name := strings.TrimSuffix(e.Name(), ".yaml") if name == "" { continue } names = append(names, name) } return names, nil } // ListCharacterNames returns character YAML stems for offline migrations. // Callers exclude online characters and update their in-memory state instead. func (s *AccountStore) ListCharacterNames() ([]string, error) { dir := filepath.Join(s.dataDir, "players", "characters") entries, err := os.ReadDir(dir) if err != nil { return nil, err } var names []string for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } name := strings.TrimSuffix(e.Name(), ".yaml") if name == "" { continue } names = append(names, name) } return names, nil }