From fa2784519b4ee81ca653ade29b9fe092a90305ce Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 19:36:24 +0900 Subject: [PATCH] fix(boosters): address final-review findings - daily claim: record the claim before granting boosters, so a crash mid-claim forfeits at most one reward instead of allowing a re-claim (booster farming) on next launch. - game screen: disarm the booster target synchronously before awaiting, so a rapid second board tap can't double-fire a use or stack a dialog. - new players: seed one of each booster once (idempotent persisted flag), fulfilling the spec's starting inventory. Wired in main(). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/data/save_repository.dart | 17 +++++++++++++++++ lib/main.dart | 2 ++ lib/state/daily_reward_notifier.dart | 5 ++++- lib/ui/screens/game_screen.dart | 7 ++++--- test/data/save_repository_booster_test.dart | 17 +++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart index 1403c19..ded824d 100644 --- a/lib/data/save_repository.dart +++ b/lib/data/save_repository.dart @@ -52,6 +52,9 @@ class SaveRepository { _reviewRequested = (json['flags'] as Map?)?['reviewRequested'] as bool? ?? false; + _boostersSeeded = (json['flags'] + as Map?)?['boostersSeeded'] as bool? ?? + false; final boosters = json['boosters'] as Map? ?? {}; for (final t in BoosterType.values) { _boosters[t] = boosters[t.name] as int? ?? 0; @@ -76,6 +79,7 @@ class SaveRepository { bool _soundEnabled = true; bool _musicEnabled = true; bool _reviewRequested = false; + bool _boostersSeeded = false; final Map _boosters = { for (final t in BoosterType.values) t: 0, }; @@ -138,6 +142,18 @@ class SaveRepository { return true; } + /// Grants one of each booster the first time it ever runs, so a new player + /// can try every booster. Idempotent for the app's lifetime via a persisted + /// flag — safe to call on every launch. + Future seedInitialBoostersIfNeeded() async { + if (_boostersSeeded) return; + _boostersSeeded = true; + for (final t in BoosterType.values) { + _boosters[t] = (_boosters[t] ?? 0) + 1; + } + await _flush(); + } + String? get dailyLastClaimedYmd => _dailyLastClaimedYmd; int get dailyCalendarDay => _dailyCalendarDay; @@ -221,6 +237,7 @@ class SaveRepository { 'soundEnabled': _soundEnabled, 'musicEnabled': _musicEnabled, 'reviewRequested': _reviewRequested, + 'boostersSeeded': _boostersSeeded, }, 'endless': {'best': _endlessBest}, 'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]}, diff --git a/lib/main.dart b/lib/main.dart index 0e505da..71cc244 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,8 @@ const contentBaseUrl = String.fromEnvironment( Future main() async { WidgetsFlutterBinding.ensureInitialized(); final saveRepository = await SaveRepository.open(); + // New players start with one of each booster (idempotent after the first run). + await saveRepository.seedInitialBoostersIfNeeded(); // Analytics: real GA4 traffic flows only from release builds so development // never pollutes production. If Firebase init fails (e.g. missing native diff --git a/lib/state/daily_reward_notifier.dart b/lib/state/daily_reward_notifier.dart index 3c3fb57..57f6d62 100644 --- a/lib/state/daily_reward_notifier.dart +++ b/lib/state/daily_reward_notifier.dart @@ -25,11 +25,14 @@ class DailyRewardNotifier extends Notifier { final r = state; if (!r.claimable) return; final reward = _cal.rewardFor(r.day); + // Record the claim BEFORE granting, so a crash mid-claim forfeits at most + // one reward rather than leaving the day unrecorded (which would let the + // player re-claim and farm boosters on the next launch). + await _save.recordDailyClaim(_cal.ymd(_now()), r.day); final inv = ref.read(boosterInventoryProvider.notifier); for (final entry in reward.entries) { await inv.grant(entry.key, entry.value * (doubled ? 2 : 1)); } - await _save.recordDailyClaim(_cal.ymd(_now()), r.day); ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled); for (final e in reward.entries) { ref.read(analyticsProvider).boosterGranted( diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index db45e1a..030590d 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -169,10 +169,13 @@ class _GameScreenState extends ConsumerState final y = (local.dy / cell).floor(); if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return; + // Disarm synchronously, before any await, so a rapid second tap on the + // board is a no-op rather than a redundant booster use / stacked dialog. + setState(() => _arming = null); + final session = ref.read(gameSessionProvider.notifier); if (armed == BoosterType.hammer) { await session.useHammer(x, y); - if (mounted) setState(() => _arming = null); } else if (armed == BoosterType.lineBomb) { final axis = await _chooseLineAxis(); if (axis == _LineAxis.row) { @@ -180,8 +183,6 @@ class _GameScreenState extends ConsumerState } else if (axis == _LineAxis.col) { await session.useLineBomb(col: x); } - // A dismissed chooser cancels the use but still clears the armed state. - if (mounted) setState(() => _arming = null); } } diff --git a/test/data/save_repository_booster_test.dart b/test/data/save_repository_booster_test.dart index 95f4dd7..b56db66 100644 --- a/test/data/save_repository_booster_test.dart +++ b/test/data/save_repository_booster_test.dart @@ -34,4 +34,21 @@ void main() { expect(repo.boosterCount(BoosterType.shuffle), 0); expect(await repo.consumeBooster(BoosterType.shuffle), isFalse); }); + + test('seedInitialBoosters grants 1 of each once, then is idempotent', + () async { + final repo = await fresh(); + await repo.seedInitialBoostersIfNeeded(); + for (final t in BoosterType.values) { + expect(repo.boosterCount(t), 1, reason: t.name); + } + + // A second call (and a reload) must not grant again — the flag persists. + await repo.seedInitialBoostersIfNeeded(); + final reloaded = SaveRepository(await SharedPreferences.getInstance()); + await reloaded.seedInitialBoostersIfNeeded(); + for (final t in BoosterType.values) { + expect(reloaded.boosterCount(t), 1, reason: '${t.name} after reload'); + } + }); }