moth

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

moth / pkg / transpile
Neale Pickett  ·  2024-01-03

puzzle.go

  1package transpile
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"context"
  7	"crypto/sha1"
  8	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"log"
 13	"net/mail"
 14	"os"
 15	"os/exec"
 16	"path"
 17	"strconv"
 18	"strings"
 19	"time"
 20
 21	"github.com/spf13/afero"
 22	"gopkg.in/yaml.v2"
 23)
 24
 25// AnswerResponse is handed back when we ask for an answer to be checked.
 26type AnswerResponse struct {
 27	Correct bool
 28}
 29
 30// PuzzleDebug is the full suite of debug fields in a puzzle
 31
 32type PuzzleDebug struct {
 33	Log     []string
 34	Errors  []string
 35	Hints   []string
 36	Notes   string
 37	Summary string
 38}
 39
 40// Puzzle contains everything about a puzzle that a client will see.
 41type Puzzle struct {
 42	// Debug contains debugging information, omitted in mothballs
 43	Debug PuzzleDebug
 44
 45	// Authors names all authors of this puzzle
 46	Authors []string
 47
 48	// Attachments is a list of filenames used by this puzzle
 49	Attachments []string
 50
 51	// Scripts is a list of EMCAScript files needed by the client for this puzzle
 52	Scripts []string
 53
 54	// Body is the HTML rendering of this puzzle
 55	Body string
 56
 57	// AnswerPattern contains the pattern (regular expression?) used to match valid answers
 58	AnswerPattern string
 59
 60	// AnswerHashes contains hashes of all answers for this puzzle
 61	AnswerHashes []string
 62
 63	// Answers lists all acceptable answers, omitted in mothballs
 64	Answers []string
 65
 66	// Extra is send unchanged to the client.
 67	// Eventually, Objective, KSAs, and Success will move into Extra.
 68	Extra map[string]any
 69
 70	// Objective is the learning objective for this puzzle
 71	Objective string
 72
 73	// KSAs lists all KSAs achieved upon successfull completion of this puzzle
 74	KSAs []string
 75
 76	// Success lists the criteria for successfully understanding this puzzle
 77	Success struct {
 78		// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
 79		Acceptable string
 80
 81		// Mastery describes the work required to be considered mastering this puzzle's conceptss
 82		Mastery string
 83	}
 84}
 85
 86func (puzzle *Puzzle) computeAnswerHashes() {
 87	if len(puzzle.Answers) == 0 {
 88		return
 89	}
 90	puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
 91	for i, answer := range puzzle.Answers {
 92		sum := sha1.Sum([]byte(answer))
 93		hexsum := fmt.Sprintf("%x", sum)
 94		puzzle.AnswerHashes[i] = hexsum[:4]
 95	}
 96}
 97
 98// StaticPuzzle contains everything a static puzzle might tell us.
 99type StaticPuzzle struct {
100	Authors       []string
101	Attachments   []StaticAttachment
102	Scripts       []StaticAttachment
103	AnswerPattern string
104	Answers       []string
105	Debug         PuzzleDebug
106	Extra         map[string]any
107	Objective     string
108	Success       struct {
109		Acceptable string
110		Mastery    string
111	}
112	KSAs []string
113}
114
115// StaticAttachment carries information about an attached file.
116type StaticAttachment struct {
117	Filename       string // Filename presented as part of puzzle
118	FilesystemPath string // Filename in backing FS (URL, mothball, or local FS)
119}
120
121// UnmarshalYAML allows a StaticAttachment to be specified as a single string.
122// The way the yaml library works is weird.
123func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
124	if err := unmarshal(&sa.Filename); err == nil {
125		sa.FilesystemPath = sa.Filename
126		return nil
127	}
128
129	parts := new(struct {
130		Filename       string
131		FilesystemPath string
132	})
133	if err := unmarshal(parts); err != nil {
134		return err
135	}
136	sa.Filename = parts.Filename
137	sa.FilesystemPath = parts.FilesystemPath
138	return nil
139}
140
141// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
142type ReadSeekCloser interface {
143	io.Reader
144	io.Seeker
145	io.Closer
146}
147
148// PuzzleProvider establishes the functionality required to provide one puzzle.
149type PuzzleProvider interface {
150	// Puzzle returns a Puzzle struct for the current puzzle.
151	Puzzle() (Puzzle, error)
152
153	// Open returns a newly-opened file.
154	Open(filename string) (ReadSeekCloser, error)
155
156	// Answer returns whether the provided answer is correct.
157	Answer(answer string) bool
158}
159
160// NewFsPuzzle returns a new FsPuzzle.
161func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
162	var command string
163
164	bfs := NewRecursiveBasePathFs(fs, "")
165	if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) {
166		if (info.Mode() & 0100) != 0 {
167			if command, err = bfs.RealPath(info.Name()); err != nil {
168				log.Println("WARN: Unable to resolve full path to", info.Name())
169			}
170		} else {
171			log.Println("WARN: mkpuzzle exists, but isn't executable.")
172		}
173	}
174
175	if command != "" {
176		return FsCommandPuzzle{
177			fs:      fs,
178			command: command,
179			timeout: 2 * time.Second,
180		}
181	}
182
183	return FsPuzzle{
184		fs: fs,
185	}
186
187}
188
189// NewFsPuzzlePoints returns a new FsPuzzle for points.
190func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
191	return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
192}
193
194// FsPuzzle is a single puzzle's directory.
195type FsPuzzle struct {
196	fs       afero.Fs
197	mkpuzzle bool
198}
199
200// Puzzle returns a Puzzle struct for the current puzzle.
201func (fp FsPuzzle) Puzzle() (Puzzle, error) {
202	puzzle := Puzzle{}
203
204	static, body, err := fp.staticPuzzle()
205	if err != nil {
206		return puzzle, err
207	}
208
209	// Convert to an exportable Puzzle
210	puzzle.Debug = static.Debug
211	puzzle.Answers = static.Answers
212	puzzle.Authors = static.Authors
213	puzzle.Extra = static.Extra
214	puzzle.Objective = static.Objective
215	puzzle.KSAs = static.KSAs
216	puzzle.Success = static.Success
217	puzzle.Body = string(body)
218	puzzle.AnswerPattern = static.AnswerPattern
219	puzzle.Attachments = make([]string, len(static.Attachments))
220	for i, attachment := range static.Attachments {
221		puzzle.Attachments[i] = attachment.Filename
222	}
223	puzzle.Scripts = make([]string, len(static.Scripts))
224	for i, script := range static.Scripts {
225		puzzle.Scripts[i] = script.Filename
226	}
227	puzzle.computeAnswerHashes()
228
229	return puzzle, nil
230}
231
232// Open returns a newly-opened file.
233func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
234	empty := nopCloser{new(bytes.Reader)}
235	static, _, err := fp.staticPuzzle()
236	if err != nil {
237		return empty, err
238	}
239
240	var fsPath string
241	for _, attachment := range append(static.Attachments, static.Scripts...) {
242		if attachment.Filename == name {
243			if attachment.FilesystemPath == "" {
244				fsPath = attachment.Filename
245			} else {
246				fsPath = attachment.FilesystemPath
247			}
248		}
249	}
250	if fsPath == "" {
251		return empty, fmt.Errorf("not listed in attachments or scripts: %s", name)
252	}
253
254	return fp.fs.Open(fsPath)
255}
256
257func (fp FsPuzzle) staticPuzzle() (StaticPuzzle, []byte, error) {
258	r, err := fp.fs.Open("puzzle.md")
259	if err != nil {
260		var err2 error
261		if r, err2 = fp.fs.Open("puzzle.moth"); err2 != nil {
262			return StaticPuzzle{}, nil, err
263		}
264	}
265	defer r.Close()
266
267	headerBuf := new(bytes.Buffer)
268	headerParser := rfc822HeaderParser
269	headerEnd := ""
270
271	scanner := bufio.NewScanner(r)
272	lineNo := 0
273	for scanner.Scan() {
274		line := scanner.Text()
275		lineNo++
276		if lineNo == 1 {
277			if line == "---" {
278				headerParser = yamlHeaderParser
279				headerEnd = "---"
280				continue
281			}
282		}
283		if line == headerEnd {
284			headerBuf.WriteRune('\n')
285			break
286		}
287		headerBuf.WriteString(line)
288		headerBuf.WriteRune('\n')
289	}
290
291	bodyBuf := new(bytes.Buffer)
292	for scanner.Scan() {
293		line := scanner.Text()
294		lineNo++
295		bodyBuf.WriteString(line)
296		bodyBuf.WriteRune('\n')
297	}
298
299	static, err := headerParser(headerBuf)
300	if err != nil {
301		return static, nil, err
302	}
303
304	html := new(bytes.Buffer)
305	err = Markdown(bodyBuf, html)
306	return static, html.Bytes(), err
307}
308
309func legacyAttachmentParser(val []string) []StaticAttachment {
310	ret := make([]StaticAttachment, len(val))
311	for idx, txt := range val {
312		parts := strings.SplitN(txt, " ", 3)
313		cur := StaticAttachment{}
314		cur.FilesystemPath = parts[0]
315		if len(parts) > 1 {
316			cur.Filename = parts[1]
317		} else {
318			cur.Filename = cur.FilesystemPath
319		}
320		ret[idx] = cur
321	}
322	return ret
323}
324
325func yamlHeaderParser(r io.Reader) (StaticPuzzle, error) {
326	p := StaticPuzzle{}
327	decoder := yaml.NewDecoder(r)
328	decoder.SetStrict(true)
329	err := decoder.Decode(&p)
330	return p, err
331}
332
333func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
334	p := StaticPuzzle{}
335	m, err := mail.ReadMessage(r)
336	if err != nil {
337		return p, fmt.Errorf("parsing RFC822 headers: %v", err)
338	}
339
340	for key, val := range m.Header {
341		key = strings.ToLower(key)
342		switch key {
343		case "author":
344			p.Authors = val
345		case "pattern":
346			p.AnswerPattern = val[0]
347		case "script":
348			p.Scripts = legacyAttachmentParser(val)
349		case "file":
350			p.Attachments = legacyAttachmentParser(val)
351		case "answer":
352			p.Answers = val
353		case "summary":
354			p.Debug.Summary = val[0]
355		case "hint":
356			p.Debug.Hints = val
357		case "solution":
358			p.Debug.Hints = val
359		case "ksa":
360			p.KSAs = val
361		case "objective":
362			p.Objective = val[0]
363		case "success.acceptable":
364			p.Success.Acceptable = val[0]
365		case "success.mastery":
366			p.Success.Mastery = val[0]
367		default:
368			return p, fmt.Errorf("unknown header field: %s", key)
369		}
370	}
371
372	return p, nil
373}
374
375// Answer checks whether the given answer is correct.
376func (fp FsPuzzle) Answer(answer string) bool {
377	p, _, err := fp.staticPuzzle()
378	if err != nil {
379		return false
380	}
381	for _, ans := range p.Answers {
382		if ans == answer {
383			return true
384		}
385	}
386	return false
387}
388
389// FsCommandPuzzle provides an FsPuzzle backed by running a command.
390type FsCommandPuzzle struct {
391	fs      afero.Fs
392	command string
393	timeout time.Duration
394}
395
396func (fp FsCommandPuzzle) run(command string, args ...string) ([]byte, error) {
397	ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
398	defer cancel()
399
400	cmdargs := append([]string{command}, args...)
401	cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), cmdargs...)
402	cmd.Dir = path.Dir(fp.command)
403	out, err := cmd.Output()
404	if err, ok := err.(*exec.ExitError); ok {
405		stderr := strings.TrimSpace(string(err.Stderr))
406		return nil, fmt.Errorf("%s (%s)", stderr, err.String())
407	}
408	return out, err
409}
410
411// Puzzle returns a Puzzle struct for the current puzzle.
412func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) {
413	stdout, err := fp.run("puzzle")
414	if exiterr, ok := err.(*exec.ExitError); ok {
415		return Puzzle{}, errors.New(string(exiterr.Stderr))
416	} else if err != nil {
417		return Puzzle{}, err
418	}
419
420	jsdec := json.NewDecoder(bytes.NewReader(stdout))
421	jsdec.DisallowUnknownFields()
422	puzzle := Puzzle{}
423	if err := jsdec.Decode(&puzzle); err != nil {
424		return Puzzle{}, err
425	}
426
427	puzzle.computeAnswerHashes()
428
429	return puzzle, nil
430}
431
432type nopCloser struct {
433	io.ReadSeeker
434}
435
436func (c nopCloser) Close() error {
437	return nil
438}
439
440// Open returns a newly-opened file.
441// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files.
442func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
443	stdout, err := fp.run("file", filename)
444	buf := nopCloser{bytes.NewReader(stdout)}
445	if err != nil {
446		return buf, err
447	}
448
449	return buf, nil
450}
451
452// Answer checks whether the given answer is correct.
453func (fp FsCommandPuzzle) Answer(answer string) bool {
454	stdout, err := fp.run("answer", answer)
455	if err != nil {
456		log.Printf("ERROR: checking answer: %s", err)
457		return false
458	}
459
460	ans := AnswerResponse{}
461	if err := json.Unmarshal(stdout, &ans); err != nil {
462		log.Printf("ERROR: checking answer: %s", err)
463		return false
464	}
465
466	return ans.Correct
467}