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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -94,6 +127,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
2EECBA43D42E2853F949CCFC /* Pods */,
|
||||
9CFCC4FE458D4EC11DAF9E88 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -121,6 +156,15 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9CFCC4FE458D4EC11DAF9E88 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */,
|
||||
7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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;
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<int> clearedRows;
|
||||
final List<int> 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 = <int>[];
|
||||
final cols = <int>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<Piece> tray) {
|
||||
if (tray.isEmpty) return true;
|
||||
final triedIds = <String>{};
|
||||
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<Piece>? 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<Piece> _pool;
|
||||
|
||||
List<Piece> nextTray(GridState grid) {
|
||||
for (var attempt = 0; attempt < _maxRedraws; attempt++) {
|
||||
final tray = _draw(grid);
|
||||
if (isTrayPlayable(grid, tray)) return tray;
|
||||
}
|
||||
return _fallback(grid);
|
||||
}
|
||||
|
||||
List<Piece> _draw(GridState grid) {
|
||||
final fill = grid.fillRatio;
|
||||
final candidates = List.of(_pool);
|
||||
final tray = <Piece>[];
|
||||
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<Piece> 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<Piece> _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 = <Piece>[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;
|
||||
}
|
||||
}
|
||||
@@ -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<Piece> 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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<Cell> _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);
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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<Piece> 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);
|
||||
}
|
||||
@@ -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 = <int>{};
|
||||
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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<int>? 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<int>? 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<Piece>());
|
||||
}
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user