moth

Monarch Of The Hill game server
git clone https://git.woozle.org/neale/moth.git

moth / theme
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)