diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 46c9bbe..4c1c6f7 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -24,7 +24,7 @@ jobs: - name: Build run: | ./syncComposeSharedSources.sh - ./gradlew --no-daemon compileReleaseSources + ./gradlew --no-daemon compileReleaseSources assembleDist - name: Test run: ./gradlew --no-daemon test @@ -51,5 +51,6 @@ jobs: -Pplaystore.keystore.password="$KEYSTORE_PASSWORD" \ -Pplaystore.keystore.key_password="$KEYSTORE_KEY_PASSWORD" \ :wordle-compose-desktop:assembleDist \ + :wordle-compose-mosaic:assembleDist \ :wordle-compose-android:assembleRelease \ -x lint diff --git a/README.md b/README.md index 4b3655c..b2d7206 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,29 @@ Congrats! You found the correct answer 🎉: HELLO +### Compose Mosaic mode + +![](raw/wordle-mosaic.png) + +In a compatible terminal, build the binary distribution and execute it: + +```bash +$ ./gradlew wordle-compose-mosaic:installDist +$ ./wordle-compose-mosaic/build/install/wordle-compose-mosaic/bin/wordle-compose-mosaic +``` + +Or you can simply execute the convenience `runWordleMosaic.sh` script doing the same. + +```bash +$ ./runWordleMosaic.sh +``` + ## Tech Stack * [Kotlin](https://kotlinlang.org/) * [Jetbrains Compose for Desktop](https://www.jetbrains.com/lp/compose/) * [Jetpack Compose for Android](https://developer.android.com/jetpack/compose) +* [Mosaic Compose for Console UI](https://github.com/JakeWharton/mosaic) ## Development diff --git a/build.gradle.kts b/build.gradle.kts index d08b960..2fa29a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.mosaic) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ceb6ea1..a8ac5f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,9 @@ accompanist-insets = "com.google.accompanist:accompanist-insets:0.25.1" gson = "com.google.code.gson:gson:2.10.1" +jline = "org.jline:jline:3.25.0" +turtle = "com.lordcodes.turtle:turtle:0.9.0" + junit4 = "junit:junit:4.13.2" [bundles] @@ -47,3 +50,4 @@ android-library = { id = "com.android.library", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose" } +mosaic = { id = "com.jakewharton.mosaic", version = "0.9.1" } \ No newline at end of file diff --git a/raw/wordle-mosaic.png b/raw/wordle-mosaic.png new file mode 100644 index 0000000..85179ec Binary files /dev/null and b/raw/wordle-mosaic.png differ diff --git a/runWordleMosaic.sh b/runWordleMosaic.sh new file mode 100755 index 0000000..4037496 --- /dev/null +++ b/runWordleMosaic.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +origin=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) || exit + +cd "$origin" + +./gradlew wordle-compose-mosaic:installDist + +echo "" + +./wordle-compose-mosaic/build/install/wordle-compose-mosaic/bin/wordle-compose-mosaic diff --git a/settings.gradle.kts b/settings.gradle.kts index ee3e223..05aa3cf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,3 +13,4 @@ include("word-data") include("wordle-ascii-cli") include("wordle-compose-desktop") include("wordle-compose-android") +include("wordle-compose-mosaic") diff --git a/wordle-compose-mosaic/build.gradle.kts b/wordle-compose-mosaic/build.gradle.kts new file mode 100644 index 0000000..60e4508 --- /dev/null +++ b/wordle-compose-mosaic/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.jetbrains.kotlin.jvm) + alias(libs.plugins.mosaic) + application +} + +dependencies { + implementation(libs.jline) { + because("need to handle terminal keyboard input") + } + implementation(libs.turtle) { + because("need to copy results to clipboard (using `pbcopy`, `xclip`, `clip` or equivalent)") + } + implementation(project(":word-data")) + implementation(project(":game-logic")) +} + +application { + mainClass = "net.opatry.game.wordle.mosaic.WordleComposeMosaicKt" +} \ No newline at end of file diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt new file mode 100644 index 0000000..f8ef69c --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/PlatformUtil.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle + +import com.lordcodes.turtle.shellRun +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +enum class OSName { + UNKNOWN, + WINDOWS, + MAC, + LINUX, +} + +val OS: OSName + get() { + val os = System.getProperty("os.name").lowercase() + return when { + os.contains("mac") -> OSName.MAC + os.contains("win") -> OSName.WINDOWS + os.contains("nix") || os.contains("nux") || os.contains("aix") -> OSName.LINUX + else -> OSName.UNKNOWN + } + } + +suspend fun String.copyToClipboard(): Boolean { + // TODO check for command availability and find fallbacks if possible + val copyCommand = when (OS) { + OSName.MAC -> "pbcopy" + OSName.WINDOWS -> "clip" // in WSL2 there is clipcopy + OSName.LINUX -> "xclip" // there is also xsel --clipboard --input + else -> null + } + return if (copyCommand != null) { + withContext(Dispatchers.IO) { + try { + // XXXcopy <<< "str" + shellRun("/bin/sh", listOf("-c", "$copyCommand <<< \"${this@copyToClipboard}\"")) + true + } catch (e: Exception) { + false + } + } + } else { + false + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt new file mode 100644 index 0000000..2f29525 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/alphabet.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic.component + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import net.opatry.game.wordle.AnswerFlag + +@Composable +fun Alphabet(alphabet: Map) { + Column { + val colCount = 9 + val cellWidth = 5 + Text("╭" + "─".repeat(cellWidth * colCount) + "╮") + alphabet.keys.chunked(colCount).forEachIndexed { index, row -> + if (index > 0) { + Text("│" + " ".repeat(colCount) + "│") + } + Row { + Text("│") + row.forEach { letter -> + // cell width is leading & trailing space + WordleCharCell compound of 3 characters + Text(" ") + WordleCharCell(letter, alphabet[letter]!!) + Text(" ") + } + // pad empty space for partial rows to align left border + repeat(colCount - row.size) { + Text(" ".repeat(cellWidth)) + } + Text("│") + } + } + Text("╰" + "─".repeat(cellWidth * colCount) + "╯") + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt new file mode 100644 index 0000000..42710fe --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/component/wordle.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic.component + +import androidx.compose.runtime.Composable +import com.jakewharton.mosaic.ui.Color +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.ui.TextStyle +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag + + +@Composable +fun WordleGrid(grid: List) { + Column { + // Box drawing: https://en.wikipedia.org/wiki/Box-drawing_character#Box_Drawing + // FIXME dividers length depends on max of row.letters + // good enough for now given that we know it's a 5x6 grid + Text("╭─────┬─────┬─────┬─────┬─────╮") + grid.forEachIndexed { rowIndex, row -> + if (rowIndex > 0) { + Text("├─────┼─────┼─────┼─────┼─────┤") + } + Row { + row.letters.forEachIndexed { cellIndex, char -> + if (cellIndex == 0) + Text("│ ") + else + Text(" │ ") + + WordleCharCell(char, row.flags[cellIndex]) + + if (cellIndex == row.letters.size - 1) + Text(" │") + } + } + } + Text("╰─────┴─────┴─────┴─────┴─────╯") + } +} + +fun AnswerFlag.cellColors(): Pair = when (this) { + AnswerFlag.NONE -> Color.Black to Color.White + AnswerFlag.PRESENT -> Color.Black to Color.Yellow + AnswerFlag.ABSENT -> Color.BrightWhite to Color.Black + AnswerFlag.CORRECT -> Color.BrightWhite to Color.Green +} + +@Composable +fun WordleCharCell(char: Char, flag: AnswerFlag) { + val (foregroundColor, backgroundColor) = + if (flag == AnswerFlag.NONE && !char.isWhitespace()) + Color.Black to Color.BrightWhite + else + flag.cellColors() + + // TODO AnnotatedString " $char " https://github.com/JakeWharton/mosaic/issues/9 + Column { + Row { + Text( + " $char ", + color = foregroundColor, + background = backgroundColor, + style = TextStyle.Bold + ) + } + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt new file mode 100644 index 0000000..a8f7adb --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/gameScreen.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.jakewharton.mosaic.ui.Color +import com.jakewharton.mosaic.ui.Column +import com.jakewharton.mosaic.ui.Row +import com.jakewharton.mosaic.ui.Text +import com.jakewharton.mosaic.ui.TextStyle +import net.opatry.game.wordle.State +import net.opatry.game.wordle.copyToClipboard +import net.opatry.game.wordle.mosaic.component.Alphabet +import net.opatry.game.wordle.mosaic.component.WordleGrid +import net.opatry.game.wordle.ui.WordleViewModel + + +@Composable +fun GameScreen(viewModel: WordleViewModel) { + Column { + Text("") + + Row { + Text(" ") + WordleGrid(viewModel.grid) + Text(" ") + Alphabet(viewModel.alphabet) + } + + Text("") + + // There must be stable number of lines for nice UI state. + // All states should display 3 lines. + when (val state = viewModel.state) { + is State.Won -> { + LaunchedEffect(viewModel.state) { + viewModel.stateLabel.copyToClipboard() + } + + Text("Wordle ${state.answers.size}/${state.maxTries}") + Text("Results copied to clipboard!") // FIXME depends on copyToClipboard success + Text(" 🔄 Play again? (y/N)?") + } + + is State.Lost -> { + Text("Wordle X/${state.maxTries}") + Row { + Text("The answer was ") + Text( + viewModel.answer, + color = Color.BrightWhite, + background = Color.Green, + style = TextStyle.Bold + ) + } + Text(" 🔄 Play again? (y/N)?") + } + + is State.Playing -> { + Text("") // TODO display error here if any or define a placeholder on top of grid + Text("") + Text(" ➡️ Enter a 5 letter english word") + } + } + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt new file mode 100644 index 0000000..a11459d --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/mosaic/wordleComposeMosaic.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.mosaic + +import com.jakewharton.mosaic.runMosaic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opatry.game.wordle.State +import net.opatry.game.wordle.WordleRules +import net.opatry.game.wordle.allDictionaries +import net.opatry.game.wordle.loadWords +import net.opatry.game.wordle.ui.WordleViewModel +import org.jline.terminal.TerminalBuilder + +suspend fun main() = runMosaic { + // TODO check terminal is compatible (eg. IDEA is not!) + val words = allDictionaries + .find { it.language == "en" && it.wordSize == 5 } + ?.loadWords() + ?: error("Can't find default dictionary") + var playing = true + val viewModel = WordleViewModel(WordleRules(words)) + + setContent { + GameScreen(viewModel) + } + + withContext(Dispatchers.IO) { + val terminal = TerminalBuilder.terminal() + terminal.enterRawMode() + terminal.reader().use { reader -> + while (playing) { + while (viewModel.state is State.Playing) { + val userInput = viewModel.userInput + val read = reader.read() + when (val char = read.toChar()) { + 13.toChar(), '\n' -> viewModel.validateUserInput() + 127.toChar(), '\b' -> if (userInput.isNotEmpty()) { + viewModel.updateUserInput(userInput.dropLast(1)) + } + + in 'a'..'z', + in 'A'..'Z' -> viewModel.updateUserInput(userInput + char) + // '?' -> viewModel.showHelp() + // ',' -> viewModel.showSettings() + // 27.toChar() -> break + else -> Unit // TODO display something to user + } + } + + val read = reader.read() + playing = read.toChar().equals('y', ignoreCase = true) + if (playing) { + viewModel.restart() + } + } + } + } +} diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt new file mode 100644 index 0000000..cfc13f2 --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/AnswerFlagExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.ui + +import net.opatry.game.wordle.AnswerFlag + + +val AnswerFlag.toEmoji: String + get() = when (this) { + AnswerFlag.NONE -> "⬛" + AnswerFlag.PRESENT -> "🟨" + AnswerFlag.ABSENT -> "⬜" + AnswerFlag.CORRECT -> "🟩" + } diff --git a/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt new file mode 100644 index 0000000..e86dc5d --- /dev/null +++ b/wordle-compose-mosaic/src/main/java/net/opatry/game/wordle/ui/WordleViewModel.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2022 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.game.wordle.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.opatry.game.wordle.Answer +import net.opatry.game.wordle.AnswerFlag +import net.opatry.game.wordle.InputState +import net.opatry.game.wordle.State +import net.opatry.game.wordle.WordleRules + +private val victoryMessages = arrayOf( + "Genius", + "Magnificent", + "Impressive", + "Splendid", + "Great", + "Phew" +) +private val State.message: String + get() { + val index = answers.size - 1 + return if (this is State.Won && index in victoryMessages.indices) { + victoryMessages[index] + } else { + "" + } + } + +private fun StringBuffer.appendAnswer(answer: Answer) { + append( + answer.flags.joinToString( + separator = " ", + postfix = "\n", + transform = AnswerFlag::toEmoji + ) + ).trimEnd() +} + +private fun State.toResultString(): String { + val buffer = StringBuffer() + buffer.append( + when (this) { + is State.Lost -> "Wordle X/$maxTries\n" + is State.Won -> "Wordle ${answers.size}/$maxTries\n" + else -> "" + } + ) + answers.forEach(buffer::appendAnswer) + + return buffer.toString() +} + +class WordleViewModel(private var rules: WordleRules) { + var firstLaunch by mutableStateOf(true) + private set + var state by mutableStateOf(rules.state) + val stateLabel: String + get() = rules.state.toResultString() + var victory by mutableStateOf(rules.state is State.Won) + private set + var answer by mutableStateOf("") + private set + var grid by mutableStateOf>(emptyList()) + private set + var userInput by mutableStateOf("") + private set + var alphabet by mutableStateOf(emptyMap()) + + private val _userFeedback = mutableListOf() + var userFeedback by mutableStateOf(_userFeedback.toList()) + private set + + init { + updateGrid() + updateAlphabet() + } + + private fun updateGrid() { + val answers = rules.state.answers.toMutableList() + val turn = answers.size + val maxTries = rules.state.maxTries.toInt() + val wordSize = rules.wordSize + if (turn < maxTries) { + answers += Answer(userInput.padEnd(wordSize, ' ').toCharArray(), Array(wordSize) { AnswerFlag.NONE }) + } + val emptyAnswer = Answer(CharArray(wordSize) { ' ' }, Array(wordSize) { AnswerFlag.NONE }) + repeat(maxTries - turn - 1) { + answers += emptyAnswer + } + grid = answers.toList() + } + + private fun updateAlphabet() { + // TODO couldn't we make this smarter? + val answers = rules.state.answers + val absent = mutableSetOf() + val present = mutableSetOf() + val correct = mutableSetOf() + + answers.forEach { + it.flags.forEachIndexed { index, flag -> + when (flag) { + AnswerFlag.ABSENT -> absent += it.letters[index] + AnswerFlag.PRESENT -> present += it.letters[index] + AnswerFlag.CORRECT -> correct += it.letters[index] + else -> Unit + } + } + } + + val alphabet = mutableMapOf().apply { + var c = 'A' + while (c <= 'Z') { + this += c to when { + correct.contains(c) -> AnswerFlag.CORRECT + present.contains(c) -> AnswerFlag.PRESENT + absent.contains(c) -> AnswerFlag.ABSENT + else -> AnswerFlag.NONE + } + ++c + } + } + this.alphabet = alphabet.toMap() + } + + private fun updateAnswer() { + answer = when (val state = rules.state) { + is State.Playing -> "" + is State.Lost -> state.selectedWord + is State.Won -> state.selectedWord + } + } + + fun updateUserInput(input: String) { + if (rules.state !is State.Playing) return + + val normalized = input.take(rules.wordSize).uppercase() + if (normalized != userInput) { + userInput = normalized + updateGrid() + } + } + + fun validateUserInput() { + if (rules.state !is State.Playing) return + + when (rules.playWord(userInput)) { + InputState.VALID -> { + userInput = "" + updateGrid() + updateAlphabet() + } + + InputState.NOT_IN_DICTIONARY -> { + _userFeedback.add("Not in word list") + userFeedback = _userFeedback.toList() + updateGrid() + } + + InputState.TOO_SHORT -> { + _userFeedback.add("Not enough letters") + userFeedback = _userFeedback.toList() + updateGrid() + } + + else -> Unit + } + val oldVictory = victory + updateAnswer() + + // notify user + victory = rules.state is State.Won + if (victory && !oldVictory) { + _userFeedback.add(rules.state.message) + userFeedback = _userFeedback.toList() + } + updateAnswer() + state = rules.state + } + + fun restart() { + rules = WordleRules(rules.words) + victory = rules.state is State.Won + userInput = "" + _userFeedback.clear() + userFeedback = _userFeedback.toList() + updateGrid() + updateAlphabet() + updateAnswer() + state = rules.state + } + + fun consumed(message: String) { + _userFeedback.remove(message) + userFeedback = _userFeedback.toList() + } + + fun firstLaunchDone() { + firstLaunch = false + } +}