feat: content repository merges cached seasons with bundled fallback
Extends ContentRepository with optional cacheDir: cached packs in <cacheDir>/seasons/*/pack.json merge with bundled ones (cached wins for same id), corrupt/future-schema packs silently ignored, refresh() fires ContentDownloader.sync() once per session. main() wires the real cache+downloader instance; default constructor stays bundled-only for tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,74 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../game/models/season.dart';
|
import '../game/models/season.dart';
|
||||||
|
import 'remote/content_downloader.dart';
|
||||||
|
|
||||||
/// Resolves season content. Phase 3: bundled assets only; remote download
|
/// Resolves season content: bundled season 1 is always available offline;
|
||||||
/// and caching land in Phase 4.
|
/// 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 {
|
class ContentRepository {
|
||||||
|
ContentRepository({this.cacheDir, ContentDownloader? downloader})
|
||||||
|
: _downloader = downloader;
|
||||||
|
|
||||||
static const bundledSeasonIds = ['season_001'];
|
static const bundledSeasonIds = ['season_001'];
|
||||||
|
|
||||||
|
final Directory? cacheDir;
|
||||||
|
final ContentDownloader? _downloader;
|
||||||
|
bool _syncedThisSession = false;
|
||||||
|
|
||||||
Future<SeasonPack> loadBundledSeason(String seasonId) async {
|
Future<SeasonPack> loadBundledSeason(String seasonId) async {
|
||||||
final raw =
|
final raw =
|
||||||
await rootBundle.loadString('assets/seasons/$seasonId/pack.json');
|
await rootBundle.loadString('assets/seasons/$seasonId/pack.json');
|
||||||
return SeasonPack.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
return SeasonPack.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SeasonPack>> availableSeasons() async => [
|
SeasonPack? _loadCachedSeason(Directory dir) {
|
||||||
for (final id in bundledSeasonIds) await loadBundledSeason(id),
|
try {
|
||||||
];
|
final file = File('${dir.path}/pack.json');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
return SeasonPack.fromJson(
|
||||||
|
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>);
|
||||||
|
} 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<List<SeasonPack>> availableSeasons() async {
|
||||||
|
final byId = <String, SeasonPack>{};
|
||||||
|
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<Directory>()) {
|
||||||
|
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<bool> refresh() async {
|
||||||
|
final downloader = _downloader;
|
||||||
|
if (downloader == null || _syncedThisSession) return false;
|
||||||
|
_syncedThisSession = true;
|
||||||
|
return downloader.sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -1,17 +1,48 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'data/content_repository.dart';
|
||||||
|
import 'data/remote/content_downloader.dart';
|
||||||
import 'data/save_repository.dart';
|
import 'data/save_repository.dart';
|
||||||
import 'state/providers.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<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final saveRepository = await SaveRepository.open();
|
final saveRepository = await SaveRepository.open();
|
||||||
runApp(
|
|
||||||
ProviderScope(
|
ContentRepository contentRepository;
|
||||||
overrides: [saveRepositoryProvider.overrideWithValue(saveRepository)],
|
try {
|
||||||
child: const BlockSeasonsApp(),
|
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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:block_seasons/data/content_repository.dart';
|
import 'package:block_seasons/data/content_repository.dart';
|
||||||
|
import 'package:block_seasons/game/models/season.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -25,4 +29,75 @@ void main() {
|
|||||||
final seasons = await repo.availableSeasons();
|
final seasons = await repo.availableSeasons();
|
||||||
expect(seasons.map((s) => s.seasonId), contains('season_001'));
|
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<String, dynamic> 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<void> 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user