Compare commits

..

10 Commits

15 changed files with 616 additions and 0 deletions
+40
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../game/models/booster.dart';
import 'streak.dart'; import 'streak.dart';
class StageProgress { class StageProgress {
@@ -51,6 +52,13 @@ class SaveRepository {
_reviewRequested = (json['flags'] _reviewRequested = (json['flags']
as Map<String, dynamic>?)?['reviewRequested'] as bool? ?? as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
false; false;
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
for (final t in BoosterType.values) {
_boosters[t] = boosters[t.name] as int? ?? 0;
}
final daily = json['daily'] as Map<String, dynamic>?;
_dailyLastClaimedYmd = daily?['lastYmd'] as String?;
_dailyCalendarDay = daily?['day'] as int? ?? 0;
} }
} }
@@ -68,6 +76,11 @@ class SaveRepository {
bool _soundEnabled = true; bool _soundEnabled = true;
bool _musicEnabled = true; bool _musicEnabled = true;
bool _reviewRequested = false; bool _reviewRequested = false;
final Map<BoosterType, int> _boosters = {
for (final t in BoosterType.values) t: 0,
};
String? _dailyLastClaimedYmd;
int _dailyCalendarDay = 0;
StreakState get streak => _streak; StreakState get streak => _streak;
bool get tutorialDone => _tutorialDone; bool get tutorialDone => _tutorialDone;
@@ -109,6 +122,31 @@ class SaveRepository {
return _flush(); return _flush();
} }
int boosterCount(BoosterType type) => _boosters[type] ?? 0;
Future<void> grantBooster(BoosterType type, [int n = 1]) {
_boosters[type] = (_boosters[type] ?? 0) + n;
return _flush();
}
/// Spends one booster. Returns false (and changes nothing) when none are left.
Future<bool> consumeBooster(BoosterType type) async {
final have = _boosters[type] ?? 0;
if (have <= 0) return false;
_boosters[type] = have - 1;
await _flush();
return true;
}
String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
int get dailyCalendarDay => _dailyCalendarDay;
Future<void> recordDailyClaim(String ymd, int day) {
_dailyLastClaimedYmd = ymd;
_dailyCalendarDay = day;
return _flush();
}
Future<void> recordEndlessScore(int score) { Future<void> recordEndlessScore(int score) {
if (score > _endlessBest) _endlessBest = score; if (score > _endlessBest) _endlessBest = score;
return _flush(); return _flush();
@@ -185,6 +223,8 @@ class SaveRepository {
'reviewRequested': _reviewRequested, 'reviewRequested': _reviewRequested,
}, },
'endless': {'best': _endlessBest}, 'endless': {'best': _endlessBest},
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay},
}), }),
); );
} }
+67
View File
@@ -0,0 +1,67 @@
// 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,
};
}
}
}
+34
View File
@@ -216,4 +216,38 @@ class GameEngine {
} }
_phase = GamePhase.lost; _phase = GamePhase.lost;
} }
/// 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;
}
/// 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;
}
/// 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;
}
} }
+5
View File
@@ -0,0 +1,5 @@
// 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 }
+28
View File
@@ -0,0 +1,28 @@
// lib/state/booster_inventory_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/save_repository.dart';
import '../game/models/booster.dart';
import 'providers.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;
}
}
+35
View File
@@ -0,0 +1,35 @@
// 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 '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();
}
}
+38
View File
@@ -2,12 +2,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../game/engine/game_engine.dart'; import '../game/engine/game_engine.dart';
import '../game/engine/piece_generator.dart'; import '../game/engine/piece_generator.dart';
import '../game/models/booster.dart';
import '../game/models/grid.dart'; import '../game/models/grid.dart';
import '../game/models/objective.dart'; import '../game/models/objective.dart';
import '../game/models/piece.dart'; import '../game/models/piece.dart';
import '../game/models/stage.dart'; import '../game/models/stage.dart';
import 'providers.dart'; import 'providers.dart';
enum BoosterUseResult { success, noBooster, invalidTarget }
/// Immutable snapshot of one engine moment; the only game state the UI sees. /// Immutable snapshot of one engine moment; the only game state the UI sees.
class GameViewState { class GameViewState {
const GameViewState({ const GameViewState({
@@ -117,6 +120,41 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
_publish(lastPlacement: null); _publish(lastPlacement: null);
} }
Future<BoosterUseResult> useHammer(int x, int y) =>
_useBooster(BoosterType.hammer, () {
final engine = _engine;
if (engine == null) return false;
return engine.useHammer(x, y);
});
Future<BoosterUseResult> useShuffle() =>
_useBooster(BoosterType.shuffle, () {
final engine = _engine;
if (engine == null) return false;
return engine.useShuffle();
});
Future<BoosterUseResult> useLineBomb({int? row, int? col}) =>
_useBooster(BoosterType.lineBomb, () {
final engine = _engine;
if (engine == null) return false;
return engine.useLineBomb(row: row, col: col);
});
Future<BoosterUseResult> _useBooster(
BoosterType type, bool Function() apply) async {
final engine = _engine;
if (engine == null) return BoosterUseResult.noBooster;
final inv = ref.read(boosterInventoryProvider.notifier);
if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) {
return BoosterUseResult.noBooster;
}
if (!apply()) return BoosterUseResult.invalidTarget;
await inv.consume(type);
_publish(lastPlacement: null);
return BoosterUseResult.success;
}
void _publish({required PlacementResult? lastPlacement}) { void _publish({required PlacementResult? lastPlacement}) {
final engine = _engine!; final engine = _engine!;
state = GameViewState( state = GameViewState(
+17
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/content_repository.dart'; import '../data/content_repository.dart';
import '../data/save_repository.dart'; import '../data/save_repository.dart';
import '../data/streak.dart'; import '../data/streak.dart';
import '../game/daily/daily_reward.dart';
import '../game/models/booster.dart';
import '../game/models/season.dart'; import '../game/models/season.dart';
import '../services/ad_service.dart'; import '../services/ad_service.dart';
import '../services/analytics_service.dart'; import '../services/analytics_service.dart';
@@ -13,6 +15,8 @@ import '../services/music_service.dart';
import '../services/review_service.dart'; import '../services/review_service.dart';
import '../services/store_reviewer.dart'; import '../services/store_reviewer.dart';
import 'ads_notifier.dart'; import 'ads_notifier.dart';
import 'booster_inventory_notifier.dart';
import 'daily_reward_notifier.dart';
import 'endless_best_notifier.dart'; import 'endless_best_notifier.dart';
import 'music_notifier.dart'; import 'music_notifier.dart';
import 'sound_notifier.dart'; import 'sound_notifier.dart';
@@ -143,3 +147,16 @@ final activeThemeProvider = Provider<SeasonTheme>((ref) {
final flow = ref.watch(seasonFlowProvider); final flow = ref.watch(seasonFlowProvider);
return flow?.pack.theme ?? SeasonTheme.fallback; return flow?.pack.theme ?? SeasonTheme.fallback;
}); });
final boosterInventoryProvider =
NotifierProvider<BoosterInventoryNotifier, Map<BoosterType, int>>(
BoosterInventoryNotifier.new,
);
/// Injectable clock for the daily calendar (overridden in tests).
final dailyNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
final dailyRewardProvider =
NotifierProvider<DailyRewardNotifier, DailyResolution>(
DailyRewardNotifier.new,
);
@@ -0,0 +1,37 @@
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);
});
}
+29
View File
@@ -0,0 +1,29 @@
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);
});
}
+51
View File
@@ -0,0 +1,51 @@
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');
});
}
+104
View File
@@ -0,0 +1,104 @@
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},
],
'stars': {
'two': {'movesLeft': 5},
'three': {'movesLeft': 10},
},
});
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);
});
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);
});
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);
});
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/game/models/booster.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);
});
}
@@ -0,0 +1,35 @@
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/game/models/booster.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);
});
}
+65
View File
@@ -0,0 +1,65 @@
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/game_session_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},
],
'stars': {
'two': {'movesLeft': 5},
'three': {'movesLeft': 10},
},
});
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);
});
}