mirror of https://github.com/dirtbags/moth.git
Compare commits
No commits in common. "8ff91e79ec0ca394d3ee6ea4769c0fd53f8f9000" and "fcfa11b01237aebbe741cf025981f5ee2def8a77" have entirely different histories.
8ff91e79ec
...
fcfa11b012
|
@ -37,45 +37,23 @@ type PuzzleDebug struct {
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puzzle contains everything about a puzzle that a client will see.
|
// Puzzle contains everything about a puzzle that a client would see.
|
||||||
type Puzzle struct {
|
type Puzzle struct {
|
||||||
// Debug contains debugging information, omitted in mothballs
|
|
||||||
Debug PuzzleDebug
|
Debug PuzzleDebug
|
||||||
|
|
||||||
// Authors names all authors of this puzzle
|
|
||||||
Authors []string
|
Authors []string
|
||||||
|
|
||||||
// Attachments is a list of filenames used by this puzzle
|
|
||||||
Attachments []string
|
Attachments []string
|
||||||
|
|
||||||
// Scripts is a list of EMCAScript files needed by the client for this puzzle
|
|
||||||
Scripts []string
|
Scripts []string
|
||||||
|
|
||||||
// Body is the HTML rendering of this puzzle
|
|
||||||
Body string
|
Body string
|
||||||
|
|
||||||
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
|
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
|
|
||||||
// AnswerHashes contains hashes of all answers for this puzzle
|
|
||||||
AnswerHashes []string
|
AnswerHashes []string
|
||||||
|
|
||||||
// Objective is the learning objective for this puzzle
|
|
||||||
Objective string
|
Objective string
|
||||||
|
|
||||||
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
|
|
||||||
KSAs []string
|
KSAs []string
|
||||||
|
|
||||||
// Success lists the criteria for successfully understanding this puzzle
|
|
||||||
Success struct {
|
Success struct {
|
||||||
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
|
|
||||||
Acceptable string
|
Acceptable string
|
||||||
|
|
||||||
// Mastery describes the work required to be considered mastering this puzzle's conceptss
|
|
||||||
Mastery string
|
Mastery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answers lists all acceptable answers, omitted in mothballs
|
// Answers will be empty in a mothball
|
||||||
Answers []string
|
Answers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
286
theme/moth.mjs
286
theme/moth.mjs
|
@ -1,231 +1,18 @@
|
||||||
/**
|
|
||||||
* A point award.
|
|
||||||
*/
|
|
||||||
class Award {
|
|
||||||
constructor(when, teamid, category, points) {
|
|
||||||
/** Unix epoch timestamp for this award
|
|
||||||
* @type {Number}
|
|
||||||
*/
|
|
||||||
this.When = when
|
|
||||||
/** Team ID this award belongs to
|
|
||||||
* @type {String}
|
|
||||||
*/
|
|
||||||
this.TeamID = teamid
|
|
||||||
/** Puzzle category for this award
|
|
||||||
* @type {String}
|
|
||||||
*/
|
|
||||||
this.Category = category
|
|
||||||
/** Points value of this award
|
|
||||||
* @type {Number}
|
|
||||||
*/
|
|
||||||
this.Points = points
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A puzzle.
|
|
||||||
*
|
|
||||||
* A new Puzzle only knows its category and point value.
|
|
||||||
* If you want to populate it with meta-information, you must call Populate().
|
|
||||||
*
|
|
||||||
* Parameters created by Populate are described in the server source code:
|
|
||||||
* {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
class Puzzle {
|
|
||||||
/**
|
|
||||||
* @param {Server} server
|
|
||||||
* @param {String} category
|
|
||||||
* @param {Number} points
|
|
||||||
*/
|
|
||||||
constructor (server, category, points) {
|
|
||||||
if (points < 1) {
|
|
||||||
throw(`Invalid points value: ${points}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server where this puzzle lives
|
|
||||||
* @type {Server}
|
|
||||||
*/
|
|
||||||
this.server = server
|
|
||||||
|
|
||||||
/** Category this puzzle belongs to */
|
|
||||||
this.Category = String(category)
|
|
||||||
|
|
||||||
/** Point value of this puzzle */
|
|
||||||
this.Points = Number(points)
|
|
||||||
|
|
||||||
/** Error returned trying to fetch this puzzle */
|
|
||||||
this.Error = {
|
|
||||||
/** Status code provided by server */
|
|
||||||
Status: 0,
|
|
||||||
/** Status text provided by server */
|
|
||||||
StatusText: "",
|
|
||||||
/** Full text of server error */
|
|
||||||
Body: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate this Puzzle object with meta-information from the server.
|
|
||||||
*/
|
|
||||||
async Populate() {
|
|
||||||
let resp = await this.Get("puzzle.json")
|
|
||||||
if (!resp.ok) {
|
|
||||||
let body = await resp.text()
|
|
||||||
this.Error = {
|
|
||||||
Status: resp.status,
|
|
||||||
StatusText: resp.statusText,
|
|
||||||
Body: body,
|
|
||||||
}
|
|
||||||
throw(this.Error)
|
|
||||||
}
|
|
||||||
let obj = await resp.json()
|
|
||||||
Object.assign(this, obj)
|
|
||||||
|
|
||||||
// Make sure lists are lists
|
|
||||||
this.AnswerHashes ||= []
|
|
||||||
this.Answers ||= []
|
|
||||||
this.Attachments ||= []
|
|
||||||
this.Authors ||= []
|
|
||||||
this.Debug.Errors ||= []
|
|
||||||
this.Debug.Hints ||= []
|
|
||||||
this.Debug.Log ||= []
|
|
||||||
this.KSAs ||= []
|
|
||||||
this.Scripts ||= []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a resource associated with this puzzle.
|
|
||||||
*
|
|
||||||
* @param {String} filename Attachment/Script to retrieve
|
|
||||||
* @returns {Promise.<Response>}
|
|
||||||
*/
|
|
||||||
Get(filename) {
|
|
||||||
return this.server.GetContent(this.Category, this.Points, filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MOTH instance state.
|
|
||||||
*/
|
|
||||||
class State {
|
|
||||||
/**
|
|
||||||
* @param {Server} server Server where we got this
|
|
||||||
* @param {Object} obj Raw state data
|
|
||||||
*/
|
|
||||||
constructor(server, obj) {
|
|
||||||
for (let key of ["Config", "Messages", "TeamNames", "PointsLog"]) {
|
|
||||||
if (!obj[key]) {
|
|
||||||
throw(`Missing state property: ${key}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.server = server
|
|
||||||
|
|
||||||
/** Configuration */
|
|
||||||
this.Config = {
|
|
||||||
/** Is the server in debug mode?
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
Debug: obj.Config.Debug,
|
|
||||||
}
|
|
||||||
/** Global messages, in HTML
|
|
||||||
* @type {String}
|
|
||||||
*/
|
|
||||||
this.Messages = obj.Messages
|
|
||||||
/** Map from Team ID to Team Name
|
|
||||||
* @type {Object.<String,String>}
|
|
||||||
*/
|
|
||||||
this.TeamNames = obj.TeamNames
|
|
||||||
/** Map from category name to puzzle point values
|
|
||||||
* @type {Object.<String,Number>}
|
|
||||||
*/
|
|
||||||
this.PointsByCategory = obj.Puzzles
|
|
||||||
/** Log of points awarded
|
|
||||||
* @type {Award[]}
|
|
||||||
*/
|
|
||||||
this.PointsLog = obj.PointsLog.map((t,i,c,p) => new Award(t,i,c,p))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a sorted list of open category names
|
|
||||||
*
|
|
||||||
* @returns {String[]} List of categories
|
|
||||||
*/
|
|
||||||
Categories() {
|
|
||||||
let ret = []
|
|
||||||
for (let category in this.PointsByCategory) {
|
|
||||||
ret.push(category)
|
|
||||||
}
|
|
||||||
ret.sort()
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a category has unsolved puzzles.
|
|
||||||
*
|
|
||||||
* The server adds a puzzle with 0 points in every "solved" category,
|
|
||||||
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
|
||||||
*
|
|
||||||
* @param {String} category
|
|
||||||
* @returns {Boolean}
|
|
||||||
*/
|
|
||||||
HasUnsolved(category) {
|
|
||||||
return !this.PointsByCategory[category].includes(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return all open puzzles.
|
|
||||||
*
|
|
||||||
* The returned list will be sorted by (category, points).
|
|
||||||
* If not categories are given, all puzzles will be returned.
|
|
||||||
*
|
|
||||||
* @param {String} categories Limit results to these categories
|
|
||||||
* @returns {Puzzle[]}
|
|
||||||
*/
|
|
||||||
Puzzles(...categories) {
|
|
||||||
if (categories.length == 0) {
|
|
||||||
categories = this.Categories()
|
|
||||||
}
|
|
||||||
let ret = []
|
|
||||||
for (let category of categories) {
|
|
||||||
for (let points of this.PointsByCategory[category]) {
|
|
||||||
if (0 == points) {
|
|
||||||
// This means all potential puzzles in the category are open
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let p = new Puzzle(this.server, category, points)
|
|
||||||
ret.push(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A MOTH Server interface.
|
|
||||||
*
|
|
||||||
* This uses localStorage to remember Team ID,
|
|
||||||
* and will send a Team ID with every request, if it can find one.
|
|
||||||
*/
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(baseUrl) {
|
constructor(baseUrl) {
|
||||||
this.baseUrl = new URL(baseUrl, location)
|
this.baseUrl = new URL(baseUrl)
|
||||||
this.teameIdKey = this.baseUrl.toString() + " teamID"
|
this.teamId = null
|
||||||
this.teamId = localStorage[this.teameIdKey]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a MOTH resource.
|
* Fetch a MOTH resource.
|
||||||
*
|
*
|
||||||
* If anything other than a 2xx code is returned,
|
* This is just a convenience wrapper to always send teamId.
|
||||||
* this function throws an error.
|
|
||||||
*
|
|
||||||
* This always sends teamId.
|
|
||||||
* If body is set, POST will be used instead of GET
|
* If body is set, POST will be used instead of GET
|
||||||
*
|
*
|
||||||
* @param {String} path Path to API endpoint
|
* @param {String} path Path to API endpoint
|
||||||
* @param {Object.<String,String>} body Key/Values to send in POST data
|
* @param {Object} body Key/Values to send in POST data
|
||||||
* @returns {Promise.<Response>} Response
|
* @returns {Promise} Response
|
||||||
*/
|
*/
|
||||||
fetch(path, body) {
|
fetch(path, body) {
|
||||||
let url = new URL(path, this.baseUrl)
|
let url = new URL(path, this.baseUrl)
|
||||||
|
@ -242,11 +29,14 @@ class Server {
|
||||||
* Send a request to a JSend API endpoint.
|
* Send a request to a JSend API endpoint.
|
||||||
*
|
*
|
||||||
* @param {String} path Path to API endpoint
|
* @param {String} path Path to API endpoint
|
||||||
* @param {Object.<String,String>} args Key/Values to send in POST
|
* @param {Object} args Key/Values to send in POST
|
||||||
* @returns {Promise.<Object>} JSend Data
|
* @returns JSend Data
|
||||||
*/
|
*/
|
||||||
async call(path, args) {
|
async postJSend(path, args) {
|
||||||
let resp = await this.fetch(path, args)
|
let resp = await this.fetch(path, args)
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(resp.statusText)
|
||||||
|
}
|
||||||
let obj = await resp.json()
|
let obj = await resp.json()
|
||||||
switch (obj.status) {
|
switch (obj.status) {
|
||||||
case "success":
|
case "success":
|
||||||
|
@ -260,27 +50,6 @@ class Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Forget about any previous Team ID.
|
|
||||||
*
|
|
||||||
* This is equivalent to logging out.
|
|
||||||
*/
|
|
||||||
Reset() {
|
|
||||||
localStorage.removeItem(this.teameIdKey)
|
|
||||||
this.teamId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch current contest state.
|
|
||||||
*
|
|
||||||
* @returns {State}
|
|
||||||
*/
|
|
||||||
async GetState() {
|
|
||||||
let resp = await this.fetch("/state")
|
|
||||||
let obj = await resp.json()
|
|
||||||
return new State(this, obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a team name with a team ID.
|
* Register a team name with a team ID.
|
||||||
*
|
*
|
||||||
|
@ -289,42 +58,23 @@ class Server {
|
||||||
*
|
*
|
||||||
* @param {String} teamId
|
* @param {String} teamId
|
||||||
* @param {String} teamName
|
* @param {String} teamName
|
||||||
* @returns {Promise.<String>} Success message from server
|
* @returns {String} Success message from server
|
||||||
*/
|
*/
|
||||||
async Register(teamId, teamName) {
|
async Register(teamId, teamName) {
|
||||||
let data = await this.call("/login", {id: teamId, name: teamName})
|
let data = await postJSend("/login", {id: teamId, name: teamName})
|
||||||
this.teamId = teamId
|
this.teamId = teamId
|
||||||
this.teamName = teamName
|
this.teamName = teamName
|
||||||
localStorage[this.teameIdKey] = teamId
|
|
||||||
return data.description || data.short
|
return data.description || data.short
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a puzzle answer for points.
|
* Fetch current contest status.
|
||||||
*
|
*
|
||||||
* The returned promise will fail if anything goes wrong, including the
|
* @returns {Object} Contest status
|
||||||
* answer being rejected.
|
|
||||||
*
|
|
||||||
* @param {String} category Category of puzzle
|
|
||||||
* @param {Number} points Point value of puzzle
|
|
||||||
* @param {String} answer Answer to submit
|
|
||||||
* @returns {Promise.<Boolean>} Was the answer accepted?
|
|
||||||
*/
|
*/
|
||||||
async SubmitAnswer(category, points, answer) {
|
async Status() {
|
||||||
await this.call("/answer", {category, points, answer})
|
let data = await this.postJSend("/status")
|
||||||
return true
|
return data
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a file associated with a puzzle.
|
|
||||||
*
|
|
||||||
* @param {String} category Category of puzzle
|
|
||||||
* @param {Number} points Point value of puzzle
|
|
||||||
* @param {String} filename
|
|
||||||
* @returns {Promise.<Response>}
|
|
||||||
*/
|
|
||||||
GetContent(category, points, filename) {
|
|
||||||
return this.fetch(`/content/${category}/${points}/${filename}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>KSA Report</title>
|
|
||||||
<script src="ksa.mjs" type="module"></script>
|
|
||||||
<link rel="stylesheet" href="../basic.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>KSA Report</h1>
|
|
||||||
<p>
|
|
||||||
This report shows all KSAs covered by this server so far.
|
|
||||||
This is not a report on your progress, but rather
|
|
||||||
what you would have covered if you had worked every exercise available.
|
|
||||||
</p>
|
|
||||||
<table class="errors">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<table class="puzzles">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Points</th>
|
|
||||||
<th>KSAs</th>
|
|
||||||
<th>Errors</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template id="puzzlerow">
|
|
||||||
<tr>
|
|
||||||
<td class="category"></td>
|
|
||||||
<td class="points"></td>
|
|
||||||
<td class="ksas"></td>
|
|
||||||
<td><pre class="error"></pre></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,45 +0,0 @@
|
||||||
import * as moth from "../moth.mjs"
|
|
||||||
|
|
||||||
function doing(what) {
|
|
||||||
for (let e of document.querySelectorAll(".doing")) {
|
|
||||||
if (what) {
|
|
||||||
e.style.display = "inherit"
|
|
||||||
} else {
|
|
||||||
e.style.display = "none"
|
|
||||||
}
|
|
||||||
for (let p of e.querySelectorAll("p")) {
|
|
||||||
p.textContent = what
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
let server = new moth.Server("../")
|
|
||||||
|
|
||||||
doing("Retrieving server state")
|
|
||||||
let state = await server.GetState()
|
|
||||||
|
|
||||||
doing("Retrieving all puzzles")
|
|
||||||
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
|
||||||
let puzzles = state.Puzzles()
|
|
||||||
for (let puzzle of puzzles) {
|
|
||||||
await puzzle.Populate().catch(x => {})
|
|
||||||
for (let tbody of document.querySelectorAll("tbody")) {
|
|
||||||
let row = puzzlerowTemplate.content.cloneNode(true)
|
|
||||||
row.querySelector(".category").textContent = puzzle.Category
|
|
||||||
row.querySelector(".points").textContent = puzzle.Points
|
|
||||||
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
|
|
||||||
row.querySelector(".error").textContent = puzzle.Error.Body
|
|
||||||
tbody.appendChild(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doing()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", init)
|
|
||||||
} else {
|
|
||||||
init()
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue