diff --git a/.gitignore b/.gitignore index a22d658..4e8c7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ cache/ target/ puzzles +__debug_bin diff --git a/example-puzzles/example/1/puzzle.moth b/example-puzzles/example/1/puzzle.md similarity index 58% rename from example-puzzles/example/1/puzzle.moth rename to example-puzzles/example/1/puzzle.md index 3b9fc1b..928e312 100644 --- a/example-puzzles/example/1/puzzle.moth +++ b/example-puzzles/example/1/puzzle.md @@ -1,21 +1,26 @@ -Author: neale -Summary: static puzzles -Answer: puzzle.moth - +--- +pre: + authors: + - neale +debug: + summary: static puzzles +answers: + - puzzle.md +--- Puzzle categories are laid out on the filesystem: example/ ├─1 - │ └─puzzle.moth + │ └─puzzle.md ├─2 - │ ├─puzzle.moth + │ ├─puzzle.md │ └─salad.jpg ├─3 - │ └─puzzle.py + │ └─mkpuzzle ├─10 - │ └─puzzle.moth + │ └─puzzle.md └─100 - └─puzzle.py + └─mkpuzzle In this example, there are puzzles with point values 1, 2, 3, 10, and 100. @@ -24,17 +29,22 @@ Puzzles 1, 2, and 10 are "static" puzzles: their content was written by hand. Puzzles 3 and 100 are "dynamic" puzzles: -they are generated from a Python module. +their content is generated by `mkpuzzle`. To create a static puzzle, all you must have is a -`puzzle.moth` file in the puzzle's directory. +`puzzle.md` file in the puzzle's directory. This file is in the following format: - Author: [name of the person who wrote this puzzle] - Summary: [brief description of the puzzle] - Answer: [answer to this puzzle] - Answer: [second acceptable answer to this puzzle] - + --- + pre: + authors: + - name of the person who wrote this puzzle + debug: + summary: brief description of the puzzle + answers: + - answer to this puzzle + - second acceptable answer to this puzzle + --- This is the puzzle body. It is Markdown formatted: you can read more about Markdown on the Internet. diff --git a/example-puzzles/example/2/puzzle.md b/example-puzzles/example/2/puzzle.md new file mode 100644 index 0000000..4836b3d --- /dev/null +++ b/example-puzzles/example/2/puzzle.md @@ -0,0 +1,36 @@ +--- +pre: + authors: + - neale + attachments: + - filename: salad.jpg + - filename: s2.jpg + filesystempath: salad2.jpg +debug: + summary: Static puzzle resource files +answers: + - salad +--- + +You can include additional resources in a static puzzle, +by dropping them in the directory and listing them under `attachments`. + +If the puzzle compiler sees both `filename` and `filesystempath`, +it changes the filename when the puzzle category is built. +You can use this to give good filenames while building, +but obscure them during build. +On this page, we obscure +`salad2.jpg` to `s2.jpg`, +so that people can't guess the answer based on filename. + +Check the source to this puzzle to see how this is done! + +You can refer to resources directly in your Markdown, +or use them however else you see fit. +They will appear in the same directory on the web server once the exercise is running. +Check the source for this puzzle to see how it was created. + +![Leafy Green Deliciousness](salad.jpg) +![Mmm so good](s2.jpg) + +The answer for this page is what is featured in the photograph. diff --git a/example-puzzles/example/2/puzzle.moth b/example-puzzles/example/2/puzzle.moth deleted file mode 100644 index 50d7918..0000000 --- a/example-puzzles/example/2/puzzle.moth +++ /dev/null @@ -1,39 +0,0 @@ -Author: neale -Summary: Static puzzle resource files -File: salad.jpg s.jpg -File: salad2.jpg s2.jpg hidden -Answer: salad -X-Answer-Pattern: *pong - -You can include additional resources in a static puzzle, -by dropping them in the directory and listing them in a `File:` header field. - -The format is: - - File: filename [translatedname] [hidden] - -If `translatedname` is provided, -the filename is changed to it when the puzzle category is built. -You can use this to give good filenames while building, -but obscure them during build. -On this page, we obscure `salad.jpg` to `s.jpg`, -and `salad2.jpg` to `s2.jpg`, -so that people can't guess the answer based on filename. - -The word `hidden`, if present, -prevents a file from being listed at the bottom of the page. - -Here are the `File:` fields in this page: - - File: salad.jpg s.jpg - File: salad2.jpg s2.jpg hidden - -You can refer to resources directly in your Markdown, -or use them however else you see fit. -They will appear in the same directory on the web server once the exercise is running. -Check the source for this puzzle to see how it was created. - -![Leafy Green Deliciousness](s.jpg) -![Mmm so good](s2.jpg) - -The answer for this page is what is featured in the photograph. diff --git a/example-puzzles/example/3/mkpuzzle b/example-puzzles/example/3/mkpuzzle index b23b47f..92df1da 100755 --- a/example-puzzles/example/3/mkpuzzle +++ b/example-puzzles/example/3/mkpuzzle @@ -1,34 +1,55 @@ -#! /bin/sh +#! /usr/bin/python3 -number=$(seq 20 500 | shuf -n 1) -answer=$(echo $(grep -v "['A-Z]" /usr/share/dict/words | shuf -n 4)) +import argparse +import json +import os +import random +import shutil +import sys -case "$1:$2" in - :) - cat <Dynamic puzzles are provided with a JSON-generating mkpuzzles program in the puzzle directory.

", - "Attachments": ["salad.jpg"] - }, - "Answers": [ - "$answer" - ], - "Debug": { - "Summary": "Dynamic puzzles", - "Hints": [ - "Check the debug output to get the answer." +parser = argparse.ArgumentParser("Generate a puzzle") +parser.add_argument("--file", dest="file", help="File to provide, instead of puzzle") +parser.add_argument("--answer", dest="answer", help="Answer to check, instead of providing puzzle") +args = parser.parse_args() + +seed = hash(os.getenv("SEED")) +random.seed(seed) + +words = ["apple", "pear", "peach", "tangerine", "orange", "potato", "carrot", "pea"] +answer = ' '.join(random.sample(words, 4)) + +if args.file: + f = open(args.file, "rb") + shutil.copyfileobj(f, sys.stdout.buffer) +elif args.answer: + if args.answer == answer: + print("correct") + else: + print("incorrect") +else: + number = random.randint(20, 500) + obj = { + "Pre": { + "Authors": ["neale"], + "Body": ( + "

Dynamic puzzles are provided with a JSON-generating mkpuzzles program in the puzzle directory.

" + "

You can write mkpuzzles in any language you like. This puzzle was written in Python 3.

" + "

Here is some salad:

" + ), + "Attachments": ["salad.jpg"], + }, + "Answers": [ + answer, ], - "Errors": [], - "Log": [ - "$number is a positive integer" - ] + "Debug": { + "Summary": "Dynamic puzzles", + "Hints": [ + "Check the debug output to get the answer." , + ], + "Errors": [], + "Log": [ + "%d is a positive integer" % number, + ], + } } -} -EOT - ;; - -file:salad.jpg) - cat salad.jpg - ;; -esac + json.dump(obj, sys.stdout) diff --git a/example-puzzles/example/3/puzzle.py b/example-puzzles/example/3/puzzle.py deleted file mode 100644 index a5e93df..0000000 --- a/example-puzzles/example/3/puzzle.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/python3 - -def make(puzzle): - puzzle.author = 'neale' - puzzle.summary = 'dynamic puzzles' - answer = puzzle.randword() - puzzle.answers.append(answer) - - puzzle.body.write("To generate a dynamic puzzle, you need to write a Python module.\n") - puzzle.body.write("\n") - puzzle.body.write("The passed-in puzzle object provides some handy methods.\n") - puzzle.body.write("In particular, please use the `puzzle.rand` object to guarantee that rebuilding a category\n") - puzzle.body.write("won't change puzzles and answers.\n") - puzzle.body.write("(Participants don't like it when puzzles and answers change.)\n") - puzzle.body.write("\n") - - puzzle.add_file('salad.jpg') - puzzle.body.write("Here are some more pictures of salad:\n") - puzzle.body.write("Markdown lets you insert raw HTML if you want") - puzzle.body.write("![salad](salad.jpg)") - puzzle.body.write("\n\n") - - number = puzzle.rand.randint(20, 500) - puzzle.log("One is the loneliest number, but {} is the saddest number.".format(number)) - - puzzle.body.write("The answer for this page is `{}`.\n".format(answer)) - diff --git a/example-puzzles/example/4/puzzle.moth b/example-puzzles/example/4/puzzle.moth deleted file mode 100644 index c06a653..0000000 --- a/example-puzzles/example/4/puzzle.moth +++ /dev/null @@ -1,20 +0,0 @@ -Summary: Answer patterns -Answer: command.com -Answer: COMMAND.COM -X-Answer-Pattern: PINBALL.* -X-Answer-Pattern: pinball.* -Author: neale -Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3} - -This puzzle features answer input pattern checking. - -Sometimes you need to provide a hint about whether the user has entered the answer in the right format. -By providing a `Pattern` value (a regular expression), -the browser will (hopefully) provide a visual hint when an answer is incorrectly formatted. -It will also (hopefully) prevent the user from submitting, -which will (hopefully) inform the participant that they may have the right solution technique, -but there's a problem with the format of the answer. -This will (hopefully) keep people from getting overly-frustrated with difficult-to-enter answers. - -This answer field will validate only FAT 8+3 filenames. -Try it! diff --git a/example-puzzles/example/5/helpers.js b/example-puzzles/example/5/helpers.js index f8cf28a..9c2b65f 100644 --- a/example-puzzles/example/5/helpers.js +++ b/example-puzzles/example/5/helpers.js @@ -1,6 +1,6 @@ // jshint asi:true -function helperUpdateAnswer(event) { +async function helperUpdateAnswer(event) { let e = event.currentTarget let value = e.value let inputs = e.querySelectorAll("input") @@ -24,7 +24,11 @@ function helperUpdateAnswer(event) { if (join === undefined) { join = "," } - value = values.join(join) + if (values.length == 0) { + value = "None" + } else { + value = values.join(join) + } } // First make any adjustments to the value @@ -35,6 +39,35 @@ function helperUpdateAnswer(event) { value = value.toUpperCase() } + // "substrings" answers try all substrings. If any are the answer, they're filled in. + if (e.classList.contains("substring")) { + let validated = null + let anchorEnd = e.classList.contains("anchor-end") + let anchorBeg = e.classList.contains("anchor-beg") + + for (let end = 0; end <= value.length; end += 1) { + for (let beg = 0; beg < value.length; beg += 1) { + if (anchorEnd && (end != value.length)) { + continue + } + if (anchorBeg && (beg != 0)) { + continue + } + let sub = value.substring(beg, end) + if (await checkAnswer(sub)) { + validated = sub + } + } + } + + value = validated + } + + // If anything zeroed out value, don't update the answer field + if (!value) { + return + } + let answer = document.querySelector("#answer") answer.value = value answer.dispatchEvent(new InputEvent("input")) @@ -78,15 +111,16 @@ function helperActivate(e) { } } +{ + let init = function(event) { + for (let e of document.querySelectorAll(".answer")) { + helperActivate(e) + } + } -function helperInit(event) { - for (let e of document.querySelectorAll(".answer")) { - helperActivate(e) + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) + } else { + init() } } - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", helperInit); -} else { - helperInit(); -} diff --git a/example-puzzles/example/5/puzzle.moth b/example-puzzles/example/5/puzzle.md similarity index 69% rename from example-puzzles/example/5/puzzle.moth rename to example-puzzles/example/5/puzzle.md index 1cbdffb..211ef51 100644 --- a/example-puzzles/example/5/puzzle.moth +++ b/example-puzzles/example/5/puzzle.md @@ -1,9 +1,15 @@ -Summary: Using JavaScript Input Helpers -Author: neale -Script: helpers.js -Script: draggable.js -Answer: helper - +--- +pre: + authors: + - neale + scripts: + - filename: helpers.js + - filename: draggable.js +answers: + - helper +debug: + summary: Using JavaScript Input Helpers +--- MOTH only takes static answers: you can't, for instance, write code to check answer correctness. But you can provide as many correct answers as you like in a single puzzle. @@ -18,7 +24,7 @@ This is just a demonstration page. You will probably only want one of these in a page, to avoid confusing people. -RFC3339 Timestamp +### RFC3339 Timestamp
@@ -26,10 +32,10 @@ RFC3339 Timestamp
-All lower-case letters +### All lower-case letters -Multiple concatenated values +### Multiple concatenated values
@@ -37,22 +43,32 @@ Multiple concatenated values
-Free input, sorted, concatenated values +### Free input, sorted, concatenated values -User-draggable values +### User-draggable values -Select from an ordered list of options +### Select from an ordered list of options + +### Substring matches +#### Any substring + + +#### Only if at the beginning + + +#### Only if at the end + diff --git a/example-puzzles/example/6/puzzle.md b/example-puzzles/example/6/puzzle.md new file mode 100644 index 0000000..cd7ae00 --- /dev/null +++ b/example-puzzles/example/6/puzzle.md @@ -0,0 +1,18 @@ +--- +pre: + authors: + - neale +answers: + - YAML + - yaml +debug: + summary: YAML Metadata +post: + objective: Understand how YAML metadata can be used in a `.moth` file + success: + acceptable: Enter the answer and move on + mastery: Create a `.md` file using YAML metadata and serve it through the devel server. +--- + +You can also provide metadata in YAML format. +This puzzle's `.md` file serves as an example. diff --git a/example-puzzles/example/6/puzzle.moth b/example-puzzles/example/6/puzzle.moth deleted file mode 100644 index f5425d0..0000000 --- a/example-puzzles/example/6/puzzle.moth +++ /dev/null @@ -1,17 +0,0 @@ ---- -Summary: YAML Metadata -Author: neale -Answer: YAML -Answer: yaml -Objective: | - Understand how YAML metadata can be used in a `.moth` file -Success: - Acceptable: | - Enter the answer and move on - Mastery: | - Create a `.moth` file using YAML metadata and serve it - through the devel server. ---- - -You can also provide metadata in YAML format. -This puzzle's `.moth` file serves as an example. diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index 006b021..3b228ea 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "io" "log" @@ -27,9 +28,9 @@ type Puzzle struct { Authors []string Attachments []string Scripts []string - AnswerHashes []string - AnswerPattern string Body string + AnswerPattern string + AnswerHashes []string } Post struct { Objective string @@ -89,7 +90,6 @@ type StaticPuzzle struct { type StaticAttachment struct { Filename string // Filename presented as part of puzzle FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) - Listed bool // Whether this file is listed as an attachment } // ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer. @@ -270,11 +270,6 @@ func legacyAttachmentParser(val []string) []StaticAttachment { } else { cur.Filename = cur.FilesystemPath } - if (len(parts) > 2) && (parts[2] == "hidden") { - cur.Listed = false - } else { - cur.Listed = true - } ret[idx] = cur } return ret @@ -351,7 +346,9 @@ func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) { cmd := exec.CommandContext(ctx, fp.command) cmd.Dir = path.Dir(fp.command) stdout, err := cmd.Output() - if err != nil { + if exiterr, ok := err.(*exec.ExitError); ok { + return Puzzle{}, errors.New(string(exiterr.Stderr)) + } else if err != nil { return Puzzle{}, err } @@ -381,7 +378,7 @@ func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) { ctx, cancel := context.WithTimeout(context.Background(), fp.timeout) defer cancel() - cmd := exec.CommandContext(ctx, fp.command, "-file", filename) + cmd := exec.CommandContext(ctx, fp.command, "--file", filename) cmd.Dir = path.Dir(fp.command) out, err := cmd.Output() buf := nopCloser{bytes.NewReader(out)} @@ -397,7 +394,7 @@ func (fp FsCommandPuzzle) Answer(answer string) bool { ctx, cancel := context.WithTimeout(context.Background(), fp.timeout) defer cancel() - cmd := exec.CommandContext(ctx, fp.command, "-answer", answer) + cmd := exec.CommandContext(ctx, fp.command, "--answer", answer) cmd.Dir = path.Dir(fp.command) out, err := cmd.Output() if err != nil { diff --git a/theme/puzzle.js b/theme/puzzle.js index 6727ac5..c5f96b1 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -48,34 +48,21 @@ async function sha256Hash(message) { } // Is the provided answer possibly correct? -async function possiblyCorrect(answer) { - let pattern = window.puzzle.Pre.AnswerPattern || [] +async function checkAnswer(answer) { + let answerHashes = [] + answerHashes.push(djb2hash(answer)) + answerHashes.push(await sha256Hash(answer)) - for (let correctHash of window.puzzle.Pre.AnswerHashes) { - if (djb2hash(answer) == correctHash) { - return answer - } - for (let end = 0; end <= answer.length; end += 1) { - if (pattern.includes("end") && (end != answer.length)) { - continue - } - for (let beg = 0; beg < answer.length; beg += 1) { - if (pattern.includes("begin") && (beg != 0)) { - continue - } - let sub = answer.substring(beg, end) - let digest = await sha256Hash(sub) - - if (digest == correctHash) { - return sub - } + for (let hash of answerHashes) { + for (let correctHash of window.puzzle.Pre.AnswerHashes) { + if (hash == correctHash) { + return true } } } return false } - // Pop up a message function toast(message, timeout=5000) { let p = document.createElement("p") @@ -196,7 +183,7 @@ function answerCheck(e) { return } - possiblyCorrect(answer) + checkAnswer(answer) .then (correct => { document.querySelector("[name=xAnswer").value = correct || answer if (correct) {