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']
|
_reviewRequested = (json['flags']
|
||||||
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
|
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
|
||||||
false;
|
false;
|
||||||
|
_boostersSeeded = (json['flags']
|
||||||
|
as Map<String, dynamic>?)?['boostersSeeded'] as bool? ??
|
||||||
|
false;
|
||||||
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
|
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
|
||||||
for (final t in BoosterType.values) {
|
for (final t in BoosterType.values) {
|
||||||
_boosters[t] = boosters[t.name] as int? ?? 0;
|
_boosters[t] = boosters[t.name] as int? ?? 0;
|
||||||
@@ -76,6 +79,7 @@ class SaveRepository {
|
|||||||
bool _soundEnabled = true;
|
bool _soundEnabled = true;
|
||||||
bool _musicEnabled = true;
|
bool _musicEnabled = true;
|
||||||
bool _reviewRequested = false;
|
bool _reviewRequested = false;
|
||||||
|
bool _boostersSeeded = false;
|
||||||
final Map<BoosterType, int> _boosters = {
|
final Map<BoosterType, int> _boosters = {
|
||||||
for (final t in BoosterType.values) t: 0,
|
for (final t in BoosterType.values) t: 0,
|
||||||
};
|
};
|
||||||
@@ -138,6 +142,18 @@ class SaveRepository {
|
|||||||
return true;
|
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;
|
String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
|
||||||
int get dailyCalendarDay => _dailyCalendarDay;
|
int get dailyCalendarDay => _dailyCalendarDay;
|
||||||
|
|
||||||
@@ -221,6 +237,7 @@ class SaveRepository {
|
|||||||
'soundEnabled': _soundEnabled,
|
'soundEnabled': _soundEnabled,
|
||||||
'musicEnabled': _musicEnabled,
|
'musicEnabled': _musicEnabled,
|
||||||
'reviewRequested': _reviewRequested,
|
'reviewRequested': _reviewRequested,
|
||||||
|
'boostersSeeded': _boostersSeeded,
|
||||||
},
|
},
|
||||||
'endless': {'best': _endlessBest},
|
'endless': {'best': _endlessBest},
|
||||||
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
|
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const contentBaseUrl = String.fromEnvironment(
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final saveRepository = await SaveRepository.open();
|
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
|
// Analytics: real GA4 traffic flows only from release builds so development
|
||||||
// never pollutes production. If Firebase init fails (e.g. missing native
|
// never pollutes production. If Firebase init fails (e.g. missing native
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ class DailyRewardNotifier extends Notifier<DailyResolution> {
|
|||||||
final r = state;
|
final r = state;
|
||||||
if (!r.claimable) return;
|
if (!r.claimable) return;
|
||||||
final reward = _cal.rewardFor(r.day);
|
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);
|
final inv = ref.read(boosterInventoryProvider.notifier);
|
||||||
for (final entry in reward.entries) {
|
for (final entry in reward.entries) {
|
||||||
await inv.grant(entry.key, entry.value * (doubled ? 2 : 1));
|
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);
|
ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled);
|
||||||
for (final e in reward.entries) {
|
for (final e in reward.entries) {
|
||||||
ref.read(analyticsProvider).boosterGranted(
|
ref.read(analyticsProvider).boosterGranted(
|
||||||
|
|||||||
@@ -169,10 +169,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
final y = (local.dy / cell).floor();
|
final y = (local.dy / cell).floor();
|
||||||
if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return;
|
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);
|
final session = ref.read(gameSessionProvider.notifier);
|
||||||
if (armed == BoosterType.hammer) {
|
if (armed == BoosterType.hammer) {
|
||||||
await session.useHammer(x, y);
|
await session.useHammer(x, y);
|
||||||
if (mounted) setState(() => _arming = null);
|
|
||||||
} else if (armed == BoosterType.lineBomb) {
|
} else if (armed == BoosterType.lineBomb) {
|
||||||
final axis = await _chooseLineAxis();
|
final axis = await _chooseLineAxis();
|
||||||
if (axis == _LineAxis.row) {
|
if (axis == _LineAxis.row) {
|
||||||
@@ -180,8 +183,6 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
} else if (axis == _LineAxis.col) {
|
} else if (axis == _LineAxis.col) {
|
||||||
await session.useLineBomb(col: x);
|
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(repo.boosterCount(BoosterType.shuffle), 0);
|
||||||
expect(await repo.consumeBooster(BoosterType.shuffle), isFalse);
|
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