Compare commits

...

10 Commits

12 changed files with 179 additions and 10 deletions

1
description Normal file
View File

@ -0,0 +1 @@
Track fear and phase in multiplayer games of the board game Spirit Island.

View File

@ -0,0 +1,16 @@
# Based on https://docs.gunicorn.org/en/stable/deploy.html#systemd
[Service]
ExecStart=/usr/bin/gunicorn fear_tracker_site.asgi:application --config=/home/anyoneeb/sites/apps/fear-tracker/gnuicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=fear-tracker
User=fear-tracker
Group=fear-tracker
KillMode=mixed
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,11 @@
[Service]
ExecStart=/usr/bin/uwsgi --ini /home/anyoneeb/sites/apps/fear-tracker/uwsgi.ini
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=fear-tracker
User=fear-tracker
Group=fear-tracker
[Install]
WantedBy=multi-user.target

17
fear_tracker/routing.py Normal file
View File

@ -0,0 +1,17 @@
from channels.http import AsgiHandler
from channels.routing import ProtocolTypeRouter, URLRouter
from django.conf.urls import url
from . import views
# No async routing currently; just default sync http.
application = ProtocolTypeRouter({
"http": URLRouter([
url(r'^(?P<access_code>[a-zA-Z]{6})/', URLRouter([
url(r"status/(?P<hashcode>[a-z0-9]{64})/",
views.StatusLongPollConsumer, name='status'),
])),
url(r"", AsgiHandler),
]),
})

View File

@ -360,13 +360,23 @@
el.addEventListener("change", formElementChanged);
}
setInterval(function() {
function checkStatus() {
if(activeRequests.size != 0) return;
// From https://stackoverflow.com/a/50101022
const abort = new AbortController();
const signal = abort.signal;
// 50 second timeout:
const timeoutId = setTimeout(() => abort.abort(), 50000);
fetch(new Request("{% url 'status' access_code=access_code %}"
+ (statusObj.hash != ""
? statusObj.hash + "/"
: "")))
: "")),
{signal})
.then(response => {
clearTimeout(timeoutId);
if(response.status === 304) {
// TODO Just skip the next step?
return statusObj;
@ -381,8 +391,14 @@
} else {
statusObj = data;
}
})
.then(checkStatus)
.catch(() => {
// If something went wrong, wait a few seconds before retrying.
setTimeout(checkStatus, 5000);
});
}, 5000);
}
checkStatus();
});
</script>
{% endif %}

View File

@ -27,7 +27,5 @@ urlpatterns = [
path('update/', views.update_game, name='update_game'),
path('qr/', views.qr_code, name='qr_code'),
path('status/', views.status, name='status'),
url('^status/(?P<hashcode>[a-z0-9]{64})/',
views.status, name='status'),
])),
])),
]

View File

@ -5,12 +5,18 @@ import hashlib
import json
import qrcode
from asgiref.sync import async_to_sync
from channels.generic.http import AsyncHttpConsumer
from channels.layers import get_channel_layer
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_safe, require_http_methods,\
require_POST
from django.urls import reverse
from django.urls import reverse, set_script_prefix
from .forms import NewGameForm, JoinGameForm, PlayerFormSet
from .models import Game, Player, Fear
@ -288,12 +294,22 @@ def handle_game_request(request, game, update):
res['value'] = current_value
else:
res = {'success': True}
async_to_sync(get_channel_layer().group_send)(
"%s_status" % game.access_code,
{"type": "fear_tracker.invalidate_status"})
return HttpResponse(json.dumps(res))
players = get_players_with_fear(game, current_phase, players)
status_obj = game_status_object(game, current_phase, players)
status_string = json.dumps(status_obj)
async_to_sync(get_channel_layer().group_send)(
"%s_status" % game.access_code, {
"type": "fear_tracker.hashcode_seen",
"hashcode": status_obj['hash'],
"status_string": status_string,
})
for player in players.values():
info = player.fear
if not info:
@ -324,4 +340,63 @@ def status(request, game, hashcode=None):
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
else:
status_string = json.dumps(status_obj)
async_to_sync(get_channel_layer().group_send)(
"%s_status" % game.access_code, {
"type": "fear_tracker.hashcode_seen",
"hashcode": status_obj['hash'],
"status_string": status_string,
})
return HttpResponse(status_string)
class StatusLongPollConsumer(AsyncHttpConsumer):
async def handle(self, body):
self.access_code = self.scope["url_route"]["kwargs"]["access_code"]
self.hashcode = self.scope["url_route"]["kwargs"]["hashcode"]
await self.channel_layer.group_add("%s_status" % self.access_code,
self.channel_name)
await self.channel_layer.group_send(
"%s_status" % self.access_code, {
"type": "fear_tracker.hashcode_seen",
"hashcode": self.hashcode,
})
async def http_request(self, message):
"""
Async entrypoint - concatenates body fragments and hands off control
to ``self.handle`` when the body has been completely received.
"""
if "body" in message:
self.body.append(message["body"])
if not message.get("more_body"):
await self.handle(b"".join(self.body))
async def disconnect(self):
await self.channel_layer.group_discard("%s_status" % self.access_code,
self.channel_name)
async def fear_tracker_hashcode_seen(self, event):
if self.hashcode != event["hashcode"]:
if event["status_string"]:
body = event["status_string"].encode('utf-8')
await self.send_response(200, body)
await self.disconnect()
await self.channel_layer.group_send(
"%s_status" % self.access_code, {
"type": "fear_tracker.invalidate_status",
})
async def fear_tracker_invalidate_status(self, event):
# get script_prefix for reverse()
script_prefix = self.scope.get("root_path", "") or ""
if settings.FORCE_SCRIPT_NAME:
script_prefix = settings.FORCE_SCRIPT_NAME
set_script_prefix(script_prefix)
no_hash_status = reverse('status',
kwargs={'access_code': self.access_code})
await self.send_response(302, b'', headers=[
(b"Location", no_hash_status.encode('utf-8'))
])
await self.http_disconnect(None)

View File

@ -1,4 +1,3 @@
from channels.routing import ProtocolTypeRouter
import fear_tracker.routing
# No async routing currently; just default sync http.
application = ProtocolTypeRouter({})
application = fear_tracker.routing.application

View File

@ -31,6 +31,7 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'channels',
'fear_tracker',
'django.contrib.admin',
'django.contrib.auth',
@ -82,6 +83,12 @@ DATABASES = {
}
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer"
}
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

14
gnuicorn.conf.py Normal file
View File

@ -0,0 +1,14 @@
import multiprocessing
bind = "127.0.0.1:8043"
worker_class = "uvicorn.workers.UvicornWorker"
workers = multiprocessing.cpu_count() * 2 + 1
chdir = "/home/anyoneeb/sites/apps/fear-tracker"
raw_env = "DJANGO_SETTINGS_MODULE=fear_tracker_site.local_settings"
proc_name = "gnuicorn-fear-tracker"
pidfile = "/tmp/gnuicorn-fear-tracker.pid"
user = "fear-tracker"
group = "fear-tracker"
max_requests=5000
max_requests_jitter=100
preload_app=True

1
source Normal file
View File

@ -0,0 +1 @@
https://git.aweirdimagination.net/perelman/fear_tracker

14
uwsgi.ini Normal file
View File

@ -0,0 +1,14 @@
[uwsgi]
socket=127.0.0.1:8043
mount = /fear-tracker=fear_tracker_site.wsgi:application
manage-script-name = true
chdir=/home/anyoneeb/sites/apps/fear-tracker
plugin=python3
module=fear_tracker_site.wsgi:application
env=DJANGO_SETTINGS_MODULE=fear_tracker_site.local_settings
master=True
pidfile=/tmp/fear-tracker-master.pid
vacuum=True
max-requests=5000
uid=fear-tracker
gid=fear-tracker