Skip to content

Commit

Permalink
Enhancements for Eight Tracks puzzles.
Browse files Browse the repository at this point in the history
- Allow labeling tracks as rings instead
- Separate checkboxes for enumerations and track directions
- Add validation for section letters
  • Loading branch information
jpd236 committed Sep 28, 2024
1 parent 6fae513 commit 79c9a26
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ data class EightTracks(
val trackStartingOffsets: List<Int>,
val trackAnswers: List<List<String>>,
val trackClues: List<List<String>>,
val includeEnumerationsAndDirections: Boolean,
val includeEnumerations: Boolean,
val includeDirections: Boolean,
val lightTrackColor: String,
val darkTrackColor: String,
val trackLabel: TrackLabel = TrackLabel.TRACKS,
) : Puzzleable() {

enum class TrackLabel(val firstTrack: String, val otherTracks: String) {
TRACKS("Track 1", "Other tracks"),
RINGS("Ring 1", "Other rings"),
}

enum class Direction {
CLOCKWISE,
COUNTERCLOCKWISE
Expand All @@ -27,6 +34,7 @@ data class EightTracks(
val clueLists = mutableListOf<Puzzle.ClueList>()
val otherTracks = mutableListOf<Puzzle.Clue>()
val words = mutableListOf<Puzzle.Word>()
var sectionLetters: Map<Int, List<Char>> = mapOf()
trackAnswers.forEachIndexed { trackIndex, answers ->
val trackWidth = gridWidth - 2 * trackIndex
val trackCoordinates =
Expand Down Expand Up @@ -58,15 +66,34 @@ data class EightTracks(
wordsCells += word
answerIndex + answer.length
}
// Record the letters in each section for validation.
val trackSectionLetters = mutableMapOf<Int, MutableList<Char>>()
trackCoordinates.forEachIndexed { i, coordinates ->
val currentSectionLetters =
trackSectionLetters.getOrPut(8 * i / trackCoordinates.size) { mutableListOf() }
currentSectionLetters.add(gridMap[coordinates]!!.solution[0])
}
if (trackIndex == 0) {
sectionLetters = trackSectionLetters
val (trackClues, trackWords) = wordsCells.mapIndexed { i, cells ->
val clue = enumerateClue(trackClues[0][i], cells.size, includeEnumerationsAndDirections)
val clue = enumerateClue(trackClues[0][i], cells.size, includeEnumerations)
val word = Puzzle.Word(101 + i, cells)
Puzzle.Clue(101 + i, "${i + 1}", clue) to word
}.unzip()
clueLists.add(Puzzle.ClueList("Track 1", trackClues))
clueLists.add(Puzzle.ClueList(trackLabel.firstTrack, trackClues))
words.addAll(trackWords)
} else {
// Validate that each section contains all letters of the outer track's section except one.
trackSectionLetters.forEach { (i, currentSectionLetters) ->
val previousSectionLetters = sectionLetters[i]!!.toMutableList()
require(previousSectionLetters.removeAll(currentSectionLetters)) {
"Invalid section in track ${trackIndex + 1}: " +
"previous track letters = ${sectionLetters[i]!!}, " +
"current track letters = ${currentSectionLetters}"
}
}
sectionLetters = trackSectionLetters

var word = wordsCells.flatten()
if (direction == Direction.COUNTERCLOCKWISE) {
word = reverseDirection(word)
Expand All @@ -75,14 +102,14 @@ data class EightTracks(
word.slice((word.size - startingOffset + 1) until word.size) +
word.slice(0 until word.size - startingOffset + 1)
var number = "${trackIndex + 1}"
if (includeEnumerationsAndDirections) {
if (includeDirections) {
if (direction == Direction.COUNTERCLOCKWISE) {
offsetWord = reverseDirection(offsetWord)
}
number += "(" + (if (direction == Direction.CLOCKWISE) "+" else "") + ")"
}
val clues = trackClues[trackIndex].mapIndexed { i, clue ->
enumerateClue(clue, wordsCells[i].size, includeEnumerationsAndDirections)
enumerateClue(clue, wordsCells[i].size, includeEnumerations)
}.joinToString(" / ")
val trackWord = Puzzle.Word(trackIndex + 1, offsetWord)
otherTracks.add(Puzzle.Clue(trackIndex + 1, number, clues))
Expand All @@ -94,7 +121,7 @@ data class EightTracks(
gridMap.getOrElse(x to y) { Puzzle.Cell(cellType = Puzzle.CellType.BLOCK) }
}
}
clueLists.add(Puzzle.ClueList("Other tracks", otherTracks))
clueLists.add(Puzzle.ClueList(trackLabel.otherTracks, otherTracks))

return Puzzle(
title = title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.jeffpdavidson.kotwords.readStringResource
import io.github.pdvrieze.xmlutil.testutil.assertXmlEquals
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.fail

class EightTracksTest {
@Test
Expand All @@ -18,7 +19,8 @@ class EightTracksTest {
trackStartingOffsets = listOf(3, 7),
trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAO")),
trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")),
includeEnumerationsAndDirections = true,
includeEnumerations = true,
includeDirections = true,
lightTrackColor = "#FFFFFF",
darkTrackColor = "#C0C0C0",
)
Expand Down Expand Up @@ -47,7 +49,8 @@ class EightTracksTest {
trackStartingOffsets = listOf(3, 7),
trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAO")),
trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")),
includeEnumerationsAndDirections = false,
includeEnumerations = false,
includeDirections = false,
lightTrackColor = "#FFFFFF",
darkTrackColor = "#C0C0C0",
)
Expand All @@ -64,4 +67,28 @@ class EightTracksTest {
).toXmlString()
)
}

@Test
fun asPuzzle_invalidSection() = runTest {
val eightTracks = EightTracks(
title = "Test title",
creator = "Test creator",
copyright = "Test copyright",
description = "Test description",
trackDirections = listOf(EightTracks.Direction.CLOCKWISE, EightTracks.Direction.COUNTERCLOCKWISE),
trackStartingOffsets = listOf(3, 7),
trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAQ")),
trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")),
includeEnumerations = true,
includeDirections = true,
lightTrackColor = "#FFFFFF",
darkTrackColor = "#C0C0C0",
)
try {
eightTracks.asPuzzle()
fail("Expected invalid section to throw IllegalArgumentException")
} catch (e: IllegalArgumentException) {
// expected
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ class EightTracksForm {
private val trackStartingOffsets: FormFields.InputField = FormFields.InputField("track-starting-offsets")
private val trackAnswers: FormFields.TextBoxField = FormFields.TextBoxField("track-answers")
private val trackClues: FormFields.TextBoxField = FormFields.TextBoxField("track-clues")
private val includeEnumerationsAndDirection: FormFields.CheckBoxField =
FormFields.CheckBoxField("include-enumerations-and-direction")
private val includeEnumerations: FormFields.CheckBoxField = FormFields.CheckBoxField("include-enumerations")
private val includeDirections: FormFields.CheckBoxField = FormFields.CheckBoxField("include-directions")
private val trackLabel: FormFields.SelectField = FormFields.SelectField("track-label")
private val lightTrackColor: FormFields.InputField = FormFields.InputField("light-track-color")
private val darkTrackColor: FormFields.InputField = FormFields.InputField("dark-track-color")

Expand All @@ -44,9 +45,20 @@ class EightTracksForm {
rows = "8"
}
}, advancedOptionsBlock = {
includeEnumerationsAndDirection.render(this, "Include clue enumerations and track directions") {
checked = true
div(classes = "form-row") {
includeEnumerations.render(this, "Include clue enumerations", flexCols = 6) {
checked = true
}
includeDirections.render(this, "Include track directions", flexCols = 6) {
checked = true
}
}
trackLabel.render(
this,
"Track label",
EightTracks.TrackLabel.entries.map { it.name.lowercase().replaceFirstChar { it.uppercase() } },
help = "How to label the tracks in the clue list"
)
div(classes = "form-row") {
lightTrackColor.render(this, "Light track color", flexCols = 6) {
type = InputType.color
Expand Down Expand Up @@ -77,9 +89,11 @@ class EightTracksForm {
trackClues = trackClues.value.trimmedLines().map { clues ->
clues.split("/").map { it.trim() }
},
includeEnumerationsAndDirections = includeEnumerationsAndDirection.value,
includeEnumerations = includeEnumerations.value,
includeDirections = includeDirections.value,
lightTrackColor = lightTrackColor.value,
darkTrackColor = darkTrackColor.value,
trackLabel = EightTracks.TrackLabel.valueOf(trackLabel.value.uppercase())
)
return eightTracks.asPuzzle()
}
Expand Down
51 changes: 30 additions & 21 deletions src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/PuzzleFileForm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import kotlinx.html.p
import kotlinx.html.role
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.dom.asList
import org.w3c.files.Blob
Expand Down Expand Up @@ -316,6 +317,7 @@ internal class PuzzleFileForm(
}

is HTMLTextAreaElement -> it.value
is HTMLSelectElement -> it.value
else -> throw IllegalStateException("")
}
saveData[id] = value
Expand All @@ -333,31 +335,38 @@ internal class PuzzleFileForm(
errorMessage.setMessage("")
val ids = (js("Object").keys(saveData) as Array<String>).filterNot { it == KEY_PUZZLE_TYPE }
ids.forEach { id ->
// Map legacy standalone metadata fields from old save data to current fields.
val fieldId = when (id) {
"title" -> elementId("title")
"creator", "author" -> elementId("creator")
"copyright" -> elementId("copyright")
"description", "notes" -> elementId("description")
else -> id
// TODO: Provide a way for individual forms to plug in their own field migrations.
val fieldIds = when (id) {
// Map legacy standalone metadata fields from old save data to current fields.
"title" -> listOf(elementId("title"))
"creator", "author" -> listOf(elementId("creator"))
"copyright" -> listOf(elementId("copyright"))
"description", "notes" -> listOf(elementId("description"))
// Eight tracks: separate enumeration/direction checkboxes
"include-enumerations-and-direction" -> listOf("include-enumerations", "include-directions")
else -> listOf(id)
}
val element = document.getElementById(fieldId) ?: return@forEach

val value = saveData[id]
when (element) {
is HTMLInputElement -> {
when (element.type) {
InputType.checkBox.realValue -> element.checked = value as Boolean
else -> element.value = value as String
fieldIds.forEach(fun(fieldId) {
val element = document.getElementById(fieldId) ?: return

val value = saveData[id]
when (element) {
is HTMLInputElement -> {
when (element.type) {
InputType.checkBox.realValue -> element.checked = value as Boolean
else -> element.value = value as String
}
// Invoke the oninput listener, if any, to emulate the user entering the value.
val event = document.createEvent("Event")
event.initEvent("input", bubbles = true, cancelable = true)
element.dispatchEvent(event)
}
// Invoke the oninput listener, if any, to emulate the user entering the value.
val event = document.createEvent("Event")
event.initEvent("input", bubbles = true, cancelable = true)
element.dispatchEvent(event)
}

is HTMLTextAreaElement -> element.value = value as String
}
is HTMLTextAreaElement -> element.value = value as String
is HTMLSelectElement -> element.value = value as String
}
})
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onInputFunction
import kotlinx.html.label
import kotlinx.html.option
import kotlinx.html.role
import kotlinx.html.select
import kotlinx.html.small
import kotlinx.html.span
import kotlinx.html.style
import kotlinx.html.textArea
import org.w3c.dom.HTMLButtonElement
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
import org.w3c.dom.HTMLSpanElement
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.files.File
Expand Down Expand Up @@ -110,11 +113,12 @@ internal object FormFields {
*
* @param parent the parent [FlowContent] to render into
* @param label the label for the input field
* @param flexCols optional number of columns this field should take up in the parent container.
* @param block optional block run in the scope of the [INPUT] tag for further customization.
*/
fun render(parent: FlowContent, label: String, block: INPUT.() -> Unit = {}) {
fun render(parent: FlowContent, label: String, flexCols: Int? = null, block: INPUT.() -> Unit = {}) {
with(parent) {
formGroup {
formGroup(flexCols) {
div(classes = "form-check") {
input(classes = "form-check-input") {
this.id = htmlId
Expand Down Expand Up @@ -339,6 +343,59 @@ internal object FormFields {
}
}


/**
* Create an <select> field for a dropdown menu.
*
* @param htmlId the ID to be used for the input field (and as an ID prefix for associated tags)
*/
class SelectField(private val htmlId: String) : FormField<String> {
private val input: HTMLSelectElement by Html.getElementById(htmlId)

override var value: String
get() = input.value
set(value) {
input.value = value
}

/**
* Render the field into the given [FlowContent].
*
* @param parent the parent [FlowContent] to render into
* @param label the label for the select field
* @param options the options to show in the menu
* @param help optional help text used to describe the field in more detail
* @param flexCols optional number of columns this field should take up in the parent container.
* @param block optional block run in the scope of the [SELECT] tag for further customization.
*/
fun render(
parent: FlowContent, label: String, options: List<String>, help: String = "", flexCols: Int? = null
) {
with(parent) {
formGroup(flexCols) {
label {
htmlFor = htmlId
+label
}
select(classes = "form-control") {
this.id = htmlId
if (help.isNotBlank()) {
attributes["aria-describedby"] = "$htmlId-help"
}
options.forEach { value ->
option {
+value
}
}
}
if (help.isNotBlank()) {
help(htmlId, help)
}
}
}
}
}

class Button(private val htmlId: String) {
val button: HTMLButtonElement by Html.getElementById(htmlId)

Expand Down

0 comments on commit 79c9a26

Please sign in to comment.