From ba4d4a662be7e863dbec9a579363a9f31c5f8dc1 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 12:17:30 +0900 Subject: [PATCH] feat(state): coordinate booster use with inventory in the session --- lib/state/game_session_notifier.dart | 38 +++++++++++++ test/state/booster_inventory_test.dart | 1 - test/state/game_session_booster_test.dart | 65 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/state/game_session_booster_test.dart diff --git a/lib/state/game_session_notifier.dart b/lib/state/game_session_notifier.dart index 6634dd0..cfdbdbe 100644 --- a/lib/state/game_session_notifier.dart +++ b/lib/state/game_session_notifier.dart @@ -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 { _publish(lastPlacement: null); } + Future useHammer(int x, int y) => + _useBooster(BoosterType.hammer, () { + final engine = _engine; + if (engine == null) return false; + return engine.useHammer(x, y); + }); + + Future useShuffle() => + _useBooster(BoosterType.shuffle, () { + final engine = _engine; + if (engine == null) return false; + return engine.useShuffle(); + }); + + Future useLineBomb({int? row, int? col}) => + _useBooster(BoosterType.lineBomb, () { + final engine = _engine; + if (engine == null) return false; + return engine.useLineBomb(row: row, col: col); + }); + + Future _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( diff --git a/test/state/booster_inventory_test.dart b/test/state/booster_inventory_test.dart index 15953f2..63b5139 100644 --- a/test/state/booster_inventory_test.dart +++ b/test/state/booster_inventory_test.dart @@ -1,6 +1,5 @@ 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'; diff --git a/test/state/game_session_booster_test.dart b/test/state/game_session_booster_test.dart new file mode 100644 index 0000000..362358c --- /dev/null +++ b/test/state/game_session_booster_test.dart @@ -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 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); + }); +}