init with crummy buzzer & docker setup
so much boilerplate :O
This commit is contained in:
commit
f6bf544f54
23 changed files with 746 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!requirements.txt
|
||||
1
.dockerimage
Normal file
1
.dockerimage
Normal file
|
|
@ -0,0 +1 @@
|
|||
tikki/quiz
|
||||
17
.editorconfig
Normal file
17
.editorconfig
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{js,json,yaml,css,html}]
|
||||
indent_size = 2
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"arrowParens": "always",
|
||||
"printWidth": 88,
|
||||
"trailingComma": "all",
|
||||
"semi": false
|
||||
}
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM docker.io/library/python:3.9-alpine
|
||||
|
||||
RUN apk update --no-cache \
|
||||
&& apk upgrade --no-cache \
|
||||
&& pip install --no-cache-dir --upgrade pip
|
||||
|
||||
RUN addgroup -g 10001 py \
|
||||
&& adduser -D -u 10000 -G py py
|
||||
|
||||
USER 10000:10001
|
||||
|
||||
WORKDIR /var/quiz
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade --requirement requirements.txt
|
||||
|
||||
CMD ["python", "-m", "quiz"]
|
||||
2
Procfile
Normal file
2
Procfile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ws: ./run dev-ws
|
||||
http: ./run dev-http
|
||||
3
dev/config.js
Normal file
3
dev/config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
wsurl: "ws://docker.local:8765",
|
||||
}
|
||||
154
played.question.txt
Normal file
154
played.question.txt
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
## TLAs
|
||||
|
||||
L: 1
|
||||
Q: Wofür steht im kulinarischen Kontext die Abkürzung BBQ?
|
||||
A: Barbecue
|
||||
T: kein wirkliches TLA, sondern nur eine Abkürzung
|
||||
T: eine Garmethode, bei der große Fleischstücke in einer Grube oder speziellen Barbecue-Smokern langsam bei mäßiger Temperatur in der heißen Abluft eines Holzfeuers gegart werden
|
||||
T: Das Wort kam ins Englische über das Spanische Wort "barbacoa", wo es zuerst 1526 (als "barbecoa") in einem Wörterbuch abgedruckt wurde. Seinen Ursprung hat es vermutlich im damaligen Haiti, wo barbacoa ein Gerüst aus Stöcken beschreibt das auf Pfosten errichtet wird.
|
||||
|
||||
L: 2
|
||||
Q: Wofür steht in der Welt der freien Software die Abkürzung GNU?
|
||||
A: "GNU's Not Unix!"
|
||||
T: Es handelt sich dabei um ein rekursives Akronym.
|
||||
T: Der Name wurde gewählt, da es sich bei der Software um Unix-ähnliche Programme handelt, die jedoch unter einer eigenen Lizenz stehen und keinen Code von Unix beinhalten.
|
||||
|
||||
L: 3
|
||||
Q: Wofür steht die dieser Kategorie namensgebende Abkürzung TLA?
|
||||
A: three-letter acronym (oder three-letter abbreviation)
|
||||
T: Zum ersten mal in Wissenschaftlicher Literatur beschrieben 1975 im Artikel "Review of The Logic of Social Systems" (im American Journal of Sociology).
|
||||
|
||||
L: 4
|
||||
Q: Wofür steht im Englischen (laut ITU) offiziell die Abkürzung UTC?
|
||||
A: Coordinated Universal Time
|
||||
T: English speakers originally proposed CUT (for "coordinated universal time"), while French speakers proposed TUC (for "temps universel coordonné"). The compromise that emerged was UTC, which conforms to the pattern for the abbreviations of the variants of Universal Time (UT0, UT1, UT2, UT1R, etc.)
|
||||
|
||||
L: 5
|
||||
Q: Wofür steht im Flugverkehr die Abkürzung (genauer, der IATA airport code) BBQ?
|
||||
A: Flughafen in Barbuda, Antigua and Barbuda (Karibik)
|
||||
T: Antigua is Spanish for "ancient" and barbuda is Spanish for "bearded". The island of Antigua was originally called Wadadli by Arawaks and is locally known by that name today; Caribs possibly called Barbuda Wa'omoni. Christopher Columbus, while sailing by in 1493 may have named it Santa Maria la Antigua, after an icon in the Spanish Seville Cathedral. The "bearded" of Barbuda is thought to refer either to the male inhabitants of the island, or the bearded fig trees present there.
|
||||
|
||||
|
||||
## "A rose by any other name"
|
||||
|
||||
L: 1
|
||||
Q: Zu welcher Pflanzengattung gehören die Arten Robusta und Arabica?
|
||||
A: Kaffee
|
||||
|
||||
L: 2
|
||||
Q: Welche Pflanze ist auch als Eierpflanze, oder Melanzani bekannt?
|
||||
A: Aubergine (auch Eierfrucht)
|
||||
|
||||
L: 3
|
||||
Q: Wie nennt man üblicherweise die Pflanze, die auch als Augenblümchen, Maßliebchen, Mondscheinblume, oder Tausendschön bekannt ist?
|
||||
A: Gänseblümchen
|
||||
|
||||
L: 4
|
||||
Q: Welche Pflanze ist auch als Nelkenpfeffer, Modegewürz, oder Allgewürz bekannt?
|
||||
A: Piment (auch Viergewürz, Jamaikapfeffer, Neugewürz, Gewürzkörner)
|
||||
|
||||
L: 5
|
||||
Q: Welche Pflanze ist auch als Silk, oder früher auch als Felsensilge und Steineppich bekannt?
|
||||
A: Petersilie (auch Peterle, Peterli, Peterling, Petergrün)
|
||||
|
||||
|
||||
## Pixelhelden
|
||||
|
||||
L: 1
|
||||
Q: Wie heißt der stumme Protagonist aus Half-Life?
|
||||
A: Gordon Freeman
|
||||
|
||||
L: 2
|
||||
Q: Wie heißen die beiden spielbaren Charaktere in Donkey Kong Country 2 (SNES, 1995)?
|
||||
A: Diddy Kong, Dixie Kong
|
||||
|
||||
L: 3
|
||||
Q: Wie heißt der spielbare Protagonist aus Assassin's Creed II?
|
||||
A: Ezio Auditore (De Firenze)
|
||||
|
||||
L: 4
|
||||
Q: Welche vollen Namen haben die beiden aus diversen Videospielserien bekannten Klempner-Brüder (laut dem 1993 erschienenen Hollywood-Film)?
|
||||
A: Mario Mario und Luigi Mario
|
||||
T: The first notable use of "Mario Mario" is in the 1993 live-action film adaptation. This was again used in two of Prima's official strategy guides. In 2012 Satoru Iwata said he had no last name, but two months after Iwata's death in July 2015, Miyamoto changed his stance, asserting that Mario's full name was indeed "Mario Mario".
|
||||
|
||||
L: 5
|
||||
Q: Wie heißt der Charakter, der in den ersten beiden Teilen einer langjährigen Videospieleserie von einem Regenwurm in einem Exoskelett gerettet werden muss?
|
||||
A: Princess What's-Her-Name (aus der Serie Eearthworm Jim)
|
||||
|
||||
|
||||
## Einheitsbrei
|
||||
|
||||
L: 1
|
||||
Q: Wie groß ist eine Astronomische Einheit?
|
||||
A: Mittlere Abstand zwischen Erde und Sonne (ca. 150 Millionen Kilometer; seit 2012 definiert als 149_597_870,7 km)
|
||||
|
||||
L: 2
|
||||
Q: Wieviele Kubikdezimeter entsprechen einem Liter?
|
||||
A: 1 dm3
|
||||
|
||||
L: 3
|
||||
Q: Wieviele Millimeter (oder Inches) entsprechen einem (Pica-)Punkt (im Kontext der Typografie)?
|
||||
A: 1/72 Inch (≈ 0,353 mm) (zuvor auf Basis des Franzosen Didot ≈ 0,376 mm)
|
||||
T: Die größe eines Punktes war nicht eindeutig festgelegt, bis in den 1980ern durch die computergestützte Typografie der DTP Punkt (DeskTop Publishing Punkt) auf exakt 1/72 Inch (1/72 ⋅ 25.4 mm ≈ 0.353 mm) standardisiert wurde.
|
||||
T: Beruht ursprünglich auf der Teilung des damaligen französischen Längenmaßes, dem Pariser Fuß (1 Fuß = 12 Zoll = 144 Linien = 864 Punkt), durch den Schriftgießer Firmin Didot, der 1780 diese kleinste typografische Einheit so festlegte.
|
||||
|
||||
L: 4
|
||||
Q: Wieviele Teilchen eines Stoffes sind in einem Mol enthalten?
|
||||
A: ca. 602 Trilliarden, genau 6,02214076e23 (oder auch der seit 2019 exakt definierte Wert der Avogadro-Zahl)
|
||||
|
||||
L: 5
|
||||
Q: Ungefähr wieviele Lichtjahre entsprechen einem Parsec?
|
||||
A: 3(,26) (oder 206_000 AU, oder 30,9 Billionen Kilometer)
|
||||
T: Ein Parsec ist die Entfernung, aus welcher der mittlere Abstand zwischen Sonne und Erde (= 1 AE/AU), unter einem Winkel von einer Bogensekunde erscheint.
|
||||
|
||||
|
||||
## EETLA
|
||||
|
||||
L: 1
|
||||
Q: Wofür steht in der Aufklärungstechnik die Abkürzung RADAR?
|
||||
A: RAdio Detection And Ranging
|
||||
|
||||
L: 2
|
||||
Q: Wofür steht in der Aufklärungstechnik die Abkürzung SONAR?
|
||||
A: sound navigation and ranging
|
||||
|
||||
L: 3
|
||||
Q: Wofür steht in der Physik die Abkürzung LASER?
|
||||
A: light amplification by stimulated emission of radiation
|
||||
|
||||
L: 4
|
||||
Q: Wofür steht die Abkürzung des jährlich stattfindenden Programmierwettbewerbs IOCCC?
|
||||
A: International Obfuscated C Code Contest
|
||||
|
||||
L: 5
|
||||
Q: Wofür steht in der Waffentechnik die Abkürzung TASER?
|
||||
A: Tom Swift and His Electric Rifle
|
||||
T: Es handelt sich dabei korrekterweise nicht um ein Akronym. Der Erfinder Jack Cover nutzte einfach den Namen des Buchs ("Tom Swift and His Electric Rifle") als Grundlage, um eine Referenz auf seinen fiktiven Held aus Kindertagen, Tom Swift, zu verewigen.
|
||||
|
||||
|
||||
## Sol-Geeier
|
||||
|
||||
L: 1
|
||||
Q: Wie groß ist die Rotationsperiode der Erde?
|
||||
A: 1 Tag (23 h 56 min 4 s)
|
||||
|
||||
L: 2
|
||||
Q: Wie heißt der größte Planet unseres Sonnensystems?
|
||||
A: Jupiter
|
||||
|
||||
L: 3
|
||||
Q: Wieviele Monde hat der Mars?
|
||||
A: 2 (Phobos und Deimos)
|
||||
T: Die Namen Phobos und Deimos sind griechisch und bedeuten Furcht und Schrecken.
|
||||
|
||||
L: 4
|
||||
Schätzfrage!
|
||||
Q: Wieviel Kelvin beträgt die (effektive) Oberflächentemperatur der Sonne?
|
||||
A: 5_778 K
|
||||
|
||||
L: 5
|
||||
Q: Wie heißt das größte uns bekannte Objekt zwischen den Planetenbahnen von Mars und Jupiter?
|
||||
A: Ceres
|
||||
T: Ceres ist mit einem Äquatordurchmesser von 964 km der kleinste (von der IAU) als Zwergplanet klassifizierte Himmelskörper und das größte Objekt im Asteroidengürtel. Im ersten halben Jahrhundert nach ihrer Entdeckung wurde sie als (damals achter) Planet, später dann als Asteroid eingestuft.
|
||||
T: Sie ist nach der römischen Göttin des Ackerbaus benannt und wurde am 1. Januar 1801 an der Sternwarte Palermo entdeckt.
|
||||
|
||||
50
public/buzzer.css
Normal file
50
public/buzzer.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
input {
|
||||
width: 20em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1em;
|
||||
}
|
||||
ul {
|
||||
/*list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;*/
|
||||
}
|
||||
li.me::after {
|
||||
content: " — that's you!";
|
||||
font-style: italic;
|
||||
}
|
||||
li.buzzing {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
font-size: 2em;
|
||||
list-style-type: none;
|
||||
}
|
||||
li.buzzing.first::before {
|
||||
content: "🥇 ";
|
||||
}
|
||||
li.buzzing.too-late {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
li.buzzing.too-late::before {
|
||||
content: "🥈 ";
|
||||
}
|
||||
#info {
|
||||
position: absolute;
|
||||
}
|
||||
#buzzbox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
/*position: absolute;*/
|
||||
}
|
||||
#active {
|
||||
color: red;
|
||||
display: none;
|
||||
}
|
||||
23
public/buzzer.html
Normal file
23
public/buzzer.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" type="text/css" href="buzzer.css" />
|
||||
<body>
|
||||
<div id="info">
|
||||
<label
|
||||
>You:
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
placeholder="Please put your name here ..."
|
||||
/></label>
|
||||
<h2>All Players</h2>
|
||||
<ul></ul>
|
||||
</div>
|
||||
<div id="buzzbox">
|
||||
<p id="active">BZZZZZ!</p>
|
||||
<p id="ready">Press <strong id="bkey">{key.name}</strong> to activate buzzer.</p>
|
||||
<p id="inactive">Please focus the window to allow the buzzer to work.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="module" src="./buzzer.js"></script>
|
||||
226
public/buzzer.js
Normal file
226
public/buzzer.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"use strict"
|
||||
|
||||
// TODOs
|
||||
// - measure/report latency
|
||||
// - use server reported time to find winner
|
||||
|
||||
import config from "./config.js"
|
||||
|
||||
const buzzer_key = {
|
||||
code: 0x20,
|
||||
name: "space bar",
|
||||
}
|
||||
|
||||
const q = (selector, root) => (root || document).querySelector(selector)
|
||||
const on = (event, cb) => document.addEventListener(event, cb)
|
||||
function node(type, { appendTo, cls, text, data, ...attrs } = {}) {
|
||||
let elem = document.createElement(type)
|
||||
if (cls) {
|
||||
elem.className = cls
|
||||
}
|
||||
if (text) {
|
||||
elem.textContent = text
|
||||
}
|
||||
for (const name in data ?? {}) {
|
||||
elem.dataset[name] = data[name]
|
||||
}
|
||||
for (const name in attrs) {
|
||||
elem.setAttribute(name, attrs[name])
|
||||
}
|
||||
if (appendTo) {
|
||||
elem = appendTo.appendChild(elem)
|
||||
}
|
||||
return elem
|
||||
}
|
||||
|
||||
/**
|
||||
* Some duration conversion constants.
|
||||
*/
|
||||
const ms_ns = 1_000_000 // nanoseconds in a millisecond
|
||||
const s_ms = 1_000 // milliseconds in a second
|
||||
const s_ns = 1_000_000_000 // nanoseconds in a second
|
||||
|
||||
let socket,
|
||||
servertime,
|
||||
toffset_ms,
|
||||
clients = [],
|
||||
me
|
||||
|
||||
function hide(e) {
|
||||
q(`#${e}`).style.display = "none"
|
||||
}
|
||||
|
||||
function show(e) {
|
||||
q(`#${e}`).style.display = "block"
|
||||
}
|
||||
|
||||
function session_id() {
|
||||
const match = /^#?(.+)/.exec(document.location.hash)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function new_session_id() {
|
||||
if (!window.crypto) {
|
||||
return Math.random().toString(36).substr(2)
|
||||
}
|
||||
const data = new Uint8Array(10)
|
||||
crypto.getRandomValues(data)
|
||||
return Array.from(data, (v) => v.toString(36)).join("")
|
||||
}
|
||||
|
||||
function setup_url() {
|
||||
const sid = session_id() || new_session_id()
|
||||
document.location.hash = sid
|
||||
}
|
||||
|
||||
function send(type, value) {
|
||||
// console.debug('sending', value)
|
||||
socket.send(JSON.stringify({ type, value }))
|
||||
}
|
||||
|
||||
function clear(container) {
|
||||
while (container.children.length > 0) {
|
||||
const child = container.children[0]
|
||||
child.remove()
|
||||
}
|
||||
}
|
||||
|
||||
let ul
|
||||
function redraw_clients(me, clients) {
|
||||
if (!me) {
|
||||
return
|
||||
}
|
||||
clear(ul)
|
||||
for (const c of clients) {
|
||||
node("li", {
|
||||
text: c.name,
|
||||
data: { cid: c.id },
|
||||
appendTo: ul,
|
||||
cls: c.id === me ? "me" : "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = {}
|
||||
function highlight(client_id, until_ns) {
|
||||
if (highlights[client_id]) {
|
||||
return
|
||||
}
|
||||
const timeout_ms = (until_ns - servertime_now_ns()) / ms_ns
|
||||
if (timeout_ms <= 10) {
|
||||
console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms)
|
||||
return
|
||||
}
|
||||
for (const li of ul.children) {
|
||||
if (li.dataset.cid === client_id) {
|
||||
li.classList.add("buzzing")
|
||||
if (Object.keys(highlights).length) {
|
||||
li.classList.add("too-late")
|
||||
} else {
|
||||
li.classList.add("first")
|
||||
}
|
||||
highlights[client_id] = setTimeout(() => {
|
||||
delete highlights[client_id]
|
||||
li.classList.remove("buzzing")
|
||||
li.classList.remove("too-late")
|
||||
li.classList.remove("first")
|
||||
}, timeout_ms)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess the exact current server time.
|
||||
*/
|
||||
function servertime_now_ns() {
|
||||
const now_ms = performance.now()
|
||||
const delta_ns = ms_ns * (now_ms - toffset_ms)
|
||||
return servertime + delta_ns
|
||||
}
|
||||
|
||||
function setup_ui() {
|
||||
on("focus", (event) => {
|
||||
hide("active")
|
||||
hide("inactive")
|
||||
show("ready")
|
||||
})
|
||||
on("blur", (event) => {
|
||||
hide("active")
|
||||
show("inactive")
|
||||
hide("ready")
|
||||
})
|
||||
if (document.hasFocus()) {
|
||||
hide("inactive")
|
||||
} else {
|
||||
hide("ready")
|
||||
}
|
||||
|
||||
q("#bkey").textContent = buzzer_key.name
|
||||
let buzzing = false
|
||||
on("keydown", (event) => {
|
||||
if (!buzzing && event.keyCode === buzzer_key.code) {
|
||||
buzzing = true
|
||||
send("buzz", servertime_now_ns())
|
||||
show("active")
|
||||
hide("ready")
|
||||
}
|
||||
})
|
||||
on("keyup", (event) => {
|
||||
if (event.keyCode === buzzer_key.code) {
|
||||
buzzing = false
|
||||
hide("active")
|
||||
show("ready")
|
||||
}
|
||||
})
|
||||
|
||||
q("#username").addEventListener("change", (event) => {
|
||||
send("name", event.target.value)
|
||||
})
|
||||
|
||||
ul = q("#info ul")
|
||||
}
|
||||
|
||||
function setup_ws() {
|
||||
const sid = session_id()
|
||||
socket = new WebSocket(`${config.wsurl}/quiz/${sid}`)
|
||||
socket.addEventListener("open", function (event) {
|
||||
send("name", q("#username").value)
|
||||
})
|
||||
socket.addEventListener("message", function (event) {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "time") {
|
||||
servertime = msg.value
|
||||
toffset_ms = performance.now()
|
||||
} else if (msg.type === "id") {
|
||||
me = msg.value
|
||||
redraw_clients(me, clients)
|
||||
} else if (msg.type === "buzz") {
|
||||
const buzztime_ns = msg.time
|
||||
const client_id = msg.client
|
||||
const duration_ns = 3 * s_ns
|
||||
const until_ns = buzztime_ns + duration_ns
|
||||
highlight(client_id, until_ns)
|
||||
} else if (msg.type === "clients") {
|
||||
clients = msg.value
|
||||
redraw_clients(me, clients)
|
||||
} else if (msg.type === "client") {
|
||||
const client = msg.value
|
||||
for (const c of clients) {
|
||||
if (c.id === client.id) {
|
||||
c.name = client.name
|
||||
redraw_clients(me, clients)
|
||||
return
|
||||
}
|
||||
}
|
||||
clients.push(client)
|
||||
redraw_clients(me, clients)
|
||||
} else {
|
||||
console.error(`Unknown message: ${event.data}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setup_url()
|
||||
setup_ui()
|
||||
setup_ws()
|
||||
3
public/config.js
Normal file
3
public/config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
wsurl: "wss://quiz.dumpr.org:443",
|
||||
}
|
||||
7
public/index.html
Normal file
7
public/index.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<ul>
|
||||
<li><a href="admin.html">admin</a></li>
|
||||
<li><a href="buzzer.html">buzzer</a></li>
|
||||
<li><a href="matrix.html">matrix</a></li>
|
||||
<li><a href="monitor.html">monitor</a></li>
|
||||
</ul>
|
||||
0
quiz/__init__.py
Normal file
0
quiz/__init__.py
Normal file
20
quiz/__main__.py
Normal file
20
quiz/__main__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from .quiz import server
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
format="{asctime},{msecs:03.0f} [{name}:{process}] {levelname}: {message}",
|
||||
style="{",
|
||||
datefmt="%Y-%m-%d %H:%M:%a",
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(server())
|
||||
asyncio.get_event_loop().run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
140
quiz/quiz.py
Normal file
140
quiz/quiz.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from json import dumps, loads
|
||||
from secrets import token_hex
|
||||
from time import perf_counter_ns
|
||||
from typing import *
|
||||
|
||||
import websockets
|
||||
|
||||
Websocket = websockets.WebSocketServerProtocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class Client:
|
||||
ws: Websocket
|
||||
path: str
|
||||
id: str = field(default_factory=lambda: token_hex(8))
|
||||
name: str = ""
|
||||
|
||||
def __str__(self):
|
||||
return f"{ascii(self.id)[1:-1]}:{ascii(self.name)[1:-1]}"
|
||||
|
||||
|
||||
sessions: dict[str, dict[str, Client]] = defaultdict(dict)
|
||||
|
||||
log = logging.getLogger("buzzer")
|
||||
|
||||
|
||||
async def send_time(client):
|
||||
await client.ws.send(dumps({"type": "time", "value": perf_counter_ns()}))
|
||||
|
||||
|
||||
async def send_buzz(target, client, time):
|
||||
await target.ws.send(dumps({"type": "buzz", "client": client.id, "time": time}))
|
||||
|
||||
|
||||
async def send_clients(client):
|
||||
clients = [
|
||||
{"name": c.name or "<noname>", "id": c.id}
|
||||
for c in sessions[client.path].values()
|
||||
]
|
||||
await client.ws.send(dumps({"type": "clients", "value": clients}))
|
||||
|
||||
|
||||
async def wait(coros, **kwds):
|
||||
"""Schedule and wait for the given coroutines to complete."""
|
||||
tasks = [asyncio.create_task(f) for f in coros]
|
||||
if not tasks:
|
||||
return
|
||||
return await asyncio.wait(tasks, **kwds)
|
||||
|
||||
|
||||
async def broadcast_client(client):
|
||||
msg = dumps(
|
||||
{
|
||||
"type": "client",
|
||||
"value": {"name": client.name or "<noname>", "id": client.id},
|
||||
}
|
||||
)
|
||||
await wait(c.ws.send(msg) for c in sessions[client.path].values())
|
||||
|
||||
|
||||
async def broadcast_clients(client):
|
||||
await wait(send_clients(c) for c in sessions[client.path].values())
|
||||
|
||||
|
||||
async def broadcast_buzz(client, time):
|
||||
await wait(send_buzz(c, client, time) for c in sessions[client.path].values())
|
||||
|
||||
|
||||
async def send_id(client):
|
||||
await client.ws.send(dumps({"type": "id", "value": client.id}))
|
||||
|
||||
|
||||
async def send_hello(client):
|
||||
await wait([send_time(client), send_id(client), send_clients(client)])
|
||||
|
||||
|
||||
async def send_heartbeat(client):
|
||||
await asyncio.sleep(5.0)
|
||||
await send_time(client)
|
||||
|
||||
|
||||
def printable(s: str) -> str:
|
||||
# See https://www.unicode.org/versions/Unicode13.0.0/ch04.pdf "Table 4-4."
|
||||
return "".join(c for c in s if not unicodedata.category(c).startswith("C"))
|
||||
|
||||
|
||||
async def handle_messages(client):
|
||||
async for message in client.ws:
|
||||
log.debug("[%s] got a message: %a", client, message)
|
||||
mdata = loads(message)
|
||||
if mdata["type"] == "buzz":
|
||||
time = mdata["value"]
|
||||
log.info("[%s] buzz: %a", client, time)
|
||||
# todo: check time against perf_counter_ns
|
||||
await broadcast_buzz(client, time)
|
||||
elif mdata["type"] == "name":
|
||||
name = printable(mdata["value"])
|
||||
log.info("[%s] new name: %a", client, name)
|
||||
client.name = name
|
||||
await broadcast_client(client)
|
||||
else:
|
||||
log.error("[%s] received borked message", client)
|
||||
|
||||
|
||||
async def juggle(client):
|
||||
while client.ws.open:
|
||||
done, pending = await wait(
|
||||
[send_heartbeat(client), handle_messages(client)],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def connected(ws: Websocket, path: str):
|
||||
if not path.startswith("/quiz/"):
|
||||
await ws.close()
|
||||
return
|
||||
client = Client(ws, path)
|
||||
log.info("[%s] new client on %a", client, path)
|
||||
sessions[path][client.id] = client
|
||||
try:
|
||||
await send_hello(client)
|
||||
await juggle(client)
|
||||
finally:
|
||||
log.info("[%s] client disconnected", client)
|
||||
del sessions[path][client.id]
|
||||
await broadcast_clients(client)
|
||||
# Clean up sessions map
|
||||
if not sessions[path]:
|
||||
del sessions[path]
|
||||
|
||||
|
||||
def server(host="0.0.0.0", port=8765):
|
||||
return websockets.serve(connected, host, port)
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
websockets>=8.1
|
||||
14
run
Executable file
14
run
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
RUN_BIN=$(realpath "$0")
|
||||
RUN_DIR=$(dirname "$RUN_BIN")
|
||||
|
||||
export RUN_BIN
|
||||
export RUN_DIR
|
||||
|
||||
task="$1"
|
||||
shift
|
||||
|
||||
set -x
|
||||
|
||||
exec "$RUN_DIR"/scripts/"$task" "$@"
|
||||
9
scripts/build
Executable file
9
scripts/build
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
dockerdir="$RUN_DIR"
|
||||
image=$(cat "$dockerdir"/.dockerimage)
|
||||
tag=latest
|
||||
|
||||
set -x
|
||||
|
||||
docker build "$@" --tag "$image":"$tag" "$dockerdir"
|
||||
9
scripts/dev
Executable file
9
scripts/dev
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"
|
||||
|
||||
set -x
|
||||
|
||||
"$RUN_BIN" build
|
||||
|
||||
exec honcho start
|
||||
20
scripts/dev-http
Executable file
20
scripts/dev-http
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
image=$(cat "$RUN_DIR"/.dockerimage)
|
||||
tag=latest
|
||||
|
||||
pubport=8080
|
||||
|
||||
set -x
|
||||
|
||||
# TODO would be better to mount dev config somewhere else & merge in code
|
||||
|
||||
exec docker run --init --name dumpr-quiz-http \
|
||||
--rm \
|
||||
--read-only \
|
||||
--label org.dumpr.quiz.service=http \
|
||||
-p "$pubport":8000 \
|
||||
-v "$RUN_DIR":/var/quiz:ro \
|
||||
-v "$RUN_DIR"/dev/config.js:/var/quiz/public/config.js:ro \
|
||||
"$image":"$tag" \
|
||||
python -m http.server -d public/ 8000
|
||||
14
scripts/dev-ws
Executable file
14
scripts/dev-ws
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
image=$(cat "$RUN_DIR"/.dockerimage)
|
||||
tag=latest
|
||||
|
||||
set -x
|
||||
|
||||
exec docker run --init --name dumpr-quiz-ws \
|
||||
--rm \
|
||||
--read-only \
|
||||
--label org.dumpr.quiz.service=ws \
|
||||
-p 8765:8765 \
|
||||
-v "$RUN_DIR":/var/quiz:ro \
|
||||
"$image":"$tag" "$@"
|
||||
7
scripts/lint
Executable file
7
scripts/lint
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh -eux
|
||||
|
||||
autoflake --in-place --recursive --remove-duplicate-keys --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports "$RUN_DIR"
|
||||
black "$RUN_DIR"
|
||||
isort --profile black "$RUN_DIR"
|
||||
prettier --write "$RUN_DIR"/public
|
||||
shellcheck "$RUN_DIR"/scripts/*
|
||||
Loading…
Add table
Add a link
Reference in a new issue