Skip to content

Instantly share code, notes, and snippets.

@cranst0n
Created May 9, 2022 13:19
Show Gist options
  • Select an option

  • Save cranst0n/bb70e865dca15a813f08c1b037b14760 to your computer and use it in GitHub Desktop.

Select an option

Save cranst0n/bb70e865dca15a813f08c1b037b14760 to your computer and use it in GitHub Desktop.
Scala 3 wordle recommender
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