443 lines
13 KiB
JavaScript
443 lines
13 KiB
JavaScript
"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);
|
|
});
|
|
};
|