code dump from kkc19
This commit is contained in:
commit
9cd9873e20
9 changed files with 1221 additions and 0 deletions
23
client.py
Normal file
23
client.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
import asyncio
|
||||
import socketio
|
||||
|
||||
|
||||
sio = socketio.AsyncClient()
|
||||
|
||||
@sio.on('state')
|
||||
def sio_state(state):
|
||||
print(state)
|
||||
|
||||
@sio.on('identified')
|
||||
def sio_identified(msg):
|
||||
if msg.get('identified') != True:
|
||||
print('COULD NOT IDENTIFY')
|
||||
return
|
||||
print('Identified.')
|
||||
|
||||
async def main():
|
||||
cl = await sio.connect('http://127.0.0.1:5000')
|
||||
await sio.emit('admin', {'password': 'changeme', 'set_role': {'who': '716a784c', 'what': 'HOST'}})
|
||||
await asyncio.sleep(2)
|
||||
|
||||
asyncio.run(main())
|
20
default.nix
Normal file
20
default.nix
Normal file
|
@ -0,0 +1,20 @@
|
|||
with import <nixpkgs> {};
|
||||
|
||||
|
||||
stdenv.mkDerivation rec {
|
||||
name = "venv";
|
||||
env = buildEnv { name = name; paths = buildInputs; };
|
||||
buildInputs = [
|
||||
python37
|
||||
] ++ (with python37Packages; [
|
||||
virtualenv
|
||||
pip
|
||||
flask
|
||||
redis
|
||||
flask-socketio
|
||||
eventlet
|
||||
ecdsa
|
||||
python-jose
|
||||
aiohttp
|
||||
]);
|
||||
}
|
55
example-round.js
Normal file
55
example-round.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"type": "simple",
|
||||
"categories": [
|
||||
{
|
||||
"name": "foo",
|
||||
"questions": [
|
||||
{"clue": "foo1", "answer": "foo1!"},
|
||||
{"clue": "foo2", "answer": "foo2!"},
|
||||
{"clue": "foo3", "answer": "foo3!"},
|
||||
{"clue": "foo4", "answer": "foo4!"},
|
||||
{"clue": "foo5", "answer": "foo5!"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bar",
|
||||
"questions": [
|
||||
{"clue": "bar1", "answer": "bar1!"},
|
||||
{"clue": "bar2", "answer": "bar2!"},
|
||||
{"clue": "bar3", "answer": "bar3!"},
|
||||
{"clue": "bar4", "answer": "bar4!"},
|
||||
{"clue": "bar5", "answer": "bar5!"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "baz",
|
||||
"questions": [
|
||||
{"clue": "baz1", "answer": "baz1!"},
|
||||
{"clue": "baz2", "answer": "baz2!"},
|
||||
{"clue": "baz3", "answer": "baz3!"},
|
||||
{"clue": "baz4", "answer": "baz4!"},
|
||||
{"clue": "baz5", "answer": "baz5!"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "barfoo",
|
||||
"questions": [
|
||||
{"clue": "barfoo1", "answer": "barfoo1!"},
|
||||
{"clue": "barfoo2", "answer": "barfoo2!"},
|
||||
{"clue": "barfoo3", "answer": "barfoo3!"},
|
||||
{"clue": "barfoo4", "answer": "barfoo4!"},
|
||||
{"clue": "barfoo5", "answer": "barfoo5!"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "barbaz",
|
||||
"questions": [
|
||||
{"clue": "barbaz1", "answer": "barbaz1!"},
|
||||
{"clue": "barbaz2", "answer": "barbaz2!"},
|
||||
{"clue": "barbaz3", "answer": "barbaz3!"},
|
||||
{"clue": "barbaz4", "answer": "barbaz4!"},
|
||||
{"clue": "barbaz5", "answer": "barbaz5!"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
468
jeopardy.py
Normal file
468
jeopardy.py
Normal file
|
@ -0,0 +1,468 @@
|
|||
import secrets
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import ecdsa
|
||||
import binascii
|
||||
|
||||
from jose.jwk import ECKey
|
||||
from flask import Flask, render_template, g, session
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'secret!'
|
||||
app.config['ADMIN_PASSWORD'] = 'changeme'
|
||||
socketio = SocketIO(app)
|
||||
log = logging.getLogger()
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.addHandler(logging.StreamHandler())
|
||||
|
||||
log.info("starting up...")
|
||||
|
||||
|
||||
class Board:
|
||||
def __init__(self, type):
|
||||
self.type = type
|
||||
self.categories = []
|
||||
|
||||
def add_category(self, c):
|
||||
self.categories.append(c)
|
||||
|
||||
def serialize(self):
|
||||
res = {'categories': [c.serialize() for c in self.categories]}
|
||||
return res
|
||||
|
||||
def copy(self):
|
||||
r = Board(self.type)
|
||||
for c in self.categories:
|
||||
r.add_category(c.copy())
|
||||
return r
|
||||
|
||||
@classmethod
|
||||
def unserialize(cls, j):
|
||||
r = cls(j)
|
||||
print('UNSERIALIZING ROUND', j)
|
||||
|
||||
if 'categories' not in j:
|
||||
log.error("No categories")
|
||||
return
|
||||
|
||||
for cid, c in enumerate(j['categories']):
|
||||
if 'name' not in c:
|
||||
log.error("No category name")
|
||||
if 'questions' not in c or len(c['questions']) != 5:
|
||||
log.error("Invalid question count")
|
||||
|
||||
cat = Category(c['name'])
|
||||
r.add_category(cat)
|
||||
|
||||
questions = c['questions']
|
||||
for qid, q in enumerate(questions):
|
||||
if 'clue' not in q:
|
||||
log.error("No clue in question")
|
||||
if 'answer' not in q:
|
||||
log.error("No answer in question")
|
||||
|
||||
value = (qid+1)*100
|
||||
ques = Question(value, q['clue'], q['answer'], c['name'], qid, cid)
|
||||
cat.add_question(ques)
|
||||
|
||||
return r
|
||||
|
||||
class Category:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.questions = []
|
||||
|
||||
def add_question(self, q):
|
||||
self.questions.append(q)
|
||||
|
||||
def serialize(self):
|
||||
return {'name': self.name, 'questions': [q.serialize() for q in self.questions]}
|
||||
|
||||
def copy(self):
|
||||
c = Category(self.name)
|
||||
for q in self.questions:
|
||||
c.add_question(q.copy())
|
||||
return c
|
||||
|
||||
class Question:
|
||||
def __init__(self, value, clue, answer, category, qid, cid, answered=False):
|
||||
self.value = value
|
||||
self.clue = clue
|
||||
self.answer = answer
|
||||
self.category = category
|
||||
self.answered = answered
|
||||
self.qid = qid
|
||||
self.cid = cid
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'value': self.value,
|
||||
'clue': self.clue,
|
||||
'answer': self.answer,
|
||||
'category': self.category,
|
||||
'answered': self.answered,
|
||||
}
|
||||
|
||||
def copy(self):
|
||||
return Question(self.value, self.clue, self.answer, self.category, self.qid, self.cid, self.answered)
|
||||
|
||||
class Compaction:
|
||||
def __init__(self):
|
||||
self.ct = 0
|
||||
self.board = None
|
||||
self.roles = {}
|
||||
self.identity = {}
|
||||
self.contestants = {}
|
||||
self.question = None
|
||||
|
||||
@property
|
||||
def buzzing(self):
|
||||
for _, c in self.contestants.items():
|
||||
if c.buzzing:
|
||||
return c.identity
|
||||
return None
|
||||
|
||||
def feed(self, entry):
|
||||
log.info("Compacting {}".format(entry))
|
||||
if entry['event'] == 'new round':
|
||||
self.board = entry['round'].copy()
|
||||
log.info("LOG ENTRY, new round")
|
||||
elif entry['event'] == 'role set':
|
||||
who, what = entry['identity'], entry['what']
|
||||
self.roles[who] = what
|
||||
if what == 'CONTESTANT' and who not in self.contestants:
|
||||
self.contestants[who] = Contestant(who, who[:8])
|
||||
elif who in self.contestants:
|
||||
del self.contestants[who]
|
||||
log.info("LOG ENTRY, role set, {} is {}".format(who, what))
|
||||
elif entry['event'] == 'pretty set':
|
||||
self.identity[entry['pretty']] = entry['identity']
|
||||
log.info("LOG ENTRY, pretty set, {} is {}".format(entry['pretty'], entry['identity']))
|
||||
elif entry['event'] == 'question':
|
||||
cid = entry['cid']
|
||||
qid = entry['qid']
|
||||
log.info("LOG ENTRY, question activated, {}/{}".format(cid, qid))
|
||||
self.question = self.board.categories[cid].questions[qid].copy()
|
||||
self.question.losers = set()
|
||||
for _, c in self.contestants.items():
|
||||
c.buzzing = False
|
||||
elif entry['event'] == 'buzz':
|
||||
if not self.question:
|
||||
self.ct += 1
|
||||
return
|
||||
log.info("LOG ENTRY, buzz")
|
||||
who = entry['identity']
|
||||
if not self.buzzing and who not in self.question.losers:
|
||||
self.contestants[who].buzzing = True
|
||||
elif entry['event'] == 'answered':
|
||||
if not self.question:
|
||||
self.ct += 1
|
||||
return
|
||||
log.info("LOG ENTRY, answer")
|
||||
ok = entry['ok']
|
||||
value = self.question.value
|
||||
who = self.buzzing
|
||||
if not who:
|
||||
self.ct += 1
|
||||
return
|
||||
self.contestants[who].buzzing = False
|
||||
|
||||
if who in self.question.losers:
|
||||
self.ct += 1
|
||||
return
|
||||
if ok :
|
||||
self.contestants[who].points += value
|
||||
self.board.categories[self.question.cid].questions[self.question.qid].answered = True
|
||||
self.question = None
|
||||
self.ct += 1
|
||||
return
|
||||
|
||||
self.contestants[who].points -= value
|
||||
self.question.losers.add(who)
|
||||
elif entry['event'] == 'give up':
|
||||
if not self.question:
|
||||
self.ct += 1
|
||||
return
|
||||
self.board.categories[self.question.cid].questions[self.question.qid].answered = True
|
||||
self.question = None
|
||||
elif entry['event'] == 'set name':
|
||||
who = entry['identity']
|
||||
name = entry['name']
|
||||
self.contestants[who].name = name
|
||||
else:
|
||||
log.error("Invalid log entry type {}".format(entry['event']))
|
||||
self.ct += 1
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'board': self.board.serialize() if self.board else None,
|
||||
'roles': dict(self.roles),
|
||||
'identity': dict(self.identity),
|
||||
'contestants': [c.serialize() for (_, c) in self.contestants.items()],
|
||||
'question': self.question.serialize() if self.question else None,
|
||||
'losers': list(self.question.losers) if self.question else [],
|
||||
}
|
||||
|
||||
|
||||
class Contestant:
|
||||
def __init__(self, identity, name):
|
||||
self.identity = identity
|
||||
self.name = name
|
||||
self.points = 0
|
||||
self.buzzing = False
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'points': self.points,
|
||||
'buzzing': self.buzzing,
|
||||
'identity': self.identity,
|
||||
}
|
||||
|
||||
|
||||
class Game:
|
||||
def __init__(self):
|
||||
self.state = 'IDLE'
|
||||
self.log = []
|
||||
self._compaction = None
|
||||
|
||||
def start_round(self, path):
|
||||
with open(path) as f:
|
||||
j = json.load(f)
|
||||
r = Board.unserialize(j)
|
||||
self.log.append({'event': 'new round', 'round': r})
|
||||
self.save()
|
||||
|
||||
def activate_question(self, qid, cid):
|
||||
if self.compacted.board.categories[cid].questions[qid].answered:
|
||||
return
|
||||
self.log.append({'event': 'question', 'qid': qid, 'cid': cid})
|
||||
self.save()
|
||||
|
||||
def set_role(self, pretty, what):
|
||||
self.log.append({'event': 'role set', 'identity': self.identity[pretty], 'what': what})
|
||||
self.save()
|
||||
|
||||
def set_pretty(self, pretty, identity):
|
||||
self.log.append({'event': 'pretty set', 'pretty': pretty, 'identity': identity})
|
||||
self.save()
|
||||
|
||||
def answer(self, ok):
|
||||
self.log.append({'event': 'answered', 'ok': ok})
|
||||
self.save()
|
||||
|
||||
def give_up(self):
|
||||
self.log.append({'event': 'give up'})
|
||||
self.save()
|
||||
|
||||
def set_name(self, who, name):
|
||||
self.log.append({'event': 'set name', 'identity': who, 'name': name})
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return self.compacted.roles
|
||||
|
||||
@property
|
||||
def identity(self):
|
||||
return self.compacted.identity
|
||||
|
||||
@property
|
||||
def compacted(self):
|
||||
if self._compaction is None:
|
||||
self._compaction = Compaction()
|
||||
|
||||
if self._compaction.ct == len(self.log):
|
||||
return self._compaction
|
||||
|
||||
missing = self.log[self._compaction.ct:]
|
||||
for m in missing:
|
||||
self._compaction.feed(m)
|
||||
|
||||
return self._compaction
|
||||
|
||||
def save(self):
|
||||
with open('jeopardy.dat.new', 'wb') as f:
|
||||
for l in self.log:
|
||||
if l['event'] == 'new round':
|
||||
l = dict(l)
|
||||
l['round'] = l['round'].serialize()
|
||||
f.write(binascii.hexlify(json.dumps(l).encode()))
|
||||
f.write(b'\n')
|
||||
os.rename('jeopardy.dat.new', 'jeopardy.dat')
|
||||
|
||||
def load(self):
|
||||
if not os.path.exists('jeopardy.dat'):
|
||||
log.info("New game...")
|
||||
return
|
||||
log.info("Loading game...")
|
||||
with open('jeopardy.dat', 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
entry = json.loads(binascii.unhexlify(line))
|
||||
print('RESTORING', entry)
|
||||
if entry['event'] == 'new round':
|
||||
entry['round'] = Board.unserialize(entry['round'])
|
||||
self.log.append(entry)
|
||||
|
||||
self._compaction = None
|
||||
|
||||
def client_state(self, identity, admin):
|
||||
role = self.roles.get(identity, 'SPECTATOR')
|
||||
return {
|
||||
'role': role,
|
||||
'admin': admin,
|
||||
'state': self.state,
|
||||
'compacted': self.compacted_filter(self.compacted.serialize(), role, admin),
|
||||
'ct': self.compacted.ct,
|
||||
}
|
||||
|
||||
def compacted_filter(self, d, role, admin):
|
||||
if admin:
|
||||
return d
|
||||
if role in ('SPECTATOR', 'CONTESTANT'):
|
||||
del d['roles']
|
||||
del d['identity']
|
||||
if d['question']:
|
||||
del d['question']['answer']
|
||||
# filter out answers
|
||||
if d['board'] is not None:
|
||||
for i, c in enumerate(d['board']['categories']):
|
||||
for j, q in enumerate(c['questions']):
|
||||
del d['board']['categories'][i]['questions'][j]['answer']
|
||||
|
||||
return d
|
||||
|
||||
def buzz(self, identity):
|
||||
if self.compacted.question is None:
|
||||
return
|
||||
for _, c in self.compacted.contestants.items():
|
||||
if c.buzzing:
|
||||
return
|
||||
self.log.append({'event': 'buzz', 'identity': identity})
|
||||
|
||||
|
||||
app.game = Game()
|
||||
app.game.load()
|
||||
#app.game.start_round('questions/final.js')
|
||||
#app.game.save()
|
||||
|
||||
|
||||
def emit_state():
|
||||
identity = session['identity']
|
||||
admin = session['admin']
|
||||
emit('state', app.game.client_state(identity, admin))
|
||||
|
||||
@app.route('/')
|
||||
def view_index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@socketio.on('connect')
|
||||
def sio_connect():
|
||||
log.info("Client connected")
|
||||
session['identity'] = None
|
||||
session['admin'] = False
|
||||
session['nonce'] = secrets.token_hex(64)
|
||||
emit_state()
|
||||
emit('nonce', session['nonce'])
|
||||
|
||||
@socketio.on('ping')
|
||||
def sio_ping():
|
||||
emit_state()
|
||||
|
||||
@socketio.on('proof')
|
||||
def sio_proof(data):
|
||||
log.info("Client attempting identity proof")
|
||||
if data.get('nonce', '') != session['nonce']:
|
||||
emit('identified', {'identified': False})
|
||||
return
|
||||
|
||||
# ,___,
|
||||
#jwk = data.get('jwk', {})
|
||||
#k = ECKey(jwk, 'ES384')
|
||||
#proof = binascii.unhexlify(data.get('proof'))
|
||||
#if not k.verify(session['nonce'], proof):
|
||||
# emit('identified', {'identified': False})
|
||||
# return
|
||||
|
||||
identity = data.get('identity')
|
||||
session['identity'] = identity
|
||||
app.game.set_pretty(identity[:8], identity)
|
||||
emit('identified', {'identified': True})
|
||||
|
||||
emit_state()
|
||||
|
||||
@socketio.on('admin')
|
||||
def sio_admin(data):
|
||||
log.info("Admin connecting...")
|
||||
if data.get('password') != app.config['ADMIN_PASSWORD']:
|
||||
emit('identified', {'identifier': True})
|
||||
return
|
||||
|
||||
emit('identified', {'identified': True})
|
||||
session['admin'] = True
|
||||
|
||||
if data.get('set_role') != None:
|
||||
who = data['set_role'].get('who')
|
||||
what = data['set_role'].get('what')
|
||||
app.game.set_role(who, what)
|
||||
|
||||
emit_state()
|
||||
|
||||
@socketio.on('host-question')
|
||||
def sio_host_question(data):
|
||||
if app.game.roles[session['identity']] != 'HOST':
|
||||
return
|
||||
|
||||
cid = data.get('category')
|
||||
qid = data.get('question')
|
||||
if cid not in (0, 1, 2, 3, 4):
|
||||
return
|
||||
if qid not in (0, 1, 2, 3, 4):
|
||||
return
|
||||
app.game.activate_question(qid, cid)
|
||||
|
||||
@socketio.on('host-set-role')
|
||||
def sio_host_contestant(data):
|
||||
if app.game.roles[session['identity']] != 'HOST':
|
||||
return
|
||||
if not data.get('who'):
|
||||
log.info("Who?")
|
||||
return
|
||||
if data.get('what') not in ('SPECTATOR', 'CONTESTANT'):
|
||||
log.info("What?")
|
||||
return
|
||||
app.game.set_role(data['who'], data['what'])
|
||||
|
||||
@socketio.on('buzz')
|
||||
def sio_buz():
|
||||
role = app.game.roles.get(session['identity'])
|
||||
if role != 'CONTESTANT':
|
||||
return
|
||||
app.game.buzz(session['identity'])
|
||||
|
||||
@socketio.on('answer')
|
||||
def sio_answer(data):
|
||||
if app.game.roles[session['identity']] != 'HOST':
|
||||
return
|
||||
|
||||
ok = data.get('ok', False)
|
||||
app.game.answer(ok)
|
||||
|
||||
@socketio.on('give up')
|
||||
def sio_give_up():
|
||||
if app.game.roles[session['identity']] != 'HOST':
|
||||
return
|
||||
app.game.give_up()
|
||||
|
||||
@socketio.on('set name')
|
||||
def sio_set_name(msg):
|
||||
if app.game.roles[session['identity']] != 'HOST':
|
||||
return
|
||||
app.game.set_name(msg['identity'], msg['name'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
socketio.run(app, debug=True, host='0.0.0.0')
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
189
static/main.css
Normal file
189
static/main.css
Normal file
|
@ -0,0 +1,189 @@
|
|||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-size: 100%;
|
||||
font-family: sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div#error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 40rem;
|
||||
margin-left: -20rem;
|
||||
margin-top: -1.5rem;
|
||||
background-color: red;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
padding: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
div#status {
|
||||
height: 1.2rem;
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: -1.2rem;
|
||||
width: 100%;
|
||||
z-index: 50;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div#status p {
|
||||
padding: 0;
|
||||
padding-right: 0.8rem;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
div#board {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div#board div.category {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
div#board div.panel, div#board div.header {
|
||||
background-color: #0d0b70;
|
||||
width: 15rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div#board div.header {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
div#board div.panel {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
div#board div.solved {
|
||||
background-color: #000010;
|
||||
color: #202020;
|
||||
}
|
||||
|
||||
div#board div.narrow {
|
||||
width: 10rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
div#host {
|
||||
width: 30rem;
|
||||
background-color: red;
|
||||
padding: 0.5rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
div#contestants {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.contestant {
|
||||
width: 18rem;
|
||||
height: 8rem;
|
||||
background-color: #1d1b90;
|
||||
border-radius: 0.5rem;
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.contestant h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div.contestant h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div.contestant h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div.buzz {
|
||||
border: 4px solid red;
|
||||
}
|
||||
|
||||
div#question {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 80rem;
|
||||
height: 40rem;
|
||||
margin-left: -40rem;
|
||||
margin-top: -20rem;
|
||||
background-color: #1d1b90;
|
||||
border: 4px solid black;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
z-index: 90;
|
||||
border-radius: 1rem;
|
||||
display: none;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
div#question div.header {
|
||||
font-size: 3rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
div#question div.inner {
|
||||
}
|
||||
|
||||
div#question div.inner img {
|
||||
width: auto;
|
||||
height: 30rem;
|
||||
}
|
||||
|
||||
div.buzz {
|
||||
background-color: #901d1b;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
div.choice {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.ack {
|
||||
background-color: green;
|
||||
width: 10rem;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
div.nack {
|
||||
background-color: red;
|
||||
width: 10rem;
|
||||
height: 2em;
|
||||
}
|
442
static/main.js
Normal file
442
static/main.js
Normal file
|
@ -0,0 +1,442 @@
|
|||
"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);
|
||||
});
|
||||
};
|
3
static/socket.io.min.js
vendored
Normal file
3
static/socket.io.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
templates/index.html
Normal file
21
templates/index.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Hacker Jeopardy!</title>
|
||||
<link href="/static/main.css" rel="stylesheet" />
|
||||
<link href="/static/favicon.png" rel="icon" />
|
||||
<script src="/static/socket.io.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nojs">
|
||||
<p>You'll have to enable JavaScript, sorry.</p>
|
||||
</div>
|
||||
<div id="error"></div>
|
||||
<div id="status"><p>Client state: No JS?</p></div>
|
||||
<div id="board"></div>
|
||||
<div id="contestants"></div>
|
||||
<div id="question"></div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue