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.
This commit is contained in:
ducklet 2021-02-01 23:07:49 +01:00
parent f406627042
commit b00f8a357c
4 changed files with 242 additions and 65 deletions

View file

@ -2,6 +2,13 @@ body {
font-family: sans-serif; font-family: sans-serif;
overflow: hidden; overflow: hidden;
} }
body.admin #info {
position: unset;
}
body.player .admin-only,
body.admin .player-only {
display: none !important;
}
#error { #error {
background-color: #fee; background-color: #fee;
border: 3px solid red; border: 3px solid red;
@ -9,6 +16,9 @@ body {
font-family: monospace; font-family: monospace;
display: none; display: none;
} }
body.error #error {
display: block;
}
#error code { #error code {
white-space: pre; white-space: pre;
} }
@ -51,6 +61,9 @@ ul {
.player.buzzing.too-late::before { .player.buzzing.too-late::before {
content: "🥈 "; content: "🥈 ";
} }
.player div.admin-only {
display: inline-block;
}
#info { #info {
position: absolute; position: absolute;
} }
@ -60,7 +73,6 @@ ul {
align-items: center; align-items: center;
width: 100%; width: 100%;
height: 90%; height: 90%;
/*position: absolute;*/
} }
#active { #active {
color: red; color: red;

View file

@ -1,12 +1,24 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="buzzer.css" /> <link rel="stylesheet" type="text/css" href="buzzer.css" />
<body> <body class="player">
<div id="error"> <div id="error">
Error: Error:
<code></code> <code></code>
</div> </div>
<div id="points-admin" class="admin-only">
<h2>Admin</h2>
<div>
<label>points: <input type="number" name="points" value="0" /></label>
<button data-eval="named.points.valueAsNumber += 100">+ 100</button>
<button data-eval="named.points.valueAsNumber -= 100">- 100</button>
<button data-eval="named.points.valueAsNumber *= -1">+/-</button>
</div>
<div>
<label>tokens: <input type="number" name="tokens" value="0" /></label>
</div>
</div>
<div id="info"> <div id="info">
<label <label class="myname player-only"
>You: >You:
<input <input
type="text" type="text"
@ -16,10 +28,10 @@
/></label> /></label>
<h2>All Players</h2> <h2>All Players</h2>
<ul class="players"> <ul class="players">
<!-- players will be inserted here --> <!-- players (see template#player) will be inserted here -->
</ul> </ul>
</div> </div>
<div id="buzzbox"> <div id="buzzbox" class="player-only">
<p id="active">BZZZZZ!</p> <p id="active">BZZZZZ!</p>
<p id="ready">Press <strong id="bkey">{key.name}</strong> to activate buzzer.</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> <p id="inactive">Please focus the window to allow the buzzer to work.</p>
@ -27,7 +39,28 @@
</body> </body>
<template id="player"> <template id="player">
<li class="player" data-cid="client ID">player name</li> <li class="player" data-cid="client ID">
<span class="name">player name</span>
<div class="admin-only">
(<span class="points">points</span> pts)
(<span class="tokens">tokens</span> tks)
<button data-eval="send('control', {target: client.id, action: 'monitor'})">
monitor
</button>
<button
data-eval="client.points += named.points.valueAsNumber;
send('points', {id: client.id, points: client.points})"
>
add points
</button>
<button
data-eval="client.tokens += named.tokens.valueAsNumber;
send('tokens', {id: client.id, tokens: client.tokens})"
>
add tokens
</button>
</div>
</li>
</template> </template>
<script type="module" src="./buzzer.js"></script> <script type="module" src="./buzzer.js"></script>

View file

@ -17,12 +17,50 @@ const buzzer_key = {
name: "space bar", name: "space bar",
} }
const q = (selector, root) => (root || document).querySelector(selector) function isString(x) {
const on = (event, cb) => document.addEventListener(event, cb) 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 } = {}) { 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 (cls) {
if (typeof cls === "string") { if (isString(cls)) {
elem.className = cls elem.className = cls
} else { } else {
for (const [name, on] of Object.entries(cls)) { for (const [name, on] of Object.entries(cls)) {
@ -63,15 +101,19 @@ let socket,
session_key session_key
function hide(e) { function hide(e) {
e = typeof e === "string" ? q(`#${e}`) : e e = isString(e) ? q(`#${e}`) : e
e.style.display = "none" e.style.display = "none"
} }
function show(e) { function show(e) {
e = typeof e === "string" ? q(`#${e}`) : e e = isString(e) ? q(`#${e}`) : e
e.style.display = "block" e.style.display = "block"
} }
function find_client(client_id) {
return clients.find((c) => c.id === client_id)
}
function session_id() { function session_id() {
const match = /^#?(.+)/.exec(document.location.hash) const match = /^#?(.+)/.exec(document.location.hash)
return match ? match[1] : null 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) { function redraw_clients(me, clients) {
if (!me) { if (!me) {
return return
} }
clear(ul) clear(player_list)
const player_tpl = q("template#player").content.firstElementChild const player_tpl = q("template#player").content.firstElementChild
for (const c of clients) { for (const c of clients) {
node(player_tpl.cloneNode(), { const player_el = node(player_tpl.cloneNode(true), {
text: c.name,
data: { cid: c.id }, data: { cid: c.id },
appendTo: ul, appendTo: player_list,
cls: { cls: {
me: c.id === me.id, me: c.id === me.id,
inactive: !c.active, 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) console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms)
return return
} }
for (const li of ul.children) { for (const li of player_list.children) {
if (li.dataset.cid === client_id) { if (li.dataset.cid === client_id) {
li.classList.add("buzzing") li.classList.add("buzzing")
if (Object.keys(highlights).length) { if (!isEmpty(highlights)) {
li.classList.add("too-late") li.classList.add("too-late")
} else { } else {
li.classList.add("first") li.classList.add("first")
@ -200,11 +244,61 @@ function setup_ui() {
if (storage["my_name"]) { if (storage["my_name"]) {
username_el.value = storage["my_name"] username_el.value = storage["my_name"]
} }
username_el.addEventListener("change", (event) => { on(
set_name(event.target.value) "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) { function set_name(name) {
@ -234,44 +328,45 @@ function setup_ws() {
}) })
socket.addEventListener("message", function (event) { socket.addEventListener("message", function (event) {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === "time") { const { type, value } = msg
servertime = msg.value if (type === "time") {
servertime = value.time
toffset_ms = performance.now() toffset_ms = performance.now()
} else if (msg.type === "id") { } else if (type === "id") {
me = { id: msg.id, key: msg.key, path: msg.path } me = value
storage["my_uid"] = me.id storage["my_uid"] = me.id
storage["my_key"] = me.key storage["my_key"] = me.key
storage["my_sid"] = session_id_from_url(me.path) storage["my_sid"] = session_id_from_url(me.path)
redraw_clients(me, clients) redraw_clients(me, clients)
} else if (msg.type === "session_key") { } else if (type === "session_key") {
session_key = { path: msg.path, key: msg.key } session_key = value
storage["session_path"] = session_key.path storage["session_path"] = session_key.path
storage["session_key"] = session_key.key storage["session_key"] = session_key.key
} else if (msg.type === "buzz") { disable_player_ui()
const buzztime_ns = msg.time enable_admin_ui()
const client_id = msg.client } else if (type === "buzz") {
const buzztime_ns = value.time
const client_id = value.client
const duration_ns = 3 * s_ns const duration_ns = 3 * s_ns
const until_ns = buzztime_ns + duration_ns const until_ns = buzztime_ns + duration_ns
highlight(client_id, until_ns) highlight(client_id, until_ns)
} else if (msg.type === "clients") { } else if (type === "clients") {
clients = msg.value clients = value.clients
redraw_clients(me, clients) redraw_clients(me, clients)
} else if (msg.type === "client") { } else if (type === "client") {
const client = { name: msg.name, id: msg.id, active: msg.active } const client = value.client
for (const c of clients) { const idx = clients.findIndex((c) => c.id === client.id)
if (c.id === client.id) { if (idx >= 0) {
c.name = client.name clients[idx] = client
redraw_clients(me, clients) } else {
return clients.push(client)
}
} }
clients.push(client)
redraw_clients(me, clients) redraw_clients(me, clients)
} else if (msg.type === "error") { } else if (type === "error") {
console.error(`Error: ${msg.reason}`) console.error(`Error: ${value.reason}`)
const errorbox = q("#error") const errorbox = q("#error")
q("code", errorbox).textContent = JSON.stringify(msg, null, 2) q("code", errorbox).textContent = JSON.stringify(msg, null, 2)
show(errorbox) q("body").classList.add("error")
} else { } else {
console.error(`Unknown message: ${event.data}`) console.error(`Unknown message: ${event.data}`)
} }

View file

@ -31,7 +31,8 @@ class Client:
id: UserId = field(default_factory=token) id: UserId = field(default_factory=token)
is_reclaimed: bool = False is_reclaimed: bool = False
name: str = "" 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) secret: Token = field(default_factory=token)
session: Optional["Session"] = None session: Optional["Session"] = None
@ -83,6 +84,8 @@ class Client:
"name": self.name or "<noname>", "name": self.name or "<noname>",
"id": self.id, "id": self.id,
"active": self.is_active, "active": self.is_active,
"points": self.play_points,
"tokens": self.play_tokens,
} }
def _reclaim(self, other: "Client"): def _reclaim(self, other: "Client"):
@ -104,7 +107,8 @@ class Client:
# Load all relevant info from other. # Load all relevant info from other.
self.id = other.id self.id = other.id
self.name = other.name self.name = other.name
self.points = other.points self.play_points = other.play_points
self.play_tokens = other.play_tokens
self.secret = other.secret self.secret = other.secret
# Invalidate other. # Invalidate other.
@ -123,6 +127,11 @@ class Session:
@classmethod @classmethod
def get(cls, client: Client) -> "Session": 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 is_new = client.path not in cls.sessions
if is_new: if is_new:
if len(cls.sessions) >= config.max_sessions: if len(cls.sessions) >= config.max_sessions:
@ -172,12 +181,12 @@ class LoginError(RuntimeError):
def msg(type_: str, **args): def msg(type_: str, **args):
return dumps({"type": type_, **args}) return dumps({"type": type_, "value": args})
async def send_time(target: Client): async def send_time(target: Client):
"""Send the current server time to the target.""" """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): 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): async def send_clients(target: Client):
"""Send info about all connected clients of a session to the target.""" """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): async def send_client(target: Client, client: Client):
"""Send info about the client to the target.""" """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): 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()) 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): async def send_credentials(target: Client):
"""Send their user credentials to a client.""" """Send their user credentials to a client."""
await target.send("id", id=target.id, key=target.secret, path=target.path) 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): async def handle_messages(client: Client):
assert client.session
async for message in client.messages: async for message in client.messages:
log.debug("[%s] got a message: %a", client, message) log.debug("[%s] got a message: %a", client, message)
mdata = loads(message) mdata = loads(message)
# XXX we should probably define & check the types (well-formed) here??
if mdata["type"] == "buzz": if mdata["type"] == "buzz":
time = mdata["value"] time = mdata["value"]
log.info("[%s] buzz: %a", client, time) 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) log.info("[%s] new name: %a", client, name)
client.name = name client.name = name
await broadcast_client(client) await broadcast_client(client)
elif mdata["type"] == "login": elif mdata["type"] == "control":
assert client.session 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"]) target_id = UserId(mdata["value"]["id"])
existent = client.session.clients.get(target_id) points = int(mdata["value"][mdata["type"]])
if existent is None: 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( log.info(
"[%s] tried to log in as non-existent user: %a", client, target_id "[%s] tried to log in as non-existent user: %a", client, target_id
) )
return return
if existent.is_active: if target.is_active:
log.info("[%s] cannot log in as active user: %a", client, target_id) log.info("[%s] cannot log in as active user: %a", client, target_id)
return return
if existent.is_reclaimed: if target.is_reclaimed:
log.info("[%s] client already reclaimed: %a", client, target_id) log.info("[%s] target already reclaimed: %a", client, target_id)
return return
if not compare_digest(mdata["value"]["key"], existent.secret): if not compare_digest(mdata["value"]["key"], target.secret):
log.info( log.info("[%s] failed to log in as target user: %a", client, target_id)
"[%s] failed to log in as existing user: %a", client, target_id
)
return return
log.info("[%s] logging in as existent user: %s", client, existent) log.info("[%s] reclaiming target user: %s", client, target)
client.session.reclaim(client, inactive=existent) client.session.reclaim(client, inactive=target)
await send_hello(client) await send_hello(client)
await broadcast_clients(client.session) await broadcast_clients(client.session)
else: else: