63ac8c6b9e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1256 lines
41 KiB
Markdown
1256 lines
41 KiB
Markdown
# 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<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.
|
|
|
|
```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`
|
|
|
|
캐시 레이아웃: `<cacheDir>/seasons/<seasonId>/pack.json` + `<cacheDir>/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<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`:
|
|
|
|
```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.
|
|
|
|
```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<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` 전체 교체:
|
|
|
|
```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`를 교체:
|
|
|
|
```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<ContentRepository>((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<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.
|
|
|
|
```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<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`에 추가:
|
|
|
|
```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` 시작부에 추가:
|
|
|
|
```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<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`:
|
|
|
|
```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`:
|
|
|
|
```dart
|
|
final analyticsProvider = Provider<AnalyticsService>(
|
|
(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<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.
|
|
|
|
```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 = <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 생성 + 검증**
|
|
|
|
```bash
|
|
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: 커밋**
|
|
|
|
```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 추가 예정.
|