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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
91 changes: 76 additions & 15 deletions index.html

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions ios/LittleDill.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
6FBC3D767AFC7F4FDA2629B4 /* ArenaExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973772CC6332B43C2FF55A0C /* ArenaExpression.swift */; };
79AC77633956986EFD962D70 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D49132AD73DDA5B3DBD4DB67 /* PrivacyInfo.xcprivacy */; };
7BA7B1B3A32F024442F76964 /* PetLooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF87E5DB22E6001365377EDF /* PetLooks.swift */; };
7C4F205F2167E6140AEE0ADD /* ScrapbookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71849DE209138799C2BBC9B0 /* ScrapbookView.swift */; };
8144210ADBBEF3E68D6D6316 /* PetStage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD5D78C8B6D5690D37D8EEC1 /* PetStage.swift */; };
82E77C18E19919F9E2AA3B59 /* ArcadeChop.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD317F87A8D84C61B9099AD /* ArcadeChop.swift */; };
86829ECB2B67B80471FCA104 /* WardrobeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C18144AAFD107F75E0F814 /* WardrobeTests.swift */; };
Expand Down Expand Up @@ -75,6 +76,7 @@
DF542A4AD96506ADC6A39744 /* ArcadeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E1909F605020F3F7001953F /* ArcadeViews.swift */; };
E089736DA1A96B67C8ABFB12 /* ArenaAudioParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980DB2F88BBB8D5DE864F44F /* ArenaAudioParityTests.swift */; };
E46A4A05B5AAD9DEB61EC6D7 /* PetMotion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24AF5B68964EA18E99630EA /* PetMotion.swift */; };
E569457483354E4367C6F426 /* pet-parity.json in Resources */ = {isa = PBXBuildFile; fileRef = DFA7A21E34555514A4E8924D /* pet-parity.json */; };
E58D926F4CAB48E8081F0AFA /* ArenaEarningsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED66AD0CC8B7FD13268B440 /* ArenaEarningsTests.swift */; };
F4F9AA76503A401F4CD0ACDB /* FeedFood.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6540F39435E33E35E31386EC /* FeedFood.swift */; };
F58B96584F3A3F07000DD3B7 /* PetLife.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9487E60E0C2FEA26D77BBC5B /* PetLife.swift */; };
Expand All @@ -83,6 +85,7 @@
FBC65563AB0BB3407FB8227D /* arena-shaker.wav in Resources */ = {isa = PBXBuildFile; fileRef = 3DA54E77E1AD483188C987A6 /* arena-shaker.wav */; };
FDEAD20540E6ED8E4763BD1A /* Design.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FDA33CEC165E433E0EB5022 /* Design.swift */; };
FE03FF6E3857D909DA9C9682 /* ArcadeCircuitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A716C993DB41C1F8A71BE19 /* ArcadeCircuitView.swift */; };
FED8F10C9E97864897B82FA6 /* PetWishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C88B6FF72B5C3D5239B589B4 /* PetWishTests.swift */; };
FEE73CBB018AAF2C607D9050 /* arena-pickup-3.wav in Resources */ = {isa = PBXBuildFile; fileRef = AB5BBC5F78D887B52814993A /* arena-pickup-3.wav */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -133,6 +136,7 @@
6556B1CFDD693A8FCB63B70D /* LittleDill.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LittleDill.app; sourceTree = BUILT_PRODUCTS_DIR; };
6CA495131EFC009273AA7DA8 /* NestText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestText.swift; sourceTree = "<group>"; };
6D4B0A2AD1CF122B1106AB63 /* arena-pickup-1.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "arena-pickup-1.wav"; sourceTree = "<group>"; };
71849DE209138799C2BBC9B0 /* ScrapbookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrapbookView.swift; sourceTree = "<group>"; };
7624C5DB7F330E4CB30D615E /* DillStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DillStore.swift; sourceTree = "<group>"; };
781DFBBE0921AA628D0BB5A2 /* gadget-audio.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "gadget-audio.json"; sourceTree = "<group>"; };
7C38CBDB8E4E40DE37C57A2B /* ArenaRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArenaRecoveryTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -166,6 +170,7 @@
BD5D78C8B6D5690D37D8EEC1 /* PetStage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetStage.swift; sourceTree = "<group>"; };
C2877A3CAFE94F78CBE49879 /* ArcadeToss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArcadeToss.swift; sourceTree = "<group>"; };
C681DCBE740415262BAE485C /* arena-grater.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "arena-grater.wav"; sourceTree = "<group>"; };
C88B6FF72B5C3D5239B589B4 /* PetWishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetWishTests.swift; sourceTree = "<group>"; };
CAFDFF4605FF2015B7E91004 /* arena-pickup-2.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "arena-pickup-2.wav"; sourceTree = "<group>"; };
CE470B3A8077CAA7604E051A /* ArenaEarnings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArenaEarnings.swift; sourceTree = "<group>"; };
D24AF5B68964EA18E99630EA /* PetMotion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetMotion.swift; sourceTree = "<group>"; };
Expand All @@ -176,6 +181,7 @@
D97F7D7ACEDDAE7BF9777D24 /* DillModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DillModel.swift; sourceTree = "<group>"; };
D9A0092F7E7FCEA74B2E09C0 /* CrossPlatformParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossPlatformParityTests.swift; sourceTree = "<group>"; };
DEBCF3D267607EEE633DE56F /* ArenaFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArenaFeedbackTests.swift; sourceTree = "<group>"; };
DFA7A21E34555514A4E8924D /* pet-parity.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pet-parity.json"; sourceTree = "<group>"; };
E880408BECBA90CD00B809E9 /* FeedFoodTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFoodTests.swift; sourceTree = "<group>"; };
EA2A451EFE0DF93D5DBFA2DE /* LittleDillUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LittleDillUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F0A062DA32D1AF9DA615F1E2 /* ArenaRecap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArenaRecap.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -241,6 +247,7 @@
BD5D78C8B6D5690D37D8EEC1 /* PetStage.swift */,
D315A7FCEEC9DA244DE4AB7D /* PickleBody.swift */,
D49132AD73DDA5B3DBD4DB67 /* PrivacyInfo.xcprivacy */,
71849DE209138799C2BBC9B0 /* ScrapbookView.swift */,
0B2B709BAF1C004A315B36A5 /* WardrobeArt.swift */,
799D87CA764AFE7FB4809EBA /* Audio */,
);
Expand All @@ -251,6 +258,7 @@
isa = PBXGroup;
children = (
81D968F8C3449CEC22F68BD0 /* arena-parity.json */,
DFA7A21E34555514A4E8924D /* pet-parity.json */,
);
name = fixtures;
path = ../tests/fixtures;
Expand All @@ -273,6 +281,7 @@
D9A0092F7E7FCEA74B2E09C0 /* CrossPlatformParityTests.swift */,
4E6AE199A809890C135FF5CF /* DillTests.swift */,
E880408BECBA90CD00B809E9 /* FeedFoodTests.swift */,
C88B6FF72B5C3D5239B589B4 /* PetWishTests.swift */,
9EF292F156C8F722841A7D1C /* PickleBodyTests.swift */,
934B4A5A85FFD82992BDC6BE /* PlayRewardIntegrationTests.swift */,
B4C18144AAFD107F75E0F814 /* WardrobeTests.swift */,
Expand Down Expand Up @@ -433,6 +442,7 @@
buildActionMask = 2147483647;
files = (
B9EF65799FA35794E5B8C7AA /* arena-parity.json in Resources */,
E569457483354E4367C6F426 /* pet-parity.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -505,6 +515,7 @@
E46A4A05B5AAD9DEB61EC6D7 /* PetMotion.swift in Sources */,
8144210ADBBEF3E68D6D6316 /* PetStage.swift in Sources */,
30906DC4DFB352FA866B9E87 /* PickleBody.swift in Sources */,
7C4F205F2167E6140AEE0ADD /* ScrapbookView.swift in Sources */,
589E54C989F5860AA3E608F0 /* WardrobeArt.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -537,6 +548,7 @@
1C03C52777587C828FF1AE0A /* CrossPlatformParityTests.swift in Sources */,
517AAED62F1E6DD723B87316 /* DillTests.swift in Sources */,
56F8C2300FE54183F331E631 /* FeedFoodTests.swift in Sources */,
FED8F10C9E97864897B82FA6 /* PetWishTests.swift in Sources */,
5665F5CF1C630B1AE14D7DB1 /* PickleBodyTests.swift in Sources */,
96102A86B6C3E427A51D7C08 /* PlayRewardIntegrationTests.swift in Sources */,
86829ECB2B67B80471FCA104 /* WardrobeTests.swift in Sources */,
Expand Down
4 changes: 4 additions & 0 deletions ios/LittleDill/ArcadeViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ struct ArcadeSheet: View {
Text(text).font(DillTheme.display(26)).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("arcade.result")
if gained != nil, PetLife.favorite(store.pet.life.variety).game == game.game.rawValue {
Text("★ favorite game · +\(Int(PetLife.FAVORITE_BONUS)) bonus happy").font(.system(size: 13, weight: .bold, design: .rounded))
.accessibilityIdentifier("arcade.favorite")
}
Text("Best \(best(game.game))").font(.system(size: 11, weight: .bold, design: .monospaced)).foregroundStyle(DillTheme.muted)
circuitResult
Button { start(game.game, circuit: circuitDay != nil) } label: { Label("Play again", systemImage: "arrow.clockwise") }
Expand Down
46 changes: 34 additions & 12 deletions ios/LittleDill/DillModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ struct CareResult: Equatable {
var applied: Bool
var coins: Int
var message: String
var favorite = false
var wish = false
}

struct ScoreEntry: Codable, Identifiable, Equatable {
Expand Down Expand Up @@ -235,7 +237,7 @@ struct PetState: Codable {
arcadeRecords = try c.decodeIfPresent([String: Int].self, forKey: .arcadeRecords) ?? [:]
arcadeCircuit = try? c.decodeIfPresent(ArcadeCircuit.self, forKey: .arcadeCircuit)
switch version {
case 2: life = try c.decode(WebPet.self, forKey: .life)
case 2: life = try c.decode(WebPet.self, forKey: .life); PetLife.cleanOptionals(&life)
case 1:
// Native v1 saves keep their pickle and restart the care clock at the moment of migration.
let now = PetLife.ms(decoder.userInfo[Self.nowKey] as? Date ?? Date())
Expand Down Expand Up @@ -295,10 +297,20 @@ struct PetState: Codable {
PetLife.advance(&life, now: PetLife.ms(now))
let day = Self.dayKey(now, calendar: calendar)
if careDay < day { careDay = day; dailyCare = [] }
guard adopted, lastVisitDay != day, day > 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 {
Expand All @@ -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) }
Expand All @@ -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. ♥") }
Expand All @@ -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) }
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading