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:
2026-06-11 13:05:55 +09:00
parent 40528238b2
commit 0210c14858
19 changed files with 1408 additions and 0 deletions
+37
View File
@@ -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
+112
View File
@@ -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
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+48
View File
@@ -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);
}
+56
View File
@@ -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,
);
}
+117
View File
@@ -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;
}
}
+41
View File
@@ -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;
}
+50
View File
@@ -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);
}
+22
View File
@@ -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)';
}
+63
View File
@@ -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);
}
+27
View File
@@ -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)';
}
+241
View File
@@ -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);
}
+61
View File
@@ -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)));
});
});
}
+93
View File
@@ -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);
});
});
}
+135
View File
@@ -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);
});
});
}
+83
View File
@@ -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);
});
});
}
+92
View File
@@ -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);
});
});
}
+67
View File
@@ -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));
});
});
}
+60
View File
@@ -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));
});
});
}