moth

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

moth / theme
Neale Pickett  ·  2024-01-03

moth.mjs

  1/**
  2 * Hash/digest functions
  3 */
  4class Hash {
  5    /**
  6     * Dan Bernstein hash
  7     * 
  8     * Used until MOTH v3.5
  9     * 
 10     * @param {string} buf Input
 11     * @returns {number}
 12     */
 13    static djb2(buf) {
 14        let h = 5381
 15        for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
 16            // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
 17            // So we have to do "unsigned right shift" by zero to get it back to unsigned.
 18            h = ((h * 33) + c) >>> 0
 19        }
 20        return h
 21    }
 22
 23    /**
 24     * Dan Bernstein hash with xor
 25     * 
 26     * @param {string} buf Input
 27     * @returns {number}
 28     */
 29    static djb2xor(buf) {
 30        let h = 5381
 31        for (let c of (new TextEncoder()).encode(buf)) {
 32            h = ((h * 33) ^ c) >>> 0
 33        }
 34        return h
 35    }
 36  
 37    /**
 38     * SHA 256
 39     * 
 40     * Used until MOTH v4.5
 41     * 
 42     * @param {string} buf Input
 43     * @returns {Promise.<string>} hex-encoded digest
 44     */
 45    static async sha256(buf) {
 46        const msgUint8 = new TextEncoder().encode(buf)
 47        const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
 48        const hashArray = Array.from(new Uint8Array(hashBuffer))
 49        return this.hexlify(hashArray);
 50    }
 51
 52    /**
 53     * SHA 1, but only the first 4 hexits (2 octets).
 54     * 
 55     * Git uses this technique with 7 hexits (default) as a "short identifier".
 56     * 
 57     * @param {string} buf Input
 58     */
 59    static async sha1_slice(buf, end=4) {
 60        const msgUint8 = new TextEncoder().encode(buf)
 61        const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
 62        const hashArray = Array.from(new Uint8Array(hashBuffer))
 63        const hexits = this.hexlify(hashArray)
 64        return hexits.slice(0, end)
 65    }
 66
 67    /**
 68     * Hex-encode a byte array
 69     * 
 70     * @param {number[]} buf Byte array
 71     * @returns {string}
 72     */
 73    static hexlify(buf) {
 74        return buf.map(b => b.toString(16).padStart(2, "0")).join("")   
 75    }
 76
 77  /**
 78   * Apply every hash to the input buffer.
 79   * 
 80   * @param {string} buf Input
 81   * @returns {Promise.<string[]>}
 82   */
 83  static async All(buf) {
 84    return [
 85        String(this.djb2(buf)),
 86        await this.sha256(buf),
 87        await this.sha1_slice(buf),
 88    ]
 89  }
 90}
 91
 92/**
 93 * A point award.
 94 */
 95class Award {
 96    constructor(when, teamid, category, points) {
 97        /** Unix epoch timestamp for this award 
 98         * @type {number}
 99        */
100        this.When = when
101        /** Team ID this award belongs to 
102         * @type {string}
103        */
104        this.TeamID = teamid
105        /** Puzzle category for this award
106         * @type {string}
107         */
108        this.Category = category
109        /** Points value of this award
110         * @type {number}
111         */
112        this.Points = points
113    }
114}
115
116/**
117 * A puzzle.
118 * 
119 * A new Puzzle only knows its category and point value.
120 * If you want to populate it with meta-information, you must call Populate().
121 * 
122 * Parameters created by Populate are described in the server source code:
123 * {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
124 * 
125 */
126class Puzzle {
127    /**
128     * @param {Server} server 
129     * @param {string} category 
130     * @param {number} points 
131     */
132    constructor (server, category, points) {
133        if (points < 1) {
134            throw(`Invalid points value: ${points}`)
135        }
136        
137        /** Server where this puzzle lives
138         * @type {Server}
139         */
140        this.server = server
141        
142        /** Category this puzzle belongs to */
143        this.Category = String(category)
144        
145        /** Point value of this puzzle */
146        this.Points = Number(points)
147
148        /** Error returned trying to retrieve this puzzle */
149        this.Error = {
150            /** Status code provided by server */
151            Status: 0,
152            /** Status text provided by server */
153            StatusText: "",
154            /** Full text of server error */
155            Body: "",
156        }
157    }
158
159    /**
160     * Populate this Puzzle object with meta-information from the server.
161     */
162    async Populate() {
163        let resp = await this.Get("puzzle.json")
164        if (!resp.ok) {
165            let body = await resp.text()
166            this.Error = {
167                Status: resp.status,
168                StatusText: resp.statusText,
169                Body: body,
170            }
171            throw(this.Error)
172        }
173        let obj = await resp.json()
174        Object.assign(this, obj)
175
176        // Make sure lists are lists
177        this.AnswerHashes ||= []
178        this.Answers ||= []
179        this.Attachments ||= []
180        this.Authors ||= []
181        this.Scripts ||= []
182        this.Debug ||= {}
183        this.Debug.Errors ||= []
184        this.Debug.Hints ||= []
185        this.Debug.Log ||= []
186        this.Extra ||= {}
187
188        // Be ready to handle a future revision to the Puzzle structure
189        this.Objective ||= this.Extra.Objective
190        this.KSAs ||= this.Extra.KSAs || []
191        this.Success ||= this.Extra.Success || {}
192    }
193
194    /**
195     * Get a resource associated with this puzzle.
196     * 
197     * @param {string} filename Attachment/Script to retrieve
198     * @returns {Promise.<Response>}
199     */
200    Get(filename) {
201        return this.server.GetContent(this.Category, this.Points, filename)
202    }
203
204    /**
205     * Check if a string is possibly correct.
206     *
207     * The server sends a list of answer hashes with each puzzle: this method
208     * checks to see if any of those hashes match a hash of the string.
209     *
210     * The MOTH development team likes obscure hash functions with a lot of
211     * collisions, which means that a given input may match another possible
212     * string's hash. We do this so that if you run a brute force attack against
213     * the list of hashes, you have to write your own brute force program, and
214     * you still have to pick through a lot of potentially correct answers when
215     * it's done.
216     *
217     * @param {string} str User-submitted possible answer
218     * @returns {Promise.<boolean>}
219     */
220    async IsPossiblyCorrect(str) {
221        let userAnswerHashes = await Hash.All(str)
222
223        for (let pah of this.AnswerHashes) {
224            for (let uah of userAnswerHashes) {
225                if (pah == uah) {
226                    return true
227                }
228            }
229        }
230        return false
231    }
232
233    /**
234     * Submit a proposed answer for points.
235     *
236     * The returned promise will fail if anything goes wrong, including the
237     * proposed answer being rejected.
238     *
239     * @param {string} proposed Answer to submit
240     * @returns {Promise.<string>} Success message
241     */
242    SubmitAnswer(proposed) {
243        return this.server.SubmitAnswer(this.Category, this.Points, proposed)
244    }
245}
246
247/**
248 * A snapshot of scores.
249 */
250class Scores {
251    constructor() {
252        /** 
253         * Timestamp of this score snapshot
254         * @type number 
255         */
256        this.Timestamp = 0
257
258        /**
259         * All categories present in this snapshot.
260         *
261         * ECMAScript sets preserve order, so iterating over this will yield
262         * categories as they were added to the points log.
263         *
264         * @type {Set.<string>}
265         */
266        this.Categories = new Set()
267
268        /**
269         * All team IDs present in this snapshot
270         * @type {Set.<string>}
271         */
272        this.TeamIDs = new Set()
273
274        /**
275         * Highest score in each category
276         * @type {Object.<string,number>}
277         */
278        this.MaxPoints = {}
279        
280        this.categoryTeamPoints = {}
281    }
282
283    /**
284     * Return a sorted list of category names
285     * 
286     * @returns {string[]}
287     */
288    SortedCategories() {
289        let categories = [...this.Categories]
290        categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
291        return categories
292    }
293
294    /**
295     * Add an award to a team's score.
296     * 
297     * Updates this.Timestamp to the award's timestamp.
298     * 
299     * @param {Award} award 
300     */
301    Add(award) {
302        this.Timestamp = award.Timestamp
303        this.Categories.add(award.Category)
304        this.TeamIDs.add(award.TeamID)
305
306        let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
307        let points = (teamPoints[award.TeamID] || 0) + award.Points
308        teamPoints[award.TeamID] = points
309
310        let max = this.MaxPoints[award.Category] || 0
311        this.MaxPoints[award.Category] = Math.max(max, points)
312    }
313
314    /**
315     * Get a team's score within a category.
316     * 
317     * @param {string} category 
318     * @param {string} teamID 
319     * @returns {number}
320     */
321    GetPoints(category, teamID) {
322        let teamPoints = this.categoryTeamPoints[category] || {}
323        return teamPoints[teamID] || 0
324    }
325
326    /**
327     * Calculate a team's score in a category, using the Cyber Fire algorithm.
328     * 
329     *@param {string} category 
330     * @param {string} teamID 
331     */
332    CyFiCategoryScore(category, teamID) {
333        return this.GetPoints(category, teamID) / this.MaxPoints[category]
334    }
335
336    /**
337     * Calculate a team's overall score, using the Cyber Fire algorithm.
338     * 
339     *@param {string} category 
340     * @param {string} teamID 
341     * @returns {number}
342     */
343    CyFiScore(teamID) {
344        let score = 0
345        for (let category of this.Categories) {
346            score += this.CyFiCategoryScore(category, teamID)
347        }
348        return score
349    }
350}
351
352/**
353 * MOTH instance state.
354 */
355class State {
356    /**
357     * @param {Server} server Server where we got this
358     * @param {Object} obj Raw state data
359     */
360    constructor(server, obj) {
361        for (let key of ["Config", "TeamNames", "PointsLog"]) {
362            if (!obj[key]) {
363                throw(`Missing state property: ${key}`)
364            }
365        }
366        this.server = server
367
368        /** Configuration */
369        this.Config = {
370            /** Is the server in development mode?
371             * @type {boolean}
372             */
373            Devel: obj.Config.Devel,
374        }
375
376        /** True if the server is in enabled state, or if  we don't know */
377        this.Enabled = obj.Enabled ?? true
378
379        /** Map from Team ID to Team Name
380         * @type {Object.<string,string>}
381         */
382        this.TeamNames = obj.TeamNames
383
384        /** Map from category name to puzzle point values
385         * @type {Object.<string,number>}
386         */
387        this.PointsByCategory = obj.Puzzles
388
389        /** Log of points awarded
390         * @type {Award[]}
391         */
392        this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
393    }
394
395    /**
396     * Returns a sorted list of open category names
397     * 
398     * @returns {string[]} List of categories
399     */
400    Categories() {
401        let ret = []
402        for (let category in this.PointsByCategory) {
403            ret.push(category)
404        }
405        ret.sort()
406        return ret
407    }
408
409    /**
410     * Check whether a category contains unsolved puzzles.
411     * 
412     * The server adds a puzzle with 0 points in every "solved" category,
413     * so this just checks whether there is a 0-point puzzle in the category's point list.
414     * 
415     * @param {string} category 
416     * @returns {boolean}
417     */
418    ContainsUnsolved(category) {
419        return !this.PointsByCategory[category].includes(0)
420    }
421
422    /**
423     * Is the server in development mode?
424     * 
425     * @returns {boolean}
426     */
427    DevelopmentMode() {
428        return this.Config && this.Config.Devel
429    }
430
431    /**
432     * Return all open puzzles.
433     * 
434     * The returned list will be sorted by (category, points).
435     * If not categories are given, all puzzles will be returned.
436     * 
437     * @param {string} categories Limit results to these categories
438     * @returns {Puzzle[]}
439     */
440    Puzzles(...categories) {
441        if (categories.length == 0) {
442            categories = this.Categories()
443        }
444        let ret = []
445        for (let category of categories) {
446            for (let points of this.PointsByCategory[category]) {
447                if (0 == points) {
448                    // This means all potential puzzles in the category are open
449                    continue
450                }
451                let p = new Puzzle(this.server, category, points)
452                ret.push(p)
453            }
454        }
455        return ret
456    }
457
458    /**
459     * Has this puzzle been solved by this team?
460     * 
461     * @param {Puzzle} puzzle 
462     * @param {string} teamID Team to check, default the logged-in team
463     * @returns {boolean}
464     */
465    IsSolved(puzzle, teamID="self") {
466        for (let award of this.PointsLog) {
467            if (
468                (award.Category == puzzle.Category)
469                && (award.Points == puzzle.Points)
470                && (award.TeamID == teamID)
471            ) {
472                return true
473            }
474        }
475        return false
476    }
477
478    /**
479     * Replay scores.
480     *
481     * MOTH has no notion of who is "winning", we consider this a user interface
482     * decision. There are lots of interesting options: see
483     * [scoring]{@link ../docs/scoring.md} for more.
484     *
485     * @yields {Scores} Snapshot at a point in time
486     */
487    * ScoresHistory() {
488        let scores = new Scores()
489        for (let award of this.PointsLog) {
490            scores.Add(award)
491            yield scores
492        }
493    }
494
495    /**
496     * Calculate the current scores.
497     * 
498     * @returns {Scores}
499     */
500    CurrentScores() {
501        return [...this.ScoresHistory()].pop()
502    }
503}
504
505/**
506 * A MOTH Server interface.
507 * 
508 * This uses localStorage to remember Team ID,
509 * and will send a Team ID with every request, if it can find one.
510 */
511class Server {
512    /**
513     * @param {string | URL} baseUrl Base URL to server, for constructing API URLs
514     */
515    constructor(baseUrl) {
516        if (!baseUrl) {
517            throw("Must provide baseURL")
518        }
519        this.baseUrl = new URL(baseUrl, location)
520        this.teamIDKey = this.baseUrl.toString() + " teamID"
521        this.TeamID = localStorage[this.teamIDKey]
522    }
523
524    /**
525     * Fetch a MOTH resource.
526     * 
527     * If anything other than a 2xx code is returned,
528     * this function throws an error.
529     * 
530     * This always sends teamID.
531     * If args is set, POST will be used instead of GET
532     * 
533     * @param {string} path Path to API endpoint
534     * @param {Object.<string,string>} args Key/Values to send in POST data
535     * @returns {Promise.<Response>} Response
536     */
537    fetch(path, args={}) {
538        let body = new URLSearchParams(args)
539        if (this.TeamID && !body.has("id")) {
540            body.set("id", this.TeamID)
541        }
542
543        let url = new URL(path, this.baseUrl)
544        return fetch(url, {
545            method: "POST",
546            body,
547            cache: "no-cache",
548        })
549    }
550
551    /**
552     * Send a request to a JSend API endpoint.
553     * 
554     * @param {string} path Path to API endpoint
555     * @param {Object.<string,string>} args Key/Values to send in POST
556     * @returns {Promise.<Object>} JSend Data
557     */
558    async call(path, args={}) {
559        let resp = await this.fetch(path, args)
560        let obj = await resp.json()
561        switch (obj.status) {
562            case "success":
563                return obj.data
564            case "fail":
565                throw new Error(obj.data.description || obj.data.short || obj.data)
566            case "error":
567                throw new Error(obj.message)
568            default:
569                throw new Error(`Unknown JSend status: ${obj.status}`)
570        }
571    }
572
573    /**
574     * Make a new URL for the given resource.
575     *
576     * The returned URL instance will be absolute, and immune to changes to the
577     * page that would affect relative URLs.
578     *
579     * @returns {URL}
580     */
581    URL(url) {
582        return new URL(url, this.baseUrl)
583    }
584
585    /**
586     * Are we logged in to the server?
587     * 
588     * @returns {boolean}
589     */
590    LoggedIn() {
591        return this.TeamID ? true : false
592    }
593
594    /**
595     * Forget about any previous Team ID.
596     * 
597     * This is equivalent to logging out.
598     */
599    Reset() {
600        localStorage.removeItem(this.teamIDKey)
601        this.TeamID = null
602    }
603
604    /**
605     * Fetch current contest state.
606     * 
607     * @returns {Promise.<State>}
608     */
609    async GetState() {
610        let resp = await this.fetch("/state")
611        let obj = await resp.json()
612        return new State(this, obj)
613    }
614
615    /**
616     * Log in to a team.
617     *
618     * This calls the server's registration endpoint; if the call succeds, or
619     * fails with "team already exists", the login is returned as successful. 
620     *
621     * @param {string} teamID
622     * @param {string} teamName 
623     * @returns {Promise.<string>} Success message from server
624     */
625    async Login(teamID, teamName) {
626        let data = await this.call("/register", {id: teamID, name: teamName})
627        this.TeamID = teamID
628        this.TeamName = teamName
629        localStorage[this.teamIDKey] = teamID
630        return data.description || data.short
631    }
632
633    /**
634     * Submit a proposed answer for points.
635     *
636     * The returned promise will fail if anything goes wrong, including the
637     * proposed answer being rejected.
638     *
639     * @param {string} category Category of puzzle
640     * @param {number} points Point value of puzzle
641     * @param {string} proposed Answer to submit
642     * @returns {Promise.<string>} Success message
643     */
644    async SubmitAnswer(category, points, proposed) {
645        let data = await this.call("/answer", {
646            cat: category, 
647            points, 
648            answer: proposed,
649        })
650        return data.description || data.short
651    }
652
653    /**
654     * Fetch a file associated with a puzzle.
655     * 
656     * @param {string} category Category of puzzle
657     * @param {number} points Point value of puzzle
658     * @param {string} filename
659     * @returns {Promise.<Response>}
660     */
661    GetContent(category, points, filename) {
662        return this.fetch(`/content/${category}/${points}/${filename}`)
663    }
664
665    /**
666     * Return a Puzzle object.
667     * 
668     * New Puzzle objects only know their category and point value.
669     * See docstrings on the Puzzle object for more information.
670     * 
671     * @param {string} category 
672     * @param {number} points 
673     * @returns {Puzzle}
674     */
675    GetPuzzle(category, points) {
676        return new Puzzle(this, category, points)
677    }
678}
679
680export {
681    Hash,
682    Server,
683    State,
684}