diff --git a/go.mod b/go.mod index 1568ea8..3d22c30 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.etcd.io/bbolt v1.4.3 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index 964e6ee..1e17dd9 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= diff --git a/store/db.go b/store/db.go new file mode 100644 index 0000000..14036a7 --- /dev/null +++ b/store/db.go @@ -0,0 +1,105 @@ +package store + +import ( + "encoding/json" + "fmt" + "sort" + + bolt "go.etcd.io/bbolt" +) + +var ( + bucketProfiles = []byte("profiles") + bucketRankings = []byte("rankings") +) + +type DB struct { + db *bolt.DB +} + +type RunRecord struct { + Player string `json:"player"` + Floor int `json:"floor"` + Score int `json:"score"` +} + +func Open(path string) (*DB, error) { + db, err := bolt.Open(path, 0600, nil) + if err != nil { + return nil, err + } + err = db.Update(func(tx *bolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists(bucketProfiles); err != nil { + return err + } + if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil { + return err + } + return nil + }) + return &DB{db: db}, err +} + +func (d *DB) Close() error { + return d.db.Close() +} + +func (d *DB) SaveProfile(fingerprint, name string) error { + return d.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(bucketProfiles).Put([]byte(fingerprint), []byte(name)) + }) +} + +func (d *DB) GetProfile(fingerprint string) (string, error) { + var name string + err := d.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(bucketProfiles).Get([]byte(fingerprint)) + if v == nil { + return fmt.Errorf("profile not found") + } + name = string(v) + return nil + }) + return name, err +} + +func (d *DB) SaveRun(player string, floor, score int) error { + return d.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketRankings) + id, _ := b.NextSequence() + record := RunRecord{Player: player, Floor: floor, Score: score} + data, err := json.Marshal(record) + if err != nil { + return err + } + return b.Put([]byte(fmt.Sprintf("%010d", id)), data) + }) +} + +func (d *DB) TopRuns(limit int) ([]RunRecord, error) { + var runs []RunRecord + err := d.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketRankings) + return b.ForEach(func(k, v []byte) error { + var r RunRecord + if err := json.Unmarshal(v, &r); err != nil { + return err + } + runs = append(runs, r) + return nil + }) + }) + if err != nil { + return nil, err + } + sort.Slice(runs, func(i, j int) bool { + if runs[i].Floor != runs[j].Floor { + return runs[i].Floor > runs[j].Floor + } + return runs[i].Score > runs[j].Score + }) + if len(runs) > limit { + runs = runs[:limit] + } + return runs, nil +} diff --git a/store/db_test.go b/store/db_test.go new file mode 100644 index 0000000..0b49937 --- /dev/null +++ b/store/db_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "os" + "testing" +) + +func TestPlayerProfile(t *testing.T) { + db, err := Open("test.db") + if err != nil { + t.Fatal(err) + } + defer func() { + db.Close() + os.Remove("test.db") + }() + + err = db.SaveProfile("SHA256:abc123", "TestPlayer") + if err != nil { + t.Fatal(err) + } + name, err := db.GetProfile("SHA256:abc123") + if err != nil { + t.Fatal(err) + } + if name != "TestPlayer" { + t.Errorf("Name: got %q, want %q", name, "TestPlayer") + } +} + +func TestRanking(t *testing.T) { + db, err := Open("test_rank.db") + if err != nil { + t.Fatal(err) + } + defer func() { + db.Close() + os.Remove("test_rank.db") + }() + + db.SaveRun("Alice", 20, 1500) + db.SaveRun("Bob", 15, 1000) + db.SaveRun("Charlie", 20, 2000) + + rankings, err := db.TopRuns(10) + if err != nil { + t.Fatal(err) + } + if len(rankings) != 3 { + t.Errorf("Rankings: got %d, want 3", len(rankings)) + } + if rankings[0].Player != "Charlie" { + t.Errorf("Top player: got %q, want Charlie", rankings[0].Player) + } +}