diff --git a/lib/data/content_repository.dart b/lib/data/content_repository.dart index c5ec6f3..c86d206 100644 --- a/lib/data/content_repository.dart +++ b/lib/data/content_repository.dart @@ -1,21 +1,74 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import '../game/models/season.dart'; +import 'remote/content_downloader.dart'; -/// Resolves season content. Phase 3: bundled assets only; remote download -/// and caching land in Phase 4. +/// Resolves season content: bundled season 1 is always available offline; +/// remotely synced packs in [cacheDir] extend or override it. Load errors on +/// any single pack never break the list — worst case the player sees the +/// bundled content. class ContentRepository { + ContentRepository({this.cacheDir, ContentDownloader? downloader}) + : _downloader = downloader; + static const bundledSeasonIds = ['season_001']; + final Directory? cacheDir; + final ContentDownloader? _downloader; + bool _syncedThisSession = false; + Future loadBundledSeason(String seasonId) async { final raw = await rootBundle.loadString('assets/seasons/$seasonId/pack.json'); return SeasonPack.fromJson(jsonDecode(raw) as Map); } - Future> availableSeasons() async => [ - for (final id in bundledSeasonIds) await loadBundledSeason(id), - ]; + SeasonPack? _loadCachedSeason(Directory dir) { + try { + final file = File('${dir.path}/pack.json'); + if (!file.existsSync()) return null; + return SeasonPack.fromJson( + jsonDecode(file.readAsStringSync()) as Map); + } catch (_) { + return null; // Corrupt or future-schema pack: ignore. + } + } + + /// All playable seasons, sorted by seasonId. Cached packs win over the + /// bundled copy of the same season (they may carry balance fixes). + Future> availableSeasons() async { + final byId = {}; + for (final id in bundledSeasonIds) { + try { + byId[id] = await loadBundledSeason(id); + } catch (_) { + // Bundled pack should never fail; if it does, skip rather than crash. + } + } + + final seasonsDir = + cacheDir == null ? null : Directory('${cacheDir!.path}/seasons'); + if (seasonsDir != null && seasonsDir.existsSync()) { + for (final entry in seasonsDir.listSync().whereType()) { + final pack = _loadCachedSeason(entry); + if (pack != null) byId[pack.seasonId] = pack; + } + } + + final list = byId.values.toList() + ..sort((a, b) => a.seasonId.compareTo(b.seasonId)); + return list; + } + + /// Fire-once-per-session remote sync. Returns true when new content + /// arrived (callers then re-read [availableSeasons]). + Future refresh() async { + final downloader = _downloader; + if (downloader == null || _syncedThisSession) return false; + _syncedThisSession = true; + return downloader.sync(); + } } diff --git a/lib/main.dart b/lib/main.dart index dfa6cdf..d4e69f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,48 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'app.dart'; +import 'data/content_repository.dart'; +import 'data/remote/content_downloader.dart'; import 'data/save_repository.dart'; import 'state/providers.dart'; +/// Remote content origin. Swap per environment with +/// --dart-define=CONTENT_BASE_URL=...; the default points at the production +/// Firebase Hosting site (owner setup pending). +const contentBaseUrl = String.fromEnvironment( + 'CONTENT_BASE_URL', + defaultValue: 'https://block-seasons.web.app/content', +); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); final saveRepository = await SaveRepository.open(); - runApp( - ProviderScope( - overrides: [saveRepositoryProvider.overrideWithValue(saveRepository)], - child: const BlockSeasonsApp(), - ), - ); + + ContentRepository contentRepository; + try { + final support = await getApplicationSupportDirectory(); + final cacheDir = Directory('${support.path}/content'); + contentRepository = ContentRepository( + cacheDir: cacheDir, + downloader: ContentDownloader( + baseUrl: contentBaseUrl, + cacheDir: cacheDir, + ), + ); + } catch (e) { + debugPrint('content cache unavailable, bundled only: $e'); + contentRepository = ContentRepository(); + } + + runApp(ProviderScope( + overrides: [ + saveRepositoryProvider.overrideWithValue(saveRepository), + contentRepositoryProvider.overrideWithValue(contentRepository), + ], + child: const BlockSeasonsApp(), + )); } diff --git a/test/data/content_repository_test.dart b/test/data/content_repository_test.dart index f49baa7..02df7a4 100644 --- a/test/data/content_repository_test.dart +++ b/test/data/content_repository_test.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:block_seasons/data/content_repository.dart'; +import 'package:block_seasons/game/models/season.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -25,4 +29,75 @@ void main() { final seasons = await repo.availableSeasons(); expect(seasons.map((s) => s.seasonId), contains('season_001')); }); + + group('cache merge and fallback', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('bs_repo_'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + Map packJson(String id) => { + 'schemaVersion': 1, + 'seasonId': id, + 'version': 1, + 'title': {'en': id}, + 'theme': const SeasonTheme().toJson(), + 'stages': [ + { + 'id': '${id}_001', + 'seed': 1, + 'moveLimit': 10, + 'preset': [], + 'objectives': [ + {'type': 'reachScore', 'target': 100} + ], + 'stars': { + 'two': {'movesLeft': 2}, + 'three': {'movesLeft': 4} + }, + 'generatorProfile': 'mid', + } + ], + }; + + Future putCachedSeason(String id) async { + final dir = Directory('${tmp.path}/seasons/$id'); + await dir.create(recursive: true); + await File('${dir.path}/pack.json') + .writeAsString(jsonEncode(packJson(id))); + } + + test('merges cached seasons after bundled ones, sorted by id', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + await putCachedSeason('season_002'); + final repo = ContentRepository(cacheDir: tmp); + final seasons = await repo.availableSeasons(); + expect(seasons.map((s) => s.seasonId).toList(), + ['season_001', 'season_002']); + }); + + test('cached copy of a bundled season wins over the bundle', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + await putCachedSeason('season_001'); + final repo = ContentRepository(cacheDir: tmp); + final seasons = await repo.availableSeasons(); + // Cached fake has exactly 1 stage; the bundled real one has 60. + expect(seasons.single.stages, hasLength(1)); + }); + + test('corrupt cached pack falls back to bundle silently', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = Directory('${tmp.path}/seasons/season_001'); + await dir.create(recursive: true); + await File('${dir.path}/pack.json').writeAsString('{not json'); + final repo = ContentRepository(cacheDir: tmp); + final seasons = await repo.availableSeasons(); + expect(seasons.single.stages, hasLength(60)); + }); + }); }