Files
BlockSeasons/lib/data/save_repository.dart
airkjw fa2784519b 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>
2026-06-18 19:36:24 +09:00

248 lines
7.7 KiB
Dart

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../game/models/booster.dart';
import 'streak.dart';
class StageProgress {
const StageProgress({required this.stars, required this.bestScore});
final int stars;
final int bestScore;
}
/// Versioned JSON-over-prefs persistence for progress. Total payload stays
/// tiny (<100 KB), so a key-value blob beats a database here.
class SaveRepository {
SaveRepository(this._prefs) {
final raw = _prefs.getString(_key);
if (raw != null) {
final json = jsonDecode(raw) as Map<String, dynamic>;
final progress = json['progress'] as Map<String, dynamic>? ?? {};
for (final entry in progress.entries) {
final value = entry.value as Map<String, dynamic>;
_progress[entry.key] = StageProgress(
stars: value['stars'] as int,
bestScore: value['bestScore'] as int,
);
}
final streak = json['streak'] as Map<String, dynamic>?;
if (streak != null) {
_streak = StreakState(
current: streak['current'] as int,
best: streak['best'] as int,
lastYmd: streak['lastYmd'] as String?,
);
}
_tutorialDone =
(json['flags'] as Map<String, dynamic>?)?['tutorialDone'] as bool? ??
false;
_endlessBest =
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
_adsRemoved =
(json['flags'] as Map<String, dynamic>?)?['adsRemoved'] as bool? ??
false;
_soundEnabled =
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
true;
_musicEnabled =
(json['flags'] as Map<String, dynamic>?)?['musicEnabled'] as bool? ??
true;
_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;
}
final daily = json['daily'] as Map<String, dynamic>?;
_dailyLastClaimedYmd = daily?['lastYmd'] as String?;
_dailyCalendarDay = daily?['day'] as int? ?? 0;
}
}
static const _key = 'save_v1';
static Future<SaveRepository> open() async =>
SaveRepository(await SharedPreferences.getInstance());
final SharedPreferences _prefs;
final Map<String, StageProgress> _progress = {};
StreakState _streak = StreakState.initial;
bool _tutorialDone = false;
int _endlessBest = 0;
bool _adsRemoved = false;
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,
};
String? _dailyLastClaimedYmd;
int _dailyCalendarDay = 0;
StreakState get streak => _streak;
bool get tutorialDone => _tutorialDone;
int get endlessBest => _endlessBest;
bool get adsRemoved => _adsRemoved;
bool get soundEnabled => _soundEnabled;
bool get musicEnabled => _musicEnabled;
/// Whether we've already asked the player for a store review (one-time).
bool get reviewRequested => _reviewRequested;
/// Distinct stages the player has cleared (any season, >=1 star). Used to
/// gate the review prompt behind a player who is clearly invested.
int get stagesClearedCount =>
_progress.values.where((p) => p.stars > 0).length;
Future<void> markTutorialDone() {
_tutorialDone = true;
return _flush();
}
Future<void> setAdsRemoved(bool value) {
_adsRemoved = value;
return _flush();
}
Future<void> setSoundEnabled(bool value) {
_soundEnabled = value;
return _flush();
}
Future<void> setMusicEnabled(bool value) {
_musicEnabled = value;
return _flush();
}
Future<void> markReviewRequested() {
_reviewRequested = true;
return _flush();
}
int boosterCount(BoosterType type) => _boosters[type] ?? 0;
Future<void> grantBooster(BoosterType type, [int n = 1]) {
_boosters[type] = (_boosters[type] ?? 0) + n;
return _flush();
}
/// Spends one booster. Returns false (and changes nothing) when none are left.
Future<bool> consumeBooster(BoosterType type) async {
final have = _boosters[type] ?? 0;
if (have <= 0) return false;
_boosters[type] = have - 1;
await _flush();
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;
Future<void> recordDailyClaim(String ymd, int day) {
_dailyLastClaimedYmd = ymd;
_dailyCalendarDay = day;
return _flush();
}
Future<void> recordEndlessScore(int score) {
if (score > _endlessBest) _endlessBest = score;
return _flush();
}
Future<void> saveStreak(StreakState streak) {
_streak = streak;
return _flush();
}
static String _id(String seasonId, String stageId) => '$seasonId/$stageId';
/// Immutable copy of all progress, keyed `seasonId/stageId`.
Map<String, StageProgress> snapshot() => Map.unmodifiable(_progress);
StageProgress? progressFor(String seasonId, String stageId) =>
_progress[_id(seasonId, stageId)];
Future<void> recordResult({
required String seasonId,
required String stageId,
required int stars,
required int score,
}) async {
final id = _id(seasonId, stageId);
final old = _progress[id];
_progress[id] = StageProgress(
stars: old == null || stars > old.stars ? stars : old.stars,
bestScore:
old == null || score > old.bestScore ? score : old.bestScore,
);
await _flush();
}
int totalStars(String seasonId) {
var total = 0;
for (final entry in _progress.entries) {
if (entry.key.startsWith('$seasonId/')) total += entry.value.stars;
}
return total;
}
/// Index of the furthest playable stage: the first without a star, or the
/// last stage when everything is starred.
int highestUnlockedIndex(String seasonId, List<String> stageIds) {
for (var i = 0; i < stageIds.length; i++) {
final progress = progressFor(seasonId, stageIds[i]);
if (progress == null || progress.stars == 0) return i;
}
return stageIds.length - 1;
}
Future<void> _flush() => _prefs.setString(
_key,
jsonEncode({
'saveVersion': 1,
'progress': {
for (final entry in _progress.entries)
entry.key: {
'stars': entry.value.stars,
'bestScore': entry.value.bestScore,
},
},
'streak': {
'current': _streak.current,
'best': _streak.best,
'lastYmd': _streak.lastYmd,
},
'flags': {
'tutorialDone': _tutorialDone,
'adsRemoved': _adsRemoved,
'soundEnabled': _soundEnabled,
'musicEnabled': _musicEnabled,
'reviewRequested': _reviewRequested,
'boostersSeeded': _boostersSeeded,
},
'endless': {'best': _endlessBest},
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay},
}),
);
}