"use strict"; let Identity = function() { this.key = null; this.pretty = null; }; Identity.prototype.start = async function() { const lskey = "hacker-jeopardy-key-6"; const algo = { name: "ECDSA", namedCurve: "P-384", }; const uses = ["sign", "verify"]; let existing = localStorage.getItem(lskey); if (existing == null) { console.log("Generating identity..."); const pair = await crypto.subtle.generateKey(algo, true, uses); this.pubkey = pair.publicKey; this.privkey = pair.privateKey; this.identity = (await this.hexsign("hacker-jeopardy-whoami")); const ls = { "pubkey": await crypto.subtle.exportKey("jwk", this.pubkey), "privkey": await crypto.subtle.exportKey("jwk", this.privkey), "identity": this.identity, }; this.jwk_pubkey = ls.pubkey; localStorage.setItem(lskey, JSON.stringify(ls)); } else { console.log("Loading identity..."); const ls = JSON.parse(existing); this.privkey = await crypto.subtle.importKey("jwk", ls.privkey, algo, false, ["sign"]); this.pubkey = await crypto.subtle.importKey("jwk", ls.pubkey, algo, false, ["verify"]); this.jwk_pubkey = ls.pubkey; this.identity = ls.identity; } this.pretty = this.identity.substr(0, 8); console.log("Pretty identity: %s", this.pretty); }; Identity.prototype.hexsign = async function(nonce) { const ugh = (new TextEncoder()).encode(nonce); const ugnh = await crypto.subtle.sign({ name: "ECDSA", hash: {name: "SHA-384"}, }, this.privkey, ugh); const ungh = Array.from(new Uint8Array(ugnh)); const argh = ungh.map((b) => { return b.toString(16).padStart(2, "0"); }).join(""); console.log("signed encoded", argh); return argh; }; const ClientState = Object.freeze({ CONNECTING: Symbol("CONNECTING"), SYNCING: Symbol("SYNCING"), SYNCED: Symbol("SYNCED"), BACKOFF: Symbol("BACKOFF"), }); let Game = function() { this.clientState = ClientState.CONNECTING; this.role = 'SPECTATOR'; this.error = null; this.interval = null; this.serverState = null; this.identified = false; this.ct = null; this.rct = null; }; Game.prototype.run = async function() { let self = this; this.identity = new Identity(); await this.identity.start(); console.log("Opening socket..."); this.socket = io.connect('https://jeopardy.rhw.q3k.org'); this.socket.on('connect', function() { self.socketConnected(); }); this.socket.on('disconnect', function() { self.socketDisconnected(); }); this.socket.on('state', function(msg) { self.socketState(msg); }); this.socket.on('nonce', function(nonce) { self.socketNonce(nonce); }); this.socket.on('identified', function(msg) { self.socketIdentified(msg); }); if (this.timeout == null) { this.interval = setInterval(function() { self.tick(); }, 1000); } this.lastStateUpdate = null; this.serverState = null; }; Game.prototype.mutate = function(newState) { if (this.clientState != newState) { console.log("Client %s -> %s", this.clientState, newState); } this.clientState = newState; this.render(); }; Game.prototype.fatal = function(msg) { console.error("FATAL in %s: %s", this.clientState, msg); this.error = msg; this.mutate(ClientState.BACKOFF); //setTimeout(() => this.backoffDone(), 3000); }; Game.prototype.backoffDone = function() { this.socket.close(); } Game.prototype.socketConnected = function() { console.log("Socket connected"); if (this.clientState != ClientState.CONNECTING) { this.fatal("Unexpected 'connect'"); } this.error = null; this.mutate(ClientState.SYNCING); }; Game.prototype.socketDisconnected = function() { console.log("Socket disconnected"); this.identified = false; this.mutate(ClientState.CONNECTING); this.socket.open(); this.error = "Disconnected"; }; Game.prototype.socketState = function(msg) { this.serverState = msg.compacted; this.ct = msg.ct; this.role = msg.role; this.lastStateUpdate = Date.now(); this.mutate(ClientState.SYNCED); if (this.role == 'HOST') { for (let [pretty, identity] of Object.entries(msg.compacted.identity)) { let role = msg.compacted.roles[identity]; if (role == undefined || role == null) { continue; } } } }; Game.prototype.socketNonce = async function(nonce) { console.log("Received nonce, sending identity proof."); const proof = await this.identity.hexsign(nonce); this.socket.emit('proof', {'identity': this.identity.identity, 'nonce': nonce, 'jwk': this.identity.jwk_pubkey, 'proof': proof}); }; Game.prototype.socketIdentified = async function(msg) { console.log("Identification result: ", msg); if (msg.identified != true) { this.fatal("Identification failed!") return; } this.identified = true; this.render(); }; Game.prototype.tick = function() { let self = this; this.socket.emit('ping') //this.render(); if (this.clientState == ClientState.CONNECTING) { if (this.socket.disconnected) { console.log("Reconnecting..."); this.socket.open(); } } if (this.clientState != ClientState.SYNCED) { return; } }; Game.prototype.render = function() { if (this.rct == this.ct) { return } this.rct = this.ct; if (this.error != null) { let err = document.querySelector("#error"); err.style.display = "block"; err.textContent = "Error: " + this.error; } else { let err = document.querySelector("#error"); err.style.display = "none"; } let status = document.querySelector("#status p"); status.textContent = "State: " + this.clientState.description; status.textContent += ", identity: " + this.identity.pretty; status.textContent += ", role: " + this.role; status.textContent += "."; this.renderGame(); }; Game.prototype.renderGame = function() { if (this.serverState == null) { return; } this.renderBoard(); this.renderContestants(); this.renderQuestion(); }; Game.prototype.renderBoard = function() { let host = (this.role == 'HOST'); let b = document.createElement("div"); b.id = "board"; if (this.serverState.board == null) { document.querySelector("#board").replaceWith(b); return; } let desc = this.serverState.board; desc.categories.forEach((category, cid) => { let c = document.createElement("div"); c.className = "category"; b.appendChild(c); let h = document.createElement("div"); h.className = "header"; if (host) { h.className += " narrow"; }; h.textContent = category.name; c.appendChild(h); category.questions.forEach((question, qid) => { let p = document.createElement("div"); p.className = "panel"; if (question.answered) { p.className += " solved"; } if (host) { p.className += " narrow"; }; if (this.role == "HOST") { p.onclick = (() => { this.socket.emit("host-question", {"category": cid, "question": qid}) }); } let t = document.createElement("div"); t.className = "text"; t.textContent = "" + question.value + " SOG"; p.appendChild(t); c.appendChild(p); }); }); if (host) { let h = document.createElement("div"); h.id = "host"; h.textContent = "Host menu"; b.appendChild(h); let u = document.createElement("ul"); h.appendChild(u); for (let [pretty, identifier] of Object.entries(this.serverState.identity)) { let role = this.serverState.roles[identifier]; if (role == undefined) { role = "SPECTATOR"; } let li = document.createElement("li"); li.textContent = pretty + " " + role + " "; u.appendChild(li); let setRole = (who, what) => { this.socket.emit('host-set-role', {'who': pretty, 'what': what}); }; if (role == "SPECTATOR") { let a = document.createElement("a"); a.textContent = "contestant"; a.href = "#"; a.onclick = () => { setRole(pretty, 'CONTESTANT'); }; li.appendChild(a); } if (role == "CONTESTANT") { let a = document.createElement("a"); a.textContent = "spectator"; a.href = "#"; a.onclick = () => { setRole(pretty, 'SPECTATOR'); }; li.appendChild(a); let b = document.createElement("a"); b.textContent = "set name"; b.href = "#"; b.onclick = () => { this.socket.emit('set name', {'identity': identifier, 'name': prompt("name?")}); }; li.appendChild(b); } } } document.querySelector("#board").replaceWith(b); } Game.prototype.renderContestants = function() { let contestants = document.createElement("div"); contestants.id = "contestants"; this.serverState.contestants.forEach((c) => { let name = c.name; let cont = document.createElement("div"); cont.className = "contestant"; if (c.buzz) { cont.className += " buzz"; } let h1 = document.createElement("h1"); h1.textContent = name; let h2 = document.createElement("h2"); h2.textContent = c.points + " SOG"; cont.appendChild(h1); if (c.identity == this.identity.identity) { let h3 = document.createElement("h3"); h3.textContent = "(that's you!)"; cont.appendChild(h3); } cont.appendChild(h2); contestants.appendChild(cont); }); document.querySelector("#contestants").replaceWith(contestants); }; Game.prototype.renderQuestion = function() { let question = document.createElement("div"); question.id = "question"; let q = this.serverState.question; if (q == undefined || q == null) { document.querySelector("#question").replaceWith(question); return; } let contestant = (this.role == "CONTESTANT"); let host = (this.role == "HOST"); let buzzed = false; let buzzing = null; this.serverState.contestants.forEach((c) => { if (c.buzzing) { buzzed = true; buzzing = c; } }); let loser = false; this.serverState.losers.forEach((c) => { if (c == this.identity.identity) { loser = true; }; }); question.style = "display: flex;"; let header = document.createElement("div"); header.className = "header"; header.textContent = q.category + " - " + q.value; question.appendChild(header); let inner = document.createElement("div"); inner.className = "inner"; inner.innerHTML = q.clue; question.appendChild(inner); if (!buzzed && !loser && contestant) { let b = document.createElement("div"); b.className = "buzz"; b.textContent = "BUZZ!"; b.onclick = () => { this.socket.emit("buzz"); }; question.append(b); } else if (buzzed && host) { let choice = document.createElement("div"); choice.className = "choice"; choice.textContent = "buzzing: " + buzzing.name; question.append(choice); let ack = document.createElement("div"); ack.className = "ack"; ack.textContent = "OK"; ack.onclick = () => { this.socket.emit("answer", {'ok': true}); }; let nack = document.createElement("div"); nack.className = "nack"; nack.textContent = "WRONG"; nack.onclick = () => { this.socket.emit("answer", {'ok': false}); }; choice.appendChild(ack); choice.appendChild(nack); } else if (host) { let choice = document.createElement("div"); choice.className = "choice"; choice.textContent = "losers: " + this.serverState.losers.map((e) => { return e.substr(0, 8); }).join(", ") + ", answer: " + this.serverState.question.answer; question.append(choice); let giveup = document.createElement("div"); giveup.className = "nack"; giveup.textContent = "CANCEL QUESTION"; giveup.onclick = () => { this.socket.emit("give up"); }; choice.appendChild(giveup); } else { question.appendChild(document.createElement("div")); }; document.querySelector("#question").replaceWith(question); }; window.onload = function() { let nojs = document.querySelector("div.nojs"); nojs.parentNode.removeChild(nojs); let run = async () => { let g = new Game(); await g.run(); g.render(); } run().catch((e) => { console.log(e); console.log(e.stack); console.log(e.message); console.log(e.name); }); };