Compare commits
10 Commits
4cda34f0b7
...
fa4247cd9b
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4247cd9b | |||
| ba4d4a662b | |||
| 638a177fbb | |||
| c185bd0886 | |||
| 544a2b8be4 | |||
| 221ea8346e | |||
| 6592b44387 | |||
| e7cd079a5d | |||
| bbf8cf3f08 | |||
| 5aee503c09 |
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../game/models/booster.dart';
|
||||
import 'streak.dart';
|
||||
|
||||
class StageProgress {
|
||||
@@ -51,6 +52,13 @@ class SaveRepository {
|
||||
_reviewRequested = (json['flags']
|
||||
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
|
||||
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 _musicEnabled = true;
|
||||
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;
|
||||
bool get tutorialDone => _tutorialDone;
|
||||
@@ -109,6 +122,31 @@ class SaveRepository {
|
||||
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) {
|
||||
if (score > _endlessBest) _endlessBest = score;
|
||||
return _flush();
|
||||
@@ -185,6 +223,8 @@ class SaveRepository {
|
||||
'reviewRequested': _reviewRequested,
|
||||
},
|
||||
'endless': {'best': _endlessBest},
|
||||
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
|
||||
'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,4 +216,38 @@ class GameEngine {
|
||||
}
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../game/engine/game_engine.dart';
|
||||
import '../game/engine/piece_generator.dart';
|
||||
import '../game/models/booster.dart';
|
||||
import '../game/models/grid.dart';
|
||||
import '../game/models/objective.dart';
|
||||
import '../game/models/piece.dart';
|
||||
import '../game/models/stage.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
enum BoosterUseResult { success, noBooster, invalidTarget }
|
||||
|
||||
/// Immutable snapshot of one engine moment; the only game state the UI sees.
|
||||
class GameViewState {
|
||||
const GameViewState({
|
||||
@@ -117,6 +120,41 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
||||
_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}) {
|
||||
final engine = _engine!;
|
||||
state = GameViewState(
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../data/content_repository.dart';
|
||||
import '../data/save_repository.dart';
|
||||
import '../data/streak.dart';
|
||||
import '../game/daily/daily_reward.dart';
|
||||
import '../game/models/booster.dart';
|
||||
import '../game/models/season.dart';
|
||||
import '../services/ad_service.dart';
|
||||
import '../services/analytics_service.dart';
|
||||
@@ -13,6 +15,8 @@ import '../services/music_service.dart';
|
||||
import '../services/review_service.dart';
|
||||
import '../services/store_reviewer.dart';
|
||||
import 'ads_notifier.dart';
|
||||
import 'booster_inventory_notifier.dart';
|
||||
import 'daily_reward_notifier.dart';
|
||||
import 'endless_best_notifier.dart';
|
||||
import 'music_notifier.dart';
|
||||
import 'sound_notifier.dart';
|
||||
@@ -143,3 +147,16 @@ final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
||||
final flow = ref.watch(seasonFlowProvider);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user