Compare commits

..

4 Commits

Author SHA1 Message Date
Neale Pickett 768600e48e Logout in devel mode generates a new TeamID 2023-09-15 16:13:09 -06:00
Neale Pickett bb4859e7a9 URL in scoreboard (configurable) 2023-09-15 16:09:08 -06:00
Neale Pickett d18de0fe8b working scoreboard 2023-09-15 15:17:07 -06:00
Neale Pickett f49eb3ed46 Change answer hash algorithm to SHA1₄ 2023-09-15 12:34:31 -06:00
13 changed files with 382 additions and 367 deletions

View File

@ -6,8 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [v4.6.0] - unreleased ## [v4.6.0] - unreleased
### Changed ### Changed
- We are now using djb2xor instead of sha256 to hash puzzle answers - Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
- Lots of work on the built-in theme - Reworked the built-in theme
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript - [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- Devel mode no longer accepts an empty team ID - Devel mode no longer accepts an empty team ID

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

@ -4,7 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha1"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -85,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
} }
puzzle.AnswerHashes = make([]string, len(puzzle.Answers)) puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers { for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer)) sum := sha1.Sum([]byte(answer))
hexsum := fmt.Sprintf("%x", sum) hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum puzzle.AnswerHashes[i] = hexsum[:4]
} }
} }

View File

@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) {
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") { if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
t.Error("Answers are wrong", p.Answers) t.Error("Answers are wrong", p.Answers)
} }
if len(p.Answers) != len(p.AnswerHashes) {
t.Error("Answer hashes length does not match answers length")
}
if len(p.AnswerHashes[0]) != 4 {
t.Error("Answer hash is wrong length")
}
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") { if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
t.Error("Authors are wrong", p.Authors) t.Error("Authors are wrong", p.Authors)
} }

View File

@ -113,36 +113,7 @@ input:invalid {
cursor: help; cursor: help;
} }
/** Scoreboard */ /** Development mode information */
#rankings {
width: 100%;
position: relative;
}
#rankings span {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.7em;
}
#rankings span.teamname {
font-size: inherit;
color: white;
text-shadow: 0 0 3px black;
opacity: 0.8;
position: absolute;
right: 0.2em;
}
#rankings div * {white-space: nowrap;}
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
.debug { .debug {
overflow: auto; overflow: auto;
padding: 1em; padding: 1em;
@ -203,11 +174,9 @@ li[draggable] {
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
/* /* We uses the alpha channel to apply hue tinting to elements, to get a
* This uses the alpha channel to apply hue tinting to elements, to get a
* similar effect in light or dark mode. That means there aren't a whole lot of * similar effect in light or dark mode. That means there aren't a whole lot of
* things to change between light and dark mode. * things to change between light and dark mode.
*
*/ */
body { body {
background-color: #b9cbd8; background-color: #b9cbd8;

View File

@ -5,6 +5,9 @@ const Millisecond = 1
const Second = Millisecond * 1000 const Second = Millisecond * 1000
const Minute = Second * 60 const Minute = Second * 60
/** URL to the top of this MOTH server */
const BaseURL = new URL(".", location)
/** /**
* Display a transient message to the user. * Display a transient message to the user.
* *
@ -53,11 +56,29 @@ function Truthy(s) {
return true return true
} }
/**
* Fetch the configuration object for this theme.
*
* @returns {Promise.<Object>}
*/
async function Config() {
let resp = await fetch(
new URL("config.json", BaseURL),
{
cache: "no-cache"
},
)
return resp.json()
}
export { export {
Millisecond, Millisecond,
Second, Second,
Minute, Minute,
BaseURL,
Toast, Toast,
WhenDOMLoaded, WhenDOMLoaded,
Truthy, Truthy,
Config,
} }

View File

@ -1,4 +1,5 @@
{ {
"TrackSolved": true, "TrackSolved": true,
"URLInScoreboard": true,
"__sentry__": "this is here so you don't have to remember to take the comma off the last item" "__sentry__": "this is here so you don't have to remember to take the comma off the last item"
} }

View File

@ -6,17 +6,10 @@ import * as common from "./common.mjs"
class App { class App {
constructor(basePath=".") { constructor(basePath=".") {
this.configURL = new URL("config.json", location)
this.config = {} this.config = {}
this.server = new moth.Server(basePath) this.server = new moth.Server(basePath)
let uuid = Math.floor(Math.random() * 1000000).toString(16)
this.fakeRegistration = {
TeamID: uuid,
TeamName: `Team ${uuid}`,
}
for (let form of document.querySelectorAll("form.login")) { for (let form of document.querySelectorAll("form.login")) {
form.addEventListener("submit", event => this.handleLoginSubmit(event)) form.addEventListener("submit", event => this.handleLoginSubmit(event))
} }
@ -74,8 +67,7 @@ class App {
* load, since configuration should (hopefully) change less frequently. * load, since configuration should (hopefully) change less frequently.
*/ */
async UpdateConfig() { async UpdateConfig() {
let resp = await fetch(this.configURL) this.config = await common.Config()
this.config = await resp.json()
} }
/** /**
@ -105,9 +97,10 @@ class App {
} }
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) { if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
let teamID = Math.floor(Math.random() * 1000000).toString(16)
common.Toast("Automatically logging in to devel server") common.Toast("Automatically logging in to devel server")
console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration) console.info(`Logging in with generated Team ID: ${teamID}`)
return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName) return this.Login(teamID, `Team ${teamID}`)
} }
} }
@ -150,7 +143,7 @@ class App {
for (let puzzle of this.state.Puzzles(cat)) { for (let puzzle of this.state.Puzzles(cat)) {
let i = l.appendChild(document.createElement("li")) let i = l.appendChild(document.createElement("li"))
let url = new URL("puzzle.html", window.location) let url = new URL("puzzle.html", common.BaseURL)
url.hash = `${puzzle.Category}:${puzzle.Points}` url.hash = `${puzzle.Category}:${puzzle.Points}`
let a = i.appendChild(document.createElement("a")) let a = i.appendChild(document.createElement("a"))
a.textContent = puzzle.Points a.textContent = puzzle.Points

View File

@ -21,7 +21,7 @@ class Hash {
} }
/** /**
* Dan Bernstein hash with xor improvement * Dan Bernstein hash with xor
* *
* @param {string} buf Input * @param {string} buf Input
* @returns {number} * @returns {number}
@ -49,6 +49,21 @@ class Hash {
return this.hexlify(hashArray); return this.hexlify(hashArray);
} }
/**
* 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 * Hex-encode a byte array
* *
@ -68,8 +83,8 @@ class Hash {
static async All(buf) { static async All(buf) {
return [ return [
String(this.djb2(buf)), String(this.djb2(buf)),
String(this.djb2xor(buf)),
await this.sha256(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. * MOTH instance state.
*/ */
@ -351,51 +471,33 @@ class State {
return false 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. * 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() { * ScoreHistory() {
/** @type {CategoryTeamPointsDict} */ let scores = new Scores()
let categoryTeamPoints = {}
for (let award of this.PointsLog) { for (let award of this.PointsLog) {
let teamPoints = (categoryTeamPoints[award.Category] ??= {}) scores.Add(award)
let points = teamPoints[award.TeamID] || 0 yield scores
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
} }
} }
/**
* Calculate the current scores.
*
* @returns {Scores}
*/
CurrentScore() {
let scores
for (scores of this.ScoreHistory());
return scores
}
} }
/** /**
@ -440,6 +542,7 @@ class Server {
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
body, body,
cache: "no-cache",
}) })
} }

View File

@ -131,7 +131,7 @@ function writeObject(e, obj) {
*/ */
async function loadPuzzle(category, points) { async function loadPuzzle(category, points) {
console.groupCollapsed("Loading puzzle:", category, points) console.groupCollapsed("Loading puzzle:", category, points)
let contentBase = new URL(`content/${category}/${points}/`, location) let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
// Tell user we're loading // Tell user we're loading
puzzleElement().appendChild(document.createElement("progress")) puzzleElement().appendChild(document.createElement("progress"))
@ -209,7 +209,7 @@ async function init() {
// Make all links absolute, because we're going to be changing the base URL // Make all links absolute, because we're going to be changing the base URL
for (let e of document.querySelectorAll("[href]")) { for (let e of document.querySelectorAll("[href]")) {
e.href = new URL(e.href, location) e.href = new URL(e.href, common.BaseURL)
} }
let hashpart = location.hash.split("#")[1] || "" let hashpart = location.hash.split("#")[1] || ""

View File

@ -47,6 +47,18 @@
text-align: center; text-align: center;
} }
.location {
color: #acf;
background-color: #0008;
position: fixed;
right: 30vw;
bottom: 0;
padding: 1em;
margin: 0;
font-size: 1.2rem;
font-weight:bold;
text-decoration: underline;
}
.qrcode { .qrcode {
width: 30vw; width: 30vw;
} }
@ -60,23 +72,34 @@
max-width: 40%; max-width: 40%;
} }
/** Scoreboard */
#rankings { #rankings {
width: 100%; width: 100%;
position: relative; position: relative;
background-color: rgba(0, 0, 0, 0.8); background-color: #000c;
}
#rankings div {
height: 1.4rem;
}
#rankings div:nth-child(6n){
background-color: #ccc1;
}
#rankings div:nth-child(6n+3) {
background-color: #0f01;
} }
#rankings span { #rankings span {
font-size: 75%; font-size: 75%;
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
height: 1.7em; height: 1.4em;
} }
#rankings span.teamname { #rankings span.teamname {
height: auto;
font-size: inherit; font-size: inherit;
color: white; color: white;
text-shadow: 0 0 3px black; background-color: #000e;
opacity: 0.8; border-radius: 3px;
position: absolute; position: absolute;
right: 0.2em; right: 0.2em;
} }

View File

@ -10,10 +10,8 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
<script type="module" src="scoreboard.mjs"></script> <script type="module" src="scoreboard.mjs"></script>
</head> </head>
<body class="wide"> <body>
<section class="rotate">
<div id="chart"><canvas></canvas></div>
<div id="rankings"></div> <div id="rankings"></div>
</section> <div class="location"></div>
</body> </body>
</html> </html>

View File

@ -1,267 +1,95 @@
// 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" const server = new moth.Server(".")
// import {DateTime} from "https://cdn.jsdelivr.net/npm/luxon@1.26.0" const ReplayDuration = 0.3 * common.Second
// import "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.1.1" const MaxFrameRate = 60
// Chart.register(...registerables) /** Don't let any team's score exceed this percentage width */
const MaxScoreWidth = 95
const MILLISECOND = 1 /**
const SECOND = 1000 * MILLISECOND * Returns a promise that resolves after timeout.
const MINUTE = 60 * SECOND *
* @param {Number} timeout How long to sleep (milliseconds)
// If all else fails... * @returns {Promise}
setInterval(() => location.reload(), 30 * SECOND)
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(),
})
}
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
}
}
})
}
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 = [] function sleep(timeout) {
for (let i in winners) { return new Promise(resolve => setTimeout(resolve, timeout));
if (i > 5) {
break
} }
let team = winners[i]
let color = chartColors[i % chartColors.length] /**
chart.data.datasets.push({ * Pull new points log, and update the scoreboard.
label: team.name, *
backgroundColor: color, * The update is animated, because I think that looks cool.
borderColor: color, */
data: team.historyLine, async function update() {
lineTension: 0, let config = await common.Config()
fill: false for (let e of document.querySelectorAll(".location")) {
}) e.textContent = common.BaseURL
e.classList.toggle("hidden", !config.URLInScoreboard)
}
let state = await server.GetState()
let rankingsElement = document.querySelector("#rankings")
let logSize = state.PointsLog.length
// 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 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)
} }
chart.update()
window.chart = chart
} }
function init() { function init() {
let base = window.location.href.replace("scoreboard.html", "") setInterval(update, common.Minute)
let location = document.querySelector("#location") update()
if (location) {
location.textContent = base
} }
setInterval(refresh, 20 * SECOND) common.WhenDOMLoaded(init)
refresh()
}
init()
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scoreboardInit)
} else {
scoreboardInit()
}