Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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의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: 패키지 추가
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.dart — build 시작부에 추가:
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.
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.dart — startSeasonStage 끝에:
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 두 곳:
_resultOverlay의notifier.useContinue/notifier.addExtraMovesonPressed에 각각:
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회차에도 반영될 수 있음):
- 홈 → 어드벤처: 시즌 맵이 "Summer Tide / 여름 파도" 30스테이지 + 딥 틸 배경으로 뜨면 성공 (= 스토어 업데이트 없이 시즌2 등장).
- 시즌 카드도 SEASON 2 표시.
- 스크린샷:
xcrun simctl io booted screenshot /tmp/sim_remote_s2.png→docs/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
- 생성된 호스팅 도메인(예: 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 추가 예정.