Generate game

This commit is contained in:
Christian Basler
2024-06-13 17:37:33 +02:00
parent baa371ac51
commit 98c4d27741
10 changed files with 441 additions and 137 deletions

View File

@@ -36,14 +36,20 @@ kotlin {
desktopMain.dependencies { desktopMain.dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
} }
commonTest.dependencies { androidNativeTest.dependencies {
// implementation(libs.junit)
implementation(kotlin("test")) implementation(kotlin("test"))
implementation(libs.atrium)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.atrium)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest) implementation(compose.uiTest)
} }
desktopTest.dependencies { desktopTest.dependencies {
implementation(kotlin("test"))
implementation(libs.atrium)
implementation(compose.desktop.uiTestJUnit4) implementation(compose.desktop.uiTestJUnit4)
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
} }

View File

@@ -1,27 +1,39 @@
package domain package domain
import kotlin.reflect.KClass
class Game( class Game(
val grid: Grid, val grid: Grid,
val rules: List<GameRule> val clues: List<Clue>
) { ) {
val horizontalRules = rules.filterIsInstance<HorizontalRule>() val horizontalClues = clues.filterIsInstance<HorizontalClue>()
val verticalRules = rules.filterIsInstance<VerticalRule>() val verticalClues = clues.filterIsInstance<SameRowClue<ItemClass<*>>>()
val positionalClues = clues.filterIsInstance<PositionClue<ItemClass<*>>>()
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 { fun areCategoriesValid(): Boolean {
val usedCategories = mutableSetOf<KClass<out ItemClass>>() val usedCategories = mutableSetOf<ItemClassCompanion<*>>()
for (row in grid.rows) { for (row in grid.rows) {
val category = row.first().options.first()::class if (usedCategories.contains(row.category)) {
if (usedCategories.contains(category)) {
return false return false
} }
usedCategories.add(category) usedCategories.add(row.category)
} }
return true return true
} }
fun areRulesViolated(): Boolean = rules fun areRulesViolated(): Boolean = clues
.map { it.isRuleViolated(grid) } .map { it.isRuleViolated(grid) }
.reduce { a, b -> a || b } .reduce { a, b -> a || b }
} }

View File

@@ -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<C:ItemClass<C>>(val a: Item<C>, val b: Item<C>) : 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<C:ItemClass<C>>(val left: Item<C>, val right: Item<C>) : 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<C:ItemClass<C>>(val a: Item<C>, val b: Item<C>, val c: Item<C>) : 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<C:ItemClass<C>>(val a: Item<C>, val b: Item<C>) : 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<C:ItemClass<C>>(val item: Item<C>, 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
}
}

View File

@@ -1,17 +1,172 @@
package domain package domain
fun generateGame(size: Int = 6): Game { import domain.PuzzleSolution.MULTIPLE_SOLUTIONS
// Here's a simple algorithm making use of your solver: import domain.PuzzleSolution.NO_SOLUTION
// 0. Select $size classes and $size items per class. import domain.PuzzleSolution.SOLVABLE
// 1. Generate a random puzzle instance. import kotlin.random.Random
// 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.) fun generateGame(size: Int = 6): Game {
// 3. Pick a random permutation c1, c2, ..., cn of the clues in C. // Generate a random puzzle instance.
// 4. Set i = 1. val classes = ItemClass.randomClasses(size)
// 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. val grid: List<List<Item<ItemClass<*>>>> = classes.map { it ->
// 7. If there is exactly one solution, set C = D. it.randomItems(size).map { item -> Item(item) }
// 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() // 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)
// (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.)
}
private fun solve(grid: Grid, clues: List<Clue>): 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<PositionClue<ItemClass<*>>>().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 <C : ItemClass<C>> GameRow<C>.tryOptionsForClues(grid: Grid, clues: List<Clue>): 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 <C : ItemClass<C>> GameRow<C>.cleanupOptions(cell: GameCell<C>) {
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<List<Item<ItemClass<*>>>>): MutableList<Clue> {
val clues = mutableListOf<Clue>()
// 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
} }

View File

@@ -1,21 +1,56 @@
package domain package domain
class GameRow<C : ItemClass>( class GameRow<C : ItemClass<C>>(
val category: C, val category: ItemClassCompanion<C>,
val options: List<Item<C>>,
val cells: List<GameCell<C>> val cells: List<GameCell<C>>
) : List<GameCell<C>> by cells ) : List<GameCell<C>> by cells
class Grid( class Grid(
val rows: List<GameRow<*>> val rows: List<GameRow<ItemClass<*>>>
) : List<GameRow<*>> by rows { ) : List<GameRow<ItemClass<*>>> by rows {
fun indexOf(element: ItemClass): Int {
val row = rows.first { it.category == element::class } fun <C: ItemClass<C>> indexOf(element: C): Int {
return row.indexOfFirst { it.selection == element } return this[element.companion]
} .indexOfFirst { it.selection?.itemType == element }
} }
class GameCell<C:ItemClass>( operator fun <C: ItemClass<C>> get(itemType: ItemClassCompanion<C>): GameRow<C> {
var selection: C?, @Suppress("UNCHECKED_CAST")
val solution: C, return rows.first { it.category == itemType } as GameRow<C>
val options: List<C> }
}
fun List<List<Item<ItemClass<*>>>>.toGrid() = Grid(
map { row ->
GameRow(
row.first().itemType.companion,
row,
row.map { GameCell(selection = null, solution = it, options = row.toMutableList()) }
) )
}
)
class GameCell<C : ItemClass<C>>(
var selection: Item<C>?,
val solution: Item<C>,
val options: MutableList<Item<C>>
)
class Item<C : ItemClass<C>>(
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()
}
}

View File

@@ -1,6 +1,6 @@
package domain package domain
enum class Animals(symbol: String) : ItemClass { enum class Animals(symbol: String) : ItemClass<Animals> {
ZEBRA("🦓"), ZEBRA("🦓"),
OCTOPUS("🐙"), OCTOPUS("🐙"),
GOAT("🐐"), GOAT("🐐"),
@@ -10,9 +10,16 @@ enum class Animals(symbol: String) : ItemClass {
ANT("🐜"); ANT("🐜");
override val symbols: Array<String> = arrayOf(symbol) override val symbols: Array<String> = arrayOf(symbol)
override val companion
get() = Animals
companion object : ItemClassCompanion<Animals> {
override val items: List<Animals> = entries
}
} }
enum class Nationality(symbol: String) : ItemClass { enum class Nationality(symbol: String) : ItemClass<Nationality> {
ENGLAND("🇬🇧"), ENGLAND("🇬🇧"),
UKRAINE("🇺🇦"), UKRAINE("🇺🇦"),
SPAIN("🇪🇸"), SPAIN("🇪🇸"),
@@ -22,9 +29,16 @@ enum class Nationality(symbol: String) : ItemClass {
CANADA("🇨🇦"); CANADA("🇨🇦");
override val symbols: Array<String> = arrayOf(symbol) override val symbols: Array<String> = arrayOf(symbol)
override val companion
get() = Nationality
companion object : ItemClassCompanion<Nationality> {
override val items: List<Nationality> = entries
}
} }
enum class Drink(symbol: String) : ItemClass { enum class Drink(symbol: String) : ItemClass<Drink> {
MILK("🥛"), MILK("🥛"),
WINE("🍷"), WINE("🍷"),
COCKTAIL("🍸"), COCKTAIL("🍸"),
@@ -34,9 +48,16 @@ enum class Drink(symbol: String) : ItemClass {
BEVERAGE("🧃"); BEVERAGE("🧃");
override val symbols: Array<String> = arrayOf(symbol) override val symbols: Array<String> = arrayOf(symbol)
override val companion
get() = Drink
companion object : ItemClassCompanion<Drink> {
override val items: List<Drink> = entries
}
} }
enum class Profession(symbol: String) : ItemClass { enum class Profession(symbol: String) : ItemClass<Profession> {
ASTRONAUT("\u200D\uD83D\uDE80"), ASTRONAUT("\u200D\uD83D\uDE80"),
HEALTH_WORKER("\u200D\uFE0F"), HEALTH_WORKER("\u200D\uFE0F"),
FARMER("\u200D\uD83C\uDF3E"), FARMER("\u200D\uD83C\uDF3E"),
@@ -47,9 +68,16 @@ enum class Profession(symbol: String) : ItemClass {
TEACHER("\u200D\uD83C\uDFEB"); TEACHER("\u200D\uD83C\uDFEB");
override val symbols: Array<String> = idic(symbol) override val symbols: Array<String> = idic(symbol)
override val companion
get() = Profession
companion object : ItemClassCompanion<Profession> {
override val items: List<Profession> = entries
}
} }
enum class Fruit(symbol: String) : ItemClass { enum class Fruit(symbol: String) : ItemClass<Fruit> {
GRAPES("🍇"), GRAPES("🍇"),
WATERMELON("🍉"), WATERMELON("🍉"),
LEMON("🍋"), LEMON("🍋"),
@@ -62,9 +90,16 @@ enum class Fruit(symbol: String) : ItemClass {
MANGO("🥭"); MANGO("🥭");
override val symbols: Array<String> = idic(symbol) override val symbols: Array<String> = idic(symbol)
override val companion
get() = Fruit
companion object : ItemClassCompanion<Fruit> {
override val items: List<Fruit> = entries
}
} }
enum class Dessert(symbol: String) : ItemClass { enum class Dessert(symbol: String) : ItemClass<Dessert> {
ICE_CREAM("🍨"), ICE_CREAM("🍨"),
DOUGHNUT("🍩"), DOUGHNUT("🍩"),
COOKIE("🍪"), COOKIE("🍪"),
@@ -76,9 +111,16 @@ enum class Dessert(symbol: String) : ItemClass {
CUSTARD("🍮"); CUSTARD("🍮");
override val symbols: Array<String> = idic(symbol) override val symbols: Array<String> = idic(symbol)
override val companion
get() = Dessert
companion object : ItemClassCompanion<Dessert> {
override val items: List<Dessert> = entries
}
} }
enum class Transportation(symbol: String) : ItemClass { enum class Transportation(symbol: String) : ItemClass<Transportation> {
BICYCLE("🚲"), BICYCLE("🚲"),
MOTOR_SCOOTER("🛵"), MOTOR_SCOOTER("🛵"),
SKATEBOARD("🛹"), SKATEBOARD("🛹"),
@@ -88,18 +130,51 @@ enum class Transportation(symbol: String) : ItemClass {
BUS("🚌"); BUS("🚌");
override val symbols: Array<String> = idic(symbol) override val symbols: Array<String> = idic(symbol)
override val companion
get() = Transportation
companion object : ItemClassCompanion<Transportation> {
override val items: List<Transportation> = entries
}
} }
private val GENDERS = arrayOf("\uD83E\uDDD1", "\uD83D\uDC68", "\uD83D\uDC69") private val GENDERS = arrayOf("\uD83E\uDDD1", "\uD83D\uDC68", "\uD83D\uDC69")
private val SKIN_TONES = private val SKIN_TONES =
arrayOf("\uD83C\uDFFB", "\uD83C\uDFFC", "\uD83C\uDFFD", "\uD83C\uDFFE", "\uD83C\uDFFF") arrayOf("\uD83C\uDFFB", "\uD83C\uDFFC", "\uD83C\uDFFD", "\uD83C\uDFFE", "\uD83C\uDFFF")
private fun idic(symbol: String) = Array<String>(GENDERS.size * SKIN_TONES.size) { i -> private fun idic(symbol: String): Array<String> = Array(GENDERS.size * SKIN_TONES.size) { i ->
val g = GENDERS[i % GENDERS.size] val g = GENDERS[i % GENDERS.size]
val t = SKIN_TONES[i / GENDERS.size] val t = SKIN_TONES[i / GENDERS.size]
g + t + symbol g + t + symbol
} }
sealed interface ItemClass { sealed interface ItemClass<out SELF : ItemClass<SELF>> {
val symbols: Array<String> val symbols: Array<String>
val companion: ItemClassCompanion<SELF>
companion object {
val classes: List<ItemClassCompanion<*>> = listOf(
Animals,
Nationality,
Drink,
Profession,
Fruit,
Dessert,
Transportation
)
fun randomClasses(n: Int): List<ItemClassCompanion<*>> {
return classes.shuffled().take(n)
}
}
}
sealed interface ItemClassCompanion<out C : ItemClass<C>> {
val items: List<C>
fun randomItems(n: Int): List<C> {
return items.shuffled().take(n)
}
} }

View File

@@ -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
}
}

View File

@@ -3,11 +3,18 @@ package ui
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import domain.Item
import domain.ItemClass import domain.ItemClass
import domain.ItemCategory import domain.ItemClassCompanion
@Composable @Composable
fun Selector(modifier: Modifier = Modifier, category: ItemCategory, selectedItem: ItemClass, onSelectItem: (ItemClass) -> Unit) { fun <C : ItemClass<C>> Selector(
modifier: Modifier = Modifier,
category: ItemClassCompanion<C>,
options: List<Item<C>>,
selectedItem: Item<C>,
onSelectItem: (Item<C>) -> Unit
) {
OutlinedCard(modifier = modifier) { OutlinedCard(modifier = modifier) {
} }

View File

@@ -1,31 +1,19 @@
@file:OptIn(ExperimentalResourceApi::class)
package domain package domain
import org.jetbrains.compose.resources.ExperimentalResourceApi import ch.tutteli.atrium.api.fluent.en_GB.feature
//import org.junit.Test import ch.tutteli.atrium.api.fluent.en_GB.toEqual
import yaep.composeapp.generated.resources.Res import ch.tutteli.atrium.api.verbs.expect
import yaep.composeapp.generated.resources.compose_multiplatform
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.fail
class GameTest { class GameTest {
@Test @Test
fun areCategoriesValid() { fun `ensure generated game is valid`() {
// Game( val game = generateGame()
// categories = listOf( expect(game) {
// category { feature(Game::areCategoriesValid).toEqual(true)
// item(Res.drawable.compose_multiplatform) feature(Game::areRulesViolated).toEqual(false)
// } }
// ),
// Grid(
//
// )
// )
} }
@Test
fun areRulesViolated() {
TODO()
}
} }

View File

@@ -10,6 +10,7 @@ kotlin = "1.9.24"
[libraries] [libraries]
#junit = { module = "junit:junit", version = "4.13.2"} #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" } 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 = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }