BZZZZZ!
Press {key.name} to activate buzzer.
Please focus the window to allow the buzzer to work.
@@ -27,7 +39,28 @@
- player name
+
+ player name
+
+ (points pts)
+ (tokens tks)
+
+
+
+
+
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: