moth

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

moth / pkg / transpile
Neale Pickett  ·  2021-03-26

category.go

  1package transpile
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log"
  9	"os/exec"
 10	"path"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"github.com/spf13/afero"
 16)
 17
 18// InventoryResponse is what's handed back when we ask for an inventory.
 19type InventoryResponse struct {
 20	Puzzles []int
 21}
 22
 23// Category defines the functionality required to be a puzzle category.
 24type Category interface {
 25	// Inventory lists every puzzle in the category.
 26	Inventory() ([]int, error)
 27
 28	// Puzzle provides a Puzzle structure for the given point value.
 29	Puzzle(points int) (Puzzle, error)
 30
 31	// Open returns an io.ReadCloser for the given filename.
 32	Open(points int, filename string) (ReadSeekCloser, error)
 33
 34	// Answer returns whether the given answer is correct.
 35	Answer(points int, answer string) bool
 36}
 37
 38// NopReadCloser provides an io.ReadCloser which does nothing.
 39type NopReadCloser struct {
 40}
 41
 42// Read satisfies io.Reader.
 43func (n NopReadCloser) Read(b []byte) (int, error) {
 44	return 0, nil
 45}
 46
 47// Close satisfies io.Closer.
 48func (n NopReadCloser) Close() error {
 49	return nil
 50}
 51
 52// NewFsCategory returns a Category based on which files are present.
 53// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
 54// Otherwise, FsCategory is returned.
 55func NewFsCategory(fs afero.Fs, cat string) Category {
 56	bfs := NewRecursiveBasePathFs(fs, cat)
 57	if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
 58		if command, err := bfs.RealPath(info.Name()); err != nil {
 59			log.Println("Unable to resolve full path to", info.Name())
 60		} else {
 61			return FsCommandCategory{
 62				fs:      bfs,
 63				command: command,
 64				timeout: 2 * time.Second,
 65			}
 66		}
 67	}
 68	return FsCategory{fs: bfs}
 69}
 70
 71// FsCategory provides a category backed by a .md file.
 72type FsCategory struct {
 73	fs afero.Fs
 74}
 75
 76// Inventory returns a list of point values for this category.
 77func (c FsCategory) Inventory() ([]int, error) {
 78	puzzleEntries, err := afero.ReadDir(c.fs, ".")
 79	if err != nil {
 80		return nil, err
 81	}
 82
 83	puzzles := make([]int, 0, len(puzzleEntries))
 84	for _, ent := range puzzleEntries {
 85		if !ent.IsDir() {
 86			continue
 87		}
 88		if points, err := strconv.Atoi(ent.Name()); err != nil {
 89			log.Println("Skipping non-numeric directory", ent.Name())
 90			continue
 91		} else {
 92			puzzles = append(puzzles, points)
 93		}
 94	}
 95	return puzzles, nil
 96}
 97
 98// Puzzle returns a Puzzle structure for the given point value.
 99func (c FsCategory) Puzzle(points int) (Puzzle, error) {
100	return NewFsPuzzlePoints(c.fs, points).Puzzle()
101}
102
103// Open returns an io.ReadCloser for the given filename.
104func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
105	return NewFsPuzzlePoints(c.fs, points).Open(filename)
106}
107
108// Answer checks whether an answer is correct.
109func (c FsCategory) Answer(points int, answer string) bool {
110	// BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
111	p, err := c.Puzzle(points)
112	if err != nil {
113		return false
114	}
115	for _, a := range p.Answers {
116		if a == answer {
117			return true
118		}
119	}
120	return false
121}
122
123// FsCommandCategory provides a category backed by running an external command.
124type FsCommandCategory struct {
125	fs      afero.Fs
126	command string
127	timeout time.Duration
128}
129
130func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) {
131	ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
132	defer cancel()
133
134	cmdargs := append([]string{command}, args...)
135	cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...)
136	cmd.Dir = path.Dir(c.command)
137	out, err := cmd.Output()
138	if err, ok := err.(*exec.ExitError); ok {
139		stderr := strings.TrimSpace(string(err.Stderr))
140		return nil, fmt.Errorf("%s (%s)", stderr, err.String())
141	}
142	return out, err
143}
144
145// Inventory returns a list of point values for this category.
146func (c FsCommandCategory) Inventory() ([]int, error) {
147	stdout, err := c.run("inventory")
148	if exerr, ok := err.(*exec.ExitError); ok {
149		return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr))
150	} else if err != nil {
151		return nil, err
152	}
153
154	inv := InventoryResponse{}
155	if err := json.Unmarshal(stdout, &inv); err != nil {
156		return nil, err
157	}
158
159	return inv.Puzzles, nil
160}
161
162// Puzzle returns a Puzzle structure for the given point value.
163func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
164	var p Puzzle
165
166	stdout, err := c.run("puzzle", strconv.Itoa(points))
167	if err != nil {
168		return p, err
169	}
170
171	if err := json.Unmarshal(stdout, &p); err != nil {
172		return p, err
173	}
174
175	p.computeAnswerHashes()
176
177	return p, nil
178}
179
180// Open returns an io.ReadCloser for the given filename.
181func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
182	stdout, err := c.run("file", strconv.Itoa(points), filename)
183	return nopCloser{bytes.NewReader(stdout)}, err
184}
185
186// Answer checks whether an answer is correct.
187func (c FsCommandCategory) Answer(points int, answer string) bool {
188	stdout, err := c.run("answer", strconv.Itoa(points), answer)
189	if err != nil {
190		log.Printf("ERROR: Answering %d points: %s", points, err)
191		return false
192	}
193
194	ans := AnswerResponse{}
195	if err := json.Unmarshal(stdout, &ans); err != nil {
196		log.Printf("ERROR: Answering %d points: %s", points, err)
197		return false
198	}
199
200	return ans.Correct
201}