# 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 추가 예정.