From 0210c148588546109170951ee729f5c791ff7ef2 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 13:05:55 +0900 Subject: [PATCH] Add pure-Dart engine core: RNG, grid, placement, line clear, scoring, piece generator PCG32 seeded RNG; immutable 8x8 GridState with occupancy bitmask; placement legality + anyPlacementExists; simultaneous row/col clears with single-count gem credit; combo scoring with one-move grace; weighted-bag generator with pity bias and depth-3 solvability nudge. All TDD, 51 tests green. Co-Authored-By: Claude Fable 5 --- ios/Podfile.lock | 37 +++ ios/Runner.xcodeproj/project.pbxproj | 112 ++++++++ .../contents.xcworkspacedata | 3 + lib/core/rng.dart | 48 ++++ lib/game/engine/line_clear.dart | 56 ++++ lib/game/engine/piece_generator.dart | 117 +++++++++ lib/game/engine/placement.dart | 41 +++ lib/game/engine/scoring.dart | 50 ++++ lib/game/models/cell.dart | 22 ++ lib/game/models/grid.dart | 63 +++++ lib/game/models/piece.dart | 27 ++ lib/game/models/piece_library.dart | 241 ++++++++++++++++++ test/core/rng_test.dart | 61 +++++ test/game/engine/line_clear_test.dart | 93 +++++++ test/game/engine/piece_generator_test.dart | 135 ++++++++++ test/game/engine/placement_test.dart | 83 ++++++ test/game/engine/scoring_test.dart | 92 +++++++ test/game/models/grid_test.dart | 67 +++++ test/game/models/piece_library_test.dart | 60 +++++ 19 files changed, 1408 insertions(+) create mode 100644 ios/Podfile.lock create mode 100644 lib/core/rng.dart create mode 100644 lib/game/engine/line_clear.dart create mode 100644 lib/game/engine/piece_generator.dart create mode 100644 lib/game/engine/placement.dart create mode 100644 lib/game/engine/scoring.dart create mode 100644 lib/game/models/cell.dart create mode 100644 lib/game/models/grid.dart create mode 100644 lib/game/models/piece.dart create mode 100644 lib/game/models/piece_library.dart create mode 100644 test/core/rng_test.dart create mode 100644 test/game/engine/line_clear_test.dart create mode 100644 test/game/engine/piece_generator_test.dart create mode 100644 test/game/engine/placement_test.dart create mode 100644 test/game/engine/scoring_test.dart create mode 100644 test/game/models/grid_test.dart create mode 100644 test/game/models/piece_library_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..a00a117 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,37 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 748bd1b..d7b1962 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A1624C49AABB61D3BB6EBA00 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */; }; + D444497F007A61A9102D174D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,9 +47,15 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 55914DA7E8E89CB02E73C3F5 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 6BD9A45428DD4E519FC38754 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 764CC05A742324C053EF9042 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 85124249F1A2E3559EC05B19 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,19 +63,44 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B4B2233E92790E4E03907BD2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B9983A741CFB90A0857F31CD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 4A86DCC6EA19E4617698E6D5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1624C49AABB61D3BB6EBA00 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D444497F007A61A9102D174D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2EECBA43D42E2853F949CCFC /* Pods */ = { + isa = PBXGroup; + children = ( + B4B2233E92790E4E03907BD2 /* Pods-Runner.debug.xcconfig */, + B9983A741CFB90A0857F31CD /* Pods-Runner.release.xcconfig */, + 6BD9A45428DD4E519FC38754 /* Pods-Runner.profile.xcconfig */, + 85124249F1A2E3559EC05B19 /* Pods-RunnerTests.debug.xcconfig */, + 764CC05A742324C053EF9042 /* Pods-RunnerTests.release.xcconfig */, + 55914DA7E8E89CB02E73C3F5 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -94,6 +127,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 2EECBA43D42E2853F949CCFC /* Pods */, + 9CFCC4FE458D4EC11DAF9E88 /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +156,15 @@ path = Runner; sourceTree = ""; }; + 9CFCC4FE458D4EC11DAF9E88 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */, + 7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + A03040A6FEAD59038610D159 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 4A86DCC6EA19E4617698E6D5 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + B317C54D86989653BFD9CDC0 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -253,6 +301,67 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A03040A6FEAD59038610D159 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B317C54D86989653BFD9CDC0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -379,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 85124249F1A2E3559EC05B19 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 764CC05A742324C053EF9042 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 55914DA7E8E89CB02E73C3F5 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/rng.dart b/lib/core/rng.dart new file mode 100644 index 0000000..9b9569f --- /dev/null +++ b/lib/core/rng.dart @@ -0,0 +1,48 @@ +/// Deterministic seedable PRNG for the game engine. +/// +/// dart:math's Random is implementation-unspecified across platforms; piece +/// sequences must be byte-identical on iOS, Android, and in tests, so we own +/// the algorithm (PCG32, https://www.pcg-random.org). +class SeededRng { + SeededRng(int seed, [int sequence = 0]) + : _state = 0, + _inc = ((sequence << 1) | 1) { + _nextRaw(); + _state = _state + seed; + _nextRaw(); + } + + // Dart native ints are 64-bit with wrapping arithmetic, which is exactly + // what the PCG32 state transition needs. + int _state; + final int _inc; + + static const _multiplier = 6364136223846793005; + static const _mask32 = 0xFFFFFFFF; + + /// One PCG32 step: 32-bit output via xorshift-high + random rotation. + int _nextRaw() { + final old = _state; + _state = old * _multiplier + _inc; + final xorshifted = (((old >>> 18) ^ old) >>> 27) & _mask32; + final rot = old >>> 59; + return ((xorshifted >>> rot) | (xorshifted << ((-rot) & 31))) & _mask32; + } + + /// Uniform integer in [0, max), bias-free via rejection sampling. + int nextInt(int max) { + assert(max > 0); + final threshold = (0x100000000 - (0x100000000 % max)) & _mask32; + if (threshold == 0) return _nextRaw() % max; + while (true) { + final r = _nextRaw(); + if (r < threshold) return r % max; + } + } + + /// Uniform double in [0, 1). + double nextDouble() => _nextRaw() / 4294967296.0; + + /// Derives an independent deterministic stream (e.g. per retry attempt). + SeededRng fork(int streamId) => SeededRng(_nextRaw() ^ streamId, streamId); +} diff --git a/lib/game/engine/line_clear.dart b/lib/game/engine/line_clear.dart new file mode 100644 index 0000000..8b21037 --- /dev/null +++ b/lib/game/engine/line_clear.dart @@ -0,0 +1,56 @@ +import '../models/cell.dart'; +import '../models/grid.dart'; + +class ClearResult { + const ClearResult({ + required this.grid, + required this.clearedRows, + required this.clearedCols, + required this.gemsCleared, + }); + + final GridState grid; + final List clearedRows; + final List clearedCols; + final int gemsCleared; + + int get linesCleared => clearedRows.length + clearedCols.length; +} + +/// Detects all simultaneously full rows/columns and clears them in one pass. +ClearResult detectAndClear(GridState grid) { + final rows = []; + final cols = []; + for (var y = 0; y < GridState.size; y++) { + if (grid.isRowFull(y)) rows.add(y); + } + for (var x = 0; x < GridState.size; x++) { + if (grid.isColFull(x)) cols.add(x); + } + if (rows.isEmpty && cols.isEmpty) { + return ClearResult( + grid: grid, + clearedRows: const [], + clearedCols: const [], + gemsCleared: 0, + ); + } + + final rowSet = rows.toSet(); + final colSet = cols.toSet(); + var gems = 0; + var next = grid; + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if (!rowSet.contains(y) && !colSet.contains(x)) continue; + if (grid.cellAt(x, y).type == CellType.gem) gems++; + next = next.withCell(x, y, Cell.empty); + } + } + return ClearResult( + grid: next, + clearedRows: rows, + clearedCols: cols, + gemsCleared: gems, + ); +} diff --git a/lib/game/engine/piece_generator.dart b/lib/game/engine/piece_generator.dart new file mode 100644 index 0000000..390e68d --- /dev/null +++ b/lib/game/engine/piece_generator.dart @@ -0,0 +1,117 @@ +import '../../core/rng.dart'; +import '../models/grid.dart'; +import '../models/piece.dart'; +import '../models/piece_library.dart'; +import 'line_clear.dart'; +import 'placement.dart'; + +/// Whether some ordering of [tray] can be fully played out (with line clears +/// resolved between placements). Depth-3 backtracking on an 8x8 is cheap. +bool isTrayPlayable(GridState grid, List tray) { + if (tray.isEmpty) return true; + final triedIds = {}; + for (var i = 0; i < tray.length; i++) { + final piece = tray[i]; + if (!triedIds.add(piece.id)) continue; + final rest = [...tray]..removeAt(i); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if (!canPlace(grid, piece, x, y)) continue; + final after = detectAndClear(place(grid, piece, x, y)).grid; + if (isTrayPlayable(after, rest)) return true; + } + } + } + return false; +} + +/// Weighted-bag tray dealer with pity bias on tight boards and a solvability +/// nudge: the game never deals an instantly dead tray when a playable deal +/// exists, but bad play can still lose. +class PieceGenerator { + PieceGenerator(this._rng, {List? pool}) + : _pool = pool ?? PieceLibrary.all; + + static const int traySize = 3; + static const int _maxRedraws = 5; + static const double _pityThreshold = 0.6; + + final SeededRng _rng; + final List _pool; + + List nextTray(GridState grid) { + for (var attempt = 0; attempt < _maxRedraws; attempt++) { + final tray = _draw(grid); + if (isTrayPlayable(grid, tray)) return tray; + } + return _fallback(grid); + } + + List _draw(GridState grid) { + final fill = grid.fillRatio; + final candidates = List.of(_pool); + final tray = []; + while (tray.length < traySize) { + final pick = _weightedPick(candidates, fill); + tray.add(pick); + candidates.removeWhere((p) => p.id == pick.id); + } + return tray; + } + + /// Above 60% board fill, small pieces get progressively more likely so the + /// late game feels fair instead of dealing unplaceable monsters. + double _effectiveWeight(Piece piece, double fill) { + var w = piece.weight; + if (fill > _pityThreshold && piece.size <= 3) { + w *= 1 + 2 * (fill - _pityThreshold); + } + return w; + } + + Piece _weightedPick(List candidates, double fill) { + var total = 0.0; + for (final p in candidates) { + total += _effectiveWeight(p, fill); + } + var r = _rng.nextDouble() * total; + for (final p in candidates) { + r -= _effectiveWeight(p, fill); + if (r < 0) return p; + } + return candidates.last; + } + + /// After repeated unplayable draws: the largest piece that still fits plus + /// the smallest fillers. Consumes no RNG, so retries stay deterministic. + List _fallback(GridState grid) { + final bySizeDesc = [..._pool] + ..sort((a, b) { + final s = b.size.compareTo(a.size); + return s != 0 ? s : a.id.compareTo(b.id); + }); + Piece? largestFitting; + for (final p in bySizeDesc) { + if (_fitsAnywhere(grid, p)) { + largestFitting = p; + break; + } + } + final tray = [if (largestFitting != null) largestFitting]; + for (final p in bySizeDesc.reversed) { + if (tray.length == traySize) break; + if (tray.any((t) => t.id == p.id)) continue; + tray.add(p); + } + return tray; + } + + bool _fitsAnywhere(GridState grid, Piece piece) { + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if (canPlace(grid, piece, x, y)) return true; + } + } + return false; + } +} diff --git a/lib/game/engine/placement.dart b/lib/game/engine/placement.dart new file mode 100644 index 0000000..2a3f727 --- /dev/null +++ b/lib/game/engine/placement.dart @@ -0,0 +1,41 @@ +import '../models/cell.dart'; +import '../models/grid.dart'; +import '../models/piece.dart'; + +/// Whether [piece] fits with its anchor at ([x], [y]). +bool canPlace(GridState grid, Piece piece, int x, int y) { + for (final (dx, dy) in piece.offsets) { + final px = x + dx; + final py = y + dy; + if (px < 0 || px >= GridState.size || py < 0 || py >= GridState.size) { + return false; + } + if (grid.isOccupied(px, py)) return false; + } + return true; +} + +/// Places [piece] at ([x], [y]); caller must check [canPlace] first. +GridState place(GridState grid, Piece piece, int x, int y) { + var next = grid; + for (final (dx, dy) in piece.offsets) { + next = next.withCell( + x + dx, + y + dy, + Cell(CellType.filled, colorId: piece.colorId), + ); + } + return next; +} + +/// Whether at least one of [pieces] has at least one legal placement. +bool anyPlacementExists(GridState grid, List pieces) { + for (final piece in pieces) { + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if (canPlace(grid, piece, x, y)) return true; + } + } + } + return false; +} diff --git a/lib/game/engine/scoring.dart b/lib/game/engine/scoring.dart new file mode 100644 index 0000000..43aa08f --- /dev/null +++ b/lib/game/engine/scoring.dart @@ -0,0 +1,50 @@ +/// Pure scoring math: placement points, line-clear bonuses, combo streaks. +library; + +/// Base score for clearing [lines] rows/columns simultaneously. +/// 1 -> 100, 2 -> 300, 3 -> 600, 4 -> 1000: simultaneous clears are +/// superlinearly rewarded. +int lineClearBase(int lines) => 100 * lines + 50 * (lines - 1) * lines; + +/// Multiplier applied to clear score at combo [streak], capped at streak 8 +/// (x5.0) so late-game scores stay bounded. +double comboMultiplier(int streak) => 1 + 0.5 * (streak > 8 ? 8 : streak); + +/// Combo streak with one dry-move grace before reset. +class ComboState { + const ComboState({required this.streak, required this.dryMoves}); + + static const initial = ComboState(streak: 0, dryMoves: 0); + + final int streak; + final int dryMoves; + + /// One dry move keeps the streak alive (grace); the second resets it. + ComboState advance({required bool cleared}) { + if (cleared) return ComboState(streak: streak + 1, dryMoves: 0); + if (dryMoves + 1 >= 2) return initial; + return ComboState(streak: streak, dryMoves: dryMoves + 1); + } +} + +class ScoreDelta { + const ScoreDelta({required this.points, required this.combo}); + + final int points; + final ComboState combo; +} + +/// Computes the score delta for one placement and the resulting combo state. +ScoreDelta scorePlacement({ + required int cellsPlaced, + required int linesCleared, + required ComboState combo, +}) { + final next = combo.advance(cleared: linesCleared > 0); + var points = cellsPlaced; + if (linesCleared > 0) { + points += (lineClearBase(linesCleared) * comboMultiplier(next.streak)) + .round(); + } + return ScoreDelta(points: points, combo: next); +} diff --git a/lib/game/models/cell.dart b/lib/game/models/cell.dart new file mode 100644 index 0000000..e125084 --- /dev/null +++ b/lib/game/models/cell.dart @@ -0,0 +1,22 @@ +enum CellType { empty, filled, gem } + +class Cell { + const Cell(this.type, {this.colorId = 0}); + + final CellType type; + final int colorId; + + static const empty = Cell(CellType.empty); + + bool get isOccupied => type != CellType.empty; + + @override + bool operator ==(Object other) => + other is Cell && other.type == type && other.colorId == colorId; + + @override + int get hashCode => Object.hash(type, colorId); + + @override + String toString() => 'Cell(${type.name}, color: $colorId)'; +} diff --git a/lib/game/models/grid.dart b/lib/game/models/grid.dart new file mode 100644 index 0000000..e82bf0d --- /dev/null +++ b/lib/game/models/grid.dart @@ -0,0 +1,63 @@ +import 'cell.dart'; + +/// Immutable 8x8 board state. Mutating operations return new instances. +/// +/// Occupancy is mirrored in a 64-bit bitmask (bit y*8+x) so fullness checks +/// and placement scans are O(1) mask operations. +class GridState { + static const int size = 8; + static const int cellCount = size * size; + + GridState.empty() + : _cells = List.filled(cellCount, Cell.empty), + _mask = 0; + + const GridState._(this._cells, this._mask); + + final List _cells; + final int _mask; + + static int _index(int x, int y) => y * size + x; + + /// Raw occupancy bitmask; exposed for placement/clear mask math. + int get mask => _mask; + + Cell cellAt(int x, int y) => _cells[_index(x, y)]; + + bool isOccupied(int x, int y) => (_mask >>> _index(x, y)) & 1 == 1; + + int get occupiedCount { + var n = 0; + var m = _mask; + while (m != 0) { + m &= m - 1; + n++; + } + return n; + } + + double get fillRatio => occupiedCount / cellCount; + + GridState withCell(int x, int y, Cell cell) { + final cells = List.of(_cells); + final i = _index(x, y); + cells[i] = cell; + final mask = + cell.isOccupied ? (_mask | (1 << i)) : (_mask & ~(1 << i)); + return GridState._(List.unmodifiable(cells), mask); + } + + static int _rowMask(int y) => 0xFF << (y * size); + + static int _colMask(int x) { + var m = 0; + for (var y = 0; y < size; y++) { + m |= 1 << _index(x, y); + } + return m; + } + + bool isRowFull(int y) => (_mask & _rowMask(y)) == _rowMask(y); + + bool isColFull(int x) => (_mask & _colMask(x)) == _colMask(x); +} diff --git a/lib/game/models/piece.dart b/lib/game/models/piece.dart new file mode 100644 index 0000000..4699569 --- /dev/null +++ b/lib/game/models/piece.dart @@ -0,0 +1,27 @@ +/// A polyomino with fixed orientation (no rotation at play time, genre +/// convention). Offsets are normalized cell positions relative to the +/// top-left anchor. +class Piece { + const Piece({ + required this.id, + required this.offsets, + required this.colorId, + this.weight = 1.0, + this.tier = 1, + }); + + final String id; + final List<(int, int)> offsets; + final int colorId; + + /// Base draw weight; stage generator profiles scale this. + final double weight; + + /// Difficulty tier 1 (easy to fit) .. 3 (demanding). + final int tier; + + int get size => offsets.length; + + @override + String toString() => 'Piece($id)'; +} diff --git a/lib/game/models/piece_library.dart b/lib/game/models/piece_library.dart new file mode 100644 index 0000000..c38dfb7 --- /dev/null +++ b/lib/game/models/piece_library.dart @@ -0,0 +1,241 @@ +import 'piece.dart'; + +/// The fixed-orientation polyomino set (no play-time rotation, genre +/// convention). Base weights skew small pieces common; stage generator +/// profiles rescale per difficulty. +class PieceLibrary { + static const double _small = 1.2; + static const double _mid = 1.0; + static const double _large = 0.7; + + static const List all = [ + // --- size 1-2 --- + Piece(id: 'mono', colorId: 0, weight: _small, tier: 1, offsets: [(0, 0)]), + Piece( + id: 'domino_h', + colorId: 1, + weight: _small, + tier: 1, + offsets: [(0, 0), (1, 0)]), + Piece( + id: 'domino_v', + colorId: 1, + weight: _small, + tier: 1, + offsets: [(0, 0), (0, 1)]), + // --- trominoes --- + Piece( + id: 'line3_h', + colorId: 2, + weight: _small, + tier: 1, + offsets: [(0, 0), (1, 0), (2, 0)]), + Piece( + id: 'line3_v', + colorId: 2, + weight: _small, + tier: 1, + offsets: [(0, 0), (0, 1), (0, 2)]), + Piece( + id: 'corner3_tl', + colorId: 3, + weight: _small, + tier: 1, + offsets: [(0, 0), (1, 0), (0, 1)]), + Piece( + id: 'corner3_tr', + colorId: 3, + weight: _small, + tier: 1, + offsets: [(0, 0), (1, 0), (1, 1)]), + Piece( + id: 'corner3_bl', + colorId: 3, + weight: _small, + tier: 1, + offsets: [(0, 0), (0, 1), (1, 1)]), + Piece( + id: 'corner3_br', + colorId: 3, + weight: _small, + tier: 1, + offsets: [(1, 0), (0, 1), (1, 1)]), + // --- tetrominoes --- + Piece( + id: 'line4_h', + colorId: 4, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (2, 0), (3, 0)]), + Piece( + id: 'line4_v', + colorId: 4, + weight: _mid, + tier: 2, + offsets: [(0, 0), (0, 1), (0, 2), (0, 3)]), + Piece( + id: 'square2', + colorId: 5, + weight: _mid, + tier: 1, + offsets: [(0, 0), (1, 0), (0, 1), (1, 1)]), + Piece( + id: 't4_up', + colorId: 6, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (2, 0), (1, 1)]), + Piece( + id: 't4_down', + colorId: 6, + weight: _mid, + tier: 2, + offsets: [(1, 0), (0, 1), (1, 1), (2, 1)]), + Piece( + id: 't4_left', + colorId: 6, + weight: _mid, + tier: 2, + offsets: [(1, 0), (0, 1), (1, 1), (1, 2)]), + Piece( + id: 't4_right', + colorId: 6, + weight: _mid, + tier: 2, + offsets: [(0, 0), (0, 1), (1, 1), (0, 2)]), + Piece( + id: 's4_h', + colorId: 7, + weight: _mid, + tier: 2, + offsets: [(1, 0), (2, 0), (0, 1), (1, 1)]), + Piece( + id: 's4_v', + colorId: 7, + weight: _mid, + tier: 2, + offsets: [(0, 0), (0, 1), (1, 1), (1, 2)]), + Piece( + id: 'z4_h', + colorId: 7, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (1, 1), (2, 1)]), + Piece( + id: 'z4_v', + colorId: 7, + weight: _mid, + tier: 2, + offsets: [(1, 0), (0, 1), (1, 1), (0, 2)]), + Piece( + id: 'l4_0', + colorId: 0, + weight: _mid, + tier: 2, + offsets: [(0, 0), (0, 1), (0, 2), (1, 2)]), + Piece( + id: 'l4_90', + colorId: 0, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (2, 0), (0, 1)]), + Piece( + id: 'l4_180', + colorId: 0, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (1, 1), (1, 2)]), + Piece( + id: 'l4_270', + colorId: 0, + weight: _mid, + tier: 2, + offsets: [(2, 0), (0, 1), (1, 1), (2, 1)]), + Piece( + id: 'j4_0', + colorId: 1, + weight: _mid, + tier: 2, + offsets: [(1, 0), (1, 1), (0, 2), (1, 2)]), + Piece( + id: 'j4_90', + colorId: 1, + weight: _mid, + tier: 2, + offsets: [(0, 0), (0, 1), (1, 1), (2, 1)]), + Piece( + id: 'j4_180', + colorId: 1, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (0, 1), (0, 2)]), + Piece( + id: 'j4_270', + colorId: 1, + weight: _mid, + tier: 2, + offsets: [(0, 0), (1, 0), (2, 0), (2, 1)]), + // --- size 5-6 --- + Piece( + id: 'line5_h', + colorId: 2, + weight: _large, + tier: 3, + offsets: [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]), + Piece( + id: 'line5_v', + colorId: 2, + weight: _large, + tier: 3, + offsets: [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]), + Piece( + id: 'rect2x3', + colorId: 3, + weight: _large, + tier: 2, + offsets: [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]), + Piece( + id: 'rect3x2', + colorId: 3, + weight: _large, + tier: 2, + offsets: [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]), + Piece( + id: 'bigl_tl', + colorId: 4, + weight: _large, + tier: 3, + offsets: [(0, 0), (1, 0), (2, 0), (0, 1), (0, 2)]), + Piece( + id: 'bigl_tr', + colorId: 4, + weight: _large, + tier: 3, + offsets: [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)]), + Piece( + id: 'bigl_bl', + colorId: 4, + weight: _large, + tier: 3, + offsets: [(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)]), + Piece( + id: 'bigl_br', + colorId: 4, + weight: _large, + tier: 3, + offsets: [(2, 0), (2, 1), (0, 2), (1, 2), (2, 2)]), + // --- size 9 --- + Piece( + id: 'square3', + colorId: 5, + weight: 0.5, + tier: 3, + offsets: [ + (0, 0), (1, 0), (2, 0), + (0, 1), (1, 1), (2, 1), + (0, 2), (1, 2), (2, 2), + ]), + ]; + + static Piece byId(String id) => all.firstWhere((p) => p.id == id); +} diff --git a/test/core/rng_test.dart b/test/core/rng_test.dart new file mode 100644 index 0000000..72fa8a4 --- /dev/null +++ b/test/core/rng_test.dart @@ -0,0 +1,61 @@ +import 'package:block_seasons/core/rng.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SeededRng', () { + test('same seed produces identical 1000-draw sequence', () { + final a = SeededRng(8841273); + final b = SeededRng(8841273); + for (var i = 0; i < 1000; i++) { + expect(a.nextInt(1 << 30), b.nextInt(1 << 30)); + } + }); + + test('different seeds produce different sequences', () { + final a = SeededRng(1); + final b = SeededRng(2); + final drawsA = List.generate(20, (_) => a.nextInt(1 << 30)); + final drawsB = List.generate(20, (_) => b.nextInt(1 << 30)); + expect(drawsA, isNot(equals(drawsB))); + }); + + test('nextInt stays within [0, max)', () { + final rng = SeededRng(42); + for (final max in [1, 2, 3, 7, 40, 1000]) { + for (var i = 0; i < 2000; i++) { + final v = rng.nextInt(max); + expect(v, inInclusiveRange(0, max - 1)); + } + } + }); + + test('nextInt eventually covers every value for small max', () { + final rng = SeededRng(7); + final seen = {}; + for (var i = 0; i < 1000; i++) { + seen.add(rng.nextInt(6)); + } + expect(seen, {0, 1, 2, 3, 4, 5}); + }); + + test('nextDouble stays within [0, 1)', () { + final rng = SeededRng(99); + for (var i = 0; i < 5000; i++) { + final v = rng.nextDouble(); + expect(v, greaterThanOrEqualTo(0.0)); + expect(v, lessThan(1.0)); + } + }); + + test('fork creates an independent stream that is deterministic', () { + final a = SeededRng(123).fork(5); + final b = SeededRng(123).fork(5); + final c = SeededRng(123).fork(6); + final drawsA = List.generate(20, (_) => a.nextInt(1 << 30)); + final drawsB = List.generate(20, (_) => b.nextInt(1 << 30)); + final drawsC = List.generate(20, (_) => c.nextInt(1 << 30)); + expect(drawsA, drawsB); + expect(drawsA, isNot(equals(drawsC))); + }); + }); +} diff --git a/test/game/engine/line_clear_test.dart b/test/game/engine/line_clear_test.dart new file mode 100644 index 0000000..89ae445 --- /dev/null +++ b/test/game/engine/line_clear_test.dart @@ -0,0 +1,93 @@ +import 'package:block_seasons/game/engine/line_clear.dart'; +import 'package:block_seasons/game/models/cell.dart'; +import 'package:block_seasons/game/models/grid.dart'; +import 'package:flutter_test/flutter_test.dart'; + +GridState _fillRow(GridState grid, int y, {Set? skip}) { + for (var x = 0; x < GridState.size; x++) { + if (skip?.contains(x) ?? false) continue; + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + return grid; +} + +GridState _fillCol(GridState grid, int x, {Set? skip}) { + for (var y = 0; y < GridState.size; y++) { + if (skip?.contains(y) ?? false) continue; + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + return grid; +} + +void main() { + group('detectAndClear', () { + test('no full lines leaves the grid untouched', () { + final grid = _fillRow(GridState.empty(), 3, skip: {5}); + final result = detectAndClear(grid); + expect(result.clearedRows, isEmpty); + expect(result.clearedCols, isEmpty); + expect(result.linesCleared, 0); + expect(result.gemsCleared, 0); + expect(result.grid.occupiedCount, grid.occupiedCount); + }); + + test('clears a single full row', () { + final grid = _fillRow(GridState.empty(), 2); + final result = detectAndClear(grid); + expect(result.clearedRows, [2]); + expect(result.clearedCols, isEmpty); + expect(result.linesCleared, 1); + expect(result.grid.occupiedCount, 0); + }); + + test('clears a single full column', () { + final grid = _fillCol(GridState.empty(), 6); + final result = detectAndClear(grid); + expect(result.clearedRows, isEmpty); + expect(result.clearedCols, [6]); + expect(result.grid.occupiedCount, 0); + }); + + test('clears two rows at once', () { + var grid = _fillRow(GridState.empty(), 0); + grid = _fillRow(grid, 7); + final result = detectAndClear(grid); + expect(result.clearedRows, [0, 7]); + expect(result.linesCleared, 2); + expect(result.grid.occupiedCount, 0); + }); + + test('row and column sharing a corner clear together', () { + var grid = _fillRow(GridState.empty(), 4); + grid = _fillCol(grid, 4); + final result = detectAndClear(grid); + expect(result.clearedRows, [4]); + expect(result.clearedCols, [4]); + expect(result.linesCleared, 2); + // 8 + 8 - 1 shared cell were occupied; all gone now. + expect(result.grid.occupiedCount, 0); + }); + + test('counts gems in cleared lines, intersection gem only once', () { + var grid = GridState.empty(); + grid = grid.withCell(4, 4, const Cell(CellType.gem)); + grid = grid.withCell(0, 4, const Cell(CellType.gem)); + grid = grid.withCell(4, 0, const Cell(CellType.gem)); + grid = _fillRow(grid, 4, skip: {0, 4}); + grid = _fillCol(grid, 4, skip: {0, 4}); + final result = detectAndClear(grid); + expect(result.clearedRows, [4]); + expect(result.clearedCols, [4]); + // Gems at (0,4), (4,0), and the shared (4,4) -> exactly 3. + expect(result.gemsCleared, 3); + }); + + test('cells outside cleared lines survive', () { + var grid = _fillRow(GridState.empty(), 1); + grid = grid.withCell(3, 5, const Cell(CellType.filled, colorId: 2)); + final result = detectAndClear(grid); + expect(result.grid.occupiedCount, 1); + expect(result.grid.cellAt(3, 5).colorId, 2); + }); + }); +} diff --git a/test/game/engine/piece_generator_test.dart b/test/game/engine/piece_generator_test.dart new file mode 100644 index 0000000..c99a866 --- /dev/null +++ b/test/game/engine/piece_generator_test.dart @@ -0,0 +1,135 @@ +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/game/models/cell.dart'; +import 'package:block_seasons/game/models/grid.dart'; +import 'package:block_seasons/game/models/piece_library.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Checkerboard: no line can ever be completed, big shapes never fit. +GridState _checkerboard() { + var grid = GridState.empty(); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if ((x + y).isEven) { + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + } + } + return grid; +} + +/// Rows 1..7 filled except column 0; row 0 filled except (0,0). +/// Only column 0 is free -> tight but recoverable via line clears. +GridState _tightBoard() { + var grid = GridState.empty(); + for (var y = 0; y < GridState.size; y++) { + for (var x = 1; x < GridState.size; x++) { + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + } + return grid; +} + +void main() { + group('isTrayPlayable', () { + test('any tray plays out on an empty grid', () { + final tray = [ + PieceLibrary.byId('square3'), + PieceLibrary.byId('line5_h'), + PieceLibrary.byId('rect3x2'), + ]; + expect(isTrayPlayable(GridState.empty(), tray), isTrue); + }); + + test('recognizes trays that need intermediate line clears', () { + // Column 0 free (8 cells); tray sums to 9 cells, so it only plays out + // if placements trigger clears that open space. + final tray = [ + PieceLibrary.byId('line4_v'), + PieceLibrary.byId('line4_v'), + PieceLibrary.byId('mono'), + ]; + expect(isTrayPlayable(_tightBoard(), tray), isTrue); + }); + + test('returns false when no ordering can play all pieces', () { + final tray = [ + PieceLibrary.byId('square3'), + PieceLibrary.byId('square3'), + PieceLibrary.byId('line5_h'), + ]; + expect(isTrayPlayable(_checkerboard(), tray), isFalse); + }); + }); + + group('PieceGenerator', () { + test('same seed produces identical tray sequences', () { + final a = PieceGenerator(SeededRng(42)); + final b = PieceGenerator(SeededRng(42)); + final grid = GridState.empty(); + for (var i = 0; i < 100; i++) { + final trayA = a.nextTray(grid).map((p) => p.id).toList(); + final trayB = b.nextTray(grid).map((p) => p.id).toList(); + expect(trayA, trayB); + } + }); + + test('trays always contain exactly 3 pieces with distinct ids', () { + final gen = PieceGenerator(SeededRng(7)); + final grids = [GridState.empty(), _checkerboard(), _tightBoard()]; + for (final grid in grids) { + for (var i = 0; i < 200; i++) { + final tray = gen.nextTray(grid); + expect(tray.length, 3); + expect(tray.map((p) => p.id).toSet().length, 3); + } + } + }); + + test('pity bias: tighter boards get smaller pieces on average', () { + final emptyGen = PieceGenerator(SeededRng(123)); + final tightGen = PieceGenerator(SeededRng(123)); + final empty = GridState.empty(); + final tight = _checkerboard(); // 50% fill, no clears possible + + double meanSize(PieceGenerator gen, GridState grid) { + var total = 0; + for (var i = 0; i < 400; i++) { + for (final p in gen.nextTray(grid)) { + total += p.size; + } + } + return total / (400 * 3); + } + + final emptyMean = meanSize(emptyGen, empty); + final tightMean = meanSize(tightGen, tight); + expect(tightMean, lessThan(emptyMean)); + }); + + test('nudge: tight-but-recoverable boards always get playable trays', + () { + final gen = PieceGenerator(SeededRng(2026)); + final grid = _tightBoard(); + for (var i = 0; i < 100; i++) { + final tray = gen.nextTray(grid); + expect(isTrayPlayable(grid, tray), isTrue, + reason: 'deal #$i: ${tray.map((p) => p.id).join(", ")}'); + } + }); + + test('on a dead board the tray still contains the smallest pieces', () { + // Fully occupied: nothing fits; engine handles game over, but the + // generator must not loop forever and should fall back to small pieces. + var grid = GridState.empty(); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + } + final tray = PieceGenerator(SeededRng(5)).nextTray(grid); + expect(tray.length, 3); + expect(tray.every((p) => p.size <= 2), isTrue); + }); + }); +} diff --git a/test/game/engine/placement_test.dart b/test/game/engine/placement_test.dart new file mode 100644 index 0000000..081f157 --- /dev/null +++ b/test/game/engine/placement_test.dart @@ -0,0 +1,83 @@ +import 'package:block_seasons/game/engine/placement.dart'; +import 'package:block_seasons/game/models/cell.dart'; +import 'package:block_seasons/game/models/grid.dart'; +import 'package:block_seasons/game/models/piece.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const mono = Piece(id: 'mono', colorId: 0, offsets: [(0, 0)]); +const dominoH = Piece(id: 'domino_h', colorId: 1, offsets: [(0, 0), (1, 0)]); +const square2 = Piece( + id: 'square2', + colorId: 2, + offsets: [(0, 0), (1, 0), (0, 1), (1, 1)], +); + +void main() { + group('canPlace', () { + test('any piece fits anywhere legal on an empty grid', () { + final grid = GridState.empty(); + expect(canPlace(grid, mono, 0, 0), isTrue); + expect(canPlace(grid, mono, 7, 7), isTrue); + expect(canPlace(grid, square2, 6, 6), isTrue); + }); + + test('rejects out-of-bounds placements', () { + final grid = GridState.empty(); + expect(canPlace(grid, square2, 7, 7), isFalse); + expect(canPlace(grid, dominoH, 7, 0), isFalse); + expect(canPlace(grid, mono, -1, 0), isFalse); + expect(canPlace(grid, mono, 0, 8), isFalse); + }); + + test('rejects overlap with occupied cells', () { + final grid = GridState.empty() + .withCell(1, 0, const Cell(CellType.filled, colorId: 0)); + expect(canPlace(grid, dominoH, 0, 0), isFalse); + expect(canPlace(grid, dominoH, 2, 0), isTrue); + }); + }); + + group('place', () { + test('fills cells with the piece color and keeps original grid', () { + final grid = GridState.empty(); + final next = place(grid, square2, 3, 3); + + expect(grid.occupiedCount, 0); + expect(next.occupiedCount, 4); + for (final (dx, dy) in square2.offsets) { + expect(next.cellAt(3 + dx, 3 + dy).type, CellType.filled); + expect(next.cellAt(3 + dx, 3 + dy).colorId, square2.colorId); + } + }); + }); + + group('anyPlacementExists', () { + test('true on an empty grid', () { + expect(anyPlacementExists(GridState.empty(), [square2]), isTrue); + }); + + test('detects when only a small piece fits', () { + // Fill everything except (7, 7). + var grid = GridState.empty(); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + if (x == 7 && y == 7) continue; + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + } + expect(anyPlacementExists(grid, [mono]), isTrue); + expect(anyPlacementExists(grid, [dominoH]), isFalse); + expect(anyPlacementExists(grid, [dominoH, mono]), isTrue); + }); + + test('false when the grid is completely full', () { + var grid = GridState.empty(); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0)); + } + } + expect(anyPlacementExists(grid, [mono]), isFalse); + }); + }); +} diff --git a/test/game/engine/scoring_test.dart b/test/game/engine/scoring_test.dart new file mode 100644 index 0000000..5d37a9a --- /dev/null +++ b/test/game/engine/scoring_test.dart @@ -0,0 +1,92 @@ +import 'package:block_seasons/game/engine/scoring.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('lineClearBase', () { + test('escalates with simultaneous lines', () { + expect(lineClearBase(1), 100); + expect(lineClearBase(2), 300); + expect(lineClearBase(3), 600); + expect(lineClearBase(4), 1000); + }); + }); + + group('comboMultiplier', () { + test('grows by 0.5 per streak step and caps at 8', () { + expect(comboMultiplier(1), 1.5); + expect(comboMultiplier(2), 2.0); + expect(comboMultiplier(8), 5.0); + expect(comboMultiplier(12), 5.0); + }); + }); + + group('ComboState', () { + test('clearing placements grow the streak', () { + var combo = ComboState.initial; + combo = combo.advance(cleared: true); + expect(combo.streak, 1); + combo = combo.advance(cleared: true); + expect(combo.streak, 2); + }); + + test('one dry move is grace, streak survives', () { + var combo = ComboState.initial; + combo = combo.advance(cleared: true); + combo = combo.advance(cleared: false); + expect(combo.streak, 1); + combo = combo.advance(cleared: true); + expect(combo.streak, 2); + }); + + test('two consecutive dry moves reset the streak', () { + var combo = ComboState.initial; + combo = combo.advance(cleared: true); + combo = combo.advance(cleared: false); + combo = combo.advance(cleared: false); + expect(combo.streak, 0); + combo = combo.advance(cleared: true); + expect(combo.streak, 1); + }); + }); + + group('scorePlacement', () { + test('placement without clear scores cell count only', () { + final delta = scorePlacement( + cellsPlaced: 4, + linesCleared: 0, + combo: ComboState.initial, + ); + expect(delta.points, 4); + expect(delta.combo.streak, 0); + }); + + test('first clear applies x1.5 multiplier', () { + final delta = scorePlacement( + cellsPlaced: 5, + linesCleared: 1, + combo: ComboState.initial, + ); + // 5 + round(100 * 1.5) = 155 + expect(delta.points, 155); + expect(delta.combo.streak, 1); + }); + + test('streak compounds across placements', () { + var combo = ComboState.initial; + final first = scorePlacement( + cellsPlaced: 3, + linesCleared: 1, + combo: combo, + ); + combo = first.combo; + final second = scorePlacement( + cellsPlaced: 3, + linesCleared: 2, + combo: combo, + ); + // 3 + round(300 * 2.0) = 603 + expect(second.points, 603); + expect(second.combo.streak, 2); + }); + }); +} diff --git a/test/game/models/grid_test.dart b/test/game/models/grid_test.dart new file mode 100644 index 0000000..b108c43 --- /dev/null +++ b/test/game/models/grid_test.dart @@ -0,0 +1,67 @@ +import 'package:block_seasons/game/models/cell.dart'; +import 'package:block_seasons/game/models/grid.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GridState', () { + test('empty grid has no occupied cells', () { + final grid = GridState.empty(); + expect(grid.occupiedCount, 0); + expect(grid.fillRatio, 0.0); + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + expect(grid.cellAt(x, y).type, CellType.empty); + expect(grid.isOccupied(x, y), isFalse); + } + } + }); + + test('withCell returns a new grid and leaves the original unchanged', () { + final grid = GridState.empty(); + final next = grid.withCell(3, 4, const Cell(CellType.filled, colorId: 2)); + + expect(grid.isOccupied(3, 4), isFalse); + expect(next.isOccupied(3, 4), isTrue); + expect(next.cellAt(3, 4).type, CellType.filled); + expect(next.cellAt(3, 4).colorId, 2); + expect(next.occupiedCount, 1); + }); + + test('gem cells count as occupied', () { + final grid = + GridState.empty().withCell(0, 0, const Cell(CellType.gem)); + expect(grid.isOccupied(0, 0), isTrue); + expect(grid.occupiedCount, 1); + }); + + test('clearing a cell back to empty updates occupancy', () { + final grid = GridState.empty() + .withCell(5, 5, const Cell(CellType.filled, colorId: 1)) + .withCell(5, 5, const Cell(CellType.empty)); + expect(grid.isOccupied(5, 5), isFalse); + expect(grid.occupiedCount, 0); + }); + + test('isRowFull and isColFull detect complete lines', () { + var grid = GridState.empty(); + for (var x = 0; x < GridState.size; x++) { + grid = grid.withCell(x, 2, const Cell(CellType.filled, colorId: 0)); + } + for (var y = 0; y < GridState.size; y++) { + grid = grid.withCell(6, y, const Cell(CellType.filled, colorId: 0)); + } + expect(grid.isRowFull(2), isTrue); + expect(grid.isRowFull(3), isFalse); + expect(grid.isColFull(6), isTrue); + expect(grid.isColFull(0), isFalse); + }); + + test('fillRatio reflects occupied fraction', () { + var grid = GridState.empty(); + for (var x = 0; x < GridState.size; x++) { + grid = grid.withCell(x, 0, const Cell(CellType.filled, colorId: 0)); + } + expect(grid.fillRatio, closeTo(8 / 64, 1e-9)); + }); + }); +} diff --git a/test/game/models/piece_library_test.dart b/test/game/models/piece_library_test.dart new file mode 100644 index 0000000..92fbe28 --- /dev/null +++ b/test/game/models/piece_library_test.dart @@ -0,0 +1,60 @@ +import 'package:block_seasons/game/models/piece.dart'; +import 'package:block_seasons/game/models/piece_library.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PieceLibrary', () { + test('has a rich fixed-orientation shape set', () { + expect(PieceLibrary.all.length, greaterThanOrEqualTo(35)); + }); + + test('ids are unique', () { + final ids = PieceLibrary.all.map((p) => p.id).toSet(); + expect(ids.length, PieceLibrary.all.length); + }); + + test('every piece is normalized to its top-left bounding box', () { + for (final piece in PieceLibrary.all) { + final minDx = + piece.offsets.map((o) => o.$1).reduce((a, b) => a < b ? a : b); + final minDy = + piece.offsets.map((o) => o.$2).reduce((a, b) => a < b ? a : b); + expect(minDx, 0, reason: '${piece.id} not normalized in x'); + expect(minDy, 0, reason: '${piece.id} not normalized in y'); + } + }); + + test('offsets are unique and fit in a 5x5 bounding box', () { + for (final piece in PieceLibrary.all) { + expect(piece.offsets.toSet().length, piece.offsets.length, + reason: '${piece.id} has duplicate offsets'); + for (final (dx, dy) in piece.offsets) { + expect(dx, inInclusiveRange(0, 4), reason: piece.id); + expect(dy, inInclusiveRange(0, 4), reason: piece.id); + } + } + }); + + test('weights are positive and tiers are 1..3', () { + for (final piece in PieceLibrary.all) { + expect(piece.weight, greaterThan(0), reason: piece.id); + expect(piece.tier, inInclusiveRange(1, 3), reason: piece.id); + } + }); + + test('contains the staple shapes', () { + for (final id in ['mono', 'square2', 'square3', 'line5_h', 'line5_v']) { + expect(PieceLibrary.byId(id), isA()); + } + }); + + test('byId throws on unknown id', () { + expect(() => PieceLibrary.byId('nope'), throwsStateError); + }); + + test('small pieces exist for tight late-game boards', () { + final smalls = PieceLibrary.all.where((p) => p.size <= 2); + expect(smalls.length, greaterThanOrEqualTo(3)); + }); + }); +}