Merge Phase 4: remote seasons + analytics

Manifest-driven season delivery (SHA256 + atomic cache, bundled
fallback), Season 2 'Summer Tide' remote-only content, analytics
abstraction with debug backend, rescue crash fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:49:42 +09:00
31 changed files with 3299 additions and 37 deletions
+1
View File
@@ -48,3 +48,4 @@ app.*.map.json
lib/l10n/gen/
.superpowers/
CLAUDE.md
*.pid
+19
View File
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"minAppBuild": 1,
"current": "season_002",
"seasons": [
{
"seasonId": "season_001",
"version": 1,
"packUrl": "seasons/season_001/pack.json",
"sha256": "5b20b88251931838563aaaa7729f48e5a35f09dbf80c576b9bc2ec944050fc0a"
},
{
"seasonId": "season_002",
"version": 1,
"packUrl": "seasons/season_002/pack.json",
"sha256": "47cc115f9982ade7df686b28aa95a82edcc1e8a4aae5f13319e7131477855de3"
}
]
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
# season_002 difficulty report
30 stages, 80 bot runs each, generated in 4s.
| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
| season_002_001 | clearGems 1 | 7 | 83% | 3/4 |
| season_002_002 | clearGems 1 | 8 | 79% | 4/5 |
| season_002_003 | clearGems 2 | 11 | 91% | 4/5 |
| season_002_004 | clearGems 2 | 12 | 86% | 5/6 |
| season_002_005 | reachScore 990 | 25 | 100% | 6/9 |
| season_002_006 | clearGems 3 | 24 | 74% | 11/17 |
| season_002_007 | clearLines 5 | 23 | 100% | 6/8 |
| season_002_008 | clearGems 2 | 6 | 75% | 2/3 |
| season_002_009 | clearGems 4 | 14 | 86% | 6/7 |
| season_002_010 | reachScore 1476 | 31 | 100% | 9/11 |
| season_002_011 | clearGems 4 | 16 | 95% | 6/9 |
| season_002_012 | clearGems 4 | 15 | 70% | 5/7 |
| season_002_013 | clearGems 4 | 20 | 75% | 9/11 |
| season_002_014 | clearLines 7 | 27 | 99% | 5/7 |
| season_002_015 | reachScore 1766 | 30 | 96% | 4/8 |
| season_002_016 | clearGems 5 | 10 | 73% | 3/5 |
| season_002_017 | clearGems 5 | 21 | 65% | 7/10 |
| season_002_018 | clearGems 6 | 27 | 74% | 9/13 |
| season_002_019 | clearGems 6 | 20 | 73% | 4/7 |
| season_002_020 | reachScore 2185 | 33 | 93% | 5/8 |
| season_002_021 | clearLines 8 | 23 | 79% | 1/3 |
| season_002_022 | clearGems 5 | 28 | 85% | 6/12 |
| season_002_023 | clearGems 7 | 26 | 76% | 6/10 |
| season_002_024 | clearGems 7 | 21 | 83% | 5/10 |
| season_002_025 | reachScore 2692 | 39 | 93% | 7/11 |
| season_002_026 | clearGems 6 | 31 | 59% | 9/15 |
| season_002_027 | clearGems 6 | 13 | 65% | 2/5 |
| season_002_028 | clearLines 10 | 27 | 89% | 2/4 |
| season_002_029 | clearGems 7 | 27 | 69% | 8/10 |
| season_002_030 | reachScore 3006 | 37 | 80% | 2/6 |
+15
View File
@@ -0,0 +1,15 @@
{
"seasonId": "season_002",
"version": 1,
"title": { "en": "Summer Tide", "ko": "여름 파도" },
"theme": {
"tileSet": "summer",
"background": "",
"backgroundGradient": [4278854704, 4279253322, 4280179302],
"accentColor": 4285517301,
"particleType": "petals"
},
"stageCount": 30,
"baseSeed": 20260612,
"runsPerStage": 80
}
+66
View File
@@ -0,0 +1,66 @@
# Firebase Hosting 시즌 배포 가이드 (오너용)
앱은 시작할 때마다 `CONTENT_BASE_URL/manifest.json`을 확인하고, 새 시즌 팩을
SHA256 검증 후 내려받습니다. 호스팅은 정적 파일 서버이기만 하면 되며,
Firebase Hosting 무료 플랜이면 충분합니다.
## 1회 설정 (약 15분)
1. https://console.firebase.google.com → **프로젝트 추가** → 이름 `block-seasons`
→ Google Analytics **사용 설정** (이후 분석 연동에 사용).
2. 터미널에서:
```bash
npm install -g firebase-tools
firebase login
cd "/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons"
firebase init hosting
# → Use an existing project → block-seasons
# → public 디렉터리: deploy
# → single-page app: No / 자동 빌드: No
```
3. 생성된 호스팅 도메인(예: `https://block-seasons.web.app`)을 Claude에게
알려주세요 — 앱의 `CONTENT_BASE_URL` 기본값(lib/main.dart)을 그 도메인으로
맞추고, `flutterfire configure`를 함께 진행해 Firebase Analytics 백엔드도
연결합니다.
## 시즌 배포 (매 시즌 약 1분)
새 시즌 팩을 생성한 뒤(`dart run tool/stage_generator/generate.dart ...`):
```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
```
배포 직후 모든 유저의 **다음 콜드 스타트**에서 새 시즌이 나타납니다.
앱 업데이트·스토어 심사가 필요 없습니다.
## 동작 방식 요약 (참고)
- `manifest.json`: 시즌 목록 + 버전 + SHA256. `tool/make_manifest.dart`가 생성.
- 클라이언트: 버전이 다른 팩만 다운로드 → SHA256 일치 시에만 원자적으로 캐시
교체. 검증 실패·오프라인·서버 오류는 전부 조용히 무시되고 기존 캐시 또는
번들 시즌 1로 동작.
- 시즌 1은 앱에 번들되어 있어 인터넷이 한 번도 연결되지 않아도 게임이
완전히 동작합니다 (E2E 검증 완료: docs/screenshots/sim_offline_fallback.png).
- 원격 시즌 등장 검증: docs/screenshots/sim_remote_season2.png ("SEASON 2 ·
여름 파도"가 로컬 서버 배포만으로 등장).
## 주의
- `pack.json`을 수정하면 반드시 `make_manifest.dart`를 다시 실행해 SHA256을
갱신해야 합니다 (불일치 시 클라이언트가 팩을 거부).
- 시즌 팩에 새 필수 필드를 도입하는 스키마 변경 시 `schemaVersion`을 올리면
구버전 앱은 그 팩을 무시합니다 (크래시 없음).
- `minAppBuild` 필드는 아직 클라이언트가 강제하지 않습니다 — 앱 버전 의존
콘텐츠를 배포하기 전에 강제 로직을 추가해야 합니다 (Phase 7 체크리스트).
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+59 -5
View File
@@ -1,21 +1,75 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import '../game/models/season.dart';
import 'remote/content_downloader.dart';
/// Resolves season content. Phase 3: bundled assets only; remote download
/// and caching land in Phase 4.
/// 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>);
}
Future<List<SeasonPack>> availableSeasons() async => [
for (final id in bundledSeasonIds) await loadBundledSeason(id),
];
Future<SeasonPack?> _loadCachedSeason(Directory dir) async {
try {
final file = File('${dir.path}/pack.json');
if (!await file.exists()) return null;
return SeasonPack.fromJson(
jsonDecode(await file.readAsString()) 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 && await seasonsDir.exists()) {
await for (final entry in seasonsDir.list()) {
if (entry is! Directory) continue;
final pack = await _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();
}
}
+110
View File
@@ -0,0 +1,110 @@
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 {
// seasonId becomes a filesystem path segment; only accept safe slugs so a
// hostile manifest can never write outside the cache dir.
if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(season.seasonId)) return false;
if (season.packUrl.contains('..') ||
season.packUrl.startsWith('/') ||
season.packUrl.contains('://')) {
return false;
}
try {
final res = await _client
.get(Uri.parse('$baseUrl/${season.packUrl}'))
.timeout(const Duration(seconds: 30));
if (res.statusCode != 200) return false;
// Packs are tens of KB; anything huge is a server error or an attack.
if (res.bodyBytes.length > 5 * 1024 * 1024) return false;
// Verify in memory before any file is touched, so a bad pack can
// never leave artifacts on disk.
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;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/// 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,
};
}
+1
View File
@@ -82,6 +82,7 @@ class GameEngine {
List<Objective> get objectives => List.unmodifiable(_objectives);
GamePhase get phase => _phase;
StuckReason? get stuckReason => _stuckReason;
bool get rescueUsed => _rescueUsed;
int get starsEarned => _phase == GamePhase.won
? _stage.stars.starsFor(movesLeft: movesLeft)
+37 -6
View File
@@ -1,17 +1,48 @@
import 'dart:io';
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 (owner setup pending).
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();
runApp(
ProviderScope(
overrides: [saveRepositoryProvider.overrideWithValue(saveRepository)],
child: const BlockSeasonsApp(),
),
);
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(),
));
}
+64
View File
@@ -0,0 +1,64 @@
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});
}
}
+15
View File
@@ -6,6 +6,7 @@ import '../game/models/grid.dart';
import '../game/models/objective.dart';
import '../game/models/piece.dart';
import '../game/models/stage.dart';
import 'providers.dart';
/// Immutable snapshot of one engine moment; the only game state the UI sees.
class GameViewState {
@@ -24,6 +25,7 @@ class GameViewState {
required this.lastPlacement,
required this.fxTick,
required this.endless,
required this.rescueUsed,
});
final GridState grid;
@@ -43,6 +45,7 @@ class GameViewState {
final int fxTick;
final bool endless;
final bool rescueUsed;
}
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
@@ -72,6 +75,17 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
final stage = _stage;
if (stage == null) throw StateError('no stage to restart');
startStage(stage, attempt: _attempt + 1, generator: _generatorOverride);
if (stage.endless) {
ref.read(analyticsProvider).endlessStart();
} else {
final flow = ref.read(seasonFlowProvider);
if (flow != null) {
ref.read(analyticsProvider).stageStart(
seasonId: flow.pack.seasonId,
stageId: flow.stage.id,
);
}
}
}
bool tryPlace(int trayIndex, int x, int y) {
@@ -119,6 +133,7 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
objectiveProgress: engine.objectiveProgress,
lastPlacement: lastPlacement,
fxTick: _fxTick,
rescueUsed: engine.rescueUsed,
);
}
}
+21 -2
View File
@@ -4,6 +4,7 @@ import '../data/content_repository.dart';
import '../data/save_repository.dart';
import '../data/streak.dart';
import '../game/models/season.dart';
import '../services/analytics_service.dart';
import '../services/audio_service.dart';
import 'endless_best_notifier.dart';
import 'game_session_notifier.dart';
@@ -40,10 +41,24 @@ final seasonFlowProvider = NotifierProvider<SeasonFlowNotifier, SeasonFlow?>(
final contentRepositoryProvider =
Provider<ContentRepository>((ref) => ContentRepository());
final seasonsProvider = FutureProvider<List<SeasonPack>>(
(ref) => ref.read(contentRepositoryProvider).availableSeasons(),
final seasonsProvider = FutureProvider<List<SeasonPack>>((ref) {
// Watching (not awaiting) the one-shot sync makes this provider re-run
// once when the sync completes, picking up freshly cached packs. Local
// content loads immediately; the network never blocks this future.
ref.watch(seasonRefreshProvider);
return ref.read(contentRepositoryProvider).availableSeasons();
});
/// 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.
/// (availableSeasons is sorted by seasonId ascending.)
SeasonPack activeSeason(List<SeasonPack> seasons) => seasons.last;
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
StreakNotifier.new,
);
@@ -56,6 +71,10 @@ final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
EndlessBestNotifier.new,
);
final analyticsProvider = Provider<AnalyticsService>(
(ref) => AnalyticsService(DebugAnalyticsBackend()),
);
/// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) {
+4
View File
@@ -23,6 +23,10 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
void startSeasonStage(SeasonPack pack, int index) {
state = SeasonFlow(pack: pack, index: index);
ref.read(gameSessionProvider.notifier).startStage(pack.stages[index]);
ref.read(analyticsProvider).stageStart(
seasonId: pack.seasonId,
stageId: pack.stages[index].id,
);
}
Future<void> recordWin({required int stars, required int score}) async {
+4 -3
View File
@@ -25,16 +25,17 @@ class TutorialNotifier extends Notifier<TutorialStep?> {
Future<void> dismissHud() async {
if (state != TutorialStep.explainHud) return;
await _finish();
await _finish(skipped: false);
}
Future<void> skip() async {
if (state == null) return;
await _finish();
await _finish(skipped: true);
}
Future<void> _finish() async {
Future<void> _finish({required bool skipped}) async {
state = null;
ref.read(analyticsProvider).tutorialFinished(skipped: skipped);
await ref.read(saveRepositoryProvider).markTutorialDone();
}
}
+61 -16
View File
@@ -160,6 +160,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
ref
.read(seasonFlowProvider.notifier)
.recordWin(stars: next.starsEarned, score: next.score);
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,
);
}
}
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
@@ -171,8 +182,22 @@ class _GameScreenState extends ConsumerState<GameScreen>
if (next.phase == GamePhase.lost && next.endless) {
ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) {
if (mounted) setState(() => _endlessNewBest = isNew);
ref.read(analyticsProvider).endlessEnd(score: next.score, isNewBest: isNew);
});
}
if (next.phase == GamePhase.lost && !next.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,
);
}
}
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
}
@@ -366,27 +391,47 @@ class _GameScreenState extends ConsumerState<GameScreen>
(GamePhase.stuck, StuckReason.outOfMoves) => (
l10n.outOfMoves,
[
FilledButton(
onPressed: notifier.addExtraMoves,
child: Text(l10n.plusFiveMoves),
),
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
if (!view.rescueUsed)
FilledButton(
onPressed: () {
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
notifier.addExtraMoves();
},
child: Text(l10n.plusFiveMoves),
),
if (view.rescueUsed)
FilledButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
)
else
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
],
),
(GamePhase.stuck, _) => (
l10n.boardFull,
[
FilledButton(
onPressed: notifier.useContinue,
child: Text(l10n.watchAdContinue),
),
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
if (!view.rescueUsed)
FilledButton(
onPressed: () {
ref.read(analyticsProvider).rescueUsed(type: 'continue');
notifier.useContinue();
},
child: Text(l10n.watchAdContinue),
),
if (view.rescueUsed)
FilledButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
)
else
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
],
),
(GamePhase.lost, _) when view.endless => (
+1
View File
@@ -85,6 +85,7 @@ class HomeScreen extends ConsumerWidget {
onPressed: () {
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
ref.read(seasonFlowProvider.notifier).clear();
ref.read(analyticsProvider).endlessStart();
ref.read(gameSessionProvider.notifier).startStage(
StageConfig.endless(
seed: DateTime.now().millisecondsSinceEpoch,
+3 -1
View File
@@ -21,7 +21,9 @@ class SeasonMapScreen extends ConsumerWidget {
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
data: (list) => _JourneyMap(pack: list.first),
data: (list) => list.isEmpty
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: _JourneyMap(pack: activeSeason(list)),
);
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ class _SeasonTitleScreenState extends ConsumerState<SeasonTitleScreen> {
_auto?.cancel();
_auto = Timer(const Duration(milliseconds: 1600), _go);
}
final pack = list.first;
final pack = activeSeason(list);
final locale = Localizations.localeOf(context).languageCode;
final number = int.tryParse(pack.seasonId.split('_').last) ?? 1;
return GestureDetector(
+3 -3
View File
@@ -146,7 +146,7 @@ packages:
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -246,7 +246,7 @@ packages:
source: hosted
version: "2.1.3"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -390,7 +390,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+3
View File
@@ -39,6 +39,9 @@ dependencies:
audioplayers: ^6.7.1
flutter_localizations:
sdk: flutter
http: ^1.6.0
crypto: ^3.0.7
path_provider: ^2.1.5
dev_dependencies:
flutter_test:
+75
View File
@@ -1,4 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:block_seasons/data/content_repository.dart';
import 'package:block_seasons/game/models/season.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@@ -25,4 +29,75 @@ void main() {
final seasons = await repo.availableSeasons();
expect(seasons.map((s) => s.seasonId), contains('season_001'));
});
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));
});
});
}
@@ -0,0 +1,179 @@
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 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);
});
test('malicious seasonId never escapes the cache dir', () async {
final outside = Directory('${tmp.path}/../bs_escape_probe');
addTearDown(() async {
if (await outside.exists()) await outside.delete(recursive: true);
});
final client = MockClient((request) async {
if (request.url.path.endsWith('manifest.json')) {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'minAppBuild': 1,
'current': 'x',
'seasons': [
{
'seasonId': '../bs_escape_probe',
'version': 1,
'packUrl': 'seasons/x/pack.json',
'sha256': shaOf(packBody),
},
],
}),
200,
);
}
return http.Response(packBody, 200);
});
final downloader = ContentDownloader(
baseUrl: 'https://example.com/content',
cacheDir: tmp,
client: client,
);
expect(await downloader.sync(), isFalse);
expect(await outside.exists(), isFalse);
});
test('corrupt cache index is treated as empty and re-downloads', () async {
await File('${tmp.path}/cache_index.json').create(recursive: true);
await File('${tmp.path}/cache_index.json').writeAsString('{broken');
final downloader = ContentDownloader(
baseUrl: 'https://example.com/content',
cacheDir: tmp,
client: okClient(),
);
expect(await downloader.sync(), isTrue);
expect(downloader.cachedVersion('season_002'), 1);
});
}
+50
View File
@@ -0,0 +1,50 @@
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');
});
}
+15
View File
@@ -193,6 +193,21 @@ void main() {
});
});
group('rescueUsed getter', () {
test('rescueUsed flag is exposed and flips after a rescue', () {
final engine = GameEngine(
_stage(moveLimit: 1),
generator: _smallPool(1),
);
expect(engine.rescueUsed, isFalse);
engine.tryPlace(0, 0, 0);
expect(engine.phase, GamePhase.stuck);
expect(engine.rescueUsed, isFalse);
engine.addExtraMoves();
expect(engine.rescueUsed, isTrue);
});
});
group('dead board and continue', () {
StageConfig deadStage() {
// Checkerboard: only monos fit, and the injected pool has none small
+50
View File
@@ -0,0 +1,50 @@
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});
});
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:block_seasons/data/content_repository.dart';
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/game/models/stage.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;
}
}
SeasonPack _pack(String id) => SeasonPack(
schemaVersion: 1,
seasonId: id,
version: 1,
title: const {'en': 'Test Season', 'ko': '테스트 시즌'},
theme: SeasonTheme.fallback,
stages: [
StageConfig(
id: 's1',
seed: 1,
moveLimit: 10,
preset: const [],
objectives: const [],
stars: const StarThresholds(twoMovesLeft: 2, threeMovesLeft: 4),
generatorProfile: 'mid',
),
],
);
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);
});
test('activeSeason picks the newest by id', () {
final p1 = _pack('season_001');
final p2 = _pack('season_002');
expect(activeSeason([p1, p2]).seasonId, 'season_002');
});
}
+41
View File
@@ -0,0 +1,41 @@
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).');
}