From 1efdf8063c0530821cf54cbf6cf2b1015c6fed7f Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Mon, 19 Oct 2026 00:59:09 -0400 Subject: [PATCH 1/3] fix(ios): replace the run goals HUD with goal-complete toasts Goals show as a 2.5-second pill under the score bar when one completes, with a light haptic and a VoiceOver announcement. Reduce Motion fades without sliding. The first snapshot after join or resume primes without toasting. The rank shows a dash while dead, and the arena and leaderboard labels speak the jar countdown and bounty holder. --- ios/LittleDill/ArenaClient.swift | 4 ++- ios/LittleDill/ArenaEvents.swift | 20 +++++++++++ ios/LittleDill/ArenaFeedback.swift | 4 ++- ios/LittleDill/ArenaRecap.swift | 41 +++++----------------- ios/LittleDill/ArenaView.swift | 41 +++++++++++++++++++--- ios/LittleDillTests/ArenaEventsTests.swift | 17 +++++++++ 6 files changed, 87 insertions(+), 40 deletions(-) diff --git a/ios/LittleDill/ArenaClient.swift b/ios/LittleDill/ArenaClient.swift index e2baab2..4768c7f 100644 --- a/ios/LittleDill/ArenaClient.swift +++ b/ios/LittleDill/ArenaClient.swift @@ -262,7 +262,9 @@ struct ArenaHeader: Decodable { var me: ArenaPlayer? { snapshot?.players.first {$0.id == playerID} } var leaders: [ArenaPlayer] { Array((snapshot?.players.filter(\.alive) ?? []).sorted {$0.mass > $1.mass}.prefix(5)) } var best: Int { me?.best ?? 0 } - var rank: Int { ((snapshot?.players.filter(\.alive).sorted {$0.mass > $1.mass}.firstIndex {$0.id == playerID}) ?? 0) + 1 } + /// 0 while this player is dead or missing from the snapshot. + var rank: Int { (snapshot?.players.filter(\.alive).sorted {$0.mass > $1.mass}.firstIndex {$0.id == playerID}).map {$0 + 1} ?? 0 } + var rankText: String { rank > 0 ? "#\(rank)" : "–" } func connect(pet:PetState,room requestedRoom:String?,fresh:Bool = false) { guard requestedRoom.map(ArenaLaunch.validRoom) ?? true else {fail("That room code isn’t valid."); return} diff --git a/ios/LittleDill/ArenaEvents.swift b/ios/LittleDill/ArenaEvents.swift index 356b353..40e1767 100644 --- a/ios/LittleDill/ArenaEvents.swift +++ b/ios/LittleDill/ArenaEvents.swift @@ -10,6 +10,7 @@ struct ArenaJar: Decodable, Equatable { let y: Double let warn: Double var spilled: Bool { warn <= 0 } + var spoken: String { spilled ? "A brine jar spilled." : "A brine jar spills in \(Int(ceil(warn))) seconds." } static func spilled(from old:ArenaJar?,to new:ArenaJar?) -> Bool { guard let old, let new else {return false} return old.warn > 0 && new.warn <= 0 @@ -68,6 +69,25 @@ struct ArenaRun: Decodable, Equatable { var values: [Double] { [peakMass,Double(kills),secondsAlive,Double(bestRank)] } } +/// Goals that turn done between consecutive observed snapshots of one life. +/// The first snapshot after `reset()` only primes, so joins and resumed sessions do not replay completions. +struct ArenaGoalWatch { + private var last: [ArenaGoal]? + mutating func reset() {last = nil} + mutating func completed(_ goals:[ArenaGoal]) -> [ArenaGoal] { + defer {last = goals} + guard let last else {return []} + return goals.enumerated().compactMap {index,goal in + guard goal.done, index < last.count else {return nil} + let before = last[index] + return before.kind == goal.kind && before.target == goal.target && !before.done ? goal : nil + } + } + static func toast(_ goal:ArenaGoal,coins:Bool) -> String { + "✓ \(goal.label)" + (coins ? " · +\(ArenaEarnings.coinsPerGoal) coins" : "") + } +} + /// Wall-clock start of the current jar's spill, for the tip animation and linger fade. final class ArenaJarClock { private var spot: CGPoint? diff --git a/ios/LittleDill/ArenaFeedback.swift b/ios/LittleDill/ArenaFeedback.swift index 2fc2f50..48a3be5 100644 --- a/ios/LittleDill/ArenaFeedback.swift +++ b/ios/LittleDill/ArenaFeedback.swift @@ -2,7 +2,7 @@ import UIKit enum ArenaFeedbackCue: CaseIterable, Equatable { case stick, edge, dash, split, bite, eaten, regroup, ready - case slicer, shaker, grater, pickup, feast, threat, spill + case slicer, shaker, grater, pickup, feast, threat, spill, goal fileprivate var cooldown: TimeInterval { switch self { @@ -21,6 +21,7 @@ enum ArenaFeedbackCue: CaseIterable, Equatable { case .feast: 0.9 case .threat: 3 case .spill: 2 + case .goal: 1 } } @@ -41,6 +42,7 @@ enum ArenaFeedbackCue: CaseIterable, Equatable { case .feast: [(.light, 0.5, 0), (.soft, 0.25, 65)] case .threat: [(.rigid, 0.42, 0)] case .spill: [(.soft, 0.6, 0), (.light, 0.3, 60)] + case .goal: [(.light, 0.45, 0), (.light, 0.3, 90)] } } diff --git a/ios/LittleDill/ArenaRecap.swift b/ios/LittleDill/ArenaRecap.swift index 076a2de..0aed318 100644 --- a/ios/LittleDill/ArenaRecap.swift +++ b/ios/LittleDill/ArenaRecap.swift @@ -67,39 +67,14 @@ struct ArenaRecapCard: View { } } -/// The life's goals with progress; compact phones get one summary row. -struct ArenaGoalsHUD: View { - let goals: [ArenaGoal] - let compact: Bool - static let doneColor = Color(hex:0x5C873E) +/// A completed-goal pill shown under the score bar. +struct ArenaGoalToast: View { + let text: String var body: some View { - if !goals.isEmpty { - Group { - if compact { - HStack(spacing:3) { - ForEach(Array(goals.enumerated()),id:\.offset) {_,goal in mark(goal)} - Text("\(goals.filter(\.done).count)/\(goals.count) goals") - } - } else { - VStack(alignment:.leading,spacing:3) { - ForEach(Array(goals.enumerated()),id:\.offset) {_,goal in - HStack(spacing:4) { - mark(goal) - Text(goal.label).lineLimit(1).minimumScaleFactor(0.7).strikethrough(goal.done) - Spacer(minLength:4) - Text(goal.progressText).monospacedDigit().foregroundStyle(DillTheme.muted) - } - } - }.frame(maxWidth:180,alignment:.leading) - } - }.font(.system(size:10,weight:.semibold)) - .accessibilityElement(children:.ignore) - .accessibilityLabel("Goals: " + goals.map {"\($0.label), \($0.done ? "done" : $0.progressText)"}.joined(separator:"; ")) - .accessibilityIdentifier("arenaGoals") - } - } - private func mark(_ goal:ArenaGoal) -> some View { - Image(systemName:goal.done ? "checkmark.circle.fill" : "circle") - .foregroundStyle(goal.done ? Self.doneColor : DillTheme.muted) + Text(text).font(.system(size:12,weight:.bold,design:.rounded)).lineLimit(1).minimumScaleFactor(0.7) + .padding(.horizontal,14).padding(.vertical,7) + .background(DillTheme.cream.opacity(0.95),in:Capsule()) + .overlay(Capsule().stroke(Color(hex:0x5C873E).opacity(0.5),lineWidth:1)) + .accessibilityIdentifier("arenaGoalToast") } } diff --git a/ios/LittleDill/ArenaView.swift b/ios/LittleDill/ArenaView.swift index d611680..e424783 100644 --- a/ios/LittleDill/ArenaView.swift +++ b/ios/LittleDill/ArenaView.swift @@ -14,6 +14,11 @@ struct ArenaView: View { @State private var tactile = ArenaFeedback() @State private var feedbackEvents = ArenaFeedbackEvents() @State private var recap: ArenaRunRecap? + @State private var goalWatch = ArenaGoalWatch() + @State private var goalToasts: [GoalToast] = [] + @State private var toastTask: Task? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private struct GoalToast: Identifiable { let id = UUID(); let text: String } @AppStorage(ArenaControls.swappedKey,store:ArenaResume.defaults) private var controlsSwapped = false var body: some View { GeometryReader { geometry in @@ -23,12 +28,16 @@ struct ArenaView: View { if let snapshot = client.snapshot, let me = client.me { TimelineView(.animation(minimumInterval:nil,paused:client.status != .playing)) { timeline in ArenaCanvas(snapshot:snapshot,previous:client.previous,food:client.food,me:me,spitAt:client.spitAt,received:client.receivedAt,now:timeline.date,motion:motion) - }.ignoresSafeArea().accessibilityLabel("Live Brine Royale arena. You are \(me.name), mass \(Int(me.mass)), rank \(client.rank), \(me.pieces.count) \(me.isCucumber ? "cucumbers" : "pickle").") + }.ignoresSafeArea().accessibilityLabel("Live Brine Royale arena. You are \(me.name), mass \(Int(me.mass)), rank \(client.rankText), \(me.pieces.count) \(me.isCucumber ? "cucumbers" : "pickle").\(snapshot.jar.map {" " + $0.spoken} ?? "")") } VStack(spacing:0) { topBar if let me = client.me { scoreBar(me,compact:compact) + if let toast = goalToasts.first { + ArenaGoalToast(text:toast.text).padding(.top,8).id(toast.id) + .transition(reduceMotion ? .opacity : .opacity.combined(with:.move(edge:.top))) + } Spacer(minLength:0) if me.alive { ArenaControls(me:me,compact:compact,swapped:controlsSwapped,stick:$stick, @@ -47,9 +56,10 @@ struct ArenaView: View { .statusBarHidden() .persistentSystemOverlays(.hidden) .onAppear {client.connect(pet:store.pet,room:launch.room)} - .onDisappear {saveBest(); client.suspend(); DillAudio.shared.stop(); tactile.enabled = false; feedbackEvents.reset()} + .onDisappear {saveBest(); client.suspend(); DillAudio.shared.stop(); tactile.enabled = false; feedbackEvents.reset(); toastTask?.cancel()} .onChange(of:client.status) {_,status in if status == .reconnecting || status == .disconnected {saveBest(); stick = .zero} + if status != .playing {goalWatch.reset()} updateGadgetSound(); updateHaptics() if status == .playing {tactile.prepare()} } @@ -80,6 +90,10 @@ struct ArenaView: View { } /// Sounds from one snapshot to the next while alive; death and respawn are handled by the `alive` change. private func listen() { + if let me = client.me, client.status == .playing { + let coins = store.arenaCoinsToday < ArenaEarnings.dailyCoinLimit + for goal in goalWatch.completed(me.goals) {queueToast(ArenaGoalWatch.toast(goal,coins:coins))} + } else {goalWatch.reset()} updateGadgetSound(); updateHaptics(); saveBest() guard let snapshot = client.snapshot else {feedbackEvents.reset(); return} if ArenaJar.spilled(from:client.previous?.jar,to:snapshot.jar) {store.sound(.splash); tactile.play(.spill)} @@ -114,6 +128,20 @@ struct ArenaView: View { if before.cooldown > 0 && after.cooldown <= 0 && after.mass >= 35 || (before.splitCooldown ?? 0) > 0 && after.canSplit {tactile.play(.ready)} } + private func queueToast(_ text:String) { + tactile.play(.goal) + AccessibilityNotification.Announcement(text).post() + withAnimation(.easeOut(duration:0.25)) {goalToasts.append(GoalToast(text:text))} + if goalToasts.count == 1 {expireToast()} + } + private func expireToast() { + toastTask = Task { @MainActor in + try? await Task.sleep(for:.seconds(2.5)) + guard !Task.isCancelled, !goalToasts.isEmpty else {return} + withAnimation(.easeOut(duration:0.3)) {_ = goalToasts.removeFirst()} + if !goalToasts.isEmpty {expireToast()} + } + } private func updateHaptics() { tactile.enabled = store.pet.haptics && scenePhase == .active && client.status == .playing if !tactile.enabled {feedbackEvents.reset()} @@ -233,7 +261,6 @@ struct ArenaView: View { Text("✦ \(store.arenaCoinsToday) / 1,000 coins today") .font(.system(size:10,weight:.semibold)).lineLimit(1).minimumScaleFactor(0.7) .accessibilityIdentifier("arenaCoinsToday") - ArenaGoalsHUD(goals:me.goals,compact:compact) if me.shield > 0 && me.alive && !compact { Label("Protected \(Int(ceil(me.shield)))s",systemImage:"shield.fill").font(.system(size:10,weight:.semibold)).lineLimit(1).minimumScaleFactor(0.8) } @@ -241,7 +268,7 @@ struct ArenaView: View { Spacer(minLength:0) Button {showScores.toggle()} label: { VStack(alignment:.trailing,spacing:compact ? 4 : 8) { - Label("#\(client.rank)",systemImage:"crown.fill").font(.system(size:compact ? 17 : 20,weight:.bold,design:.rounded)) + Label(client.rankText,systemImage:"crown.fill").font(.system(size:compact ? 17 : 20,weight:.bold,design:.rounded)) ForEach(client.leaders.prefix(compact ? 1 : showScores ? 5 : 3)) { leader in HStack(spacing:4) { if leader.id == client.snapshot?.bounty {Image(systemName:"crown.fill").foregroundStyle(ArenaEventArt.gold).accessibilityLabel("Bounty")} @@ -250,9 +277,13 @@ struct ArenaView: View { }.font(.system(size:10)).frame(maxWidth:135,alignment:.trailing) } }.padding(compact ? 10 : 14).background(DillTheme.cream.opacity(0.9),in:RoundedRectangle(cornerRadius:20)) - }.accessibilityLabel("Leaderboard. Rank \(client.rank). Tap to show more.") + }.accessibilityLabel("Leaderboard. Rank \(client.rankText).\(bountyName.map {" Bounty on \($0)."} ?? "") Tap to show more.") }.padding(.horizontal,18).padding(.top,compact ? 6 : 12) } + private var bountyName: String? { + guard let snapshot = client.snapshot, let bounty = snapshot.bounty else {return nil} + return snapshot.players.first {$0.id == bounty}?.name + } private func saveEarnings() { guard let earnedMass = client.me?.earnedMass, !client.room.isEmpty, !client.playerID.isEmpty else { return } store.recordArena(earnedMass:earnedMass,session:client.room + ":" + client.playerID,goalsDone:client.me?.goalsDone) diff --git a/ios/LittleDillTests/ArenaEventsTests.swift b/ios/LittleDillTests/ArenaEventsTests.swift index 50745a5..89df2b7 100644 --- a/ios/LittleDillTests/ArenaEventsTests.swift +++ b/ios/LittleDillTests/ArenaEventsTests.swift @@ -78,6 +78,23 @@ final class ArenaEventsTests: XCTestCase { XCTAssertNil(ArenaRunRecap(player:try snapshot().players.first)) } + func testGoalToastsFireOnceWhenAGoalCompletesAfterThePrimingSnapshot() { + var watch = ArenaGoalWatch() + let open = [ArenaGoal(kind:"eat",target:2,progress:1),ArenaGoal(kind:"food",target:40,progress:39)] + let done = [ArenaGoal(kind:"eat",target:2,progress:2),ArenaGoal(kind:"food",target:40,progress:40)] + XCTAssertEqual(watch.completed(done),[]) + XCTAssertEqual(watch.completed(done),[]) + watch.reset() + XCTAssertEqual(watch.completed(open),[]) + XCTAssertEqual(watch.completed(done),done) + XCTAssertEqual(watch.completed(done),[]) + let respawned = [ArenaGoal(kind:"mass",target:2,progress:2),ArenaGoal(kind:"food",target:40,progress:0)] + XCTAssertEqual(watch.completed(open),[]) + XCTAssertEqual(watch.completed(respawned),[]) + XCTAssertEqual(ArenaGoalWatch.toast(done[0],coins:true),"✓ Crunch 2 pickles · +5 coins") + XCTAssertEqual(ArenaGoalWatch.toast(done[1],coins:false),"✓ Eat 40 pellets") + } + func testGoalLabelsFallBackAndClampProgress() { XCTAssertEqual(ArenaGoal(kind:"juggle",target:3,progress:0).label,"Complete a goal") XCTAssertEqual(ArenaGoal(kind:"mass",target:12000,progress:0).label,"Reach 12000 mass") From 804af540eddb04de82ccc0bc0ecb5912420b5e02 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Mon, 19 Oct 2026 00:59:09 -0400 Subject: [PATCH 2/3] test(ios): dismiss the run recap after leaving the arena --- ios/LittleDillUITests/ContinuityUITests.swift | 4 ++-- ios/LittleDillUITests/LittleDillUITests.swift | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ios/LittleDillUITests/ContinuityUITests.swift b/ios/LittleDillUITests/ContinuityUITests.swift index 51c28b7..c386605 100644 --- a/ios/LittleDillUITests/ContinuityUITests.swift +++ b/ios/LittleDillUITests/ContinuityUITests.swift @@ -73,14 +73,14 @@ final class ContinuityUITests: XCTestCase { XCTAssertTrue(joystick.isHittable) XCTAssertFalse(app.buttons["arenaReconnect"].exists) capture("arena-restored") - tap("arenaLeave"); app.buttons["Leave arena"].tap() + tap("arenaLeave"); app.buttons["Leave arena"].tap(); tap("arenaRecapDone") XCTAssertTrue(app.buttons["tab.Friends"].waitForExistence(timeout:5)) } func testArenaThumbControlsSwapFromSettingsAndPersistOnCompactPhone() { let joystick = app.otherElements["arenaJoystick"], split = app.buttons["arenaSplit"], dash = app.buttons["arenaDash"] func enterArena() {tap("tab.Friends"); tap("createArenaRoom"); XCTAssertTrue(joystick.waitForExistence(timeout:20))} - func leaveArena() {tap("arenaLeave"); app.buttons["Leave arena"].tap()} + func leaveArena() {tap("arenaLeave"); app.buttons["Leave arena"].tap(); tap("arenaRecapDone")} func toggleSwap() { tap("Settings") let toggle = app.switches["arenaControlsSwapped"] diff --git a/ios/LittleDillUITests/LittleDillUITests.swift b/ios/LittleDillUITests/LittleDillUITests.swift index 049a916..4b8c369 100644 --- a/ios/LittleDillUITests/LittleDillUITests.swift +++ b/ios/LittleDillUITests/LittleDillUITests.swift @@ -68,7 +68,12 @@ final class LittleDillUITests: XCTestCase { let destination = joystick.coordinate(withNormalizedOffset:CGVector(dx:0.8,dy:0.3)) origin.press(forDuration:0.1,thenDragTo:destination,withVelocity:.slow,thenHoldForDuration:2) screenshot("11-live-arena-movement") + XCTAssertFalse(app.descendants(matching:.any)["arenaGoals"].exists) tap("arenaLeave"); app.buttons["Leave arena"].tap() + XCTAssertTrue(app.buttons["arenaRecapDone"].waitForExistence(timeout:5),"No run recap after leaving") + XCTAssertTrue(app.staticTexts["RUN RECAP"].exists) + screenshot("12-arena-recap") + tap("arenaRecapDone") XCTAssertTrue(app.buttons["tab.Nest"].waitForExistence(timeout:5)) } func testArenaSplitControlsStayVisibleOnCompactScreen() { @@ -86,7 +91,7 @@ final class LittleDillUITests: XCTestCase { XCTAssertFalse(joystick.frame.intersects(split.frame)) XCTAssertEqual(split.label,"Split your pickle") screenshot("split-controls-compact") - tap("arenaLeave"); app.buttons["Leave arena"].tap() + tap("arenaLeave"); app.buttons["Leave arena"].tap(); tap("arenaRecapDone") XCTAssertTrue(app.buttons["tab.Friends"].waitForExistence(timeout:5)) } func testDistinctCareScenesAndSoundSetting() { From 2ce094abe39dbe461c01ddfdfe980ebb97f441b1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Sun, 18 Oct 2026 23:10:30 -0400 Subject: [PATCH 3/3] chore(ios): prepare TestFlight 1.0.5 --- docs/testflight/1.0.5.md | 21 +++++++++++++++++++++ ios/LittleDill.xcodeproj/project.pbxproj | 4 ++-- ios/project.yml | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 docs/testflight/1.0.5.md diff --git a/docs/testflight/1.0.5.md b/docs/testflight/1.0.5.md new file mode 100644 index 0000000..72e0767 --- /dev/null +++ b/docs/testflight/1.0.5.md @@ -0,0 +1,21 @@ +This build adds three Brine Royale events and three ways to bond with your pickle. + +In the arena: +- Jar drops: every few minutes a jar appears on the minimap with a 10-second warning, then tips over and spills bonus food. +- Leader bounty: the biggest pickle wears a crown. Eating any of its pieces pays 10% extra mass. +- Run goals: each spawn gets three hidden goals. A short note pops up when you finish one, and each pays 5 coins within the daily coin cap. Dying or leaving shows a recap of peak mass, best rank, crunches, time alive, and goals, and you can share it. + +At home: +- Wishes: up to twice a day (from 9am and from 3pm), your pickle asks for a food, a bath, a pet, a game, or a nap. Granting it within 6 hours gives +10 happy and 5 coins. +- Scrapbook: life events, a 32-slot elder album, and care-streak stickers at 3, 7, 14, and 30 days. +- Favorites: each variety has a favorite food and game worth +4 extra happy. + +Please check: +1. Join Brine Royale and wait for a jar warning. Check the minimap marker, the jar tipping, and the food ring. +2. Find the crowned leader on the board and in the garden. +3. Finish a goal and check the pop-up, then leave. Check the recap card and that coins arrive in the closet. +4. After 9am, open the Nest and check for a wish. Grant it and check the reward. +5. Open the scrapbook and check the dates, the elder album, and your streak. +6. Feed your pickle its favorite food and check for the special reaction. + +Report the device, iOS version, steps taken, expected result, actual result, and a screenshot or recording when useful. diff --git a/ios/LittleDill.xcodeproj/project.pbxproj b/ios/LittleDill.xcodeproj/project.pbxproj index 6e301d3..44c8a83 100644 --- a/ios/LittleDill.xcodeproj/project.pbxproj +++ b/ios/LittleDill.xcodeproj/project.pbxproj @@ -639,7 +639,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -793,7 +793,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ios/project.yml b/ios/project.yml index 6cb7e6c..9be7217 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,7 +8,7 @@ settings: SWIFT_VERSION: "5.0" TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_STYLE: Automatic - MARKETING_VERSION: "1.0.4" + MARKETING_VERSION: "1.0.5" CURRENT_PROJECT_VERSION: 1 targets: LittleDill: