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:
@@ -48,3 +48,4 @@ app.*.map.json
|
||||
lib/l10n/gen/
|
||||
.superpowers/
|
||||
CLAUDE.md
|
||||
*.pid
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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).');
|
||||
}
|
||||
Reference in New Issue
Block a user