diff --git a/docs/superpowers/plans/2026-06-12-remote-seasons.md b/docs/superpowers/plans/2026-06-12-remote-seasons.md new file mode 100644 index 0000000..8d27c1d --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-remote-seasons.md @@ -0,0 +1,1255 @@ +# Phase 4 — 원격 시즌 배포 + 분석 추상화 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 앱 스토어 업데이트 없이 새 시즌이 등장한다 — 정적 호스팅의 manifest.json을 확인해 새 팩을 SHA256 검증 후 원자적으로 캐시하고, 오프라인이면 캐시→번들 시즌1로 폴백한다. 분석 이벤트는 백엔드 추상화(디버그 로거)로 먼저 깔고 Firebase는 오너 작업 후 연결한다. + +**Architecture:** `ContentDownloader`(HTTP fetch + SHA256 + temp-write-then-rename)가 캐시 디렉터리를 채우고, `ContentRepository`가 캐시+번들을 병합해 시즌 목록을 제공한다. 다운로더/캐시 디렉터리/HTTP 클라이언트는 전부 생성자 주입이라 테스트에서 실제 네트워크·path_provider가 필요 없다. 활성 시즌 = 목록의 마지막(seasonId 오름차순 최신). 분석은 `AnalyticsBackend` 인터페이스 뒤에 숨긴다. + +**Tech Stack:** Flutter 3.35 / Riverpod 2 plain Notifier / 신규 패키지: `http`, `crypto`, `path_provider` (전부 Dart/Flutter 1st-party). Firebase는 이번 플랜에 없음 (오너의 `flutterfire configure` 후 후속). + +**Spec:** `~/.claude/plans/jolly-waddling-meadow.md`의 Phase 4 섹션 + "시즌 콘텐츠 파이프라인" 섹션. + +**확인된 사실 (구현자 참고):** +- `tool/stage_generator/generate.dart`는 spec의 `theme`를 `SeasonTheme.fromJson`으로 파싱(확장 비주얼 필드 호환)하고 `copyToAssets`는 옵셔널(`as String?`) — 시즌2는 이 키를 빼서 번들에 복사하지 않는다. +- `SeasonPack.fromJson`은 `schemaVersion > 1`이면 FormatException — 다운로더가 아닌 Repository 로드 단계에서 자연 거부됨. +- 모든 명령은 `/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons` 안에서, 경로 공백 주의. +- 테스트 개수 보고는 최종 `+N: All tests passed!` 라인 기준. + +--- + +### Task 1: 의존성 + RemoteManifest 모델 + +**Files:** +- Modify: `pubspec.yaml` +- Create: `lib/data/remote/manifest.dart` +- Test: `test/data/remote/manifest_test.dart` + +- [ ] **Step 1: 패키지 추가** + +```bash +flutter pub add http crypto path_provider +``` +Expected: pubspec.yaml dependencies에 3개 추가, `flutter pub get` 성공. + +- [ ] **Step 2: 실패하는 테스트** — `test/data/remote/manifest_test.dart`: + +```dart +import 'package:block_seasons/data/remote/manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final json = { + 'schemaVersion': 1, + 'minAppBuild': 1, + 'current': 'season_002', + 'seasons': [ + { + 'seasonId': 'season_001', + 'version': 1, + 'packUrl': 'seasons/season_001/pack.json', + 'sha256': 'abc123', + }, + { + 'seasonId': 'season_002', + 'version': 3, + 'packUrl': 'seasons/season_002/pack.json', + 'sha256': 'def456', + }, + ], + }; + + test('round-trips through json', () { + final manifest = RemoteManifest.fromJson(json); + expect(manifest.schemaVersion, 1); + expect(manifest.minAppBuild, 1); + expect(manifest.current, 'season_002'); + expect(manifest.seasons, hasLength(2)); + expect(manifest.seasons[1].seasonId, 'season_002'); + expect(manifest.seasons[1].version, 3); + expect(manifest.seasons[1].packUrl, 'seasons/season_002/pack.json'); + expect(manifest.seasons[1].sha256, 'def456'); + expect(RemoteManifest.fromJson(manifest.toJson()).toJson(), + manifest.toJson()); + }); + + test('rejects unsupported schema', () { + expect( + () => RemoteManifest.fromJson({...json, 'schemaVersion': 99}), + throwsFormatException, + ); + }); + + test('missing current falls back to last season id', () { + final m = RemoteManifest.fromJson({...json}..remove('current')); + expect(m.current, 'season_002'); + }); +} +``` + +- [ ] **Step 3: 실패 확인** + +Run: `flutter test test/data/remote/manifest_test.dart` +Expected: FAIL (파일 없음). + +- [ ] **Step 4: 구현** — `lib/data/remote/manifest.dart`: + +```dart +/// Index of remotely available seasons, served as a static JSON next to the +/// pack files. The client compares (seasonId, version) against its cache. +class RemoteManifest { + const RemoteManifest({ + required this.schemaVersion, + required this.minAppBuild, + required this.current, + required this.seasons, + }); + + static const int supportedSchema = 1; + + factory RemoteManifest.fromJson(Map json) { + final schema = json['schemaVersion'] as int; + if (schema > supportedSchema) { + throw FormatException('Unsupported manifest schema: $schema'); + } + final seasons = [ + for (final s in json['seasons'] as List) + ManifestSeason.fromJson(s as Map), + ]; + return RemoteManifest( + schemaVersion: schema, + minAppBuild: (json['minAppBuild'] as int?) ?? 1, + current: (json['current'] as String?) ?? + (seasons.isNotEmpty ? seasons.last.seasonId : ''), + seasons: seasons, + ); + } + + final int schemaVersion; + final int minAppBuild; + final String current; + final List seasons; + + Map toJson() => { + 'schemaVersion': schemaVersion, + 'minAppBuild': minAppBuild, + 'current': current, + 'seasons': [for (final s in seasons) s.toJson()], + }; +} + +class ManifestSeason { + const ManifestSeason({ + required this.seasonId, + required this.version, + required this.packUrl, + required this.sha256, + }); + + factory ManifestSeason.fromJson(Map json) => + ManifestSeason( + seasonId: json['seasonId'] as String, + version: json['version'] as int, + packUrl: json['packUrl'] as String, + sha256: json['sha256'] as String, + ); + + final String seasonId; + final int version; + final String packUrl; + final String sha256; + + Map toJson() => { + 'seasonId': seasonId, + 'version': version, + 'packUrl': packUrl, + 'sha256': sha256, + }; +} +``` + +- [ ] **Step 5: 통과 + 커밋** + +Run: `flutter test test/data/remote/manifest_test.dart && flutter analyze` +Expected: PASS, 0 issues. + +```bash +git add pubspec.yaml pubspec.lock lib/data/remote/manifest.dart test/data/remote/manifest_test.dart +git commit -m "feat: remote manifest model and content dependencies" +``` + +--- + +### Task 2: ContentDownloader — fetch + SHA256 + 원자적 캐시 + +**Files:** +- Create: `lib/data/remote/content_downloader.dart` +- Test: `test/data/remote/content_downloader_test.dart` + +캐시 레이아웃: `/seasons//pack.json` + `/cache_index.json` (`{seasonId: version}`). + +- [ ] **Step 1: 실패하는 테스트** — `test/data/remote/content_downloader_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:block_seasons/data/remote/content_downloader.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('bs_cache_'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + String shaOf(String body) => sha256.convert(utf8.encode(body)).toString(); + + // Minimal valid-enough pack body (repository parses later; downloader + // only verifies bytes). + const packBody = '{"schemaVersion":1,"seasonId":"season_002"}'; + + Map manifestJson({String? sha, int version = 1}) => { + 'schemaVersion': 1, + 'minAppBuild': 1, + 'current': 'season_002', + 'seasons': [ + { + 'seasonId': 'season_002', + 'version': version, + 'packUrl': 'seasons/season_002/pack.json', + 'sha256': sha ?? shaOf(packBody), + }, + ], + }; + + MockClient okClient({String? sha, int version = 1}) => + MockClient((request) async { + if (request.url.path.endsWith('manifest.json')) { + return http.Response( + jsonEncode(manifestJson(sha: sha, version: version)), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith('pack.json')) { + return http.Response(packBody, 200); + } + return http.Response('not found', 404); + }); + + test('downloads, verifies, and caches a new season', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + final updated = await downloader.sync(); + expect(updated, isTrue); + final cached = + File('${tmp.path}/seasons/season_002/pack.json'); + expect(await cached.readAsString(), packBody); + expect(downloader.cachedVersion('season_002'), 1); + }); + + test('second sync with same version is a no-op', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + await downloader.sync(); + expect(await downloader.sync(), isFalse); + }); + + test('checksum mismatch rejects the pack and keeps cache clean', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(sha: 'deadbeef'), + ); + final updated = await downloader.sync(); + expect(updated, isFalse); + expect( + File('${tmp.path}/seasons/season_002/pack.json').existsSync(), + isFalse, + ); + // No stray temp files left behind. + final leftovers = tmp + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.tmp')); + expect(leftovers, isEmpty); + }); + + test('network failure leaves existing cache untouched', () async { + final good = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + await good.sync(); + + final offline = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: MockClient((_) async => throw const SocketException('off')), + ); + expect(await offline.sync(), isFalse); + expect( + File('${tmp.path}/seasons/season_002/pack.json').existsSync(), + isTrue, + ); + }); + + test('version bump re-downloads', () async { + await ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(version: 1), + ).sync(); + final updated = await ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(version: 2), + ).sync(); + expect(updated, isTrue); + }); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/data/remote/content_downloader_test.dart` +Expected: FAIL (파일 없음). + +- [ ] **Step 3: 구현** — `lib/data/remote/content_downloader.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'manifest.dart'; + +/// Pulls the remote season manifest and downloads new/updated packs into +/// [cacheDir] with SHA256 verification and atomic (temp + rename) writes. +/// All failures are swallowed into a `false` return — remote content is an +/// enhancement, never a crash source. +class ContentDownloader { + ContentDownloader({ + required this.baseUrl, + required this.cacheDir, + http.Client? client, + }) : _client = client ?? http.Client() { + _loadIndex(); + } + + final String baseUrl; + final Directory cacheDir; + final http.Client _client; + final Map _index = {}; + + File get _indexFile => File('${cacheDir.path}/cache_index.json'); + + void _loadIndex() { + try { + if (_indexFile.existsSync()) { + final json = + jsonDecode(_indexFile.readAsStringSync()) as Map; + json.forEach((k, v) => _index[k] = v as int); + } + } catch (_) { + // Corrupt index: treat as empty; packs will re-download. + _index.clear(); + } + } + + Future _saveIndex() async { + await cacheDir.create(recursive: true); + await _indexFile.writeAsString(jsonEncode(_index)); + } + + int? cachedVersion(String seasonId) => _index[seasonId]; + + /// Checks the manifest and downloads anything new. Returns true when at + /// least one pack was added or updated. + Future sync() async { + final RemoteManifest manifest; + try { + final res = await _client + .get(Uri.parse('$baseUrl/manifest.json')) + .timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) return false; + manifest = + RemoteManifest.fromJson(jsonDecode(res.body) as Map); + } catch (_) { + return false; + } + + var changed = false; + for (final season in manifest.seasons) { + if (_index[season.seasonId] == season.version) continue; + if (await _downloadPack(season)) changed = true; + } + return changed; + } + + Future _downloadPack(ManifestSeason season) async { + try { + final res = await _client + .get(Uri.parse('$baseUrl/${season.packUrl}')) + .timeout(const Duration(seconds: 30)); + if (res.statusCode != 200) return false; + + final digest = sha256.convert(res.bodyBytes).toString(); + if (digest != season.sha256) return false; + + final dir = Directory('${cacheDir.path}/seasons/${season.seasonId}'); + await dir.create(recursive: true); + final tmp = File('${dir.path}/pack.json.tmp'); + await tmp.writeAsBytes(res.bodyBytes, flush: true); + await tmp.rename('${dir.path}/pack.json'); + + _index[season.seasonId] = season.version; + await _saveIndex(); + return true; + } catch (_) { + return false; + } + } +} +``` + +- [ ] **Step 4: 통과 + 커밋** + +Run: `flutter test test/data/remote/ && flutter analyze` +Expected: PASS, 0 issues. + +```bash +git add lib/data/remote/content_downloader.dart test/data/remote/content_downloader_test.dart +git commit -m "feat: content downloader with sha256 verify and atomic cache" +``` + +--- + +### Task 3: ContentRepository — 캐시 병합 + 번들 폴백 + refresh + +**Files:** +- Modify: `lib/data/content_repository.dart` (전면 확장) +- Modify: `lib/state/providers.dart` (contentRepositoryProvider 주입 변경) +- Modify: `lib/main.dart` (캐시 디렉터리 준비) +- Test: `test/data/content_repository_test.dart` (확장) + +- [ ] **Step 1: 실패하는 테스트** — `test/data/content_repository_test.dart`에 추가 (기존 테스트는 유지하되 ContentRepository 생성이 새 시그니처면 같이 갱신): + +```dart +// 파일 상단 import 추가: +// import 'dart:convert'; +// import 'dart:io'; +// import 'package:block_seasons/game/models/season.dart'; + +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)); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/data/content_repository_test.dart` +Expected: FAIL (`cacheDir` 파라미터 없음). + +- [ ] **Step 3: 구현** — `lib/data/content_repository.dart` 전체 교체: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../game/models/season.dart'; +import 'remote/content_downloader.dart'; + +/// 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); + } + + 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(); + } +} +``` + +- [ ] **Step 4: providers.dart 주입 + main.dart 캐시 준비** + +`lib/state/providers.dart` — 기존 `contentRepositoryProvider`를 교체: + +```dart +/// Overridden in main() with the real cache directory + downloader; tests +/// override with in-memory fixtures or keep the bundled-only default. +final contentRepositoryProvider = + Provider((ref) => ContentRepository()); +``` +(시그니처가 기본 생성자 호환이라 선언 자체는 그대로여도 됨 — 변경 없으면 이 파일은 건드리지 않는다.) + +`lib/main.dart` — runApp 전에 캐시 디렉터리와 다운로더를 준비해 override: + +```dart +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +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 (configured in Phase 4 owner setup). +const contentBaseUrl = String.fromEnvironment( + 'CONTENT_BASE_URL', + defaultValue: 'https://block-seasons.web.app/content', +); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final saveRepository = await SaveRepository.open(); + + 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(), + )); +} +``` +(기존 main의 다른 내용은 유지하면서 위 구조로 합칠 것.) + +- [ ] **Step 5: 통과 + 커밋** + +Run: `flutter test && flutter analyze` +Expected: 전체 PASS (기존 content_repository 테스트 포함), 0 issues. + +```bash +git add lib/data/content_repository.dart lib/main.dart lib/state/providers.dart test/data/content_repository_test.dart +git commit -m "feat: content repository merges cached seasons with bundled fallback" +``` + +--- + +### Task 4: 시즌 갱신 트리거 + 활성 시즌 선택 + +**Files:** +- Modify: `lib/state/providers.dart` (seasonRefreshProvider) +- Modify: `lib/ui/screens/home_screen.dart` (refresh listen) +- Modify: `lib/ui/screens/season_map_screen.dart` (`list.first` → 활성 시즌) +- Modify: `lib/ui/screens/season_title_screen.dart` (`list.first` → 활성 시즌) +- Test: `test/state/season_refresh_test.dart` + +- [ ] **Step 1: 실패하는 테스트** — `test/state/season_refresh_test.dart`: + +```dart +import 'dart:io'; + +import 'package:block_seasons/data/content_repository.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeRepo extends ContentRepository { + _FakeRepo(this.result); + final bool result; + int calls = 0; + + @override + Future refresh() async { + calls++; + return result; + } +} + +void main() { + test('seasonRefreshProvider runs refresh once and exposes the result', + () async { + final repo = _FakeRepo(true); + final container = ProviderContainer( + overrides: [contentRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + expect(await container.read(seasonRefreshProvider.future), isTrue); + // Re-reading does not re-run (FutureProvider caches). + expect(await container.read(seasonRefreshProvider.future), isTrue); + expect(repo.calls, 1); + }); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/state/season_refresh_test.dart` +Expected: FAIL (`seasonRefreshProvider` 없음). + +- [ ] **Step 3: 구현** + +`lib/state/providers.dart`에 추가: + +```dart +/// One background content sync per app session. Home listens and refreshes +/// the season list when new packs arrived. +final seasonRefreshProvider = FutureProvider( + (ref) => ref.read(contentRepositoryProvider).refresh(), +); + +/// The season players land in by default: the newest available. +SeasonPack activeSeason(List seasons) => seasons.last; +``` +(`activeSeason`은 단순 헬퍼 — provider로 만들 필요 없음. providers.dart 하단에 top-level 함수로.) + +`lib/ui/screens/home_screen.dart` — `build` 시작부에 추가: + +```dart +ref.listen(seasonRefreshProvider, (_, next) { + if (next.valueOrNull == true) ref.invalidate(seasonsProvider); +}); +``` + +`lib/ui/screens/season_map_screen.dart` — `data: (list) => _JourneyMap(pack: list.first)` 를 `data: (list) => _JourneyMap(pack: activeSeason(list))` 로. (import는 providers.dart에 이미 있음.) + +`lib/ui/screens/season_title_screen.dart` — `final pack = list.first;` 를 `final pack = activeSeason(list);` 로. (빈 리스트 가드는 그 위에 이미 있음 — 유지.) + +- [ ] **Step 4: 통과 + 커밋** + +Run: `flutter test && flutter analyze` +Expected: 전체 PASS, 0 issues. + +```bash +git add lib/state/providers.dart lib/ui/screens test/state/season_refresh_test.dart +git commit -m "feat: session content sync trigger and newest-season selection" +``` + +--- + +### Task 5: AnalyticsService — 백엔드 추상화 + 게임 이벤트 배선 + +**Files:** +- Create: `lib/services/analytics_service.dart` +- Modify: `lib/state/providers.dart` (analyticsProvider) +- Modify: `lib/state/season_flow_notifier.dart` (stage_start) +- Modify: `lib/ui/screens/game_screen.dart` (stage_end / endless_end / rescue) +- Modify: `lib/ui/screens/home_screen.dart` (endless_start) +- Modify: `lib/state/tutorial_notifier.dart` (tutorial_done) +- Test: `test/services/analytics_service_test.dart` + +- [ ] **Step 1: 실패하는 테스트** — `test/services/analytics_service_test.dart`: + +```dart +import 'package:block_seasons/services/analytics_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingBackend implements AnalyticsBackend { + final events = <(String, Map)>[]; + + @override + void logEvent(String name, Map params) { + events.add((name, params)); + } +} + +void main() { + test('typed helpers produce stable event names and params', () { + final backend = _RecordingBackend(); + final analytics = AnalyticsService(backend); + + analytics.stageStart(seasonId: 'season_001', stageId: 's1'); + analytics.stageEnd( + seasonId: 'season_001', + stageId: 's1', + won: true, + stars: 3, + score: 1200, + movesUsed: 9, + ); + analytics.endlessStart(); + analytics.endlessEnd(score: 500, isNewBest: true); + analytics.rescueUsed(type: 'continue'); + analytics.tutorialFinished(skipped: false); + + expect(backend.events.map((e) => e.$1).toList(), [ + 'stage_start', + 'stage_end', + 'endless_start', + 'endless_end', + 'rescue_used', + 'tutorial_finished', + ]); + expect(backend.events[1].$2, { + 'season_id': 'season_001', + 'stage_id': 's1', + 'won': 1, + 'stars': 3, + 'score': 1200, + 'moves_used': 9, + }); + expect(backend.events[3].$2, {'score': 500, 'new_best': 1}); + }); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/services/analytics_service_test.dart` +Expected: FAIL (파일 없음). + +- [ ] **Step 3: 구현** — `lib/services/analytics_service.dart`: + +```dart +import 'package:flutter/foundation.dart'; + +/// Where events land. Phase 4 ships the debug logger; the Firebase backend +/// plugs in here after the owner runs flutterfire configure. +abstract class AnalyticsBackend { + void logEvent(String name, Map params); +} + +class DebugAnalyticsBackend implements AnalyticsBackend { + @override + void logEvent(String name, Map params) { + debugPrint('[analytics] $name $params'); + } +} + +/// Typed event surface. Booleans are sent as 0/1 ints so every backend +/// (GA4 included) aggregates them the same way. +class AnalyticsService { + AnalyticsService(this._backend); + + final AnalyticsBackend _backend; + + void stageStart({required String seasonId, required String stageId}) { + _backend.logEvent('stage_start', { + 'season_id': seasonId, + 'stage_id': stageId, + }); + } + + void stageEnd({ + required String seasonId, + required String stageId, + required bool won, + required int stars, + required int score, + required int movesUsed, + }) { + _backend.logEvent('stage_end', { + 'season_id': seasonId, + 'stage_id': stageId, + 'won': won ? 1 : 0, + 'stars': stars, + 'score': score, + 'moves_used': movesUsed, + }); + } + + void endlessStart() => _backend.logEvent('endless_start', const {}); + + void endlessEnd({required int score, required bool isNewBest}) { + _backend.logEvent('endless_end', { + 'score': score, + 'new_best': isNewBest ? 1 : 0, + }); + } + + void rescueUsed({required String type}) { + _backend.logEvent('rescue_used', {'type': type}); + } + + void tutorialFinished({required bool skipped}) { + _backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0}); + } +} +``` + +- [ ] **Step 4: provider + 배선** + +`lib/state/providers.dart`: + +```dart +final analyticsProvider = Provider( + (ref) => AnalyticsService(DebugAnalyticsBackend()), +); +``` +(+ `import '../services/analytics_service.dart';`) + +`lib/state/season_flow_notifier.dart` — `startSeasonStage` 끝에: + +```dart +ref.read(analyticsProvider).stageStart( + seasonId: pack.seasonId, + stageId: pack.stages[index].id, + ); +``` + +`lib/ui/screens/game_screen.dart` — `_onSessionChange`의 phase 전환 블록: +- won 분기 (`if (!next.endless)` recordWin 가드 안, recordWin 다음): + +```dart +final flow = ref.read(seasonFlowProvider); +if (flow != null) { + ref.read(analyticsProvider).stageEnd( + seasonId: flow.pack.seasonId, + stageId: flow.stage.id, + won: true, + stars: next.starsEarned, + score: next.score, + movesUsed: next.moveLimit - next.movesLeft, + ); +} +``` + +- lost 처리부: endless면 (`record(...)` 호출 뒤): + +```dart +ref.read(analyticsProvider).endlessEnd( + score: next.score, + isNewBest: isNew, + ); +``` +(`record(next.score).then((isNew) {...})` 콜백 안에서 호출.) + +- lost && !endless (시즌 스테이지 패배): + +```dart +final flow = ref.read(seasonFlowProvider); +if (flow != null) { + ref.read(analyticsProvider).stageEnd( + seasonId: flow.pack.seasonId, + stageId: flow.stage.id, + won: false, + stars: 0, + score: next.score, + movesUsed: next.moveLimit - next.movesLeft, + ); +} +``` + +주의: 엔드리스의 movesLeft는 1<<30 센티널이고 moveLimit은 0 — 위 stageEnd 호출들은 모두 `flow != null`(시즌 컨텍스트)에서만 일어나므로 안전. + +- rescue 두 곳: `_resultOverlay`의 `notifier.useContinue` / `notifier.addExtraMoves` onPressed에 각각: + +```dart +ref.read(analyticsProvider).rescueUsed(type: 'continue'); +// / type: 'extra_moves' +``` +(onPressed를 블록으로 바꿔 분석 호출 후 기존 메서드 호출.) + +`lib/ui/screens/home_screen.dart` — 클래식 onPressed의 startStage 직전: + +```dart +ref.read(analyticsProvider).endlessStart(); +``` + +`lib/state/tutorial_notifier.dart` — `_finish()`에 skipped 플래그가 필요하므로 시그니처 변경: + +```dart +Future dismissHud() async { + if (state != TutorialStep.explainHud) return; + await _finish(skipped: false); +} + +Future skip() async { + if (state == null) return; + await _finish(skipped: true); +} + +Future _finish({required bool skipped}) async { + state = null; + ref.read(analyticsProvider).tutorialFinished(skipped: skipped); + await ref.read(saveRepositoryProvider).markTutorialDone(); +} +``` + +- [ ] **Step 5: 통과 + 커밋** + +Run: `flutter test && flutter analyze` +Expected: 전체 PASS (기존 tutorial_notifier_test 영향 없음 — 외부 동작 동일), 0 issues. + +```bash +git add lib test/services +git commit -m "feat: analytics abstraction with debug backend and game event wiring" +``` + +--- + +### Task 6: 시즌 2 콘텐츠 생성 + manifest 생성 도구 + +**Files:** +- Create: `content/season_002/spec.json` +- Create: `tool/make_manifest.dart` +- Generated: `content/season_002/pack.json`, `content/season_002/report.md`, `content/manifest.json` + +- [ ] **Step 1: 시즌 2 스펙 작성** — `content/season_002/spec.json`: + +```json +{ + "seasonId": "season_002", + "version": 1, + "title": { "en": "Summer Tide", "ko": "여름 파도" }, + "theme": { + "tileSet": "summer", + "background": "", + "backgroundGradient": [4278854704, 4279253322, 4280179302], + "accentColor": 4285456885, + "particleType": "petals" + }, + "stageCount": 30, + "baseSeed": 20260612, + "runsPerStage": 80 +} +``` +(그라데이션 = 0xFF0A2430/0xFF10394A/0xFF1E5A66 딥 틸, accent = 0xFF6FCDF5 시안 — 비주얼 컴패니언에서 "여름에 어울림"으로 평가된 톤. `copyToAssets` 없음 = 번들 미포함, 원격 전용. int는 JSON이라 10진수 표기.) + +주의: 4285456885 = 0xFF6FCDF5 확인 계산: 0xFF6FCDF5 = 4285713909. **구현 시 반드시 Dart로 검증**: `print(0xFF6FCDF5);` → 그 값을 spec에 쓸 것. (0xFF0A2430 = 4278920240, 0xFF10394A = 4279318858, 0xFF1E5A66 = 4280245862 — 역시 print로 검증 후 기입.) + +- [ ] **Step 2: 시즌 2 생성** + +```bash +dart run tool/stage_generator/generate.dart content/season_002/spec.json +``` +Expected: `content/season_002/pack.json` + `report.md` 생성, 콘솔에 스테이지별 보정 로그. (60스테이지 시즌1이 ~10초였으므로 30스테이지는 ~5초.) + +생성 후 확인: +```bash +dart -e "import 'dart:convert'; import 'dart:io'; void main(){ final p=jsonDecode(File('content/season_002/pack.json').readAsStringSync()); print(p['seasonId']); print((p['stages'] as List).length); print(p['theme']); }" +``` +Expected: season_002 / 30 / theme에 backgroundGradient·accentColor 포함. + +- [ ] **Step 3: make_manifest.dart 작성** — `tool/make_manifest.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +/// Builds content/manifest.json from the season packs under content/. +/// Usage: dart run tool/make_manifest.dart +void main() { + final contentDir = Directory('content'); + final seasons = >[]; + + final dirs = contentDir + .listSync() + .whereType() + .where((d) => File('${d.path}/pack.json').existsSync()) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + for (final dir in dirs) { + final file = File('${dir.path}/pack.json'); + final bytes = file.readAsBytesSync(); + final pack = jsonDecode(utf8.decode(bytes)) as Map; + seasons.add({ + 'seasonId': pack['seasonId'], + 'version': pack['version'], + 'packUrl': 'seasons/${pack['seasonId']}/pack.json', + 'sha256': sha256.convert(bytes).toString(), + }); + } + + final manifest = { + 'schemaVersion': 1, + 'minAppBuild': 1, + 'current': seasons.isEmpty ? '' : seasons.last['seasonId'], + 'seasons': seasons, + }; + + File('content/manifest.json') + .writeAsStringSync('${const JsonEncoder.withIndent(" ").convert(manifest)}\n'); + stdout.writeln('manifest.json written with ${seasons.length} season(s).'); +} +``` + +- [ ] **Step 4: manifest 생성 + 검증** + +```bash +dart run tool/make_manifest.dart +cat content/manifest.json +``` +Expected: season_001과 season_002 항목, current=season_002, 각 sha256 64자 hex. + +주의: packUrl 규약은 `seasons//pack.json`인데 로컬 content/ 레이아웃은 `content//pack.json` — 배포 시 `seasons/` 하위로 올리는 구조다. Task 7의 로컬 서빙 단계에서 디렉터리를 그 레이아웃으로 구성한다 (스크립트 제공됨). + +- [ ] **Step 5: 커밋** + +```bash +git add content/season_002 content/manifest.json tool/make_manifest.dart +git commit -m "feat: season 2 'Summer Tide' content and manifest generator" +``` + +--- + +### Task 7: 통합 검증 — 로컬 서버로 원격 시즌 E2E + 오너 가이드 + +**Files:** +- Create: `docs/firebase-hosting-guide.md` +- 검증만: 시뮬레이터 E2E + +- [ ] **Step 1: 전체 테스트 + analyze** + +```bash +flutter analyze +flutter test +``` +Expected: 0 issues, 전체 PASS. 최종 `+N` 라인 기록. + +- [ ] **Step 2: 로컬 콘텐츠 서버 구성** + +```bash +mkdir -p /tmp/bs_serve/content/seasons/season_001 /tmp/bs_serve/content/seasons/season_002 +cp content/manifest.json /tmp/bs_serve/content/ +cp content/season_001/pack.json /tmp/bs_serve/content/seasons/season_001/ +cp content/season_002/pack.json /tmp/bs_serve/content/seasons/season_002/ +cd /tmp/bs_serve && python3 -m http.server 8787 & +cd - +curl -s http://localhost:8787/content/manifest.json | head -5 +``` +Expected: manifest JSON 출력. + +- [ ] **Step 3: 시뮬레이터에서 원격 시즌 E2E** + +```bash +xcrun simctl boot "iPhone 17 Pro" 2>/dev/null +flutter build ios --simulator --debug --dart-define=CONTENT_BASE_URL=http://localhost:8787/content +xcrun simctl install booted build/ios/iphonesimulator/Runner.app +xcrun simctl launch booted com.airkjw.blockseasons +``` + +수동 확인 (콜드 스타트 2회 — 1회차에 다운로드, listen이 invalidate하면 1회차에도 반영될 수 있음): +1. 홈 → 어드벤처: 시즌 맵이 **"Summer Tide / 여름 파도" 30스테이지 + 딥 틸 배경**으로 뜨면 성공 (= 스토어 업데이트 없이 시즌2 등장). +2. 시즌 카드도 SEASON 2 표시. +3. 스크린샷: `xcrun simctl io booted screenshot /tmp/sim_remote_s2.png` → `docs/screenshots/sim_remote_season2.png` 로 복사. + +- [ ] **Step 4: 오프라인 폴백 E2E** + +```bash +kill %1 # python http.server 종료 +xcrun simctl uninstall booted com.airkjw.blockseasons # 캐시 제거(신규 설치 시나리오) +xcrun simctl install booted build/ios/iphonesimulator/Runner.app +xcrun simctl launch booted com.airkjw.blockseasons +``` +확인: 서버가 죽어 있어도 앱이 정상 구동하고 어드벤처 = 번들 시즌1 (First Bloom/첫 개화 60스테이지). 크래시·멈춤 없어야 함. (= 비행기 모드 시나리오) + +- [ ] **Step 5: 오너 가이드 작성** — `docs/firebase-hosting-guide.md`: + +```markdown +# Firebase Hosting 시즌 배포 가이드 (오너용) + +## 1회 설정 (약 15분) + +1. https://console.firebase.google.com → "프로젝트 추가" → 이름 `block-seasons` + → Google Analytics **사용 설정** (분석 Phase에서 사용). +2. 터미널에서: + ```bash + npm install -g firebase-tools + firebase login + cd "/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons" + firebase init hosting # 기존 프로젝트 block-seasons 선택, public 디렉터리: deploy + ``` +3. 생성된 호스팅 도메인(예: https://block-seasons.web.app)을 Claude에게 알려주면 + 앱의 CONTENT_BASE_URL 기본값을 맞춰드립니다. + +## 시즌 배포 (매 시즌 약 1분) + +```bash +cd "/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons" +dart run tool/make_manifest.dart +rm -rf deploy/content && mkdir -p deploy/content/seasons +cp content/manifest.json deploy/content/ +for d in content/season_*/; do + id=$(basename "$d") + mkdir -p "deploy/content/seasons/$id" + cp "$d/pack.json" "deploy/content/seasons/$id/" +done +firebase deploy --only hosting +``` + +배포 직후 모든 유저의 다음 콜드 스타트에서 새 시즌이 나타납니다. +앱 업데이트·심사 불필요. +``` + +- [ ] **Step 6: 스크린샷 + 최종 커밋 + 푸시** + +```bash +git add docs +git commit -m "feat: phase 4 remote seasons verified end-to-end; owner hosting guide" +git push +``` + +- [ ] **Step 7: 오너 보고** — 시즌2가 원격으로 등장한 스크린샷 공유, Firebase 가이드 안내, Phase 5(수익화) 전 오너 선행 작업(AdMob 앱/광고단위 생성) 리마인드. + +--- + +## 자체 검토 결과 + +- **Spec 커버리지**: manifest+SHA256+원자 캐시(T2), 캐시→번들 폴백(T3), 갱신 트리거(T4), 분석 이벤트(T5), 시즌2 콘텐츠+배포물(T6), "스토어 업데이트 없이 시즌2 등장"+"비행기 모드 시즌1 정상" 검증(T7), 오너 Firebase 가이드(T7). 스트릭 UI는 Phase 3에서 완료(범위 제외 명시). theme.zip은 보류 — 현재 테마는 pack.json 내 JSON 필드로 충분(이미지 에셋 도입 시 Phase 6에서 추가). +- **플레이스홀더**: 없음. 단 spec.json의 ARGB 10진수 값은 구현자가 Dart print로 검증 후 기입하도록 명시(계산 실수 방지). +- **타입 일관성**: `ContentDownloader(baseUrl:, cacheDir:, client:)` (T2 정의 → T3 main 사용), `ContentRepository(cacheDir:, downloader:)` (T3), `seasonRefreshProvider`/`activeSeason` (T4 정의 → T4 사용처), `AnalyticsService`/`AnalyticsBackend` (T5), manifest 필드명 (T1 ↔ T2 ↔ T6 make_manifest) 일치 확인. +- **Firebase 미포함 확인**: firebase_core/analytics 의존성 없음 — 오너 작업 완료 후 별도 태스크로 FirebaseAnalyticsBackend 추가 예정.