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

46 KiB
Raw Permalink Blame History

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.dartBoosterType enum (pure).
  • Modify lib/game/engine/game_engine.dartuseHammer/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

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:

  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):
      final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
      for (final t in BoosterType.values) {
        _boosters[t] = boosters[t.name] as int? ?? 0;
      }
  1. Add field near the other fields:
  final Map<BoosterType, int> _boosters = {
    for (final t in BoosterType.values) t: 0,
  };
  1. 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;
  }
  1. In _flush()'s jsonEncode({...}), 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
  1. 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;
  1. Fields:
  String? _dailyLastClaimedYmd;
  int _dailyCalendarDay = 0;
  1. API:
  String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
  int get dailyCalendarDay => _dailyCalendarDay;

  Future<void> recordDailyClaim(String ymd, int day) {
    _dailyLastClaimedYmd = ymd;
    _dailyCalendarDay = day;
    return _flush();
  }
  1. 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 (17) 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:

  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:
enum BoosterUseResult { success, noBooster, invalidTarget }
  1. 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.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

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 passesflutter 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 = 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)

// 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

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:

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 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

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× (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.