Files
BlockSeasons/docs/superpowers/plans/2026-06-18-boosters-daily-reward.md
airkjw 4cda34f0b7 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) <noreply@anthropic.com>
2026-06-18 11:57:51 +09:00

1448 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<SaveRepository> 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<String, dynamic>? ?? {};
for (final t in BoosterType.values) {
_boosters[t] = boosters[t.name] as int? ?? 0;
}
```
3. Add field near the other fields:
```dart
final Map<BoosterType, int> _boosters = {
for (final t in BoosterType.values) t: 0,
};
```
4. Add API:
```dart
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;
}
```
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<SaveRepository> 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<String, dynamic>?;
_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<void> 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 (17) 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<BoosterType, int> 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<ProviderContainer> 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<Map<BoosterType, int>> {
SaveRepository get _save => ref.read(saveRepositoryProvider);
@override
Map<BoosterType, int> build() => _snapshot();
Map<BoosterType, int> _snapshot() =>
{for (final t in BoosterType.values) t: _save.boosterCount(t)};
Future<void> grant(BoosterType type, [int n = 1]) async {
await _save.grantBooster(type, n);
state = _snapshot();
}
Future<bool> 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, Map<BoosterType, int>>(
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<ProviderContainer> 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<BoosterUseResult> useHammer(int x, int y) =>
_useBooster(BoosterType.hammer, () => _engine.useHammer(x, y));
Future<BoosterUseResult> useShuffle() =>
_useBooster(BoosterType.shuffle, () => _engine.useShuffle());
Future<BoosterUseResult> useLineBomb({int? row, int? col}) => _useBooster(
BoosterType.lineBomb,
() => _engine.useLineBomb(row: row, col: col),
);
Future<BoosterUseResult> _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<ProviderContainer> 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<DailyResolution> {
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<void> 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<DateTime Function()>((ref) => DateTime.now);
final dailyRewardProvider =
NotifierProvider<DailyRewardNotifier, DailyResolution>(
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<BoosterType, int> 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<bool> 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× (T1T3), 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.