Settings & saves
Care reminders Optional care reminders. At most one a day, quiet from 10pm to 9am. No repeat nudges if you’re away.
Your pickle is happy with a daily check-in.
Enable reminders Send a test
@@ -689,6 +702,13 @@ Your pickle, as saved. value && typeof value === 'object' && !Array.isArray(value) ? { streak: value.streak, lastVisitDay: value.lastVisitDay } : { streak: 0, lastVisitDay: '' };
+ let visits = readVisits(nativeProgress);
+ try { visits = readVisits(JSON.parse(localStorage.getItem(VISITS_KEY)) ?? nativeProgress); } catch { /* Native extras or the next visit start the streak. */ }
+ const streak = () => Number.isSafeInteger(visits.streak) ? Math.max(0, Math.min(Life.MAX_STREAK, visits.streak)) : 0;
+ let scrapbookKey = '';
let game = null;
const gameTimers = new Set();
let effectTimer;
@@ -719,6 +739,7 @@
Your pickle, as saved. Your pickle, as saved.
= 0) arcade[id] = records[id];
}
- const raw = await window.LittleDillSaves.encode({ ...state }, nativeProgress ? { ...nativeProgress, arcade } : undefined);
+ const raw = await window.LittleDillSaves.encode({ ...state }, nativeProgress ? { ...nativeProgress, arcade, streak: streak(), lastVisitDay: visits.lastVisitDay || '' } : undefined);
const url = URL.createObjectURL(new Blob([raw], { type: 'application/octet-stream' }));
const link = document.createElement('a');
link.href = url; link.download = 'little-dill-' + new Date().toISOString().replace(/[:.]/g, '-') + '.dill';
@@ -816,9 +837,10 @@
Your pickle, as saved. {
if (!pendingRestore || backupBusy) return;
- syncTime(); previousPet = { pet: { ...state }, native: nativeProgress, records: { ...records } };
+ syncTime(); previousPet = { pet: { ...state }, native: nativeProgress, records: { ...records }, visits: { ...visits } };
if (pendingRestore.native) {
nativeProgress = pendingRestore.native;
+ visits = readVisits(nativeProgress);
for (const id of Object.keys(GAME_NAMES)) {
const score = nativeProgress.arcade?.[id];
if (Number.isSafeInteger(score) && score >= 0) records[id] = Math.max(Number(records[id]) || 0, score);
@@ -832,7 +854,7 @@
Your pickle, as saved. {
if (!previousPet) return;
- nativeProgress = previousPet.native; records = previousPet.records;
+ nativeProgress = previousPet.native; records = previousPet.records; visits = previousPet.visits;
try { localStorage.setItem('little-dill.arcade.v1', JSON.stringify(records)); } catch { /* Scores stay in memory. */ }
replacePet(previousPet.pet); previousPet = null; $('undo-import').hidden = true;
backupNotice(saveAvailable ? 'Import undone. Your previous pickle is saved here again.' : 'Import undone for this visit. Download a backup before leaving.');
@@ -842,7 +864,10 @@
Your pickle, as saved. Your pickle, as saved.
Your pickle, as saved.
'
' + (index < unlocked ? '✦ ' : '') + form.name + ' ' + (index < unlocked ? form.quip : 'Day ' + (14 + index * 3)) + '
').join('');
}
+ renderScrapbook();
+ }
+ function renderScrapbook() {
+ const days = streak(), page = Life.scrapbook(state, Date.now(), days);
+ const key = JSON.stringify([days, page.unlocked, page.moments]);
+ if (scrapbookKey === key) return;
+ scrapbookKey = key;
+ const date = at => new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+ const favorite = Life.favorite(state.variety);
+ $('scrapbook-summary').textContent = 'Scrapbook · ' + days + '-day visit streak';
+ $('scrapbook-page').innerHTML = 'Loves ' + Life.FOOD_NAMES[favorite.food] + ' and ' + Life.GAME_NAMES[favorite.game] + '.
' +
+ 'Streak stickers ' + page.stickers.map(sticker => '
' + (sticker.earned ? '★ ' : '') + sticker.days + ' days
').join('') + '
' +
+ 'Life so far ' + page.moments.map(moment => '' + Life.MOMENTS[moment.id] + ' ' + date(moment.at) + ' ').join('') + ' ' +
+ 'Elder album · ' + page.unlocked + '/32 ' + page.album.map((slot, index) => '
' + (slot.unlocked ? '✦ ' + Life.ELDERS[index].name : 'Locked') + ' ' + (slot.unlocked ? date(slot.at) : 'Day ' + slot.day) + '
').join('') + '
';
+ }
+ function renderWish() {
+ const wish = Life.activeWish(state, Date.now());
+ $('wish').hidden = !wish || !!game;
+ if (!wish) return;
+ const text = '♡ wish: ' + Life.wishText(wish) + ' · ' + Math.max(1, Math.ceil((wish.expiresAt - Date.now()) / Life.HOUR)) + 'h left';
+ if ($('wish').textContent !== text) $('wish').textContent = text;
+ }
+ // Grants a matching wish with a sparkle and a fanfare. Returns the line to add to the pickle's reply.
+ function grantWish(kind, target = '') {
+ const added = Life.grantWish(state, kind, target, Date.now());
+ if (added === null) return '';
+ happyUntil = Date.now() + 5000;
+ if (!game) effect('★ wish granted ★');
+ sfx('win');
+ return ' wish granted! +' + added + ' happy.';
}
document.querySelectorAll('[data-brine]').forEach(button => button.addEventListener('click', () => {
selectedBrine = button.dataset.brine;
@@ -958,6 +1013,7 @@ Your pickle, as saved. little dill' : 'R.I.P. little dill';
$('tombstone').setAttribute('aria-label', state.eaten ? 'Your pickle has been eaten. Crumbs remain.' : 'Your pickle has passed away');
@@ -1088,8 +1144,8 @@
Your pickle, as saved. Your pickle, as saved.
Your pickle, as saved.
= 99) { say('already sparkling. how about a game?'); sfx('blocked'); }
else {
state.hygiene = 100;
const happy = addHappy(4);
- say('a sudsy little bath. +' + happy + ' happy, too.'); effect('✧ squeaky clean ✧'); act('bath', 2800); sfx('clean');
+ effect('✧ squeaky clean ✧'); act('bath', 2800); sfx('clean');
+ say('a sudsy little bath. +' + happy + ' happy, too.' + grantWish('clean'));
}
assessHealth(); recordCare(); save(); render();
}
@@ -1196,8 +1255,10 @@
Your pickle, as saved. lastVisitDay else { return }
+ guard adopted else { return }
let yesterday = Self.dayKey(calendar.date(byAdding: .day, value: -1, to: now) ?? now, calendar: calendar)
- streak = lastVisitDay == yesterday ? min(streak, Self.maxStreak - 1) + 1 : 1
- lastVisitDay = day
+ PetLife.visit(streak: &streak, lastVisitDay: &lastVisitDay, day: day, yesterday: yesterday)
+ }
+
+ /// Rolls a wish for the local day and hour. `food` is the next food in this device's feeding rotation.
+ mutating func rollWish(at now: Date, calendar: Calendar = PetState.localCalendar, food: String) {
+ PetLife.rollWish(&life, now: PetLife.ms(now), day: Self.dayKey(now, calendar: calendar), hour: calendar.component(.hour, from: now), food: food)
+ }
+
+ /// Grants an active wish of this kind: +10 happy from the shared rule plus iOS coins, with the reply line.
+ private mutating func grantWish(_ kind: String, target: String = "", at ms: Int64) -> (added: Int, coins: Int, line: String)? {
+ guard let added = PetLife.grantWish(&life, kind: kind, target: target, now: ms) else { return nil }
+ return (added, addCoins(PetLife.WISH_COINS), " wish granted! +\(added) happy.")
}
private mutating func addHappy(_ amount: Double) -> Int {
@@ -322,7 +334,7 @@ struct PetState: Codable {
}
// Mirrors the web care(), petPickle() and toggleSleep() handlers. Wash follows the web Clean rules.
- @discardableResult mutating func care(_ action: Care, at now: Date = Date()) -> CareResult {
+ @discardableResult mutating func care(_ action: Care, at now: Date = Date(), food: String? = nil) -> CareResult {
if action == .nap { return toggleSleep(at: now) }
refresh(at: now)
guard life.phase == .living, !life.dead else { return CareResult(applied: false, coins: 0, message: restingMessage) }
@@ -331,14 +343,16 @@ struct PetState: Codable {
return CareResult(applied: false, coins: 0, message: "zzZ... use Wake to rise & brine.")
}
let ms = PetLife.ms(now)
- var applied = true, message = ""
+ var applied = true, message = "", favorite = false
switch action {
case .feed:
if life.fullness >= 99 { applied = false; message = "full to the brim. try a little pet!" }
else {
let added = min(40, 100 - life.fullness)
life.fullness = PetLife.clamp(life.fullness + 40)
- message = "finest brine. +\(Int(added.rounded(.toNearestOrAwayFromZero))) food, +\(addHappy(3)) happy."
+ favorite = food != nil && food == PetLife.favorite(life.variety).food
+ let gain = "+\(Int(added.rounded(.toNearestOrAwayFromZero))) food, +\(addHappy(favorite ? 3 + PetLife.FAVORITE_BONUS : 3)) happy."
+ message = (favorite ? "my favorite snack! " : "finest brine. ") + gain
}
case .pet:
if let last = lastPetAt, ms - last < 2000 { return CareResult(applied: false, coins: 0, message: "so loved. another pet in a moment. ♥") }
@@ -350,9 +364,11 @@ struct PetState: Codable {
else { life.hygiene = 100; message = "a sudsy little bath. +\(addHappy(4)) happy, too." }
case .nap: break
}
+ let wish = applied ? grantWish(action == .wash ? "clean" : action.rawValue, at: ms) : nil
PetLife.assess(&life)
life.lastCareAt = ms
- return CareResult(applied: applied, coins: applied ? reward(action) : 0, message: message)
+ return CareResult(applied: applied, coins: (applied ? reward(action) : 0) + (wish?.coins ?? 0), message: message + (wish?.line ?? ""),
+ favorite: favorite, wish: wish != nil)
}
@discardableResult mutating func toggleSleep(at now: Date = Date()) -> CareResult { setSleeping(!life.sleeping, at: now) }
@@ -362,7 +378,9 @@ struct PetState: Codable {
guard life.phase == .living, !life.dead else { return CareResult(applied: false, coins: 0, message: restingMessage) }
life.sleeping = sleeping
life.lastCareAt = PetLife.ms(now)
- return CareResult(applied: true, coins: reward(action), message: sleeping ? "night night. don’t let the dill bugs bite." : "rise & brine, sleepyhead.")
+ let wish = sleeping ? grantWish("nap", at: life.lastCareAt) : nil
+ return CareResult(applied: true, coins: reward(action) + (wish?.coins ?? 0),
+ message: (sleeping ? "night night. don’t let the dill bugs bite." : "rise & brine, sleepyhead.") + (wish?.line ?? ""), wish: wish != nil)
}
mutating func prepareArcade(at now: Date = Date()) -> Bool {
@@ -404,11 +422,15 @@ struct PetState: Codable {
mutating func finishArcade(game: String, score: Int, completed: Bool, at now: Date = Date()) -> Int {
refresh(at: now)
guard completed, Self.arcadeGames.contains(game), life.phase == .living, !life.dead else { return 0 }
- let added = addHappy(Double(Self.arcadeReward(game: game, score: score)))
+ let ms = PetLife.ms(now)
+ let bonus = PetLife.favorite(life.variety).game == game ? PetLife.FAVORITE_BONUS : 0
+ let added = addHappy(Double(Self.arcadeReward(game: game, score: score)) + bonus)
arcadeRecords[game] = max(arcadeRecords[game] ?? 0, max(0, score))
+ PetLife.recordEvent(&life, "game", now: ms)
+ let wish = grantWish("play", target: game, at: ms)
PetLife.assess(&life)
- life.lastCareAt = PetLife.ms(now)
- return added
+ life.lastCareAt = ms
+ return added + (wish?.added ?? 0)
}
@discardableResult mutating func record(score: Int, day: String, at now: Date = Date()) -> Int {
diff --git a/ios/LittleDill/DillStore.swift b/ios/LittleDill/DillStore.swift
index 6c251b0..21a79cf 100644
--- a/ios/LittleDill/DillStore.swift
+++ b/ios/LittleDill/DillStore.swift
@@ -51,6 +51,7 @@ import SwiftUI
private func sync(at time: Date) {
let before = PetLife.stage(pet.life, now: pet.life.updatedAt)
pet.refresh(at: time)
+ pet.rollWish(at: time, food: FeedFood.at(defaults.integer(forKey: FeedFood.turnKey)).rawValue)
if pet.life.dead { arcadeActive = false; activeCircuit = nil; snackBites = nil; snackUntil = nil }
let after = PetLife.stage(pet.life, now: PetLife.ms(time))
if after != before && pet.life.phase == .living && !pet.life.dead { speech = "look at you grow. hello, \(after.rawValue)!" }
@@ -75,13 +76,14 @@ import SwiftUI
speech = "welcome home, \(pet.name)."
save(); return true
}
- @discardableResult func care(_ action: Care) -> CareResult {
+ /// `food` is the food shown for a feed, which decides the favorite-food bonus.
+ @discardableResult func care(_ action: Care, food: FeedFood? = nil) -> CareResult {
if snackBites != nil { cancelBite(); return CareResult(applied: false, coins: 0, message: speech ?? "") }
if action == .nap { return toggleSleep() }
guard !arcadeActive else { return CareResult(applied: false, coins: 0, message: "") }
let time = now
sync(at: time)
- let result = pet.care(action, at: time)
+ let result = pet.care(action, at: time, food: food?.rawValue)
if !result.message.isEmpty { speech = result.message }
save(); return result
}
@@ -134,7 +136,13 @@ import SwiftUI
let circuit = activeCircuit
activeCircuit = nil
let eligible = completed && pet.adopted && !pet.life.dead
+ let wished = PetLife.activeWish(pet.life, now: PetLife.ms(time)).map { $0.kind == "play" && $0.target == game } ?? false
let added = pet.finishArcade(game: game, score: score, completed: completed, at: time)
+ if eligible {
+ let favorite = PetLife.favorite(pet.life.variety).game == game
+ if wished { speech = "wish granted! that was exactly the game i wanted." }
+ else if favorite { speech = "my favorite game! +\(Int(PetLife.FAVORITE_BONUS)) bonus happy." }
+ }
if eligible, let circuit, circuit.game.rawValue == game,
pet.arcadeCircuit.map({ $0.day <= circuit.day }) ?? true {
var progress = pet.arcadeCircuit.flatMap { $0.day == circuit.day ? $0 : nil } ?? ArcadeCircuit(day: circuit.day)
@@ -231,7 +239,8 @@ import SwiftUI
@discardableResult func recordArena(earnedMass: Double, session: String, goalsDone: Int? = nil) -> Int {
let previous = pet.arenaEarnings
let earned = pet.recordArena(earnedMass: earnedMass, session: session, goalsDone: goalsDone, at: now)
- if pet.arenaEarnings != previous { save() }
+ let firstRun = PetLife.recordEvent(&pet.life, "arena", now: PetLife.ms(now))
+ if pet.arenaEarnings != previous || firstRun { save() }
return earned
}
func setHaptics(_ enabled: Bool) { pet.haptics = enabled; save() }
diff --git a/ios/LittleDill/FeedFood.swift b/ios/LittleDill/FeedFood.swift
index a9964b4..1f6b373 100644
--- a/ios/LittleDill/FeedFood.swift
+++ b/ios/LittleDill/FeedFood.swift
@@ -2,6 +2,7 @@ import SwiftUI
enum FeedFood: String, CaseIterable {
case carrot, strawberry, broccoli, apple, cheese
+ static let turnKey = "little-dill.food-turn.v1"
static func at(_ successfulFeeds: Int) -> FeedFood {
allCases[successfulFeeds % allCases.count]
diff --git a/ios/LittleDill/NestView.swift b/ios/LittleDill/NestView.swift
index 59d3054..ddccd3b 100644
--- a/ios/LittleDill/NestView.swift
+++ b/ios/LittleDill/NestView.swift
@@ -8,7 +8,8 @@ struct NestView: View {
var revealCare: () -> Void = {}
@StateObject private var stage = PetStage()
@State private var performance: CarePerformance?
- @AppStorage("little-dill.food-turn.v1",store:ArenaResume.defaults) private var successfulFeeds = 0
+ @AppStorage(FeedFood.turnKey,store:ArenaResume.defaults) private var successfulFeeds = 0
+ @State private var scrapbook = false
@State private var reactionTask: Task?
@State private var arcade = false
@State private var joyBeforeArcade = 0.0
@@ -23,6 +24,7 @@ struct NestView: View {
header
VStack(spacing:12) {
NestMeters(life:life)
+ if let wish = PetLife.activeWish(life,now:nowMs) { WishChip(wish:wish,now:nowMs) }
PetGarden(pet:store.pet,bites:store.snackBites,stage:stage,performance:performance,message:message,
hint:NestText.hint(life,bites:store.snackBites),onTap:tapPickle,onHold:startSnack,restart:restart)
careButtons
@@ -36,8 +38,10 @@ struct NestView: View {
ElderClub(life:life)
RoyaleCard(play:arena)
dailyCard
+ HStack { Image(systemName:"book.closed"); Text("Every big day, kept.").font(.caption); Spacer(); Button("Scrapbook") { scrapbook = true }.font(.caption.bold()).accessibilityIdentifier("openScrapbook") }.foregroundStyle(DillTheme.muted)
HStack { Image(systemName:"tshirt"); Text("A new look, a whole new dill.").font(.caption); Spacer(); Button("Dress up",action:closet).font(.caption.bold()) }.foregroundStyle(DillTheme.muted)
}
+ .sheet(isPresented:$scrapbook) { ScrapbookView().environmentObject(store) }
.fullScreenCover(isPresented:$arcade,onDismiss:arcadeClosed) { ArcadeSheet().environmentObject(store) }
.onAppear {
if let line = store.speech, PetLife.ms(Date()) - life.bornAt < 15_000 { stage.say(line,for:6) }
@@ -118,19 +122,27 @@ struct NestView: View {
private func care(_ action: Care) {
let before = life.fullness
- let result = store.care(action)
+ let food = action == .feed ? FeedFood.at(successfulFeeds) : nil
+ let result = store.care(action, food: food)
stage.say(line(result))
guard result.applied else { stage.play(.wiggle); store.feedback(.rigid); return }
store.feedback()
stage.happyUntil = Date().addingTimeInterval(5)
if action == .feed {
- stage.pop("+\(Int((life.fullness - before).rounded())) food")
- perform(action, food: FeedFood.at(successfulFeeds))
+ stage.pop(result.favorite ? "★ favorite snack ★" : "+\(Int((life.fullness - before).rounded())) food",hearts:result.favorite)
+ perform(action, food: food)
successfulFeeds = (successfulFeeds + 1) % FeedFood.allCases.count
} else {
stage.pop("✧ squeaky clean ✧")
perform(action)
}
+ celebrate(result)
+ }
+
+ private func celebrate(_ result: CareResult) {
+ guard result.wish else { return }
+ stage.pop("★ wish granted ★",hearts:true)
+ store.feedback(.medium)
}
private func pet(performing: Bool) {
@@ -143,6 +155,7 @@ struct NestView: View {
stage.pop(added > 0 ? "+\(added) happy ♥" : "♥ so loved ♥",hearts:true)
store.feedback()
if performing { perform(.pet) } else { stage.play(.hop); store.sound(.pet) }
+ celebrate(result)
}
private func tapPickle() {
@@ -160,6 +173,7 @@ struct NestView: View {
store.feedback(.medium)
if life.sleeping { stage.play(.yawn); perform(.nap) }
else { stopPerformance(); stage.play(.wake); store.sound(.pop) }
+ celebrate(result)
}
private func openArcade() {
@@ -234,6 +248,22 @@ struct NestView: View {
}
}
+/// The active wish, like the web `.wish-chip` above the pickle.
+struct WishChip: View {
+ let wish: PetWish
+ let now: Int64
+ var body: some View {
+ let hours = max(1, Int((Double(wish.expiresAt - now) / Double(PetLife.HOUR)).rounded(.up)))
+ HStack(spacing:6) {
+ Image(systemName:"sparkles").font(.system(size:11,weight:.semibold))
+ Text("wish: " + PetLife.wishText(wish)).font(.system(size:12,weight:.semibold,design:.rounded))
+ Spacer(minLength:4)
+ Text("\(hours)h left").font(.system(size:10,weight:.semibold,design:.monospaced)).foregroundStyle(DillTheme.muted)
+ }.padding(.horizontal,14).frame(minHeight:36).background(DillTheme.peach.opacity(0.55),in:Capsule())
+ .accessibilityElement(children:.combine).accessibilityIdentifier("petWish")
+ }
+}
+
/// The pickle's room: message line, the animated scene, and the web screen hint.
struct PetGarden: View {
let pet: PetState
diff --git a/ios/LittleDill/PetLife.swift b/ios/LittleDill/PetLife.swift
index 598bbea..9a030de 100644
--- a/ios/LittleDill/PetLife.swift
+++ b/ios/LittleDill/PetLife.swift
@@ -102,6 +102,22 @@ struct ElderLook: Equatable, Identifiable {
].enumerated().map { i, e in ElderLook(id: e.0, name: e.1, quip: e.2, hat: e.3, prop: e.4, accent: [0x718449, 0x8b754a, 0x65785f, 0x8e8460][i % 4]) }
}
+struct PetWish: Codable, Equatable { var kind: String, target: String, issuedAt: Int64, expiresAt: Int64 }
+struct WishLog: Codable, Equatable { var day: String, count: Int }
+struct PetEvents: Codable, Equatable {
+ var game: Int64?, wish: Int64?, arena: Int64?
+ subscript(event: String) -> Int64? {
+ get { switch event { case "game": return game; case "wish": return wish; case "arena": return arena; default: return nil } }
+ set { switch event { case "game": game = newValue; case "wish": wish = newValue; case "arena": arena = newValue; default: break } }
+ }
+}
+struct ScrapbookMoment: Equatable { let id: String, at: Int64 }
+struct ScrapbookPage: Equatable {
+ struct Slot: Equatable { let id: String, day: Int, at: Int64, unlocked: Bool }
+ struct Sticker: Equatable { let days: Int, earned: Bool }
+ let moments: [ScrapbookMoment], album: [Slot], unlocked: Int, stickers: [Sticker]
+}
+
enum LifeStage: String { case new, brining, naming, baby, young, teen, adult, elder }
enum PetPhase: String, Codable { case new, brining, naming, living }
enum PetStat: String, CaseIterable { case fullness, happiness, energy, hygiene }
@@ -127,6 +143,9 @@ struct WebPet: Codable, Equatable {
var lastCareAt: Int64 = 0
var updatedAt: Int64 = 0
var eaten: Bool?
+ var wish: PetWish?
+ var wishes: WishLog?
+ var events: PetEvents?
subscript(stat: PetStat) -> Double {
get { switch stat { case .fullness: return fullness; case .happiness: return happiness; case .energy: return energy; case .hygiene: return hygiene } }
@@ -142,6 +161,27 @@ enum PetLife {
static let RATES: [(stat: PetStat, rate: Double)] = [(.fullness, 1.5), (.happiness, 1), (.hygiene, 1.25)]
static let BRINES = ["classic", "garlic", "spicy"]
static let MAX_TIMESTAMP: Int64 = 8_640_000_000_000_000
+ static let FOODS = ["carrot", "strawberry", "broccoli", "apple", "cheese"]
+ static let FOOD_NAMES = ["carrot": "carrot", "strawberry": "strawberry", "broccoli": "broccoli", "apple": "apple slice", "cheese": "cheese wedge"]
+ static let GAMES = ["hunt", "memory", "catch"]
+ static let GAME_NAMES = ["hunt": "Heart hunt", "memory": "Dill says", "catch": "Brine catch"]
+ static let FAVORITES: [String: (food: String, game: String)] = [
+ "dill": ("strawberry", "catch"), "gherkin": ("carrot", "memory"), "garlic": ("broccoli", "hunt"),
+ "butter": ("cheese", "memory"), "chili": ("apple", "hunt"), "pepper": ("carrot", "catch")
+ ]
+ static let FAVORITE_BONUS = 4.0
+ static let WISH_KINDS = ["feed", "clean", "pet", "play", "nap"]
+ static let WISH_MS: Int64 = 6 * HOUR
+ static let WISHES_PER_DAY = 2, WISH_HAPPINESS = 10.0, WISH_COINS = 5
+ // Slot n opens at WISH_HOURS[n] local time; quiet hours run from QUIET_HOUR to WISH_HOURS[0].
+ static let WISH_HOURS = [9, 15], QUIET_HOUR = 22
+ static let STAGE_DAYS: [(id: String, days: Int64)] = [("young", 1), ("teen", 3), ("adult", 7), ("elder", 14)]
+ static let EVENTS = ["game", "wish", "arena"]
+ static let MOMENTS = ["hatch": "Hatched from the brine", "name": "Got a name", "young": "Grew up: young pickle", "teen": "Became a teen",
+ "adult": "All grown up", "elder": "Joined the elder club", "game": "First arcade game", "wish": "First wish granted",
+ "arena": "First arena run"]
+ static let STREAK_STICKERS = [3, 7, 14, 30]
+ static let MAX_STREAK = 100_000
private static let hour = Double(HOUR), neglectLimit = 48 * Double(HOUR)
static func ms(_ date: Date) -> Int64 { Int64((date.timeIntervalSince1970 * 1000).rounded(.down)) }
@@ -191,9 +231,52 @@ enum PetLife {
else { throw PetLifeError.invalid }
var result = pet
result.eaten = pet.eaten == true ? true : nil
+ cleanOptionals(&result)
return result
}
+ // Optional shared fields are dropped when malformed so one bad value cannot block a whole save.
+ static func cleanOptionals(_ pet: inout WebPet) {
+ pet.wish = pet.wish.flatMap(cleanWish)
+ pet.wishes = pet.wishes.flatMap { isDay($0.day) && (0...WISHES_PER_DAY).contains($0.count) ? $0 : nil }
+ if var events = pet.events {
+ for event in EVENTS where !(events[event].map(isTimestamp) ?? false) { events[event] = nil }
+ pet.events = EVENTS.contains { events[$0] != nil } ? events : nil
+ }
+ }
+
+ static func cleanWish(_ wish: PetWish) -> PetWish? {
+ let target: String? = wish.kind == "feed" ? (FOODS.contains(wish.target) ? wish.target : nil)
+ : wish.kind == "play" ? (GAMES.contains(wish.target) ? wish.target : nil) : ""
+ guard WISH_KINDS.contains(wish.kind), wish.target == target, isTimestamp(wish.issuedAt), isTimestamp(wish.expiresAt),
+ wish.expiresAt > wish.issuedAt, wish.expiresAt - wish.issuedAt <= WISH_MS else { return nil }
+ return wish
+ }
+
+ /// JS /^\d{4}-\d{2}-\d{2}$/.
+ static func isDay(_ day: String) -> Bool {
+ let bytes = Array(day.utf8)
+ return bytes.count == 10 && bytes.enumerated().allSatisfy { i, b in i == 4 || i == 7 ? b == 45 : (48...57).contains(b) }
+ }
+
+ private static func optionals(json value: JSONValue) -> (PetWish?, WishLog?, PetEvents?) {
+ var wish: PetWish?, wishes: WishLog?, events: PetEvents?
+ if let raw = value["wish"], raw.isObject, let kind = raw["kind"]?.string, let target = raw["target"]?.string,
+ let issuedAt = raw["issuedAt"]?.safeInteger, let expiresAt = raw["expiresAt"]?.safeInteger {
+ wish = PetWish(kind: kind, target: target, issuedAt: issuedAt, expiresAt: expiresAt)
+ }
+ if let raw = value["wishes"], raw.isObject, let day = raw["day"]?.string, let count = raw["count"]?.safeInteger,
+ (0...Int64(WISHES_PER_DAY)).contains(count) {
+ wishes = WishLog(day: day, count: Int(count))
+ }
+ if let raw = value["events"], raw.isObject {
+ var found = PetEvents()
+ for event in EVENTS { found[event] = raw[event]?.safeInteger }
+ events = found
+ }
+ return (wish, wishes, events)
+ }
+
static func validate(json value: JSONValue) throws -> WebPet {
guard value.isObject, value["version"]?.number == 2,
let phase = value["phase"]?.string.flatMap(PetPhase.init(rawValue:)), let name = value["name"]?.string,
@@ -208,10 +291,11 @@ enum PetLife {
else { throw PetLifeError.invalid }
var eaten: Bool?
if let raw = value["eaten"] { guard let flag = raw.bool else { throw PetLifeError.invalid }; eaten = flag }
+ let (wish, wishes, events) = optionals(json: value)
return try validate(WebPet(version: 2, phase: phase, name: name, variety: variety, brine: brine, brinedAt: brinedAt, hatchAt: hatchAt,
bornAt: bornAt, fullness: fullness, happiness: happiness, energy: energy, hygiene: hygiene,
sleeping: sleeping, sick: sick, dead: dead, neglectMs: neglectMs, diedAt: diedAt,
- lastCareAt: lastCareAt, updatedAt: updatedAt, eaten: eaten))
+ lastCareAt: lastCareAt, updatedAt: updatedAt, eaten: eaten, wish: wish, wishes: wishes, events: events))
}
static func restore(json value: JSONValue, now: Int64) throws -> WebPet {
@@ -314,4 +398,88 @@ enum PetLife {
let hours = RATES.map { max(0, pet[$0.stat] - 55) / $0.rate }.min()!
return Double(now) + max(2, hours) * hour
}
+
+ static func dayNumber(_ day: String) -> Int64 {
+ let parts = day.split(separator: "-").compactMap { Int64($0) }
+ guard parts.count == 3 else { return 0 }
+ return parts[0] * 372 + (parts[1] - 1) * 31 + parts[2] - 1
+ }
+
+ static func favorite(_ variety: String) -> (food: String, game: String) { FAVORITES[variety] ?? FAVORITES["dill"]! }
+
+ static func activeWish(_ pet: WebPet, now: Int64) -> PetWish? {
+ guard pet.phase == .living, !pet.dead, let wish = pet.wish, now < wish.expiresAt else { return nil }
+ return wish
+ }
+
+ /// `day` and `hour` are the device's local calendar day and hour; `food` is the next food in the feeding rotation.
+ @discardableResult static func rollWish(_ pet: inout WebPet, now: Int64, day: String, hour: Int, food: String) -> PetWish? {
+ if pet.wish != nil && activeWish(pet, now: now) == nil { pet.wish = nil }
+ guard pet.phase == .living, !pet.dead, !pet.sleeping, pet.wish == nil, !trim(pet.name).isEmpty, isDay(day) else { return nil }
+ if let log = pet.wishes, log.day > day { return nil }
+ let count = pet.wishes?.day == day ? pet.wishes!.count : 0
+ guard count < WISHES_PER_DAY, hour >= WISH_HOURS[count], hour < QUIET_HOUR else { return nil }
+ let kinds = WISH_KINDS.filter { $0 == "clean" ? pet.hygiene < 80 : $0 == "nap" ? pet.energy < 50 : true }
+ let seed = pet.bornAt / 1000 + dayNumber(day) * 7 + Int64(count) * 3
+ let kind = kinds[Int(seed % Int64(kinds.count))]
+ let target = kind == "feed" ? (FOODS.contains(food) ? food : FOODS[0])
+ : kind == "play" ? GAMES[Int(seed / Int64(kinds.count) % Int64(GAMES.count))] : ""
+ let wish = PetWish(kind: kind, target: target, issuedAt: now, expiresAt: now + WISH_MS)
+ pet.wish = wish
+ pet.wishes = WishLog(day: day, count: count + 1)
+ return wish
+ }
+
+ @discardableResult static func recordEvent(_ pet: inout WebPet, _ event: String, now: Int64) -> Bool {
+ guard pet.phase == .living, EVENTS.contains(event), !(pet.events?[event].map(isTimestamp) ?? false) else { return false }
+ var events = pet.events ?? PetEvents()
+ events[event] = now
+ pet.events = events
+ return true
+ }
+
+ /// Feed wishes match by kind; play wishes need the named game. Returns the happiness added, or nil.
+ static func grantWish(_ pet: inout WebPet, kind: String, target: String = "", now: Int64) -> Int? {
+ guard let wish = activeWish(pet, now: now), wish.kind == kind, kind != "play" || wish.target == target else { return nil }
+ pet.wish = nil
+ let before = pet.happiness
+ pet.happiness = clamp(before + WISH_HAPPINESS)
+ recordEvent(&pet, "wish", now: now)
+ return Int((pet.happiness - before).rounded(.toNearestOrAwayFromZero))
+ }
+
+ static func wishText(_ wish: PetWish) -> String {
+ switch wish.kind {
+ case "feed": return "a " + (FOOD_NAMES[wish.target] ?? wish.target)
+ case "clean": return "a bubble bath"
+ case "pet": return "a little pet"
+ case "play": return "a round of " + (GAME_NAMES[wish.target] ?? wish.target)
+ default: return "a cozy nap"
+ }
+ }
+
+ static func scrapbook(_ pet: WebPet, now: Int64, streak: Int) -> ScrapbookPage {
+ let stickers = STREAK_STICKERS.map { ScrapbookPage.Sticker(days: $0, earned: streak >= $0) }
+ guard pet.phase == .living else { return ScrapbookPage(moments: [], album: [], unlocked: 0, stickers: stickers) }
+ let reached = age(pet, now: now)
+ var moments = [ScrapbookMoment(id: "hatch", at: pet.hatchAt), ScrapbookMoment(id: "name", at: pet.bornAt)]
+ for (id, days) in STAGE_DAYS where reached >= days * DAY { moments.append(ScrapbookMoment(id: id, at: pet.bornAt + days * DAY)) }
+ for id in EVENTS { if let at = pet.events?[id], isTimestamp(at) { moments.append(ScrapbookMoment(id: id, at: at)) } }
+ // Stable order for equal timestamps, like the JS sort.
+ moments = moments.enumerated().sorted { ($0.element.at, $0.offset) < ($1.element.at, $1.offset) }.map(\.element)
+ let unlocked = elder(pet, now: now)?.unlocked ?? 0
+ let album = ElderLook.all.enumerated().map { index, form in
+ ScrapbookPage.Slot(id: form.id, day: 14 + index * 3, at: pet.bornAt + Int64(14 + index * 3) * DAY, unlocked: index < unlocked)
+ }
+ return ScrapbookPage(moments: moments, album: album, unlocked: unlocked, stickers: stickers)
+ }
+
+ /// The visit streak: a new local day extends it when the last visit was yesterday, otherwise restarts at 1.
+ @discardableResult static func visit(streak: inout Int, lastVisitDay: inout String, day: String, yesterday: String) -> Bool {
+ let last = isDay(lastVisitDay) ? lastVisitDay : ""
+ guard isDay(day), day > last else { return false }
+ streak = last == yesterday ? min(max(0, streak), MAX_STREAK - 1) + 1 : 1
+ lastVisitDay = day
+ return true
+ }
}
diff --git a/ios/LittleDill/ScrapbookView.swift b/ios/LittleDill/ScrapbookView.swift
new file mode 100644
index 0000000..ca87990
--- /dev/null
+++ b/ios/LittleDill/ScrapbookView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+/// Life events derived from the pet rules, the stored first-time moments, streak stickers and the 32-slot elder album.
+struct ScrapbookView: View {
+ @EnvironmentObject private var store: DillStore
+ @Environment(\.dismiss) private var dismiss
+ var body: some View {
+ let now = PetLife.ms(Date())
+ let page = PetLife.scrapbook(store.pet.life, now: now, streak: store.pet.streak)
+ let favorite = PetLife.favorite(store.pet.life.variety)
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(store.pet.name).font(DillTheme.display(30)).tracking(-1)
+ Label("\(store.pet.streak)-day visit streak", systemImage: "flame.fill").font(.system(size: 12, weight: .semibold))
+ .accessibilityIdentifier("scrapbook.streak")
+ Text("Loves \(PetLife.FOOD_NAMES[favorite.food] ?? favorite.food) and \(PetLife.GAME_NAMES[favorite.game] ?? favorite.game).")
+ .font(.caption).foregroundStyle(DillTheme.muted)
+ }
+ stickers(page.stickers)
+ moments(page.moments)
+ album(page)
+ }.padding(24).frame(maxWidth: 640, alignment: .leading).frame(maxWidth: .infinity)
+ }
+ .background(DillTheme.cream.ignoresSafeArea())
+ .navigationTitle("Scrapbook").navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
+ }
+ .foregroundStyle(DillTheme.ink)
+ .accessibilityIdentifier("scrapbook")
+ }
+
+ private func heading(_ text: String) -> some View {
+ Text(text.uppercased()).font(.system(size: 9, weight: .bold, design: .monospaced)).tracking(1.4).foregroundStyle(DillTheme.muted)
+ }
+
+ private func stickers(_ stickers: [ScrapbookPage.Sticker]) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ heading("Streak stickers")
+ HStack(spacing: 10) {
+ ForEach(stickers, id: \.days) { sticker in
+ VStack(spacing: 4) {
+ Image(systemName: sticker.earned ? "star.circle.fill" : "lock.circle").font(.system(size: 26))
+ Text("\(sticker.days) days").font(.system(size: 10, weight: .semibold, design: .rounded))
+ }.frame(maxWidth: .infinity).padding(.vertical, 12)
+ .background(sticker.earned ? DillTheme.peach.opacity(0.6) : .clear, in: RoundedRectangle(cornerRadius: 14))
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(DillTheme.line, lineWidth: 1))
+ .foregroundStyle(sticker.earned ? DillTheme.ink : DillTheme.muted)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(sticker.days)-day streak sticker, \(sticker.earned ? "earned" : "locked")")
+ }
+ }
+ }
+ }
+
+ private func moments(_ moments: [ScrapbookMoment]) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ heading("Life so far")
+ if moments.isEmpty { Text("The first page is waiting for a hatchling.").font(.caption).foregroundStyle(DillTheme.muted) }
+ ForEach(moments, id: \.id) { moment in
+ HStack(spacing: 10) {
+ Image(systemName: "sparkle").font(.system(size: 11))
+ Text(PetLife.MOMENTS[moment.id] ?? moment.id).font(.system(size: 13, weight: .semibold, design: .rounded))
+ Spacer(minLength: 6)
+ Text(PetLife.date(moment.at).formatted(date: .abbreviated, time: .omitted)).font(.system(size: 11)).foregroundStyle(DillTheme.muted)
+ }.padding(.vertical, 4).accessibilityElement(children: .combine)
+ }
+ }.accessibilityIdentifier("scrapbook.moments")
+ }
+
+ private func album(_ page: ScrapbookPage) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ heading("Elder album · \(page.unlocked)/32")
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 7)], spacing: 7) {
+ ForEach(Array(page.album.enumerated()), id: \.element.id) { index, slot in
+ let form = ElderLook.all[index]
+ VStack(alignment: .leading, spacing: 5) {
+ Text((slot.unlocked ? "✦ " : "") + (slot.unlocked ? form.name : "Locked")).font(.system(size: 11, weight: .bold))
+ Text(slot.unlocked ? PetLife.date(slot.at).formatted(date: .abbreviated, time: .omitted) : "Day \(slot.day)")
+ .font(.system(size: 10)).foregroundStyle(DillTheme.muted)
+ }.padding(11).frame(maxWidth: .infinity, alignment: .leading)
+ .background(slot.unlocked ? Color(hex: 0xE7ECD9) : .clear, in: RoundedRectangle(cornerRadius: 9))
+ .overlay(RoundedRectangle(cornerRadius: 9).stroke(DillTheme.line, lineWidth: 1))
+ .foregroundStyle(slot.unlocked ? DillTheme.ink : DillTheme.muted)
+ .accessibilityElement(children: .combine)
+ }
+ }
+ }.accessibilityIdentifier("scrapbook.album")
+ }
+}
diff --git a/ios/LittleDillTests/DillTests.swift b/ios/LittleDillTests/DillTests.swift
index a92bcd7..3b7b08a 100644
--- a/ios/LittleDillTests/DillTests.swift
+++ b/ios/LittleDillTests/DillTests.swift
@@ -650,10 +650,13 @@ final class PetModelTests: XCTestCase {
}
@MainActor func testBackupRoundTripCarriesNativeExtrasAndCanBeUndone() throws {
let clockA = TestClock(t0), a = hatched(suite(), clockA, brine: .spicy, name: "Sir Crunch")
- XCTAssertEqual(a.care(.feed).coins, 5)
+ // A wish rolls when t0 falls in a local wish slot, which depends on the runner's time zone.
+ let feedWish = PetLife.activeWish(a.pet.life, now: PetLife.ms(clockA.now))?.kind == "feed"
+ XCTAssertEqual(a.care(.feed).coins, feedWish ? 5 + PetLife.WISH_COINS : 5)
XCTAssertEqual(a.record(score: 250, day: DailyChallenge.today(clockA.now)), 25)
XCTAssertTrue(a.equip(.shades))
XCTAssertTrue(a.startArcade()); a.finishArcade(game: "catch", score: 7, completed: true)
+ let wishCoins = a.pet.life.events?.wish != nil ? PetLife.WISH_COINS : 0
let file = try a.exportBackup()
XCTAssertLessThanOrEqual(file.count, DillBackup.MAX_FILE_BYTES)
let clockB = TestClock(t0.addingTimeInterval(3600)), b = DillStore(defaults: suite(), clock: { clockB.now })
@@ -662,7 +665,7 @@ final class PetModelTests: XCTestCase {
XCTAssertEqual(preview.savedAt, clockA.now); XCTAssertEqual(preview.rows.map(\.label), ["Name", "Age", "Food", "Happy", "Energy", "Clean", "Condition"])
let exported = a.pet.life
b.restoreBackup(preview)
- XCTAssertEqual(b.pet.name, "Sir Crunch"); XCTAssertEqual(b.pet.coins, 0); XCTAssertEqual(b.pet.outfit, .shades)
+ XCTAssertEqual(b.pet.name, "Sir Crunch"); XCTAssertEqual(b.pet.coins, wishCoins); XCTAssertEqual(b.pet.outfit, .shades)
XCTAssertEqual(b.pet.arcadeRecords["catch"], 7); XCTAssertEqual(b.pet.scores.first?.score, 250)
XCTAssertEqual(b.pet.life.fullness, exported.fullness - 1.5 * 3540 / 3600, accuracy: 1e-9, "Elapsed time applies after restore")
XCTAssertTrue(b.canUndoRestore); XCTAssertTrue(b.undoRestore())
diff --git a/ios/LittleDillTests/PetWishTests.swift b/ios/LittleDillTests/PetWishTests.swift
new file mode 100644
index 0000000..31753cc
--- /dev/null
+++ b/ios/LittleDillTests/PetWishTests.swift
@@ -0,0 +1,185 @@
+import XCTest
+@testable import LittleDill
+
+final class PetWishTests: XCTestCase {
+ private let t0 = ISO8601DateFormatter().date(from: "2026-09-17T12:00:00Z")!
+ private var utc: Calendar { var c = Calendar(identifier: .gregorian); c.timeZone = TimeZone(secondsFromGMT: 0)!; return c }
+ private func ms(_ date: Date) -> Int64 { PetLife.ms(date) }
+ private func living(at now: Date, variety: String = "garlic") -> PetState {
+ var p = PetState(now: now)
+ p.life.phase = .living; p.life.name = "Wishful"; p.life.variety = variety; p.life.brine = PickleVariety.of(variety).brine
+ p.life.bornAt = ms(now) - 3 * PetLife.DAY; p.life.hatchAt = p.life.bornAt; p.life.brinedAt = p.life.bornAt - PetLife.HATCH_MS
+ return p
+ }
+ private func wish(_ kind: String, _ target: String = "", at now: Date) -> PetWish {
+ PetWish(kind: kind, target: target, issuedAt: ms(now), expiresAt: ms(now) + PetLife.WISH_MS)
+ }
+
+ func testNativeRulesMatchTheSharedPetFixture() throws {
+ let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "pet-parity", withExtension: "json"))
+ let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url))
+ XCTAssertEqual(PickleVariety.all.map(\.id).sorted(), fixture.favorites.keys.sorted())
+ for (variety, favorite) in fixture.favorites {
+ XCTAssertEqual(PetLife.favorite(variety).food, favorite.food, variety)
+ XCTAssertEqual(PetLife.favorite(variety).game, favorite.game, variety)
+ }
+ XCTAssertEqual(PetLife.FAVORITE_BONUS, fixture.favoriteBonus)
+ XCTAssertEqual(PetLife.FOODS, fixture.foods); XCTAssertEqual(FeedFood.allCases.map(\.rawValue), fixture.foods)
+ XCTAssertEqual(PetLife.FOOD_NAMES, fixture.foodNames); XCTAssertEqual(FeedFood.allCases.map(\.name), fixture.foods.map { fixture.foodNames[$0]! })
+ XCTAssertEqual(PetLife.GAMES, fixture.games); XCTAssertEqual(PetLife.GAME_NAMES, fixture.gameNames)
+ XCTAssertEqual(PetLife.WISH_KINDS, fixture.wish.kinds); XCTAssertEqual(PetLife.WISH_MS, fixture.wish.ms)
+ XCTAssertEqual(PetLife.WISHES_PER_DAY, fixture.wish.perDay); XCTAssertEqual(PetLife.WISH_HAPPINESS, fixture.wish.happiness)
+ XCTAssertEqual(PetLife.WISH_COINS, fixture.wish.coins); XCTAssertEqual(PetLife.WISH_HOURS, fixture.wish.hours)
+ XCTAssertEqual(PetLife.QUIET_HOUR, fixture.wish.quietHour)
+ XCTAssertEqual(PetLife.MOMENTS, fixture.moments); XCTAssertEqual(PetLife.STREAK_STICKERS, fixture.streakStickers)
+ XCTAssertEqual(PetLife.MAX_STREAK, fixture.maxStreak); XCTAssertEqual(PetState.maxStreak, fixture.maxStreak)
+ for row in fixture.wishTexts { XCTAssertEqual(PetLife.wishText(row.wish), row.text) }
+ for row in fixture.rolls {
+ var pet = PetLife.fresh(now: row.now)
+ pet.phase = .living; pet.name = "Parity"; pet.bornAt = row.pet.bornAt; pet.hygiene = row.pet.hygiene; pet.energy = row.pet.energy
+ pet.sleeping = row.pet.sleeping; pet.dead = row.pet.dead; pet.diedAt = row.pet.dead ? row.now : 0
+ pet.wish = row.pet.wish; pet.wishes = row.pet.wishes
+ PetLife.rollWish(&pet, now: row.now, day: row.day, hour: row.hour, food: row.food)
+ XCTAssertEqual(pet.wish, row.wish, row.name); XCTAssertEqual(pet.wishes, row.wishes, row.name)
+ }
+ for row in fixture.scrapbook {
+ var pet = PetLife.fresh(now: row.pet.bornAt)
+ pet.phase = .living; pet.name = "Parity"; pet.bornAt = row.pet.bornAt; pet.hatchAt = row.pet.hatchAt
+ pet.dead = row.pet.dead; pet.diedAt = row.pet.diedAt; pet.events = row.pet.events
+ let page = PetLife.scrapbook(pet, now: row.now, streak: row.streak)
+ XCTAssertEqual(page.moments.map(\.id), row.moments.map(\.id), row.name)
+ XCTAssertEqual(page.moments.map(\.at), row.moments.map(\.at), row.name)
+ XCTAssertEqual(page.unlocked, row.unlocked, row.name)
+ XCTAssertEqual(page.stickers.filter(\.earned).map(\.days), row.stickers, row.name)
+ XCTAssertEqual(page.album.filter(\.unlocked).map(\.id), row.album.map { $0[0].string ?? "" }, row.name)
+ XCTAssertEqual(page.album.filter(\.unlocked).map(\.at), row.album.map { $0[1].safeInteger ?? -1 }, row.name)
+ }
+ for row in fixture.visits {
+ var streak = row.streak, last = row.lastVisitDay
+ XCTAssertEqual(PetLife.visit(streak: &streak, lastVisitDay: &last, day: row.day, yesterday: row.yesterday), row.changed, row.name)
+ XCTAssertEqual(streak, row.after.streak, row.name); XCTAssertEqual(last, row.after.lastVisitDay, row.name)
+ }
+ }
+
+ func testRollWaitsForAwakeDaytimeAndCapsAtTwoADay() {
+ let morning = utc.date(byAdding: .hour, value: -4, to: t0)!
+ var p = living(at: morning)
+ p.rollWish(at: morning, calendar: utc, food: "apple"); XCTAssertNil(p.life.wish, "8am is quiet")
+ p.life.sleeping = true; p.rollWish(at: t0, calendar: utc, food: "apple"); XCTAssertNil(p.life.wish)
+ p.life.sleeping = false; p.rollWish(at: t0, calendar: utc, food: "apple")
+ let first = try? XCTUnwrap(p.life.wish)
+ XCTAssertEqual(p.life.wishes, WishLog(day: "2026-09-17", count: 1))
+ p.rollWish(at: t0.addingTimeInterval(4 * 3600), calendar: utc, food: "apple"); XCTAssertEqual(p.life.wish, first, "one wish at a time")
+ p.rollWish(at: t0.addingTimeInterval(6 * 3600), calendar: utc, food: "apple")
+ XCTAssertEqual(p.life.wishes?.count, 2, "the expired wish clears and the 3pm slot opens")
+ p.life.wish = nil
+ p.rollWish(at: t0.addingTimeInterval(8 * 3600), calendar: utc, food: "apple"); XCTAssertNil(p.life.wish, "two a day")
+ p.rollWish(at: t0.addingTimeInterval(11 * 3600), calendar: utc, food: "apple"); XCTAssertNil(p.life.wish, "11pm is quiet")
+ p.rollWish(at: t0.addingTimeInterval(24 * 3600), calendar: utc, food: "apple"); XCTAssertEqual(p.life.wishes?.count, 1)
+ }
+
+ func testFeedWishSurvivesRefusedFeedAndGrantsHappinessAndCoins() {
+ var p = living(at: t0)
+ p.life.fullness = 99.5; p.life.happiness = 40; p.life.wish = wish("feed", "carrot", at: t0)
+ XCTAssertEqual(p.care(.feed, at: t0, food: "carrot").applied, false)
+ XCTAssertNotNil(p.life.wish, "a refused feed keeps the wish")
+ let later = t0.addingTimeInterval(3600), coins = p.coins
+ let result = p.care(.feed, at: later, food: "carrot")
+ XCTAssertEqual(result, CareResult(applied: true, coins: 10, message: "finest brine. +2 food, +3 happy. wish granted! +10 happy.", wish: true))
+ XCTAssertEqual(p.coins, coins + 10); XCTAssertNil(p.life.wish)
+ XCTAssertEqual(p.life.happiness, 52, accuracy: 1e-9); XCTAssertEqual(p.life.events?.wish, ms(later))
+ XCTAssertEqual(p.care(.pet, at: later).message, "+8 happy. you’re my favorite human.", "no wish, no bonus")
+ }
+
+ func testCleanPetAndNapWishesGrantOnTheirCare() {
+ var p = living(at: t0)
+ p.life.hygiene = 50; p.life.wish = wish("clean", at: t0)
+ XCTAssertTrue(p.care(.wash, at: t0).wish)
+ p.life.wish = wish("pet", at: t0)
+ XCTAssertFalse(p.care(.wash, at: t0).wish, "the wrong care leaves the wish")
+ XCTAssertTrue(p.care(.pet, at: t0).wish)
+ p.life.wish = wish("nap", at: t0); p.life.happiness = 50
+ let nap = p.toggleSleep(at: t0)
+ XCTAssertTrue(nap.wish); XCTAssertEqual(nap.coins, 10); XCTAssertTrue(nap.message.hasSuffix("wish granted! +10 happy."))
+ }
+
+ func testFavoritesAddFourHappinessForFoodAndGame() {
+ var p = living(at: t0, variety: "dill")
+ p.life.fullness = 10; p.life.happiness = 40
+ let snack = p.care(.feed, at: t0, food: "strawberry")
+ XCTAssertTrue(snack.favorite); XCTAssertEqual(snack.message, "my favorite snack! +40 food, +7 happy.")
+ p.life.fullness = 10
+ XCTAssertEqual(p.care(.feed, at: t0, food: "carrot").message, "finest brine. +40 food, +3 happy.")
+ p.life.happiness = 40
+ XCTAssertEqual(p.finishArcade(game: "catch", score: 0, completed: true, at: t0), 14)
+ XCTAssertEqual(p.finishArcade(game: "hunt", score: 0, completed: true, at: t0), 10)
+ XCTAssertEqual(p.life.events?.game, ms(t0))
+ p.life.wish = wish("play", "memory", at: t0)
+ XCTAssertEqual(p.finishArcade(game: "hunt", score: 0, completed: true, at: t0), 10, "play wishes need the named game")
+ XCTAssertEqual(p.finishArcade(game: "memory", score: 0, completed: true, at: t0), 20)
+ XCTAssertNil(p.life.wish)
+ }
+
+ func testSaveAndBackupRoundTripsWithAndWithoutNewFields() throws {
+ var p = living(at: t0)
+ p.life.wish = wish("play", "hunt", at: t0); p.life.wishes = WishLog(day: "2026-09-17", count: 1)
+ p.life.events = PetEvents(game: ms(t0) - 1, wish: nil, arena: ms(t0) - 2)
+ let decoded = try JSONDecoder().decode(PetState.self, from: JSONEncoder().encode(p))
+ XCTAssertEqual(decoded.life, p.life); XCTAssertTrue(decoded.isValid)
+ let backup = try DillBackup.decode(DillBackup.encode(pet: p.life, native: p.native, savedAt: ms(t0)))
+ XCTAssertEqual(backup.pet, .current(p.life))
+ var old = living(at: t0)
+ let oldJSON = try XCTUnwrap(String(data: JSONEncoder().encode(old), encoding: .utf8))
+ XCTAssertFalse(oldJSON.contains("wish") || oldJSON.contains("events"))
+ XCTAssertEqual(try JSONDecoder().decode(PetState.self, from: Data(oldJSON.utf8)).life, old.life)
+ XCTAssertEqual(try DillBackup.decode(DillBackup.encode(pet: old.life, native: nil, savedAt: ms(t0))).pet, .current(old.life))
+ old.life.wish = PetWish(kind: "dance", target: "", issuedAt: 0, expiresAt: 1)
+ old.life.events = PetEvents(game: -1)
+ let cleaned = try JSONDecoder().decode(PetState.self, from: JSONEncoder().encode(old))
+ XCTAssertNil(cleaned.life.wish); XCTAssertNil(cleaned.life.events)
+ var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(p.life)) as? [String: Any])
+ json["wish"] = ["kind": "feed", "target": "pizza", "issuedAt": 1, "expiresAt": 2]
+ json["wishes"] = ["day": "2026-09-17", "count": 3]
+ json["events"] = ["game": 5, "party": 6, "wish": 1.5]
+ let lenient = try PetLife.validate(json: JSONValue.parse(JSONSerialization.data(withJSONObject: json)))
+ XCTAssertNil(lenient.wish); XCTAssertNil(lenient.wishes); XCTAssertEqual(lenient.events, PetEvents(game: 5))
+ }
+
+ @MainActor func testStoreRollsWithTheRotationFoodAndRecordsTheFirstArenaRun() {
+ let name = "test.wish.\(UUID())", defaults = UserDefaults(suiteName: name)!
+ addTeardownBlock { defaults.removePersistentDomain(forName: name) }
+ defaults.set(3, forKey: FeedFood.turnKey)
+ var pet = living(at: t0)
+ pet.life.updatedAt = ms(t0)
+ defaults.set(try? JSONEncoder().encode(pet), forKey: "little-dill.native.v1")
+ let store = DillStore(defaults: defaults, clock: { self.t0 })
+ if let wish = store.pet.life.wish, wish.kind == "feed" { XCTAssertEqual(wish.target, "apple") }
+ XCTAssertNil(store.pet.life.events?.arena)
+ store.recordArena(earnedMass: 10, session: "public-1:a")
+ XCTAssertEqual(store.pet.life.events?.arena, ms(t0))
+ store.recordArena(earnedMass: 20, session: "public-1:a")
+ XCTAssertEqual(DillStore(defaults: defaults, clock: { self.t0 }).pet.life.events?.arena, ms(t0))
+ }
+
+ private struct Fixture: Decodable {
+ struct Favorite: Decodable { let food: String, game: String }
+ struct Wish: Decodable { let kinds: [String]; let ms: Int64; let perDay: Int; let happiness: Double; let coins: Int; let hours: [Int]; let quietHour: Int }
+ struct Text: Decodable { let wish: PetWish; let text: String }
+ struct RollPet: Decodable { let bornAt: Int64; let hygiene: Double; let energy: Double; let sleeping: Bool; let dead: Bool; let wish: PetWish?; let wishes: WishLog? }
+ struct Roll: Decodable { let name: String; let pet: RollPet; let now: Int64; let day: String; let hour: Int; let food: String; let wish: PetWish?; let wishes: WishLog? }
+ struct PagePet: Decodable { let hatchAt: Int64; let bornAt: Int64; let dead: Bool; let diedAt: Int64; let events: PetEvents? }
+ struct Moment: Decodable { let id: String; let at: Int64 }
+ struct Page: Decodable { let name: String; let pet: PagePet; let now: Int64; let streak: Int; let moments: [Moment]; let unlocked: Int; let stickers: [Int]; let album: [[JSONValue]] }
+ struct Record: Decodable { let streak: Int; let lastVisitDay: String }
+ struct Visit: Decodable { let name: String; let streak: Int; let lastVisitDay: String; let day: String; let yesterday: String; let changed: Bool; let after: Record }
+ let favorites: [String: Favorite]
+ let favoriteBonus: Double
+ let foods: [String], foodNames: [String: String], games: [String], gameNames: [String: String]
+ let wish: Wish
+ let wishTexts: [Text]
+ let moments: [String: String]
+ let streakStickers: [Int]
+ let maxStreak: Int
+ let rolls: [Roll], scrapbook: [Page], visits: [Visit]
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 6990811..6cb7e6c 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -33,6 +33,8 @@ targets:
- LittleDillTests
- path: ../tests/fixtures/arena-parity.json
buildPhase: resources
+ - path: ../tests/fixtures/pet-parity.json
+ buildPhase: resources
dependencies:
- target: LittleDill
settings:
diff --git a/pet-life.js b/pet-life.js
index 83260e7..72655a3 100644
--- a/pet-life.js
+++ b/pet-life.js
@@ -57,8 +57,55 @@
['band', 'Band Kid', 'This one time, at brine camp...', 'shako', 'trumpet'],
['prep', 'Prep', 'Early decision. Extra brine.', 'mortarboard', 'phone']
].map(([id, name, quip, hat, prop], index) => ({ id, name, quip, hat, prop, accent: ['#5b6f8a', '#3d3d47', '#8a5b5b', '#6f7f4a'][index % 4] }));
+ const FOODS = ['carrot', 'strawberry', 'broccoli', 'apple', 'cheese'];
+ const FOOD_NAMES = { carrot: 'carrot', strawberry: 'strawberry', broccoli: 'broccoli', apple: 'apple slice', cheese: 'cheese wedge' };
+ const GAMES = ['hunt', 'memory', 'catch'];
+ const GAME_NAMES = { hunt: 'Heart hunt', memory: 'Dill says', catch: 'Brine catch' };
+ const FAVORITES = {
+ dill: { food: 'strawberry', game: 'catch' }, gherkin: { food: 'carrot', game: 'memory' },
+ garlic: { food: 'broccoli', game: 'hunt' }, butter: { food: 'cheese', game: 'memory' },
+ chili: { food: 'apple', game: 'hunt' }, pepper: { food: 'carrot', game: 'catch' }
+ };
+ const FAVORITE_BONUS = 4;
+ const WISH_KINDS = ['feed', 'clean', 'pet', 'play', 'nap'];
+ const WISH_MS = 6 * HOUR, WISHES_PER_DAY = 2, WISH_HAPPINESS = 10, WISH_COINS = 5;
+ // Slot n opens at WISH_HOURS[n] local time; quiet hours run from QUIET_HOUR to WISH_HOURS[0].
+ const WISH_HOURS = [9, 15], QUIET_HOUR = 22;
+ const STAGE_DAYS = [['young', 1], ['teen', 3], ['adult', 7], ['elder', 14]];
+ const EVENTS = ['game', 'wish', 'arena'];
+ const MOMENTS = { hatch: 'Hatched from the brine', name: 'Got a name', young: 'Grew up: young pickle', teen: 'Became a teen',
+ adult: 'All grown up', elder: 'Joined the elder club', game: 'First arcade game', wish: 'First wish granted', arena: 'First arena run' };
+ const STREAK_STICKERS = [3, 7, 14, 30];
+ const MAX_STREAK = 100000;
const clamp = value => Math.max(0, Math.min(100, value));
const timestamp = value => Number.isSafeInteger(value) && value >= 0 && value <= 8640000000000000;
+ const isDay = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
+ const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value);
+ function cleanWish(value) {
+ if (!isObject(value) || !WISH_KINDS.includes(value.kind) || typeof value.target !== 'string' ||
+ value.target !== (value.kind === 'feed' ? (FOODS.includes(value.target) ? value.target : null) :
+ value.kind === 'play' ? (GAMES.includes(value.target) ? value.target : null) : '') ||
+ !timestamp(value.issuedAt) || !timestamp(value.expiresAt) || value.expiresAt <= value.issuedAt ||
+ value.expiresAt - value.issuedAt > WISH_MS) return undefined;
+ return { kind: value.kind, target: value.target, issuedAt: value.issuedAt, expiresAt: value.expiresAt };
+ }
+ function cleanWishes(value) {
+ if (!isObject(value) || !isDay(value.day) || !Number.isSafeInteger(value.count) || value.count < 0 || value.count > WISHES_PER_DAY) return undefined;
+ return { day: value.day, count: value.count };
+ }
+ function cleanEvents(value) {
+ if (!isObject(value)) return undefined;
+ const events = Object.fromEntries(EVENTS.filter(key => timestamp(value[key])).map(key => [key, value[key]]));
+ return Object.keys(events).length ? events : undefined;
+ }
+ // Optional shared fields are dropped when malformed so one bad value cannot block a whole save.
+ function withOptionals(pet, value) {
+ for (const [key, clean] of [['wish', cleanWish], ['wishes', cleanWishes], ['events', cleanEvents]]) {
+ const cleaned = clean(value[key]);
+ if (cleaned) pet[key] = cleaned;
+ }
+ return pet;
+ }
function fresh(now = Date.now()) {
return { version: 2, phase: 'new', name: '', variety: 'dill', brine: 'classic', brinedAt: 0, hatchAt: 0, bornAt: 0,
fullness: 90, happiness: 85, energy: 90, hygiene: 95, sleeping: false, sick: false, dead: false,
@@ -79,7 +126,7 @@
}
const pet = Object.fromEntries(Object.keys(fresh()).map(key => [key, value[key]]));
if (value.eaten) pet.eaten = true;
- return pet;
+ return withOptionals(pet, value);
}
function restore(value, now = Date.now()) {
if (value?.version === 2) return validate(value);
@@ -154,7 +201,78 @@
const hours = Math.min(...Object.entries(RATES).map(([key, rate]) => Math.max(0, pet[key] - 55) / rate));
return now + Math.max(2, hours) * HOUR;
}
- const api = Object.freeze({ HOUR, DAY, HATCH_MS, STATS, RATES, VARIETIES, ELDERS, TEENS, fresh, validate, restore, brine, name, assess, advance, age, stage, teen, elder, nextCareAt });
+ const pad = (value, size = 2) => String(value).padStart(size, '0');
+ const dayKey = date => pad(date.getFullYear(), 4) + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
+ function localTime(now = Date.now()) {
+ const date = new Date(now);
+ return { day: dayKey(date), yesterday: dayKey(new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1)), hour: date.getHours() };
+ }
+ function dayNumber(day) {
+ const [year, month, date] = day.split('-').map(Number);
+ return year * 372 + (month - 1) * 31 + date - 1;
+ }
+ function favorite(variety) { return FAVORITES[variety] || FAVORITES.dill; }
+ function activeWish(pet, now = Date.now()) {
+ return pet.phase === 'living' && !pet.dead && pet.wish && now < pet.wish.expiresAt ? pet.wish : null;
+ }
+ // `day` and `hour` are the device's local calendar day and hour; `food` is the next food in the feeding rotation.
+ function rollWish(pet, { now = Date.now(), day, hour, food }) {
+ if (pet.wish && !activeWish(pet, now)) delete pet.wish;
+ if (pet.phase !== 'living' || pet.dead || pet.sleeping || pet.wish || !pet.name.trim() || !isDay(day)) return null;
+ if (pet.wishes && pet.wishes.day > day) return null;
+ const count = pet.wishes?.day === day ? pet.wishes.count : 0;
+ if (count >= WISHES_PER_DAY || hour < WISH_HOURS[count] || hour >= QUIET_HOUR) return null;
+ const kinds = WISH_KINDS.filter(kind => kind === 'clean' ? pet.hygiene < 80 : kind === 'nap' ? pet.energy < 50 : true);
+ const seed = Math.floor(pet.bornAt / 1000) + dayNumber(day) * 7 + count * 3;
+ const kind = kinds[seed % kinds.length];
+ const target = kind === 'feed' ? (FOODS.includes(food) ? food : FOODS[0]) : kind === 'play' ? GAMES[Math.floor(seed / kinds.length) % GAMES.length] : '';
+ pet.wish = { kind, target, issuedAt: now, expiresAt: now + WISH_MS };
+ pet.wishes = { day, count: count + 1 };
+ return pet.wish;
+ }
+ function recordEvent(pet, event, now = Date.now()) {
+ if (pet.phase !== 'living' || !EVENTS.includes(event) || timestamp(pet.events?.[event])) return false;
+ pet.events = { ...pet.events, [event]: now };
+ return true;
+ }
+ // Feed wishes match by kind; play wishes need the named game. Returns the happiness added, or null.
+ function grantWish(pet, kind, target = '', now = Date.now()) {
+ const wish = activeWish(pet, now);
+ if (!wish || wish.kind !== kind || (kind === 'play' && wish.target !== target)) return null;
+ delete pet.wish;
+ const before = pet.happiness;
+ pet.happiness = clamp(before + WISH_HAPPINESS);
+ recordEvent(pet, 'wish', now);
+ return Math.round(pet.happiness - before);
+ }
+ function wishText(wish) {
+ return { feed: 'a ' + FOOD_NAMES[wish.target], clean: 'a bubble bath', pet: 'a little pet', play: 'a round of ' + GAME_NAMES[wish.target], nap: 'a cozy nap' }[wish.kind];
+ }
+ function scrapbook(pet, now = Date.now(), streak = 0) {
+ const stickers = STREAK_STICKERS.map(days => ({ days, earned: streak >= days }));
+ if (pet.phase !== 'living') return { moments: [], album: [], unlocked: 0, stickers };
+ const reached = age(pet, now);
+ const moments = [{ id: 'hatch', at: pet.hatchAt }, { id: 'name', at: pet.bornAt }];
+ for (const [id, days] of STAGE_DAYS) if (reached >= days * DAY) moments.push({ id, at: pet.bornAt + days * DAY });
+ for (const id of EVENTS) if (timestamp(pet.events?.[id])) moments.push({ id, at: pet.events[id] });
+ moments.sort((a, b) => a.at - b.at);
+ const unlocked = elder(pet, now)?.unlocked || 0;
+ const album = ELDERS.map((form, index) => ({ id: form.id, day: 14 + index * 3, at: pet.bornAt + (14 + index * 3) * DAY, unlocked: index < unlocked }));
+ return { moments, album, unlocked, stickers };
+ }
+ // The iOS visit streak: a new local day extends it when the last visit was yesterday, otherwise restarts at 1.
+ function visit(record, day, yesterday) {
+ const last = isDay(record.lastVisitDay) ? record.lastVisitDay : '';
+ const streak = Number.isSafeInteger(record.streak) ? Math.max(0, Math.min(MAX_STREAK, record.streak)) : 0;
+ if (!isDay(day) || day <= last) return false;
+ record.streak = last === yesterday ? Math.min(streak, MAX_STREAK - 1) + 1 : 1;
+ record.lastVisitDay = day;
+ return true;
+ }
+ const api = Object.freeze({ HOUR, DAY, HATCH_MS, STATS, RATES, VARIETIES, ELDERS, TEENS, FOODS, FOOD_NAMES, GAMES, GAME_NAMES, FAVORITES, FAVORITE_BONUS,
+ WISH_KINDS, WISH_MS, WISHES_PER_DAY, WISH_HAPPINESS, WISH_COINS, WISH_HOURS, QUIET_HOUR, STAGE_DAYS, MOMENTS, STREAK_STICKERS, MAX_STREAK,
+ fresh, validate, restore, brine, name, assess, advance, age, stage, teen, elder, nextCareAt,
+ localTime, favorite, activeWish, rollWish, recordEvent, grantWish, wishText, scrapbook, visit });
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.LittleDillLife = api;
})(globalThis);
diff --git a/sw.js b/sw.js
index 8b3bc2a..e9f5254 100644
--- a/sw.js
+++ b/sw.js
@@ -1,5 +1,5 @@
/* Bump CACHE_VERSION with every deploy so installed apps pick up new files. */
-const CACHE_VERSION = 'little-dill-v17';
+const CACHE_VERSION = 'little-dill-v18';
const PRECACHE = [
'./',
'./save-codec.js',
diff --git a/tests/feeding.test.cjs b/tests/feeding.test.cjs
index a143618..48fa7aa 100644
--- a/tests/feeding.test.cjs
+++ b/tests/feeding.test.cjs
@@ -1,3 +1,5 @@
+// Wishes roll by local hour; the fixed test clock (08:00 UTC) sits in quiet hours.
+process.env.TZ = 'UTC';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -110,3 +112,38 @@ test('feeding resumes the saved assortment after a page reload', () => {
app.click('feed');
assert.equal(app.get('feed-food').dataset.food, 'apple');
});
+
+test('a variety’s favorite food adds 4 happiness and its own reaction', () => {
+ const app = client({ fullness: 0 }, { storage: { 'little-dill.food-turn.v1': '1' } });
+ app.click('feed');
+ assert.equal(app.get('feed-food').dataset.food, 'strawberry');
+ assert.equal(app.get('message').textContent, 'strawberry! my favorite! +40 food, +7 happy.');
+ assert.equal(app.get('effect').textContent, '★ favorite snack ★');
+ assert.equal(app.saved().happiness, 27);
+ app.click('feed');
+ assert.equal(app.get('message').textContent, 'broccoli time! +40 food, +3 happy.');
+});
+
+test('a feed wish keeps the rotation on a refused feed and the next successful feed grants it', () => {
+ const now = 1800000000000, day = 86400000;
+ const pet = { version: 2, phase: 'living', name: 'Wishful', variety: 'garlic', brine: 'garlic', brinedAt: now - 3 * day - 60000, hatchAt: now - 3 * day,
+ bornAt: now - 3 * day, fullness: 99.5, happiness: 40, energy: 80, hygiene: 90, sleeping: false, sick: false, dead: false, neglectMs: 0, diedAt: 0,
+ lastCareAt: now, updatedAt: now, wish: { kind: 'feed', target: 'carrot', issuedAt: now, expiresAt: now + 6 * 3600000 }, wishes: { day: '2027-01-15', count: 2 } };
+ const app = client({}, { storage: { 'little-dill.v2': JSON.stringify(pet) } });
+ assert.equal(app.get('wish').hidden, false);
+ assert.equal(app.get('wish').textContent, '♡ wish: a carrot · 6h left');
+ app.click('feed');
+ assert.match(app.get('message').textContent, /full to the brim/);
+ assert.deepEqual(app.saved().wish, pet.wish);
+ assert.equal(app.storage.get('little-dill.food-turn.v1'), undefined, 'a refused feed leaves the rotation');
+ app.run(3600000);
+ app.click('feed');
+ assert.equal(app.get('feed-food').dataset.food, 'carrot');
+ assert.equal(app.get('message').textContent, 'carrot time! +2 food, +3 happy. wish granted! +10 happy.');
+ assert.equal(app.get('effect').textContent, '★ wish granted ★');
+ assert.equal(app.saved().wish, undefined);
+ assert.ok(Math.abs(app.saved().happiness - 52) < 1e-9);
+ assert.equal(app.saved().events.wish, now + 3600000);
+ assert.equal(app.get('wish').hidden, true);
+ assert.equal(app.foodAt(Number(app.storage.get('little-dill.food-turn.v1'))), 'strawberry');
+});
diff --git a/tests/fixtures/pet-parity.json b/tests/fixtures/pet-parity.json
new file mode 100644
index 0000000..cec56b2
--- /dev/null
+++ b/tests/fixtures/pet-parity.json
@@ -0,0 +1,1604 @@
+{
+ "favorites": {
+ "dill": {
+ "food": "strawberry",
+ "game": "catch"
+ },
+ "gherkin": {
+ "food": "carrot",
+ "game": "memory"
+ },
+ "garlic": {
+ "food": "broccoli",
+ "game": "hunt"
+ },
+ "butter": {
+ "food": "cheese",
+ "game": "memory"
+ },
+ "chili": {
+ "food": "apple",
+ "game": "hunt"
+ },
+ "pepper": {
+ "food": "carrot",
+ "game": "catch"
+ }
+ },
+ "favoriteBonus": 4,
+ "foods": [
+ "carrot",
+ "strawberry",
+ "broccoli",
+ "apple",
+ "cheese"
+ ],
+ "foodNames": {
+ "carrot": "carrot",
+ "strawberry": "strawberry",
+ "broccoli": "broccoli",
+ "apple": "apple slice",
+ "cheese": "cheese wedge"
+ },
+ "games": [
+ "hunt",
+ "memory",
+ "catch"
+ ],
+ "gameNames": {
+ "hunt": "Heart hunt",
+ "memory": "Dill says",
+ "catch": "Brine catch"
+ },
+ "wish": {
+ "kinds": [
+ "feed",
+ "clean",
+ "pet",
+ "play",
+ "nap"
+ ],
+ "ms": 21600000,
+ "perDay": 2,
+ "happiness": 10,
+ "coins": 5,
+ "hours": [
+ 9,
+ 15
+ ],
+ "quietHour": 22
+ },
+ "wishTexts": [
+ {
+ "wish": {
+ "kind": "feed",
+ "target": "apple",
+ "issuedAt": 0,
+ "expiresAt": 1
+ },
+ "text": "a apple slice"
+ },
+ {
+ "wish": {
+ "kind": "clean",
+ "target": "",
+ "issuedAt": 0,
+ "expiresAt": 1
+ },
+ "text": "a bubble bath"
+ },
+ {
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 0,
+ "expiresAt": 1
+ },
+ "text": "a little pet"
+ },
+ {
+ "wish": {
+ "kind": "play",
+ "target": "memory",
+ "issuedAt": 0,
+ "expiresAt": 1
+ },
+ "text": "a round of Dill says"
+ },
+ {
+ "wish": {
+ "kind": "nap",
+ "target": "",
+ "issuedAt": 0,
+ "expiresAt": 1
+ },
+ "text": "a cozy nap"
+ }
+ ],
+ "moments": {
+ "hatch": "Hatched from the brine",
+ "name": "Got a name",
+ "young": "Grew up: young pickle",
+ "teen": "Became a teen",
+ "adult": "All grown up",
+ "elder": "Joined the elder club",
+ "game": "First arcade game",
+ "wish": "First wish granted",
+ "arena": "First arena run"
+ },
+ "streakStickers": [
+ 3,
+ 7,
+ 14,
+ 30
+ ],
+ "maxStreak": 100000,
+ "rolls": [
+ {
+ "name": "morning slot opens at nine",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 9,
+ "food": "apple",
+ "wish": {
+ "kind": "play",
+ "target": "memory",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "quiet before nine",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 8,
+ "food": "apple",
+ "wish": null,
+ "wishes": null
+ },
+ {
+ "name": "quiet from ten at night",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 22,
+ "food": "apple",
+ "wish": null,
+ "wishes": null
+ },
+ {
+ "name": "asleep",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": true,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 12,
+ "food": "apple",
+ "wish": null,
+ "wishes": null
+ },
+ {
+ "name": "dead",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": true
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 12,
+ "food": "apple",
+ "wish": null,
+ "wishes": null
+ },
+ {
+ "name": "second slot waits for three",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 14,
+ "food": "apple",
+ "wish": null,
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "second slot at three",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 15,
+ "food": "apple",
+ "wish": {
+ "kind": "play",
+ "target": "catch",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 2
+ }
+ },
+ {
+ "name": "daily cap",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 2
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 18,
+ "food": "apple",
+ "wish": null,
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 2
+ }
+ },
+ {
+ "name": "new day resets the count",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wishes": {
+ "day": "2027-01-14",
+ "count": 2
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 10,
+ "food": "broccoli",
+ "wish": {
+ "kind": "play",
+ "target": "memory",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "clock rollback",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wishes": {
+ "day": "2027-01-16",
+ "count": 1
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 10,
+ "food": "apple",
+ "wish": null,
+ "wishes": {
+ "day": "2027-01-16",
+ "count": 1
+ }
+ },
+ {
+ "name": "active wish stays",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1799996400000,
+ "expiresAt": 1800018000000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 16,
+ "food": "apple",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1799996400000,
+ "expiresAt": 1800018000000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "expired wish clears before the next slot",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false,
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1799978400000,
+ "expiresAt": 1800000000000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 16,
+ "food": "cheese",
+ "wish": {
+ "kind": "play",
+ "target": "catch",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 2
+ }
+ },
+ {
+ "name": "needy pickle seed 0",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "carrot",
+ "wish": {
+ "kind": "clean",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 1",
+ "pet": {
+ "bornAt": 1799740801000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "strawberry",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 2",
+ "pet": {
+ "bornAt": 1799740802000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "broccoli",
+ "wish": {
+ "kind": "play",
+ "target": "catch",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 3",
+ "pet": {
+ "bornAt": 1799740803000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "apple",
+ "wish": {
+ "kind": "nap",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 4",
+ "pet": {
+ "bornAt": 1799740804000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "cheese",
+ "wish": {
+ "kind": "feed",
+ "target": "cheese",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 5",
+ "pet": {
+ "bornAt": 1799740805000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "carrot",
+ "wish": {
+ "kind": "clean",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 6",
+ "pet": {
+ "bornAt": 1799740806000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "strawberry",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 7",
+ "pet": {
+ "bornAt": 1799740807000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "broccoli",
+ "wish": {
+ "kind": "play",
+ "target": "hunt",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 8",
+ "pet": {
+ "bornAt": 1799740808000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "apple",
+ "wish": {
+ "kind": "nap",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 9",
+ "pet": {
+ "bornAt": 1799740809000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "cheese",
+ "wish": {
+ "kind": "feed",
+ "target": "cheese",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 10",
+ "pet": {
+ "bornAt": 1799740810000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "carrot",
+ "wish": {
+ "kind": "clean",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "needy pickle seed 11",
+ "pet": {
+ "bornAt": 1799740811000,
+ "hygiene": 50,
+ "energy": 30,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-01-15",
+ "hour": 11,
+ "food": "strawberry",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-01-15",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 0",
+ "pet": {
+ "bornAt": 1799740800000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-01",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-01",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 1",
+ "pet": {
+ "bornAt": 1799740801000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-02",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "feed",
+ "target": "carrot",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-02",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 2",
+ "pet": {
+ "bornAt": 1799740802000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-03",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "play",
+ "target": "memory",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-03",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 3",
+ "pet": {
+ "bornAt": 1799740803000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-04",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "pet",
+ "target": "",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-04",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 4",
+ "pet": {
+ "bornAt": 1799740804000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-05",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "feed",
+ "target": "carrot",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-05",
+ "count": 1
+ }
+ },
+ {
+ "name": "healthy pickle seed 5",
+ "pet": {
+ "bornAt": 1799740805000,
+ "hygiene": 95,
+ "energy": 90,
+ "sleeping": false,
+ "dead": false
+ },
+ "now": 1800000000000,
+ "day": "2027-02-06",
+ "hour": 12,
+ "food": "carrot",
+ "wish": {
+ "kind": "play",
+ "target": "hunt",
+ "issuedAt": 1800000000000,
+ "expiresAt": 1800021600000
+ },
+ "wishes": {
+ "day": "2027-02-06",
+ "count": 1
+ }
+ }
+ ],
+ "scrapbook": [
+ {
+ "name": "half a day",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0
+ },
+ "now": 1800043200000,
+ "streak": 0,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ }
+ ],
+ "unlocked": 0,
+ "stickers": [],
+ "album": []
+ },
+ {
+ "name": "young at one day",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000030000,
+ "dead": false,
+ "diedAt": 0,
+ "events": {
+ "game": 1800003600000
+ }
+ },
+ "now": 1800086430000,
+ "streak": 3,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000030000
+ },
+ {
+ "id": "game",
+ "at": 1800003600000
+ },
+ {
+ "id": "young",
+ "at": 1800086430000
+ }
+ ],
+ "unlocked": 0,
+ "stickers": [
+ 3
+ ],
+ "album": []
+ },
+ {
+ "name": "teen",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0,
+ "events": {
+ "wish": 1800172800000,
+ "game": 1800172800000
+ }
+ },
+ "now": 1800259200000,
+ "streak": 6,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "game",
+ "at": 1800172800000
+ },
+ {
+ "id": "wish",
+ "at": 1800172800000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ }
+ ],
+ "unlocked": 0,
+ "stickers": [
+ 3
+ ],
+ "album": []
+ },
+ {
+ "name": "adult",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0
+ },
+ "now": 1800604800000,
+ "streak": 7,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ }
+ ],
+ "unlocked": 0,
+ "stickers": [
+ 3,
+ 7
+ ],
+ "album": []
+ },
+ {
+ "name": "first elder",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0,
+ "events": {
+ "arena": 1800691200000,
+ "game": 1800003600000,
+ "wish": 1800777600000
+ }
+ },
+ "now": 1801209600000,
+ "streak": 14,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "game",
+ "at": 1800003600000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ },
+ {
+ "id": "arena",
+ "at": 1800691200000
+ },
+ {
+ "id": "wish",
+ "at": 1800777600000
+ },
+ {
+ "id": "elder",
+ "at": 1801209600000
+ }
+ ],
+ "unlocked": 1,
+ "stickers": [
+ 3,
+ 7,
+ 14
+ ],
+ "album": [
+ [
+ "grandill",
+ 1801209600000
+ ]
+ ]
+ },
+ {
+ "name": "third elder",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0
+ },
+ "now": 1801728000000,
+ "streak": 29,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ },
+ {
+ "id": "elder",
+ "at": 1801209600000
+ }
+ ],
+ "unlocked": 3,
+ "stickers": [
+ 3,
+ 7,
+ 14
+ ],
+ "album": [
+ [
+ "grandill",
+ 1801209600000
+ ],
+ [
+ "retired-dj",
+ 1801468800000
+ ],
+ [
+ "tax-wizard",
+ 1801728000000
+ ]
+ ]
+ },
+ {
+ "name": "full album",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0
+ },
+ "now": 1809244800000,
+ "streak": 30,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ },
+ {
+ "id": "elder",
+ "at": 1801209600000
+ }
+ ],
+ "unlocked": 32,
+ "stickers": [
+ 3,
+ 7,
+ 14,
+ 30
+ ],
+ "album": [
+ [
+ "grandill",
+ 1801209600000
+ ],
+ [
+ "retired-dj",
+ 1801468800000
+ ],
+ [
+ "tax-wizard",
+ 1801728000000
+ ],
+ [
+ "lawn-lord",
+ 1801987200000
+ ],
+ [
+ "soup-oracle",
+ 1802246400000
+ ],
+ [
+ "disco",
+ 1802505600000
+ ],
+ [
+ "admiral",
+ 1802764800000
+ ],
+ [
+ "professor",
+ 1803024000000
+ ],
+ [
+ "couch-duke",
+ 1803283200000
+ ],
+ [
+ "pickle-cowboy",
+ 1803542400000
+ ],
+ [
+ "tea-gossip",
+ 1803801600000
+ ],
+ [
+ "space-grandpa",
+ 1804060800000
+ ],
+ [
+ "yoga",
+ 1804320000000
+ ],
+ [
+ "pirate",
+ 1804579200000
+ ],
+ [
+ "detective",
+ 1804838400000
+ ],
+ [
+ "influencer",
+ 1805097600000
+ ],
+ [
+ "goth",
+ 1805356800000
+ ],
+ [
+ "billionaire",
+ 1805616000000
+ ],
+ [
+ "mushroom",
+ 1805875200000
+ ],
+ [
+ "chef",
+ 1806134400000
+ ],
+ [
+ "knight",
+ 1806393600000
+ ],
+ [
+ "weather",
+ 1806652800000
+ ],
+ [
+ "punk",
+ 1806912000000
+ ],
+ [
+ "gardener",
+ 1807171200000
+ ],
+ [
+ "accountant",
+ 1807430400000
+ ],
+ [
+ "zen",
+ 1807689600000
+ ],
+ [
+ "magician",
+ 1807948800000
+ ],
+ [
+ "artist",
+ 1808208000000
+ ],
+ [
+ "royal",
+ 1808467200000
+ ],
+ [
+ "timekeeper",
+ 1808726400000
+ ],
+ [
+ "cosmic",
+ 1808985600000
+ ],
+ [
+ "eternal",
+ 1809244800000
+ ]
+ ]
+ },
+ {
+ "name": "second lap",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": false,
+ "diedAt": 0
+ },
+ "now": 1825920000000,
+ "streak": 100,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ },
+ {
+ "id": "elder",
+ "at": 1801209600000
+ }
+ ],
+ "unlocked": 32,
+ "stickers": [
+ 3,
+ 7,
+ 14,
+ 30
+ ],
+ "album": [
+ [
+ "grandill",
+ 1801209600000
+ ],
+ [
+ "retired-dj",
+ 1801468800000
+ ],
+ [
+ "tax-wizard",
+ 1801728000000
+ ],
+ [
+ "lawn-lord",
+ 1801987200000
+ ],
+ [
+ "soup-oracle",
+ 1802246400000
+ ],
+ [
+ "disco",
+ 1802505600000
+ ],
+ [
+ "admiral",
+ 1802764800000
+ ],
+ [
+ "professor",
+ 1803024000000
+ ],
+ [
+ "couch-duke",
+ 1803283200000
+ ],
+ [
+ "pickle-cowboy",
+ 1803542400000
+ ],
+ [
+ "tea-gossip",
+ 1803801600000
+ ],
+ [
+ "space-grandpa",
+ 1804060800000
+ ],
+ [
+ "yoga",
+ 1804320000000
+ ],
+ [
+ "pirate",
+ 1804579200000
+ ],
+ [
+ "detective",
+ 1804838400000
+ ],
+ [
+ "influencer",
+ 1805097600000
+ ],
+ [
+ "goth",
+ 1805356800000
+ ],
+ [
+ "billionaire",
+ 1805616000000
+ ],
+ [
+ "mushroom",
+ 1805875200000
+ ],
+ [
+ "chef",
+ 1806134400000
+ ],
+ [
+ "knight",
+ 1806393600000
+ ],
+ [
+ "weather",
+ 1806652800000
+ ],
+ [
+ "punk",
+ 1806912000000
+ ],
+ [
+ "gardener",
+ 1807171200000
+ ],
+ [
+ "accountant",
+ 1807430400000
+ ],
+ [
+ "zen",
+ 1807689600000
+ ],
+ [
+ "magician",
+ 1807948800000
+ ],
+ [
+ "artist",
+ 1808208000000
+ ],
+ [
+ "royal",
+ 1808467200000
+ ],
+ [
+ "timekeeper",
+ 1808726400000
+ ],
+ [
+ "cosmic",
+ 1808985600000
+ ],
+ [
+ "eternal",
+ 1809244800000
+ ]
+ ]
+ },
+ {
+ "name": "died an adult",
+ "pet": {
+ "hatchAt": 1800000000000,
+ "bornAt": 1800000000000,
+ "dead": true,
+ "diedAt": 1800864000000
+ },
+ "now": 1803456000000,
+ "streak": 2,
+ "moments": [
+ {
+ "id": "hatch",
+ "at": 1800000000000
+ },
+ {
+ "id": "name",
+ "at": 1800000000000
+ },
+ {
+ "id": "young",
+ "at": 1800086400000
+ },
+ {
+ "id": "teen",
+ "at": 1800259200000
+ },
+ {
+ "id": "adult",
+ "at": 1800604800000
+ }
+ ],
+ "unlocked": 0,
+ "stickers": [],
+ "album": []
+ }
+ ],
+ "visits": [
+ {
+ "name": "first visit",
+ "streak": 0,
+ "lastVisitDay": "",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": true,
+ "after": {
+ "streak": 1,
+ "lastVisitDay": "2027-01-15"
+ }
+ },
+ {
+ "name": "same day",
+ "streak": 4,
+ "lastVisitDay": "2027-01-15",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": false,
+ "after": {
+ "streak": 4,
+ "lastVisitDay": "2027-01-15"
+ }
+ },
+ {
+ "name": "next day",
+ "streak": 4,
+ "lastVisitDay": "2027-01-14",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": true,
+ "after": {
+ "streak": 5,
+ "lastVisitDay": "2027-01-15"
+ }
+ },
+ {
+ "name": "missed a day",
+ "streak": 9,
+ "lastVisitDay": "2027-01-13",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": true,
+ "after": {
+ "streak": 1,
+ "lastVisitDay": "2027-01-15"
+ }
+ },
+ {
+ "name": "clock rollback",
+ "streak": 4,
+ "lastVisitDay": "2027-01-16",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": false,
+ "after": {
+ "streak": 4,
+ "lastVisitDay": "2027-01-16"
+ }
+ },
+ {
+ "name": "month boundary",
+ "streak": 29,
+ "lastVisitDay": "2027-02-28",
+ "day": "2027-03-01",
+ "yesterday": "2027-02-28",
+ "changed": true,
+ "after": {
+ "streak": 30,
+ "lastVisitDay": "2027-03-01"
+ }
+ },
+ {
+ "name": "limit",
+ "streak": 100000,
+ "lastVisitDay": "2027-01-14",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": true,
+ "after": {
+ "streak": 100000,
+ "lastVisitDay": "2027-01-15"
+ }
+ },
+ {
+ "name": "malformed day",
+ "streak": 3,
+ "lastVisitDay": "yesterday",
+ "day": "2027-01-15",
+ "yesterday": "2027-01-14",
+ "changed": true,
+ "after": {
+ "streak": 1,
+ "lastVisitDay": "2027-01-15"
+ }
+ }
+ ]
+}
diff --git a/tests/gameplay.test.cjs b/tests/gameplay.test.cjs
index 0176323..f71b1ff 100644
--- a/tests/gameplay.test.cjs
+++ b/tests/gameplay.test.cjs
@@ -1,3 +1,5 @@
+// Wishes roll by local hour; the fixed test clock (08:00 UTC) sits in quiet hours, so care rewards stay exact.
+process.env.TZ = 'UTC';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -393,3 +395,57 @@ test('photos stay hidden for a dead pickle or an unavailable photo module', () =
app.click('take-photo'); app.click('restart');
assert.equal(app.get('take-photo').hidden, true);
});
+
+test('a variety’s favorite game adds 4 happiness, says so, and starts the scrapbook', () => {
+ const app = client(); app.start('catch');
+ app.until(() => !app.get('game-again').hidden);
+ assert.match(app.get('message').textContent, /\+14 happy ♥ ★ my favorite game!$/);
+ assert.equal(app.saved().events.game > 0, true);
+ app.click('game-close');
+ assert.match(app.get('scrapbook-page').innerHTML, /First arcade game/);
+});
+
+test('a play wish is granted by finishing the named game', () => {
+ const now = 1800000000000, day = 86400000;
+ const pet = { version: 2, phase: 'living', name: 'Wishful', variety: 'garlic', brine: 'garlic', brinedAt: now - 3 * day - 60000, hatchAt: now - 3 * day,
+ bornAt: now - 3 * day, fullness: 80, happiness: 40, energy: 80, hygiene: 90, sleeping: false, sick: false, dead: false, neglectMs: 0, diedAt: 0,
+ lastCareAt: now, updatedAt: now, wish: { kind: 'play', target: 'catch', issuedAt: now, expiresAt: now + 6 * 3600000 }, wishes: { day: '2027-01-15', count: 2 } };
+ const app = client({}, { storage: { 'little-dill.v2': JSON.stringify(pet) } });
+ assert.equal(app.get('wish').textContent, '♡ wish: a round of Brine catch · 6h left');
+ app.start('catch');
+ assert.equal(app.get('wish').hidden, true, 'the chip hides during games');
+ app.until(() => !app.get('game-again').hidden);
+ assert.match(app.get('message').textContent, /\+10 happy ♥ wish granted! \+10 happy\.$/);
+ assert.equal(app.saved().wish, undefined);
+ assert.equal(app.saved().events.wish, app.saved().events.game);
+ app.click('game-close');
+ assert.equal(app.get('wish').hidden, true);
+});
+
+test('the browser visit streak matches iOS, travels in native extras, and fills scrapbook stickers', async () => {
+ let exported, restored;
+ const saves = { available: () => true, MAX_FILE_BYTES: 16384, decode: async () => restored,
+ encode: async (pet, native) => { exported = JSON.parse(JSON.stringify({ pet, native })); throw Error('Captured download'); } };
+ const web = client({}, { saves });
+ assert.deepEqual(JSON.parse(web.storage.get('little-dill.visits.v1')), { streak: 1, lastVisitDay: '2027-01-15' });
+ web.run(2 * 86400000);
+ assert.equal(JSON.parse(web.storage.get('little-dill.visits.v1')).streak, 3);
+ assert.match(web.get('scrapbook-summary').textContent, /3-day visit streak/);
+ assert.match(web.get('scrapbook-page').innerHTML, /class="sticker earned">★ 3 days/);
+ assert.match(web.get('scrapbook-page').innerHTML, /Elder album · 0\/32/);
+ web.visible(false); web.run(2 * 86400000); web.visible(true);
+ assert.equal(JSON.parse(web.storage.get('little-dill.visits.v1')).streak, 1, 'a missed day restarts the streak');
+ await web.get('download-save').dispatch('click');
+ assert.equal(exported.native, undefined, 'browser-only saves still export without native extras');
+
+ const app = client({}, { saves, storage: { 'little-dill.native-extras.v1': JSON.stringify({ coins: 15, streak: 9, lastVisitDay: '2027-01-14' }) } });
+ await app.get('download-save').dispatch('click');
+ assert.deepEqual([exported.native.coins, exported.native.streak, exported.native.lastVisitDay], [15, 10, '2027-01-15']);
+ restored = { savedAt: app.saved().updatedAt, pet: app.saved(), native: { coins: 3, streak: 4, lastVisitDay: '2027-01-15' } };
+ const input = app.get('backup-file');
+ input.files = [{ size: 1, text: async () => 'backup' }];
+ await input.dispatch('change'); app.click('backup-restore');
+ assert.match(app.get('scrapbook-summary').textContent, /4-day visit streak/);
+ app.click('undo-import');
+ assert.match(app.get('scrapbook-summary').textContent, /10-day visit streak/);
+});
diff --git a/tests/generate-pet-parity.cjs b/tests/generate-pet-parity.cjs
new file mode 100644
index 0000000..89254f9
--- /dev/null
+++ b/tests/generate-pet-parity.cjs
@@ -0,0 +1,74 @@
+// Regenerates tests/fixtures/pet-parity.json from pet-life.js. The JS and Swift parity tests read it.
+const fs = require('node:fs');
+const path = require('node:path');
+const life = require('../pet-life.js');
+
+const NOW = 1800000000000, H = life.HOUR, D = life.DAY;
+const base = { bornAt: NOW - 3 * D, hygiene: 95, energy: 90, sleeping: false, dead: false };
+const living = changes => ({ ...life.fresh(NOW), phase: 'living', name: 'Parity', brinedAt: NOW - life.HATCH_MS, hatchAt: NOW, bornAt: NOW, ...changes });
+
+const rolls = [];
+function roll(name, pet, day, hour, food) {
+ const state = living({ ...base, ...pet });
+ if (state.dead) state.diedAt = NOW;
+ life.rollWish(state, { now: NOW, day, hour, food });
+ rolls.push({ name, pet: { ...base, ...pet }, now: NOW, day, hour, food, wish: state.wish ?? null, wishes: state.wishes ?? null });
+}
+roll('morning slot opens at nine', {}, '2027-01-15', 9, 'apple');
+roll('quiet before nine', {}, '2027-01-15', 8, 'apple');
+roll('quiet from ten at night', {}, '2027-01-15', 22, 'apple');
+roll('asleep', { sleeping: true }, '2027-01-15', 12, 'apple');
+roll('dead', { dead: true }, '2027-01-15', 12, 'apple');
+roll('second slot waits for three', { wishes: { day: '2027-01-15', count: 1 } }, '2027-01-15', 14, 'apple');
+roll('second slot at three', { wishes: { day: '2027-01-15', count: 1 } }, '2027-01-15', 15, 'apple');
+roll('daily cap', { wishes: { day: '2027-01-15', count: 2 } }, '2027-01-15', 18, 'apple');
+roll('new day resets the count', { wishes: { day: '2027-01-14', count: 2 } }, '2027-01-15', 10, 'broccoli');
+roll('clock rollback', { wishes: { day: '2027-01-16', count: 1 } }, '2027-01-15', 10, 'apple');
+roll('active wish stays', { wish: { kind: 'pet', target: '', issuedAt: NOW - H, expiresAt: NOW + 5 * H }, wishes: { day: '2027-01-15', count: 1 } }, '2027-01-15', 16, 'apple');
+roll('expired wish clears before the next slot', { wish: { kind: 'pet', target: '', issuedAt: NOW - 6 * H, expiresAt: NOW }, wishes: { day: '2027-01-15', count: 1 } }, '2027-01-15', 16, 'cheese');
+for (let i = 0; i < 12; i++) roll('needy pickle seed ' + i, { bornAt: NOW - 3 * D + i * 1000, hygiene: 50, energy: 30 }, '2027-01-15', 11, life.FOODS[i % 5]);
+for (let i = 0; i < 6; i++) roll('healthy pickle seed ' + i, { bornAt: NOW - 3 * D + i * 1000 }, '2027-02-0' + (i + 1), 12, 'carrot');
+
+const scrapbook = [];
+function page(name, pet, now, streak) {
+ const state = living(pet);
+ const result = life.scrapbook(state, now, streak);
+ scrapbook.push({ name, pet: { hatchAt: state.hatchAt, bornAt: state.bornAt, dead: state.dead, diedAt: state.diedAt, ...(state.events ? { events: state.events } : {}) },
+ now, streak, moments: result.moments, unlocked: result.unlocked, stickers: result.stickers.filter(s => s.earned).map(s => s.days),
+ album: result.album.filter(s => s.unlocked).map(s => [s.id, s.at]) });
+}
+page('half a day', {}, NOW + D / 2, 0);
+page('young at one day', { bornAt: NOW + 30000, events: { game: NOW + H } }, NOW + D + 30000, 3);
+page('teen', { events: { wish: NOW + 2 * D, game: NOW + 2 * D } }, NOW + 3 * D, 6);
+page('adult', {}, NOW + 7 * D, 7);
+page('first elder', { events: { arena: NOW + 8 * D, game: NOW + H, wish: NOW + 9 * D } }, NOW + 14 * D, 14);
+page('third elder', {}, NOW + 20 * D, 29);
+page('full album', {}, NOW + (14 + 3 * 31) * D, 30);
+page('second lap', {}, NOW + 300 * D, 100);
+page('died an adult', { dead: true, diedAt: NOW + 10 * D }, NOW + 40 * D, 2);
+
+const visits = [];
+function visit(name, streak, lastVisitDay, day, yesterday) {
+ const record = { streak, lastVisitDay };
+ const changed = life.visit(record, day, yesterday);
+ visits.push({ name, streak, lastVisitDay, day, yesterday, changed, after: record });
+}
+visit('first visit', 0, '', '2027-01-15', '2027-01-14');
+visit('same day', 4, '2027-01-15', '2027-01-15', '2027-01-14');
+visit('next day', 4, '2027-01-14', '2027-01-15', '2027-01-14');
+visit('missed a day', 9, '2027-01-13', '2027-01-15', '2027-01-14');
+visit('clock rollback', 4, '2027-01-16', '2027-01-15', '2027-01-14');
+visit('month boundary', 29, '2027-02-28', '2027-03-01', '2027-02-28');
+visit('limit', life.MAX_STREAK, '2027-01-14', '2027-01-15', '2027-01-14');
+visit('malformed day', 3, 'yesterday', '2027-01-15', '2027-01-14');
+
+const texts = [['feed', 'apple'], ['clean', ''], ['pet', ''], ['play', 'memory'], ['nap', '']]
+ .map(([kind, target]) => ({ wish: { kind, target, issuedAt: 0, expiresAt: 1 }, text: life.wishText({ kind, target }) }));
+const fixture = {
+ favorites: life.FAVORITES, favoriteBonus: life.FAVORITE_BONUS, foods: life.FOODS, foodNames: life.FOOD_NAMES, games: life.GAMES, gameNames: life.GAME_NAMES,
+ wish: { kinds: life.WISH_KINDS, ms: life.WISH_MS, perDay: life.WISHES_PER_DAY, happiness: life.WISH_HAPPINESS, coins: life.WISH_COINS, hours: life.WISH_HOURS, quietHour: life.QUIET_HOUR },
+ wishTexts: texts, moments: life.MOMENTS, streakStickers: life.STREAK_STICKERS, maxStreak: life.MAX_STREAK, rolls, scrapbook, visits
+};
+const output = JSON.stringify(fixture, null, 1) + '\n';
+if (require.main === module) fs.writeFileSync(path.join(__dirname, 'fixtures', 'pet-parity.json'), output);
+module.exports = { output };
diff --git a/tests/pet-wishes.test.cjs b/tests/pet-wishes.test.cjs
new file mode 100644
index 0000000..35b04bc
--- /dev/null
+++ b/tests/pet-wishes.test.cjs
@@ -0,0 +1,130 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const life = require('../pet-life.js');
+const saves = require('../save-codec.js');
+const fixture = require('./fixtures/pet-parity.json');
+const NOW = 1800000000000, H = life.HOUR, D = life.DAY;
+const DAY = '2027-01-15';
+function living(changes = {}) {
+ return { ...life.fresh(NOW), phase: 'living', name: 'Sir Crunch', bornAt: NOW - 3 * D, brinedAt: NOW - 3 * D - life.HATCH_MS, hatchAt: NOW - 3 * D, ...changes };
+}
+const roll = (pet, hour, changes = {}) => life.rollWish(pet, { now: NOW, day: DAY, hour, food: 'broccoli', ...changes });
+
+test('the shared parity fixture matches pet-life.js', () => {
+ assert.equal(fs.readFileSync(require.resolve('./fixtures/pet-parity.json'), 'utf8'), require('./generate-pet-parity.cjs').output,
+ 'run node tests/generate-pet-parity.cjs after changing a shared pet rule');
+ assert.deepEqual(Object.keys(fixture.favorites), life.VARIETIES.map(variety => variety.id));
+ for (const { food, game } of Object.values(fixture.favorites)) { assert.ok(life.FOODS.includes(food)); assert.ok(life.GAMES.includes(game)); }
+ assert.deepEqual(new Set(Object.values(fixture.favorites).map(item => item.food)), new Set(life.FOODS), 'every food is somebody’s favorite');
+});
+
+test('wishes wait for a living, awake, named pickle outside quiet hours', () => {
+ for (const hour of [0, 8, 22, 23]) assert.equal(roll(living(), hour), null, 'quiet hour ' + hour);
+ assert.equal(roll(living({ sleeping: true }), 12), null);
+ assert.equal(roll(living({ dead: true, diedAt: NOW }), 12), null);
+ assert.equal(roll({ ...life.fresh(NOW), phase: 'naming' }, 12), null);
+ const pet = living();
+ const wish = roll(pet, 9);
+ assert.ok(wish);
+ assert.deepEqual(pet.wishes, { day: DAY, count: 1 });
+ assert.equal(wish.expiresAt - wish.issuedAt, 6 * H);
+ assert.deepEqual(roll(structuredClone(living()), 9), wish, 'the roll is deterministic');
+});
+
+test('a pickle makes at most two wishes a day, one at a time, the second after 3pm', () => {
+ const pet = living();
+ const first = roll(pet, 10);
+ assert.equal(roll(pet, 16), null, 'an active wish blocks a new one');
+ assert.deepEqual(pet.wish, first);
+ pet.wish.expiresAt = NOW; // expired
+ assert.equal(roll(pet, 14), null);
+ assert.equal(pet.wish, undefined, 'expired wishes clear');
+ const second = roll(pet, 15);
+ assert.ok(second);
+ assert.deepEqual(pet.wishes, { day: DAY, count: 2 });
+ delete pet.wish;
+ assert.equal(roll(pet, 18), null, 'the daily cap holds');
+ assert.ok(roll(pet, 10, { day: '2027-01-16' }), 'a new local day reopens wishes');
+ assert.equal(roll(living({ wishes: { day: '2027-01-16', count: 1 } }), 12), null, 'a clock rollback cannot reopen a day');
+});
+
+test('clean and nap wishes need a reason; feed wishes ask for the next rotation food; play names a shared game', () => {
+ const kinds = new Set(), healthy = new Set();
+ for (let i = 0; i < 40; i++) {
+ const needy = living({ bornAt: NOW - 3 * D + i * 1000, hygiene: 50, energy: 30 });
+ const wish = roll(needy, 11, { food: 'apple' });
+ kinds.add(wish.kind);
+ if (wish.kind === 'feed') assert.equal(wish.target, 'apple');
+ else if (wish.kind === 'play') assert.ok(life.GAMES.includes(wish.target));
+ else assert.equal(wish.target, '');
+ healthy.add(roll(living({ bornAt: NOW - 3 * D + i * 1000 }), 11).kind);
+ }
+ assert.deepEqual([...kinds].sort(), ['clean', 'feed', 'nap', 'pet', 'play']);
+ assert.deepEqual([...healthy].sort(), ['feed', 'pet', 'play'], 'hygiene 95 and energy 90 rule out clean and nap');
+});
+
+test('granting a wish adds 10 happiness once, records the first wish, and play needs the named game', () => {
+ const pet = living({ happiness: 50, wish: { kind: 'play', target: 'hunt', issuedAt: NOW, expiresAt: NOW + 6 * H } });
+ assert.equal(life.grantWish(pet, 'play', 'memory', NOW + H), null);
+ assert.equal(life.grantWish(pet, 'feed', '', NOW + H), null);
+ assert.equal(life.grantWish(pet, 'play', 'hunt', NOW + H), 10);
+ assert.equal(pet.happiness, 60); assert.equal(pet.wish, undefined); assert.deepEqual(pet.events, { wish: NOW + H });
+ assert.equal(life.grantWish(pet, 'play', 'hunt', NOW + H), null, 'a granted wish is gone');
+ const late = living({ happiness: 95, events: { wish: NOW - D }, wish: { kind: 'feed', target: 'carrot', issuedAt: NOW, expiresAt: NOW + 6 * H } });
+ assert.equal(life.grantWish(late, 'feed', '', NOW + 6 * H), null, 'expired wishes cannot be granted');
+ late.wish.expiresAt = NOW + 7 * H - 1;
+ late.wish.issuedAt = NOW + H;
+ assert.equal(life.grantWish(late, 'feed', '', NOW + 2 * H), 5, 'feed wishes match by kind; happiness caps at 100');
+ assert.deepEqual(late.events, { wish: NOW - D }, 'the first wish date stays');
+});
+
+test('the scrapbook derives stage dates and the elder album from bornAt', () => {
+ const pet = living({ bornAt: NOW, hatchAt: NOW - 5000 });
+ const ids = at => life.scrapbook(pet, at).moments.map(moment => moment.id);
+ assert.deepEqual(ids(NOW + D - 1), ['hatch', 'name']);
+ assert.deepEqual(ids(NOW + 7 * D), ['hatch', 'name', 'young', 'teen', 'adult']);
+ const elder = life.scrapbook(pet, NOW + 14 * D);
+ assert.deepEqual(elder.moments.slice(2).map(moment => [moment.id, (moment.at - NOW) / D]), [['young', 1], ['teen', 3], ['adult', 7], ['elder', 14]]);
+ assert.equal(elder.album.length, 32);
+ for (const [days, unlocked] of [[13, 0], [14, 1], [16.9, 1], [17, 2], [20, 3], [14 + 31 * 3, 32], [500, 32]]) {
+ const page = life.scrapbook(pet, NOW + days * D);
+ assert.equal(page.unlocked, unlocked, days + ' days');
+ assert.equal(page.album.filter(slot => slot.unlocked).length, unlocked);
+ }
+ assert.equal(life.scrapbook(pet, NOW + 20 * D).album[2].at, NOW + 20 * D);
+ const stored = living({ bornAt: NOW, events: { game: NOW + H, arena: NOW + 2 * D } });
+ assert.deepEqual(life.scrapbook(stored, NOW + 2 * D).moments.map(moment => moment.id), ['hatch', 'name', 'game', 'young', 'arena']);
+ assert.deepEqual(life.scrapbook(pet, NOW, 14).stickers, [{ days: 3, earned: true }, { days: 7, earned: true }, { days: 14, earned: true }, { days: 30, earned: false }]);
+ assert.deepEqual(life.scrapbook(life.fresh(NOW), NOW).moments, []);
+});
+
+test('the browser streak follows the iOS visit rule', () => {
+ const record = { streak: 0, lastVisitDay: '' };
+ assert.equal(life.visit(record, '2027-01-15', '2027-01-14'), true); assert.equal(record.streak, 1);
+ assert.equal(life.visit(record, '2027-01-15', '2027-01-14'), false); assert.equal(record.streak, 1);
+ life.visit(record, '2027-01-16', '2027-01-15'); assert.equal(record.streak, 2);
+ life.visit(record, '2027-01-18', '2027-01-17'); assert.equal(record.streak, 1);
+ assert.equal(life.visit(record, '2027-01-17', '2027-01-16'), false, 'a clock rollback keeps the streak');
+ const capped = { streak: Number.MAX_SAFE_INTEGER, lastVisitDay: '2027-01-14' };
+ life.visit(capped, '2027-01-15', '2027-01-14'); assert.equal(capped.streak, life.MAX_STREAK);
+ const local = life.localTime(new Date(2027, 2, 1, 10).getTime());
+ assert.deepEqual(local, { day: '2027-03-01', yesterday: '2027-02-28', hour: 10 });
+});
+
+test('wishes and scrapbook events survive validation and backups; malformed or missing fields still load', async () => {
+ const pet = living({ wish: { kind: 'feed', target: 'cheese', issuedAt: NOW, expiresAt: NOW + 6 * H }, wishes: { day: DAY, count: 1 },
+ events: { game: NOW - H, wish: NOW - 2 * H, arena: NOW - 3 * H } });
+ assert.deepEqual(life.validate(JSON.parse(JSON.stringify(pet))), pet);
+ assert.deepEqual((await saves.decode(await saves.encode(pet))).pet, pet);
+ const old = living();
+ const restored = (await saves.decode(await saves.encode(old))).pet;
+ assert.deepEqual(restored, old);
+ for (const key of ['wish', 'wishes', 'events']) assert.equal(key in restored, false);
+ for (const [key, value] of [['wish', { kind: 'dance', target: '', issuedAt: NOW, expiresAt: NOW + H }], ['wish', { kind: 'feed', target: 'pizza', issuedAt: NOW, expiresAt: NOW + H }],
+ ['wish', { kind: 'pet', target: '', issuedAt: NOW, expiresAt: NOW + 7 * H }], ['wishes', { day: '15/01/2027', count: 1 }], ['wishes', { day: DAY, count: 3 }],
+ ['events', { game: -1 }], ['events', []], ['wish', 'feed']]) {
+ assert.equal(key in life.validate({ ...old, [key]: value }), false, key + ' ' + JSON.stringify(value));
+ }
+ assert.deepEqual(life.validate({ ...old, events: { game: NOW, party: NOW, wish: 1.5 } }).events, { game: NOW });
+});