Neale Pickett
·
2024-04-08
puzzle.mjs
1/**
2 * Functionality for puzzle.html (Puzzle display / answer form)
3 */
4import * as moth from "./moth.mjs"
5import * as common from "./common.mjs"
6
7const workspacePromise = import("./workspace/workspace.mjs")
8
9const server = new moth.Server(".")
10
11/**
12 * Handle a submit event on a form.
13 *
14 * Called when the user submits the form,
15 * either by clicking a "submit" button,
16 * or by some other means provided by the browser,
17 * like hitting the Enter key.
18 *
19 * @param {Event} event
20 */
21async function formSubmitHandler(event) {
22 event.preventDefault()
23 let data = new FormData(event.target)
24 let proposed = data.get("answer")
25 let message
26
27 console.groupCollapsed("Submit answer")
28 console.info(`Proposed answer: ${proposed}`)
29 try {
30 message = await window.app.puzzle.SubmitAnswer(proposed)
31 common.Toast(message)
32 common.StateUpdateChannel.postMessage({})
33 document.dispatchEvent(new CustomEvent("answerCorrect"))
34 }
35 catch (err) {
36 common.Toast(err)
37 }
38 console.groupEnd("Submit answer")
39}
40
41/**
42 * Handle an input event on the answer field.
43 *
44 * @param {Event} event
45 */
46async function answerInputHandler(event) {
47 let answer = event.target.value
48 let correct = await window.app.puzzle.IsPossiblyCorrect(answer)
49 for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) {
50 if (correct) {
51 ok.textContent = "⭕"
52 ok.title = "Possibly correct"
53 } else {
54 ok.textContent = "❌"
55 ok.title = "Definitely not correct"
56 }
57 }
58}
59
60/**
61 * Return the puzzle content element, possibly with everything cleared out of it.
62 *
63 * @param {boolean} clear Should the element be cleared of children? Default true.
64 * @returns {Element}
65 */
66function puzzleElement(clear=true) {
67 let e = document.querySelector("#puzzle")
68 if (clear) {
69 while (e.firstChild) e.firstChild.remove()
70 }
71 return e
72}
73
74/**
75 * Display an error in the puzzle area, and also send it to the console.
76 *
77 * Errors are rendered in the puzzle area, so the user can see a bit more about
78 * what the problem is.
79 *
80 * @param {string} error
81 */
82function error(error) {
83 console.error(error)
84 let e = puzzleElement().appendChild(document.createElement("pre"))
85 e.classList.add("error")
86 e.textContent = error.Body || error
87}
88
89/**
90 * Set the answer and invoke input handlers.
91 *
92 * Makes sure the Circle Of Success gets updated.
93 *
94 * @param {string} s
95 */
96function SetAnswer(s) {
97 let e = document.querySelector("#answer")
98 e.value = s
99 e.dispatchEvent(new Event("input"))
100}
101
102function writeObject(e, obj) {
103 let keys = Object.keys(obj)
104 keys.sort()
105 for (let key of keys) {
106 let val = obj[key]
107 if ((key === "Body") || (!val) || (val.length === 0)) {
108 continue
109 }
110
111 let d = e.appendChild(document.createElement("dt"))
112 d.textContent = key
113
114 let t = e.appendChild(document.createElement("dd"))
115 if (Array.isArray(val)) {
116 let vi = t.appendChild(document.createElement("ul"))
117 vi.multiple = true
118 for (let a of val) {
119 let opt = vi.appendChild(document.createElement("li"))
120 opt.textContent = a
121 }
122 } else if (typeof(val) === "object") {
123 writeObject(t, val)
124 } else {
125 t.textContent = val
126 }
127 }
128}
129
130/**
131 * Load the given puzzle.
132 *
133 * @param {string} category
134 * @param {number} points
135 */
136async function loadPuzzle(category, points) {
137 console.groupCollapsed("Loading puzzle:", category, points)
138 let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
139
140 // Tell user we're loading
141 puzzleElement().appendChild(document.createElement("progress"))
142 for (let qs of ["#authors", "#title", "title"]) {
143 for (let e of document.querySelectorAll(qs)) {
144 e.textContent = "[loading]"
145 }
146 }
147
148 let puzzle = server.GetPuzzle(category, points)
149
150 console.time("Populate")
151 try {
152 await puzzle.Populate()
153 }
154 catch {
155 let error = puzzleElement().appendChild(document.createElement("pre"))
156 error.classList.add("notification", "error")
157 error.textContent = puzzle.Error.Body
158 return
159 }
160 finally {
161 console.timeEnd("Populate")
162 }
163
164 console.info(`Setting base tag to ${contentBase}`)
165 let baseElement = document.head.appendChild(document.createElement("base"))
166 baseElement.href = contentBase
167
168 console.info("Tweaking HTML...")
169 let title = `${category} ${points}`
170 document.querySelector("title").textContent = title
171 document.querySelector("#title").textContent = title
172 document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
173 if (puzzle.AnswerPattern) {
174 document.querySelector("#answer").pattern = puzzle.AnswerPattern
175 }
176 puzzleElement().innerHTML = puzzle.Body
177
178 console.info("Adding attached scripts...")
179 for (let script of (puzzle.Scripts || [])) {
180 let st = document.createElement("script")
181 document.head.appendChild(st)
182 st.src = new URL(script, contentBase)
183 }
184
185 console.info("Listing attached files...")
186 let attachmentUrls = []
187 for (let fn of (puzzle.Attachments || [])) {
188 let li = document.createElement("li")
189 let a = document.createElement("a")
190 let url = new URL(fn, contentBase)
191 attachmentUrls.push(url)
192 a.href = url
193 a.innerText = fn
194 li.appendChild(a)
195 document.getElementById("files").appendChild(li)
196 }
197
198 workspacePromise.then(workspace => {
199 let codeBlocks = document.querySelectorAll("code[class^=language-]")
200 for (let i = 0; i < codeBlocks.length; i++) {
201 let codeBlock = codeBlocks[i]
202 let id = category + "#" + points + "#" + i
203 new workspace.Workspace(codeBlock, id, attachmentUrls)
204 }
205 })
206
207 console.info("Filling debug information...")
208 for (let e of document.querySelectorAll(".debug")) {
209 if (puzzle.Answers.length > 0) {
210 writeObject(e, puzzle)
211 } else {
212 e.classList.add("hidden")
213 }
214 }
215
216 window.app.puzzle = puzzle
217 console.info("window.app.puzzle:", window.app.puzzle)
218
219 console.groupEnd()
220
221 return puzzle
222}
223
224const confettiPromise = import("https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/+esm")
225async function CorrectAnswer() {
226 setInterval(window.close, 3 * common.Second)
227
228 let confetti = await confettiPromise
229 confetti.default({
230 disableForReducedMotion: true,
231 })
232}
233
234async function init() {
235 window.app = {}
236 window.setanswer = (str => SetAnswer(str))
237 window.checkAnswer = (str => window.app.puzzle.IsPossiblyCorrect(str))
238
239 for (let form of document.querySelectorAll("form.submit-answer")) {
240 form.addEventListener("submit", formSubmitHandler)
241 for (let e of form.querySelectorAll("[name=answer]")) {
242 e.addEventListener("input", answerInputHandler)
243 }
244 }
245 // There isn't a more graceful way to "unload" scripts attached to the current puzzle
246 window.addEventListener("hashchange", () => location.reload())
247
248 // Workspaces may trigger a "this is the answer" event
249 document.addEventListener("setAnswer", e => SetAnswer(e.detail.value))
250
251 // Celebrate on correct answer
252 document.addEventListener("answerCorrect", e => CorrectAnswer())
253
254 // Make all links absolute, because we're going to be changing the base URL
255 for (let e of document.querySelectorAll("[href]")) {
256 e.href = new URL(e.href, common.BaseURL)
257 }
258
259 let hashpart = location.hash.split("#")[1] || ""
260 let catpoints = hashpart.split(":")
261 let category = catpoints[0]
262 let points = Number(catpoints[1])
263 if (!category && !points) {
264 error(`Doesn't look like a puzzle reference: ${hashpart}`)
265 return
266 }
267
268 window.app.puzzle = await loadPuzzle(category, points)
269}
270
271common.WhenDOMLoaded(init)