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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 19:36:24 +09:00
parent 412cc08167
commit fa2784519b
5 changed files with 44 additions and 4 deletions
+17
View File
@@ -52,6 +52,9 @@ class SaveRepository {
_reviewRequested = (json['flags']
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
false;
_boostersSeeded = (json['flags']
as Map<String, dynamic>?)?['boostersSeeded'] as bool? ??
false;
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
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<BoosterType, int> _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<void> 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]},
+2
View File
@@ -28,6 +28,8 @@ const contentBaseUrl = String.fromEnvironment(
Future<void> 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
+4 -1
View File
@@ -25,11 +25,14 @@ class DailyRewardNotifier extends Notifier<DailyResolution> {
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(
+4 -3
View File
@@ -169,10 +169,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
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<GameScreen>
} 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);
}
}
@@ -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');
}
});
}