From 4cda34f0b7374f4de9bd09f2bafbc75ece212039 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 11:57:51 +0900 Subject: [PATCH] docs(plan): boosters & daily reward implementation plan 17 bite-sized TDD tasks: engine booster ops -> inventory/daily persistence -> pure daily-calendar -> notifiers -> analytics/l10n -> booster bar + targeting -> rewarded-ad grants -> daily popup -> integration. Verification is flutter test + analyze only (builds are owner-commanded). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-18-boosters-daily-reward.md | 1447 +++++++++++++++++ 1 file changed, 1447 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-boosters-daily-reward.md diff --git a/docs/superpowers/plans/2026-06-18-boosters-daily-reward.md b/docs/superpowers/plans/2026-06-18-boosters-daily-reward.md new file mode 100644 index 0000000..5e57bb8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-boosters-daily-reward.md @@ -0,0 +1,1447 @@ +# Boosters & Daily Reward Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a lightweight booster economy (hammer / shuffle / line-bomb) earned via a 7-day login calendar and rewarded ads, used in a stage through a booster bar. + +**Architecture:** Pure-Dart booster ops live on `GameEngine` (grid-only mutation, no score/move/objective effects). A `BoosterType` enum + `SaveRepository` counts back a `BoosterInventoryNotifier`. A pure `DailyRewardCalendar` computes the claimable day; `DailyRewardNotifier` grants. `GameSessionNotifier` coordinates engine + inventory on use. UI adds a booster bar (targeting mode) and a daily popup. Rewarded ads reuse `AdService.showRewarded()`. + +**Tech Stack:** Flutter, Riverpod (plain Notifiers, no codegen), shared_preferences, google_mobile_ads, flutter gen-l10n, flutter_test. + +**Spec:** `docs/superpowers/specs/2026-06-18-boosters-daily-reward-design.md` + +**Conventions:** TDD (red → green). Run a single test file with `flutter test path`. Run all with `flutter test`. `flutter analyze` must stay clean. **Do NOT run `flutter build` / Xcode — builds are owner-commanded only** (see CLAUDE.md). Commit after each green task. + +--- + +## File Structure + +- Create `lib/game/models/booster.dart` — `BoosterType` enum (pure). +- Modify `lib/game/engine/game_engine.dart` — `useHammer`/`useShuffle`/`useLineBomb`. +- Modify `lib/data/save_repository.dart` — booster counts + daily-claim persistence. +- Create `lib/game/daily/daily_reward.dart` — pure `DailyRewardCalendar` + reward table. +- Create `lib/state/booster_inventory_notifier.dart` — inventory Notifier. +- Create `lib/state/daily_reward_notifier.dart` — daily Notifier. +- Modify `lib/state/game_session_notifier.dart` — booster-use coordination + view fields. +- Modify `lib/state/providers.dart` — new providers. +- Create `lib/ui/widgets/booster_bar.dart` — booster bar + targeting state. +- Modify `lib/ui/screens/game_screen.dart` — mount bar, targeting taps, 0-count ad dialog. +- Create `lib/ui/widgets/daily_reward_sheet.dart` — 7-day popup. +- Modify `lib/ui/screens/home_screen.dart` — show popup when claimable. +- Modify `lib/services/analytics_service.dart` — booster/daily events. +- Modify `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` — strings. +- Tests under `test/game/`, `test/data/`, `test/state/`, `test/ui/`. + +--- + +## Task 1: Engine — `useHammer` + +**Files:** +- Modify: `lib/game/engine/game_engine.dart` +- Test: `test/game/engine/booster_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/game/engine/game_engine.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +StageConfig _stage() => StageConfig.fromJson({ + 'id': 'b_test', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 0, 'y': 0, 't': 'filled', 'c': 3}, + {'x': 1, 'y': 0, 't': 'filled', 'c': 4}, + ], + 'objectives': [ + {'type': 'reachScore', 'target': 100000}, + ], + }); + +void main() { + test('useHammer empties a filled cell without scoring or spending a move', () { + final e = GameEngine(_stage()); + final score0 = e.score; + final moves0 = e.movesUsed; + + expect(e.grid.isOccupied(0, 0), isTrue); + final ok = e.useHammer(0, 0); + + expect(ok, isTrue); + expect(e.grid.isOccupied(0, 0), isFalse); + expect(e.score, score0, reason: 'no points from a booster'); + expect(e.movesUsed, moves0, reason: 'no move spent'); + }); + + test('useHammer on an empty cell returns false and changes nothing', () { + final e = GameEngine(_stage()); + expect(e.grid.isOccupied(5, 5), isFalse); + expect(e.useHammer(5, 5), isFalse); + expect(e.grid.isOccupied(5, 5), isFalse); + }); + + test('useHammer is rejected once the stage is won or lost', () { + final e = GameEngine(_stage()); + e.declineAndLose(); + expect(e.useHammer(0, 0), isFalse); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: FAIL — `useHammer` not defined. + +- [ ] **Step 3: Write minimal implementation** + +Add to `GameEngine` (after `declineAndLose`): + +```dart + /// Booster: empties one filled cell. No move/score/combo/objective effect. + /// Allowed only mid-attempt (playing or stuck). Returns false on an empty + /// cell or finished attempt so the caller keeps the booster. + bool useHammer(int x, int y) { + if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; + if (!_grid.isOccupied(x, y)) return false; + _grid = _grid.withCell(x, y, const Cell(CellType.empty)); + _checkStuck(); + return true; + } +``` + +Ensure `cell.dart` (`Cell`, `CellType`) is imported — it already is. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart +git commit -m "feat(engine): hammer booster removes one cell, no scoring" +``` + +--- + +## Task 2: Engine — `useShuffle` + +**Files:** +- Modify: `lib/game/engine/game_engine.dart` +- Test: `test/game/engine/booster_test.dart` (append) + +- [ ] **Step 1: Append the failing test** + +```dart + test('useShuffle replaces the tray without spending a move or scoring', () { + final e = GameEngine(_stage()); + final before = List.of(e.tray); + final score0 = e.score; + final moves0 = e.movesUsed; + + final ok = e.useShuffle(); + + expect(ok, isTrue); + expect(e.tray.length, before.length); + expect(e.score, score0); + expect(e.movesUsed, moves0); + }); + + test('useShuffle is rejected after the attempt finishes', () { + final e = GameEngine(_stage()); + e.declineAndLose(); + expect(e.useShuffle(), isFalse); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: FAIL — `useShuffle` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```dart + /// Booster: re-deals the tray. No move/score effect. Re-checks stuck so a + /// dead board with a hopeless tray can become playable again. + bool useShuffle() { + if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; + _tray = _generator.nextTray(_grid); + _checkStuck(); + return true; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart +git commit -m "feat(engine): shuffle booster re-deals the tray" +``` + +--- + +## Task 3: Engine — `useLineBomb` + +**Files:** +- Modify: `lib/game/engine/game_engine.dart` +- Test: `test/game/engine/booster_test.dart` (append) + +- [ ] **Step 1: Append the failing test** + +```dart + test('useLineBomb(row:) empties that row, no scoring or move', () { + final e = GameEngine(_stage()); // row 0 has cells at x=0,1 + final score0 = e.score; + final moves0 = e.movesUsed; + + final ok = e.useLineBomb(row: 0); + + expect(ok, isTrue); + for (var x = 0; x < 8; x++) { + expect(e.grid.isOccupied(x, 0), isFalse, reason: 'col $x'); + } + expect(e.score, score0); + expect(e.movesUsed, moves0); + }); + + test('useLineBomb(col:) empties that column', () { + final e = GameEngine(_stage()); // (0,0) filled + expect(e.useLineBomb(col: 0), isTrue); + for (var y = 0; y < 8; y++) { + expect(e.grid.isOccupied(0, y), isFalse, reason: 'row $y'); + } + }); + + test('useLineBomb requires exactly one of row/col', () { + final e = GameEngine(_stage()); + expect(e.useLineBomb(), isFalse); + expect(e.useLineBomb(row: 0, col: 0), isFalse); + }); + + test('useLineBomb is rejected after the attempt finishes', () { + final e = GameEngine(_stage()); + e.declineAndLose(); + expect(e.useLineBomb(row: 0), isFalse); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: FAIL — `useLineBomb` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```dart + /// Booster: empties one row or one column (exactly one of [row]/[col]). + /// No move/score/objective effect. Re-checks stuck. + bool useLineBomb({int? row, int? col}) { + if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; + if ((row == null) == (col == null)) return false; // need exactly one + for (var i = 0; i < GridState.size; i++) { + final x = col ?? i; + final y = row ?? i; + _grid = _grid.withCell(x, y, const Cell(CellType.empty)); + } + _checkStuck(); + return true; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/game/engine/booster_test.dart` +Expected: PASS (all booster engine tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart +git commit -m "feat(engine): line-bomb booster clears a row or column" +``` + +--- + +## Task 4: `BoosterType` model + +**Files:** +- Create: `lib/game/models/booster.dart` + +- [ ] **Step 1: Create the enum** (no behavior → no test; it is exercised by later tasks) + +```dart +// lib/game/models/booster.dart + +/// The three boosters the player can earn and spend. Pure model so the data +/// and state layers can both reference it without a Flutter dependency. +enum BoosterType { hammer, shuffle, lineBomb } +``` + +- [ ] **Step 2: Verify analyze is clean** + +Run: `flutter analyze` +Expected: No issues. + +- [ ] **Step 3: Commit** + +```bash +git add lib/game/models/booster.dart +git commit -m "feat(model): BoosterType enum" +``` + +--- + +## Task 5: SaveRepository — booster inventory + +**Files:** +- Modify: `lib/data/save_repository.dart` +- Test: `test/data/save_repository_booster_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/models/booster.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future fresh() async { + SharedPreferences.setMockInitialValues({}); + return SaveRepository(await SharedPreferences.getInstance()); + } + + test('booster counts start at zero', () async { + final repo = await fresh(); + for (final t in BoosterType.values) { + expect(repo.boosterCount(t), 0); + } + }); + + test('grantBooster adds and persists across reload', () async { + final repo = await fresh(); + await repo.grantBooster(BoosterType.hammer, 2); + expect(repo.boosterCount(BoosterType.hammer), 2); + + final reloaded = SaveRepository(await SharedPreferences.getInstance()); + expect(reloaded.boosterCount(BoosterType.hammer), 2); + }); + + test('consumeBooster decrements and returns false at zero', () async { + final repo = await fresh(); + await repo.grantBooster(BoosterType.shuffle, 1); + expect(await repo.consumeBooster(BoosterType.shuffle), isTrue); + expect(repo.boosterCount(BoosterType.shuffle), 0); + expect(await repo.consumeBooster(BoosterType.shuffle), isFalse); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/data/save_repository_booster_test.dart` +Expected: FAIL — `boosterCount` not defined. + +- [ ] **Step 3: Write minimal implementation** + +In `save_repository.dart`: + +1. Add import at top: `import '../game/models/booster.dart';` +2. Add field + parse in constructor (inside the `if (raw != null)` block, after the flags parsing): + +```dart + final boosters = json['boosters'] as Map? ?? {}; + for (final t in BoosterType.values) { + _boosters[t] = boosters[t.name] as int? ?? 0; + } +``` + +3. Add field near the other fields: + +```dart + final Map _boosters = { + for (final t in BoosterType.values) t: 0, + }; +``` + +4. Add API: + +```dart + int boosterCount(BoosterType type) => _boosters[type] ?? 0; + + Future 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 consumeBooster(BoosterType type) async { + final have = _boosters[type] ?? 0; + if (have <= 0) return false; + _boosters[type] = have - 1; + await _flush(); + return true; + } +``` + +5. In `_flush()`'s `jsonEncode({...})`, add a key: + +```dart + 'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/data/save_repository_booster_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/save_repository.dart test/data/save_repository_booster_test.dart +git commit -m "feat(save): persist booster inventory" +``` + +--- + +## Task 6: SaveRepository — daily-claim persistence + +**Files:** +- Modify: `lib/data/save_repository.dart` +- Test: `test/data/save_repository_daily_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/data/save_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future fresh() async { + SharedPreferences.setMockInitialValues({}); + return SaveRepository(await SharedPreferences.getInstance()); + } + + test('daily claim fields start empty', () async { + final repo = await fresh(); + expect(repo.dailyLastClaimedYmd, isNull); + expect(repo.dailyCalendarDay, 0); + }); + + test('recordDailyClaim persists day and ymd across reload', () async { + final repo = await fresh(); + await repo.recordDailyClaim('2026-06-18', 3); + expect(repo.dailyLastClaimedYmd, '2026-06-18'); + expect(repo.dailyCalendarDay, 3); + + final reloaded = SaveRepository(await SharedPreferences.getInstance()); + expect(reloaded.dailyLastClaimedYmd, '2026-06-18'); + expect(reloaded.dailyCalendarDay, 3); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/data/save_repository_daily_test.dart` +Expected: FAIL — `dailyLastClaimedYmd` not defined. + +- [ ] **Step 3: Write minimal implementation** + +1. Add fields + parse (in constructor, after boosters parse): + +```dart + final daily = json['daily'] as Map?; + _dailyLastClaimedYmd = daily?['lastYmd'] as String?; + _dailyCalendarDay = daily?['day'] as int? ?? 0; +``` + +2. Fields: + +```dart + String? _dailyLastClaimedYmd; + int _dailyCalendarDay = 0; +``` + +3. API: + +```dart + String? get dailyLastClaimedYmd => _dailyLastClaimedYmd; + int get dailyCalendarDay => _dailyCalendarDay; + + Future recordDailyClaim(String ymd, int day) { + _dailyLastClaimedYmd = ymd; + _dailyCalendarDay = day; + return _flush(); + } +``` + +4. In `_flush()`'s map, add: + +```dart + 'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/data/save_repository_daily_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/save_repository.dart test/data/save_repository_daily_test.dart +git commit -m "feat(save): persist daily-reward claim state" +``` + +--- + +## Task 7: Pure `DailyRewardCalendar` + +**Files:** +- Create: `lib/game/daily/daily_reward.dart` +- Test: `test/game/daily/daily_reward_test.dart` + +This is pure logic: given the last-claimed ymd + stored day + today's ymd, compute +whether today is claimable and which calendar day (1–7) applies, plus the reward +table. Dates are passed as `DateTime` (date-only) to stay testable. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/game/daily/daily_reward.dart'; +import 'package:block_seasons/game/models/booster.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final cal = DailyRewardCalendar(); + + DateTime d(int day) => DateTime(2026, 6, day); + + test('first ever claim is day 1', () { + final r = cal.resolve(lastClaimedYmd: null, storedDay: 0, today: d(18)); + expect(r.claimable, isTrue); + expect(r.day, 1); + }); + + test('claiming again the same day is not claimable', () { + final r = cal.resolve( + lastClaimedYmd: '2026-06-18', storedDay: 1, today: d(18)); + expect(r.claimable, isFalse); + }); + + test('a consecutive day advances the calendar', () { + final r = cal.resolve( + lastClaimedYmd: '2026-06-18', storedDay: 3, today: d(19)); + expect(r.claimable, isTrue); + expect(r.day, 4); + }); + + test('day 7 wraps back to day 1', () { + final r = cal.resolve( + lastClaimedYmd: '2026-06-18', storedDay: 7, today: d(19)); + expect(r.day, 1); + }); + + test('a missed day resets to day 1', () { + final r = cal.resolve( + lastClaimedYmd: '2026-06-18', storedDay: 4, today: d(20)); + expect(r.claimable, isTrue); + expect(r.day, 1); + }); + + test('reward table covers days 1..7 and day 7 is the jackpot', () { + expect(cal.rewardFor(1), {BoosterType.hammer: 1}); + expect(cal.rewardFor(7), + {BoosterType.hammer: 2, BoosterType.shuffle: 2, BoosterType.lineBomb: 2}); + }); + + test('ymd formats a date as YYYY-MM-DD', () { + expect(cal.ymd(DateTime(2026, 6, 7)), '2026-06-07'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/game/daily/daily_reward_test.dart` +Expected: FAIL — `DailyRewardCalendar` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```dart +// lib/game/daily/daily_reward.dart +import '../models/booster.dart'; + +/// The result of evaluating the calendar for "today". +class DailyResolution { + const DailyResolution({required this.claimable, required this.day}); + final bool claimable; + final int day; // 1..7, the day to show/grant +} + +/// Pure 7-day login-calendar logic. No storage or clock — callers pass the +/// persisted state and today's date so it is fully unit-testable. +class DailyRewardCalendar { + const DailyRewardCalendar(); + + static const int cycle = 7; + + String ymd(DateTime date) { + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '${date.year}-$m-$d'; + } + + DailyResolution resolve({ + required String? lastClaimedYmd, + required int storedDay, + required DateTime today, + }) { + final todayYmd = ymd(today); + if (lastClaimedYmd == todayYmd) { + return DailyResolution(claimable: false, day: storedDay); + } + final yesterday = ymd(today.subtract(const Duration(days: 1))); + final int day; + if (lastClaimedYmd == yesterday) { + day = (storedDay % cycle) + 1; // advance, wrapping 7 -> 1 + } else { + day = 1; // first ever, or a day was missed + } + return DailyResolution(claimable: true, day: day); + } + + /// Boosters granted for a given calendar day (1..7). Tunable. + Map rewardFor(int day) { + switch (day) { + case 1: + return {BoosterType.hammer: 1}; + case 2: + return {BoosterType.shuffle: 1}; + case 3: + return {BoosterType.lineBomb: 1}; + case 4: + return {BoosterType.hammer: 1, BoosterType.shuffle: 1}; + case 5: + return {BoosterType.shuffle: 1, BoosterType.lineBomb: 1}; + case 6: + return {BoosterType.hammer: 1, BoosterType.lineBomb: 1}; + case 7: + default: + return { + BoosterType.hammer: 2, + BoosterType.shuffle: 2, + BoosterType.lineBomb: 2, + }; + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/game/daily/daily_reward_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/game/daily/daily_reward.dart test/game/daily/daily_reward_test.dart +git commit -m "feat(daily): pure 7-day login-calendar logic + reward table" +``` + +--- + +## Task 8: `BoosterInventoryNotifier` + +**Files:** +- Create: `lib/state/booster_inventory_notifier.dart` +- Modify: `lib/state/providers.dart` +- Test: `test/state/booster_inventory_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/models/booster.dart'; +import 'package:block_seasons/state/booster_inventory_notifier.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future container() async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + return ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + } + + test('exposes counts and updates on grant/consume', () async { + final c = await container(); + final notifier = c.read(boosterInventoryProvider.notifier); + + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0); + + await notifier.grant(BoosterType.hammer, 2); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2); + + expect(await notifier.consume(BoosterType.hammer), isTrue); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/state/booster_inventory_test.dart` +Expected: FAIL — `booster_inventory_notifier.dart` missing. + +- [ ] **Step 3: Write minimal implementation** + +```dart +// lib/state/booster_inventory_notifier.dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/save_repository.dart'; +import '../game/models/booster.dart'; + +/// Live booster counts backed by [SaveRepository]. State is an immutable map. +class BoosterInventoryNotifier extends Notifier> { + SaveRepository get _save => ref.read(saveRepositoryProvider); + + @override + Map build() => _snapshot(); + + Map _snapshot() => + {for (final t in BoosterType.values) t: _save.boosterCount(t)}; + + Future grant(BoosterType type, [int n = 1]) async { + await _save.grantBooster(type, n); + state = _snapshot(); + } + + Future consume(BoosterType type) async { + final ok = await _save.consumeBooster(type); + if (ok) state = _snapshot(); + return ok; + } +} +``` + +Note: `saveRepositoryProvider` is already imported via `providers.dart`; this file +imports `save_repository.dart` directly to read the provider value through `ref`. +Add to `providers.dart` (with the other imports + providers): + +```dart +import 'booster_inventory_notifier.dart'; +``` +```dart +final boosterInventoryProvider = + NotifierProvider>( + BoosterInventoryNotifier.new, +); +``` +Also add `import '../game/models/booster.dart';` to `providers.dart` if not present. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/state/booster_inventory_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/state/booster_inventory_notifier.dart lib/state/providers.dart test/state/booster_inventory_test.dart +git commit -m "feat(state): BoosterInventoryNotifier" +``` + +--- + +## Task 9: `GameSessionNotifier` — booster-use coordination + +**Files:** +- Modify: `lib/state/game_session_notifier.dart` +- Test: `test/state/game_session_booster_test.dart` + +Coordination rule: call the engine first; **consume inventory only on engine +success**; never call the engine when the count is 0. Returns a small result so +the UI knows whether to show the "watch ad" path. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/game/models/booster.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:block_seasons/state/booster_inventory_notifier.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +StageConfig _stage() => StageConfig.fromJson({ + 'id': 'gs_b', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 0, 'y': 0, 't': 'filled', 'c': 3}, + ], + 'objectives': [ + {'type': 'reachScore', 'target': 100000}, + ], + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future container() async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + final c = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + c.read(gameSessionProvider.notifier) + .startStage(_stage(), generator: PieceGenerator(SeededRng(1))); + return c; + } + + test('hammer consumes a booster only when one is owned and target valid', + () async { + final c = await container(); + final session = c.read(gameSessionProvider.notifier); + final inv = c.read(boosterInventoryProvider.notifier); + + // No booster -> noBooster, grid unchanged. + expect(await session.useHammer(0, 0), BoosterUseResult.noBooster); + + await inv.grant(BoosterType.hammer, 1); + expect(await session.useHammer(0, 0), BoosterUseResult.success); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0); + expect(c.read(gameSessionProvider)!.grid.isOccupied(0, 0), isFalse); + }); + + test('an invalid target keeps the booster', () async { + final c = await container(); + final session = c.read(gameSessionProvider.notifier); + await c.read(boosterInventoryProvider.notifier).grant(BoosterType.hammer, 1); + + expect(await session.useHammer(5, 5), BoosterUseResult.invalidTarget); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/state/game_session_booster_test.dart` +Expected: FAIL — `useHammer`/`BoosterUseResult` not defined. + +- [ ] **Step 3: Write minimal implementation** + +In `game_session_notifier.dart`: +1. Imports: `import '../game/models/booster.dart';` and `import 'booster_inventory_notifier.dart';` (and `providers.dart` access to `boosterInventoryProvider` — it is in the same library group; import what is needed). +2. Add the result enum near the top of the file: + +```dart +enum BoosterUseResult { success, noBooster, invalidTarget } +``` + +3. Add methods to the notifier (the engine instance is held as `_engine`; confirm +the field name when implementing and rebuild the view via the existing +`_emit()`/`state = ...` mechanism this file already uses after a move): + +```dart + Future useHammer(int x, int y) => + _useBooster(BoosterType.hammer, () => _engine.useHammer(x, y)); + + Future useShuffle() => + _useBooster(BoosterType.shuffle, () => _engine.useShuffle()); + + Future useLineBomb({int? row, int? col}) => _useBooster( + BoosterType.lineBomb, + () => _engine.useLineBomb(row: row, col: col), + ); + + Future _useBooster( + BoosterType type, bool Function() apply) async { + final inv = ref.read(boosterInventoryProvider.notifier); + if (ref.read(boosterInventoryProvider)[type]! <= 0) { + return BoosterUseResult.noBooster; + } + if (!apply()) return BoosterUseResult.invalidTarget; + await inv.consume(type); + _emitFromEngine(); // rebuild GameViewState from _engine (use existing helper) + return BoosterUseResult.success; + } +``` + +**Implementation note:** This file already rebuilds `state` (a `GameViewState`) +from the engine after `tryPlace`. Reuse that exact rebuild path for +`_emitFromEngine()` — do not invent a new one. Read the file first and call the +same constructor/helper used after a successful placement. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/state/game_session_booster_test.dart` +Expected: PASS. + +- [ ] **Step 5: Run the full suite + analyze** + +Run: `flutter test` then `flutter analyze` +Expected: all green, no issues. + +- [ ] **Step 6: Commit** + +```bash +git add lib/state/game_session_notifier.dart test/state/game_session_booster_test.dart +git commit -m "feat(state): coordinate booster use with inventory in the session" +``` + +--- + +## Task 10: `DailyRewardNotifier` + +**Files:** +- Create: `lib/state/daily_reward_notifier.dart` +- Modify: `lib/state/providers.dart` +- Test: `test/state/daily_reward_notifier_test.dart` + +State: the current `DailyResolution` (claimable + day). `claim({bool doubled})` +grants the day's boosters (×2 if doubled), records the claim, refreshes state. A +`DateTime Function() now` is injected for testability (default `DateTime.now`). + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/models/booster.dart'; +import 'package:block_seasons/state/booster_inventory_notifier.dart'; +import 'package:block_seasons/state/daily_reward_notifier.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future container(DateTime today) async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + return ProviderContainer(overrides: [ + saveRepositoryProvider.overrideWithValue(repo), + dailyNowProvider.overrideWithValue(() => today), + ]); + } + + test('first day is claimable as day 1 and claim grants the reward', () async { + final c = await container(DateTime(2026, 6, 18)); + expect(c.read(dailyRewardProvider).claimable, isTrue); + expect(c.read(dailyRewardProvider).day, 1); + + await c.read(dailyRewardProvider.notifier).claim(); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); // day 1 + expect(c.read(dailyRewardProvider).claimable, isFalse); + }); + + test('doubled claim grants twice the reward', () async { + final c = await container(DateTime(2026, 6, 18)); + await c.read(dailyRewardProvider.notifier).claim(doubled: true); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/state/daily_reward_notifier_test.dart` +Expected: FAIL — `daily_reward_notifier.dart` / `dailyNowProvider` missing. + +- [ ] **Step 3: Write minimal implementation** + +```dart +// lib/state/daily_reward_notifier.dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/save_repository.dart'; +import '../game/daily/daily_reward.dart'; +import '../game/models/booster.dart'; +import 'booster_inventory_notifier.dart'; +import 'providers.dart'; + +class DailyRewardNotifier extends Notifier { + static const _cal = DailyRewardCalendar(); + + SaveRepository get _save => ref.read(saveRepositoryProvider); + DateTime Function() get _now => ref.read(dailyNowProvider); + + @override + DailyResolution build() => _resolve(); + + DailyResolution _resolve() => _cal.resolve( + lastClaimedYmd: _save.dailyLastClaimedYmd, + storedDay: _save.dailyCalendarDay, + today: _now(), + ); + + /// Grants the current day's reward (×2 if [doubled]) and records the claim. + Future claim({bool doubled = false}) async { + final r = state; + if (!r.claimable) return; + final reward = _cal.rewardFor(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); + state = _resolve(); + } +} +``` + +Add to `providers.dart`: + +```dart +import 'daily_reward_notifier.dart'; +``` +```dart +/// Injectable clock for the daily calendar (overridden in tests). +final dailyNowProvider = Provider((ref) => DateTime.now); + +final dailyRewardProvider = + NotifierProvider( + DailyRewardNotifier.new, +); +``` +Add `import '../game/daily/daily_reward.dart';` to `providers.dart` for `DailyResolution`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/state/daily_reward_notifier_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/state/daily_reward_notifier.dart lib/state/providers.dart test/state/daily_reward_notifier_test.dart +git commit -m "feat(state): DailyRewardNotifier with injectable clock" +``` + +--- + +## Task 11: Analytics events + +**Files:** +- Modify: `lib/services/analytics_service.dart` +- Test: `test/services/analytics_service_test.dart` (append; this test uses a fake backend already in the file — match its pattern) + +- [ ] **Step 1: Read the existing test** to copy its fake-backend setup, then append: + +```dart + test('booster + daily events carry their fields', () { + final backend = _RecordingBackend(); // reuse the file's existing fake + final a = AnalyticsService(backend); + + a.boosterUsed(type: 'hammer'); + a.boosterGranted(type: 'hammer', count: 2, source: 'daily'); + a.dailyRewardClaimed(day: 7, doubled: true); + + expect(backend.events, [ + ('booster_used', {'type': 'hammer'}), + ('booster_granted', {'type': 'hammer', 'count': 2, 'source': 'daily'}), + ('daily_reward_claimed', {'day': 7, 'doubled': 1}), + ]); + }); +``` + +If the existing test file's fake records differently, adapt the assertion to its +shape — the point is that each method forwards the named event + params. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/analytics_service_test.dart` +Expected: FAIL — methods not defined. + +- [ ] **Step 3: Write minimal implementation** (append to `AnalyticsService`) + +```dart + void boosterUsed({required String type}) => + _backend.logEvent('booster_used', {'type': type}); + + void boosterGranted( + {required String type, required int count, required String source}) => + _backend.logEvent( + 'booster_granted', {'type': type, 'count': count, 'source': source}); + + void dailyRewardClaimed({required int day, required bool doubled}) => + _backend.logEvent( + 'daily_reward_claimed', {'day': day, 'doubled': doubled ? 1 : 0}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/analytics_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Wire the calls** (no new behavior, just emit): +- In `BoosterInventoryNotifier.consume` success path is the wrong layer; instead + emit `boosterUsed` from `GameSessionNotifier._useBooster` on success, and + `boosterGranted`/`dailyRewardClaimed` from `DailyRewardNotifier.claim` and from + the rewarded-ad grant path (Task 14). Read `analyticsProvider` via `ref`. + Add, in `_useBooster` after `await inv.consume(type)`: + `ref.read(analyticsProvider).boosterUsed(type: type.name);` + In `DailyRewardNotifier.claim` after recording: + `ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled);` + and per grant: `ref.read(analyticsProvider).boosterGranted(type: e.key.name, count: e.value*(doubled?2:1), source: 'daily');` + +- [ ] **Step 6: Run full suite + commit** + +Run: `flutter test` +```bash +git add lib/services/analytics_service.dart lib/state/game_session_notifier.dart lib/state/daily_reward_notifier.dart test/services/analytics_service_test.dart +git commit -m "feat(analytics): booster + daily-reward events" +``` + +--- + +## Task 12: l10n strings + +**Files:** +- Modify: `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` + +- [ ] **Step 1: Add keys to BOTH arb files** (keys must match exactly; KO values translated). Add: + +``` +boosterHammer, boosterShuffle, boosterLineBomb (names) +boosterGetWithAd ("Watch an ad to get one" / "광고 보고 1개 받기") +dailyRewardTitle ("Daily Reward" / "출석 보상") +dailyClaim ("Claim" / "받기") +dailyDoubleWithAd ("Watch ad for 2x" / "광고 보고 2배") +boosterTapTarget ("Tap a cell" / "칸을 선택하세요"), boosterTapLine ("Tap a row or column" / "줄을 선택하세요") +``` + +EN example entries: +```json +"boosterHammer": "Hammer", +"boosterShuffle": "Shuffle", +"boosterLineBomb": "Line Bomb", +"boosterGetWithAd": "Watch an ad to get one", +"dailyRewardTitle": "Daily Reward", +"dailyClaim": "Claim", +"dailyDoubleWithAd": "Watch ad for 2×", +"boosterTapTarget": "Tap a cell", +"boosterTapLine": "Tap a row or column", +``` +KO values: `해머`, `셔플`, `줄 폭탄`, `광고 보고 1개 받기`, `출석 보상`, `받기`, `광고 보고 2배`, `칸을 선택하세요`, `줄을 선택하세요`. + +- [ ] **Step 2: Regenerate + analyze** + +Run: `flutter gen-l10n` (or `flutter test` which triggers generation via the build) then `flutter analyze`. +Expected: `AppLocalizations` exposes the new getters; no issues. + +- [ ] **Step 3: Commit** + +```bash +git add lib/l10n/app_en.arb lib/l10n/app_ko.arb +git commit -m "feat(l10n): booster + daily-reward strings (EN/KO)" +``` + +--- + +## Task 13: Booster bar widget + +**Files:** +- Create: `lib/ui/widgets/booster_bar.dart` +- Test: `test/ui/booster_bar_test.dart` + +A stateless row of three booster buttons (icon + count badge). It is presentational: +it takes the counts map + an `onTap(BoosterType)` callback. Targeting state lives in +the game screen (Task 14). + +- [ ] **Step 1: Write the failing widget test** + +```dart +import 'package:block_seasons/game/models/booster.dart'; +import 'package:block_seasons/ui/widgets/booster_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('renders three boosters with their counts', (tester) async { + BoosterType? tapped; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: BoosterBar( + counts: const { + BoosterType.hammer: 3, + BoosterType.shuffle: 0, + BoosterType.lineBomb: 1, + }, + onTap: (t) => tapped = t, + ), + ), + )); + + expect(find.text('3'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('booster_hammer'))); + expect(tapped, BoosterType.hammer); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/ui/booster_bar_test.dart` +Expected: FAIL — `BoosterBar` missing. + +- [ ] **Step 3: Implement the widget** + +```dart +// lib/ui/widgets/booster_bar.dart +import 'package:flutter/material.dart'; + +import '../../game/models/booster.dart'; + +class BoosterBar extends StatelessWidget { + const BoosterBar({super.key, required this.counts, required this.onTap}); + + final Map counts; + final void Function(BoosterType) onTap; + + static const _icons = { + BoosterType.hammer: Icons.gavel, + BoosterType.shuffle: Icons.shuffle, + BoosterType.lineBomb: Icons.clear_all, + }; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final t in BoosterType.values) + _BoosterButton( + key: ValueKey('booster_${t.name}'), + icon: _icons[t]!, + count: counts[t] ?? 0, + onTap: () => onTap(t), + ), + ], + ); + } +} + +class _BoosterButton extends StatelessWidget { + const _BoosterButton( + {super.key, required this.icon, required this.count, required this.onTap}); + final IconData icon; + final int count; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 28), + const SizedBox(height: 2), + Text('$count'), + ]), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** → `flutter test test/ui/booster_bar_test.dart` (PASS) + +- [ ] **Step 5: Commit** + +```bash +git add lib/ui/widgets/booster_bar.dart test/ui/booster_bar_test.dart +git commit -m "feat(ui): presentational booster bar" +``` + +--- + +## Task 14: Mount bar in the game screen + targeting + 0-count ad + +**Files:** +- Modify: `lib/ui/screens/game_screen.dart` +- Test: `test/ui/game_screen_booster_test.dart` + +**Read `game_screen.dart` fully first.** Mount `BoosterBar` below the board, reading +`boosterInventoryProvider`. Hold local targeting state (`BoosterType? _arming`). + +Behavior: +- Tap a booster with count > 0 → set `_arming = type` and show a hint + (`boosterTapTarget` for hammer, `boosterTapLine` for line-bomb). Shuffle applies + immediately via `session.useShuffle()` (no targeting). +- While arming **hammer**: a tap on a board cell calls `session.useHammer(x,y)`; + clear `_arming`. +- While arming **line-bomb**: show row handles (left edge) + column handles (top + edge); tapping a row handle calls `session.useLineBomb(row: r)`, a column handle + `useLineBomb(col: c)`; clear `_arming`. +- Tap a booster with count 0 → show a dialog with `boosterGetWithAd`; on confirm, + call the rewarded-ad grant (Task 15 helper) and grant +1 on reward. + +- [ ] **Step 1: Write the failing widget test** (targeting hammer path) + +```dart +// Pump GameScreen with an overridden saveRepository pre-granted 1 hammer and a +// started stage that has a filled cell at a known position; tap the hammer +// booster, then tap that cell; assert the cell becomes empty and the count +// drops to 0. Mirror the ProviderContainer + _wrap pattern used in +// test/tool/generate_store_screenshots_test.dart for mounting a screen. +``` + +Write the concrete test using that mounting pattern (real font load is NOT needed +for logic assertions; pump + tap + read provider state). Assert: +`expect(container.read(boosterInventoryProvider)[BoosterType.hammer], 0);` + +- [ ] **Step 2: Run → FAIL** (`flutter test test/ui/game_screen_booster_test.dart`) + +- [ ] **Step 3: Implement** the bar mount + targeting in `game_screen.dart` per the +behavior list above. Keep the board's existing drag handling intact; targeting +mode intercepts taps only while `_arming != null`. + +- [ ] **Step 4: Run → PASS**, then `flutter test` (full) + `flutter analyze`. + +- [ ] **Step 5: Commit** + +```bash +git add lib/ui/screens/game_screen.dart test/ui/game_screen_booster_test.dart +git commit -m "feat(ui): booster bar targeting in the game screen" +``` + +--- + +## Task 15: Rewarded-ad booster grants + +**Files:** +- Modify: `lib/ui/screens/game_screen.dart` (0-count path) and a small helper +- Test: covered via a fake `AdService` override in `test/ui/game_screen_booster_test.dart` + +- [ ] **Step 1: Write a failing test** where the 0-count booster tap, with an +overridden `adServiceProvider` whose `showRewarded()` returns `true`, grants +1 of +that booster. (Override `adServiceProvider` with a fake exposing +`Future showRewarded()`.) + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** — on confirming the "watch ad" dialog: +```dart +final earned = await ref.read(adServiceProvider).showRewarded(); +if (earned) { + await ref.read(boosterInventoryProvider.notifier).grant(type, 1); + ref.read(analyticsProvider).boosterGranted( + type: type.name, count: 1, source: 'ad'); +} +``` + +- [ ] **Step 4: Run → PASS**, full suite green. + +- [ ] **Step 5: Commit** + +```bash +git add lib/ui/screens/game_screen.dart test/ui/game_screen_booster_test.dart +git commit -m "feat(ui): rewarded-ad grant for an empty booster" +``` + +--- + +## Task 16: Daily reward popup + home wiring + +**Files:** +- Create: `lib/ui/widgets/daily_reward_sheet.dart` +- Modify: `lib/ui/screens/home_screen.dart` +- Test: `test/ui/daily_reward_sheet_test.dart` + +The sheet shows 7 cells (past = checked/dim, today = highlighted, future = locked), +a Claim button, and a "2× with ad" button. Home shows it once when +`dailyRewardProvider.claimable` is true on first build. + +- [ ] **Step 1: Write the failing widget test** — pump `DailyRewardSheet` with +`day: 1, onClaim: (doubled) {...}`; assert 7 day cells render and tapping Claim +invokes `onClaim(false)`, tapping the 2× button invokes `onClaim(true)`. + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implement** `DailyRewardSheet` (presentational: `int day`, +`void Function(bool doubled) onClaim`, uses the `DailyRewardCalendar.rewardFor` +table to render each day's icons). Then in `home_screen.dart`, in a post-frame +callback, if `ref.read(dailyRewardProvider).claimable`, show the sheet as a dialog; +its callbacks call `ref.read(dailyRewardProvider.notifier).claim(doubled: ...)`, +and for the 2× path gate the claim on `adServiceProvider.showRewarded()` first. + +- [ ] **Step 4: Run → PASS**, full suite + analyze clean. + +- [ ] **Step 5: Commit** + +```bash +git add lib/ui/widgets/daily_reward_sheet.dart lib/ui/screens/home_screen.dart test/ui/daily_reward_sheet_test.dart +git commit -m "feat(ui): 7-day daily-reward popup on home" +``` + +--- + +## Task 17: Integration pass + +**Files:** none new — verification + any glue fixes. + +- [ ] **Step 1:** Run the whole suite: `flutter test`. Expected: all green +(existing 194 + new). Fix any regressions following TDD (failing test first). +- [ ] **Step 2:** `flutter analyze` → No issues. +- [ ] **Step 3:** Manual smoke checklist for the owner's eventual build (document only; +**do not build here**): start a stage → use each booster → daily popup grants → +0-count booster offers ad. Note these in the commit body. +- [ ] **Step 4: Commit** any glue fixes: + +```bash +git add -A +git commit -m "test: integrate boosters + daily reward, full suite green" +``` + +--- + +## Self-Review (completed by author) + +- **Spec coverage:** boosters 3× (T1–T3), model (T4), inventory persistence (T5), + daily persistence (T6), daily logic (T7), inventory notifier (T8), use + coordination (T9), daily notifier (T10), analytics (T11), l10n (T12), bar UI + (T13), targeting + 0-count (T14), ad grant (T15), daily popup (T16), + integration (T17). All spec sections mapped. +- **No-credit rule:** enforced in engine tasks (no score/move/objective asserts). +- **Type consistency:** `BoosterType`, `BoosterUseResult`, `DailyResolution`, + `DailyRewardCalendar.{resolve,rewardFor,ymd}`, providers + `boosterInventoryProvider`/`dailyRewardProvider`/`dailyNowProvider` used + consistently across tasks. +- **Owner build rule:** no `flutter build`/Xcode steps anywhere; verification is + `flutter test` + `flutter analyze` only.