diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 749fefe..540f0ad 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -36,14 +36,20 @@ kotlin { desktopMain.dependencies { implementation(compose.desktop.currentOs) } - commonTest.dependencies { -// implementation(libs.junit) + androidNativeTest.dependencies { implementation(kotlin("test")) + implementation(libs.atrium) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.atrium) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.uiTest) } desktopTest.dependencies { + implementation(kotlin("test")) + implementation(libs.atrium) implementation(compose.desktop.uiTestJUnit4) implementation(compose.desktop.currentOs) } diff --git a/composeApp/src/commonMain/kotlin/domain/Game.kt b/composeApp/src/commonMain/kotlin/domain/Game.kt index bd73a7a..cbdb400 100644 --- a/composeApp/src/commonMain/kotlin/domain/Game.kt +++ b/composeApp/src/commonMain/kotlin/domain/Game.kt @@ -1,27 +1,39 @@ package domain -import kotlin.reflect.KClass - class Game( val grid: Grid, - val rules: List + val clues: List ) { - val horizontalRules = rules.filterIsInstance() - val verticalRules = rules.filterIsInstance() - + val horizontalClues = clues.filterIsInstance() + val verticalClues = clues.filterIsInstance>>() + val positionalClues = clues.filterIsInstance>>() + + init { + for (position in positionalClues) { + val row = grid[position.item.itemType.companion] + row.forEachIndexed { index, gameCell -> + if (index == position.index) { + gameCell.selection = position.item + } else { + gameCell.options.remove(position.item) + } + } + + } + } + fun areCategoriesValid(): Boolean { - val usedCategories = mutableSetOf>() + val usedCategories = mutableSetOf>() for (row in grid.rows) { - val category = row.first().options.first()::class - if (usedCategories.contains(category)) { + if (usedCategories.contains(row.category)) { return false } - usedCategories.add(category) + usedCategories.add(row.category) } return true } - fun areRulesViolated(): Boolean = rules + fun areRulesViolated(): Boolean = clues .map { it.isRuleViolated(grid) } .reduce { a, b -> a || b } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/clues.kt b/composeApp/src/commonMain/kotlin/domain/clues.kt new file mode 100644 index 0000000..615de14 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/domain/clues.kt @@ -0,0 +1,94 @@ +package domain + +import kotlin.math.abs + +sealed class Clue { + abstract fun isRuleViolated(grid: Grid): Boolean +} + +sealed class HorizontalClue : Clue() + +class NeighbourClue>(val a: Item, val b: Item) : HorizontalClue() { + private val aType = a.itemType + private val bType = b.itemType + + override fun isRuleViolated(grid: Grid): Boolean { + val ia = grid.indexOf(aType) + val ib = grid.indexOf(bType) + + if (ia == -1 || ib == -1) return false + + return abs(ia - ib) != 1 + } +} + +class OrderClue>(val left: Item, val right: Item) : HorizontalClue() { + private val leftType = left.itemType + private val rightType = right.itemType + + override fun isRuleViolated(grid: Grid): Boolean { + val il = grid.indexOf(leftType) + val ir = grid.indexOf(rightType) + + if (il == -1 || ir == -1) return false + + return ir <= il + } +} + +class TripletClue>(val a: Item, val b: Item, val c: Item) : HorizontalClue() { + private val aType = a.itemType + private val bType = b.itemType + private val cType = c.itemType + + private fun isNeighbourRuleViolated(ix: Int, iy: Int): Boolean { + if (ix == -1 || iy == -1) return false + return abs(ix - iy) != 1 + } + + override fun isRuleViolated(grid: Grid): Boolean { + val ia = grid.indexOf(aType) + val ib = grid.indexOf(bType) + val ic = grid.indexOf(cType) + + if (ia == -1 && ic == -1) { + return false + } + + if (ia == ib) { + return true + } + + if (isNeighbourRuleViolated(ia, ib) || isNeighbourRuleViolated(ib, ic)) { + return true + } + + return false + } +} + +class SameRowClue>(val a: Item, val b: Item) : Clue() { + private val aType = a.itemType + private val bType = b.itemType + + override fun isRuleViolated(grid: Grid): Boolean { + val ia = grid.indexOf(aType) + val ib = grid.indexOf(bType) + + if (ia == -1 || ib == -1) return false + + return ia != ib + } +} + +class PositionClue>(val item: Item, val index: Int) : Clue() { + private val aType = item.itemType + + override fun isRuleViolated(grid: Grid): Boolean { + val ia = grid.indexOf(aType) + + if (ia == -1) return false + + return ia != index + } +} diff --git a/composeApp/src/commonMain/kotlin/domain/generator.kt b/composeApp/src/commonMain/kotlin/domain/generator.kt index 81fd7f5..9304fc5 100644 --- a/composeApp/src/commonMain/kotlin/domain/generator.kt +++ b/composeApp/src/commonMain/kotlin/domain/generator.kt @@ -1,17 +1,172 @@ package domain -fun generateGame(size: Int = 6): Game { - // Here's a simple algorithm making use of your solver: - // 0. Select $size classes and $size items per class. - // 1. Generate a random puzzle instance. +import domain.PuzzleSolution.MULTIPLE_SOLUTIONS +import domain.PuzzleSolution.NO_SOLUTION +import domain.PuzzleSolution.SOLVABLE +import kotlin.random.Random + +fun generateGame(size: Int = 6): Game { + // Generate a random puzzle instance. + val classes = ItemClass.randomClasses(size) + + val grid: List>>> = classes.map { it -> + it.randomItems(size).map { item -> Item(item) } + } + + // Build a set C of all possible clues that pertain to this puzzle instance. + // (There are a finite and in fact quite small number of possible clues: for example + // if there are 5 houses, there are 5 possible clues of the form "Person A lives in + // house B", 8 possible clues of the form "Person A lives next to house B", and so on.) + var clues = getAllClues(grid).shuffled() + + var i = 0 + + // If i >= n, we are done. The set of clues is minimal. + while (i < clues.size) { + // Run your solver on the reduced set of clues and count the number of possible solutions. + val temp = clues - clues[i] + if (solve(grid.toGrid(), temp) == SOLVABLE) { + // If there is exactly one solution, update clues and reset index. + clues = temp + i = 0 + } + i++ + } + return Game(grid.toGrid(), clues) - // 2. Build a set C of all possible clues that pertain to this puzzle instance. (There are a finite and in fact quite small number of possible clues: for example if there are 5 houses, there are 5 possible clues of the form "Person A lives in house B", 8 possible clues of the form "Person A lives next to house B", and so on.) - // 3. Pick a random permutation c1, c2, ..., cn of the clues in C. - // 4. Set i = 1. - // 5. If i > n, we are done. The set C of clues is minimal. - // 6. Let D = C āˆ’ { ci }. Run your solver on the set D of clues and count the number of possible solutions. - // 7. If there is exactly one solution, set C = D. - // 8. Set i = i + 1 and go back to step 5. // (You can speed this up by removing clues in batches rather than one at a time, but it makes the algorithm more complicated to describe.) - TODO() +} + +private fun solve(grid: Grid, clues: List): PuzzleSolution { + // Start with a grid where each cell is a list of all possible items. + + // First, set the positions of the items that are already known. + val positionClues = clues.filterIsInstance>>().toSet() + positionClues.forEach { position -> + val row = grid[position.item.itemType.companion] + row.forEachIndexed { index, gameCell -> + if (index == position.index) { + gameCell.selection = position.item + } else { + gameCell.options.remove(position.item) + } + } + } + + // For each clue, remove any items that violate the clue. + // If any cell has only one item left, remove that item from all other cells. + // Repeat until no more items can be removed. + val otherClues = clues - positionClues + var removedOptions = false + do { + grid.forEach { row -> + removedOptions = row.tryOptionsForClues(grid, otherClues) || removedOptions + } + } while (removedOptions) + + // If any cell has no items left, the puzzle has no solution. + grid.flatMap { it }.forEach { cell -> + if (cell.options.isEmpty()) { + return NO_SOLUTION + } + } + // If all cells have exactly one item left, the puzzle is solved. + if (grid.flatMap { it }.all { it.selection != null }) return SOLVABLE + + // If there are still cells with multiple items, pick one and try each item in turn, then go back to step 2. + // TODO: Does this need to be implemented? We would need to try all possible options to check if there are multiple solutions. + return MULTIPLE_SOLUTIONS +} + +private enum class PuzzleSolution { + NO_SOLUTION, + SOLVABLE, + MULTIPLE_SOLUTIONS +} + +fun > GameRow.tryOptionsForClues(grid: Grid, clues: List): Boolean { + var removedOptions = false + filter { cell -> cell.selection == null }.forEach { cell -> + val removed = cell.options.removeIf { option -> + cell.selection = option + clues.any { it.isRuleViolated(grid) } + } + cell.selection = null + if (removed) { + cleanupOptions(cell) + } + removedOptions = removedOptions || removed + } + return removedOptions +} + +fun > GameRow.cleanupOptions(cell: GameCell) { + if (cell.options.size == 1) { + cell.selection = cell.options.first() + forEach { otherCell -> + if (otherCell != cell && otherCell.selection == null) { + otherCell.options.remove(cell.selection) + cleanupOptions(otherCell) + } + } + } +} + +private fun getAllClues(rows: List>>>): MutableList { + val clues = mutableListOf() + // For optimization reasons we want the positional clues first + rows.forEach { row -> + row.forEachIndexed { i, item -> + clues.add(PositionClue(item, i)) + } + } + rows.forEachIndexed { i, columns -> + columns.forEachIndexed { j, item -> + // Clue: Neighbours + if (j > 0) { + rows.map { it[j - 1] }.forEach { + // We don't want to give away the order with this clue, + // but it needs to be displayed consistently. + if (Random.nextBoolean()) { + clues.add(NeighbourClue(item, it)) + } else { + clues.add(NeighbourClue(it, item)) + } + } + } + + // Clue: Order + if (j > 0) { + rows.flatMap { it.take(j - 1) }.forEach { + clues.add(OrderClue(it, item)) + } + } + + // Clue: Triplet + if (j > 1) { + val lefts = rows.map { it[j - 2] } + val middles = rows.map { it[j - 1] } + + lefts.forEach { left -> + middles.forEach { middle -> + // We don't want to give away the order with this clue, + // but it needs to be displayed consistently. + if (Random.nextBoolean()) { + clues.add(TripletClue(left, middle, item)) + } else { + clues.add(TripletClue(item, middle, left)) + } + } + } + } + + // Clue: Same Column + rows.map { it[j] }.forEach { + if (it != item) { + clues.add(SameRowClue(item, it)) + } + } + } + } + return clues } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/grid.kt b/composeApp/src/commonMain/kotlin/domain/grid.kt index b89a772..578cd17 100644 --- a/composeApp/src/commonMain/kotlin/domain/grid.kt +++ b/composeApp/src/commonMain/kotlin/domain/grid.kt @@ -1,21 +1,56 @@ package domain -class GameRow( - val category: C, +class GameRow>( + val category: ItemClassCompanion, + val options: List>, val cells: List> ) : List> by cells class Grid( - val rows: List> -) : List> by rows { - fun indexOf(element: ItemClass): Int { - val row = rows.first { it.category == element::class } - return row.indexOfFirst { it.selection == element } + val rows: List>> +) : List>> by rows { + + fun > indexOf(element: C): Int { + return this[element.companion] + .indexOfFirst { it.selection?.itemType == element } } + + operator fun > get(itemType: ItemClassCompanion): GameRow { + @Suppress("UNCHECKED_CAST") + return rows.first { it.category == itemType } as GameRow + } + } -class GameCell( - var selection: C?, - val solution: C, - val options: List +fun List>>>.toGrid() = Grid( + map { row -> + GameRow( + row.first().itemType.companion, + row, + row.map { GameCell(selection = null, solution = it, options = row.toMutableList()) } + ) + } ) + +class GameCell>( + var selection: Item?, + val solution: Item, + val options: MutableList> +) + +class Item>( + val itemType: C, + val symbol: String +) { + constructor(itemType: C) : this(itemType, itemType.symbols.random()) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Item<*>) return false + return itemType == other.itemType + } + + override fun hashCode(): Int { + return itemType.hashCode() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/items.kt b/composeApp/src/commonMain/kotlin/domain/items.kt index fadc298..5df4e3a 100644 --- a/composeApp/src/commonMain/kotlin/domain/items.kt +++ b/composeApp/src/commonMain/kotlin/domain/items.kt @@ -1,6 +1,6 @@ package domain -enum class Animals(symbol: String) : ItemClass { +enum class Animals(symbol: String) : ItemClass { ZEBRA("šŸ¦“"), OCTOPUS("šŸ™"), GOAT("🐐"), @@ -10,9 +10,16 @@ enum class Animals(symbol: String) : ItemClass { ANT("🐜"); override val symbols: Array = arrayOf(symbol) + + override val companion + get() = Animals + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Nationality(symbol: String) : ItemClass { +enum class Nationality(symbol: String) : ItemClass { ENGLAND("šŸ‡¬šŸ‡§"), UKRAINE("šŸ‡ŗšŸ‡¦"), SPAIN("šŸ‡ŖšŸ‡ø"), @@ -22,9 +29,16 @@ enum class Nationality(symbol: String) : ItemClass { CANADA("šŸ‡ØšŸ‡¦"); override val symbols: Array = arrayOf(symbol) + + override val companion + get() = Nationality + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Drink(symbol: String) : ItemClass { +enum class Drink(symbol: String) : ItemClass { MILK("šŸ„›"), WINE("šŸ·"), COCKTAIL("šŸø"), @@ -34,9 +48,16 @@ enum class Drink(symbol: String) : ItemClass { BEVERAGE("🧃"); override val symbols: Array = arrayOf(symbol) + + override val companion + get() = Drink + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Profession(symbol: String) : ItemClass { +enum class Profession(symbol: String) : ItemClass { ASTRONAUT("\u200D\uD83D\uDE80"), HEALTH_WORKER("\u200Dāš•\uFE0F"), FARMER("\u200D\uD83C\uDF3E"), @@ -47,9 +68,16 @@ enum class Profession(symbol: String) : ItemClass { TEACHER("\u200D\uD83C\uDFEB"); override val symbols: Array = idic(symbol) + + override val companion + get() = Profession + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Fruit(symbol: String) : ItemClass { +enum class Fruit(symbol: String) : ItemClass { GRAPES("šŸ‡"), WATERMELON("šŸ‰"), LEMON("šŸ‹"), @@ -62,9 +90,16 @@ enum class Fruit(symbol: String) : ItemClass { MANGO("🄭"); override val symbols: Array = idic(symbol) + + override val companion + get() = Fruit + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Dessert(symbol: String) : ItemClass { +enum class Dessert(symbol: String) : ItemClass { ICE_CREAM("šŸØ"), DOUGHNUT("šŸ©"), COOKIE("šŸŖ"), @@ -76,9 +111,16 @@ enum class Dessert(symbol: String) : ItemClass { CUSTARD("šŸ®"); override val symbols: Array = idic(symbol) + + override val companion + get() = Dessert + + companion object : ItemClassCompanion { + override val items: List = entries + } } -enum class Transportation(symbol: String) : ItemClass { +enum class Transportation(symbol: String) : ItemClass { BICYCLE("🚲"), MOTOR_SCOOTER("šŸ›µ"), SKATEBOARD("šŸ›¹"), @@ -88,18 +130,51 @@ enum class Transportation(symbol: String) : ItemClass { BUS("🚌"); override val symbols: Array = idic(symbol) + + override val companion + get() = Transportation + + companion object : ItemClassCompanion { + override val items: List = entries + } } private val GENDERS = arrayOf("\uD83E\uDDD1", "\uD83D\uDC68", "\uD83D\uDC69") private val SKIN_TONES = arrayOf("\uD83C\uDFFB", "\uD83C\uDFFC", "\uD83C\uDFFD", "\uD83C\uDFFE", "\uD83C\uDFFF") -private fun idic(symbol: String) = Array(GENDERS.size * SKIN_TONES.size) { i -> +private fun idic(symbol: String): Array = Array(GENDERS.size * SKIN_TONES.size) { i -> val g = GENDERS[i % GENDERS.size] val t = SKIN_TONES[i / GENDERS.size] g + t + symbol } -sealed interface ItemClass { +sealed interface ItemClass> { val symbols: Array + + val companion: ItemClassCompanion + + companion object { + val classes: List> = listOf( + Animals, + Nationality, + Drink, + Profession, + Fruit, + Dessert, + Transportation + ) + + fun randomClasses(n: Int): List> { + return classes.shuffled().take(n) + } + } +} + +sealed interface ItemClassCompanion> { + val items: List + + fun randomItems(n: Int): List { + return items.shuffled().take(n) + } } diff --git a/composeApp/src/commonMain/kotlin/domain/rules.kt b/composeApp/src/commonMain/kotlin/domain/rules.kt deleted file mode 100644 index 768fa75..0000000 --- a/composeApp/src/commonMain/kotlin/domain/rules.kt +++ /dev/null @@ -1,69 +0,0 @@ -package domain - -import kotlin.math.abs - -sealed class GameRule { - abstract fun isRuleViolated(grid: Grid): Boolean -} - -sealed class HorizontalRule : GameRule() - -class NeighbourRule(val a: ItemClass, val b: ItemClass) : HorizontalRule() { - override fun isRuleViolated(grid: Grid): Boolean { - val ia = grid.indexOf(a) - val ib = grid.indexOf(b) - - if (ia == -1 || ib == -1) return false - - return abs(ia - ib) != 1 - } -} - -class OrderRule(val left: ItemClass, val right: ItemClass) : HorizontalRule() { - override fun isRuleViolated(grid: Grid): Boolean { - val il = grid.indexOf(left) - val ir = grid.indexOf(right) - - if (il == -1 || ir == -1) return false - - return ir <= il - } -} - -class TripletRule(val a: ItemClass, val b: ItemClass, val c: ItemClass) : HorizontalRule() { - private fun isNeighbourRuleViolated(ix: Int, iy: Int): Boolean { - if (ix == -1 || iy == -1) return false - return abs(ix - iy) != 1 - } - - override fun isRuleViolated(grid: Grid): Boolean { - val ia = grid.indexOf(a) - val ib = grid.indexOf(b) - val ic = grid.indexOf(c) - - if (ia == -1 && ic == -1) { - return false - } - - if (ia == ib) { - return true - } - - if (isNeighbourRuleViolated(ia, ib) || isNeighbourRuleViolated(ib, ic)) { - return true - } - - return false - } -} - -class VerticalRule(val a: ItemClass, val b: ItemClass) : GameRule() { - override fun isRuleViolated(grid: Grid): Boolean { - val ia = grid.indexOf(a) - val ib = grid.indexOf(b) - - if (ia == -1 || ib == -1) return false - - return ia != ib - } -} diff --git a/composeApp/src/commonMain/kotlin/ui/selector.kt b/composeApp/src/commonMain/kotlin/ui/selector.kt index c7e4801..22267fe 100644 --- a/composeApp/src/commonMain/kotlin/ui/selector.kt +++ b/composeApp/src/commonMain/kotlin/ui/selector.kt @@ -3,11 +3,18 @@ package ui import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import domain.Item import domain.ItemClass -import domain.ItemCategory +import domain.ItemClassCompanion @Composable -fun Selector(modifier: Modifier = Modifier, category: ItemCategory, selectedItem: ItemClass, onSelectItem: (ItemClass) -> Unit) { +fun > Selector( + modifier: Modifier = Modifier, + category: ItemClassCompanion, + options: List>, + selectedItem: Item, + onSelectItem: (Item) -> Unit +) { OutlinedCard(modifier = modifier) { } diff --git a/composeApp/src/commonTest/kotlin/domain/GameTest.kt b/composeApp/src/commonTest/kotlin/domain/GameTest.kt index 4be936e..19e7d21 100644 --- a/composeApp/src/commonTest/kotlin/domain/GameTest.kt +++ b/composeApp/src/commonTest/kotlin/domain/GameTest.kt @@ -1,31 +1,19 @@ -@file:OptIn(ExperimentalResourceApi::class) package domain -import org.jetbrains.compose.resources.ExperimentalResourceApi -//import org.junit.Test -import yaep.composeapp.generated.resources.Res -import yaep.composeapp.generated.resources.compose_multiplatform +import ch.tutteli.atrium.api.fluent.en_GB.feature +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect import kotlin.test.Test -import kotlin.test.fail class GameTest { @Test - fun areCategoriesValid() { -// Game( -// categories = listOf( -// category { -// item(Res.drawable.compose_multiplatform) -// } -// ), -// Grid( -// -// ) -// ) + fun `ensure generated game is valid`() { + val game = generateGame() + expect(game) { + feature(Game::areCategoriesValid).toEqual(true) + feature(Game::areRulesViolated).toEqual(false) + } } - @Test - fun areRulesViolated() { - TODO() - } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 804a6dc..2ef47b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ kotlin = "1.9.24" [libraries] #junit = { module = "junit:junit", version = "4.13.2"} +atrium = { module = "ch.tutteli.atrium:atrium-fluent", version = "1.2.0" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }