diff --git a/.gitignore b/.gitignore index 6b5629d..7f97c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ app.*.map.json lib/l10n/gen/ .superpowers/ CLAUDE.md +*.pid diff --git a/content/manifest.json b/content/manifest.json new file mode 100644 index 0000000..6779deb --- /dev/null +++ b/content/manifest.json @@ -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" + } + ] +} diff --git a/content/season_002/pack.json b/content/season_002/pack.json new file mode 100644 index 0000000..a0f1cf0 --- /dev/null +++ b/content/season_002/pack.json @@ -0,0 +1,2235 @@ +{ + "schemaVersion": 1, + "seasonId": "season_002", + "version": 1, + "title": { + "en": "Summer Tide", + "ko": "여름 파도" + }, + "theme": { + "tileSet": "summer", + "background": "", + "backgroundGradient": [ + 4278854704, + 4279253322, + 4280179302 + ], + "accentColor": 4285517301, + "particleType": "petals" + }, + "stages": [ + { + "id": "season_002_001", + "seed": 1024716960, + "moveLimit": 7, + "preset": [ + { + "x": 0, + "y": 2, + "t": "gem" + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 1 + } + ], + "stars": { + "two": { + "movesLeft": 3 + }, + "three": { + "movesLeft": 4 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_002", + "seed": 144388466, + "moveLimit": 8, + "preset": [ + { + "x": 7, + "y": 5, + "t": "gem" + }, + { + "x": 1, + "y": 3, + "t": "filled", + "c": 3 + }, + { + "x": 5, + "y": 4, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 1 + } + ], + "stars": { + "two": { + "movesLeft": 4 + }, + "three": { + "movesLeft": 5 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_003", + "seed": 679292503, + "moveLimit": 11, + "preset": [ + { + "x": 4, + "y": 1, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "gem" + }, + { + "x": 6, + "y": 6, + "t": "filled", + "c": 2 + }, + { + "x": 1, + "y": 6, + "t": "filled", + "c": 4 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 2 + } + ], + "stars": { + "two": { + "movesLeft": 4 + }, + "three": { + "movesLeft": 5 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_004", + "seed": 517040954, + "moveLimit": 12, + "preset": [ + { + "x": 5, + "y": 3, + "t": "gem" + }, + { + "x": 6, + "y": 5, + "t": "gem" + }, + { + "x": 0, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 7, + "y": 0, + "t": "filled", + "c": 1 + }, + { + "x": 4, + "y": 0, + "t": "filled", + "c": 1 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 2 + } + ], + "stars": { + "two": { + "movesLeft": 5 + }, + "three": { + "movesLeft": 6 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_005", + "seed": 887462827, + "moveLimit": 25, + "preset": [ + { + "x": 3, + "y": 3, + "t": "gem" + }, + { + "x": 5, + "y": 4, + "t": "gem" + }, + { + "x": 2, + "y": 7, + "t": "gem" + }, + { + "x": 3, + "y": 2, + "t": "filled", + "c": 4 + }, + { + "x": 4, + "y": 6, + "t": "filled", + "c": 4 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 990 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 9 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_006", + "seed": 629021082, + "moveLimit": 24, + "preset": [ + { + "x": 4, + "y": 0, + "t": "gem" + }, + { + "x": 4, + "y": 2, + "t": "gem" + }, + { + "x": 0, + "y": 7, + "t": "gem" + }, + { + "x": 7, + "y": 6, + "t": "filled", + "c": 3 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 1 + }, + { + "x": 7, + "y": 1, + "t": "filled", + "c": 5 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 3 + } + ], + "stars": { + "two": { + "movesLeft": 11 + }, + "three": { + "movesLeft": 17 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_007", + "seed": 306710347, + "moveLimit": 23, + "preset": [ + { + "x": 2, + "y": 7, + "t": "gem" + }, + { + "x": 7, + "y": 0, + "t": "gem" + }, + { + "x": 4, + "y": 6, + "t": "gem" + }, + { + "x": 1, + "y": 2, + "t": "filled", + "c": 0 + } + ], + "objectives": [ + { + "type": "clearLines", + "count": 5 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 8 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_008", + "seed": 864645209, + "moveLimit": 6, + "preset": [ + { + "x": 6, + "y": 4, + "t": "gem" + }, + { + "x": 7, + "y": 4, + "t": "gem" + }, + { + "x": 2, + "y": 6, + "t": "filled", + "c": 7 + }, + { + "x": 3, + "y": 5, + "t": "filled", + "c": 5 + }, + { + "x": 4, + "y": 4, + "t": "filled", + "c": 0 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 2 + } + ], + "stars": { + "two": { + "movesLeft": 2 + }, + "three": { + "movesLeft": 3 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_009", + "seed": 132156210, + "moveLimit": 14, + "preset": [ + { + "x": 2, + "y": 6, + "t": "gem" + }, + { + "x": 7, + "y": 6, + "t": "gem" + }, + { + "x": 2, + "y": 7, + "t": "gem" + }, + { + "x": 3, + "y": 6, + "t": "gem" + }, + { + "x": 6, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 4, + "y": 1, + "t": "filled", + "c": 0 + }, + { + "x": 5, + "y": 3, + "t": "filled", + "c": 7 + }, + { + "x": 4, + "y": 4, + "t": "filled", + "c": 1 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 4 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 7 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_010", + "seed": 537285571, + "moveLimit": 31, + "preset": [ + { + "x": 3, + "y": 4, + "t": "gem" + }, + { + "x": 0, + "y": 3, + "t": "gem" + }, + { + "x": 5, + "y": 2, + "t": "gem" + }, + { + "x": 0, + "y": 5, + "t": "gem" + }, + { + "x": 6, + "y": 2, + "t": "filled", + "c": 6 + }, + { + "x": 2, + "y": 1, + "t": "filled", + "c": 5 + }, + { + "x": 6, + "y": 5, + "t": "filled", + "c": 7 + }, + { + "x": 6, + "y": 0, + "t": "filled", + "c": 6 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 1476 + } + ], + "stars": { + "two": { + "movesLeft": 9 + }, + "three": { + "movesLeft": 11 + } + }, + "generatorProfile": "easy" + }, + { + "id": "season_002_011", + "seed": 71136992, + "moveLimit": 16, + "preset": [ + { + "x": 2, + "y": 6, + "t": "gem" + }, + { + "x": 5, + "y": 1, + "t": "gem" + }, + { + "x": 2, + "y": 7, + "t": "gem" + }, + { + "x": 5, + "y": 6, + "t": "gem" + }, + { + "x": 4, + "y": 6, + "t": "filled", + "c": 6 + }, + { + "x": 0, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 2, + "y": 4, + "t": "filled", + "c": 6 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 4 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 9 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_012", + "seed": 382813599, + "moveLimit": 15, + "preset": [ + { + "x": 6, + "y": 7, + "t": "gem" + }, + { + "x": 7, + "y": 5, + "t": "gem" + }, + { + "x": 4, + "y": 5, + "t": "gem" + }, + { + "x": 7, + "y": 4, + "t": "gem" + }, + { + "x": 5, + "y": 7, + "t": "filled", + "c": 3 + }, + { + "x": 0, + "y": 7, + "t": "filled", + "c": 3 + }, + { + "x": 2, + "y": 5, + "t": "filled", + "c": 1 + }, + { + "x": 5, + "y": 5, + "t": "filled", + "c": 3 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 4 + } + ], + "stars": { + "two": { + "movesLeft": 5 + }, + "three": { + "movesLeft": 7 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_013", + "seed": 164663924, + "moveLimit": 20, + "preset": [ + { + "x": 6, + "y": 6, + "t": "gem" + }, + { + "x": 5, + "y": 1, + "t": "gem" + }, + { + "x": 6, + "y": 7, + "t": "gem" + }, + { + "x": 0, + "y": 3, + "t": "gem" + }, + { + "x": 7, + "y": 4, + "t": "filled", + "c": 5 + }, + { + "x": 7, + "y": 6, + "t": "filled", + "c": 4 + }, + { + "x": 6, + "y": 5, + "t": "filled", + "c": 3 + }, + { + "x": 3, + "y": 4, + "t": "filled", + "c": 3 + }, + { + "x": 7, + "y": 2, + "t": "filled", + "c": 4 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 4 + } + ], + "stars": { + "two": { + "movesLeft": 9 + }, + "three": { + "movesLeft": 11 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_014", + "seed": 625450574, + "moveLimit": 27, + "preset": [ + { + "x": 2, + "y": 4, + "t": "gem" + }, + { + "x": 5, + "y": 2, + "t": "gem" + }, + { + "x": 5, + "y": 6, + "t": "gem" + }, + { + "x": 5, + "y": 1, + "t": "gem" + }, + { + "x": 0, + "y": 5, + "t": "filled", + "c": 7 + }, + { + "x": 7, + "y": 7, + "t": "filled", + "c": 1 + }, + { + "x": 6, + "y": 7, + "t": "filled", + "c": 5 + }, + { + "x": 4, + "y": 7, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearLines", + "count": 7 + } + ], + "stars": { + "two": { + "movesLeft": 5 + }, + "three": { + "movesLeft": 7 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_015", + "seed": 740124347, + "moveLimit": 30, + "preset": [ + { + "x": 4, + "y": 5, + "t": "gem" + }, + { + "x": 7, + "y": 3, + "t": "gem" + }, + { + "x": 2, + "y": 3, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "gem" + }, + { + "x": 7, + "y": 4, + "t": "gem" + }, + { + "x": 3, + "y": 6, + "t": "filled", + "c": 0 + }, + { + "x": 0, + "y": 0, + "t": "filled", + "c": 6 + }, + { + "x": 1, + "y": 6, + "t": "filled", + "c": 7 + }, + { + "x": 6, + "y": 6, + "t": "filled", + "c": 7 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 1766 + } + ], + "stars": { + "two": { + "movesLeft": 4 + }, + "three": { + "movesLeft": 8 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_016", + "seed": 61542891, + "moveLimit": 10, + "preset": [ + { + "x": 4, + "y": 5, + "t": "gem" + }, + { + "x": 5, + "y": 7, + "t": "gem" + }, + { + "x": 5, + "y": 1, + "t": "gem" + }, + { + "x": 5, + "y": 0, + "t": "gem" + }, + { + "x": 5, + "y": 6, + "t": "gem" + }, + { + "x": 2, + "y": 7, + "t": "filled", + "c": 7 + }, + { + "x": 3, + "y": 6, + "t": "filled", + "c": 1 + }, + { + "x": 1, + "y": 7, + "t": "filled", + "c": 0 + }, + { + "x": 6, + "y": 1, + "t": "filled", + "c": 5 + }, + { + "x": 3, + "y": 1, + "t": "filled", + "c": 3 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 5 + } + ], + "stars": { + "two": { + "movesLeft": 3 + }, + "three": { + "movesLeft": 5 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_017", + "seed": 442657612, + "moveLimit": 21, + "preset": [ + { + "x": 0, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "gem" + }, + { + "x": 7, + "y": 0, + "t": "gem" + }, + { + "x": 5, + "y": 3, + "t": "gem" + }, + { + "x": 2, + "y": 4, + "t": "gem" + }, + { + "x": 5, + "y": 6, + "t": "filled", + "c": 2 + }, + { + "x": 7, + "y": 1, + "t": "filled", + "c": 6 + }, + { + "x": 2, + "y": 1, + "t": "filled", + "c": 7 + }, + { + "x": 4, + "y": 3, + "t": "filled", + "c": 1 + }, + { + "x": 1, + "y": 7, + "t": "filled", + "c": 6 + }, + { + "x": 7, + "y": 7, + "t": "filled", + "c": 5 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 5 + } + ], + "stars": { + "two": { + "movesLeft": 7 + }, + "three": { + "movesLeft": 10 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_018", + "seed": 559709856, + "moveLimit": 27, + "preset": [ + { + "x": 1, + "y": 0, + "t": "gem" + }, + { + "x": 1, + "y": 5, + "t": "gem" + }, + { + "x": 6, + "y": 6, + "t": "gem" + }, + { + "x": 5, + "y": 5, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 1, + "t": "gem" + }, + { + "x": 7, + "y": 0, + "t": "filled", + "c": 7 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 5 + }, + { + "x": 1, + "y": 2, + "t": "filled", + "c": 0 + }, + { + "x": 5, + "y": 1, + "t": "filled", + "c": 1 + }, + { + "x": 1, + "y": 6, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 6 + } + ], + "stars": { + "two": { + "movesLeft": 9 + }, + "three": { + "movesLeft": 13 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_019", + "seed": 12667865, + "moveLimit": 20, + "preset": [ + { + "x": 3, + "y": 5, + "t": "gem" + }, + { + "x": 1, + "y": 1, + "t": "gem" + }, + { + "x": 7, + "y": 3, + "t": "gem" + }, + { + "x": 3, + "y": 6, + "t": "gem" + }, + { + "x": 3, + "y": 0, + "t": "gem" + }, + { + "x": 2, + "y": 0, + "t": "gem" + }, + { + "x": 6, + "y": 0, + "t": "filled", + "c": 3 + }, + { + "x": 2, + "y": 5, + "t": "filled", + "c": 2 + }, + { + "x": 6, + "y": 2, + "t": "filled", + "c": 3 + }, + { + "x": 4, + "y": 7, + "t": "filled", + "c": 1 + }, + { + "x": 4, + "y": 5, + "t": "filled", + "c": 6 + }, + { + "x": 5, + "y": 7, + "t": "filled", + "c": 6 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 6 + } + ], + "stars": { + "two": { + "movesLeft": 4 + }, + "three": { + "movesLeft": 7 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_020", + "seed": 656616749, + "moveLimit": 33, + "preset": [ + { + "x": 1, + "y": 1, + "t": "gem" + }, + { + "x": 3, + "y": 5, + "t": "gem" + }, + { + "x": 4, + "y": 3, + "t": "gem" + }, + { + "x": 7, + "y": 2, + "t": "gem" + }, + { + "x": 0, + "y": 5, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "filled", + "c": 2 + }, + { + "x": 1, + "y": 3, + "t": "filled", + "c": 2 + }, + { + "x": 3, + "y": 3, + "t": "filled", + "c": 4 + }, + { + "x": 0, + "y": 2, + "t": "filled", + "c": 3 + }, + { + "x": 5, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 2, + "y": 5, + "t": "filled", + "c": 1 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 2185 + } + ], + "stars": { + "two": { + "movesLeft": 5 + }, + "three": { + "movesLeft": 8 + } + }, + "generatorProfile": "mid" + }, + { + "id": "season_002_021", + "seed": 861387935, + "moveLimit": 23, + "preset": [ + { + "x": 0, + "y": 7, + "t": "gem" + }, + { + "x": 5, + "y": 3, + "t": "gem" + }, + { + "x": 6, + "y": 0, + "t": "gem" + }, + { + "x": 5, + "y": 4, + "t": "gem" + }, + { + "x": 1, + "y": 2, + "t": "gem" + }, + { + "x": 4, + "y": 5, + "t": "filled", + "c": 7 + }, + { + "x": 6, + "y": 7, + "t": "filled", + "c": 7 + }, + { + "x": 6, + "y": 4, + "t": "filled", + "c": 6 + }, + { + "x": 6, + "y": 6, + "t": "filled", + "c": 6 + }, + { + "x": 2, + "y": 5, + "t": "filled", + "c": 7 + } + ], + "objectives": [ + { + "type": "clearLines", + "count": 8 + } + ], + "stars": { + "two": { + "movesLeft": 1 + }, + "three": { + "movesLeft": 3 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_022", + "seed": 393356148, + "moveLimit": 28, + "preset": [ + { + "x": 4, + "y": 0, + "t": "gem" + }, + { + "x": 7, + "y": 5, + "t": "gem" + }, + { + "x": 1, + "y": 7, + "t": "gem" + }, + { + "x": 2, + "y": 3, + "t": "gem" + }, + { + "x": 0, + "y": 2, + "t": "gem" + }, + { + "x": 6, + "y": 4, + "t": "filled", + "c": 1 + }, + { + "x": 6, + "y": 3, + "t": "filled", + "c": 6 + }, + { + "x": 3, + "y": 2, + "t": "filled", + "c": 2 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 7 + }, + { + "x": 3, + "y": 7, + "t": "filled", + "c": 0 + }, + { + "x": 3, + "y": 5, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 5 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 12 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_023", + "seed": 295173126, + "moveLimit": 26, + "preset": [ + { + "x": 3, + "y": 0, + "t": "gem" + }, + { + "x": 3, + "y": 3, + "t": "gem" + }, + { + "x": 4, + "y": 4, + "t": "gem" + }, + { + "x": 6, + "y": 5, + "t": "gem" + }, + { + "x": 6, + "y": 0, + "t": "gem" + }, + { + "x": 1, + "y": 7, + "t": "gem" + }, + { + "x": 2, + "y": 1, + "t": "gem" + }, + { + "x": 4, + "y": 0, + "t": "filled", + "c": 1 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 7 + }, + { + "x": 5, + "y": 6, + "t": "filled", + "c": 3 + }, + { + "x": 2, + "y": 2, + "t": "filled", + "c": 4 + }, + { + "x": 6, + "y": 2, + "t": "filled", + "c": 7 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 7 + } + ], + "stars": { + "two": { + "movesLeft": 6 + }, + "three": { + "movesLeft": 10 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_024", + "seed": 796224392, + "moveLimit": 21, + "preset": [ + { + "x": 6, + "y": 3, + "t": "gem" + }, + { + "x": 3, + "y": 6, + "t": "gem" + }, + { + "x": 0, + "y": 6, + "t": "gem" + }, + { + "x": 2, + "y": 7, + "t": "gem" + }, + { + "x": 6, + "y": 0, + "t": "gem" + }, + { + "x": 3, + "y": 3, + "t": "gem" + }, + { + "x": 6, + "y": 4, + "t": "gem" + }, + { + "x": 4, + "y": 0, + "t": "filled", + "c": 5 + }, + { + "x": 0, + "y": 0, + "t": "filled", + "c": 2 + }, + { + "x": 3, + "y": 4, + "t": "filled", + "c": 1 + }, + { + "x": 2, + "y": 0, + "t": "filled", + "c": 1 + }, + { + "x": 5, + "y": 4, + "t": "filled", + "c": 3 + }, + { + "x": 5, + "y": 2, + "t": "filled", + "c": 2 + }, + { + "x": 6, + "y": 7, + "t": "filled", + "c": 4 + }, + { + "x": 7, + "y": 4, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 7 + } + ], + "stars": { + "two": { + "movesLeft": 5 + }, + "three": { + "movesLeft": 10 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_025", + "seed": 543476835, + "moveLimit": 39, + "preset": [ + { + "x": 1, + "y": 2, + "t": "gem" + }, + { + "x": 4, + "y": 3, + "t": "gem" + }, + { + "x": 3, + "y": 5, + "t": "gem" + }, + { + "x": 6, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 0, + "t": "gem" + }, + { + "x": 5, + "y": 4, + "t": "gem" + }, + { + "x": 6, + "y": 7, + "t": "gem" + }, + { + "x": 2, + "y": 6, + "t": "filled", + "c": 6 + }, + { + "x": 4, + "y": 6, + "t": "filled", + "c": 4 + }, + { + "x": 2, + "y": 3, + "t": "filled", + "c": 2 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 0 + }, + { + "x": 1, + "y": 5, + "t": "filled", + "c": 2 + }, + { + "x": 5, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 2, + "y": 4, + "t": "filled", + "c": 6 + }, + { + "x": 1, + "y": 6, + "t": "filled", + "c": 0 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 2692 + } + ], + "stars": { + "two": { + "movesLeft": 7 + }, + "three": { + "movesLeft": 11 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_026", + "seed": 834421452, + "moveLimit": 31, + "preset": [ + { + "x": 7, + "y": 3, + "t": "gem" + }, + { + "x": 5, + "y": 6, + "t": "gem" + }, + { + "x": 3, + "y": 7, + "t": "gem" + }, + { + "x": 4, + "y": 4, + "t": "gem" + }, + { + "x": 0, + "y": 4, + "t": "gem" + }, + { + "x": 7, + "y": 0, + "t": "gem" + }, + { + "x": 6, + "y": 7, + "t": "filled", + "c": 3 + }, + { + "x": 2, + "y": 3, + "t": "filled", + "c": 0 + }, + { + "x": 7, + "y": 4, + "t": "filled", + "c": 2 + }, + { + "x": 6, + "y": 1, + "t": "filled", + "c": 3 + }, + { + "x": 3, + "y": 0, + "t": "filled", + "c": 5 + }, + { + "x": 3, + "y": 6, + "t": "filled", + "c": 6 + }, + { + "x": 6, + "y": 5, + "t": "filled", + "c": 6 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 2 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 6 + } + ], + "stars": { + "two": { + "movesLeft": 9 + }, + "three": { + "movesLeft": 15 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_027", + "seed": 59263045, + "moveLimit": 13, + "preset": [ + { + "x": 2, + "y": 3, + "t": "gem" + }, + { + "x": 2, + "y": 6, + "t": "gem" + }, + { + "x": 2, + "y": 1, + "t": "gem" + }, + { + "x": 5, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 5, + "t": "gem" + }, + { + "x": 3, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "filled", + "c": 3 + }, + { + "x": 0, + "y": 5, + "t": "filled", + "c": 2 + }, + { + "x": 4, + "y": 6, + "t": "filled", + "c": 6 + }, + { + "x": 3, + "y": 6, + "t": "filled", + "c": 7 + }, + { + "x": 4, + "y": 7, + "t": "filled", + "c": 2 + }, + { + "x": 1, + "y": 0, + "t": "filled", + "c": 4 + }, + { + "x": 0, + "y": 0, + "t": "filled", + "c": 5 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 6 + } + ], + "stars": { + "two": { + "movesLeft": 2 + }, + "three": { + "movesLeft": 5 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_028", + "seed": 521249389, + "moveLimit": 27, + "preset": [ + { + "x": 0, + "y": 0, + "t": "gem" + }, + { + "x": 1, + "y": 2, + "t": "gem" + }, + { + "x": 5, + "y": 5, + "t": "gem" + }, + { + "x": 0, + "y": 7, + "t": "gem" + }, + { + "x": 4, + "y": 3, + "t": "gem" + }, + { + "x": 0, + "y": 3, + "t": "gem" + }, + { + "x": 3, + "y": 4, + "t": "gem" + }, + { + "x": 6, + "y": 2, + "t": "gem" + }, + { + "x": 4, + "y": 2, + "t": "filled", + "c": 4 + }, + { + "x": 0, + "y": 1, + "t": "filled", + "c": 6 + }, + { + "x": 7, + "y": 2, + "t": "filled", + "c": 3 + }, + { + "x": 5, + "y": 2, + "t": "filled", + "c": 5 + }, + { + "x": 4, + "y": 7, + "t": "filled", + "c": 0 + }, + { + "x": 3, + "y": 5, + "t": "filled", + "c": 1 + }, + { + "x": 4, + "y": 5, + "t": "filled", + "c": 1 + }, + { + "x": 6, + "y": 4, + "t": "filled", + "c": 5 + } + ], + "objectives": [ + { + "type": "clearLines", + "count": 10 + } + ], + "stars": { + "two": { + "movesLeft": 2 + }, + "three": { + "movesLeft": 4 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_029", + "seed": 809199245, + "moveLimit": 27, + "preset": [ + { + "x": 3, + "y": 2, + "t": "gem" + }, + { + "x": 7, + "y": 7, + "t": "gem" + }, + { + "x": 1, + "y": 3, + "t": "gem" + }, + { + "x": 7, + "y": 5, + "t": "gem" + }, + { + "x": 1, + "y": 4, + "t": "gem" + }, + { + "x": 5, + "y": 7, + "t": "gem" + }, + { + "x": 0, + "y": 4, + "t": "gem" + }, + { + "x": 3, + "y": 1, + "t": "filled", + "c": 7 + }, + { + "x": 3, + "y": 4, + "t": "filled", + "c": 6 + }, + { + "x": 0, + "y": 7, + "t": "filled", + "c": 4 + }, + { + "x": 4, + "y": 6, + "t": "filled", + "c": 5 + }, + { + "x": 3, + "y": 3, + "t": "filled", + "c": 5 + }, + { + "x": 6, + "y": 3, + "t": "filled", + "c": 4 + }, + { + "x": 5, + "y": 2, + "t": "filled", + "c": 4 + }, + { + "x": 2, + "y": 1, + "t": "filled", + "c": 4 + } + ], + "objectives": [ + { + "type": "clearGems", + "count": 7 + } + ], + "stars": { + "two": { + "movesLeft": 8 + }, + "three": { + "movesLeft": 10 + } + }, + "generatorProfile": "hard" + }, + { + "id": "season_002_030", + "seed": 729553183, + "moveLimit": 37, + "preset": [ + { + "x": 4, + "y": 5, + "t": "gem" + }, + { + "x": 7, + "y": 3, + "t": "gem" + }, + { + "x": 4, + "y": 6, + "t": "gem" + }, + { + "x": 2, + "y": 2, + "t": "gem" + }, + { + "x": 3, + "y": 2, + "t": "gem" + }, + { + "x": 2, + "y": 0, + "t": "gem" + }, + { + "x": 3, + "y": 4, + "t": "gem" + }, + { + "x": 2, + "y": 3, + "t": "gem" + }, + { + "x": 5, + "y": 7, + "t": "filled", + "c": 7 + }, + { + "x": 5, + "y": 2, + "t": "filled", + "c": 3 + }, + { + "x": 5, + "y": 1, + "t": "filled", + "c": 1 + }, + { + "x": 3, + "y": 3, + "t": "filled", + "c": 1 + }, + { + "x": 2, + "y": 1, + "t": "filled", + "c": 4 + }, + { + "x": 0, + "y": 0, + "t": "filled", + "c": 5 + }, + { + "x": 3, + "y": 7, + "t": "filled", + "c": 3 + }, + { + "x": 1, + "y": 1, + "t": "filled", + "c": 4 + } + ], + "objectives": [ + { + "type": "reachScore", + "target": 3006 + } + ], + "stars": { + "two": { + "movesLeft": 2 + }, + "three": { + "movesLeft": 6 + } + }, + "generatorProfile": "hard" + } + ] +} \ No newline at end of file diff --git a/content/season_002/report.md b/content/season_002/report.md new file mode 100644 index 0000000..e55e190 --- /dev/null +++ b/content/season_002/report.md @@ -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 | diff --git a/content/season_002/spec.json b/content/season_002/spec.json new file mode 100644 index 0000000..414ffe6 --- /dev/null +++ b/content/season_002/spec.json @@ -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 +} diff --git a/docs/firebase-hosting-guide.md b/docs/firebase-hosting-guide.md new file mode 100644 index 0000000..96fb5f5 --- /dev/null +++ b/docs/firebase-hosting-guide.md @@ -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 체크리스트). diff --git a/docs/screenshots/sim_offline_fallback.png b/docs/screenshots/sim_offline_fallback.png new file mode 100644 index 0000000..3cc5e1a Binary files /dev/null and b/docs/screenshots/sim_offline_fallback.png differ diff --git a/docs/screenshots/sim_remote_season2.png b/docs/screenshots/sim_remote_season2.png new file mode 100644 index 0000000..e191b40 Binary files /dev/null and b/docs/screenshots/sim_remote_season2.png differ diff --git a/lib/data/content_repository.dart b/lib/data/content_repository.dart index c5ec6f3..76e2848 100644 --- a/lib/data/content_repository.dart +++ b/lib/data/content_repository.dart @@ -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 loadBundledSeason(String seasonId) async { final raw = await rootBundle.loadString('assets/seasons/$seasonId/pack.json'); return SeasonPack.fromJson(jsonDecode(raw) as Map); } - Future> availableSeasons() async => [ - for (final id in bundledSeasonIds) await loadBundledSeason(id), - ]; + Future _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); + } 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> availableSeasons() async { + final byId = {}; + 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 refresh() async { + final downloader = _downloader; + if (downloader == null || _syncedThisSession) return false; + _syncedThisSession = true; + return downloader.sync(); + } } diff --git a/lib/data/remote/content_downloader.dart b/lib/data/remote/content_downloader.dart new file mode 100644 index 0000000..e271c8c --- /dev/null +++ b/lib/data/remote/content_downloader.dart @@ -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 _index = {}; + + File get _indexFile => File('${cacheDir.path}/cache_index.json'); + + void _loadIndex() { + try { + if (_indexFile.existsSync()) { + final json = + jsonDecode(_indexFile.readAsStringSync()) as Map; + json.forEach((k, v) => _index[k] = v as int); + } + } catch (_) { + // Corrupt index: treat as empty; packs will re-download. + _index.clear(); + } + } + + Future _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 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); + } 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 _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; + } + } +} diff --git a/lib/data/remote/manifest.dart b/lib/data/remote/manifest.dart new file mode 100644 index 0000000..dbb25c2 --- /dev/null +++ b/lib/data/remote/manifest.dart @@ -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 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), + ]; + 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 seasons; + + Map 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 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 toJson() => { + 'seasonId': seasonId, + 'version': version, + 'packUrl': packUrl, + 'sha256': sha256, + }; +} diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart index 83f10d3..d467094 100644 --- a/lib/game/engine/game_engine.dart +++ b/lib/game/engine/game_engine.dart @@ -82,6 +82,7 @@ class GameEngine { List 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) diff --git a/lib/main.dart b/lib/main.dart index dfa6cdf..d4e69f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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(), + )); } diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart new file mode 100644 index 0000000..5903454 --- /dev/null +++ b/lib/services/analytics_service.dart @@ -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 params); +} + +class DebugAnalyticsBackend implements AnalyticsBackend { + @override + void logEvent(String name, Map 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}); + } +} diff --git a/lib/state/game_session_notifier.dart b/lib/state/game_session_notifier.dart index b0d8858..1823578 100644 --- a/lib/state/game_session_notifier.dart +++ b/lib/state/game_session_notifier.dart @@ -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 { 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 { objectiveProgress: engine.objectiveProgress, lastPlacement: lastPlacement, fxTick: _fxTick, + rescueUsed: engine.rescueUsed, ); } } diff --git a/lib/state/providers.dart b/lib/state/providers.dart index c7eaeac..34d1f52 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -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( final contentRepositoryProvider = Provider((ref) => ContentRepository()); -final seasonsProvider = FutureProvider>( - (ref) => ref.read(contentRepositoryProvider).availableSeasons(), +final seasonsProvider = FutureProvider>((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( + (ref) => ref.read(contentRepositoryProvider).refresh(), ); +/// The season players land in by default: the newest available. +/// (availableSeasons is sorted by seasonId ascending.) +SeasonPack activeSeason(List seasons) => seasons.last; + final streakProvider = NotifierProvider( StreakNotifier.new, ); @@ -56,6 +71,10 @@ final endlessBestProvider = NotifierProvider( EndlessBestNotifier.new, ); +final analyticsProvider = Provider( + (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((ref) { diff --git a/lib/state/season_flow_notifier.dart b/lib/state/season_flow_notifier.dart index eede482..1536565 100644 --- a/lib/state/season_flow_notifier.dart +++ b/lib/state/season_flow_notifier.dart @@ -23,6 +23,10 @@ class SeasonFlowNotifier extends Notifier { 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 recordWin({required int stars, required int score}) async { diff --git a/lib/state/tutorial_notifier.dart b/lib/state/tutorial_notifier.dart index f6cb2c0..3b7313b 100644 --- a/lib/state/tutorial_notifier.dart +++ b/lib/state/tutorial_notifier.dart @@ -25,16 +25,17 @@ class TutorialNotifier extends Notifier { Future dismissHud() async { if (state != TutorialStep.explainHud) return; - await _finish(); + await _finish(skipped: false); } Future skip() async { if (state == null) return; - await _finish(); + await _finish(skipped: true); } - Future _finish() async { + Future _finish({required bool skipped}) async { state = null; + ref.read(analyticsProvider).tutorialFinished(skipped: skipped); await ref.read(saveRepositoryProvider).markTutorialDone(); } } diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 695dbd9..8c58745 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -160,6 +160,17 @@ class _GameScreenState extends ConsumerState 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 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 (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 => ( diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 47a6863..aa85a2d 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -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, diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart index 4776bb0..8e02b49 100644 --- a/lib/ui/screens/season_map_screen.dart +++ b/lib/ui/screens/season_map_screen.dart @@ -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)), ); } } diff --git a/lib/ui/screens/season_title_screen.dart b/lib/ui/screens/season_title_screen.dart index 02b5d5a..45c0600 100644 --- a/lib/ui/screens/season_title_screen.dart +++ b/lib/ui/screens/season_title_screen.dart @@ -63,7 +63,7 @@ class _SeasonTitleScreenState extends ConsumerState { _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( diff --git a/pubspec.lock b/pubspec.lock index 3c4d887..cf6931a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index c1f5c9f..d120b03 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/data/content_repository_test.dart b/test/data/content_repository_test.dart index f49baa7..02df7a4 100644 --- a/test/data/content_repository_test.dart +++ b/test/data/content_repository_test.dart @@ -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 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 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)); + }); + }); } diff --git a/test/data/remote/content_downloader_test.dart b/test/data/remote/content_downloader_test.dart new file mode 100644 index 0000000..552606d --- /dev/null +++ b/test/data/remote/content_downloader_test.dart @@ -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 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() + .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); + }); +} diff --git a/test/data/remote/manifest_test.dart b/test/data/remote/manifest_test.dart new file mode 100644 index 0000000..1562d4b --- /dev/null +++ b/test/data/remote/manifest_test.dart @@ -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'); + }); +} diff --git a/test/game/engine/game_engine_test.dart b/test/game/engine/game_engine_test.dart index 88d507a..6784e31 100644 --- a/test/game/engine/game_engine_test.dart +++ b/test/game/engine/game_engine_test.dart @@ -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 diff --git a/test/services/analytics_service_test.dart b/test/services/analytics_service_test.dart new file mode 100644 index 0000000..58035bc --- /dev/null +++ b/test/services/analytics_service_test.dart @@ -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)>[]; + + @override + void logEvent(String name, Map 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}); + }); +} diff --git a/test/state/season_refresh_test.dart b/test/state/season_refresh_test.dart new file mode 100644 index 0000000..5c41b9a --- /dev/null +++ b/test/state/season_refresh_test.dart @@ -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 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'); + }); +} diff --git a/tool/make_manifest.dart b/tool/make_manifest.dart new file mode 100644 index 0000000..959e33e --- /dev/null +++ b/tool/make_manifest.dart @@ -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 = >[]; + + final dirs = contentDir + .listSync() + .whereType() + .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; + 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).'); +}