From cb438a22ad0d9b91121d599c48db3419a8526f9e Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Sun, 18 Oct 2026 22:18:56 -0400 Subject: [PATCH 1/2] feat(pet): add pickle wishes, a scrapbook, and variety favorites Wishes: up to two per local day outside 10pm-9am, one active for 6 hours. Kinds are feed (next rotation food), clean (hygiene < 80), pet, play (named game), and nap (energy < 50). Granting adds 10 happiness, plus 5 coins on iOS. Shared rules live in pet-life.js and PetLife.swift. Scrapbook: derives hatch, naming, and stage moments from bornAt, shows a 32-slot elder album, and stores first game, first wish, and first arena run. The web streak follows the iOS rule and travels in .dill native extras. Stickers unlock at 3/7/14/30 days. Favorites: each variety has one food and one game worth +4 happiness. tests/fixtures/pet-parity.json keeps JS and Swift aligned. New save fields are optional. CACHE_VERSION is little-dill-v18. --- README.md | 15 + index.html | 91 +- ios/LittleDill.xcodeproj/project.pbxproj | 12 + ios/LittleDill/ArcadeViews.swift | 4 + ios/LittleDill/DillModel.swift | 46 +- ios/LittleDill/DillStore.swift | 15 +- ios/LittleDill/FeedFood.swift | 1 + ios/LittleDill/NestView.swift | 38 +- ios/LittleDill/PetLife.swift | 170 ++- ios/LittleDill/ScrapbookView.swift | 91 ++ ios/LittleDillTests/PetWishTests.swift | 185 +++ ios/project.yml | 2 + pet-life.js | 122 +- sw.js | 2 +- tests/feeding.test.cjs | 37 + tests/fixtures/pet-parity.json | 1604 ++++++++++++++++++++++ tests/gameplay.test.cjs | 56 + tests/generate-pet-parity.cjs | 74 + tests/pet-wishes.test.cjs | 130 ++ 19 files changed, 2657 insertions(+), 38 deletions(-) create mode 100644 ios/LittleDill/ScrapbookView.swift create mode 100644 ios/LittleDillTests/PetWishTests.swift create mode 100644 tests/fixtures/pet-parity.json create mode 100644 tests/generate-pet-parity.cjs create mode 100644 tests/pet-wishes.test.cjs diff --git a/README.md b/README.md index d70b949..1e9c9c7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ A daily feed, clean, game, and pet is enough even if the game goes badly. A heal Baby pickles have a round body and pacifier. Young pickles are taller with a leaf, teens cycle through 8 stereotypes (nerd, emo, jock, skater, gamer, theater kid, band kid, prep) with a new one each day, adults reach their full shape, and elders wear glasses and changing accessories. Pickles become young at 1 day, teens at 3 days, adults at 7 days, and elders at 14 days. A content pickle rotates every 30 to 60 seconds between hopping, chilling in sunglasses, lounging in the sun, reading, and sitting by a campfire; petting it always makes it hop. Every 3 days after that, your pickle takes another of 32 elder forms, each with its own hat and accessory pairing, name, and lines. After all 32 forms, the cycle repeats indefinitely. All 6 varieties keep their own colors and shapes throughout life. +Wishes: an awake, named pickle makes up to 2 wishes per local day, one at a time, outside quiet hours (10pm to 9am device time). The first can appear from 9am and the second from 3pm. Each lasts 6 hours. A wish asks for the next food in the feeding rotation, a bath (hygiene under 80), a pet, a named round of Heart hunt, Dill says, or Brine catch, or a nap (energy under 50). Granting it adds 10 happiness, and 5 coins on iOS. Wishes stay on the device and in backups; reminder notifications do not include them. + +Scrapbook: the Scrapbook panel on web and the Scrapbook sheet on iOS list the hatch, naming, and stage dates, the first finished game, first granted wish, and first iOS arena run, plus a 32-slot elder album with locked future forms. Visit streak stickers unlock at 3, 7, 14, and 30 days. The browser keeps the same daily visit streak as iOS and exchanges it through `.dill` native extras. + The app automatically migrates old local saves and encrypted v1 backups to a separate v2 local-save key. This prevents older open tabs from overwriting the new save. Migration preserves stats, life/death status, and the equivalent growth stage. Pickles receive a new grace period when upgrading from the old rapid-decay model. Existing pickles receive the name Little Dill. ## Play with your pickle @@ -44,6 +48,17 @@ The app automatically migrates old local saves and encrypted v1 backups to a sep Feeding rotates through carrot, strawberry, broccoli, apple, and cheese on web and iOS. Successful feeds advance the sequence, which is remembered on that device; refused feeds leave it unchanged. +Each variety has a favorite food and game. Feeding the favorite food or finishing the favorite game adds 4 happiness on top of the usual reward: + +| Variety | Favorite food | Favorite game | +| --- | --- | --- | +| Classic Dill | strawberry | Brine catch | +| Tiny Gherkin | carrot | Dill says | +| Garlic Goblin | broccoli | Heart hunt | +| Butter Bean | cheese | Dill says | +| Chili Dill | apple | Heart hunt | +| Pepper Punk | carrot | Brine catch | + Play wakes a sleeping pickle and opens the arcade without spending energy. Starting a game costs 6 energy. The native arcade also has three full-screen action games: Countertop escape (run through seeded kitchen scenery with varied obstacle spacing and dill streaks), Cuke chop (slice cucumbers while sparing your pickle), and Jar toss (aim for a brine jar). Its Daily Circuit combines seeded runs of those games into a 300-point score with medals. Native arena controls include swappable thumbstick placement, cooldown indicators, and distinct haptic cues. See the [iOS guide](ios/README.md) for controls and native features. diff --git a/index.html b/index.html index f0be32c..3f15ae2 100644 --- a/index.html +++ b/index.html @@ -271,6 +271,17 @@ .elder-card { border: 1px solid var(--line); border-radius: 9px; padding: 11px; font-size: 11px; background: #eef0df; } .elder-card small { display: block; font-size: 10px; margin-top: 5px; color: #69765b; line-height: 1.5; } .elder-card.locked { background: transparent; color: #69765b; } + .wish-chip { position: absolute; z-index: 2; top: 24px; left: 50%; transform: translateX(-50%); max-width: 92%; padding: 2px 7px; border: 2px solid var(--pixel); border-radius: 8px; background: #dfe9b6; font-size: 9px; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } + .scrapbook { max-width: 805px; margin: 14px auto; padding: 18px; border: 1px solid var(--line); border-radius: 16px; } + .scrapbook summary { font-size: 13px; font-weight: bold; } + .scrapbook h3 { margin: 16px 0 8px; font-size: 10px; letter-spacing: 1.2px; text-transform: uppercase; color: #69765b; } + .scrapbook-note { font-size: 12px; color: #69765b; margin: 10px 0 0; } + .stickers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; } + .sticker { border: 1px solid var(--line); border-radius: 9px; padding: 9px 4px; text-align: center; font-size: 11px; color: #69765b; } + .sticker.earned { background: #f3dcc4; color: var(--ink); font-weight: bold; } + .moments { list-style: none; margin: 0; padding: 0; font-size: 12px; } + .moments li { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--line); } + .moments time { color: #69765b; white-space: nowrap; } .reminder-card { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between; } .reminder-card > div { flex: 1 1 260px; } .reminder-actions { display: flex; flex-wrap: wrap; gap: 8px; } @@ -423,6 +434,7 @@ .page.is-expanded .tombstone { margin: -19px 0 0; top: calc(16px * var(--pet-scale)); transform: scale(var(--pet-scale)); } .page.is-expanded .restart { top: auto; bottom: calc(50% + 45px * var(--pet-scale)); } .page.is-expanded .room.is-dead .effect { top: calc(50% - 44px * var(--pet-scale)); } + .page.is-expanded .wish-chip { top: 32px; font-size: 12px; } .page.is-expanded .crumbs { left: calc(50% + 42px * var(--pet-scale)); bottom: calc(50% - 50px * var(--pet-scale)); transform: scale(var(--pet-scale)); transform-origin: bottom left; } .page.is-expanded .room::after { --pet-scale: 1; bottom: calc(50% - 50px * var(--pet-scale)); } @container (min-height: 250px) { .page.is-expanded .room::after { --pet-scale: 1.25; } } @@ -516,7 +528,7 @@
CLEAN95
- + @@ -559,6 +571,7 @@

THE DILL ARCADE

+
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.

♫ PICKLE RADIOFour original 8-bit tracks

@@ -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] + '
  • ').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/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 }); +}); From d0439b216fad7ff1e6c5b229ca62e7a50364a179 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Sun, 18 Oct 2026 22:35:20 -0400 Subject: [PATCH 2/2] test(ios): keep backup round trip independent of wish slots --- ios/LittleDillTests/DillTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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())