diff --git a/Sources/AgentPetCore/PetCare.swift b/Sources/AgentPetCore/PetCare.swift index 29c31bf..3ae3f7f 100644 --- a/Sources/AgentPetCore/PetCare.swift +++ b/Sources/AgentPetCore/PetCare.swift @@ -1,5 +1,23 @@ import Foundation +/// Achievements the pet can unlock over its lifetime. +public enum Achievement: String, Codable, CaseIterable, Sendable { + case firstMeal + case sessions100 + case sessions500 + case tokens1M + case tokens10M + case tokens50M + case level5 + case level10 + case level20 + case level35 + case streak7 + case streak14 + case streak30 + case nightOwl +} + /// How hungry the pet is, derived from the time since its last feeding. public enum PetHunger: String, Codable, CaseIterable, Sendable { case full @@ -36,6 +54,7 @@ public struct PetCareState: Codable, Equatable, Sendable { /// to draw the weekly trend. Optional so states saved before this field /// existed still decode. public var days: [String: Int]? + public var unlockedAchievements: Set? public init() { xp = 0 @@ -228,6 +247,65 @@ public enum PetCare { return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) } + // MARK: - Achievements + + public static func checkAchievements(state: PetCareState, hour: Int) -> Set { + let dl = displayLevel(forXP: state.xp) + + let mealThresholds: [(Int, Achievement)] = [ + (1, .firstMeal), (100, .sessions100), (500, .sessions500), + ] + let tokenThresholds: [(Int, Achievement)] = [ + (1_000_000, .tokens1M), (10_000_000, .tokens10M), (50_000_000, .tokens50M), + ] + let levelThresholds: [(Int, Achievement)] = [ + (5, .level5), (10, .level10), (20, .level20), (35, .level35), + ] + let streakThresholds: [(Int, Achievement)] = [ + (7, .streak7), (14, .streak14), (30, .streak30), + ] + + var result = Set() + for (threshold, badge) in mealThresholds where state.totalMeals >= threshold { result.insert(badge) } + for (threshold, badge) in tokenThresholds where state.totalTokens >= threshold { result.insert(badge) } + for (threshold, badge) in levelThresholds where dl >= threshold { result.insert(badge) } + for (threshold, badge) in streakThresholds where state.streakDays >= threshold { result.insert(badge) } + if hour < 6, state.totalMeals >= 1 { result.insert(.nightOwl) } + return result + } + + @discardableResult + public static func unlockNewAchievements( + state: inout PetCareState, now: Date, calendar: Calendar = .current + ) -> Set { + let hour = calendar.component(.hour, from: now) + let qualified = checkAchievements(state: state, hour: hour) + let already = state.unlockedAchievements ?? [] + let newly = qualified.subtracting(already) + state.unlockedAchievements = already.union(newly) + return newly + } + + /// Human-readable display name for an achievement, localised. + public static func achievementDisplayName(_ a: Achievement) -> String { + switch a { + case .firstMeal: return NSLocalizedString("First Meal", comment: "achievement name") + case .sessions100: return NSLocalizedString("100 Sessions", comment: "achievement name") + case .sessions500: return NSLocalizedString("500 Sessions", comment: "achievement name") + case .tokens1M: return NSLocalizedString("1M Tokens", comment: "achievement name") + case .tokens10M: return NSLocalizedString("10M Tokens", comment: "achievement name") + case .tokens50M: return NSLocalizedString("50M Tokens", comment: "achievement name") + case .level5: return NSLocalizedString("Level 5", comment: "achievement name") + case .level10: return NSLocalizedString("Level 10", comment: "achievement name") + case .level20: return NSLocalizedString("Level 20", comment: "achievement name") + case .level35: return NSLocalizedString("Level 35", comment: "achievement name") + case .streak7: return NSLocalizedString("7-Day Streak", comment: "achievement name") + case .streak14: return NSLocalizedString("14-Day Streak", comment: "achievement name") + case .streak30: return NSLocalizedString("30-Day Streak", comment: "achievement name") + case .nightOwl: return NSLocalizedString("Night Owl", comment: "achievement name") + } + } + private static func markFed(_ state: inout PetCareState, now: Date, calendar: Calendar) { state.lastFedAt = now let today = dayKey(for: now, calendar: calendar) diff --git a/Sources/App/CareTabView.swift b/Sources/App/CareTabView.swift index bc248a4..10bde80 100644 --- a/Sources/App/CareTabView.swift +++ b/Sources/App/CareTabView.swift @@ -107,6 +107,22 @@ struct CareTabView: View { LabeledContent("Total sessions", value: "\(care.current.totalMeals)") } + Section { + let unlocked = care.achievements + if unlocked.isEmpty { + Text("No achievements unlocked yet.") + .foregroundStyle(.secondary) + } else { + ForEach(Array(unlocked).sorted(by: { $0.rawValue < $1.rawValue }), id: \.self) { a in + Text(PetCare.achievementDisplayName(a)) + } + } + } header: { + Text("Achievements") + } footer: { + Text(verbatim: "\(care.achievements.count) of \(Achievement.allCases.count) unlocked") + } + if care.raisedPetIDs.count > 1 { Section("All companions") { ForEach(care.raisedPetIDs, id: \.self) { id in diff --git a/Sources/App/PetCareController.swift b/Sources/App/PetCareController.swift index 0ab0756..ff483b3 100644 --- a/Sources/App/PetCareController.swift +++ b/Sources/App/PetCareController.swift @@ -82,8 +82,10 @@ final class PetCareController: ObservableObject { private func mutateCurrent(_ change: (inout PetCareState) -> Void) { guard let petID = currentPetID else { return } - let levelBefore = PetCare.level(forXP: state(for: petID).xp) - var s = state(for: petID) + let stateBefore = state(for: petID) + let levelBefore = PetCare.level(forXP: stateBefore.xp) + let achievementsBefore = stateBefore.unlockedAchievements ?? [] + var s = stateBefore change(&s) guard s != states[petID] else { return } states[petID] = s @@ -97,8 +99,21 @@ final class PetCareController: ObservableObject { ) PetController.shared.flashCelebrate(line: line) } + let achievementsAfter = s.unlockedAchievements ?? [] + let newAchievements = achievementsAfter.subtracting(achievementsBefore) + for achievement in newAchievements { + let name = PetCare.achievementDisplayName(achievement) + let line = String( + format: NSLocalizedString("Achievement unlocked: %@ 🏆", comment: "achievement unlock celebrate line"), + name + ) + PetController.shared.flashCelebrate(line: line) + } } + /// All achievements unlocked by the currently selected pet. + var achievements: Set { current.unlockedAchievements ?? [] } + private func persist() { if let data = try? JSONEncoder().encode(states) { UserDefaults.standard.set(data, forKey: Self.storageKey) diff --git a/Sources/App/PetStatsView.swift b/Sources/App/PetStatsView.swift index 6fa725c..26c09ce 100644 --- a/Sources/App/PetStatsView.swift +++ b/Sources/App/PetStatsView.swift @@ -25,6 +25,7 @@ struct PetStatsView: View { VStack(alignment: .leading, spacing: 12) { header(state) xpBlock(state) + achievementBlock statGrid(state) trendBlock(state) usageBlock @@ -147,6 +148,51 @@ struct PetStatsView: View { } } + // MARK: - Achievements + + private static let achievementSymbols: [Achievement: String] = [ + .firstMeal: "fork.knife", + .sessions100: "trophy", + .sessions500: "trophy.fill", + .tokens1M: "flame", + .tokens10M: "flame.fill", + .tokens50M: "bolt.fill", + .level5: "star", + .level10: "star.fill", + .level20: "shield.fill", + .level35: "crown.fill", + .streak7: "calendar", + .streak14: "calendar.badge.clock", + .streak30: "calendar.badge.checkmark", + .nightOwl: "moon.fill", + ] + + private var achievementBlock: some View { + let unlocked = care.achievements + let total = Achievement.allCases.count + return VStack(alignment: .leading, spacing: 5) { + HStack { + Text("Achievements") + .font(.system(size: 9, weight: .semibold)).tracking(0.8) + .foregroundStyle(.white.opacity(0.35)) + Spacer() + Text(verbatim: "\(unlocked.count) / \(total)") + .font(.system(size: 9, weight: .semibold)).foregroundStyle(.white.opacity(0.55)) + } + HStack(spacing: 4) { + ForEach(Achievement.allCases, id: \.self) { a in + let symbol = Self.achievementSymbols[a] ?? "star" + let isUnlocked = unlocked.contains(a) + Image(systemName: symbol) + .font(.system(size: 11)) + .foregroundStyle(isUnlocked ? stageColor : Color.white.opacity(0.15)) + .frame(width: 20, height: 20) + .help(PetCare.achievementDisplayName(a)) + } + } + } + } + // MARK: - Stat grid private func statGrid(_ state: PetCareState) -> some View { diff --git a/Tests/AgentPetCoreTests/AchievementTests.swift b/Tests/AgentPetCoreTests/AchievementTests.swift new file mode 100644 index 0000000..6bdda7c --- /dev/null +++ b/Tests/AgentPetCoreTests/AchievementTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import AgentPetCore + +final class AchievementTests: XCTestCase { + + // MARK: - Shared helpers (mirror PetCareTests pattern) + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + private func date(_ s: String) -> Date { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + f.timeZone = TimeZone(identifier: "UTC") + return f.date(from: s)! + } + + // MARK: - 1. First meal + + func testFirstMealUnlocksOnFirstFeeding() { + var state = PetCareState() + let now = date("2026-06-18 10:00") + PetCare.recordMeal(state: &state, now: now, calendar: calendar) + + let newly = PetCare.unlockNewAchievements(state: &state, now: now, calendar: calendar) + + XCTAssertTrue(newly.contains(.firstMeal), + "first meal should unlock .firstMeal; got \(newly)") + } + + // MARK: - 2. Level milestones + + func testLevelMilestones() { + var state = PetCareState() + let now = date("2026-06-18 10:00") + // XP to reach internal level 6 (display level 5) = xpToReach(6) = 60*6*5 = 1800 + // Feed enough meals to exceed that XP threshold. + let targetXP = PetCare.xpToReach(level: 6) // 1800 + let mealsNeeded = Int(ceil(Double(targetXP) / Double(PetCare.mealXP))) + for _ in 0..