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:
@@ -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]},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user