Created
May 9, 2022 13:19
-
-
Save cranst0n/bb70e865dca15a813f08c1b037b14760 to your computer and use it in GitHub Desktop.
Scala 3 wordle recommender
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import cats.effect.ExitCode | |
| import cats.effect.IO | |
| import cats.effect.std.Console | |
| import cats.syntax.all.* | |
| import com.monovore.decline.Opts | |
| import com.monovore.decline.effect.CommandIOApp | |
| import fs2.io.file.Files | |
| import fs2.io.file.Path | |
| object WordleMain extends CommandIOApp(name = "wordle", header = "wordle", version = "0.1"): | |
| val wordSize = 5 | |
| val wordFilePath = s"/path/to/$wordSize-letter-words.txt" | |
| enum Letter: | |
| case AtPosition(letter: Char, position: Int) extends Letter | |
| case InWord(letter: Char, position: Int) extends Letter | |
| case Absent(letter: Char) extends Letter | |
| object Letter: | |
| // Example: 'train:*+--*' => AtPosition :: InWord :: Absent :: Absent :: AtPosition :: Nil | |
| def fromString(s: String): Either[String, List[Letter]] = | |
| s.split(":") match | |
| case Array(chars, symbols) if chars.size == wordSize && symbols.size == wordSize => | |
| chars.toList.zip(symbols.toList).zipWithIndex.traverse { | |
| case ((char, symbol), position) => | |
| if (!char.isLetter) | |
| s"Invalid known letter: '$char' in string '$s'".asLeft[Letter] | |
| else | |
| symbol match | |
| case '*' => Letter.AtPosition(char.toLower, position).asRight[String] | |
| case '+' => Letter.InWord(char.toLower, position).asRight[String] | |
| case '-' => Letter.Absent(char.toLower).asRight[String] | |
| case x => s"Invalid known symbol: '$x' in string '$s'".asLeft[Letter] | |
| } | |
| case _ => | |
| s"Invalid guess format: $s".asLeft[List[Letter]] | |
| final case class Options( | |
| known: Option[List[Letter]], | |
| numRecommendations: Int | |
| ) | |
| val knownOpt = Opts | |
| .arguments[String]("known") | |
| .mapValidated(s => s.toList.flatTraverse(Letter.fromString).toValidatedNel) | |
| .map { letters => | |
| // Filter out absents that already have AtPosition or InWord | |
| letters.filter { thisLetter => | |
| thisLetter match | |
| case Letter.Absent(thisChar) => | |
| letters.collect { | |
| case x @ Letter.AtPosition(`thisChar`, _) => x | |
| case x @ Letter.InWord(`thisChar`, _) => x | |
| }.isEmpty | |
| case _ => true | |
| } | |
| } | |
| .orNone | |
| val numRecommendationsOpt = Opts | |
| .option[Int]("num-recommendations", "Number of recommendations to print", "N") | |
| .withDefault(15) | |
| val opts = (knownOpt, numRecommendationsOpt).mapN(Options.apply) | |
| override def main: Opts[IO[ExitCode]] = | |
| opts | |
| .map { opts => | |
| loadWords | |
| .flatMap { wordList => | |
| val charFrequency = wordList.foldMap(word => word.toList.foldMap(c => Map(c -> 1))) | |
| val usableWords = opts.known match | |
| case None => wordList | |
| case Some(known) => wordleMatches(known, wordList) | |
| // Print two columns of recommendations. 1 for words with all unique letters | |
| // and another with duplicate letters | |
| val uniqueCharacterRecommendations = | |
| findRecommendations(usableWords.filterNot(hasDuplicateLetters), charFrequency) | |
| .take(opts.numRecommendations) | |
| val duplicateCharacterRecommendations = | |
| findRecommendations(usableWords.filter(hasDuplicateLetters), charFrequency) | |
| .take(opts.numRecommendations) | |
| val tableColLine = "─" * (wordSize + 4) | |
| Console[IO].println(s"╭$tableColLine┬$tableColLine╮") *> | |
| uniqueCharacterRecommendations | |
| .padTo(duplicateCharacterRecommendations.size, " " * wordSize) | |
| .zip( | |
| duplicateCharacterRecommendations | |
| .padTo(uniqueCharacterRecommendations.size, " " * wordSize) | |
| ) | |
| .traverse_ { case (uniqueRecommendation, duplicateRecommendation) => | |
| Console[IO].println(s"│ $uniqueRecommendation │ $duplicateRecommendation │") | |
| } *> Console[IO].println(s"╰$tableColLine┴$tableColLine╯") | |
| } | |
| .as(ExitCode.Success) | |
| } | |
| def loadWords: IO[List[String]] = | |
| Files[IO] | |
| .readAll(Path(wordFilePath)) | |
| .through(fs2.text.utf8.decode) | |
| .through(fs2.text.lines) | |
| .map(_.toLowerCase) | |
| .compile | |
| .toList | |
| def hasDuplicateLetters(s: String): Boolean = s.toSet.size < s.size | |
| def wordleMatches(known: List[Letter], words: List[String]): List[String] = | |
| words.filter { word => | |
| known.forall { letter => | |
| letter match | |
| case Letter.InWord(letter, position) => | |
| word.toList.lift(position).exists(_ != letter) && word.contains(letter) | |
| case Letter.AtPosition(letter, position) => | |
| word.toList.lift(position).exists(_ == letter) | |
| case Letter.Absent(letter) => | |
| word.forall(_ != letter) | |
| } | |
| } | |
| def findRecommendations( | |
| usableWords: List[String], | |
| charFrequency: Map[Char, Int] | |
| ): List[String] = | |
| usableWords | |
| .map(word => word -> wordScore(word, charFrequency)) | |
| .sortBy(-_._2) | |
| .map(_._1) | |
| def wordScore( | |
| word: String, | |
| charFrequency: Map[Char, Int] | |
| ): Int = | |
| word.toList.foldMap(c => charFrequency.getOrElse(c, 0)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment