diff --git a/game/session.go b/game/session.go index df28a4b..5cd15f2 100644 --- a/game/session.go +++ b/game/session.go @@ -50,6 +50,8 @@ type GameState struct { SubmittedActions map[string]string // fingerprint -> action description PendingLogs []string // logs waiting to be revealed one by one TurnResolving bool // true while logs are being replayed + BossKilled bool + FleeSucceeded bool } func (s *GameSession) addLog(msg string) { @@ -277,6 +279,8 @@ func (s *GameSession) GetState() GameState { SubmittedActions: submittedCopy, PendingLogs: pendingCopy, TurnResolving: s.state.TurnResolving, + BossKilled: s.state.BossKilled, + FleeSucceeded: s.state.FleeSucceeded, } } diff --git a/game/turn.go b/game/turn.go index 8110b72..795739d 100644 --- a/game/turn.go +++ b/game/turn.go @@ -182,6 +182,7 @@ func (s *GameSession) resolvePlayerActions() { case ActionFlee: if combat.AttemptFlee() { s.addLog(fmt.Sprintf("%s fled from battle!", p.Name)) + s.state.FleeSucceeded = true if s.state.SoloMode { s.state.Phase = PhaseExploring return @@ -255,6 +256,7 @@ func (s *GameSession) resolvePlayerActions() { } s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward)) if m.IsBoss { + s.state.BossKilled = true s.grantBossRelic() } } diff --git a/store/achievements.go b/store/achievements.go new file mode 100644 index 0000000..37c9ead --- /dev/null +++ b/store/achievements.go @@ -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 +} diff --git a/store/db.go b/store/db.go index d65ec3c..fb2a178 100644 --- a/store/db.go +++ b/store/db.go @@ -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 diff --git a/ui/achievements_view.go b/ui/achievements_view.go new file mode 100644 index 0000000..c851765 --- /dev/null +++ b/ui/achievements_view.go @@ -0,0 +1,33 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/tolelom/catacombs/store" +) + +func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string { + title := styleHeader.Render("── Achievements ──") + + var content string + unlocked := 0 + for _, a := range achievements { + icon := styleSystem.Render(" ○ ") + nameStyle := styleSystem + if a.Unlocked { + icon = styleGold.Render(" ★ ") + nameStyle = stylePlayer + unlocked++ + } + content += icon + nameStyle.Render(a.Name) + "\n" + content += styleSystem.Render(" "+a.Description) + "\n" + } + + progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d Unlocked", unlocked, len(achievements)))) + + footer := styleSystem.Render("\n[A] Back") + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer)) +} diff --git a/ui/model.go b/ui/model.go index 62b5461..e6bda97 100644 --- a/ui/model.go +++ b/ui/model.go @@ -22,6 +22,8 @@ const ( screenResult screenHelp screenStats + screenAchievements + screenLeaderboard ) // StateUpdateMsg is sent by GameSession to update the view @@ -110,6 +112,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateHelp(msg) case screenStats: return m.updateStats(msg) + case screenAchievements: + return m.updateAchievements(msg) + case screenLeaderboard: + return m.updateLeaderboard(msg) } return m, nil } @@ -143,6 +149,19 @@ func (m Model) View() string { stats, _ = m.store.GetStats(m.playerName) } return renderStats(m.playerName, stats, m.width, m.height) + case screenAchievements: + var achievements []store.Achievement + if m.store != nil { + achievements, _ = m.store.GetAchievements(m.playerName) + } + return renderAchievements(m.playerName, achievements, m.width, m.height) + case screenLeaderboard: + var byFloor, byGold []store.RunRecord + if m.store != nil { + byFloor, _ = m.store.TopRuns(10) + byGold, _ = m.store.TopRunsByGold(10) + } + return renderLeaderboard(byFloor, byGold, m.width, m.height) } return "" } @@ -192,12 +211,19 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { if m.fingerprint == "" { m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano()) } + if m.lobby != nil { + m.lobby.PlayerOnline(m.fingerprint, m.playerName) + } m.screen = screenLobby m = m.withRefreshedLobby() } else if isKey(key, "h") { m.screen = screenHelp } else if isKey(key, "s") { m.screen = screenStats + } else if isKey(key, "a") { + m.screen = screenAchievements + } else if isKey(key, "l") { + m.screen = screenLeaderboard } else if isQuit(key) { return m, tea.Quit } @@ -214,6 +240,24 @@ func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "a") || isEnter(key) || isQuit(key) { + m.screen = screenTitle + } + } + return m, nil +} + +func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "l") || isEnter(key) || isQuit(key) { + m.screen = screenTitle + } + } + return m, nil +} + func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { if isKey(key, "h") || isEnter(key) || isQuit(key) { @@ -275,6 +319,9 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) { } } } else if isKey(key, "q") { + if m.lobby != nil { + m.lobby.PlayerOffline(m.fingerprint) + } m.screen = screenTitle } } @@ -347,7 +394,45 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) { for _, p := range m.gameState.Players { score += p.Gold } - m.store.SaveRun(m.playerName, m.gameState.FloorNum, score) + // Find the current player's class + playerClass := "" + for _, p := range m.gameState.Players { + if p.Fingerprint == m.fingerprint { + playerClass = p.Class.String() + break + } + } + m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass) + // Check achievements + if m.gameState.FloorNum >= 5 { + m.store.UnlockAchievement(m.playerName, "first_clear") + } + if m.gameState.FloorNum >= 10 { + m.store.UnlockAchievement(m.playerName, "floor10") + } + if m.gameState.Victory { + m.store.UnlockAchievement(m.playerName, "floor20") + } + if m.gameState.SoloMode && m.gameState.FloorNum >= 5 { + m.store.UnlockAchievement(m.playerName, "solo_clear") + } + if m.gameState.BossKilled { + m.store.UnlockAchievement(m.playerName, "boss_slayer") + } + if m.gameState.FleeSucceeded { + m.store.UnlockAchievement(m.playerName, "flee_master") + } + for _, p := range m.gameState.Players { + if p.Gold >= 200 { + m.store.UnlockAchievement(p.Name, "gold_hoarder") + } + if len(p.Relics) >= 3 { + m.store.UnlockAchievement(p.Name, "relic_collector") + } + } + if len(m.gameState.Players) >= 4 { + m.store.UnlockAchievement(m.playerName, "full_party") + } m.rankingSaved = true } m.screen = screenResult @@ -555,6 +640,7 @@ func (m Model) withRefreshedLobby() Model { Status: status, } } + m.lobbyState.online = len(m.lobby.ListOnline()) m.lobbyState.cursor = 0 return m } diff --git a/ui/title.go b/ui/title.go index 953a95c..05774ad 100644 --- a/ui/title.go +++ b/ui/title.go @@ -44,7 +44,7 @@ func renderTitle(width, height int) string { menu := lipgloss.NewStyle(). Foreground(colorWhite). Bold(true). - Render("[Enter] Start [H] Help [S] Stats [Q] Quit") + Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit") content := lipgloss.JoinVertical(lipgloss.Center, logo,