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
21 changes: 21 additions & 0 deletions docs/testflight/1.0.5.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions ios/LittleDill.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion ios/LittleDill/ArenaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
20 changes: 20 additions & 0 deletions ios/LittleDill/ArenaEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
4 changes: 3 additions & 1 deletion ios/LittleDill/ArenaFeedback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +21,7 @@ enum ArenaFeedbackCue: CaseIterable, Equatable {
case .feast: 0.9
case .threat: 3
case .spill: 2
case .goal: 1
}
}

Expand All @@ -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)]
}
}

Expand Down
41 changes: 8 additions & 33 deletions ios/LittleDill/ArenaRecap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
41 changes: 36 additions & 5 deletions ios/LittleDill/ArenaView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void,Never>?
@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
Expand All @@ -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,
Expand All @@ -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()}
}
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -233,15 +261,14 @@ 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)
}
}.padding(compact ? 10 : 14).background(DillTheme.cream.opacity(0.9),in:RoundedRectangle(cornerRadius:20))
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")}
Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions ios/LittleDillTests/ArenaEventsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions ios/LittleDillUITests/ContinuityUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
7 changes: 6 additions & 1 deletion ios/LittleDillUITests/LittleDillUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading