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
### Changed
- We are now using djb2xor instead of sha256 to hash puzzle answers
- Lots of work on the built-in theme
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
- Reworked the built-in theme
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- 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"
"bytes"
"context"
"crypto/sha256"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
@ -85,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
}
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer))
sum := sha1.Sum([]byte(answer))
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") {
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") {
t.Error("Authors are wrong", p.Authors)
}

View File

@ -113,36 +113,7 @@ input:invalid {
cursor: help;
}
/** 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;}
/** Development mode information */
.debug {
overflow: auto;
padding: 1em;
@ -203,11 +174,9 @@ li[draggable] {
}
@media (prefers-color-scheme: light) {
/*
* This uses the alpha channel to apply hue tinting to elements, to get a
/* We 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
* things to change between light and dark mode.
*
*/
body {
background-color: #b9cbd8;

View File

@ -5,6 +5,9 @@ const Millisecond = 1
const Second = Millisecond * 1000
const Minute = Second * 60
/** URL to the top of this MOTH server */
const BaseURL = new URL(".", location)
/**
* Display a transient message to the user.
*
@ -53,11 +56,29 @@ function Truthy(s) {
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 {
Millisecond,
Second,
Minute,
BaseURL,
Toast,
WhenDOMLoaded,
Truthy,
Config,
}

View File

@ -1,4 +1,5 @@
{
"TrackSolved": true,
"URLInScoreboard": true,
"__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 {
constructor(basePath=".") {
this.configURL = new URL("config.json", location)
this.config = {}
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")) {
form.addEventListener("submit", event => this.handleLoginSubmit(event))
}
@ -74,8 +67,7 @@ class App {
* load, since configuration should (hopefully) change less frequently.
*/
async UpdateConfig() {
let resp = await fetch(this.configURL)
this.config = await resp.json()
this.config = await common.Config()
}
/**
@ -105,9 +97,10 @@ class App {
}
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
let teamID = Math.floor(Math.random() * 1000000).toString(16)
common.Toast("Automatically logging in to devel server")
console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName)
console.info(`Logging in with generated Team ID: ${teamID}`)
return this.Login(teamID, `Team ${teamID}`)
}
}
@ -150,7 +143,7 @@ class App {
for (let puzzle of this.state.Puzzles(cat)) {
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}`
let a = i.appendChild(document.createElement("a"))
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
* @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
}
}
/**
@ -440,6 +542,7 @@ class Server {
return fetch(url, {
method: "POST",
body,
cache: "no-cache",
})
}

View File

@ -131,7 +131,7 @@ function writeObject(e, obj) {
*/
async function loadPuzzle(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
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
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] || ""

View File

@ -47,6 +47,18 @@
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 {
width: 30vw;
}
@ -60,23 +72,34 @@
max-width: 40%;
}
/** Scoreboard */
#rankings {
width: 100%;
position: relative;
background-color: rgba(0, 0, 0, 0.8);
position: relative;
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 {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.7em;
height: 1.4em;
}
#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

@ -10,10 +10,8 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
<script type="module" src="scoreboard.mjs"></script>
</head>
<body class="wide">
<section class="rotate">
<div id="chart"><canvas></canvas></div>
<div id="rankings"></div>
</section>
<body>
<div id="rankings"></div>
<div class="location"></div>
</body>
</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"
// 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 = 0.3 * common.Second
const MaxFrameRate = 60
/** 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)
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(),
})
/**
* Pull new points log, and update the scoreboard.
*
* 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 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 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)
}
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)