working scoreboard

This commit is contained in:
Neale Pickett 2023-09-15 15:17:07 -06:00
parent f49eb3ed46
commit d18de0fe8b
5 changed files with 309 additions and 313 deletions

73
docs/scoring.md Normal file
View File

@ -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.

View File

@ -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;
}

View File

@ -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.<string>}
*/
this.Categories = new Set()
/**
* All team IDs present in this snapshot
* @type {Set.<string>}
*/
this.TeamIDs = new Set()
/**
* Highest score in each category
* @type {Object.<string,number>}
*/
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.<string, number>} TeamPointsDict
* @property {Number} max Highest number of points
*/
/**
* Map from category to PointsDict.
*
* @typedef {Object.<string, TeamPointsDict>} 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
}
}
/**

View File

@ -12,7 +12,6 @@
</head>
<body class="wide">
<section class="rotate">
<div id="chart"><canvas></canvas></div>
<div id="rankings"></div>
</section>
</body>

View File

@ -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)