2016-10-16 20:32:00 -06:00
|
|
|
|
#!/usr/bin/python3
|
|
|
|
|
|
|
|
|
|
import argparse
|
2016-10-22 10:35:55 -06:00
|
|
|
|
import contextlib
|
2016-10-16 20:32:00 -06:00
|
|
|
|
import glob
|
2016-10-17 19:58:51 -06:00
|
|
|
|
import hashlib
|
2016-10-22 10:35:55 -06:00
|
|
|
|
import io
|
|
|
|
|
import importlib.machinery
|
2016-10-16 20:32:00 -06:00
|
|
|
|
import mistune
|
2016-10-17 13:24:54 -06:00
|
|
|
|
import os
|
2016-10-16 20:32:00 -06:00
|
|
|
|
import random
|
2016-10-23 14:55:35 -06:00
|
|
|
|
import string
|
2016-10-17 19:58:51 -06:00
|
|
|
|
import tempfile
|
2016-10-16 20:32:00 -06:00
|
|
|
|
|
|
|
|
|
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
|
|
|
|
|
|
|
|
def djb2hash(buf):
|
|
|
|
|
h = 5381
|
|
|
|
|
for c in buf:
|
|
|
|
|
h = ((h * 33) + c) & 0xffffffff
|
|
|
|
|
return h
|
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def pushd(newdir):
|
|
|
|
|
curdir = os.getcwd()
|
|
|
|
|
os.chdir(newdir)
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
os.chdir(curdir)
|
|
|
|
|
|
|
|
|
|
# Get a big list of clean words for our answer file.
|
|
|
|
|
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
|
|
|
|
|
'answer_words.txt'))]
|
|
|
|
|
|
|
|
|
|
class PuzzleFile:
|
|
|
|
|
"""A file associated with a puzzle.
|
|
|
|
|
|
2016-10-18 13:11:53 -06:00
|
|
|
|
path: The path to the original input file. May be None (when this is created from a file handle
|
|
|
|
|
and there is no original input.
|
|
|
|
|
handle: A File-like object set to read the file from. You should be able to read straight
|
|
|
|
|
from it without having to seek to the beginning of the file.
|
|
|
|
|
name: The name of the output file.
|
|
|
|
|
visible: A boolean indicating whether this file should visible to the user. If False,
|
|
|
|
|
the file is still expected to be accessible, but it's path must be known
|
|
|
|
|
(or figured out) to retrieve it."""
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
def __init__(self, stream, name, visible=True):
|
|
|
|
|
self.stream = stream
|
|
|
|
|
self.name = name
|
|
|
|
|
self.visible = visible
|
2016-10-17 21:26:56 -06:00
|
|
|
|
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
class Puzzle:
|
|
|
|
|
def __init__(self, category_seed, points):
|
2016-10-18 13:29:41 -06:00
|
|
|
|
"""A MOTH Puzzle.
|
2016-10-22 10:35:55 -06:00
|
|
|
|
|
2016-10-18 11:24:46 -06:00
|
|
|
|
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
|
|
|
|
It is combined with the puzzle points.
|
2016-10-22 10:35:55 -06:00
|
|
|
|
:param points: The point value of the puzzle.
|
2016-10-17 21:26:56 -06:00
|
|
|
|
"""
|
|
|
|
|
|
2016-10-17 13:24:54 -06:00
|
|
|
|
super().__init__()
|
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
self.points = points
|
|
|
|
|
self.summary = None
|
2017-01-30 12:13:02 -07:00
|
|
|
|
self.authors = []
|
2016-10-22 10:35:55 -06:00
|
|
|
|
self.answers = []
|
|
|
|
|
self.files = {}
|
|
|
|
|
self.body = io.StringIO()
|
|
|
|
|
self.logs = []
|
|
|
|
|
self.randseed = category_seed * self.points
|
|
|
|
|
self.rand = random.Random(self.randseed)
|
2016-10-17 21:26:56 -06:00
|
|
|
|
|
2017-01-31 17:03:42 -07:00
|
|
|
|
def log(self, *vals):
|
2016-10-19 15:02:38 -06:00
|
|
|
|
"""Add a new log message to this puzzle."""
|
2017-01-31 17:03:42 -07:00
|
|
|
|
msg = ' '.join(str(v) for v in vals)
|
|
|
|
|
self.logs.append(msg)
|
2016-10-19 15:02:38 -06:00
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
def read_stream(self, stream):
|
2016-10-16 20:32:00 -06:00
|
|
|
|
header = True
|
|
|
|
|
for line in stream:
|
|
|
|
|
if header:
|
|
|
|
|
line = line.strip()
|
2016-10-22 10:35:55 -06:00
|
|
|
|
if not line:
|
2016-10-16 20:32:00 -06:00
|
|
|
|
header = False
|
|
|
|
|
continue
|
|
|
|
|
key, val = line.split(':', 1)
|
2016-10-22 10:35:55 -06:00
|
|
|
|
key = key.lower()
|
2017-01-05 16:50:41 -07:00
|
|
|
|
val = val.strip()
|
2016-10-22 10:35:55 -06:00
|
|
|
|
if key == 'author':
|
2017-01-30 12:13:02 -07:00
|
|
|
|
self.authors.append(val)
|
2016-10-22 10:35:55 -06:00
|
|
|
|
elif key == 'summary':
|
|
|
|
|
self.summary = val
|
|
|
|
|
elif key == 'answer':
|
|
|
|
|
self.answers.append(val)
|
|
|
|
|
elif key == 'file':
|
|
|
|
|
parts = val.split()
|
|
|
|
|
name = parts[0]
|
|
|
|
|
hidden = False
|
|
|
|
|
stream = open(name, 'rb')
|
|
|
|
|
try:
|
|
|
|
|
name = parts[1]
|
|
|
|
|
hidden = parts[2]
|
|
|
|
|
except IndexError:
|
|
|
|
|
pass
|
|
|
|
|
self.files[name] = PuzzleFile(stream, name, not hidden)
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError("Unrecognized header field: {}".format(key))
|
2016-10-16 20:32:00 -06:00
|
|
|
|
else:
|
2016-10-22 10:35:55 -06:00
|
|
|
|
self.body.write(line)
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
def read_directory(self, path):
|
|
|
|
|
try:
|
|
|
|
|
fn = os.path.join(path, "puzzle.py")
|
|
|
|
|
loader = importlib.machinery.SourceFileLoader('puzzle_mod', fn)
|
|
|
|
|
puzzle_mod = loader.load_module()
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
puzzle_mod = None
|
|
|
|
|
|
2016-12-01 16:20:04 -07:00
|
|
|
|
with pushd(path):
|
|
|
|
|
if puzzle_mod:
|
2016-10-22 10:35:55 -06:00
|
|
|
|
puzzle_mod.make(self)
|
2016-12-01 16:20:04 -07:00
|
|
|
|
else:
|
|
|
|
|
with open('puzzle.moth') as f:
|
|
|
|
|
self.read_stream(f)
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
def random_hash(self):
|
|
|
|
|
"""Create a file basename (no extension) with our number generator."""
|
2016-10-23 14:55:35 -06:00
|
|
|
|
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-10-18 20:11:20 -06:00
|
|
|
|
def make_temp_file(self, name=None, visible=True):
|
2016-10-17 21:26:56 -06:00
|
|
|
|
"""Get a file object for adding dynamically generated data to the puzzle. When you're
|
|
|
|
|
done with this file, flush it, but don't close it.
|
2016-10-22 10:35:55 -06:00
|
|
|
|
|
2016-10-17 21:26:56 -06:00
|
|
|
|
:param name: The name of the file for links within the puzzle. If this is None, a name
|
|
|
|
|
will be generated for you.
|
|
|
|
|
:param visible: Whether or not the file will be visible to the user.
|
2016-10-17 19:58:51 -06:00
|
|
|
|
:return: A file object for writing
|
|
|
|
|
"""
|
|
|
|
|
|
2016-10-23 14:55:35 -06:00
|
|
|
|
stream = tempfile.TemporaryFile()
|
|
|
|
|
self.add_stream(stream, name, visible)
|
|
|
|
|
return stream
|
|
|
|
|
|
|
|
|
|
def add_stream(self, stream, name=None, visible=True):
|
2016-10-17 21:26:56 -06:00
|
|
|
|
if name is None:
|
|
|
|
|
name = self.random_hash()
|
2016-10-22 10:35:55 -06:00
|
|
|
|
self.files[name] = PuzzleFile(stream, name, visible)
|
2016-10-17 19:58:51 -06:00
|
|
|
|
|
2016-12-01 16:20:04 -07:00
|
|
|
|
def add_file(self, filename, visible=True):
|
|
|
|
|
fd = open(filename, 'rb')
|
|
|
|
|
name = os.path.basename(filename)
|
|
|
|
|
self.add_stream(fd, name=name, visible=visible)
|
|
|
|
|
|
2016-11-28 15:17:12 -07:00
|
|
|
|
def randword(self):
|
|
|
|
|
"""Return a randomly-chosen word"""
|
|
|
|
|
|
|
|
|
|
return self.rand.choice(ANSWER_WORDS)
|
|
|
|
|
|
2016-12-01 16:20:04 -07:00
|
|
|
|
def make_answer(self, word_count=4, sep=' '):
|
2016-10-17 19:58:51 -06:00
|
|
|
|
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
|
|
|
|
:param int word_count: The number of words to include in the answer.
|
|
|
|
|
:param str|bytes sep: The word separator.
|
2016-10-18 09:51:33 -06:00
|
|
|
|
:returns: The answer string
|
2016-10-17 19:58:51 -06:00
|
|
|
|
"""
|
|
|
|
|
|
2016-11-28 15:17:12 -07:00
|
|
|
|
words = [self.randword() for i in range(word_count)]
|
|
|
|
|
answer = sep.join(words)
|
2016-10-22 10:35:55 -06:00
|
|
|
|
self.answers.append(answer)
|
2016-10-17 19:58:51 -06:00
|
|
|
|
return answer
|
|
|
|
|
|
2017-01-20 17:07:36 -07:00
|
|
|
|
hexdump_stdch = stdch = (
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
' !"#$%&\'()*+,-./'
|
|
|
|
|
'0123456789:;<=>?'
|
|
|
|
|
'@ABCDEFGHIJKLMNO'
|
|
|
|
|
'PQRSTUVWXYZ[\]^_'
|
|
|
|
|
'`abcdefghijklmno'
|
|
|
|
|
'pqrstuvwxyz{|}~·'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
'················'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '⌷')):
|
|
|
|
|
hexes, chars = [], []
|
|
|
|
|
out = []
|
|
|
|
|
|
|
|
|
|
for b in buf:
|
|
|
|
|
if len(chars) == 16:
|
|
|
|
|
out.append((hexes, chars))
|
|
|
|
|
hexes, chars = [], []
|
|
|
|
|
|
|
|
|
|
if b is None:
|
|
|
|
|
h, c = gap
|
|
|
|
|
else:
|
|
|
|
|
h = '{:02x}'.format(b)
|
|
|
|
|
c = charset[b]
|
|
|
|
|
chars.append(c)
|
|
|
|
|
hexes.append(h)
|
|
|
|
|
|
|
|
|
|
out.append((hexes, chars))
|
|
|
|
|
|
|
|
|
|
offset = 0
|
|
|
|
|
elided = False
|
|
|
|
|
lastchars = None
|
|
|
|
|
self.body.write('<pre>')
|
|
|
|
|
for hexes, chars in out:
|
|
|
|
|
if chars == lastchars:
|
|
|
|
|
if not elided:
|
|
|
|
|
self.body.write('*\n')
|
|
|
|
|
elided = True
|
|
|
|
|
continue
|
|
|
|
|
lastchars = chars[:]
|
|
|
|
|
elided = False
|
|
|
|
|
|
|
|
|
|
pad = 16 - len(chars)
|
|
|
|
|
hexes += [' '] * pad
|
|
|
|
|
|
|
|
|
|
self.body.write('{:08x} '.format(offset))
|
|
|
|
|
self.body.write(' '.join(hexes[:8]))
|
|
|
|
|
self.body.write(' ')
|
|
|
|
|
self.body.write(' '.join(hexes[8:]))
|
|
|
|
|
self.body.write(' |')
|
|
|
|
|
self.body.write(''.join(chars))
|
|
|
|
|
self.body.write('|\n')
|
|
|
|
|
offset += len(chars)
|
|
|
|
|
self.body.write('{:08x}\n'.format(offset))
|
|
|
|
|
self.body.write('</pre>')
|
|
|
|
|
|
2017-01-30 12:13:02 -07:00
|
|
|
|
def get_authors(self):
|
|
|
|
|
return self.authors or [self.author]
|
|
|
|
|
|
2016-10-22 10:35:55 -06:00
|
|
|
|
def get_body(self):
|
|
|
|
|
return self.body.getvalue()
|
|
|
|
|
|
|
|
|
|
def html_body(self):
|
2016-10-17 21:26:56 -06:00
|
|
|
|
"""Format and return the markdown for the puzzle body."""
|
2016-11-28 15:17:12 -07:00
|
|
|
|
return mistune.markdown(self.get_body(), escape=False)
|
2016-10-22 10:35:55 -06:00
|
|
|
|
|
|
|
|
|
def hashes(self):
|
|
|
|
|
"Return a list of answer hashes"
|
|
|
|
|
|
2017-01-05 16:50:41 -07:00
|
|
|
|
return [djb2hash(a.encode('utf-8')) for a in self.answers]
|
2016-10-16 20:32:00 -06:00
|
|
|
|
|
2016-10-17 23:02:05 -06:00
|
|
|
|
|
|
|
|
|
class Category:
|
|
|
|
|
def __init__(self, path, seed):
|
|
|
|
|
self.path = path
|
|
|
|
|
self.seed = seed
|
|
|
|
|
self.pointvals = []
|
2016-10-20 17:07:34 -06:00
|
|
|
|
self.catmod = None
|
|
|
|
|
|
2016-10-24 21:09:04 -06:00
|
|
|
|
if os.path.exists(os.path.join(path, 'category.py')):
|
2017-01-31 17:03:42 -07:00
|
|
|
|
with pushd(path):
|
|
|
|
|
loader = importlib.machinery.SourceFileLoader('catmod', 'category.py')
|
|
|
|
|
self.catmod = loader.load_module()
|
|
|
|
|
self.pointvals.extend(self.catmod.points)
|
2016-10-24 21:09:04 -06:00
|
|
|
|
else:
|
|
|
|
|
for fpath in glob.glob(os.path.join(path, "[0-9]*")):
|
|
|
|
|
pn = os.path.basename(fpath)
|
|
|
|
|
points = int(pn)
|
|
|
|
|
self.pointvals.append(points)
|
2016-10-20 17:07:34 -06:00
|
|
|
|
|
2016-10-17 23:02:05 -06:00
|
|
|
|
self.pointvals.sort()
|
|
|
|
|
|
|
|
|
|
def puzzle(self, points):
|
2016-10-22 10:35:55 -06:00
|
|
|
|
puzzle = Puzzle(self.seed, points)
|
|
|
|
|
path = os.path.join(self.path, str(points))
|
|
|
|
|
if self.catmod:
|
2016-10-24 21:49:32 -06:00
|
|
|
|
with pushd(self.path):
|
|
|
|
|
self.catmod.make(points, puzzle)
|
2016-10-24 21:09:04 -06:00
|
|
|
|
else:
|
|
|
|
|
puzzle.read_directory(path)
|
2016-10-22 10:35:55 -06:00
|
|
|
|
return puzzle
|
2016-10-17 23:02:05 -06:00
|
|
|
|
|
2017-01-05 16:50:41 -07:00
|
|
|
|
def __iter__(self):
|
2016-10-17 23:02:05 -06:00
|
|
|
|
for points in self.pointvals:
|
|
|
|
|
yield self.puzzle(points)
|