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>
46 KiB
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—BoosterTypeenum (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— pureDailyRewardCalendar+ 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
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):
/// 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
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
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
/// 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
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
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
/// 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
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)
// 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
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
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:
- Add import at top:
import '../game/models/booster.dart'; - Add field + parse in constructor (inside the
if (raw != null)block, after the flags parsing):
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
for (final t in BoosterType.values) {
_boosters[t] = boosters[t.name] as int? ?? 0;
}
- Add field near the other fields:
final Map<BoosterType, int> _boosters = {
for (final t in BoosterType.values) t: 0,
};
- Add API:
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;
}
- In
_flush()'sjsonEncode({...}), add a key:
'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
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
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
- Add fields + parse (in constructor, after boosters parse):
final daily = json['daily'] as Map<String, dynamic>?;
_dailyLastClaimedYmd = daily?['lastYmd'] as String?;
_dailyCalendarDay = daily?['day'] as int? ?? 0;
- Fields:
String? _dailyLastClaimedYmd;
int _dailyCalendarDay = 0;
- API:
String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
int get dailyCalendarDay => _dailyCalendarDay;
Future<void> recordDailyClaim(String ymd, int day) {
_dailyLastClaimedYmd = ymd;
_dailyCalendarDay = day;
return _flush();
}
- In
_flush()'s map, add:
'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
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
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
// 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
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
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
// 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):
import 'booster_inventory_notifier.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
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
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:
- Imports:
import '../game/models/booster.dart';andimport 'booster_inventory_notifier.dart';(andproviders.dartaccess toboosterInventoryProvider— it is in the same library group; import what is needed). - Add the result enum near the top of the file:
enum BoosterUseResult { success, noBooster, invalidTarget }
- 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):
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
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
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
// 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:
import 'daily_reward_notifier.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
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:
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)
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.consumesuccess path is the wrong layer; instead emitboosterUsedfromGameSessionNotifier._useBoosteron success, andboosterGranted/dailyRewardClaimedfromDailyRewardNotifier.claimand from the rewarded-ad grant path (Task 14). ReadanalyticsProviderviaref. Add, in_useBoosterafterawait inv.consume(type):ref.read(analyticsProvider).boosterUsed(type: type.name);InDailyRewardNotifier.claimafter 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
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:
"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
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
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
// 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
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 = typeand show a hint (boosterTapTargetfor hammer,boosterTapLinefor line-bomb). Shuffle applies immediately viasession.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 handleuseLineBomb(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)
// 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.dartper 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
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
AdServiceoverride intest/ui/game_screen_booster_test.dart -
Step 1: Write a failing test where the 0-count booster tap, with an overridden
adServiceProviderwhoseshowRewarded()returnstrue, grants +1 of that booster. (OverrideadServiceProviderwith a fake exposingFuture<bool> showRewarded().) -
Step 2: Run → FAIL
-
Step 3: Implement — on confirming the "watch ad" dialog:
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
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
DailyRewardSheetwithday: 1, onClaim: (doubled) {...}; assert 7 day cells render and tapping Claim invokesonClaim(false), tapping the 2× button invokesonClaim(true). -
Step 2: Run → FAIL
-
Step 3: Implement
DailyRewardSheet(presentational:int day,void Function(bool doubled) onClaim, uses theDailyRewardCalendar.rewardFortable to render each day's icons). Then inhome_screen.dart, in a post-frame callback, ifref.read(dailyRewardProvider).claimable, show the sheet as a dialog; its callbacks callref.read(dailyRewardProvider.notifier).claim(doubled: ...), and for the 2× path gate the claim onadServiceProvider.showRewarded()first. -
Step 4: Run → PASS, full suite + analyze clean.
-
Step 5: Commit
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:
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}, providersboosterInventoryProvider/dailyRewardProvider/dailyNowProviderused consistently across tasks. - Owner build rule: no
flutter build/Xcode steps anywhere; verification isflutter test+flutter analyzeonly.