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: