// totals.go: the totals, one per character. // // The game already tracks each character's death count separately (it's // in the save, not on the PC), so the program does the same: for each // character it saves a "base", and the number shown is always // total = deaths read from the game + base. // // The number shown is, with nothing in between, whatever the game tracks // for that character. What's saved to disk is NOT an adjustment: it's // just the last thing seen, so there's something to show if you open the // counter before the game and there's nothing to read yet. // // There were two earlier models. First a single lifetime total that // never went down, which with multiple characters left the number stuck // until the next death. Then a per-character "base" added to the // reading, inherited from when we didn't trust what we were reading: // once the total comes straight from the game on every read, correcting // it by hand stopped fixing anything and just made the number lie quietly. package main import ( "encoding/json" "log" "os" "path/filepath" "strconv" "strings" "sync" ) type charTotal struct { Name string `json:"name"` Total int64 `json:"total"` // last total seen: shows something before the game is read } type totalsFile struct { LastCharacter string `json:"lastCharacter"` Characters map[string]*charTotal `json:"characters"` } type totalsStore struct { mu sync.Mutex path string data totalsFile } // testExeDir lets tests point this at a temp directory. var testExeDir string func exeDir() (string, bool) { if testExeDir != "" { return testExeDir, true } if exe, err := os.Executable(); err == nil { return filepath.Dir(exe), true } return "", false } func totalsPath() string { if dir, ok := exeDir(); ok { return filepath.Join(dir, "totals.json") } return "totals.json" } func newTotalsStore() *totalsStore { s := &totalsStore{path: totalsPath()} s.data.Characters = map[string]*charTotal{} data, err := os.ReadFile(s.path) if err == nil { if err := json.Unmarshal(data, &s.data); err != nil { log.Printf("totals.json is unreadable (%v): starting fresh, won't overwrite it until the next write", err) s.data = totalsFile{Characters: map[string]*charTotal{}} } if s.data.Characters == nil { s.data.Characters = map[string]*charTotal{} } } return s } func (s *totalsStore) get(key string) (*charTotal, bool) { s.mu.Lock() defer s.mu.Unlock() c, ok := s.data.Characters[key] if !ok { return nil, false } cp := *c return &cp, true } // lastKnownTotal: what to show while the game isn't open. func (s *totalsStore) lastKnownTotal() (key, name string, total int64) { s.mu.Lock() defer s.mu.Unlock() if c, ok := s.data.Characters[s.data.LastCharacter]; ok { return s.data.LastCharacter, c.Name, c.Total } return "", "", 0 } func (s *totalsStore) save(key string, c charTotal) { s.mu.Lock() s.data.Characters[key] = &c s.data.LastCharacter = key blob, err := json.MarshalIndent(s.data, "", " ") s.mu.Unlock() if err != nil { log.Printf("couldn't serialize totals.json: %v", err) return } // Atomic write: if the power cuts mid-write, the old file stays intact // instead of ending up as a JSON file cut in half. tmp := s.path + ".tmp" if err := os.WriteFile(tmp, blob, 0644); err != nil { log.Printf("couldn't write totals.json: %v", err) return } if err := os.Rename(tmp, s.path); err != nil { log.Printf("couldn't replace totals.json: %v", err) } } // characterKey: a character's identity. // // The save slot (GameMan+0xAC0) is the good identity: it never changes, // and two characters with the same name stay distinct. The name is only // a fallback for when we can't read the slot, with the known limitation // that there, two homonyms DO share a counter. func characterKey(slot int, name string) string { if slot >= 0 { return "slot" + strconv.Itoa(slot) } if n := strings.ToLower(strings.TrimSpace(name)); n != "" { return "name:" + n } return "" }