feat: achievement system with 10 unlockable achievements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 15:33:12 +09:00
parent 57e56ae7a4
commit fb0e64a109
7 changed files with 229 additions and 4 deletions

72
store/achievements.go Normal file
View File

@@ -0,0 +1,72 @@
package store
import (
bolt "go.etcd.io/bbolt"
)
var bucketAchievements = []byte("achievements")
type Achievement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Unlocked bool `json:"unlocked"`
}
var AchievementDefs = []Achievement{
{ID: "first_clear", Name: "Dungeon Delver", Description: "Clear floor 5 for the first time"},
{ID: "boss_slayer", Name: "Boss Slayer", Description: "Defeat any boss"},
{ID: "floor10", Name: "Deep Explorer", Description: "Reach floor 10"},
{ID: "floor20", Name: "Conqueror", Description: "Conquer the Catacombs (floor 20)"},
{ID: "solo_clear", Name: "Lone Wolf", Description: "Clear floor 5 solo"},
{ID: "gold_hoarder", Name: "Gold Hoarder", Description: "Accumulate 200+ gold in one run"},
{ID: "no_death", Name: "Untouchable", Description: "Complete a floor without anyone dying"},
{ID: "full_party", Name: "Fellowship", Description: "Start a game with 4 players"},
{ID: "relic_collector", Name: "Relic Collector", Description: "Collect 3+ relics in one run"},
{ID: "flee_master", Name: "Tactical Retreat", Description: "Successfully flee from combat"},
}
func (d *DB) initAchievements() error {
return d.db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketAchievements)
return err
})
}
func (d *DB) UnlockAchievement(player, achievementID string) (bool, error) {
key := []byte(player + ":" + achievementID)
alreadyUnlocked := false
err := d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAchievements)
if b.Get(key) != nil {
alreadyUnlocked = true
return nil
}
return b.Put(key, []byte("1"))
})
// Returns true if newly unlocked (not already had it)
return !alreadyUnlocked, err
}
func (d *DB) GetAchievements(player string) ([]Achievement, error) {
unlocked := make(map[string]bool)
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAchievements)
if b == nil {
return nil
}
for _, a := range AchievementDefs {
key := []byte(player + ":" + a.ID)
if b.Get(key) != nil {
unlocked[a.ID] = true
}
}
return nil
})
result := make([]Achievement, len(AchievementDefs))
for i, a := range AchievementDefs {
result[i] = a
result[i].Unlocked = unlocked[a.ID]
}
return result, err
}

View File

@@ -21,6 +21,7 @@ type RunRecord struct {
Player string `json:"player"`
Floor int `json:"floor"`
Score int `json:"score"`
Class string `json:"class,omitempty"`
}
func Open(path string) (*DB, error) {
@@ -35,6 +36,9 @@ func Open(path string) (*DB, error) {
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
return err
}
return nil
})
return &DB{db: db}, err
@@ -63,11 +67,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
return name, err
}
func (d *DB) SaveRun(player string, floor, score int) error {
func (d *DB) SaveRun(player string, floor, score int, class string) 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}
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class}
data, err := json.Marshal(record)
if err != nil {
return err
@@ -76,6 +80,30 @@ func (d *DB) SaveRun(player string, floor, score int) error {
})
}
func (d *DB) TopRunsByGold(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 json.Unmarshal(v, &r) == nil {
runs = append(runs, r)
}
return nil
})
})
if err != nil {
return nil, err
}
sort.Slice(runs, func(i, j int) bool {
return runs[i].Score > runs[j].Score
})
if len(runs) > limit {
runs = runs[:limit]
}
return runs, nil
}
type PlayerStats struct {
TotalRuns int
BestFloor int