package manager import ( "encoding/json" "errors" "os" "path/filepath" "sort" "sync" "time" ) func saveJSON(path string, v any) error { b, err := json.MarshalIndent(v, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, path) } func loadJSON(path string, v any) error { b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } return json.Unmarshal(b, v) } // PlayerRecord is what Megalodon remembers about someone who has been on any server. type PlayerRecord struct { SteamID string `json:"steam_id"` Name string `json:"name"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` Seconds int64 `json:"seconds"` // time spent online, across all servers Joins int `json:"joins"` LastOn string `json:"last_server,omitempty"` Notes string `json:"notes,omitempty"` Watch bool `json:"watch,omitempty"` // raise an event when they join } // PlayerDB is the list of everyone seen, saved to a file. type PlayerDB struct { mu sync.Mutex path string recs map[string]*PlayerRecord dirty bool } func openPlayers(dir string) (*PlayerDB, error) { db := &PlayerDB{path: filepath.Join(dir, "players.json"), recs: map[string]*PlayerRecord{}} var list []*PlayerRecord if err := loadJSON(db.path, &list); err != nil { return nil, err } for _, r := range list { db.recs[r.SteamID] = r } return db, nil } // Seen notes that a player is online on server. online is the time since the previous poll to // credit them with; joined is true the moment they appear. func (db *PlayerDB) Seen(id, name, server string, online time.Duration, joined bool) PlayerRecord { db.mu.Lock() defer db.mu.Unlock() now := time.Now() r := db.recs[id] if r == nil { r = &PlayerRecord{SteamID: id, FirstSeen: now} db.recs[id] = r } r.Name, r.LastSeen, r.LastOn = name, now, server r.Seconds += int64(online / time.Second) if joined { r.Joins++ } db.dirty = true return *r } func (db *PlayerDB) Get(id string) (PlayerRecord, bool) { db.mu.Lock() defer db.mu.Unlock() if r := db.recs[id]; r != nil { return *r, true } return PlayerRecord{}, false } // List returns everyone, most recently seen first. func (db *PlayerDB) List() []PlayerRecord { db.mu.Lock() defer db.mu.Unlock() out := make([]PlayerRecord, 0, len(db.recs)) for _, r := range db.recs { out = append(out, *r) } sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) }) return out } // Annotate sets the notes and watch flag for a player, creating the record if needed. func (db *PlayerDB) Annotate(id, notes string, watch bool) { db.mu.Lock() defer db.mu.Unlock() r := db.recs[id] if r == nil { r = &PlayerRecord{SteamID: id, FirstSeen: time.Now(), LastSeen: time.Now()} db.recs[id] = r } r.Notes, r.Watch = notes, watch db.dirty = true } func (db *PlayerDB) Flush() { db.mu.Lock() defer db.mu.Unlock() if !db.dirty { return } list := make([]*PlayerRecord, 0, len(db.recs)) for _, r := range db.recs { list = append(list, r) } if saveJSON(db.path, list) == nil { db.dirty = false } } // Ban is a ban Megalodon enforces on top of the game's own list: it can expire, carries a // reason, and follows the player across a whole cluster. type Ban struct { SteamID string `json:"steam_id"` Name string `json:"name,omitempty"` Reason string `json:"reason,omitempty"` By string `json:"by"` At time.Time `json:"at"` Expires *time.Time `json:"expires,omitempty"` Servers []string `json:"servers,omitempty"` // empty means every server } func (b Ban) Active(now time.Time) bool { return b.Expires == nil || now.Before(*b.Expires) } func (b Ban) Covers(server string) bool { if len(b.Servers) == 0 { return true } for _, s := range b.Servers { if s == server { return true } } return false } type BanDB struct { mu sync.Mutex path string bans map[string]Ban } func openBans(dir string) (*BanDB, error) { db := &BanDB{path: filepath.Join(dir, "bans.json"), bans: map[string]Ban{}} var list []Ban if err := loadJSON(db.path, &list); err != nil { return nil, err } for _, b := range list { db.bans[b.SteamID] = b } return db, nil } func (db *BanDB) saveLocked() { list := make([]Ban, 0, len(db.bans)) for _, b := range db.bans { list = append(list, b) } sort.Slice(list, func(i, j int) bool { return list[i].At.After(list[j].At) }) saveJSON(db.path, list) } func (db *BanDB) Add(b Ban) { db.mu.Lock() defer db.mu.Unlock() db.bans[b.SteamID] = b db.saveLocked() } func (db *BanDB) Remove(id string) (Ban, bool) { db.mu.Lock() defer db.mu.Unlock() b, ok := db.bans[id] if ok { delete(db.bans, id) db.saveLocked() } return b, ok } // Active returns the ban that applies to id on server right now, if any. func (db *BanDB) Active(id, server string) (Ban, bool) { db.mu.Lock() defer db.mu.Unlock() b, ok := db.bans[id] if !ok || !b.Active(time.Now()) || !b.Covers(server) { return Ban{}, false } return b, true } func (db *BanDB) List() []Ban { db.mu.Lock() defer db.mu.Unlock() out := make([]Ban, 0, len(db.bans)) for _, b := range db.bans { out = append(out, b) } sort.Slice(out, func(i, j int) bool { return out[i].At.After(out[j].At) }) return out } // Expired returns (without removing) the bans whose time is up. func (db *BanDB) Expired(now time.Time) []Ban { db.mu.Lock() defer db.mu.Unlock() var out []Ban for _, b := range db.bans { if !b.Active(now) { out = append(out, b) } } return out }