diff --git a/store/titles.go b/store/titles.go new file mode 100644 index 0000000..fd914a4 --- /dev/null +++ b/store/titles.go @@ -0,0 +1,115 @@ +package store + +import ( + "encoding/json" + + bolt "go.etcd.io/bbolt" +) + +var bucketTitles = []byte("titles") + +type TitleDef struct { + ID string + Name string + Description string +} + +var TitleDefs = []TitleDef{ + {ID: "novice", Name: "Novice", Description: "Default title for new players"}, + {ID: "explorer", Name: "Explorer", Description: "Reach floor 5"}, + {ID: "veteran", Name: "Veteran", Description: "Reach floor 10"}, + {ID: "champion", Name: "Champion", Description: "Reach floor 20"}, + {ID: "gold_king", Name: "Gold King", Description: "Accumulate 500+ gold in one run"}, + {ID: "team_player", Name: "Team Player", Description: "Complete 5 multiplayer runs"}, + {ID: "survivor", Name: "Survivor", Description: "Complete a run without dying"}, +} + +type PlayerTitleData struct { + ActiveTitle string `json:"active_title"` + Earned []string `json:"earned"` +} + +func (d *DB) EarnTitle(fingerprint, titleID string) (bool, error) { + newlyEarned := false + err := d.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketTitles) + key := []byte(fingerprint) + var data PlayerTitleData + + v := b.Get(key) + if v != nil { + if err := json.Unmarshal(v, &data); err != nil { + return err + } + } + + // Check if already earned + for _, e := range data.Earned { + if e == titleID { + return nil + } + } + + newlyEarned = true + data.Earned = append(data.Earned, titleID) + + // Auto-set first earned title as active + if data.ActiveTitle == "" { + data.ActiveTitle = titleID + } + + encoded, err := json.Marshal(data) + if err != nil { + return err + } + return b.Put(key, encoded) + }) + return newlyEarned, err +} + +func (d *DB) SetActiveTitle(fingerprint, titleID string) error { + return d.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketTitles) + key := []byte(fingerprint) + var data PlayerTitleData + + v := b.Get(key) + if v != nil { + if err := json.Unmarshal(v, &data); err != nil { + return err + } + } + + // Verify title is earned + found := false + for _, e := range data.Earned { + if e == titleID { + found = true + break + } + } + if !found { + return nil + } + + data.ActiveTitle = titleID + encoded, err := json.Marshal(data) + if err != nil { + return err + } + return b.Put(key, encoded) + }) +} + +func (d *DB) GetTitleData(fingerprint string) (PlayerTitleData, error) { + var data PlayerTitleData + err := d.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketTitles) + v := b.Get([]byte(fingerprint)) + if v == nil { + return nil + } + return json.Unmarshal(v, &data) + }) + return data, err +} diff --git a/store/titles_test.go b/store/titles_test.go new file mode 100644 index 0000000..44bb16f --- /dev/null +++ b/store/titles_test.go @@ -0,0 +1,115 @@ +package store + +import ( + "testing" +) + +func TestEarnTitle(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir + "/test_titles.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Earn first title - should be newly earned and set as active + newlyEarned, err := db.EarnTitle("fp1", "novice") + if err != nil { + t.Fatal(err) + } + if !newlyEarned { + t.Error("should be newly earned") + } + + data, err := db.GetTitleData("fp1") + if err != nil { + t.Fatal(err) + } + if data.ActiveTitle != "novice" { + t.Errorf("active title should be novice, got %s", data.ActiveTitle) + } + if len(data.Earned) != 1 { + t.Errorf("should have 1 earned title, got %d", len(data.Earned)) + } + + // Earn second title - active should remain first + newlyEarned2, err := db.EarnTitle("fp1", "explorer") + if err != nil { + t.Fatal(err) + } + if !newlyEarned2 { + t.Error("explorer should be newly earned") + } + + data2, err := db.GetTitleData("fp1") + if err != nil { + t.Fatal(err) + } + if data2.ActiveTitle != "novice" { + t.Errorf("active title should remain novice, got %s", data2.ActiveTitle) + } + if len(data2.Earned) != 2 { + t.Errorf("should have 2 earned titles, got %d", len(data2.Earned)) + } + + // Duplicate earn returns false + dup, err := db.EarnTitle("fp1", "novice") + if err != nil { + t.Fatal(err) + } + if dup { + t.Error("duplicate earn should return false") + } +} + +func TestSetActiveTitle(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir + "/test_titles_active.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + db.EarnTitle("fp1", "novice") + db.EarnTitle("fp1", "explorer") + + err = db.SetActiveTitle("fp1", "explorer") + if err != nil { + t.Fatal(err) + } + + data, err := db.GetTitleData("fp1") + if err != nil { + t.Fatal(err) + } + if data.ActiveTitle != "explorer" { + t.Errorf("active title should be explorer, got %s", data.ActiveTitle) + } + + // Setting unearned title should be a no-op + db.SetActiveTitle("fp1", "champion") + data2, _ := db.GetTitleData("fp1") + if data2.ActiveTitle != "explorer" { + t.Errorf("active title should remain explorer, got %s", data2.ActiveTitle) + } +} + +func TestGetTitleDataEmpty(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir + "/test_titles_empty.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + data, err := db.GetTitleData("fp_unknown") + if err != nil { + t.Fatal(err) + } + if data.ActiveTitle != "" { + t.Errorf("expected empty active title, got %s", data.ActiveTitle) + } + if len(data.Earned) != 0 { + t.Errorf("expected no earned titles, got %d", len(data.Earned)) + } +}