Full moth.mjs, and an example to use it

This commit is contained in:
Neale Pickett 2023-09-07 16:16:46 -06:00
parent fcfa11b012
commit 99d7245c49
3 changed files with 410 additions and 19 deletions

View File

@ -1,18 +1,284 @@
/**
* 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 Get().
*/
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
* @type {String}
*/
this.Category = category
/** Point value of this puzzle
* @type {Number}
*/
this.Points = points
}
/** Error returned trying to fetch this puzzle */
Error = {
/** Status code provided by server */
Status: 0,
/** Status text provided by server */
StatusText: "",
/** Full text of server error */
Body: "",
}
/** Hashes of answers
* @type {String[]}
*/
AnswerHashes = []
/** Pattern that answer should match
* @type {String[]}
*/
AnswerPattern = ""
/** Accepted answers
* @type {String[]}
*/
Answers = []
/** Other files attached to this puzzles
* @type {String[]}
*/
Attachments = []
/** This puzzle's authors
* @type {String[]}
*/
Authors = []
/** HTML body of this puzzle */
Body = ""
/** Debugging information */
Debug = {
Errors: [],
Hints: [],
Log: [],
Notes: "",
Summary: "",
}
/** KSAs met by solving this puzzle
* @type {String[]}
*/
KSAs = []
/** Learning objective for this puzzle */
Objective = ""
/** ECMAScript scripts needed for this puzzle
* @type {String[]}
*/
Scripts = []
/** Criteria for succeeding at this puzzle */
Success = {
/** Acceptable Minimum criteria for success */
Minimum: "",
/** Criteria for demonstrating mastery of this puzzle */
Mastery: "",
}
/**
* 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.
*
* @property {Object} Config
* @property {Boolean} Config.Enabled Are points log updates enabled?
* @property {String} Messages Global broadcast messages, in HTML
* @property {Object.<String>} TeamNames Mapping from IDs to team names
* @property {Object.<String,Number[]>} PointsByCategory Map from category name to open puzzle point values
* @property {Award[]} PointsLog Log of points awarded
*/
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 {
constructor(baseUrl) {
this.baseUrl = new URL(baseUrl)
this.teamId = null
this.baseUrl = new URL(baseUrl, location)
this.teameIdKey = this.baseUrl.toString() + " teamID"
this.teamId = localStorage[this.teameIdKey]
}
/**
* Fetch a MOTH resource.
*
* This is just a convenience wrapper to always send teamId.
* If anything other than a 2xx code is returned,
* this function throws an error.
*
* This always sends teamId.
* If body is set, POST will be used instead of GET
*
* @param {String} path Path to API endpoint
* @param {Object} body Key/Values to send in POST data
* @returns {Promise} Response
* @param {Object<String,String>} body Key/Values to send in POST data
* @returns {Promise<Response>} Response
*/
fetch(path, body) {
let url = new URL(path, this.baseUrl)
@ -29,14 +295,11 @@ class Server {
* Send a request to a JSend API endpoint.
*
* @param {String} path Path to API endpoint
* @param {Object} args Key/Values to send in POST
* @returns JSend Data
* @param {Object<String,String>} args Key/Values to send in POST
* @returns {Promise<Object>} JSend Data
*/
async postJSend(path, args) {
async call(path, args) {
let resp = await this.fetch(path, args)
if (!resp.ok) {
throw new Error(resp.statusText)
}
let obj = await resp.json()
switch (obj.status) {
case "success":
@ -50,6 +313,27 @@ 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.
*
@ -58,23 +342,42 @@ class Server {
*
* @param {String} teamId
* @param {String} teamName
* @returns {String} Success message from server
* @returns {Promise<String>} Success message from server
*/
async Register(teamId, teamName) {
let data = await postJSend("/login", {id: teamId, name: teamName})
let data = await this.call("/login", {id: teamId, name: teamName})
this.teamId = teamId
this.teamName = teamName
localStorage[this.teameIdKey] = teamId
return data.description || data.short
}
/**
* Fetch current contest status.
*
* @returns {Object} Contest status
* Submit a puzzle answer for points.
*
* The returned promise will fail if anything goes wrong, including the
* 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 Status() {
let data = await this.postJSend("/status")
return data
async SubmitAnswer(category, points, answer) {
await this.call("/answer", {category, points, answer})
return true
}
/**
* 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}`)
}
}

39
theme/reports/ksa.html Normal file
View File

@ -0,0 +1,39 @@
<!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>

49
theme/reports/ksa.mjs Normal file
View File

@ -0,0 +1,49 @@
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 puzzles = state.Puzzles()
for (let p of puzzles) {
await p.Populate().catch(x => {})
}
doing("Filling table")
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
for (let tbody of document.querySelectorAll("tbody")) {
for (let puzzle of puzzles) {
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()
}