Compare commits

..

No commits in common. "768600e48e6f869c0a62e7c3281c2f3d6d40840f" and "c72d13af327eedb353a327234cb074d405a30d79" have entirely different histories.

13 changed files with 367 additions and 382 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
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest - We are now using djb2xor instead of sha256 to hash puzzle answers
- Reworked the built-in theme - Lots of work on 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

View File

@ -1,73 +0,0 @@
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/sha1" "crypto/sha256"
"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 := sha1.Sum([]byte(answer)) sum := sha256.Sum256([]byte(answer))
hexsum := fmt.Sprintf("%x", sum) hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum[:4] puzzle.AnswerHashes[i] = hexsum
} }
} }

View File

@ -23,12 +23,6 @@ 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,7 +113,36 @@ input:invalid {
cursor: help; cursor: help;
} }
/** Development mode information */ /** Scoreboard */
#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;
@ -174,9 +203,11 @@ 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,9 +5,6 @@ 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.
* *
@ -56,29 +53,11 @@ 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,5 +1,4 @@
{ {
"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,10 +6,17 @@ 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))
} }
@ -67,7 +74,8 @@ class App {
* load, since configuration should (hopefully) change less frequently. * load, since configuration should (hopefully) change less frequently.
*/ */
async UpdateConfig() { async UpdateConfig() {
this.config = await common.Config() let resp = await fetch(this.configURL)
this.config = await resp.json()
} }
/** /**
@ -97,10 +105,9 @@ 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: ${teamID}`) console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
return this.Login(teamID, `Team ${teamID}`) return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName)
} }
} }
@ -143,7 +150,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", common.BaseURL) let url = new URL("puzzle.html", window.location)
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 * Dan Bernstein hash with xor improvement
* *
* @param {string} buf Input * @param {string} buf Input
* @returns {number} * @returns {number}
@ -49,30 +49,15 @@ class Hash {
return this.hexlify(hashArray); return this.hexlify(hashArray);
} }
/** /**
* SHA 1, but only the first 4 hexits (2 octets). * Hex-encode a byte array
* *
* Git uses this technique with 7 hexits (default) as a "short identifier". * @param {number[]} buf Byte array
* * @returns {string}
* @param {string} buf Input */
*/ static hexlify(buf) {
static async sha1_slice(buf, end=4) { return buf.map(b => b.toString(16).padStart(2, "0")).join("")
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. * Apply every hash to the input buffer.
@ -83,8 +68,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),
] ]
} }
} }
@ -238,111 +223,6 @@ 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.
*/ */
@ -472,31 +352,49 @@ class State {
} }
/** /**
* Replay scores. * Map from team ID to points.
* *
* MOTH has no notion of who is "winning", we consider this a user interface * A special "max" property contains the highest number of points in this map.
* decision. There are lots of interesting options: see *
* [scoring]{@link ../docs/scoring.md} for more. * @typedef {Object.<string, number>} TeamPointsDict
* * @property {Number} max Highest number of points
* @yields {Scores} Snapshot at a point in time
*/ */
* ScoreHistory() {
let scores = new Scores()
for (let award of this.PointsLog) {
scores.Add(award)
yield scores
}
}
/** /**
* Calculate the current scores. * Map from category to PointsDict.
* *
* @returns {Scores} * @typedef {Object.<string, TeamPointsDict>} CategoryTeamPointsDict
*/ */
CurrentScore() {
let scores /**
for (scores of this.ScoreHistory()); * Score snapshot.
return scores *
* @typedef {Object} ScoreSnapshot
* @property {number} when Epoch time of this snapshot
* @property {CategoryTeamPointsDict} snapshot
*/
/**
* Replay scores.
*
* @yields {ScoreSnapshot} Snapshot at a point in time
*/
* ScoreHistory() {
/** @type {CategoryTeamPointsDict} */
let categoryTeamPoints = {}
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
}
} }
} }
@ -542,7 +440,6 @@ 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}/`, common.BaseURL) let contentBase = new URL(`content/${category}/${points}/`, location)
// 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, common.BaseURL) e.href = new URL(e.href, location)
} }
let hashpart = location.hash.split("#")[1] || "" let hashpart = location.hash.split("#")[1] || ""

View File

@ -47,18 +47,6 @@
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;
} }
@ -72,34 +60,23 @@
max-width: 40%; max-width: 40%;
} }
/** Scoreboard */
#rankings { #rankings {
width: 100%; width: 100%;
position: relative; position: relative;
background-color: #000c; background-color: rgba(0, 0, 0, 0.8);
}
#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.4em; height: 1.7em;
} }
#rankings span.teamname { #rankings span.teamname {
height: auto;
font-size: inherit; font-size: inherit;
color: white; color: white;
background-color: #000e; text-shadow: 0 0 3px black;
border-radius: 3px; opacity: 0.8;
position: absolute; position: absolute;
right: 0.2em; right: 0.2em;
} }

View File

@ -10,8 +10,10 @@
<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> <body class="wide">
<div id="rankings"></div> <section class="rotate">
<div class="location"></div> <div id="chart"><canvas></canvas></div>
<div id="rankings"></div>
</section>
</body> </body>
</html> </html>

View File

@ -1,95 +1,267 @@
import * as moth from "./moth.mjs" // jshint asi:true
import * as common from "./common.mjs"
const server = new moth.Server(".") // import { Chart, registerables } from "https://cdn.jsdelivr.net/npm/chart.js@3.0.2"
const ReplayDuration = 0.3 * common.Second // import {DateTime} from "https://cdn.jsdelivr.net/npm/luxon@1.26.0"
const MaxFrameRate = 60 // import "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.1.1"
/** Don't let any team's score exceed this percentage width */ // Chart.register(...registerables)
const MaxScoreWidth = 95
/** const MILLISECOND = 1
* Returns a promise that resolves after timeout. const SECOND = 1000 * MILLISECOND
* const MINUTE = 60 * SECOND
* @param {Number} timeout How long to sleep (milliseconds)
* @returns {Promise}
*/
function sleep(timeout) {
return new Promise(resolve => setTimeout(resolve, timeout));
}
/** // If all else fails...
* Pull new points log, and update the scoreboard. setInterval(() => location.reload(), 30 * SECOND)
*
* The update is animated, because I think that looks cool.
*/
async function update() {
let config = await common.Config()
for (let e of document.querySelectorAll(".location")) {
e.textContent = common.BaseURL
e.classList.toggle("hidden", !config.URLInScoreboard)
}
let state = await server.GetState() function scoreboardInit() {
let rankingsElement = document.querySelector("#rankings") let chartColors = [
let logSize = state.PointsLog.length "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)"
]
// Figure out the timing so that we can replay the scoreboard in about for (let q of document.querySelectorAll("[data-url]")) {
// ReplayDuration, but no more than 24 frames per second. let url = new URL(q.dataset.url, document.location)
let frameModulo = 1 q.textContent = url.hostname
let delay = 0 if (url.port) {
while (delay < (common.Second / MaxFrameRate)) { q.textContent += `:${url.port}`
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
} }
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(),
})
}
while (rankingsElement.firstChild) rankingsElement.firstChild.remove() let chart
let canvas = document.querySelector("#chart canvas")
let sortedTeamIDs = [...scores.TeamIDs] if (canvas) {
sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b)) chart = new Chart(canvas.getContext("2d"), {
sortedTeamIDs.reverse() type: "line",
options: {
let topScore = scores.CyFiScore(sortedTeamIDs[0]) responsive: true,
for (let teamID of sortedTeamIDs) { scales: {
let teamName = state.TeamNames[teamID] x: {
type: "time",
let row = rankingsElement.appendChild(document.createElement("div")) time: {
// XXX: the manual says this should do something, it does something in the samples, IDK
let heading = row.appendChild(document.createElement("span")) tooltipFormat: "HH:mm"
heading.textContent = teamName },
heading.classList.add("teamname") title: {
display: true,
let categoryNumber = 0 text: "Time"
for (let category of scores.Categories) { }
let score = scores.CyFiCategoryScore(category, teamID) },
if (!score) { y: {
continue title: {
display: true,
text: "Points"
}
}
},
tooltips: {
mode: "index",
intersect: false
},
hover: {
mode: "nearest",
intersect: true
} }
}
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()
} }
function init() { if (document.readyState === "loading") {
setInterval(update, common.Minute) document.addEventListener("DOMContentLoaded", scoreboardInit)
update() } else {
scoreboardInit()
} }
common.WhenDOMLoaded(init)