diff --git a/docs/scoring.md b/docs/scoring.md new file mode 100644 index 0000000..885f657 --- /dev/null +++ b/docs/scoring.md @@ -0,0 +1,73 @@ +Scoring +======= + +MOTH does not carry any notion of who is winning: we consider this a user +interface issue. The server merely provides a timestamped log of point awards. + +The bundled scoreboard provides one way to interpret the scores: this is the +main algorithm we use at Cyber Fire events. We use other views of the scoreboard +in other contexts, though! Here are some ideas: + + +Percentage of Each Category +--------------------- + +This is implemented in the scoreboard distributed with MOTH, and is how our +primary score calculation at Cyber Fire. + +For each category: + +* Divide the team's score in this category by the highest score in this category +* Add that to the team's overall score + +This means the highest theoretical score in any event is the number of open +categories. + +This algorithm means that point values only matter relative to other point +values within that category. A category with 5 total points is worth the same as +a category with 5000 total points, and a 2 point puzzle in the first category is +worth as much as a 2000 point puzzle in the second. + +One interesting effect here is that a team solving a previously-unsolved puzzle +will reduce everybody else's ranking in that category, because it increases the +divisor for calculating that category's score. + +Cyber Fire used to not display overall score: we would only show each team's +relative ranking per category. We may go back to this at some point! + + +Category Completion +---------------- + +Cyber Fire also has a scoreboard called the "class" scoreboard, which lists each +team, and which puzzles they have completed. This provides instructors with a +graphical overview of how people are progressing through content. We can provide +assistance to the general group when we see that a large number of teams are +stuck on a particular puzzle, and we can provide individual assistance if we see +that someone isn't keeping up with the class. + + +Monarch Of The Hill +---------------- + +You could also implement a "winner takes all" approach: any team with the +maximum number of points in a category gets 1 point, and all other teams get 0. + + +Time Bonuses +----------- + +If you wanted to provide extra points to whichever team solves a puzzle first, +this is possible with the log. You could either boost a puzzle's point value or +decay it; either by timestamp, or by how many teams had solved it prior. + + +Bonkers Scoring +------------- + +Other zany options exist: + +* The first team to solve a puzzle with point value divisible by 7 gets double + points. +* [Tokens](tokens.md) with negative point values could be introduced, allowing + teams to manipulate other teams' scores, if they know the team ID. diff --git a/theme/basic.css b/theme/basic.css index 90be911..d67ad72 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -118,18 +118,18 @@ input:invalid { width: 100%; position: relative; } - -#rankings span { - font-size: 75%; - display: inline-block; - overflow: hidden; - height: 1.7em; +#rankings div:nth-child(6n){ + background-color: #8881; +} +#rankings div:nth-child(6n+3) { + background-color: #0f01; } #rankings span.teamname { + height: auto; font-size: inherit; color: white; - text-shadow: 0 0 3px black; - opacity: 0.8; + background-color: #000e; + border-radius: 3px; position: absolute; right: 0.2em; } diff --git a/theme/moth.mjs b/theme/moth.mjs index bc5d190..a617753 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -21,7 +21,7 @@ class Hash { } /** - * Dan Bernstein hash with xor improvement + * Dan Bernstein hash with xor * * @param {string} buf Input * @returns {number} @@ -49,15 +49,30 @@ class Hash { return this.hexlify(hashArray); } - /** - * Hex-encode a byte array - * - * @param {number[]} buf Byte array - * @returns {string} - */ - static hexlify(buf) { - return buf.map(b => b.toString(16).padStart(2, "0")).join("") - } + /** + * SHA 1, but only the first 4 hexits (2 octets). + * + * Git uses this technique with 7 hexits (default) as a "short identifier". + * + * @param {string} buf Input + */ + static async sha1_slice(buf, end=4) { + const msgUint8 = new TextEncoder().encode(buf) + const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hexits = this.hexlify(hashArray) + return hexits.slice(0, end) + } + + /** + * Hex-encode a byte array + * + * @param {number[]} buf Byte array + * @returns {string} + */ + static hexlify(buf) { + return buf.map(b => b.toString(16).padStart(2, "0")).join("") + } /** * Apply every hash to the input buffer. @@ -68,8 +83,8 @@ class Hash { static async All(buf) { return [ String(this.djb2(buf)), - String(this.djb2xor(buf)), await this.sha256(buf), + await this.sha1_slice(buf), ] } } @@ -223,6 +238,111 @@ class Puzzle { } } +/** + * A snapshot of scores. + */ +class Scores { + constructor() { + /** + * Timestamp of this score snapshot + * @type number + */ + this.Timestamp = 0 + + /** + * All categories present in this snapshot. + * + * ECMAScript sets preserve order, so iterating over this will yield + * categories as they were added to the points log. + * + * @type {Set.} + */ + this.Categories = new Set() + + /** + * All team IDs present in this snapshot + * @type {Set.} + */ + this.TeamIDs = new Set() + + /** + * Highest score in each category + * @type {Object.} + */ + this.MaxPoints = {} + + this.categoryTeamPoints = {} + } + + /** + * Return a sorted list of category names + * + * @returns {string[]} + */ + SortedCategories() { + let categories = [...this.Categories] + categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"})) + return categories + } + + /** + * Add an award to a team's score. + * + * Updates this.Timestamp to the award's timestamp. + * + * @param {Award} award + */ + Add(award) { + this.Timestamp = award.Timestamp + this.Categories.add(award.Category) + this.TeamIDs.add(award.TeamID) + + let teamPoints = (this.categoryTeamPoints[award.Category] ??= {}) + let points = (teamPoints[award.TeamID] || 0) + award.Points + teamPoints[award.TeamID] = points + + let max = this.MaxPoints[award.Category] || 0 + this.MaxPoints[award.Category] = Math.max(max, points) + } + + /** + * Get a team's score within a category. + * + * @param {string} category + * @param {string} teamID + * @returns {number} + */ + GetPoints(category, teamID) { + let teamPoints = this.categoryTeamPoints[category] || {} + return teamPoints[teamID] || 0 + } + + /** + * Calculate a team's score in a category, using the Cyber Fire algorithm. + * + *@param {string} category + * @param {string} teamID + */ + CyFiCategoryScore(category, teamID) { + return this.GetPoints(category, teamID) / this.MaxPoints[category] + } + + /** + * Calculate a team's overall score, using the Cyber Fire algorithm. + * + *@param {string} category + * @param {string} teamID + * @returns {number} + */ + CyFiScore(teamID) { + let score = 0 + for (let category of this.Categories) { + score += this.CyFiCategoryScore(category, teamID) + } + return score + } +} + /** * MOTH instance state. */ @@ -351,51 +471,33 @@ class State { return false } - /** - * Map from team ID to points. - * - * A special "max" property contains the highest number of points in this map. - * - * @typedef {Object.} TeamPointsDict - * @property {Number} max Highest number of points - */ - - /** - * Map from category to PointsDict. - * - * @typedef {Object.} CategoryTeamPointsDict - */ - - /** - * Score snapshot. - * - * @typedef {Object} ScoreSnapshot - * @property {number} when Epoch time of this snapshot - * @property {CategoryTeamPointsDict} snapshot - */ - /** * Replay scores. * - * @yields {ScoreSnapshot} Snapshot at a point in time + * MOTH has no notion of who is "winning", we consider this a user interface + * decision. There are lots of interesting options: see + * [scoring]{@link ../docs/scoring.md} for more. + * + * @yields {Scores} Snapshot at a point in time */ * ScoreHistory() { - /** @type {CategoryTeamPointsDict} */ - let categoryTeamPoints = {} + let scores = new Scores() for (let award of this.PointsLog) { - let teamPoints = (categoryTeamPoints[award.Category] ??= {}) - let points = teamPoints[award.TeamID] || 0 - let max = teamPoints.max || 0 - - points += award.Points - teamPoints[award.TeamID] = points - teamPoints.max = Math.max(points, max) - - /** @type ScoreSnapshot */ - let snapshot = {when: award.When, snapshot: categoryTeamPoints} - yield snapshot + scores.Add(award) + yield scores } } + + /** + * Calculate the current scores. + * + * @returns {Scores} + */ + CurrentScore() { + let scores + for (scores of this.ScoreHistory()); + return scores + } } /** diff --git a/theme/scoreboard.html b/theme/scoreboard.html index 8b1f787..74af715 100644 --- a/theme/scoreboard.html +++ b/theme/scoreboard.html @@ -12,7 +12,6 @@
-
diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index e2d7cfc..8f7ce6f 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -1,267 +1,89 @@ -// jshint asi:true +import * as moth from "./moth.mjs" +import * as common from "./common.mjs" -// import { Chart, registerables } from "https://cdn.jsdelivr.net/npm/chart.js@3.0.2" -// import {DateTime} from "https://cdn.jsdelivr.net/npm/luxon@1.26.0" -// import "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.1.1" -// Chart.register(...registerables) +const server = new moth.Server(".") +const ReplayDuration = 3 * common.Second +const MaxFrameRate = 24 +/** Don't let any team's score exceed this percentage width */ +const MaxScoreWidth = 95 -const MILLISECOND = 1 -const SECOND = 1000 * MILLISECOND -const MINUTE = 60 * SECOND +/** + * Returns a promise that resolves after timeout. + * + * @param {Number} timeout How long to sleep (milliseconds) + * @returns {Promise} + */ +function sleep(timeout) { + return new Promise(resolve => setTimeout(resolve, timeout)); +} -// If all else fails... -setInterval(() => location.reload(), 30 * SECOND) +/** + * Pull new points log, and update the scoreboard. + * + * The update is animated, because I think that looks cool. + */ +async function update() { + let state = await server.GetState() + let rankingsElement = document.querySelector("#rankings") + let logSize = state.PointsLog.length -function scoreboardInit() { - let chartColors = [ - "rgb(255, 99, 132)", - "rgb(255, 159, 64)", - "rgb(255, 205, 86)", - "rgb(75, 192, 192)", - "rgb(54, 162, 235)", - "rgb(153, 102, 255)", - "rgb(201, 203, 207)" - ] - - for (let q of document.querySelectorAll("[data-url]")) { - let url = new URL(q.dataset.url, document.location) - q.textContent = url.hostname - if (url.port) { - q.textContent += `:${url.port}` - } - if (url.pathname != "/") { - q.textContent += url.pathname - } - } - for (let q of document.querySelectorAll(".qrcode")) { - let url = new URL(q.dataset.url, document.location) - let qr = new QRious({ - element: q, - value: url.toString(), - }) + // Figure out the timing so that we can replay the scoreboard in about + // ReplayDuration, but no more than 24 frames per second. + let frameModulo = 1 + let delay = 0 + while (delay < (common.Second / MaxFrameRate)) { + frameModulo += 1 + delay = ReplayDuration / (logSize / frameModulo) } - let chart - let canvas = document.querySelector("#chart canvas") - if (canvas) { - chart = new Chart(canvas.getContext("2d"), { - type: "line", - options: { - responsive: true, - scales: { - x: { - type: "time", - time: { - // XXX: the manual says this should do something, it does something in the samples, IDK - tooltipFormat: "HH:mm" - }, - title: { - display: true, - text: "Time" - } - }, - y: { - title: { - display: true, - text: "Points" - } - } - }, - tooltips: { - mode: "index", - intersect: false - }, - hover: { - mode: "nearest", - intersect: true + let frame = 0 + for (let scores of state.ScoreHistory()) { + frame += 1 + if ((frame < state.PointsLog.length) && (frame % frameModulo)) { + continue + } + + while (rankingsElement.firstChild) rankingsElement.firstChild.remove() + + let sortedTeamIDs = [...scores.TeamIDs] + sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b)) + sortedTeamIDs.reverse() + + let topScore = scores.CyFiScore(sortedTeamIDs[0]) + for (let teamID of sortedTeamIDs) { + let teamName = state.TeamNames[teamID] + + let row = rankingsElement.appendChild(document.createElement("div")) + + let heading = row.appendChild(document.createElement("span")) + heading.textContent = teamName + heading.classList.add("teamname") + + let categoryNumber = 0 + for (let category of scores.Categories) { + let score = scores.CyFiCategoryScore(category, teamID) + if (!score) { + continue } - } - }) + + let block = row.appendChild(document.createElement("span")) + let points = scores.GetPoints(category, teamID) + let width = MaxScoreWidth * score / topScore + + block.textContent = category + block.title = `${points} points` + block.style.width = `${width}%` + block.classList.add(`cat${categoryNumber}`) + categoryNumber += 1 + } + } + await sleep(delay) } - - async function refresh() { - let resp = await fetch("../state") - let state = await resp.json() - - for (let rotate of document.querySelectorAll(".rotate")) { - rotate.appendChild(rotate.firstElementChild) - } - window.scrollTo(0,0) - - let element = document.getElementById("rankings") - let teamNames = state.TeamNames - let pointsLog = state.PointsLog - - // Every machine that's displaying the scoreboard helpfully stores the last 20 values of - // points.json for us, in case of catastrophe. Thanks, y'all! - // - // We have been doing some variation on this "everybody backs up the server state" trick since 2009. - // We have needed it 0 times. - let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || [] - if (pointsHistory.length >= 20) { - pointsHistory.shift() - } - pointsHistory.push(pointsLog) - localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory)) - - let teams = {} - let highestCategoryScore = {} // map[string]int - - // Initialize data structures - for (let teamId in teamNames) { - teams[teamId] = { - categoryScore: {}, // map[string]int - overallScore: 0, // int - historyLine: [], // []{x: int, y: int} - name: teamNames[teamId], - id: teamId - } - } - - // Dole out points - for (let entry of pointsLog) { - let timestamp = entry[0] - let teamId = entry[1] - let category = entry[2] - let points = entry[3] - - let team = teams[teamId] - - let score = team.categoryScore[category] || 0 - score += points - team.categoryScore[category] = score - - let highest = highestCategoryScore[category] || 0 - if (score > highest) { - highestCategoryScore[category] = score - } - } - - for (let teamId in teamNames) { - teams[teamId].categoryScore = {} - } - - for (let entry of pointsLog) { - let timestamp = entry[0] - let teamId = entry[1] - let category = entry[2] - let points = entry[3] - - let team = teams[teamId] - - let score = team.categoryScore[category] || 0 - score += points - team.categoryScore[category] = score - - let overall = 0 - for (let cat in team.categoryScore) { - overall += team.categoryScore[cat] / highestCategoryScore[cat] - } - - team.historyLine.push({x: timestamp * 1000, y: overall}) - } - - // Compute overall scores based on current highest - for (let teamId in teams) { - let team = teams[teamId] - team.overallScore = 0 - for (let cat in team.categoryScore) { - team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat] - } - } - - // Sort by team score - function teamCompare(a, b) { - return a.overallScore - b.overallScore - } - - // Figure out how to order each team on the scoreboard - let winners = [] - for (let teamId in teams) { - winners.push(teams[teamId]) - } - winners.sort(teamCompare) - winners.reverse() - - // Let's make some better names for things we've computed - let winningScore = winners[0].overallScore - let numCategories = Object.keys(highestCategoryScore).length - - // Clear out the element we're about to populate - Array.from(element.childNodes).map(e => e.remove()) - - let maxWidth = 100 / winningScore - for (let team of winners) { - let row = document.createElement("div") - let ncat = 0 - for (let category in highestCategoryScore) { - let catHigh = highestCategoryScore[category] - let catTeam = team.categoryScore[category] || 0 - let catPct = catTeam / catHigh - let width = maxWidth * catPct - - let bar = document.createElement("span") - bar.classList.add("category") - bar.classList.add("cat" + ncat) - bar.style.width = width + "%" - bar.textContent = category + ": " + catTeam - bar.title = bar.textContent - - row.appendChild(bar) - ncat += 1 - } - - let te = document.createElement("span") - te.classList.add("teamname") - te.textContent = team.name - row.appendChild(te) - - element.appendChild(row) - } - - if (!chart) { - return - } - - /* - * Update chart - */ - chart.data.datasets = [] - for (let i in winners) { - if (i > 5) { - break - } - let team = winners[i] - let color = chartColors[i % chartColors.length] - chart.data.datasets.push({ - label: team.name, - backgroundColor: color, - borderColor: color, - data: team.historyLine, - lineTension: 0, - fill: false - }) - } - chart.update() - window.chart = chart - } - - function init() { - let base = window.location.href.replace("scoreboard.html", "") - let location = document.querySelector("#location") - if (location) { - location.textContent = base - } - - setInterval(refresh, 20 * SECOND) - refresh() - } - - init() } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", scoreboardInit) -} else { - scoreboardInit() +function init() { + setInterval(update, common.Minute) + update() } + +common.WhenDOMLoaded(init) \ No newline at end of file