diff --git a/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/BottomAppBarItemClickListener.kt b/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/BottomAppBarItemClickListener.kt
index 33a9e9b5..3c58f693 100644
--- a/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/BottomAppBarItemClickListener.kt
+++ b/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/BottomAppBarItemClickListener.kt
@@ -5,12 +5,14 @@ import androidx.appcompat.widget.Toolbar
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.piepmeyer.gauguin.R
+import org.piepmeyer.gauguin.difficulty.human.HumanSolver
import org.piepmeyer.gauguin.game.Game
import org.piepmeyer.gauguin.game.GameSolveService
class BottomAppBarItemClickListener(
private val mainActivity: MainActivity,
-) : Toolbar.OnMenuItemClickListener, KoinComponent {
+) : Toolbar.OnMenuItemClickListener,
+ KoinComponent {
private val game: Game by inject()
private val gameSolveService: GameSolveService by inject()
@@ -23,6 +25,19 @@ class BottomAppBarItemClickListener(
R.id.menu_reveal_cell -> gameSolveService.revealSelectedCell()
R.id.menu_reveal_cage -> gameSolveService.revealSelectedCage()
R.id.menu_show_solution -> gameSolveService.solveGrid()
+ R.id.menu_debug_solve_by_human_solver_from_start -> {
+ val solver = HumanSolver(game.grid)
+ solver.prepareGrid()
+ solver.solveAndCalculateDifficulty()
+
+ game.gridUI.invalidate()
+ }
+ R.id.menu_debug_solve_by_human_solver_from_here -> {
+ val solver = HumanSolver(game.grid)
+ solver.solveAndCalculateDifficulty()
+
+ game.gridUI.invalidate()
+ }
}
return true
diff --git a/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/GameTopFragment.kt b/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/GameTopFragment.kt
index a7555782..349594e4 100644
--- a/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/GameTopFragment.kt
+++ b/gauguin-app/src/main/kotlin/org/piepmeyer/gauguin/ui/main/GameTopFragment.kt
@@ -10,6 +10,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -19,9 +20,12 @@ import org.piepmeyer.gauguin.databinding.FragmentMainGameTopBinding
import org.piepmeyer.gauguin.difficulty.DisplayableGameDifficulty
import org.piepmeyer.gauguin.difficulty.GameDifficulty
import org.piepmeyer.gauguin.difficulty.GameDifficultyRater
+import org.piepmeyer.gauguin.difficulty.human.HumanSolver
import org.piepmeyer.gauguin.game.Game
import org.piepmeyer.gauguin.game.GameLifecycle
import org.piepmeyer.gauguin.game.PlayTimeListener
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
import org.piepmeyer.gauguin.preferences.ApplicationPreferences
import org.piepmeyer.gauguin.ui.difficulty.MainGameDifficultyLevelBalloon
import org.piepmeyer.gauguin.ui.difficulty.MainGameDifficultyLevelFragment
@@ -142,6 +146,45 @@ class GameTopFragment :
binding.playtime.text = Utils.displayableGameDuration(game.grid.playTime)
}
+
+ if (resources.getBoolean(R.bool.debuggable)) {
+ lifecycleScope.launch(Dispatchers.Default) {
+ val grid = Grid(game.grid.variant)
+
+ game.grid.cages.forEach {
+ val newCage = GridCage(it.id, grid.options.showOperators, it.action, it.cageType)
+
+ it.cells.forEach { newCage.addCell(grid.getCell(it.cellNumber)) }
+
+ newCage.result = it.result
+
+ grid.addCage(newCage)
+ }
+
+ game.grid.cells.forEach {
+ val newCell = grid.getCell(it.cellNumber)
+
+ newCell.value = it.value
+ }
+
+ val solver = HumanSolver(grid)
+ solver.prepareGrid()
+ val solverResult = solver.solveAndCalculateDifficulty()
+
+ var text = binding.difficulty.text as String + " (${solverResult.difficulty}"
+
+ if (!solverResult.success) {
+ text += "!"
+ }
+ text += ")"
+
+ launch(Dispatchers.Main) {
+ if (!binding.difficulty.text.contains(' ')) {
+ binding.difficulty.text = text
+ }
+ }
+ }
+ }
}
private fun setStarsByDifficulty(difficulty: GameDifficulty?) {
diff --git a/gauguin-app/src/main/res/menu/bottom_app_bar.xml b/gauguin-app/src/main/res/menu/bottom_app_bar.xml
index 5e9a2716..370410be 100644
--- a/gauguin-app/src/main/res/menu/bottom_app_bar.xml
+++ b/gauguin-app/src/main/res/menu/bottom_app_bar.xml
@@ -1,5 +1,6 @@
-
\ No newline at end of file
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/FillSingleCage.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/FillSingleCage.kt
new file mode 100644
index 00000000..87722a1c
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/FillSingleCage.kt
@@ -0,0 +1,20 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.grid.Grid
+
+class FillSingleCage {
+ fun fillCells(grid: Grid): Int {
+ val cagesToBeFilled = grid.cages.filter { it.cageType == GridCageType.SINGLE && !it.getCell(0).isUserValueSet }
+
+ var filledCells = 0
+
+ cagesToBeFilled.forEach {
+ grid.setUserValueAndRemovePossibles(it.getCell(0), it.result)
+
+ filledCells++
+ }
+
+ return filledCells
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLine.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLine.kt
new file mode 100644
index 00000000..7424cfe3
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLine.kt
@@ -0,0 +1,27 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+import org.piepmeyer.gauguin.grid.GridCell
+
+data class GridLine(
+ private val grid: Grid,
+ val type: GridLineType,
+ val lineNumber: Int,
+) {
+ fun contains(cell: GridCell): Boolean =
+ when (type) {
+ GridLineType.COLUMN -> cell.column == lineNumber
+ GridLineType.ROW -> cell.row == lineNumber
+ }
+
+ fun cells(): List = grid.cells.filter { contains(it) }
+
+ fun cages(): Set =
+ grid.cells
+ .filter { contains(it) }
+ .map { it.cage!! }
+ .toSet()
+
+ override fun toString(): String = "GridLine type=$type, lineNumber=$lineNumber"
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLineType.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLineType.kt
new file mode 100644
index 00000000..651e8c1c
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLineType.kt
@@ -0,0 +1,6 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+enum class GridLineType {
+ COLUMN,
+ ROW,
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLines.kt
new file mode 100644
index 00000000..47951ebb
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/GridLines.kt
@@ -0,0 +1,79 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import org.piepmeyer.gauguin.grid.Grid
+
+class GridLines(
+ private val grid: Grid,
+) {
+ fun allLines(): Set {
+ val lines = mutableSetOf()
+
+ for (column in 0.. {
+ val lines = mutableSetOf()
+
+ if (grid.gridSize.height == grid.gridSize.largestSide()) {
+ for (column in 0..> {
+ val lines = mutableSetOf>()
+
+ if (grid.gridSize.height == grid.gridSize.largestSide()) {
+ for (column in 0..> {
+ val lines = mutableSetOf>()
+
+ for (column in 0..>()
+
+ fun initialize() {
+ cageToPossibles +=
+ grid.cages.associateWith {
+ val creator = GridSingleCageCreator(grid.variant, it)
+ creator.possibleCombinations
+ }
+ }
+
+ fun validateEntries() {
+ cageToPossibles.forEach { (cage, possibles) ->
+ val possiblesToDelete =
+ possibles.filterNot {
+ cage.cells.withIndex().all { cell ->
+ if (cell.value.isUserValueSet) {
+ cell.value.userValue == it[cell.index]
+ } else {
+ cell.value.possibles.contains(it[cell.index])
+ }
+ }
+ }
+
+ if (possiblesToDelete.isNotEmpty()) {
+ cageToPossibles[cage] = possibles - possiblesToDelete.toSet()
+ }
+ }
+ }
+
+ fun possibles(cage: GridCage): List = cageToPossibles[cage]!!
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/PossiblesReducer.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/PossiblesReducer.kt
new file mode 100644
index 00000000..48f224df
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/PossiblesReducer.kt
@@ -0,0 +1,25 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import org.piepmeyer.gauguin.grid.GridCage
+
+class PossiblesReducer(
+ private val cage: GridCage,
+) {
+ fun reduceToPossibleCombinations(possibleCombinations: List): Boolean {
+ var foundPossibles = false
+
+ cage.cells.forEachIndexed { cellIndex, cell ->
+ val differentPossibles = possibleCombinations.map { it[cellIndex] }.toSet()
+
+ for (possible in cell.possibles) {
+ if (!differentPossibles.contains(possible)) {
+ cage.getCell(cellIndex).possibles -= possible
+
+ foundPossibles = true
+ }
+ }
+ }
+
+ return foundPossibles
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesOddEvenCheckSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesOddEvenCheckSum.kt
new file mode 100644
index 00000000..a1ed4b6d
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesOddEvenCheckSum.kt
@@ -0,0 +1,88 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+/**
+ * Scans two adjacent lines to detect if each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated and enforced by deleting
+ * deviant possibles.
+ */
+abstract class AbstractLinesOddEvenCheckSum(
+ private val numberOfLines: Int,
+) : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val linePairs = GridLines(grid).adjacentlinesWithEachPossibleValue(numberOfLines)
+
+ linePairs.forEach { linePair ->
+ val (singleCageNotCoveredByLines, remainingSumIsEven) = calculateSingleCageCoveredByLines(grid, linePair, cache)
+
+ singleCageNotCoveredByLines?.let { cage ->
+ val validPossibles = cache.possibles(cage)
+
+ val indexesInLines =
+ cage.cells.mapIndexedNotNull { index, cell ->
+ if (linePair.any { line -> line.contains(cell) }) {
+ index
+ } else {
+ null
+ }
+ }
+
+ val validPossiblesWithNeededSum =
+ validPossibles
+ .filter {
+ it
+ .filterIndexed { index, _ ->
+ indexesInLines.contains(index)
+ }.sum()
+ .mod(2) == if (remainingSumIsEven) 0 else 1
+ }
+
+ if (validPossiblesWithNeededSum.isNotEmpty() && validPossiblesWithNeededSum.size < validPossibles.size) {
+ val reducedPossibles = PossiblesReducer(cage).reduceToPossibleCombinations(validPossiblesWithNeededSum)
+
+ if (reducedPossibles) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun calculateSingleCageCoveredByLines(
+ grid: Grid,
+ lines: Set,
+ cache: PossiblesCache,
+ ): Pair {
+ val cages = lines.map { it.cages() }.flatten().toSet()
+ val lineCells = lines.map { it.cells() }.flatten().toSet()
+
+ var cageEvenAndOddSums: GridCage? = null
+ var remainingSumIsEven = (grid.variant.possibleDigits.sum() * numberOfLines).mod(2) == 0
+
+ cages.forEach { cage ->
+ if (EvenOddSumUtils.hasOnlyEvenOrOddSumsInCells(grid, cage, lineCells, cache)) {
+ val even = EvenOddSumUtils.hasEvenSumsOnlyInCells(grid, cage, lineCells, cache)
+
+ remainingSumIsEven = !remainingSumIsEven.xor(even)
+ } else if (cageEvenAndOddSums == null) {
+ cageEvenAndOddSums = cage
+ } else {
+ return Pair(null, true)
+ }
+ }
+
+ return Pair(cageEvenAndOddSums, remainingSumIsEven)
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesSingleCagePossiblesSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesSingleCagePossiblesSum.kt
new file mode 100644
index 00000000..ce3f32fd
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractLinesSingleCagePossiblesSum.kt
@@ -0,0 +1,88 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+abstract class AbstractLinesSingleCagePossiblesSum(
+ private val numberOfLines: Int,
+) : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val adjacentLinesSet = GridLines(grid).adjacentlinesWithEachPossibleValue(numberOfLines)
+
+ adjacentLinesSet.forEach { adjacentLines ->
+ val (singleCageNotCoveredByLines, staticGridSum) = calculateSingleCageCoveredByLines(grid, adjacentLines, cache)
+
+ singleCageNotCoveredByLines?.let { cage ->
+ val neededSumOfLines = grid.variant.possibleDigits.sum() * numberOfLines - staticGridSum
+
+ val indexesInLines =
+ cage.cells.mapIndexedNotNull { index, cell ->
+ if (adjacentLines.any { line -> line.contains(cell) }) {
+ index
+ } else {
+ null
+ }
+ }
+
+ val validPossibles = cache.possibles(cage)
+ val validPossiblesWithNeededSum =
+ validPossibles.filter {
+ it
+ .filterIndexed { index, _ ->
+ indexesInLines.contains(index)
+ }.sum() == neededSumOfLines
+ }
+
+ if (validPossiblesWithNeededSum.isNotEmpty() && validPossiblesWithNeededSum.size < validPossibles.size) {
+ val reducedPossibles = PossiblesReducer(cage).reduceToPossibleCombinations(validPossiblesWithNeededSum)
+
+ if (reducedPossibles) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun calculateSingleCageCoveredByLines(
+ grid: Grid,
+ lines: Set,
+ cache: PossiblesCache,
+ ): Pair {
+ val cages = lines.map { it.cages() }.flatten().toSet()
+ val lineCells = lines.map { it.cells() }.flatten().toSet()
+
+ var singleCageNotCoveredByLines: GridCage? = null
+ var staticGridSum = 0
+
+ cages.forEach { cage ->
+ val hasAtLeastOnePossibleInLines =
+ cage.cells
+ .filter {
+ lines.any { line -> line.contains(it) }
+ }.any { !it.isUserValueSet }
+
+ if (!StaticSumUtils.hasStaticSumInCells(grid, cage, lineCells, cache)) {
+ if (singleCageNotCoveredByLines != null && hasAtLeastOnePossibleInLines) {
+ return Pair(null, 0)
+ }
+
+ singleCageNotCoveredByLines = cage
+ } else {
+ staticGridSum += StaticSumUtils.staticSumInCells(grid, cage, lineCells, cache)
+ }
+ }
+
+ return Pair(singleCageNotCoveredByLines, staticGridSum)
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractMinMaxSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractMinMaxSum.kt
new file mode 100644
index 00000000..ab9beafe
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractMinMaxSum.kt
@@ -0,0 +1,114 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+abstract class AbstractMinMaxSum(
+ private val numberOfLines: Int,
+) : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val adjacentLinesSet = GridLines(grid).adjacentlinesWithEachPossibleValue(numberOfLines)
+
+ val sumOfAdjacentLines = grid.variant.possibleDigits.sum() * numberOfLines
+
+ adjacentLinesSet.forEach { lines ->
+ val lineCages = lines.flatMap { it.cages() }.toSet()
+
+ lineCages.forEach { cage ->
+
+ val otherCages = lineCages - cage
+
+ val (minSum, maxSum) = minAndMaxSum(otherCages, lines, cache)
+
+ val possibles = possiblesInLines(cage, lines, cache)
+
+ val possiblesWithinSum =
+ possibles.filter {
+ it.sum() + minSum <= sumOfAdjacentLines && it.sum() + maxSum >= sumOfAdjacentLines
+ }
+
+ if (possiblesWithinSum.size < possibles.size) {
+ val cellIndexes = cellIndexesInLine(cage, lines)
+
+ val validPossibles =
+ cache.possibles(cage).filter { possibles ->
+ possiblesWithinSum.any {
+ it.contentEquals(
+ possibles.filterIndexed { index, _ -> cellIndexes.contains(index) }.toIntArray(),
+ )
+ }
+ }
+
+ val reduced = PossiblesReducer(cage).reduceToPossibleCombinations(validPossibles)
+
+ if (reduced) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun minAndMaxSum(
+ otherCages: Set,
+ lines: Set,
+ cache: PossiblesCache,
+ ): Pair {
+ var minSum = 0
+ var maxSum = 0
+
+ otherCages.forEach { otherCage ->
+ val possiblesInLines = possiblesInLines(otherCage, lines, cache)
+
+ minSum += requireNotNull(possiblesInLines.minByOrNull { it.sum() }).sum()
+ maxSum += requireNotNull(possiblesInLines.maxByOrNull { it.sum() }).sum()
+ }
+
+ return Pair(minSum, maxSum)
+ }
+
+ private fun possiblesInLines(
+ cage: GridCage,
+ lines: Set,
+ cache: PossiblesCache,
+ ): List {
+ val cellIndexesInLine =
+ cellIndexesInLine(cage, lines)
+
+ val possiblesInLines =
+ cache.possibles(cage).map {
+ it
+ .filterIndexed { index, _ ->
+ cellIndexesInLine.contains(index)
+ }.toIntArray()
+ }
+
+ return possiblesInLines
+ }
+
+ private fun cellIndexesInLine(
+ cage: GridCage,
+ lines: Set,
+ ): List {
+ val cellIndexesInLine =
+ cage.cells
+ .mapIndexed { index, cell ->
+ if (lines.any { line -> line.contains(cell) }) {
+ index
+ } else {
+ null
+ }
+ }.filterNotNull()
+ return cellIndexesInLine
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractTwoCellsPossiblesSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractTwoCellsPossiblesSum.kt
new file mode 100644
index 00000000..841b9eee
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/AbstractTwoCellsPossiblesSum.kt
@@ -0,0 +1,85 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCell
+
+abstract class AbstractTwoCellsPossiblesSum(
+ private val numberOfLines: Int,
+) : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val adjacentLinesSet = GridLines(grid).adjacentlinesWithEachPossibleValue(numberOfLines)
+
+ adjacentLinesSet.forEach { adjacentLines ->
+ val (cellsNotCoveredByLines, staticGridSum) = calculateTwoCellsCoveredByLines(grid, adjacentLines, cache)
+
+ if (cellsNotCoveredByLines.size == 2) {
+ val neededSumOfLines = grid.variant.possibleDigits.sum() * numberOfLines - staticGridSum
+
+ var found = false
+
+ cellsNotCoveredByLines.forEach { cell ->
+ val otherCell = (cellsNotCoveredByLines - cell).first()
+
+ cell.possibles.forEach { possible ->
+ if (!otherCell.possibles.contains(neededSumOfLines - possible)) {
+ found = true
+ cell.possibles -= possible
+ }
+ }
+ }
+
+ if (found) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun calculateTwoCellsCoveredByLines(
+ grid: Grid,
+ lines: Set,
+ cache: PossiblesCache,
+ ): Pair, Int> {
+ val cages = lines.map { it.cages() }.flatten().toSet()
+ val lineCells = lines.map { it.cells() }.flatten().toSet()
+
+ val cellsNotCoveredByLines = mutableListOf()
+ var staticGridSum = 0
+
+ cages.forEach { cage ->
+ val dynamicSumCells =
+ cage.cells
+ .filter {
+ lines.any { line -> line.contains(it) }
+ }.filter { !it.isUserValueSet }
+
+ if (!StaticSumUtils.hasStaticSumInCells(grid, cage, lineCells, cache)) {
+ cellsNotCoveredByLines += dynamicSumCells
+ staticGridSum +=
+ cage.cells
+ .filter {
+ lines.any { line -> line.contains(it) }
+ }.filter { it.isUserValueSet }
+ .map { it.userValue }
+ .sum()
+
+ if (cellsNotCoveredByLines.size > 2) {
+ return Pair(emptyList(), 0)
+ }
+ } else {
+ staticGridSum += StaticSumUtils.staticSumInCells(grid, cage, lineCells, cache)
+ }
+ }
+
+ return Pair(cellsNotCoveredByLines, staticGridSum)
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossibleUsedInLinesByOtherCagesDualLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossibleUsedInLinesByOtherCagesDualLines.kt
new file mode 100644
index 00000000..64f064a1
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossibleUsedInLinesByOtherCagesDualLines.kt
@@ -0,0 +1,70 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+
+class DetectPossibleUsedInLinesByOtherCagesDualLines : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val lines = GridLines(grid).adjacentlines(2)
+
+ lines.forEach { dualLines ->
+
+ val cellsOfLines =
+ dualLines.map { it.cells() }.flatten()
+
+ val (cagesIntersectingWithLines, possiblesInLines) = GridLineHelper.getIntersectingCagesAndPossibles(dualLines, cache)
+
+ cagesIntersectingWithLines.forEach { cage ->
+ val possiblesForFirstCage = possiblesInLines[cage]!!
+
+ val possibleInEachFirstCageCombination =
+ grid.variant.possibleDigits.filter { possible ->
+ possiblesForFirstCage.all { it.contains(possible) }
+ }
+
+ if (possibleInEachFirstCageCombination.isNotEmpty()) {
+ cagesIntersectingWithLines
+ .filter { it.id > cage.id }
+ .forEach { otherCage ->
+ val possiblesForOtherCage = possiblesInLines[otherCage]!!
+
+ val possibleInEachOtherCageCombination =
+ grid.variant.possibleDigits.filter { possible ->
+ possiblesForOtherCage.all { it.contains(possible) }
+ }
+
+ val possiblesInBothCages =
+ possibleInEachFirstCageCombination.intersect(
+ possibleInEachOtherCageCombination,
+ )
+
+ if (possiblesInBothCages.isNotEmpty()) {
+ val foreignCells = cellsOfLines - cage.cells - otherCage.cells
+
+ var found = false
+
+ possiblesInBothCages.forEach { possible ->
+ if (foreignCells.any { it.possibles.contains(possible) }) {
+ found = true
+
+ foreignCells.forEach { it.possibles -= possible }
+ }
+ }
+
+ if (found) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossiblesBreakingOtherCagesPossiblesDualLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossiblesBreakingOtherCagesPossiblesDualLines.kt
new file mode 100644
index 00000000..7243447e
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/DetectPossiblesBreakingOtherCagesPossiblesDualLines.kt
@@ -0,0 +1,69 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+
+class DetectPossiblesBreakingOtherCagesPossiblesDualLines : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val lines = GridLines(grid).adjacentlines(2)
+
+ lines.forEach { dualLines ->
+
+ val cellsOfLines =
+ dualLines.map { it.cells() }.flatten()
+
+ val cagesContainedInBothLines =
+ dualLines
+ .map { it.cages() }
+ .flatten()
+ .filter { it.cells.all { it.isUserValueSet || cellsOfLines.contains(it) } }
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .toSet()
+
+ cagesContainedInBothLines.forEach { cage ->
+ val combinations = cache.possibles(cage)
+
+ combinations.forEach { combination ->
+ combination
+ .groupBy { it }
+ .filter { groupedSize ->
+ groupedSize.value.size == 2 && combinations.none { it.count { it == groupedSize.key } == 1 }
+ }.map { it.key }
+ .filter { doublePossible ->
+ cage.cells.none { it.userValue == doublePossible }
+ }.forEach { doublePossible ->
+ val otherCages = cagesContainedInBothLines - cage
+
+ otherCages
+ .filter { it.cells.none { it.userValue == doublePossible } }
+ .forEach { otherCage ->
+ val eachPossibleEnforcesDoublePossible =
+ cache
+ .possibles(otherCage)
+ .all { it.contains(doublePossible) }
+
+ if (eachPossibleEnforcesDoublePossible) {
+ val reducing =
+ PossiblesReducer(cage).reduceToPossibleCombinations(
+ combinations.filterNot { it.count { it == doublePossible } == 2 },
+ )
+
+ if (reducing) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/EvenOddSumUtils.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/EvenOddSumUtils.kt
new file mode 100644
index 00000000..0bb4f5e9
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/EvenOddSumUtils.kt
@@ -0,0 +1,115 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+import org.piepmeyer.gauguin.grid.GridCageAction
+import org.piepmeyer.gauguin.grid.GridCell
+
+object EvenOddSumUtils {
+ fun hasEvenSumsOnly(
+ grid: Grid,
+ cage: GridCage,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE) {
+ return cage.cells
+ .first()
+ .value
+ .mod(2) == 0
+ }
+
+ if (cage.action == GridCageAction.ACTION_ADD) {
+ return cage.result.mod(2) == 0
+ }
+
+ if (cage.cells.all { it.isUserValueSet }) {
+ return cage.cells
+ .map { it.userValue }
+ .sum()
+ .mod(2) == 0
+ }
+
+ return cache
+ .possibles(cage)
+ .map { it.sum() }
+ .distinct()
+ .all { it.mod(2) == 0 }
+ }
+
+ fun hasEvenSumsOnlyInCells(
+ grid: Grid,
+ cage: GridCage,
+ cells: Set,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE) {
+ return if (cage.cells.first() in cells) {
+ cage.cells
+ .first()
+ .value
+ .mod(2) == 0
+ } else {
+ false
+ }
+ }
+
+ val filteredCells = cage.cells.filter { it in cells }
+
+ if (filteredCells.all { it.isUserValueSet }) {
+ return filteredCells.sumOf { it.userValue }.mod(2) == 0
+ }
+
+ return cache
+ .possibles(cage)
+ .map { it.filterIndexed { index, _ -> cage.cells[index] in cells } }
+ .map { it.sum() }
+ .distinct()
+ .all { it.mod(2) == 0 }
+ }
+
+ fun hasOnlyEvenOrOddSums(
+ grid: Grid,
+ cage: GridCage,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE || cage.action == GridCageAction.ACTION_ADD) {
+ return true
+ }
+
+ if (cage.cells.all { it.isUserValueSet }) {
+ return true
+ }
+
+ val validPossiblesSums =
+ cache
+ .possibles(cage)
+ .map { it.sum() }
+ .map { it.mod(2) == 0 }
+ .distinct()
+
+ return validPossiblesSums.size == 1
+ }
+
+ fun hasOnlyEvenOrOddSumsInCells(
+ grid: Grid,
+ cage: GridCage,
+ cells: Set,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE && cage.cells.first() in cells) {
+ return true
+ }
+
+ val validPossiblesSums =
+ cache
+ .possibles(cage)
+ .map { it.filterIndexed { index, _ -> cage.cells[index] in cells } }
+ .map { it.sum() }
+ .map { it.mod(2) == 0 }
+ .distinct()
+
+ return validPossiblesSums.size == 1
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridLineHelper.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridLineHelper.kt
new file mode 100644
index 00000000..aef12f6e
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridLineHelper.kt
@@ -0,0 +1,38 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCage
+
+object GridLineHelper {
+ fun getIntersectingCagesAndPossibles(
+ dualLines: Set,
+ cache: PossiblesCache,
+ ): Pair, Map>>> {
+ val cellsOfLines =
+ dualLines.map { it.cells() }.flatten()
+
+ val cagesIntersectingWithLines =
+ dualLines
+ .map { it.cages() }
+ .flatten()
+ .filter { it.cells.any { !it.isUserValueSet && cellsOfLines.contains(it) } }
+ .toSet()
+
+ val possiblesInLines =
+ cagesIntersectingWithLines.associateWith { cage ->
+ cache
+ .possibles(cage)
+ .map {
+ it.filterIndexed {
+ index,
+ _,
+ ->
+ !cage.cells[index].isUserValueSet && cellsOfLines.contains(cage.cells[index])
+ }
+ }.toSet()
+ }
+
+ return Pair(cagesIntersectingWithLines, possiblesInLines)
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridSumEnforcesCageSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridSumEnforcesCageSum.kt
new file mode 100644
index 00000000..5ade926d
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/GridSumEnforcesCageSum.kt
@@ -0,0 +1,49 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+/*
+ * Calculates the sum of all cages having a static cage sum. If there is exactly one cage with a
+ * dynamic sum, calculate the remaining sum of it and delete all possibles which do not lead to this
+ * sum.
+ */
+class GridSumEnforcesCageSum : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ var cageWithDynamicSum: GridCage? = null
+ var staticGridSum = 0
+
+ grid.cages.forEach { cage ->
+ if (StaticSumUtils.hasStaticSum(grid, cage, cache)) {
+ staticGridSum += StaticSumUtils.staticSum(grid, cage, cache)
+ } else if (cageWithDynamicSum == null) {
+ cageWithDynamicSum = cage
+ } else {
+ return false
+ }
+ }
+
+ cageWithDynamicSum?.let { cage ->
+ val neededSumOfCage = grid.variant.possibleDigits.sum() * grid.gridSize.smallestSide() - staticGridSum
+
+ val validPossibles = cache.possibles(cage)
+ val validPossiblesWithNeededSum = validPossibles.filter { it.sum() == neededSumOfCage }
+
+ if (validPossiblesWithNeededSum.size < validPossibles.size) {
+ val reducedPossibles = PossiblesReducer(cage).reduceToPossibleCombinations(validPossiblesWithNeededSum)
+
+ if (reducedPossibles) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/ImpossibleCombinationInLineDetector.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/ImpossibleCombinationInLineDetector.kt
new file mode 100644
index 00000000..6f717a73
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/ImpossibleCombinationInLineDetector.kt
@@ -0,0 +1,72 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+private val logger = KotlinLogging.logger {}
+
+object ImpossibleCombinationInLineDetector {
+ fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ isImpossible: (GridLine, GridCage, cache: PossiblesCache, List) -> Boolean,
+ ): Boolean {
+ val lines = GridLines(grid).linesWithEachPossibleValue()
+
+ lines.forEach { line ->
+ line.cages().forEach { cage ->
+ val validPossibles =
+ cache.possibles(cage)
+
+ val lineCageCells =
+ cage.cells
+ .filter { line.contains(it) && !it.isUserValueSet }
+
+ lineCageCells.forEach { cell ->
+ val cellIndex = cage.cells.indexOf(cell)
+
+ logger.trace { "analysing $line, $cage, $cell" }
+
+ val validPossiblesOfCell = validPossibles.map { it[cellIndex] }
+
+ val validPossiblesSetOfCell = validPossiblesOfCell.distinct()
+
+ val possiblesWithSingleCombination =
+ validPossiblesSetOfCell.filter { validPossible ->
+ validPossiblesOfCell.count { it == validPossible } == 1
+ }
+
+ logger.trace { "set of possible cell values: $validPossiblesSetOfCell" }
+ logger.trace { "set of possible cell values with single combination: $possiblesWithSingleCombination" }
+
+ possiblesWithSingleCombination.forEach { singleCombinationPossible ->
+ val lineCageCellsIndexes =
+ lineCageCells
+ .map { cage.cells.indexOf(it) }
+
+ val singlePossible =
+ validPossibles
+ .first { it[cellIndex] == singleCombinationPossible }
+ .filterIndexed { index, _ ->
+ lineCageCellsIndexes.contains(index)
+ }
+
+ logger.trace { "Relevant line indexes: $lineCageCellsIndexes" }
+ logger.trace { "Single possible: $singlePossible" }
+
+ if (singlePossible.isNotEmpty() && isImpossible.invoke(line, cage, cache, singlePossible)) {
+ cell.removePossible(singleCombinationPossible)
+ return true
+ }
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LineSingleCagePossiblesSumSingle.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LineSingleCagePossiblesSumSingle.kt
new file mode 100644
index 00000000..d7cdaf61
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LineSingleCagePossiblesSumSingle.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans a single line to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class LineSingleCagePossiblesSumSingle : AbstractLinesSingleCagePossiblesSum(1)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumDual.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumDual.kt
new file mode 100644
index 00000000..c6439bb6
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumDual.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans two adjacent lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class LinesSingleCagePossiblesSumDual : AbstractLinesSingleCagePossiblesSum(2)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumTriple.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumTriple.kt
new file mode 100644
index 00000000..12481f4e
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/LinesSingleCagePossiblesSumTriple.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans three adjacent lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class LinesSingleCagePossiblesSumTriple : AbstractLinesSingleCagePossiblesSum(3)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumOneLine.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumOneLine.kt
new file mode 100644
index 00000000..1e43be28
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumOneLine.kt
@@ -0,0 +1,3 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+class MinMaxSumOneLine : AbstractMinMaxSum(1)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumThreeLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumThreeLines.kt
new file mode 100644
index 00000000..d34bd92b
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumThreeLines.kt
@@ -0,0 +1,3 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+class MinMaxSumThreeLines : AbstractMinMaxSum(3)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumTwoLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumTwoLines.kt
new file mode 100644
index 00000000..e9f93cba
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/MinMaxSumTwoLines.kt
@@ -0,0 +1,3 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+class MinMaxSumTwoLines : AbstractMinMaxSum(2)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPair.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPair.kt
new file mode 100644
index 00000000..014b7058
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPair.kt
@@ -0,0 +1,59 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCell
+
+/**
+ * Finds a naked pair, that is two cells in the same row or column which have to same list of
+ * exactly two possible values. As these values could not occur in any other cells beside these
+ * two, these values get deleted from the other cages possibles.
+ */
+class NakedPair : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val cellsWithoutUserValue = grid.cells.filter { !it.isUserValueSet }
+
+ cellsWithoutUserValue.forEach { cell ->
+ cellsWithoutUserValue.forEach { otherCell ->
+ if (isNakedPair(cell, otherCell)) {
+ val possibles = cell.possibles
+
+ val cellsOfSameRowOrColumn =
+ if (cell.row == otherCell.row) {
+ grid.getCellsAtSameRow(cell) - otherCell
+ } else {
+ grid.getCellsAtSameColumn(cell) - otherCell
+ }
+
+ val cellsWithPossibles =
+ cellsOfSameRowOrColumn
+ .filter { !it.isUserValueSet }
+ .filter { it.possibles.intersect(possibles).isNotEmpty() }
+
+ if (cellsWithPossibles.isNotEmpty()) {
+ cellsWithPossibles.forEach {
+ it.possibles -= possibles
+ }
+
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun isNakedPair(
+ cell: GridCell,
+ otherCell: GridCell,
+ ) = cell != otherCell &&
+ (cell.row == otherCell.row || cell.column == otherCell.column) &&
+ cell.possibles.size == 2 &&
+ otherCell.possibles.size == 2 &&
+ cell.possibles.containsAll(otherCell.possibles)
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTriple.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTriple.kt
new file mode 100644
index 00000000..59d0e3e2
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTriple.kt
@@ -0,0 +1,59 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+
+private val logger = KotlinLogging.logger {}
+
+/**
+ * Finds a naked triple, that is three cells in the same row or column which have to same list of
+ * exactly two possible values. As these values could not occur in any other cells beside these
+ * two, these values get deletes from the other cages possibles.
+ */
+class NakedTriple : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ GridLines(grid)
+ .allLines()
+ .map { it.cells() }
+ .forEach { lineCells ->
+ val relevantCells = lineCells.filter { !it.isUserValueSet && it.possibles.size <= 3 }
+
+ if (relevantCells.size >= 3) {
+ relevantCells.forEach { cellOne ->
+ (relevantCells - cellOne).forEach { cellTwo ->
+ (relevantCells - cellOne - cellTwo).forEach { cellThree ->
+ val possibles = cellOne.possibles + cellTwo.possibles + cellThree.possibles
+
+ if (possibles.size == 3) {
+ val otherCellsWithPossibles =
+ (lineCells - cellOne - cellTwo - cellThree)
+ .filter { !it.isUserValueSet }
+ .filter { it.possibles.intersect(possibles).isNotEmpty() }
+
+ if (otherCellsWithPossibles.isNotEmpty()) {
+ otherCellsWithPossibles.forEach {
+ it.possibles -= possibles
+ }
+
+ logger.debug {
+ "Naked triple found: ${cellOne.cellNumber}, ${cellTwo.cellNumber}, ${cellThree.cellNumber}"
+ }
+
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NumberOfCagesWithPossibleForcesPossibleInCage.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NumberOfCagesWithPossibleForcesPossibleInCage.kt
new file mode 100644
index 00000000..b4119ece
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NumberOfCagesWithPossibleForcesPossibleInCage.kt
@@ -0,0 +1,110 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+
+/**
+ * Scans the whole grid for each possible value analysing if the number of possibles contained in
+ * the cages:
+ *
+ * - Calculates the occurrences of the possible which must be fulfilled via undecided cells.
+ * - Calculates the set of cages with a static count of possibles in each combination.
+ * - Calculates the set of cages with a dynamic count of possibles.
+ * - If the static set already fulfills the needed amount of possible, delete all possible
+ * combinations of the dynamic cages which contain this possible.
+ * - If the missing count of occurrences 1 and there is exactly one dynamic cage, delete all
+ * possible combinations from this cage that do not contain the needed possible.
+ */
+class NumberOfCagesWithPossibleForcesPossibleInCage : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ grid.variant.possibleDigits.forEach { possible ->
+ val numberOfPossiblesLeft =
+ grid.variant.gridSize.smallestSide() - grid.cells.count { it.userValue == possible }
+
+ val cagesWithPossible =
+ grid.cages
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .filter { it.cells.any { it.possibles.contains(possible) } }
+
+ if (cagesWithPossible.isNotEmpty()) {
+ val cagesWithStaticNumberOfPossible =
+ cagesWithPossible.filter { cage ->
+ val staticPossibleCount =
+ cache
+ .possibles(cage)
+ .first()
+ .filterIndexed { index, _ -> !cage.cells[index].isUserValueSet }
+ .count { it == possible }
+
+ cache
+ .possibles(cage)
+ .map {
+ it.filterIndexed { index, _ -> !cage.cells[index].isUserValueSet }
+ }.all { possibleCombination ->
+ possibleCombination.count { it == possible } == staticPossibleCount
+ }
+ }
+
+ val staticNumberOfPossibles =
+ cagesWithStaticNumberOfPossible.sumOf { cage ->
+ cache
+ .possibles(cage)
+ .first()
+ .filterIndexed { index, _ -> !cage.cells[index].isUserValueSet }
+ .count { it == possible }
+ }
+
+ val cagesWithDynamicNumberOfPossible =
+ cagesWithPossible - cagesWithStaticNumberOfPossible.toSet()
+
+ if (staticNumberOfPossibles == numberOfPossiblesLeft) {
+ /*
+ * All possibles are already contained in the static cages, so we delete
+ * the possible from all other cages.
+ */
+ cagesWithDynamicNumberOfPossible.forEach { dynamicCage ->
+ val reduced =
+ PossiblesReducer(dynamicCage).reduceToPossibleCombinations(
+ cache
+ .possibles(dynamicCage)
+ .filter {
+ it
+ .filterIndexed { index, value ->
+ !dynamicCage.cells[index].isUserValueSet && value == possible
+ }.isEmpty()
+ },
+ )
+
+ if (reduced) {
+ return true
+ }
+ }
+ } else if (cagesWithDynamicNumberOfPossible.size == 1 && staticNumberOfPossibles == numberOfPossiblesLeft - 1) {
+ val dynamicCage = cagesWithDynamicNumberOfPossible.first()
+
+ val reduced =
+ PossiblesReducer(dynamicCage).reduceToPossibleCombinations(
+ cache
+ .possibles(dynamicCage)
+ .filter {
+ it
+ .filterIndexed { index, _ -> !dynamicCage.cells[index].isUserValueSet }
+ .count { it == possible } == 1
+ },
+ )
+
+ if (reduced) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckGridSum.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckGridSum.kt
new file mode 100644
index 00000000..fb852f38
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckGridSum.kt
@@ -0,0 +1,49 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+/*
+ * Calculates the even/odd sum of all cages having a static cage sum. If there is exactly one cage
+ * with a dynamic even/odd sum, the even/odd state of the remaining cage gets calculated. All
+ * combinations which do not lead to such a even or odd sum get deleted.
+ */
+class OddEvenCheckGridSum : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ var cageEvenAndOddSums: GridCage? = null
+ var remainingSumIsEven = (grid.variant.possibleDigits.sum() * grid.gridSize.smallestSide()).mod(2) == 0
+
+ grid.cages.forEach { cage ->
+ if (EvenOddSumUtils.hasOnlyEvenOrOddSums(grid, cage, cache)) {
+ val even = EvenOddSumUtils.hasEvenSumsOnly(grid, cage, cache)
+
+ remainingSumIsEven = !remainingSumIsEven.xor(even)
+ } else if (cageEvenAndOddSums == null) {
+ cageEvenAndOddSums = cage
+ } else {
+ return false
+ }
+ }
+
+ cageEvenAndOddSums?.let { cage ->
+ val validPossibles = cache.possibles(cage)
+ val validPossiblesWithNeededSum = validPossibles.filter { it.sum().mod(2) == if (remainingSumIsEven) 0 else 1 }
+
+ if (validPossiblesWithNeededSum.size < validPossibles.size) {
+ val reducedPossibles = PossiblesReducer(cage).reduceToPossibleCombinations(validPossiblesWithNeededSum)
+
+ if (reducedPossibles) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumDual.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumDual.kt
new file mode 100644
index 00000000..af2060c4
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumDual.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans two adjacent lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class OddEvenCheckSumDual : AbstractLinesOddEvenCheckSum(2)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumSingle.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumSingle.kt
new file mode 100644
index 00000000..635ecffb
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumSingle.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans one line to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class OddEvenCheckSumSingle : AbstractLinesOddEvenCheckSum(1)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumTriple.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumTriple.kt
new file mode 100644
index 00000000..aaa31fa8
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/OddEvenCheckSumTriple.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans three adjacent lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class OddEvenCheckSumTriple : AbstractLinesOddEvenCheckSum(3)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLine.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLine.kt
new file mode 100644
index 00000000..db75cadc
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLine.kt
@@ -0,0 +1,66 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLines
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+class PossibleMustBeContainedInSingleCageInLine : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ val lines = GridLines(grid).linesWithEachPossibleValue()
+
+ lines.forEach { line ->
+ for (singlePossible in grid.variant.possibleDigits) {
+ val cagesWithPossible =
+ line
+ .cells()
+ .filter { it.possibles.contains(singlePossible) }
+ .map { it.cage!! }
+ .toSet()
+
+ if (cagesWithPossible.size == 1) {
+ val cage = cagesWithPossible.first()
+
+ val validPossibles =
+ cache
+ .possibles(cage)
+ .filter {
+ it.withIndex().any { possibleWithIndex ->
+ possibleWithIndex.value == singlePossible &&
+ line.contains(cage.cells[possibleWithIndex.index])
+ }
+ }
+
+ if (validPossibles.isNotEmpty()) {
+ if (deletePossibleInSingleCage(cage, validPossibles)) return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun deletePossibleInSingleCage(
+ cage: GridCage,
+ validPossibles: List,
+ ): Boolean {
+ for (cellNumber in 0..
+ line
+ .cages()
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .forEach { cage ->
+
+ val validPossibles =
+ cache
+ .possibles(cage)
+ .map {
+ it.filterIndexed { index, _ ->
+ line.contains(cage.cells[index])
+ }
+ }
+
+ if (validPossibles.isNotEmpty()) {
+ val possibleDigitsAlwaysInLine =
+ grid.variant.possibleDigits.filter { possible ->
+ validPossibles.all { it.contains(possible) }
+ }
+
+ if (deletePossibleInSingleCage(
+ line,
+ cage,
+ possibleDigitsAlwaysInLine,
+ )
+ ) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun deletePossibleInSingleCage(
+ line: GridLine,
+ cage: GridCage,
+ possiblesToBeDeleted: List,
+ ): Boolean {
+ line
+ .cells()
+ .filter { it.cage != cage && !it.isUserValueSet }
+ .forEach { cell ->
+ possiblesToBeDeleted.forEach { possibleToBeDeleted ->
+ if (cell.possibles.contains(possibleToBeDeleted)) {
+ println("In line deletion: $line, cage to ignore $cage, $possibleToBeDeleted")
+ cell.removePossible(possibleToBeDeleted)
+
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCageCombinations.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCageCombinations.kt
new file mode 100644
index 00000000..284fe5c0
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCageCombinations.kt
@@ -0,0 +1,30 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.difficulty.human.PossiblesReducer
+import org.piepmeyer.gauguin.grid.Grid
+
+/**
+ * Looks out if a cage's cells contain possibles which are not included in any
+ * valid combination. If so, deletes these possibles out of all the cage's
+ * cells.
+ */
+class RemoveImpossibleCageCombinations : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ grid.cages
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .forEach { cage ->
+ val reducedPossibles = PossiblesReducer(cage).reduceToPossibleCombinations(cache.possibles(cage))
+
+ if (reducedPossibles) {
+ return true
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfPossiblesOfOtherCage.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfPossiblesOfOtherCage.kt
new file mode 100644
index 00000000..d3403dbd
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfPossiblesOfOtherCage.kt
@@ -0,0 +1,54 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+/*
+ * Detects and deletes possibles if a possible is included in a single combination
+ * of the cage and that combination may not be chosen because there is another cell
+ * in the line which only has possibles left contained in the single combination
+ */
+class RemoveImpossibleCombinationInLineBecauseOfPossiblesOfOtherCage : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean = ImpossibleCombinationInLineDetector.fillCells(grid, cache, this::isImpossible)
+
+ private fun isImpossible(
+ line: GridLine,
+ cage: GridCage,
+ cache: PossiblesCache,
+ singlePossible: List,
+ ): Boolean {
+ line
+ .cages()
+ .filter { it != cage }
+ .forEach { otherCage ->
+ val validPossiblesOtherCage =
+ cache.possibles(otherCage)
+
+ val otherCageLineCellsIndexes =
+ otherCage.cells
+ .filter { line.contains(it) && !it.isUserValueSet }
+ .map { otherCage.cells.indexOf(it) }
+
+ val allPossiblesInvalid =
+ validPossiblesOtherCage.all { validPossibles ->
+ validPossibles
+ .filterIndexed { index, _ ->
+ otherCageLineCellsIndexes.contains(index)
+ }.intersect(singlePossible.toSet())
+ .isNotEmpty()
+ }
+
+ if (allPossiblesInvalid) {
+ return true
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfSingleCell.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfSingleCell.kt
new file mode 100644
index 00000000..3d5872f4
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineBecauseOfSingleCell.kt
@@ -0,0 +1,40 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.piepmeyer.gauguin.difficulty.human.GridLine
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+
+private val logger = KotlinLogging.logger {}
+
+/*
+ * Detects and deletes possibles if a possible is included in a single combination
+ * of the cage and that combination may not be chosen because there is another cell
+ * in the line which only has possibles left contained in the single combination
+ */
+class RemoveImpossibleCombinationInLineBecauseOfSingleCell : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean = ImpossibleCombinationInLineDetector.fillCells(grid, cache, this::isImpossible)
+
+ private fun isImpossible(
+ line: GridLine,
+ cage: GridCage,
+ cache: PossiblesCache,
+ singlePossible: List,
+ ): Boolean {
+ line
+ .cells()
+ .filter { it.cage() != cage && !it.isUserValueSet }
+ .forEach { otherCell ->
+ if (singlePossible.containsAll(otherCell.possibles)) {
+ return true
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemovePossibleWithoutCombination.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemovePossibleWithoutCombination.kt
new file mode 100644
index 00000000..e825cf62
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemovePossibleWithoutCombination.kt
@@ -0,0 +1,36 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+
+/**
+ * Calculates all possible combinations per cage and deletes one possible that is not contained
+ * in one of the combinations.
+ */
+class RemovePossibleWithoutCombination : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ grid.cages
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .forEach { cage ->
+ val possibles = cache.possibles(cage)
+
+ cage.cells.forEachIndexed { index, cageCell ->
+ if (!cageCell.isUserValueSet) {
+ cageCell.possibles.forEach { possibleValue ->
+ if (possibles.none { it[index] == possibleValue }) {
+ cageCell.removePossible(possibleValue)
+
+ return true
+ }
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/SinglePossibleInCage.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/SinglePossibleInCage.kt
new file mode 100644
index 00000000..7c8b9707
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/SinglePossibleInCage.kt
@@ -0,0 +1,33 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+
+class SinglePossibleInCage : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ grid.cages
+ .filter { it.cells.any { !it.isUserValueSet } }
+ .forEach { cage ->
+ val validPossibles = cache.possibles(cage)
+
+ for (cellNumber in 0..
+ line
+ .cells()
+ .filter { !it.isUserValueSet }
+ .forEach { cell ->
+ val otherCellsInLine = line.cells() - cell
+
+ cell.possibles.forEach { possible ->
+ if (otherCellsInLine
+ .map { it.possibles }
+ .none { it.contains(possible) }
+ ) {
+ grid.setUserValueAndRemovePossibles(cell, possible)
+
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/StaticSumUtils.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/StaticSumUtils.kt
new file mode 100644
index 00000000..2807a66b
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/StaticSumUtils.kt
@@ -0,0 +1,94 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+import org.piepmeyer.gauguin.grid.GridCage
+import org.piepmeyer.gauguin.grid.GridCageAction
+import org.piepmeyer.gauguin.grid.GridCell
+
+object StaticSumUtils {
+ fun staticSum(
+ grid: Grid,
+ cage: GridCage,
+ cache: PossiblesCache,
+ ): Int {
+ if (cage.cageType == GridCageType.SINGLE) {
+ return cage.cells.first().value
+ }
+
+ if (cage.cells.all { it.isUserValueSet }) {
+ return cage.cells.map { it.userValue }.sum()
+ }
+
+ return cache
+ .possibles(cage)
+ .first()
+ .sum()
+ }
+
+ fun staticSumInCells(
+ grid: Grid,
+ cage: GridCage,
+ cells: Set,
+ cache: PossiblesCache,
+ ): Int {
+ if (cage.cageType == GridCageType.SINGLE) {
+ return if (cage.cells.first() in cells) {
+ cage.cells.first().value
+ } else {
+ 0
+ }
+ }
+
+ val filteredCells = cage.cells.filter { it in cells }
+
+ if (filteredCells.all { it.isUserValueSet }) {
+ return filteredCells.sumOf { it.userValue }
+ }
+
+ return cache
+ .possibles(cage)
+ .map { it.filterIndexed { index, _ -> cage.cells[index] in cells } }
+ .first()
+ .sum()
+ }
+
+ fun hasStaticSum(
+ grid: Grid,
+ cage: GridCage,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE || cage.action == GridCageAction.ACTION_ADD) {
+ return true
+ }
+
+ val validPossiblesSums =
+ cache
+ .possibles(cage)
+ .map { it.sum() }
+ .distinct()
+
+ return validPossiblesSums.size == 1
+ }
+
+ fun hasStaticSumInCells(
+ grid: Grid,
+ cage: GridCage,
+ cells: Set,
+ cache: PossiblesCache,
+ ): Boolean {
+ if (cage.cageType == GridCageType.SINGLE && cage.cells.first() in cells) {
+ return true
+ }
+
+ val validPossiblesSums =
+ cache
+ .possibles(cage)
+ .map { it.filterIndexed { index, _ -> cage.cells[index] in cells } }
+ .map { it.sum() }
+ .distinct()
+
+ return validPossiblesSums.size == 1
+ }
+}
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumSingleLine.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumSingleLine.kt
new file mode 100644
index 00000000..7dd2dcd2
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumSingleLine.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans a single line to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class TwoCellsPossiblesSumSingleLine : AbstractTwoCellsPossiblesSum(1)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumThreeLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumThreeLines.kt
new file mode 100644
index 00000000..1d544f21
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumThreeLines.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans three lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class TwoCellsPossiblesSumThreeLines : AbstractTwoCellsPossiblesSum(3)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumTwoLines.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumTwoLines.kt
new file mode 100644
index 00000000..202d09d1
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/TwoCellsPossiblesSumTwoLines.kt
@@ -0,0 +1,8 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+/**
+ * Scans two lines to find that each part of cage contained in this lines has a static sum
+ * excluding one part of cage. The sum of this part of cages is calculated all enforced by deleting
+ * deviant possibles.
+ */
+class TwoCellsPossiblesSumTwoLines : AbstractTwoCellsPossiblesSum(2)
diff --git a/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/XWing.kt b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/XWing.kt
new file mode 100644
index 00000000..ddb37719
--- /dev/null
+++ b/gauguin-core/src/main/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/XWing.kt
@@ -0,0 +1,84 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import org.piepmeyer.gauguin.difficulty.human.HumanSolverStrategy
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.Grid
+
+class XWing : HumanSolverStrategy {
+ override fun fillCells(
+ grid: Grid,
+ cache: PossiblesCache,
+ ): Boolean {
+ for (x in 0.. {
+ return cells.filter { it.row == cell.row && it != cell }
+ }
+
+ fun getCellsAtSameColumn(cell: GridCell): List {
+ return cells.filter { it.column == cell.column && it != cell }
+ }
+
val options: GameOptionsVariant
get() = variant.options
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultyBalanceTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultyBalanceTest.kt
new file mode 100644
index 00000000..4c66e3be
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultyBalanceTest.kt
@@ -0,0 +1,54 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.game.save.SaveGame
+import java.io.File
+import java.nio.file.Files
+import java.util.stream.Collectors
+import kotlin.io.path.isDirectory
+import kotlin.io.path.name
+
+private val logger = KotlinLogging.logger {}
+
+class HumanDifficultyBalanceTest :
+ FunSpec({
+ test("balancing") {
+ val savedGames =
+ Files
+ .list(File("src/test/resources/difficulty-balancing").toPath())
+ .collect(Collectors.toList())
+ .filter { !it.isDirectory() }
+
+ val namesToGrids =
+ savedGames
+ .associateWith {
+ val grid = SaveGame.createWithFile(it.toFile()).restore()!!
+ grid.clearUserValues()
+ grid.addPossiblesAtNewGame()
+
+ grid
+ }.mapKeys { it.key.name }
+
+ val namesToDifficulties =
+ namesToGrids.mapValues {
+ logger.info { it.key + "..." }
+
+ val result = HumanSolver(it.value).solveAndCalculateDifficulty()
+
+ withClue(it.key) {
+ result.success shouldBe true
+ }
+
+ result.difficulty
+ }
+
+ namesToDifficulties.entries
+ .sortedBy { it.value }
+ .forEach {
+ logger.info { "${it.value} -> ${it.key}" }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverHandpickedTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverHandpickedTest.kt
new file mode 100644
index 00000000..d03dadf4
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverHandpickedTest.kt
@@ -0,0 +1,88 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.MergingCageGridCalculator
+import org.piepmeyer.gauguin.creation.RandomPossibleDigitsShuffler
+import org.piepmeyer.gauguin.creation.SeedRandomizerMock
+import org.piepmeyer.gauguin.grid.GridSize
+import org.piepmeyer.gauguin.options.GameOptionsVariant
+import org.piepmeyer.gauguin.options.GameVariant
+
+class HumanDifficultySolverHandpickedTest :
+ FunSpec({
+ test("seed random grid should be solved") {
+ val randomizer = SeedRandomizerMock(16)
+
+ val calculator =
+ MergingCageGridCalculator(
+ GameVariant(
+ GridSize(3, 6),
+ GameOptionsVariant.createClassic(),
+ ),
+ randomizer,
+ RandomPossibleDigitsShuffler(randomizer.random),
+ )
+
+ val grid = calculator.calculate()
+ grid.cells.forEach { it.possibles = grid.variant.possibleDigits }
+
+ val solver = HumanSolver(grid, true)
+
+ solver.solveAndCalculateDifficulty()
+
+ println(grid.toString())
+
+ grid.isSolved() shouldBe true
+ }
+
+ test("merging algo 4x4 wrong solution") {
+ val randomizer = SeedRandomizerMock(6096)
+
+ val calculator =
+ MergingCageGridCalculator(
+ GameVariant(
+ GridSize(4, 4),
+ GameOptionsVariant.createClassic(),
+ ),
+ randomizer,
+ RandomPossibleDigitsShuffler(randomizer.random),
+ )
+
+ val grid = calculator.calculate()
+ grid.cells.forEach { it.possibles = grid.variant.possibleDigits }
+
+ val solver = HumanSolver(grid)
+
+ solver.solveAndCalculateDifficulty()
+
+ println(grid.toString())
+
+ grid.isSolved() shouldBe true
+ }
+
+ test("merging algo 4x4 DetectPossiblesBreakingOtherCagesPossiblesDualLines bug") {
+ val randomizer = SeedRandomizerMock(36)
+
+ val calculator =
+ MergingCageGridCalculator(
+ GameVariant(
+ GridSize(5, 5),
+ GameOptionsVariant.createClassic(),
+ ),
+ randomizer,
+ RandomPossibleDigitsShuffler(randomizer.random),
+ )
+
+ val grid = calculator.calculate()
+ grid.cells.forEach { it.possibles = grid.variant.possibleDigits }
+
+ val solver = HumanSolver(grid)
+
+ solver.solveAndCalculateDifficulty()
+
+ println(grid.toString())
+
+ grid.isSolved() shouldBe true
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverPerformanceTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverPerformanceTest.kt
new file mode 100644
index 00000000..a51d3376
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverPerformanceTest.kt
@@ -0,0 +1,53 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.ints.shouldBeGreaterThan
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.RandomCageGridCalculator
+import org.piepmeyer.gauguin.creation.RandomPossibleDigitsShuffler
+import org.piepmeyer.gauguin.creation.SeedRandomizerMock
+import org.piepmeyer.gauguin.grid.GridSize
+import org.piepmeyer.gauguin.options.GameOptionsVariant
+import org.piepmeyer.gauguin.options.GameVariant
+
+class HumanDifficultySolverPerformanceTest :
+ FunSpec({
+ for (seed in 0..9) {
+ withClue("seed $seed") {
+
+ test("seed random grid should be solved") {
+ val randomizer = SeedRandomizerMock(1)
+
+ val calculator =
+ RandomCageGridCalculator(
+ GameVariant(
+ GridSize(11, 11),
+ GameOptionsVariant.createClassic(),
+ ),
+ randomizer,
+ RandomPossibleDigitsShuffler(randomizer.random),
+ )
+
+ val grid = calculator.calculate()
+ grid.cells.forEach { it.possibles = grid.variant.possibleDigits }
+
+ val solver = HumanSolver(grid)
+
+ val solverResult = solver.solveAndCalculateDifficulty()
+
+ println(grid.toString())
+
+ if (!grid.isSolved()) {
+ if (grid.numberOfMistakes() != 0) {
+ throw IllegalStateException("Found a grid with wrong values.")
+ }
+ }
+
+ grid.isSolved() shouldBe true
+
+ solverResult.difficulty shouldBeGreaterThan 0
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverTest.kt
new file mode 100644
index 00000000..d5fbe93e
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/HumanDifficultySolverTest.kt
@@ -0,0 +1,70 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.ints.shouldBeGreaterThan
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.MergingCageGridCalculator
+import org.piepmeyer.gauguin.creation.RandomPossibleDigitsShuffler
+import org.piepmeyer.gauguin.creation.SeedRandomizerMock
+import org.piepmeyer.gauguin.grid.GridSize
+import org.piepmeyer.gauguin.options.GameOptionsVariant
+import org.piepmeyer.gauguin.options.GameVariant
+
+class HumanDifficultySolverTest :
+ FunSpec({
+ for (seed in 0..999) {
+ // 10_000 of 4x4, random: 4 left unsolved
+ // 10_000 of 4x4, merge: 19 left unsolved
+ // 10_000 of 5x5, merge: 134 left unsolved
+ // 10_000 of 2x4, merge: no (!) left unsolved
+ // 1_000 of 3x6, merge: 119 left unsolved
+ // 100 of 9x9, merge: 51 left unsolved
+ withClue("seed $seed") {
+ test("seed random grid should be solved") {
+ val randomizer = SeedRandomizerMock(seed)
+
+ val calculator =
+ MergingCageGridCalculator(
+ GameVariant(
+ GridSize(3, 6),
+ GameOptionsVariant.createClassic(),
+ ),
+ randomizer,
+ RandomPossibleDigitsShuffler(randomizer.random),
+ )
+
+ val grid = calculator.calculate()
+ grid.cells.forEach { it.possibles = grid.variant.possibleDigits }
+
+ val solver = HumanSolver(grid, true)
+
+ val solverResult = solver.solveAndCalculateDifficulty()
+
+ println(grid.toString())
+
+ if (!grid.isSolved()) {
+ if (grid.numberOfMistakes() != 0) {
+ throw IllegalStateException("Found a grid with wrong values.")
+ }
+ grid.isActive = true
+ grid.startedToBePlayed = true
+ grid.description = "${grid.gridSize.width}x${grid.gridSize.height}-$seed"
+ /*val saveGame =
+ SaveGame.createWithFile(
+ File(
+ SaveGame.SAVEGAME_NAME_PREFIX +
+ "${grid.numberOfMistakes()}-${grid.gridSize.width}x${grid.gridSize.height}-$seed.yml",
+ ),
+ )
+
+ saveGame.save(grid)*/
+ }
+
+ grid.isSolved() shouldBe true
+
+ solverResult.difficulty shouldBeGreaterThan 0
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/LinesPossiblesSumDualTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/LinesPossiblesSumDualTest.kt
new file mode 100644
index 00000000..8757bf1e
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/LinesPossiblesSumDualTest.kt
@@ -0,0 +1,80 @@
+package org.piepmeyer.gauguin.difficulty.human
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.strategy.LinesSingleCagePossiblesSumDual
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class LinesPossiblesSumDualTest :
+ FunSpec({
+
+ test("4x4 grid") {
+ val grid =
+ GridBuilder(4, 4)
+ .addSingleCage(3, 3)
+ .addSingleCage(2, 14)
+ .addCage(
+ 24,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.L_HORIZONTAL_SHORT_RIGHT_BOTTOM,
+ 0,
+ ).addCage(
+ 1,
+ GridCageAction.ACTION_SUBTRACT,
+ GridCageType.DOUBLE_VERTICAL,
+ 4,
+ ).addCage(
+ 9,
+ GridCageAction.ACTION_ADD,
+ GridCageType.ANGLE_RIGHT_TOP,
+ 5,
+ ).addCage(
+ 8,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.TRIPLE_VERTICAL,
+ 7,
+ ).addCage(
+ 3,
+ GridCageAction.ACTION_DIVIDE,
+ GridCageType.DOUBLE_HORIZONTAL,
+ 12,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(2, 4)
+ grid.cells[1].possibles = setOf(2, 4)
+ grid.cells[2].userValue = 1
+ grid.cells[3].userValue = 3
+ grid.cells[4].possibles = setOf(1, 2, 4)
+ grid.cells[5].possibles = setOf(2, 4)
+ grid.cells[6].userValue = 3
+ grid.cells[7].possibles = setOf(1, 2)
+ grid.cells[8].possibles = setOf(1, 2, 3)
+ grid.cells[9].possibles = setOf(1, 3)
+ grid.cells[10].userValue = 4
+ grid.cells[11].possibles = setOf(1, 2)
+ grid.cells[12].possibles = setOf(1, 3)
+ grid.cells[13].possibles = setOf(1, 3)
+ grid.cells[14].userValue = 2
+ grid.cells[15].userValue = 4
+
+ val solver = LinesSingleCagePossiblesSumDual()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("cell 4") {
+ grid.cells[4].possibles shouldContainExactly setOf(2, 4)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPairTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPairTest.kt
new file mode 100644
index 00000000..8e45a138
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedPairTest.kt
@@ -0,0 +1,49 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class NakedPairTest :
+ FunSpec({
+
+ test("4x1 grid") {
+ val grid =
+ GridBuilder(4, 1)
+ .addSingleCage(2, 2)
+ .addSingleCage(4, 3)
+ .addCage(
+ 3,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.DOUBLE_HORIZONTAL,
+ 0,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(1, 3)
+ grid.cells[1].possibles = setOf(1, 3)
+ grid.cells[2].possibles = setOf(1, 2, 3, 4)
+ grid.cells[3].possibles = setOf(1, 2, 3, 4)
+
+ val solver = NakedPair()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("possibles should be deleted from other cells") {
+ grid.cells[2].possibles shouldContainExactly setOf(2, 4)
+ grid.cells[3].possibles shouldContainExactly setOf(2, 4)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTripleTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTripleTest.kt
new file mode 100644
index 00000000..ac988b42
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/NakedTripleTest.kt
@@ -0,0 +1,85 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class NakedTripleTest :
+ FunSpec({
+
+ test("naked triple with all three possibles in each cell") {
+ val grid =
+ GridBuilder(5, 1)
+ .addSingleCage(4, 3)
+ .addSingleCage(5, 4)
+ .addCage(
+ 6,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.TRIPLE_HORIZONTAL,
+ 0,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(1, 2, 3)
+ grid.cells[1].possibles = setOf(1, 2, 3)
+ grid.cells[2].possibles = setOf(1, 2, 3)
+ grid.cells[3].possibles = setOf(1, 2, 3, 4, 5)
+ grid.cells[4].possibles = setOf(1, 2, 3, 4, 5)
+
+ val solver = NakedTriple()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("possibles should be deleted from other cells") {
+ grid.cells[3].possibles shouldContainExactly setOf(4, 5)
+ grid.cells[4].possibles shouldContainExactly setOf(4, 5)
+ }
+ }
+ }
+
+ test("naked triple with different possible combination in each cell") {
+ val grid =
+ GridBuilder(5, 1)
+ .addSingleCage(4, 3)
+ .addSingleCage(5, 4)
+ .addCage(
+ 6,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.TRIPLE_HORIZONTAL,
+ 0,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(1, 2)
+ grid.cells[1].possibles = setOf(2, 3)
+ grid.cells[2].possibles = setOf(1, 3)
+ grid.cells[3].possibles = setOf(1, 2, 3, 4, 5)
+ grid.cells[4].possibles = setOf(1, 2, 3, 4, 5)
+
+ val solver = NakedTriple()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("possibles should be deleted from other cells") {
+ grid.cells[3].possibles shouldContainExactly setOf(4, 5)
+ grid.cells[4].possibles shouldContainExactly setOf(4, 5)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineDeleteFromOtherCagesTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineDeleteFromOtherCagesTest.kt
new file mode 100644
index 00000000..9db0e1a5
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineDeleteFromOtherCagesTest.kt
@@ -0,0 +1,79 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class PossibleMustBeContainedInSingleCageInLineDeleteFromOtherCagesTest :
+ FunSpec({
+
+ test("2x6 grid") {
+ val grid =
+ GridBuilder(2, 6)
+ .addCage(
+ 72,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.ANGLE_RIGHT_BOTTOM,
+ 0,
+ ).addCage(
+ 9,
+ GridCageAction.ACTION_ADD,
+ GridCageType.ANGLE_LEFT_TOP,
+ 3,
+ ).addCage(
+ 10,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.TRIPLE_VERTICAL,
+ 6,
+ ).addCage(
+ 12,
+ GridCageAction.ACTION_ADD,
+ GridCageType.TRIPLE_VERTICAL,
+ 7,
+ ).createGrid()
+
+ // first column
+ grid.cells[0].possibles = setOf(2, 3, 4, 6)
+ grid.cells[2].possibles = setOf(4, 6)
+ grid.cells[4].possibles = setOf(2, 3, 4, 5, 6)
+ grid.cells[6].possibles = setOf(1, 2, 5)
+ grid.cells[8].possibles = setOf(1, 2, 5)
+ grid.cells[10].possibles = setOf(1, 2)
+ // second column
+ grid.cells[1].possibles = setOf(3, 4, 6)
+ grid.cells[3].possibles = setOf(1, 2, 4)
+ grid.cells[5].possibles = setOf(2, 3, 4, 5, 6)
+ grid.cells[7].possibles = setOf(1, 2, 3, 4, 5, 6)
+ grid.cells[9].possibles = setOf(1, 2, 3, 4, 5, 6)
+ grid.cells[11].possibles = setOf(1, 2, 3, 4, 6)
+
+ val solver = PossibleMustBeContainedInSingleCageInLineDeleteFromOtherCages()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("cell 0") {
+ grid.cells[0].possibles shouldContainExactly setOf(3, 4, 6)
+ }
+ withClue("cell 2") {
+ grid.cells[2].possibles shouldContainExactly setOf(4, 6)
+ }
+ withClue("cell 4") {
+ grid.cells[4].possibles shouldContainExactly setOf(3, 4, 6)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineTest.kt
new file mode 100644
index 00000000..a8344b6d
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/PossibleMustBeContainedInSingleCageInLineTest.kt
@@ -0,0 +1,48 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class PossibleMustBeContainedInSingleCageInLineTest :
+ FunSpec({
+
+ test("1x5 grid and 5x1 grid with same data") {
+ for ((width, height) in mapOf(1 to 5, 5 to 1)) {
+ withClue("width $width, height $height") {
+ val grid =
+ GridBuilder(width, height)
+ .addSingleCage(2, 0)
+ .addCage(
+ 10,
+ GridCageAction.ACTION_ADD,
+ if (width == 1) GridCageType.TRIPLE_VERTICAL else GridCageType.TRIPLE_HORIZONTAL,
+ 1,
+ ).addSingleCage(2, 4)
+ .createGrid()
+
+ grid.cells[0].possibles = setOf(3, 4)
+ grid.cells[1].possibles = setOf(2, 3, 4)
+ grid.cells[2].userValue = 5
+ grid.cells[3].possibles = setOf(1, 2, 3)
+ grid.cells[4].possibles = setOf(1, 4)
+
+ println(grid)
+
+ val solver = PossibleMustBeContainedInSingleCageInLine()
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ grid.cells[1].possibles shouldContainExactly setOf(2, 3)
+ grid.cells[3].possibles shouldContainExactly setOf(2, 3)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineTest.kt b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineTest.kt
new file mode 100644
index 00000000..9c698f4b
--- /dev/null
+++ b/gauguin-core/src/test/kotlin/org/piepmeyer/gauguin/difficulty/human/strategy/RemoveImpossibleCombinationInLineTest.kt
@@ -0,0 +1,150 @@
+package org.piepmeyer.gauguin.difficulty.human.strategy
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.shouldBe
+import org.piepmeyer.gauguin.creation.GridBuilder
+import org.piepmeyer.gauguin.creation.cage.GridCageType
+import org.piepmeyer.gauguin.difficulty.human.PossiblesCache
+import org.piepmeyer.gauguin.grid.GridCageAction
+
+class RemoveImpossibleCombinationInLineTest :
+ FunSpec({
+
+ test("4x4 grid") {
+ val grid =
+ GridBuilder(4, 4)
+ .addSingleCage(1, 3)
+ .addSingleCage(4, 10)
+ .addCage(
+ 24,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.TRIPLE_HORIZONTAL,
+ 0,
+ ).addCage(
+ 24,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.L_VERTICAL_SHORT_RIGHT_TOP,
+ 4,
+ ).addCage(
+ 8,
+ GridCageAction.ACTION_ADD,
+ GridCageType.ANGLE_LEFT_BOTTOM,
+ 6,
+ ).addCage(
+ 2,
+ GridCageAction.ACTION_DIVIDE,
+ GridCageType.DOUBLE_VERTICAL,
+ 9,
+ ).addCage(
+ 1,
+ GridCageAction.ACTION_SUBTRACT,
+ GridCageType.DOUBLE_HORIZONTAL,
+ 14,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(3, 4)
+ grid.cells[1].possibles = setOf(3, 4)
+ grid.cells[2].userValue = 2
+ grid.cells[3].userValue = 1
+ grid.cells[4].possibles = setOf(1, 2, 3, 4)
+ grid.cells[5].possibles = setOf(3, 4)
+ grid.cells[6].possibles = setOf(1, 3)
+ grid.cells[7].possibles = setOf(2, 4)
+ grid.cells[8].possibles = setOf(1, 2)
+ grid.cells[9].possibles = setOf(1, 2)
+ grid.cells[10].userValue = 4
+ grid.cells[11].userValue = 3
+ grid.cells[12].possibles = setOf(1, 2, 3, 4)
+ grid.cells[13].possibles = setOf(1, 2)
+ grid.cells[14].possibles = setOf(1, 3)
+ grid.cells[15].possibles = setOf(2, 4)
+
+ val solver = RemoveImpossibleCombinationInLineBecauseOfSingleCell()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("cell 14") {
+ grid.cells[14].possibles shouldContainExactly setOf(3)
+ }
+ }
+ }
+
+ test("4x4 grid impossible combinations") {
+ val grid =
+ GridBuilder(4, 4)
+ .addSingleCage(2, 3)
+ .addSingleCage(1, 13)
+ .addCage(
+ 3,
+ GridCageAction.ACTION_DIVIDE,
+ GridCageType.DOUBLE_VERTICAL,
+ 0,
+ ).addCage(
+ 9,
+ GridCageAction.ACTION_ADD,
+ GridCageType.ANGLE_LEFT_BOTTOM,
+ 1,
+ ).addCage(
+ 6,
+ GridCageAction.ACTION_MULTIPLY,
+ GridCageType.DOUBLE_VERTICAL,
+ 5,
+ ).addCage(
+ 8,
+ GridCageAction.ACTION_ADD,
+ GridCageType.TRIPLE_VERTICAL,
+ 7,
+ ).addCage(
+ 2,
+ GridCageAction.ACTION_DIVIDE,
+ GridCageType.DOUBLE_VERTICAL,
+ 8,
+ ).addCage(
+ 1,
+ GridCageAction.ACTION_SUBTRACT,
+ GridCageType.DOUBLE_VERTICAL,
+ 10,
+ ).createGrid()
+
+ grid.cells[0].possibles = setOf(1, 3)
+ grid.cells[1].userValue = 4
+ grid.cells[2].possibles = setOf(1, 3)
+ grid.cells[3].userValue = 2
+ grid.cells[4].possibles = setOf(1, 3)
+ grid.cells[5].possibles = setOf(2, 3)
+ grid.cells[6].possibles = setOf(2, 4)
+ grid.cells[7].possibles = setOf(1, 3, 4)
+ grid.cells[8].possibles = setOf(2, 4)
+ grid.cells[9].possibles = setOf(2, 3)
+ grid.cells[10].possibles = setOf(1, 2, 3, 4)
+ grid.cells[11].possibles = setOf(1, 3, 4)
+ grid.cells[12].possibles = setOf(2, 4)
+ grid.cells[13].userValue = 1
+ grid.cells[14].possibles = setOf(2, 3, 4)
+ grid.cells[15].possibles = setOf(3, 4)
+
+ val solver = RemoveImpossibleCombinationInLineBecauseOfPossiblesOfOtherCage()
+
+ println(grid)
+
+ // solver should find two possibles and delete one of them for each run
+ solver.fillCells(grid, PossiblesCache(grid)) shouldBe true
+
+ println(grid)
+
+ assertSoftly {
+ withClue("cell 2") {
+ grid.cells[2].possibles shouldContainExactly setOf(1)
+ }
+ }
+ }
+ })
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-easy-but-not-so-simple.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-easy-but-not-so-simple.yml
new file mode 100644
index 00000000..b5d17f4d
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-easy-but-not-so-simple.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":3,"height":3},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"NO_SINGLE_CAGES","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723225807344,"playTimeInMilliseconds":26563,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":0,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":6,"cellNumbers":[0,3,4]},{"id":1,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":6,"cellNumbers":[1,2]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[5,8]},{"id":3,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[6,7]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-simple.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-simple.yml
new file mode 100644
index 00000000..fd9a7b3c
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_3x3-simple.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":3,"height":3},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723225631857,"playTimeInMilliseconds":27047,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":8,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":8,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"TRIPLE_VERTICAL","result":6,"cellNumbers":[2,5,8]},{"id":1,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":4,"cellNumbers":[3,6,7]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":7,"cellNumbers":[0,1,4]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_3x5-5-possibles-should-be-eliminated.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_3x5-5-possibles-should-be-eliminated.yml
new file mode 100644
index 00000000..c270e33a
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_3x5-5-possibles-should-be-eliminated.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":3,"height":5},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723272996505,"playTimeInMilliseconds":3527,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":3,"row":1,"column":0,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":4,"row":1,"column":1,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":5,"row":1,"column":2,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":6,"row":2,"column":0,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":7,"row":2,"column":1,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":8,"row":2,"column":2,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":9,"row":3,"column":0,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":10,"row":3,"column":1,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":11,"row":3,"column":2,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":12,"row":4,"column":0,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":13,"row":4,"column":1,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":14,"row":4,"column":2,"value":1,"userValue":2147483647,"possibles":[]}],"selectedCellNumber":null,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":3,"cellNumbers":[4]},{"id":1,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[3,6]},{"id":2,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":20,"cellNumbers":[7,8]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_TOP","result":15,"cellNumbers":[11,14,13]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":10,"cellNumbers":[9,10,12]},{"id":5,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":48,"cellNumbers":[0,1,2,5]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-0-4x4-82.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-0-4x4-82.yml
new file mode 100644
index 00000000..67453cd5
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-0-4x4-82.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722159495623,"playTimeInMilliseconds":0,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":2147483647,"possibles":[2,3,4]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":3,"row":0,"column":3,"value":4,"userValue":2147483647,"possibles":[1,2,3,4]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2147483647,"possibles":[2,3,4]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":2147483647,"possibles":[1,2,3]}],"selectedCellNumber":null,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[6]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":1,"cellNumbers":[9]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":6,"cellNumbers":[0,4,5]},{"id":3,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":10,"cellNumbers":[1,2,3,7]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":10,"cellNumbers":[8,12,13]},{"id":5,"action":"ACTION_ADD","type":"SQUARE","result":9,"cellNumbers":[10,11,14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-2712-sum-of-to-lines-with-incomplete-frist-cage-gets-to-value-of-minus-one.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-2712-sum-of-to-lines-with-incomplete-frist-cage-gets-to-value-of-minus-one.yml
new file mode 100644
index 00000000..9b4fbbe2
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-2712-sum-of-to-lines-with-incomplete-frist-cage-gets-to-value-of-minus-one.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722159539505,"playTimeInMilliseconds":0,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2147483647,"possibles":[1,2,4]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2147483647,"possibles":[2,4]}],"selectedCellNumber":null,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":1,"cellNumbers":[13]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[4]},{"id":2,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":8,"cellNumbers":[0,1,2]},{"id":3,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":9,"cellNumbers":[3,7,11,10]},{"id":4,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[5,6]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":8,"cellNumbers":[8,9,12]},{"id":6,"action":"ACTION_ADD","type":"DOUBLE_HORIZONTAL","result":6,"cellNumbers":[14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-a-tetris-t-brainfuck-but-solvable.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-a-tetris-t-brainfuck-but-solvable.yml
new file mode 100644
index 00000000..c5105a27
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-a-tetris-t-brainfuck-but-solvable.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722160870079,"playTimeInMilliseconds":103732,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":2147483647,"possibles":[3]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":2147483647,"possibles":[3]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":2147483647,"possibles":[3]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":2147483647,"possibles":[3]}],"selectedCellNumber":13,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"TETRIS_T_LEFT_UP","result":9,"cellNumbers":[4,8,9,12]},{"id":1,"action":"ACTION_ADD","type":"TETRIS_T","result":8,"cellNumbers":[0,1,2,5]},{"id":2,"action":"ACTION_ADD","type":"TETRIS_T_RIGHT_UP","result":11,"cellNumbers":[3,6,7,11]},{"id":3,"action":"ACTION_MULTIPLY","type":"TETRIS_T_BOTTOM_UP","result":48,"cellNumbers":[10,13,14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-doppelte-zwei-nicht-moeglich.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-doppelte-zwei-nicht-moeglich.yml
new file mode 100644
index 00000000..0ba23c1a
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-doppelte-zwei-nicht-moeglich.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723385002222,"playTimeInMilliseconds":116955,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2147483647,"possibles":[1,2,4]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":12,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[6]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":3,"cellNumbers":[8]},{"id":2,"action":"ACTION_MULTIPLY","type":"SQUARE","result":12,"cellNumbers":[0,1,4,5]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_BOTTOM","result":12,"cellNumbers":[2,3,7]},{"id":4,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":8,"cellNumbers":[9,10,11,15]},{"id":5,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":24,"cellNumbers":[12,13,14]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-and-smooth.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-and-smooth.yml
new file mode 100644
index 00000000..30a143d7
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-and-smooth.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723319581539,"playTimeInMilliseconds":56239,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":4,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[12,13]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":7,"cellNumbers":[11,15,14]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":5,"cellNumbers":[6,10,9]},{"id":3,"action":"ACTION_MULTIPLY","type":"TETRIS_T_LEFT_UP","result":72,"cellNumbers":[0,4,5,8]},{"id":4,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":32,"cellNumbers":[1,2,3,7]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-may-be-missing-xwing.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-may-be-missing-xwing.yml
new file mode 100644
index 00000000..0754379b
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-may-be-missing-xwing.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723716757705,"playTimeInMilliseconds":47244,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":14,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":5,"cellNumbers":[0,4]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":9,"cellNumbers":[11,15,14]},{"id":2,"action":"ACTION_MULTIPLY","type":"SQUARE","result":18,"cellNumbers":[8,9,12,13]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":7,"cellNumbers":[6,7,10]},{"id":4,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":24,"cellNumbers":[1,2,3,5]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-much-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-much-too-high.yml
new file mode 100644
index 00000000..92a6acf7
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-much-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723717884585,"playTimeInMilliseconds":55612,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":6,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"DOUBLE_HORIZONTAL","result":5,"cellNumbers":[2,3]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":8,"cellNumbers":[8,12,13]},{"id":2,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_BOTTOM","result":4,"cellNumbers":[0,1,4]},{"id":3,"action":"ACTION_ADD","type":"SQUARE","result":9,"cellNumbers":[5,6,9,10]},{"id":4,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":12,"cellNumbers":[7,11,15,14]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-rating-matches.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-rating-matches.yml
new file mode 100644
index 00000000..0c8db59a
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-rating-matches.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723800783333,"playTimeInMilliseconds":50719,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":4,"userValue":4,"possibles":[]}],"selectedCellNumber":15,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[1]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":3,"cellNumbers":[7]},{"id":2,"action":"ACTION_MULTIPLY","type":"TRIPLE_VERTICAL","result":12,"cellNumbers":[0,4,8]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_BOTTOM","result":12,"cellNumbers":[2,3,6]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":7,"cellNumbers":[5,9,10]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":6,"cellNumbers":[11,15,14]},{"id":6,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[12,13]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium-very-smooth.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium-very-smooth.yml
new file mode 100644
index 00000000..8c2dce77
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium-very-smooth.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722086532646,"playTimeInMilliseconds":52039,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":1,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[6]},{"id":1,"action":"ACTION_DIVIDE","type":"DOUBLE_VERTICAL","result":3,"cellNumbers":[8,12]},{"id":2,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":8,"cellNumbers":[0,4]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_TOP","result":8,"cellNumbers":[7,11,10]},{"id":4,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":9,"cellNumbers":[1,2,3,5]},{"id":5,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_TOP","result":10,"cellNumbers":[9,13,14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium.yml
new file mode 100644
index 00000000..767bbb6a
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-to-medium.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723274372399,"playTimeInMilliseconds":47345,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":4,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[3,7]},{"id":1,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[4,8]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":7,"cellNumbers":[11,15,14]},{"id":3,"action":"ACTION_ADD","type":"TRIPLE_VERTICAL","result":8,"cellNumbers":[2,6,10]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":4,"cellNumbers":[0,1,5]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":10,"cellNumbers":[9,13,12]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-too-high.yml
new file mode 100644
index 00000000..ce892ba8
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-easy-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723716870483,"playTimeInMilliseconds":37638,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":13,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":3,"cellNumbers":[8,12]},{"id":1,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":10,"cellNumbers":[0,1,2,4]},{"id":2,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":8,"cellNumbers":[5,6]},{"id":3,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_TOP","result":10,"cellNumbers":[9,13,14,15]},{"id":4,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":11,"cellNumbers":[3,7,11,10]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-but-no-question-mark-rated-too-strong.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-but-no-question-mark-rated-too-strong.yml
new file mode 100644
index 00000000..50532178
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-but-no-question-mark-rated-too-strong.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722084385333,"playTimeInMilliseconds":108051,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":15,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[14,15]},{"id":1,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[2,3]},{"id":2,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":5,"cellNumbers":[8,12]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_BOTTOM","result":8,"cellNumbers":[6,7,11]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":6,"cellNumbers":[9,10,13]},{"id":5,"action":"ACTION_ADD","type":"SQUARE","result":12,"cellNumbers":[0,1,4,5]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-not-too-hard.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-not-too-hard.yml
new file mode 100644
index 00000000..c4bf49e8
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard-not-too-hard.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722086060839,"playTimeInMilliseconds":65174,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":11,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[2,3]},{"id":1,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":8,"cellNumbers":[13,14,15]},{"id":2,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_TOP","result":24,"cellNumbers":[7,11,10]},{"id":3,"action":"ACTION_MULTIPLY","type":"TETRIS_T_LEFT_UP","result":12,"cellNumbers":[4,8,9,12]},{"id":4,"action":"ACTION_MULTIPLY","type":"TETRIS_HORIZONTAL_LEFT_TOP","result":16,"cellNumbers":[0,1,5,6]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard.yml
new file mode 100644
index 00000000..1d022563
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-hard.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"NO_SINGLE_CAGES","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722087482831,"playTimeInMilliseconds":379500,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":4,"userValue":4,"possibles":[]}],"selectedCellNumber":8,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_DIVIDE","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[0,4]},{"id":1,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":24,"cellNumbers":[1,2,3,5]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[6,7]},{"id":3,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":9,"cellNumbers":[8,9,10,14]},{"id":4,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[11,15]},{"id":5,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[12,13]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-is-really-hard.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-is-really-hard.yml
new file mode 100644
index 00000000..fbdccab4
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-is-really-hard.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723735921614,"playTimeInMilliseconds":836385,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":0,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[11,15]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":7,"cellNumbers":[2,3,7]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":9,"cellNumbers":[0,1,4]},{"id":3,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_RIGHT_TOP","result":24,"cellNumbers":[10,12,13,14]},{"id":4,"action":"ACTION_ADD","type":"TETRIS_HORIZONTAL_RIGHT_TOP","result":9,"cellNumbers":[5,6,8,9]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-in-flow-no-breaks-fast-to-play.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-in-flow-no-breaks-fast-to-play.yml
new file mode 100644
index 00000000..9790fa78
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-in-flow-no-breaks-fast-to-play.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722086673656,"playTimeInMilliseconds":52167,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":4,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":1,"cellNumbers":[9]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":3,"cellNumbers":[15]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[0,4]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":9,"cellNumbers":[1,5,6]},{"id":4,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[2,3]},{"id":5,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_TOP","result":12,"cellNumbers":[7,11,10]},{"id":6,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[8,12]},{"id":7,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[13,14]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-rated-much-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-rated-much-too-high.yml
new file mode 100644
index 00000000..1d022563
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-rated-much-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"NO_SINGLE_CAGES","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722087482831,"playTimeInMilliseconds":379500,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":4,"userValue":4,"possibles":[]}],"selectedCellNumber":8,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_DIVIDE","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[0,4]},{"id":1,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":24,"cellNumbers":[1,2,3,5]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[6,7]},{"id":3,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":9,"cellNumbers":[8,9,10,14]},{"id":4,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[11,15]},{"id":5,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[12,13]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-too-high.yml
new file mode 100644
index 00000000..5bac67f0
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723319765246,"playTimeInMilliseconds":93631,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":4,"userValue":4,"possibles":[]}],"selectedCellNumber":3,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":3,"cellNumbers":[3,7]},{"id":1,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":12,"cellNumbers":[8,12,13]},{"id":2,"action":"ACTION_MULTIPLY","type":"SQUARE","result":48,"cellNumbers":[10,11,14,15]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":8,"cellNumbers":[0,1,4]},{"id":4,"action":"ACTION_ADD","type":"TETRIS_VERTICAL_RIGHT_TOP","result":10,"cellNumbers":[2,5,6,9]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium_question-mark.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium_question-mark.yml
new file mode 100644
index 00000000..6501103f
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-medium_question-mark.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722084196546,"playTimeInMilliseconds":133591,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":13,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[8,12]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":5,"cellNumbers":[9,10,13]},{"id":2,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":13,"cellNumbers":[7,11,15,14]},{"id":3,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":6,"cellNumbers":[4,5,6]},{"id":4,"action":"ACTION_ADD","type":"FOUR_HORIZONTAL","result":10,"cellNumbers":[0,1,2,3]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-no-brainer.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-no-brainer.yml
new file mode 100644
index 00000000..c7afea68
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-no-brainer.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722083776533,"playTimeInMilliseconds":84884,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":12,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":2,"cellNumbers":[5]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[0]},{"id":2,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":7,"cellNumbers":[1,2,3,7]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":9,"cellNumbers":[4,8,9]},{"id":4,"action":"ACTION_MULTIPLY","type":"TRIPLE_VERTICAL","result":8,"cellNumbers":[6,10,14]},{"id":5,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":12,"cellNumbers":[11,15]},{"id":6,"action":"ACTION_ADD","type":"DOUBLE_HORIZONTAL","result":6,"cellNumbers":[12,13]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-not-easy-rated-too-low.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-not-easy-rated-too-low.yml
new file mode 100644
index 00000000..1bf0f92d
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-not-easy-rated-too-low.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"DYNAMIC","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723802131326,"playTimeInMilliseconds":356071,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":3,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":5,"cellNumbers":[0,4]},{"id":1,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":7,"cellNumbers":[1,2,3]},{"id":2,"action":"ACTION_DIVIDE","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[5,9]},{"id":3,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_RIGHT_TOP","result":11,"cellNumbers":[6,10,14,7]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":8,"cellNumbers":[8,12,13]},{"id":5,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[11,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-overall_sum_but_rest_easy.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-overall_sum_but_rest_easy.yml
new file mode 100644
index 00000000..623930e5
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-overall_sum_but_rest_easy.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722767174418,"playTimeInMilliseconds":85591,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":11,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":2,"cellNumbers":[2]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[9]},{"id":2,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_RIGHT_TOP","result":11,"cellNumbers":[0,4,8,1]},{"id":3,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":10,"cellNumbers":[3,7,11,10]},{"id":4,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[5,6]},{"id":5,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[12,13]},{"id":6,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-schwierig-herausfordernd-gut-zu-spielen.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-schwierig-herausfordernd-gut-zu-spielen.yml
new file mode 100644
index 00000000..6c72604b
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-schwierig-herausfordernd-gut-zu-spielen.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723710949433,"playTimeInMilliseconds":150954,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":4,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[3,7]},{"id":1,"action":"ACTION_DIVIDE","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[9,10]},{"id":2,"action":"ACTION_MULTIPLY","type":"L_VERTICAL_SHORT_RIGHT_TOP","result":12,"cellNumbers":[4,8,12,5]},{"id":3,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_BOTTOM","result":8,"cellNumbers":[0,1,2,6]},{"id":4,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_RIGHT_TOP","result":24,"cellNumbers":[11,13,14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-seems-hard-but-is-easy-to-medium-rated-much-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-seems-hard-but-is-easy-to-medium-rated-much-too-high.yml
new file mode 100644
index 00000000..1212a202
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-seems-hard-but-is-easy-to-medium-rated-much-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722086306583,"playTimeInMilliseconds":92151,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":4,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[12,13]},{"id":1,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[14,15]},{"id":2,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":3,"cellNumbers":[3,7]},{"id":3,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":12,"cellNumbers":[0,1,2]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":8,"cellNumbers":[4,5,8]},{"id":5,"action":"ACTION_MULTIPLY","type":"TETRIS_T_BOTTOM_UP","result":32,"cellNumbers":[6,9,10,11]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-simple-hopping-from-cage-to-cage.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-simple-hopping-from-cage-to-cage.yml
new file mode 100644
index 00000000..42a636b0
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-simple-hopping-from-cage-to-cage.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722086172613,"playTimeInMilliseconds":59390,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":5,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":1,"cellNumbers":[12]},{"id":1,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[2,3]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[0,1]},{"id":3,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":4,"cellNumbers":[11,15]},{"id":4,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_TOP","result":32,"cellNumbers":[10,14,13]},{"id":5,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":12,"cellNumbers":[4,8,9]},{"id":6,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":12,"cellNumbers":[5,6,7]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-smooth-playble-easy-rated-too-high.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-smooth-playble-easy-rated-too-high.yml
new file mode 100644
index 00000000..4aec7726
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-smooth-playble-easy-rated-too-high.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723273099272,"playTimeInMilliseconds":47162,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":12,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":12,"cellNumbers":[3,7]},{"id":1,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_BOTTOM","result":8,"cellNumbers":[0,1,4]},{"id":2,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":4,"cellNumbers":[5,9]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":8,"cellNumbers":[8,12,13]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":7,"cellNumbers":[11,15,14]},{"id":5,"action":"ACTION_MULTIPLY","type":"TRIPLE_VERTICAL","result":6,"cellNumbers":[2,6,10]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-start-of-hard-no-question-mark.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-start-of-hard-no-question-mark.yml
new file mode 100644
index 00000000..3686a591
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-start-of-hard-no-question-mark.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1722085401750,"playTimeInMilliseconds":77929,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":12,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[0]},{"id":1,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[3,7]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":4,"cellNumbers":[1,2,6]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":7,"cellNumbers":[4,5,8]},{"id":4,"action":"ACTION_MULTIPLY","type":"TETRIS_T_BOTTOM_UP","result":36,"cellNumbers":[9,12,13,14]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":7,"cellNumbers":[10,11,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-sum-of-lower-two-lines.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-sum-of-lower-two-lines.yml
new file mode 100644
index 00000000..d34dd74d
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-sum-of-lower-two-lines.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723479252940,"playTimeInMilliseconds":77642,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":4,"row":1,"column":0,"value":3,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":6,"row":1,"column":2,"value":4,"userValue":2147483647,"possibles":[1,4]},{"cellNumber":7,"row":1,"column":3,"value":2,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":2147483647,"possibles":[1,4]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":2147483647,"possibles":[1,3,4]},{"cellNumber":12,"row":3,"column":0,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":1,"userValue":2147483647,"possibles":[1,2,4]}],"selectedCellNumber":10,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[8,12]},{"id":1,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":5,"cellNumbers":[3,7]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":8,"cellNumbers":[11,15,14]},{"id":3,"action":"ACTION_ADD","type":"DOUBLE_VERTICAL","result":5,"cellNumbers":[6,10]},{"id":4,"action":"ACTION_MULTIPLY","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":24,"cellNumbers":[0,1,2,4]},{"id":5,"action":"ACTION_MULTIPLY","type":"TRIPLE_VERTICAL","result":6,"cellNumbers":[5,9,13]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-summe-spalten-links.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-summe-spalten-links.yml
new file mode 100644
index 00000000..6eb84762
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-summe-spalten-links.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723385339079,"playTimeInMilliseconds":86708,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":2,"row":0,"column":2,"value":2,"userValue":2147483647,"possibles":[1,2,4]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":2,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2147483647,"possibles":[1,2]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":11,"row":2,"column":3,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2147483647,"possibles":[2,4]}],"selectedCellNumber":15,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":1,"cellNumbers":[13]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":4,"cellNumbers":[4]},{"id":2,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":8,"cellNumbers":[0,1,2]},{"id":3,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":9,"cellNumbers":[3,7,11,10]},{"id":4,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[5,6]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":8,"cellNumbers":[8,9,12]},{"id":6,"action":"ACTION_ADD","type":"DOUBLE_HORIZONTAL","result":6,"cellNumbers":[14,15]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-super-fast.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-super-fast.yml
new file mode 100644
index 00000000..744c54cb
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-super-fast.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723273237838,"playTimeInMilliseconds":47789,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":4,"userValue":4,"possibles":[]}],"selectedCellNumber":10,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":6,"cellNumbers":[2,3]},{"id":1,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":12,"cellNumbers":[11,15]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":3,"cellNumbers":[0,1]},{"id":3,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":6,"cellNumbers":[12,13,14]},{"id":4,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":6,"cellNumbers":[5,6,7]},{"id":5,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_TOP","result":11,"cellNumbers":[4,8,9,10]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-very-easy.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-very-easy.yml
new file mode 100644
index 00000000..f7be646a
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-very-easy.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723716673190,"playTimeInMilliseconds":46304,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":3,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_BOTTOM","result":48,"cellNumbers":[2,3,7]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":5,"cellNumbers":[0,1,4]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":11,"cellNumbers":[8,12,13]},{"id":3,"action":"ACTION_ADD","type":"SQUARE","result":9,"cellNumbers":[5,6,9,10]},{"id":4,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":4,"cellNumbers":[11,15,14]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-viel-einfacher-als-angegeben.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-viel-einfacher-als-angegeben.yml
new file mode 100644
index 00000000..af73095f
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4-viel-einfacher-als-angegeben.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723729469983,"playTimeInMilliseconds":51955,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":9,"row":2,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":12,"row":3,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":3,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":15,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":0,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[0,1]},{"id":1,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":12,"cellNumbers":[14,15]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_HORIZONTAL","result":1,"cellNumbers":[10,11]},{"id":3,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":8,"cellNumbers":[2,3,7]},{"id":4,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":6,"cellNumbers":[8,12,13]},{"id":5,"action":"ACTION_ADD","type":"TETRIS_T","result":10,"cellNumbers":[4,5,6,9]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_4x4_sum_of_two_right_column_eliminates_possible_at_12x.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4_sum_of_two_right_column_eliminates_possible_at_12x.yml
new file mode 100644
index 00000000..77456702
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_4x4_sum_of_two_right_column_eliminates_possible_at_12x.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":4,"height":4},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1728039415968,"playTimeInMilliseconds":998120,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":3,"userValue":2147483647,"possibles":[2,3,4]},{"cellNumber":1,"row":0,"column":1,"value":2,"userValue":2147483647,"possibles":[1,2,3]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":4,"row":1,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":5,"row":1,"column":1,"value":3,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":6,"row":1,"column":2,"value":2,"userValue":2147483647,"possibles":[2,3]},{"cellNumber":7,"row":1,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":8,"row":2,"column":0,"value":2,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":9,"row":2,"column":1,"value":4,"userValue":2147483647,"possibles":[2,4]},{"cellNumber":10,"row":2,"column":2,"value":1,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":11,"row":2,"column":3,"value":3,"userValue":2147483647,"possibles":[1,3]},{"cellNumber":12,"row":3,"column":0,"value":4,"userValue":2147483647,"possibles":[3,4]},{"cellNumber":13,"row":3,"column":1,"value":1,"userValue":2147483647,"possibles":[1,3,4]},{"cellNumber":14,"row":3,"column":2,"value":3,"userValue":2147483647,"possibles":[1,3,4]},{"cellNumber":15,"row":3,"column":3,"value":2,"userValue":2,"possibles":[]}],"selectedCellNumber":null,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_ADD","type":"ANGLE_LEFT_BOTTOM","result":9,"cellNumbers":[2,3,7]},{"id":1,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":6,"cellNumbers":[0,1,4]},{"id":2,"action":"ACTION_MULTIPLY","type":"TRIPLE_HORIZONTAL","result":12,"cellNumbers":[12,13,14]},{"id":3,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_BOTTOM","result":6,"cellNumbers":[10,11,15]},{"id":4,"action":"ACTION_MULTIPLY","type":"TETRIS_HORIZONTAL_RIGHT_TOP","result":48,"cellNumbers":[5,6,8,9]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-702.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-702.yml
new file mode 100644
index 00000000..ea7e095b
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-702.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":5,"height":5},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"DYNAMIC","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723981472850,"playTimeInMilliseconds":269753,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":5,"userValue":5,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":4,"row":0,"column":4,"value":3,"userValue":3,"possibles":[]},{"cellNumber":5,"row":1,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":6,"row":1,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":7,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":1,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":9,"row":1,"column":4,"value":5,"userValue":5,"possibles":[]},{"cellNumber":10,"row":2,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":11,"row":2,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":12,"row":2,"column":2,"value":5,"userValue":5,"possibles":[]},{"cellNumber":13,"row":2,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":14,"row":2,"column":4,"value":2,"userValue":2,"possibles":[]},{"cellNumber":15,"row":3,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":16,"row":3,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":17,"row":3,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":18,"row":3,"column":3,"value":5,"userValue":5,"possibles":[]},{"cellNumber":19,"row":3,"column":4,"value":4,"userValue":4,"possibles":[]},{"cellNumber":20,"row":4,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":21,"row":4,"column":1,"value":5,"userValue":5,"possibles":[]},{"cellNumber":22,"row":4,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":23,"row":4,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":24,"row":4,"column":4,"value":1,"userValue":1,"possibles":[]}],"selectedCellNumber":19,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"DOUBLE_VERTICAL","result":15,"cellNumbers":[4,9]},{"id":1,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":1,"cellNumbers":[0,5]},{"id":2,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[17,22]},{"id":3,"action":"ACTION_DIVIDE","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[1,6]},{"id":4,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_BOTTOM","result":6,"cellNumbers":[2,3,7]},{"id":5,"action":"ACTION_ADD","type":"ANGLE_RIGHT_TOP","result":7,"cellNumbers":[8,13,14]},{"id":6,"action":"ACTION_ADD","type":"ANGLE_LEFT_TOP","result":8,"cellNumbers":[16,21,20]},{"id":7,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":12,"cellNumbers":[10,11,12,15]},{"id":8,"action":"ACTION_ADD","type":"SQUARE","result":13,"cellNumbers":[18,19,23,24]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-929-very-difficult.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-929-very-difficult.yml
new file mode 100644
index 00000000..1c30a7a7
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-929-very-difficult.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":5,"height":5},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"EXTREME","singleCageUsage":"DYNAMIC","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723967043372,"playTimeInMilliseconds":160949,"startedToBePlayed":true,"isActive":false,"cells":[{"cellNumber":0,"row":0,"column":0,"value":1,"userValue":1,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":3,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":4,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":5,"userValue":5,"possibles":[]},{"cellNumber":4,"row":0,"column":4,"value":2,"userValue":2,"possibles":[]},{"cellNumber":5,"row":1,"column":0,"value":5,"userValue":5,"possibles":[]},{"cellNumber":6,"row":1,"column":1,"value":1,"userValue":1,"possibles":[]},{"cellNumber":7,"row":1,"column":2,"value":3,"userValue":3,"possibles":[]},{"cellNumber":8,"row":1,"column":3,"value":2,"userValue":2,"possibles":[]},{"cellNumber":9,"row":1,"column":4,"value":4,"userValue":4,"possibles":[]},{"cellNumber":10,"row":2,"column":0,"value":3,"userValue":3,"possibles":[]},{"cellNumber":11,"row":2,"column":1,"value":4,"userValue":4,"possibles":[]},{"cellNumber":12,"row":2,"column":2,"value":2,"userValue":2,"possibles":[]},{"cellNumber":13,"row":2,"column":3,"value":1,"userValue":1,"possibles":[]},{"cellNumber":14,"row":2,"column":4,"value":5,"userValue":5,"possibles":[]},{"cellNumber":15,"row":3,"column":0,"value":4,"userValue":4,"possibles":[]},{"cellNumber":16,"row":3,"column":1,"value":2,"userValue":2,"possibles":[]},{"cellNumber":17,"row":3,"column":2,"value":5,"userValue":5,"possibles":[]},{"cellNumber":18,"row":3,"column":3,"value":3,"userValue":3,"possibles":[]},{"cellNumber":19,"row":3,"column":4,"value":1,"userValue":1,"possibles":[]},{"cellNumber":20,"row":4,"column":0,"value":2,"userValue":2,"possibles":[]},{"cellNumber":21,"row":4,"column":1,"value":5,"userValue":5,"possibles":[]},{"cellNumber":22,"row":4,"column":2,"value":1,"userValue":1,"possibles":[]},{"cellNumber":23,"row":4,"column":3,"value":4,"userValue":4,"possibles":[]},{"cellNumber":24,"row":4,"column":4,"value":3,"userValue":3,"possibles":[]}],"selectedCellNumber":24,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_MULTIPLY","type":"ANGLE_RIGHT_TOP","result":40,"cellNumbers":[15,20,21]},{"id":1,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_RIGHT_TOP","result":9,"cellNumbers":[19,22,23,24]},{"id":2,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":9,"cellNumbers":[13,14,18]},{"id":3,"action":"ACTION_SUBTRACT","type":"DOUBLE_VERTICAL","result":2,"cellNumbers":[11,16]},{"id":4,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":8,"cellNumbers":[0,1,2]},{"id":5,"action":"ACTION_MULTIPLY","type":"ANGLE_LEFT_BOTTOM","result":40,"cellNumbers":[3,4,9]},{"id":6,"action":"ACTION_ADD","type":"ANGLE_RIGHT_BOTTOM","result":9,"cellNumbers":[5,6,10]},{"id":7,"action":"ACTION_MULTIPLY","type":"L_VERTICAL_SHORT_RIGHT_TOP","result":60,"cellNumbers":[7,12,17,8]}]}
\ No newline at end of file
diff --git a/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-xwing.yml b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-xwing.yml
new file mode 100644
index 00000000..7b9b5b13
--- /dev/null
+++ b/gauguin-core/src/test/resources/difficulty-balancing/game_5x5-xwing.yml
@@ -0,0 +1 @@
+{"variant":{"gridSize":{"width":5,"height":5},"options":{"showOperators":true,"cageOperation":"OPERATIONS_ALL","digitSetting":"FIRST_DIGIT_ONE","difficultySetting":"ANY","singleCageUsage":"FIXED_NUMBER","numeralSystem":"Decimal"}},"savedAtInMilliseconds":1723381453769,"playTimeInMilliseconds":2756133,"startedToBePlayed":true,"isActive":true,"cells":[{"cellNumber":0,"row":0,"column":0,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":1,"row":0,"column":1,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":2,"row":0,"column":2,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":3,"row":0,"column":3,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":4,"row":0,"column":4,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":5,"row":1,"column":0,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":6,"row":1,"column":1,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":7,"row":1,"column":2,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":8,"row":1,"column":3,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":9,"row":1,"column":4,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":10,"row":2,"column":0,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":11,"row":2,"column":1,"value":4,"userValue":2147483647,"possibles":[]},{"cellNumber":12,"row":2,"column":2,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":13,"row":2,"column":3,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":14,"row":2,"column":4,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":15,"row":3,"column":0,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":16,"row":3,"column":1,"value":2,"userValue":2147483647,"possibles":[]},{"cellNumber":17,"row":3,"column":2,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":18,"row":3,"column":3,"value":5,"userValue":2147483647,"possibles":[4]},{"cellNumber":19,"row":3,"column":4,"value":4,"userValue":2147483647,"possibles":[4]},{"cellNumber":20,"row":4,"column":0,"value":5,"userValue":2147483647,"possibles":[]},{"cellNumber":21,"row":4,"column":1,"value":1,"userValue":2147483647,"possibles":[]},{"cellNumber":22,"row":4,"column":2,"value":3,"userValue":2147483647,"possibles":[]},{"cellNumber":23,"row":4,"column":3,"value":4,"userValue":2147483647,"possibles":[4]},{"cellNumber":24,"row":4,"column":4,"value":2,"userValue":2147483647,"possibles":[4]}],"selectedCellNumber":13,"invalidCellNumbers":[],"cheatedCellNumbers":[],"cages":[{"id":0,"action":"ACTION_NONE","type":"SINGLE","result":3,"cellNumbers":[15]},{"id":1,"action":"ACTION_NONE","type":"SINGLE","result":5,"cellNumbers":[12]},{"id":2,"action":"ACTION_ADD","type":"TRIPLE_VERTICAL","result":7,"cellNumbers":[0,5,10]},{"id":3,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":8,"cellNumbers":[1,2,3]},{"id":4,"action":"ACTION_ADD","type":"L_VERTICAL_SHORT_LEFT_BOTTOM","result":11,"cellNumbers":[4,9,14,13]},{"id":5,"action":"ACTION_ADD","type":"L_HORIZONTAL_SHORT_LEFT_BOTTOM","result":14,"cellNumbers":[6,7,8,11]},{"id":6,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":2,"cellNumbers":[16,17]},{"id":7,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":20,"cellNumbers":[18,19]},{"id":8,"action":"ACTION_ADD","type":"TRIPLE_HORIZONTAL","result":9,"cellNumbers":[20,21,22]},{"id":9,"action":"ACTION_MULTIPLY","type":"DOUBLE_HORIZONTAL","result":8,"cellNumbers":[23,24]}]}
\ No newline at end of file