From b00f8a357c0681ee075e5fa779ee9c315c8902c3 Mon Sep 17 00:00:00 2001 From: ducklet Date: Mon, 1 Feb 2021 23:07:49 +0100 Subject: [PATCH] add support for setting points, tokens, and broadcasting control msgs Control messages are messages that are broadcast to all clients and have no clearly defined content. The idea is that this can be used to control a monitor without having to keep adding support for specific commands on the protocol layer. This also changes some of the existing messages and adds another ridiculous convenience layer to our HTML/JS templating: data-eval. We should probably just bite the bullet and use some reactive framework. --- public/buzzer.css | 14 +++- public/buzzer.html | 43 +++++++++-- public/buzzer.js | 175 ++++++++++++++++++++++++++++++++++----------- quiz/quiz.py | 75 ++++++++++++++----- 4 files changed, 242 insertions(+), 65 deletions(-) diff --git a/public/buzzer.css b/public/buzzer.css index 6ba0fe9..36cdd03 100644 --- a/public/buzzer.css +++ b/public/buzzer.css @@ -2,6 +2,13 @@ body { font-family: sans-serif; overflow: hidden; } +body.admin #info { + position: unset; +} +body.player .admin-only, +body.admin .player-only { + display: none !important; +} #error { background-color: #fee; border: 3px solid red; @@ -9,6 +16,9 @@ body { font-family: monospace; display: none; } +body.error #error { + display: block; +} #error code { white-space: pre; } @@ -51,6 +61,9 @@ ul { .player.buzzing.too-late::before { content: "🥈 "; } +.player div.admin-only { + display: inline-block; +} #info { position: absolute; } @@ -60,7 +73,6 @@ ul { align-items: center; width: 100%; height: 90%; - /*position: absolute;*/ } #active { color: red; diff --git a/public/buzzer.html b/public/buzzer.html index af6575f..8ad8f46 100644 --- a/public/buzzer.html +++ b/public/buzzer.html @@ -1,12 +1,24 @@ - +
Error:
+
+

Admin

+
+ + + + +
+
+ +
+
-

All Players

-
+

BZZZZZ!

Press {key.name} to activate buzzer.

Please focus the window to allow the buzzer to work.

@@ -27,7 +39,28 @@ diff --git a/public/buzzer.js b/public/buzzer.js index 71fc8d2..4cb7d0f 100644 --- a/public/buzzer.js +++ b/public/buzzer.js @@ -17,12 +17,50 @@ const buzzer_key = { name: "space bar", } -const q = (selector, root) => (root || document).querySelector(selector) -const on = (event, cb) => document.addEventListener(event, cb) +function isString(x) { + return typeof x === "string" +} + +function isEmpty(x) { + return (Array.isArray(x) ? x : Object.keys(x)).length === 0 +} + +const q = (selector, root = document) => root.querySelector(selector) + +const _evlisteners = {} +/** + * @param {String} event [description] + * @param {Function} cb [description] + * @param {?{once: bool, target: Element = document}} options + */ +function on(event, cb, options = {}) { + const target = options.target || document + if ((_evlisteners[target] ?? {})[event]) { + console.warn(`Replacing previous event listener for '${event}' on target:`, target) + off(event, target) + } + target.addEventListener(event, cb, { once: !!options.once }) + if (!_evlisteners[target]) { + _evlisteners[target] = {} + } + _evlisteners[target][event] = cb +} + +function off(event, target = document) { + if (!_evlisteners[target] || !_evlisteners[target][event]) { + return + } + target.removeEventListener(event, _evlisteners[target][event]) + delete _evlisteners[target][event] + if (isEmpty(_evlisteners[target])) { + delete _evlisteners[target] + } +} + function node(type, { appendTo, cls, text, data, style, ...attrs } = {}) { - let elem = typeof type === "string" ? document.createElement(type) : type + let elem = isString(type) ? document.createElement(type) : type if (cls) { - if (typeof cls === "string") { + if (isString(cls)) { elem.className = cls } else { for (const [name, on] of Object.entries(cls)) { @@ -63,15 +101,19 @@ let socket, session_key function hide(e) { - e = typeof e === "string" ? q(`#${e}`) : e + e = isString(e) ? q(`#${e}`) : e e.style.display = "none" } function show(e) { - e = typeof e === "string" ? q(`#${e}`) : e + e = isString(e) ? q(`#${e}`) : e e.style.display = "block" } +function find_client(client_id) { + return clients.find((c) => c.id === client_id) +} + function session_id() { const match = /^#?(.+)/.exec(document.location.hash) return match ? match[1] : null @@ -103,23 +145,25 @@ function clear(container) { } } -let ul +const player_list = q("#info ul") function redraw_clients(me, clients) { if (!me) { return } - clear(ul) + clear(player_list) const player_tpl = q("template#player").content.firstElementChild for (const c of clients) { - node(player_tpl.cloneNode(), { - text: c.name, + const player_el = node(player_tpl.cloneNode(true), { data: { cid: c.id }, - appendTo: ul, + appendTo: player_list, cls: { me: c.id === me.id, inactive: !c.active, }, }) + q(".name", player_el).textContent = c.name + q(".points", player_el).textContent = c.points + q(".tokens", player_el).textContent = c.tokens } } @@ -133,10 +177,10 @@ function highlight(client_id, until_ns) { console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms) return } - for (const li of ul.children) { + for (const li of player_list.children) { if (li.dataset.cid === client_id) { li.classList.add("buzzing") - if (Object.keys(highlights).length) { + if (!isEmpty(highlights)) { li.classList.add("too-late") } else { li.classList.add("first") @@ -200,11 +244,61 @@ function setup_ui() { if (storage["my_name"]) { username_el.value = storage["my_name"] } - username_el.addEventListener("change", (event) => { - set_name(event.target.value) - }) + on( + "change", + (event) => { + set_name(event.target.value) + }, + { target: username_el }, + ) +} - ul = q("#info ul") +function disable_player_ui() { + off("focus") + off("blur") + off("keydown") + off("keyup") + node(q("body"), { cls: { player: 0 } }) +} + +function enable_admin_ui() { + const body = q("body") + if (body.classList.contains("admin")) { + return + } + body.classList.add("admin") + + const named = Object.fromEntries( + Array.from(document.querySelectorAll("[name]")).map((el) => [el.name, el]), + ) + on( + "click", + ({ target }) => { + if (!target.dataset.eval) { + return + } + eval(target.dataset.eval) + }, + { target: q("#points-admin") }, + ) + on( + "click", + ({ target }) => { + let node = target + while (!node.dataset.cid && node.parentElement) { + node = node.parentElement + } + if (!target.dataset.eval) { + return + } + const client = find_client(node.dataset.cid) + if (!client) { + return + } + eval(target.dataset.eval) + }, + { target: q("#info .players") }, + ) } function set_name(name) { @@ -234,44 +328,45 @@ function setup_ws() { }) socket.addEventListener("message", function (event) { const msg = JSON.parse(event.data) - if (msg.type === "time") { - servertime = msg.value + const { type, value } = msg + if (type === "time") { + servertime = value.time toffset_ms = performance.now() - } else if (msg.type === "id") { - me = { id: msg.id, key: msg.key, path: msg.path } + } else if (type === "id") { + me = value storage["my_uid"] = me.id storage["my_key"] = me.key storage["my_sid"] = session_id_from_url(me.path) redraw_clients(me, clients) - } else if (msg.type === "session_key") { - session_key = { path: msg.path, key: msg.key } + } else if (type === "session_key") { + session_key = value storage["session_path"] = session_key.path storage["session_key"] = session_key.key - } else if (msg.type === "buzz") { - const buzztime_ns = msg.time - const client_id = msg.client + disable_player_ui() + enable_admin_ui() + } else if (type === "buzz") { + const buzztime_ns = value.time + const client_id = value.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 + } else if (type === "clients") { + clients = value.clients redraw_clients(me, clients) - } else if (msg.type === "client") { - const client = { name: msg.name, id: msg.id, active: msg.active } - for (const c of clients) { - if (c.id === client.id) { - c.name = client.name - redraw_clients(me, clients) - return - } + } else if (type === "client") { + const client = value.client + const idx = clients.findIndex((c) => c.id === client.id) + if (idx >= 0) { + clients[idx] = client + } else { + clients.push(client) } - clients.push(client) redraw_clients(me, clients) - } else if (msg.type === "error") { - console.error(`Error: ${msg.reason}`) + } else if (type === "error") { + console.error(`Error: ${value.reason}`) const errorbox = q("#error") q("code", errorbox).textContent = JSON.stringify(msg, null, 2) - show(errorbox) + q("body").classList.add("error") } else { console.error(`Unknown message: ${event.data}`) } diff --git a/quiz/quiz.py b/quiz/quiz.py index 0d2d0ec..0a7566c 100644 --- a/quiz/quiz.py +++ b/quiz/quiz.py @@ -31,7 +31,8 @@ class Client: id: UserId = field(default_factory=token) is_reclaimed: bool = False name: str = "" - points: int = 0 + play_points: int = 0 # The points the player has accumulated. + play_tokens: int = 0 # The action tokens the player has left. secret: Token = field(default_factory=token) session: Optional["Session"] = None @@ -83,6 +84,8 @@ class Client: "name": self.name or "", "id": self.id, "active": self.is_active, + "points": self.play_points, + "tokens": self.play_tokens, } def _reclaim(self, other: "Client"): @@ -104,7 +107,8 @@ class Client: # Load all relevant info from other. self.id = other.id self.name = other.name - self.points = other.points + self.play_points = other.play_points + self.play_tokens = other.play_tokens self.secret = other.secret # Invalidate other. @@ -123,6 +127,11 @@ class Session: @classmethod def get(cls, client: Client) -> "Session": + """Return the session for the client. + + A new session will automatically be created if the given client + is not yet associated with any session. + """ is_new = client.path not in cls.sessions if is_new: if len(cls.sessions) >= config.max_sessions: @@ -172,12 +181,12 @@ class LoginError(RuntimeError): def msg(type_: str, **args): - return dumps({"type": type_, **args}) + return dumps({"type": type_, "value": args}) async def send_time(target: Client): """Send the current server time to the target.""" - await target.send("time", value=perf_counter_ns()) + await target.send("time", time=perf_counter_ns()) async def send_buzz(target: Client, client: Client, time: int): @@ -187,12 +196,17 @@ async def send_buzz(target: Client, client: Client, time: int): async def send_clients(target: Client): """Send info about all connected clients of a session to the target.""" - await target.send("clients", value=[c.info for c in target.session_clients()]) + await target.send("clients", clients=[c.info for c in target.session_clients()]) async def send_client(target: Client, client: Client): """Send info about the client to the target.""" - await target.send("client", **client.info) + await target.send("client", client=client.info) + + +async def send_control(target: Client, payload: Any): + """Send a control message to the target.""" + await target.send("control", payload=payload) async def wait(coros, **kwds): @@ -223,6 +237,11 @@ async def broadcast_buzz(client: Client, time: int): await wait(send_buzz(c, client, time) for c in client.session_clients()) +async def broadcast_control(session: Session, payload: Any): + """Send a control message to all clients of a session.""" + await wait(send_control(c, payload) for c in session.clients.values()) + + async def send_credentials(target: Client): """Send their user credentials to a client.""" await target.send("id", id=target.id, key=target.secret, path=target.path) @@ -253,9 +272,11 @@ def printable(s: str) -> str: async def handle_messages(client: Client): + assert client.session async for message in client.messages: log.debug("[%s] got a message: %a", client, message) mdata = loads(message) + # XXX we should probably define & check the types (well-formed) here?? if mdata["type"] == "buzz": time = mdata["value"] log.info("[%s] buzz: %a", client, time) @@ -266,28 +287,44 @@ async def handle_messages(client: Client): log.info("[%s] new name: %a", client, name) client.name = name await broadcast_client(client) - elif mdata["type"] == "login": - assert client.session + elif mdata["type"] == "control": + if not client.is_admin: + log.info("[%s] not authorized to send control messages.") + control_data = mdata["value"] + await broadcast_control(client.session, control_data) + elif mdata["type"] in ("points", "tokens"): + if not client.is_admin: + log.info("[%s] not authorized to set points or tokens.") target_id = UserId(mdata["value"]["id"]) - existent = client.session.clients.get(target_id) - if existent is None: + points = int(mdata["value"][mdata["type"]]) + target = client.session.clients.get(target_id) + if target is None: + log.info("[%s] no such user: %a", client, target_id) + return + if mdata["type"] == "points": + target.play_points = points + elif mdata["type"] == "tokens": + target.play_tokens = points + await broadcast_client(client) + elif mdata["type"] == "login": + target_id = UserId(mdata["value"]["id"]) + target = client.session.clients.get(target_id) + if target is None: log.info( "[%s] tried to log in as non-existent user: %a", client, target_id ) return - if existent.is_active: + if target.is_active: log.info("[%s] cannot log in as active user: %a", client, target_id) return - if existent.is_reclaimed: - log.info("[%s] client already reclaimed: %a", client, target_id) + if target.is_reclaimed: + log.info("[%s] target already reclaimed: %a", client, target_id) return - if not compare_digest(mdata["value"]["key"], existent.secret): - log.info( - "[%s] failed to log in as existing user: %a", client, target_id - ) + if not compare_digest(mdata["value"]["key"], target.secret): + log.info("[%s] failed to log in as target user: %a", client, target_id) return - log.info("[%s] logging in as existent user: %s", client, existent) - client.session.reclaim(client, inactive=existent) + log.info("[%s] reclaiming target user: %s", client, target) + client.session.reclaim(client, inactive=target) await send_hello(client) await broadcast_clients(client.session) else: