Files
BlockSeasons/docs/superpowers/plans/2026-06-12-remote-seasons.md
T
2026-06-12 12:44:41 +09:00

41 KiB

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의 themeSeasonTheme.fromJson으로 파싱(확장 비주얼 필드 호환)하고 copyToAssets는 옵셔널(as String?) — 시즌2는 이 키를 빼서 번들에 복사하지 않는다.
  • SeasonPack.fromJsonschemaVersion > 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: 패키지 추가

flutter pub add http crypto path_provider

Expected: pubspec.yaml dependencies에 3개 추가, flutter pub get 성공.

  • Step 2: 실패하는 테스트test/data/remote/manifest_test.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:
/// 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<String, dynamic> 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<String, dynamic>),
    ];
    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<ManifestSeason> seasons;

  Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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.

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

캐시 레이아웃: <cacheDir>/seasons/<seasonId>/pack.json + <cacheDir>/cache_index.json ({seasonId: version}).

  • Step 1: 실패하는 테스트test/data/remote/content_downloader_test.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<String, dynamic> 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<File>()
        .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:
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<String, int> _index = {};

  File get _indexFile => File('${cacheDir.path}/cache_index.json');

  void _loadIndex() {
    try {
      if (_indexFile.existsSync()) {
        final json =
            jsonDecode(_indexFile.readAsStringSync()) as Map<String, dynamic>;
        json.forEach((k, v) => _index[k] = v as int);
      }
    } catch (_) {
      // Corrupt index: treat as empty; packs will re-download.
      _index.clear();
    }
  }

  Future<void> _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<bool> 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<String, dynamic>);
    } 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<bool> _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.

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 생성이 새 시그니처면 같이 갱신):

// 파일 상단 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<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));
  });
});
  • Step 2: 실패 확인

Run: flutter test test/data/content_repository_test.dart Expected: FAIL (cacheDir 파라미터 없음).

  • Step 3: 구현lib/data/content_repository.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<SeasonPack> loadBundledSeason(String seasonId) async {
    final raw =
        await rootBundle.loadString('assets/seasons/$seasonId/pack.json');
    return SeasonPack.fromJson(jsonDecode(raw) as Map<String, dynamic>);
  }

  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<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();
  }
}
  • Step 4: providers.dart 주입 + main.dart 캐시 준비

lib/state/providers.dart — 기존 contentRepositoryProvider를 교체:

/// Overridden in main() with the real cache directory + downloader; tests
/// override with in-memory fixtures or keep the bundled-only default.
final contentRepositoryProvider =
    Provider<ContentRepository>((ref) => ContentRepository());

(시그니처가 기본 생성자 호환이라 선언 자체는 그대로여도 됨 — 변경 없으면 이 파일은 건드리지 않는다.)

lib/main.dart — runApp 전에 캐시 디렉터리와 다운로더를 준비해 override:

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<void> 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.

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:

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<bool> 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에 추가:

/// One background content sync per app session. Home listens and refreshes
/// the season list when new packs arrived.
final seasonRefreshProvider = FutureProvider<bool>(
  (ref) => ref.read(contentRepositoryProvider).refresh(),
);

/// The season players land in by default: the newest available.
SeasonPack activeSeason(List<SeasonPack> seasons) => seasons.last;

(activeSeason은 단순 헬퍼 — provider로 만들 필요 없음. providers.dart 하단에 top-level 함수로.)

lib/ui/screens/home_screen.dartbuild 시작부에 추가:

ref.listen(seasonRefreshProvider, (_, next) {
  if (next.valueOrNull == true) ref.invalidate(seasonsProvider);
});

lib/ui/screens/season_map_screen.dartdata: (list) => _JourneyMap(pack: list.first)data: (list) => _JourneyMap(pack: activeSeason(list)) 로. (import는 providers.dart에 이미 있음.)

lib/ui/screens/season_title_screen.dartfinal pack = list.first;final pack = activeSeason(list); 로. (빈 리스트 가드는 그 위에 이미 있음 — 유지.)

  • Step 4: 통과 + 커밋

Run: flutter test && flutter analyze Expected: 전체 PASS, 0 issues.

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:

import 'package:block_seasons/services/analytics_service.dart';
import 'package:flutter_test/flutter_test.dart';

class _RecordingBackend implements AnalyticsBackend {
  final events = <(String, Map<String, Object>)>[];

  @override
  void logEvent(String name, Map<String, Object> 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:
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<String, Object> params);
}

class DebugAnalyticsBackend implements AnalyticsBackend {
  @override
  void logEvent(String name, Map<String, Object> 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:

final analyticsProvider = Provider<AnalyticsService>(
  (ref) => AnalyticsService(DebugAnalyticsBackend()),
);

(+ import '../services/analytics_service.dart';)

lib/state/season_flow_notifier.dartstartSeasonStage 끝에:

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 다음):
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(...) 호출 뒤):
ref.read(analyticsProvider).endlessEnd(
      score: next.score,
      isNewBest: isNew,
    );

(record(next.score).then((isNew) {...}) 콜백 안에서 호출.)

  • lost && !endless (시즌 스테이지 패배):
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 두 곳: _resultOverlaynotifier.useContinue / notifier.addExtraMoves onPressed에 각각:
ref.read(analyticsProvider).rescueUsed(type: 'continue');
// / type: 'extra_moves'

(onPressed를 블록으로 바꿔 분석 호출 후 기존 메서드 호출.)

lib/ui/screens/home_screen.dart — 클래식 onPressed의 startStage 직전:

ref.read(analyticsProvider).endlessStart();

lib/state/tutorial_notifier.dart_finish()에 skipped 플래그가 필요하므로 시그니처 변경:

Future<void> dismissHud() async {
  if (state != TutorialStep.explainHud) return;
  await _finish(skipped: false);
}

Future<void> skip() async {
  if (state == null) return;
  await _finish(skipped: true);
}

Future<void> _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.

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:

{
  "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 생성
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초.)

생성 후 확인:

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:
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 = <Map<String, dynamic>>[];

  final dirs = contentDir
      .listSync()
      .whereType<Directory>()
      .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<String, dynamic>;
    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 생성 + 검증
dart run tool/make_manifest.dart
cat content/manifest.json

Expected: season_001과 season_002 항목, current=season_002, 각 sha256 64자 hex.

주의: packUrl 규약은 seasons/<id>/pack.json인데 로컬 content/ 레이아웃은 content/<id>/pack.json — 배포 시 seasons/ 하위로 올리는 구조다. Task 7의 로컬 서빙 단계에서 디렉터리를 그 레이아웃으로 구성한다 (스크립트 제공됨).

  • Step 5: 커밋
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

flutter analyze
flutter test

Expected: 0 issues, 전체 PASS. 최종 +N 라인 기록.

  • Step 2: 로컬 콘텐츠 서버 구성
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
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.pngdocs/screenshots/sim_remote_season2.png 로 복사.
  • Step 4: 오프라인 폴백 E2E
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:
# 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
  1. 생성된 호스팅 도메인(예: https://block-seasons.web.app)을 Claude에게 알려주면 앱의 CONTENT_BASE_URL 기본값을 맞춰드립니다.

시즌 배포 (매 시즌 약 1분)

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