Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions Sources/AgentPetCore/PetCare.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Achievement>?

public init() {
xp = 0
Expand Down Expand Up @@ -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<Achievement> {
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<Achievement>()
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<Achievement> {
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)
Expand Down
16 changes: 16 additions & 0 deletions Sources/App/CareTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions Sources/App/PetCareController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Achievement> { current.unlockedAchievements ?? [] }

private func persist() {
if let data = try? JSONEncoder().encode(states) {
UserDefaults.standard.set(data, forKey: Self.storageKey)
Expand Down
46 changes: 46 additions & 0 deletions Sources/App/PetStatsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct PetStatsView: View {
VStack(alignment: .leading, spacing: 12) {
header(state)
xpBlock(state)
achievementBlock
statGrid(state)
trendBlock(state)
usageBlock
Expand Down Expand Up @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions Tests/AgentPetCoreTests/AchievementTests.swift
Original file line number Diff line number Diff line change
@@ -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..<mealsNeeded {
PetCare.recordMeal(state: &state, now: now, calendar: calendar)
}

let newly = PetCare.unlockNewAchievements(state: &state, now: now, calendar: calendar)

XCTAssertTrue(newly.contains(.level5),
"display level ≥ 5 should unlock .level5; got \(newly)")
}

// MARK: - 3. Token milestones

func testTokenMilestones() {
var state = PetCareState()
let now = date("2026-06-18 10:00")
// Feed 1M tokens total
PetCare.feedTokens(1_000_000, state: &state, now: now, calendar: calendar)

let newly = PetCare.unlockNewAchievements(state: &state, now: now, calendar: calendar)

XCTAssertTrue(newly.contains(.tokens1M),
"1M total tokens should unlock .tokens1M; got \(newly)")
}

// MARK: - 4. Streak milestones

func testStreakMilestones() {
var state = PetCareState()
// Simulate 7 consecutive days of feeding
for day in 1...7 {
let d = date(String(format: "2026-06-%02d 10:00", day))
PetCare.recordMeal(state: &state, now: d, calendar: calendar)
}
let lastDay = date("2026-06-07 10:00")

let newly = PetCare.unlockNewAchievements(state: &state, now: lastDay, calendar: calendar)

XCTAssertTrue(newly.contains(.streak7),
"7-day streak should unlock .streak7; got \(newly)")
}

// MARK: - 5. Night owl

func testNightOwl() {
var state = PetCareState()
// Feed at 2am UTC
let nightTime = date("2026-06-18 02:00")
PetCare.recordMeal(state: &state, now: nightTime, calendar: calendar)

let newly = PetCare.unlockNewAchievements(state: &state, now: nightTime, calendar: calendar)

XCTAssertTrue(newly.contains(.nightOwl),
"feeding at 2am should unlock .nightOwl; got \(newly)")
}

// MARK: - 6. No double unlock

func testNoDoubleUnlock() {
var state = PetCareState()
let now = date("2026-06-18 10:00")
PetCare.recordMeal(state: &state, now: now, calendar: calendar)

// First call — should contain firstMeal
let first = PetCare.unlockNewAchievements(state: &state, now: now, calendar: calendar)
XCTAssertTrue(first.contains(.firstMeal))

// Second call with same state — firstMeal is already stored, must not appear again
let second = PetCare.unlockNewAchievements(state: &state, now: now, calendar: calendar)
XCTAssertFalse(second.contains(.firstMeal),
"already-unlocked achievements must not be returned a second time")
}

// MARK: - 7. Backward compat

func testBackwardCompat() throws {
// JSON produced before unlockedAchievements field was added
let legacy = """
{"xp":200,"tokenCarry":0,"tokensToday":0,"mealsToday":0,\
"totalTokens":200000,"totalMeals":5,"dayKey":"2026-06-18",\
"streakDays":3}
"""
let state = try JSONDecoder().decode(
PetCareState.self,
from: legacy.data(using: .utf8)!
)
XCTAssertNil(state.unlockedAchievements,
"legacy JSON without achievements field must decode to nil")
XCTAssertEqual(state.xp, 200)
}

// MARK: - 8. checkAchievements is pure

func testCheckAchievementsIsPure() {
var state = PetCareState()
let now = date("2026-06-18 10:00")
PetCare.feedTokens(1_000_000, state: &state, now: now, calendar: calendar)

let resultA = PetCare.checkAchievements(state: state, hour: 10)
let resultB = PetCare.checkAchievements(state: state, hour: 10)

XCTAssertEqual(resultA, resultB,
"checkAchievements must return identical sets for identical inputs")
// State must not have been mutated
XCTAssertNil(state.unlockedAchievements,
"checkAchievements must not mutate state")
}
}
Loading